Compare commits

...

104 Commits

Author SHA1 Message Date
Isaac Abadi
d08fee1223 Added v1 of chat sidebar for Twitch VODs 2020-11-29 03:18:28 -05:00
Isaac Abadi
8938844ffa Added ability to select the max quality for a subscription. It defaults to 'best' which will get the best native mp4 video 2020-11-28 00:45:47 -05:00
Tzahi12345
9895d77e01 Merge pull request #258 from diveflo/fix/dockerreadme
Cleanup & clarify README.md
2020-11-27 15:06:46 -05:00
Florian Gabsteiger
27437a615f Cleanup README and clarify docker port usage 2020-11-27 12:32:51 +01:00
Isaac Abadi
b730bc5adc Added option to set a default file output - custom file output in the advanced expansion panel will override this 2020-11-27 00:25:31 -05:00
Isaac Abadi
d15d262b87 Fixed bug that resulted in the "download videos in the last X days" timerange in edit subscriptions to come up blank 2020-11-26 15:49:14 -05:00
Tzahi12345
1aade1202d Merge pull request #259 from diveflo/feat/cibuildandrelease
Automated build & release via GitHub Actions
2020-11-25 15:38:50 -05:00
Isaac Abadi
2f541a49df Thumbnails now load using a faster method with a dedicated API route rather than sending blobs directly.
- In cases of lots of files, loading should be significantly faster
2020-11-25 15:36:00 -05:00
Florian Gabsteiger
d93481640c automated release creation for tagged commits 2020-11-24 11:45:27 +01:00
Florian Gabsteiger
71814cbdc9 build via github actions 2020-11-24 11:43:04 +01:00
Isaac Abadi
09832ad15b Multi download mode and download-only mode now reloads recent videos 2020-11-24 03:39:30 -05:00
Tzahi12345
cc78091403 Merge pull request #262 from diveflo/fix/dockerci
do not push new docker images for pull requests
2020-11-23 15:09:58 -05:00
Florian Gabsteiger
cb88c7bc7c do not push new docker images for pull requests 2020-11-23 14:39:16 +01:00
Tzahi12345
98f4828db4 Merge pull request #257 from diveflo/feat/multiarchdockerci
Multi-arch docker image build via GitHub Actions
2020-11-22 17:15:08 -05:00
Tzahi12345
8f0739c0f9 Removes extra line
Co-authored-by: Sandro <sandro.jaeckel@gmail.com>
2020-11-22 00:40:53 -05:00
Tzahi12345
ab355d62a0 GitHub autobuild now uses nightly tag
Co-authored-by: Sandro <sandro.jaeckel@gmail.com>
2020-11-21 23:13:58 -05:00
Florian Gabsteiger
4d2d9a6b10 change docker image tag name to align with upstream 2020-11-21 20:58:58 +01:00
Florian Gabsteiger
89dfac1249 update job name to better reflect what it's actually doing 2020-11-21 20:55:08 +01:00
Florian Gabsteiger
d4f81eb0ab add platform emulator 2020-11-21 20:16:49 +01:00
Florian Gabsteiger
6b7d0681d2 add automated multi-arch docker image build and push to dockerhub 2020-11-21 20:15:12 +01:00
Isaac Abadi
b32fdb2445 Tab title now matches the top title set in the settings 2020-11-20 17:39:44 -05:00
Tzahi12345
b059c7ed5e Update README.md 2020-11-18 01:55:49 -05:00
Isaac Abadi
8d87cbb08d Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-11-14 16:55:12 -05:00
Isaac Abadi
1bb2f54eba Fixed bug where updating a subscription would not work correctly 2020-11-14 16:54:20 -05:00
Tzahi12345
7392338d6e Merge pull request #247 from hwalker928/master
Update README.md
2020-11-14 04:04:03 -05:00
hwalker928
82df92a72d Update README.md 2020-11-14 09:00:45 +00:00
Isaac Abadi
9e4b328f91 Default youtube downloader switched back to youtube-dl after testing
Fixed bug that caused some non-youtube downloads from failing
2020-11-01 20:21:36 -05:00
Isaac Abadi
3a049a99ac Fixed bug where non-youtube downloads would fail 2020-11-01 19:38:22 -05:00
Isaac Abadi
b323b548ca Added ability to use youtube-dl forks
Downloader now defaults to youtube-dlc because of the recent DMCA requests
2020-11-01 19:16:41 -05:00
Tzahi12345
568463487f Merge pull request #236 from Tzahi12345/categories
Adds rule-based categories
2020-10-24 01:13:26 -04:00
Isaac Abadi
3318ac364d Code cleanup and changed proposed handling of existing tags for suggestions 2020-10-24 00:29:42 -04:00
Isaac Abadi
1ce85813fb Saving a category will now cause the UI to refresh the cache of categories 2020-10-24 00:20:39 -04:00
Isaac Abadi
6ea4176d63 Added missing code that makes category paths relative to the root dir 2020-10-24 00:15:47 -04:00
Isaac Abadi
3aa08e1817 Added scaffolding for tags 2020-10-23 02:44:24 -04:00
Isaac Abadi
727b047c39 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into categories 2020-10-23 02:00:57 -04:00
Isaac Abadi
d659a7614f updated default.json 2020-10-23 01:49:27 -04:00
Tzahi12345
6ad9d5ea8e Merge pull request #235 from Tzahi12345/locale-based-dates
File cards now use the locale to format dates
2020-10-23 01:40:48 -04:00
Isaac Abadi
edc22cc47b File cards now use the locale to format dates 2020-10-23 01:38:44 -04:00
Isaac Abadi
0189d292a8 Fixed bug that prevented categorized files from being deletes and simplified the two delete file API calls into one 2020-10-18 02:20:06 -04:00
Isaac Abadi
deac54e8d6 Fixed bug in goToPlaylist 2020-10-15 17:00:56 -04:00
Isaac Abadi
d4e5082039 Confirm dialog can now optionally use warn colors (used for deletion or breaking changes)
Category re-ordering is fixed

Category deletion in settings is now functional
2020-10-15 17:00:48 -04:00
Isaac Abadi
6f089491a5 Updated player component to support categories 2020-10-15 16:59:33 -04:00
Isaac Abadi
0a38b01971 Updated posts service to allow for category deletion and subscription retrieval based on name 2020-10-15 16:59:14 -04:00
Isaac Abadi
fe7303a191 Replaced /audio and /video APIs with /stream that now requires a type parameter to simplify future code changes
getSubscription can now accept a subscription name instead of just an ID

Added API call to delete a category

Categories can now have a custom path

Minor code cleanup
2020-10-15 16:57:45 -04:00
Isaac Abadi
dff4b141b0 Blobs are now only included in getAllFiles() if the config option for including thumbnail is set to true 2020-10-12 22:47:11 -04:00
Isaac Abadi
fed0a54145 Updated styling on edit category dialog 2020-10-12 22:46:23 -04:00
Isaac Abadi
8366089444 Added support for French, Chinese (Mandarin, simplified), and Norweigan. Updated German and Spanish translations
- Updated README to reflect new official translator
- XLIFFs to come later
2020-10-02 03:05:54 -04:00
Tzahi12345
44445f0b67 Added code analysis GH action 2020-10-01 14:46:04 -04:00
Isaac Abadi
7dcc38c26d Updated string in settings: "Select a logger level" -> "Log Level"
Made modify playlist component fully translatable

Fixed typo in cookies settings text
2020-09-30 04:52:43 -04:00
Isaac Abadi
79b4b993f8 Fixed bug in translation source file (2) 2020-09-30 04:41:15 -04:00
Isaac Abadi
37a19eabe6 Fixed bug in source translation file 2020-09-30 04:34:52 -04:00
Isaac Abadi
91b892b21a Updated source translation file 2020-09-30 04:19:04 -04:00
Tzahi12345
fb72dee26f Merge pull request #216 from NotWoods/await
Use async versions of filesystem methods
2020-09-29 17:41:19 -04:00
Tiger Oakes
3e4e7edd90 Oops.
for in -> for of
2020-09-29 14:32:28 -07:00
Isaac Abadi
b8280e8646 Updated spanish translation file (2) 2020-09-29 16:58:17 -04:00
Isaac Abadi
70ee071e57 Cleaned up spanish translation file 2020-09-29 14:41:38 -04:00
Tiger Oakes
e26ac82c66 Fix missing keywords 2020-09-29 08:53:36 -07:00
Isaac Abadi
cdd2f78998 Fixed bug that prevented video playlists from being deleted 2020-09-27 05:05:45 -04:00
Tiger Oakes
21eafeab22 Make utils.recFindByExt and utils.getDownloadedFilesByType async 2020-09-26 15:24:41 -07:00
Tiger Oakes
f535d18cb9 Use async methods in auth and subscriptions 2020-09-26 15:14:37 -07:00
Tiger Oakes
2c43ce3c47 Use async versions of filesystem methods 2020-09-26 14:57:23 -07:00
Isaac Abadi
3d2d4efb31 Added context menu on right click of the unified file cards, with options to open a file in the player or do so in a new tab 2020-09-26 03:00:26 -04:00
Isaac Abadi
10922fedd7 Fixed bugs that prevented subscription videos from being downloaded and non-users from accessing shared videos 2020-09-26 00:29:13 -04:00
Isaac Abadi
96cf1b87d1 Fixed bug in subscriptions that caused audio files to be downloaded as webm 2020-09-26 00:08:22 -04:00
Tzahi12345
6bed5851ed Merge pull request #220 from Tzahi12345/fix-playlist-downloading-bug
Fixed bug that preventing playlists from being downloaded a zip
2020-09-26 00:04:43 -04:00
Isaac Abadi
6717a59422 Fixed bug that preventing playlists from being downloaded a zip 2020-09-24 02:26:58 -04:00
Isaac Abadi
899633e124 Fixed bug that showed users their subscription videos after subscriptions were disabled 2020-09-20 23:13:56 -04:00
Isaac Abadi
8fdc231f08 Updated new home page UI to support file manager disabling and permissions
- file manager enabled state is now cached for faster loading
2020-09-18 11:22:45 -04:00
Isaac Abadi
ae8f7a2a33 Fixed bug that prevented playlists from being navigated to 2020-09-18 11:05:13 -04:00
Tzahi12345
d0782bb444 Update README.md
Updated API docs

Fixes #213
2020-09-18 00:46:42 -04:00
Isaac Abadi
49210abb49 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2020-09-17 03:15:29 -04:00
Isaac Abadi
8595864118 Added basic categorization functionality in the server & UI 2020-09-17 03:14:24 -04:00
Isaac Abadi
851bfb81ba File cards are now properly centered 2020-09-17 03:12:09 -04:00
Isaac Abadi
35d0d439fa Control-clicking file cards will now open the player in a new tab 2020-09-17 03:11:52 -04:00
Tzahi12345
ded3ad6dfc Merge pull request #212 from Tzahi12345/dependabot/npm_and_yarn/backend/node-fetch-2.6.1
Bump node-fetch from 2.6.0 to 2.6.1 in /backend
2020-09-12 17:07:53 -04:00
dependabot[bot]
61daf26641 Bump node-fetch from 2.6.0 to 2.6.1 in /backend
Bumps [node-fetch](https://github.com/bitinn/node-fetch) from 2.6.0 to 2.6.1.
- [Release notes](https://github.com/bitinn/node-fetch/releases)
- [Changelog](https://github.com/node-fetch/node-fetch/blob/master/docs/CHANGELOG.md)
- [Commits](https://github.com/bitinn/node-fetch/compare/v2.6.0...v2.6.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-12 20:55:44 +00:00
Tzahi12345
95e53b9549 Fixed bug where unix paths would improperly parsed while importing unregistered files 2020-09-07 16:06:25 -04:00
Tzahi12345
46ed0fe992 Fixed bug in import unregistered logic where files in subfolders could not be found 2020-09-07 00:39:27 -04:00
Isaac Abadi
082252ab1e Updated sidenav logic for "side" mode, where it will now autoclose in the player, be open everywhere else 2020-08-31 15:21:58 -04:00
Tzahi12345
5eccaa13e5 Merge pull request #206 from Tzahi12345/downloader-improvements
Downloader improvements - updated system and bug fixes
2020-08-31 15:17:40 -04:00
Isaac Abadi
71633950b2 Comments cleanup 2020-08-31 15:03:04 -04:00
Isaac Abadi
f31dad0215 JSON metadata files are no longer kept if the associated setting is not enabled 2020-08-30 05:56:25 -04:00
Isaac Abadi
7efbe40bb2 Added setting for including metadata/thumbnails in the UI 2020-08-30 05:55:50 -04:00
Isaac Abadi
5b768b5bda JSON blobs were accidentally inserted into DB, stringifying then parsing the video file object fixes this 2020-08-30 05:42:52 -04:00
Isaac Abadi
365cbc3ffa Mkv/webm formats are now included for quality select (will get merged into mp4 at the end) 2020-08-29 23:08:23 -04:00
Isaac Abadi
44647f3306 Download progress is now shown when downloads are 1% complete or more (it was 15% before) 2020-08-29 23:06:40 -04:00
Isaac Abadi
8a7409478a Added the ability to download videos at higher resolutions than the highest mp4 (fixes #76)
Deprecates normal downloading method. The "safe" method is now always used, and download progress is now estimated using the predicted end file size

Thumbnails are now auto downloaded along with the other metadata
2020-08-29 23:05:37 -04:00
Tzahi12345
70159813e5 Merge pull request #205 from Tzahi12345/add-ldap-auth
Added ability to register/login through LDAP
2020-08-26 04:30:43 -04:00
Isaac Abadi
babba9aa30 Added ability to register/login through LDAP
- Added ability to edit LDAP settings and whether to use LDAP or not in the users tab in the settings
2020-08-26 04:18:29 -04:00
Tzahi12345
d292275956 Unfinished subscriptions will no longer cause an error during server startup 2020-08-24 05:13:27 -04:00
Tzahi12345
ba2acedb94 Files are now reloaded when you navigate back home 2020-08-24 05:13:01 -04:00
Tzahi12345
aa0558b770 Subscriptions are now reloaded on subscribe/unsubscribe in PostsService 2020-08-24 05:11:56 -04:00
Tzahi12345
d7f04fc90a Text for file duration in the unified file card component is now always black 2020-08-24 05:11:04 -04:00
Tzahi12345
087c9f1bb1 Added public directory to the gitignore 2020-08-24 02:44:52 -04:00
Tzahi12345
f874617965 Fixes bug where cached JWT token could prevent default admin creation 2020-08-24 02:44:39 -04:00
Tzahi12345
8fb8543829 Merge pull request #203 from Tzahi12345/arm-autobuild-test
Fix ARM autobuild
2020-08-24 02:20:19 -04:00
Tzahi12345
70d89d310c Removed unneeded hooks 2020-08-24 02:18:39 -04:00
Tzahi12345
c48aaaf13c Possible fix for arm autobuild (2) 2020-08-24 00:25:59 -04:00
Tzahi12345
6cf7ea193a Possible fix for arm autobuild 2020-08-24 00:21:10 -04:00
Isaac Abadi
c9016f446d Ghosting colors for loading videos is now customizable in an internal config, allowing dark mode to have different colors for the ghosting 2020-08-22 18:55:24 -04:00
Isaac Abadi
919e2a649a Updated styling of recent videos
Added content loader module which will add ghosting to the recent videos component while the files are loading

Updated custom playlists component to support large sized cards
2020-08-22 18:34:28 -04:00
Isaac Abadi
dc6dd5f5a2 Added support for "large" sized cards 2020-08-22 00:47:18 -04:00
Tzahi12345
2e4ef3b224 Moves hooks back into their proper directory and updated arm dockerfile to avoid installing curl just go get qemu 2020-08-18 16:43:52 -04:00
Tzahi12345
0e35b2ca1b Proxy now forces safe download mode 2020-08-18 15:34:03 -04:00
73 changed files with 4427 additions and 2134 deletions

92
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: continuous integration
on:
push:
branches: [master, feat/*]
tags:
- v*
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v1
- name: install dependencies
run: |
npm install
cd backend
npm install
sudo npm install -g @angular/cli
- name: build
run: ng build --prod
- name: prepare artifact upload
shell: pwsh
run: |
New-Item -Name build -ItemType Directory
New-Item -Path build -Name youtubedl-material -ItemType Directory
Copy-Item -Path ./backend/appdata -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/audio -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/authentication -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/public -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/subscriptions -Recurse -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/video -Recurse -Destination ./build/youtubedl-material
New-Item -Path ./build/youtubedl-material -Name users
Copy-Item -Path ./backend/*.js -Destination ./build/youtubedl-material
Copy-Item -Path ./backend/*.json -Destination ./build/youtubedl-material
- name: upload build artifact
uses: actions/upload-artifact@v1
with:
name: youtubedl-material
path: build
release:
runs-on: ubuntu-latest
needs: build
if: contains(github.ref, '/tags/v')
steps:
- name: checkout code
uses: actions/checkout@v2
- name: create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: YoutubeDL-Material ${{ github.ref }}
body: |
# New features
# Minor additions
# Bug fixes
draft: true
prerelease: false
- name: download build artifact
uses: actions/download-artifact@v1
with:
name: youtubedl-material
path: ${{runner.temp}}/youtubedl-material
- name: prepare release asset
shell: pwsh
run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ github.ref }}.zip
- name: upload build asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./youtubedl-material-${{ github.ref }}.zip
asset_name: youtubedl-material-${{ github.ref }}.zip
asset_content_type: application/zip
- name: upload docker-compose asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./docker-compose.yml
asset_name: docker-compose.yml
asset_content_type: text/plain

71
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: '0 12 * * 6'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Override automatic language detection by changing the below list
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
language: ['javascript']
# Learn more...
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
steps:
- name: Checkout repository
uses: actions/checkout@v2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
fetch-depth: 2
# If this run was triggered by a pull request event, then checkout
# the head of the pull request instead of the merge commit.
- run: git checkout HEAD^2
if: ${{ github.event_name == 'pull_request' }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# 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
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

29
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: docker
on:
push:
branches: [master]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
- name: setup platform emulator
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build & push images
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true
tags: tzahi12345/youtubedl-material:nightly

1
.gitignore vendored
View File

@@ -65,3 +65,4 @@ backend/appdata/logs/error.log
backend/appdata/users.json
backend/users/*
backend/appdata/cookies.txt
backend/public

View File

@@ -1,10 +1,10 @@
# YoutubeDL-Material
[![](https://img.shields.io/docker/pulls/tzahi12345/youtubedl-material.svg)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![](https://img.shields.io/docker/image-size/tzahi12345/youtubedl-material?sort=date)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![](https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg)](https://heroku.com/deploy?template=https://github.com/Tzahi12345/YoutubeDL-Material)
[![](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
[![Docker pulls badge](https://img.shields.io/docker/pulls/tzahi12345/youtubedl-material.svg)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![Docker image size badge](https://img.shields.io/docker/image-size/tzahi12345/youtubedl-material?sort=date)](https://hub.docker.com/r/tzahi12345/youtubedl-material)
[![Heroku deploy badge](https://img.shields.io/badge/%E2%86%91_Deploy_to-Heroku-7056bf.svg)](https://heroku.com/deploy?template=https://github.com/Tzahi12345/YoutubeDL-Material)
[![GitHub issues badge](https://img.shields.io/github/issues/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/issues)
[![License badge](https://img.shields.io/github/license/Tzahi12345/YoutubeDL-Material)](https://github.com/Tzahi12345/YoutubeDL-Material/blob/master/LICENSE.md)
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 9](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
@@ -30,13 +30,25 @@ Dark mode:
NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide.
Make sure you have these dependencies installed on your system: nodejs and youtube-dl. If you don't, run this command:
Debian/Ubuntu:
```bash
sudo apt-get install nodejs youtube-dl ffmpeg
```
sudo apt-get install nodejs youtube-dl
CentOS 7:
```bash
sudo yum install epel-release
sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfusion-free-release-7.noarch.rpm
sudo yum install centos-release-scl-rh
sudo yum install rh-nodejs12
scl enable rh-nodejs12 bash
sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
```
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
### Installing
@@ -75,14 +87,16 @@ If you are looking to setup YoutubeDL-Material with Docker, this section is for
1. Run `curl -L https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest/download/docker-compose.yml -o docker-compose.yml` to download the latest Docker Compose, or go to the [releases](https://github.com/Tzahi12345/YoutubeDL-Material/releases/) page to grab the version you'd like.
2. Run `docker-compose pull`. This will download the official YoutubeDL-Material docker image.
3. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 8998" or something similar.
4. Make sure you can connect to the specified URL + port, and if so, you are done!
3. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 17443" or something similar. This tells you the *container-internal* port of the application. Please check your `docker-compose.yml` file for the *external* port. If you downloaded the file as described above, it defaults to **8998**.
4. Make sure you can connect to the specified URL + *external* port, and if so, you are done!
NOTE: It is currently recommended that you use the `nightly` tag on Docker. To do so, simply update the docker-compose.yml `image` field so that it points to `tzahi12345/youtubedl-material:nightly`.
### Custom UID/GID
By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so:
```
```yml
environment:
UID: YOUR_UID
GID: YOUR_GID
@@ -90,7 +104,7 @@ environment:
## API
[API Docs](https://stoplight.io/p/docs/gh/tzahi12345/youtubedl-material?group=master&utm_campaign=publish_dialog&utm_source=studio)
[API Docs](https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml)
To get started, go to the settings menu and enable the public API from the *Extra* tab. You can generate an API key if one is missing.
@@ -109,8 +123,10 @@ If you're interested in translating the app into a new language, check out the [
* **Isaac Grynsztein** (me!) - *Initial work*
Official translators:
* Spanish - tzahi12345
* German - UnlimitedCookies
* Chinese - TyRoyal
See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project.

View File

@@ -1,4 +1,4 @@
FROM arm32v7/alpine:3.12 as frontend
FROM alpine:3.12 as frontend
RUN apk add --no-cache \
npm \
@@ -6,9 +6,10 @@ RUN apk add --no-cache \
RUN npm install -g @angular/cli
WORKDIR /build
RUN curl -L https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-arm.tar.gz | tar zxvf - -C . && mv qemu-3.0.0+resin-arm/qemu-arm-static .
WORKDIR /build
COPY [ "package.json", "package-lock.json", "/build/" ]
RUN npm install
@@ -20,7 +21,7 @@ RUN ng build --prod
FROM arm32v7/alpine:3.12
COPY --from=frontend /qemu-arm-static /usr/bin
COPY --from=frontend /build/qemu-arm-static /usr/bin
ENV UID=1000 \
GID=1000 \

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,12 @@
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true
},
"Extra": {
"title_top": "YoutubeDL-Material",
@@ -23,7 +26,10 @@
"use_API_key": false,
"API_key": "",
"use_youtube_API": false,
"youtube_API_key": ""
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
},
"Themes": {
"default_theme": "default",
@@ -36,9 +42,18 @@
},
"Users": {
"base_path": "users/",
"allow_registration": true
"allow_registration": true,
"auth_method": "internal",
"ldap_config": {
"url": "ldap://localhost:389",
"bindDN": "cn=root",
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
}
},
"Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -9,6 +9,7 @@ var bcrypt = require('bcryptjs');
var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth');
var JwtStrategy = require('passport-jwt').Strategy,
ExtractJwt = require('passport-jwt').ExtractJwt;
@@ -90,24 +91,7 @@ exports.registerUser = function(req, res) {
bcrypt.hash(plaintextPassword, saltRounds)
.then(function(hash) {
let new_user = {
name: username,
uid: userid,
passhash: hash,
files: {
audio: [],
video: []
},
playlists: {
audio: [],
video: []
},
subscriptions: [],
created: Date.now(),
role: userid === 'admin' ? 'admin' : 'user',
permissions: [],
permission_overrides: []
};
let new_user = generateUserObject(userid, username, hash);
// check if user exists
if (users_db.get('users').find({uid: userid}).value()) {
// user id is taken!
@@ -153,52 +137,43 @@ exports.registerUser = function(req, res) {
exports.passport.use(new LocalStrategy({
usernameField: 'userid',
usernameField: 'username',
passwordField: 'password'},
function(username, password, done) {
async function(username, password, done) {
const user = users_db.get('users').find({name: username}).value();
if (!user) { logger.error(`User ${username} not found`); return done(null, false); }
if (user.auth_method && user.auth_method !== 'internal') { return done(null, false); }
if (user) {
return done(null, bcrypt.compareSync(password, user.passhash) ? user : false);
return done(null, (await bcrypt.compare(password, user.passhash)) ? user : false);
}
}
));
/*passport.use(new BasicStrategy(
function(userid, plainTextPassword, done) {
const user = users_db.get('users').find({name: userid}).value();
if (user) {
var hashedPwd = user.passhash;
return bcrypt.compare(plainTextPassword, hashedPwd);
} else {
return false;
var getLDAPConfiguration = function(req, callback) {
const ldap_config = config_api.getConfigItem('ytdl_ldap_config');
const opts = {server: ldap_config};
callback(null, opts);
};
exports.passport.use(new LdapStrategy(getLDAPConfiguration,
function(user, done) {
// check if ldap auth is enabled
const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap';
if (!ldap_enabled) return done(null, false);
const user_uid = user.uid;
let db_user = users_db.get('users').find({uid: user_uid}).value();
if (!db_user) {
// generate DB user
let new_user = generateUserObject(user_uid, user_uid, null, 'ldap');
users_db.get('users').push(new_user).write();
db_user = new_user;
logger.verbose(`Generated new user ${user_uid} using LDAP`);
}
return done(null, db_user);
}
));
*/
/*************************************************************
* This is a wrapper for auth.passport.authenticate().
* We use this to change WWW-Authenticate header so
* the browser doesn't pop-up challenge dialog box by default.
* Browser's will pop-up up dialog when status is 401 and
* "WWW-Authenticate:Basic..."
*************************************************************/
/*
exports.authenticateViaPassport = function(req, res, next) {
exports.passport.authenticate('basic',{session:false},
function(err, user, info) {
if(!user){
res.set('WWW-Authenticate', 'x'+info); // change to xBasic
res.status(401).send('Invalid Authentication');
} else {
req.user = user;
next();
}
}
)(req, res, next);
};
*/
/**********************************
* Generating/Signing a JWT token
@@ -251,15 +226,13 @@ exports.ensureAuthenticatedElseError = function(req, res, next) {
// change password
exports.changeUserPassword = async function(user_uid, new_pass) {
return new Promise(resolve => {
bcrypt.hash(new_pass, saltRounds)
.then(function(hash) {
users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write();
resolve(true);
}).catch(err => {
resolve(false);
});
});
try {
const hash = await bcrypt.hash(new_pass, saltRounds);
users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write();
return true;
} catch (err) {
return false;
}
}
// change user permissions
@@ -308,6 +281,7 @@ exports.getUserVideos = function(user_uid, type) {
}
exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false) {
let file = null;
if (!type) {
file = users_db.get('users').find({uid: user_uid}).get(`files.audio`).find({uid: file_uid}).value();
if (!file) {
@@ -321,7 +295,7 @@ exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false
if (!file && type) file = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value();
// prevent unauthorized users from accessing the file info
if (requireSharing && !file['sharingEnabled']) file = null;
if (file && !file['sharingEnabled'] && requireSharing) file = null;
return file;
}
@@ -376,7 +350,7 @@ exports.registerUserFile = function(user_uid, file_object, type) {
.write();
}
exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) {
exports.deleteUserFile = async function(user_uid, file_uid, type, blacklistMode = false) {
let success = false;
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value();
if (file_obj) {
@@ -399,20 +373,20 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals
.remove({
uid: file_uid
}).write();
if (fs.existsSync(full_path)) {
if (await fs.pathExists(full_path)) {
// remove json and file
const json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + '.info.json');
const alternate_json_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext + '.info.json');
let youtube_id = null;
if (fs.existsSync(json_path)) {
youtube_id = fs.readJSONSync(json_path).id;
fs.unlinkSync(json_path);
} else if (fs.existsSync(alternate_json_path)) {
youtube_id = fs.readJSONSync(alternate_json_path).id;
fs.unlinkSync(alternate_json_path);
if (await fs.pathExists(json_path)) {
youtube_id = await fs.readJSON(json_path).id;
await fs.unlink(json_path);
} else if (await fs.pathExists(alternate_json_path)) {
youtube_id = await fs.readJSON(alternate_json_path).id;
await fs.unlink(alternate_json_path);
}
fs.unlinkSync(full_path);
await fs.unlink(full_path);
// do archive stuff
@@ -421,17 +395,17 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals
const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`);
// use subscriptions API to remove video from the archive file, and write it to the blacklist
if (fs.existsSync(archive_path)) {
const line = youtube_id ? subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null;
if (await fs.pathExists(archive_path)) {
const line = youtube_id ? await subscriptions_api.removeIDFromArchive(archive_path, youtube_id) : null;
if (blacklistMode && line) {
let blacklistPath = path.join(usersFileFolder, user_uid, 'archives', `blacklist_${type}.txt`);
// adds newline to the beginning of the line
line = '\n' + line;
fs.appendFileSync(blacklistPath, line);
await fs.appendFile(blacklistPath, line);
}
} else {
logger.info(`Could not find archive file for ${type} files. Creating...`);
fs.ensureFileSync(archive_path);
await fs.ensureFile(archive_path);
}
}
}
@@ -538,3 +512,26 @@ function getToken(queryParams) {
return null;
}
};
function generateUserObject(userid, username, hash, auth_method = 'internal') {
let new_user = {
name: username,
uid: userid,
passhash: auth_method === 'internal' ? hash : null,
files: {
audio: [],
video: []
},
playlists: {
audio: [],
video: []
},
subscriptions: [],
created: Date.now(),
role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user',
permissions: [],
permission_overrides: [],
auth_method: auth_method
};
return new_user;
}

123
backend/categories.js Normal file
View File

@@ -0,0 +1,123 @@
const config_api = require('./config');
var logger = null;
var db = null;
var users_db = null;
var db_api = null;
function setDB(input_db, input_users_db, input_db_api) { db = input_db; users_db = input_users_db; db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) {
setDB(input_db, input_users_db, input_db_api);
setLogger(input_logger);
}
/*
Categories:
Categories are a way to organize videos based on dynamic rules set by the user. Categories are universal (so not per-user).
Categories, besides rules, have an optional custom output. This custom output can help users create their
desired directory structure.
Rules:
A category rule consists of a property, a comparison, and a value. For example, "uploader includes 'VEVO'"
Rules are stored as an object with the above fields. In addition to those fields, it also has a preceding_operator, which
is either OR or AND, and signifies whether the rule should be ANDed with the previous rules, or just ORed. For the first
rule, this field is null.
Ex. (title includes 'Rihanna' OR title includes 'Beyonce' AND uploader includes 'VEVO')
*/
async function categorize(file_json) {
let selected_category = null;
const categories = getCategories();
if (!categories) {
logger.warn('Categories could not be found. Initializing categories...');
db.assign({categories: []}).write();
return null;
return;
}
for (let i = 0; i < categories.length; i++) {
const category = categories[i];
const rules = category['rules'];
// if rules for current category apply, then that is the selected category
if (applyCategoryRules(file_json, rules, category['name'])) {
selected_category = category;
logger.verbose(`Selected category ${category['name']} for ${file_json['webpage_url']}`);
return selected_category;
}
}
return selected_category;
}
function getCategories() {
const categories = db.get('categories').value();
return categories ? categories : null;
}
function applyCategoryRules(file_json, rules, category_name) {
let rules_apply = false;
for (let i = 0; i < rules.length; i++) {
const rule = rules[i];
let rule_applies = null;
let preceding_operator = rule['preceding_operator'];
switch (rule['comparator']) {
case 'includes':
rule_applies = file_json[rule['property']].includes(rule['value']);
break;
case 'not_includes':
rule_applies = !(file_json[rule['property']].includes(rule['value']));
break;
case 'equals':
rule_applies = file_json[rule['property']] === rule['value'];
break;
case 'not_equals':
rule_applies = file_json[rule['property']] !== rule['value'];
break;
default:
logger.warn(`Invalid comparison used for category ${category_name}`)
break;
}
// OR the first rule with rules_apply, which will be initially false
if (i === 0) preceding_operator = 'or';
// update rules_apply based on current rule
if (preceding_operator === 'or')
rules_apply = rules_apply || rule_applies;
else
rules_apply = rules_apply && rule_applies;
}
return rules_apply;
}
async function addTagToVideo(tag, video, user_uid) {
// TODO: Implement
}
async function removeTagFromVideo(tag, video, user_uid) {
// TODO: Implement
}
// adds tag to list of existing tags (used for tag suggestions)
async function addTagToExistingTags(tag) {
const existing_tags = db.get('tags').value();
if (!existing_tags.includes(tag)) {
db.get('tags').push(tag).write();
}
}
module.exports = {
initialize: initialize,
categorize: categorize,
}

View File

@@ -157,7 +157,7 @@ function setConfigItems(items) {
function globalArgsRequiresSafeDownload() {
const globalArgs = getConfigItem('ytdl_custom_args').split(',,');
const argsThatRequireSafeDownload = ['--write-sub', '--write-srt'];
const argsThatRequireSafeDownload = ['--write-sub', '--write-srt', '--proxy'];
const failedArgs = globalArgs.filter(arg => argsThatRequireSafeDownload.includes(arg));
return failedArgs && failedArgs.length > 0;
}
@@ -184,9 +184,12 @@ DEFAULT_CONFIG = {
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false
"safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true
},
"Extra": {
"title_top": "YoutubeDL-Material",
@@ -200,7 +203,10 @@ DEFAULT_CONFIG = {
"use_API_key": false,
"API_key": "",
"use_youtube_API": false,
"youtube_API_key": ""
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
},
"Themes": {
"default_theme": "default",
@@ -213,9 +219,18 @@ DEFAULT_CONFIG = {
},
"Users": {
"base_path": "users/",
"allow_registration": true
"allow_registration": true,
"auth_method": "internal",
"ldap_config": {
"url": "ldap://localhost:389",
"bindDN": "cn=root",
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
}
},
"Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -18,6 +18,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video'
},
'ytdl_default_file_output': {
'key': 'ytdl_default_file_output',
'path': 'YoutubeDLMaterial.Downloader.default_file_output'
},
'ytdl_use_youtubedl_archive': {
'key': 'ytdl_use_youtubedl_archive',
'path': 'YoutubeDLMaterial.Downloader.use_youtubedl_archive'
@@ -30,6 +34,14 @@ let CONFIG_ITEMS = {
'key': 'ytdl_safe_download_override',
'path': 'YoutubeDLMaterial.Downloader.safe_download_override'
},
'ytdl_include_thumbnail': {
'key': 'ytdl_include_thumbnail',
'path': 'YoutubeDLMaterial.Downloader.include_thumbnail'
},
'ytdl_include_metadata': {
'key': 'ytdl_include_metadata',
'path': 'YoutubeDLMaterial.Downloader.include_metadata'
},
// Extra
'ytdl_title_top': {
@@ -74,6 +86,18 @@ let CONFIG_ITEMS = {
'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.youtube_API_key'
},
'ytdl_use_twitch_api': {
'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API'
},
'ytdl_twitch_api_key': {
'key': 'ytdl_twitch_api_key',
'path': 'YoutubeDLMaterial.API.twitch_API_key'
},
'ytdl_twitch_auto_download_chat': {
'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
},
// Themes
'ytdl_default_theme': {
@@ -112,8 +136,20 @@ let CONFIG_ITEMS = {
'key': 'ytdl_allow_registration',
'path': 'YoutubeDLMaterial.Users.allow_registration'
},
'ytdl_auth_method': {
'key': 'ytdl_auth_method',
'path': 'YoutubeDLMaterial.Users.auth_method'
},
'ytdl_ldap_config': {
'key': 'ytdl_ldap_config',
'path': 'YoutubeDLMaterial.Users.ldap_config'
},
// Advanced
'ytdl_default_downloader': {
'key': 'ytdl_default_downloader',
'path': 'YoutubeDLMaterial.Advanced.default_downloader'
},
'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent',
'path': 'YoutubeDLMaterial.Advanced.use_default_downloading_agent'

View File

@@ -15,10 +15,10 @@ function initialize(input_db, input_users_db, input_logger) {
setLogger(input_logger);
}
function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
function registerFileDB(file_path, type, multiUserMode = null, sub = null, customPath = null) {
let db_path = null;
const file_id = file_path.substring(0, file_path.length-4);
const file_object = generateFileObject(file_id, type, multiUserMode && multiUserMode.file_path, sub);
const file_object = generateFileObject(file_id, type, customPath || multiUserMode && multiUserMode.file_path, sub);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_id}`);
return false;
@@ -26,11 +26,8 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path);
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, customPath || multiUserMode && multiUserMode.file_path);
if (!sub) {
if (multiUserMode) {
@@ -48,7 +45,13 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
}
}
const file_uid = registerFileDBManual(db_path, file_object)
const file_uid = registerFileDBManual(db_path, file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_id, type, multiUserMode && multiUserMode.file_path)
}
return file_uid;
}
@@ -165,6 +168,10 @@ async function importUnregisteredFiles() {
// add subscriptions to check list
for (let i = 0; i < subscriptions_to_check.length; i++) {
let subscription_to_check = subscriptions_to_check[i];
if (!subscription_to_check.name) {
// TODO: Remove subscription as it'll never complete
continue;
}
dirs_to_check.push({
basePath: multi_user_mode ? path.join(usersFileFolder, subscription_to_check.user_uid, 'subscriptions', subscription_to_check.isPlaylist ? 'playlists/' : 'channels/', subscription_to_check.name)
: path.join(subscriptions_base_path, subscription_to_check.isPlaylist ? 'playlists/' : 'channels/', subscription_to_check.name),
@@ -175,9 +182,9 @@ async function importUnregisteredFiles() {
}
// run through check list and check each file to see if it's missing from the db
dirs_to_check.forEach(dir_to_check => {
for (const dir_to_check of dirs_to_check) {
// recursively get all files in dir's path
const files = utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
files.forEach(file => {
// check if file exists in db, if not add it
@@ -188,7 +195,7 @@ async function importUnregisteredFiles() {
logger.verbose(`Added discovered file to the database: ${file.id}`);
}
});
});
}
}

View File

@@ -1,3 +0,0 @@
#!/bin/bash
# downloads a local copy of qemu on docker-hub build machines
curl -L https://github.com/balena-io/qemu/releases/download/v3.0.0%2Bresin/qemu-3.0.0+resin-arm.tar.gz | tar zxvf - -C . && mv qemu-3.0.0+resin-arm/qemu-arm-static .

View File

@@ -1,4 +0,0 @@
#!/bin/bash
# Register qemu-*-static for all supported processors except the
# current one, but also remove all registered binfmt_misc before
docker run --rm --privileged multiarch/qemu-user-static:register --reset

View File

@@ -4,6 +4,89 @@
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@types/body-parser": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.0.tgz",
"integrity": "sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==",
"requires": {
"@types/connect": "*",
"@types/node": "*"
}
},
"@types/connect": {
"version": "3.4.33",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.33.tgz",
"integrity": "sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==",
"requires": {
"@types/node": "*"
}
},
"@types/express": {
"version": "4.17.7",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.7.tgz",
"integrity": "sha512-dCOT5lcmV/uC2J9k0rPafATeeyz+99xTt54ReX11/LObZgfzJqZNcW27zGhYyX+9iSEGXGt5qLPwRSvBZcLvtQ==",
"requires": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "*",
"@types/qs": "*",
"@types/serve-static": "*"
}
},
"@types/express-serve-static-core": {
"version": "4.17.9",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.9.tgz",
"integrity": "sha512-DG0BYg6yO+ePW+XoDENYz8zhNGC3jDDEpComMYn7WJc4mY1Us8Rw9ax2YhJXxpyk2SF47PQAoQ0YyVT1a0bEkA==",
"requires": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*"
}
},
"@types/ldapjs": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-1.0.9.tgz",
"integrity": "sha512-3PvY7Drp1zoLbcGlothCAkoc5o6Jp9KvUPwHadlHyKp3yPvyeIh7w2zQc9UXMzgDRkoeGXUEODtbEs5XCh9ZyA==",
"requires": {
"@types/node": "*"
}
},
"@types/mime": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.3.tgz",
"integrity": "sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q=="
},
"@types/node": {
"version": "14.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.6.0.tgz",
"integrity": "sha512-mikldZQitV94akrc4sCcSjtJfsTKt4p+e/s0AGscVA6XArQ9kFclP+ZiYUMnq987rc6QlYxXv/EivqlfSLxpKA=="
},
"@types/passport": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.4.tgz",
"integrity": "sha512-h5OfAbfBBYSzjeU0GTuuqYEk9McTgWeGQql9g3gUw2/NNCfD7VgExVRYJVVeU13Twn202Mvk9BT0bUrl30sEgA==",
"requires": {
"@types/express": "*"
}
},
"@types/qs": {
"version": "6.9.4",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.4.tgz",
"integrity": "sha512-+wYo+L6ZF6BMoEjtf8zB2esQsqdV6WsjRK/GP9WOgLPrq87PbNWgIxS76dS5uvl/QXtHGakZmwTznIfcPXcKlQ=="
},
"@types/range-parser": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.3.tgz",
"integrity": "sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA=="
},
"@types/serve-static": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.5.tgz",
"integrity": "sha512-6M64P58N+OXjU432WoLLBQxbA0LRGBCRm7aAGQJ+SMC1IMl0dgRVi9EFfoDcS2a7Xogygk/eGN94CfwU9UF7UQ==",
"requires": {
"@types/express-serve-static-core": "*",
"@types/mime": "*"
}
},
"abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@@ -169,6 +252,14 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz",
"integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug=="
},
"backoff": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz",
"integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=",
"requires": {
"precond": "0.2"
}
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@@ -315,6 +406,17 @@
"resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz",
"integrity": "sha1-skV5w77U1tOWru5tmorn9Ugqt7s="
},
"bunyan": {
"version": "1.8.14",
"resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.14.tgz",
"integrity": "sha512-LlahJUxXzZLuw/hetUQJmRgZ1LF6+cr5TPpRj6jf327AsiIq2jhYEH4oqUUkVKTor+9w2BT3oxVwhzE5lw9tcg==",
"requires": {
"dtrace-provider": "~0.8",
"moment": "^2.19.3",
"mv": "~2",
"safe-json-stringify": "~1"
}
},
"busboy": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
@@ -739,6 +841,15 @@
"is-obj": "^1.0.0"
}
},
"dtrace-provider": {
"version": "0.8.8",
"resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz",
"integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==",
"optional": true,
"requires": {
"nan": "^2.14.0"
}
},
"duplexer2": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
@@ -1472,6 +1583,72 @@
}
}
},
"ldap-filter": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz",
"integrity": "sha1-8rhCvguG2jNSeYUFsx68rlkNd9A=",
"requires": {
"assert-plus": "0.1.5"
},
"dependencies": {
"assert-plus": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz",
"integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA="
}
}
},
"ldapauth-fork": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-4.3.3.tgz",
"integrity": "sha512-x76VpQ5ZqkwAJmqwcD6KIwDiNEbgIGIPGwC/eA17e1dxWhlTx36w0DlLOFwjTuZ2iuaLTsZsUprlVqvSlwc/1Q==",
"requires": {
"@types/ldapjs": "^1.0.0",
"@types/node": "*",
"bcryptjs": "^2.4.0",
"ldapjs": "^1.0.2",
"lru-cache": "^5.1.1"
},
"dependencies": {
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"requires": {
"yallist": "^3.0.2"
}
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}
}
},
"ldapjs": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-1.0.2.tgz",
"integrity": "sha1-VE/3Ayt7g8aPBwEyjZKXqmlDQPk=",
"requires": {
"asn1": "0.2.3",
"assert-plus": "^1.0.0",
"backoff": "^2.5.0",
"bunyan": "^1.8.3",
"dashdash": "^1.14.0",
"dtrace-provider": "~0.8",
"ldap-filter": "0.2.2",
"once": "^1.4.0",
"vasync": "^1.6.4",
"verror": "^1.8.1"
},
"dependencies": {
"asn1": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
"integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y="
}
}
},
"listenercount": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz",
@@ -1672,6 +1849,12 @@
"minimist": "^1.2.5"
}
},
"moment": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz",
"integrity": "sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==",
"optional": true
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -1717,6 +1900,41 @@
}
}
},
"mv": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
"integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=",
"optional": true,
"requires": {
"mkdirp": "~0.5.1",
"ncp": "~2.0.0",
"rimraf": "~2.4.0"
},
"dependencies": {
"glob": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=",
"optional": true,
"requires": {
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "2 || 3",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"rimraf": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=",
"optional": true,
"requires": {
"glob": "^6.0.1"
}
}
}
},
"mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -1727,20 +1945,32 @@
"thenify-all": "^1.0.0"
}
},
"nan": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.1.tgz",
"integrity": "sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==",
"optional": true
},
"nanoid": {
"version": "2.1.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz",
"integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA=="
},
"ncp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
"integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=",
"optional": true
},
"negotiator": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
},
"node-fetch": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
"integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA=="
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
},
"node-id3": {
"version": "0.1.16",
@@ -1918,6 +2148,17 @@
}
}
},
"passport-ldapauth": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-2.1.4.tgz",
"integrity": "sha512-VeVL69ZK+cpJe0DKMSGuwcf7k+V4dr0U0Y7ZhXL785pcRb5gRA6qYZfIH+XTsAzwqTK9l0Dn3Ds4weOZ1jKkLQ==",
"requires": {
"@types/node": "*",
"@types/passport": "^1.0.0",
"ldapauth-fork": "^4.3.2",
"passport-strategy": "^1.0.0"
}
},
"passport-local": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
@@ -1971,6 +2212,11 @@
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY="
},
"precond": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz",
"integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw="
},
"prepend-http": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz",
@@ -2166,6 +2412,12 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-json-stringify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz",
"integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==",
"optional": true
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@@ -2682,6 +2934,29 @@
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw="
},
"vasync": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz",
"integrity": "sha1-3+k2Fq0OeugBszKp2Iv8XNyOHR8=",
"requires": {
"verror": "1.6.0"
},
"dependencies": {
"extsprintf": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz",
"integrity": "sha1-WtlGwi9bMrp/jNdCZxHG6KP8JSk="
},
"verror": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz",
"integrity": "sha1-fROyex+swuLakEBetepuW90lLqU=",
"requires": {
"extsprintf": "1.2.0"
}
}
}
},
"verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",

View File

@@ -37,17 +37,19 @@
"express": "^4.17.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0",
"glob": "^7.1.6",
"jsonwebtoken": "^8.5.1",
"lowdb": "^1.0.0",
"md5": "^2.2.1",
"merge-files": "^0.1.2",
"multer": "^1.4.2",
"node-fetch": "^2.6.0",
"node-fetch": "^2.6.1",
"node-id3": "^0.1.14",
"nodemon": "^2.0.2",
"passport": "^0.4.1",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.0",
"passport-ldapauth": "^2.1.4",
"passport-local": "^1.0.0",
"progress": "^2.0.3",
"ps-node": "^0.1.6",

View File

@@ -79,17 +79,18 @@ async function getSubscriptionInfo(sub, user_uid = null) {
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
return new Promise(resolve => {
// get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1'];
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (fs.existsSync(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
}
// get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1'];
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
}
}
return new Promise(resolve => {
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
if (debugMode) {
logger.info('Subscribe: got info for subscription ' + sub.id);
@@ -113,7 +114,11 @@ async function getSubscriptionInfo(sub, user_uid = null) {
continue;
}
if (!sub.name) {
sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader;
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) {
if (user_uid)
@@ -152,39 +157,36 @@ async function getSubscriptionInfo(sub, user_uid = null) {
}
async function unsubscribe(sub, deleteMode, user_uid = null) {
return new Promise(async resolve => {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let result_obj = { success: false, error: '' };
let id = sub.id;
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write();
else
db.get('subscriptions').remove({id: id}).write();
let id = sub.id;
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write();
else
db.get('subscriptions').remove({id: id}).write();
// failed subs have no name, on unsubscribe they shouldn't error
if (!sub.name) {
return;
}
// failed subs have no name, on unsubscribe they shouldn't error
if (!sub.name) {
return;
}
const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && fs.existsSync(appendedBasePath)) {
if (sub.archive && fs.existsSync(sub.archive)) {
const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists
if (fs.existsSync(archive_file_path)) {
fs.unlinkSync(archive_file_path);
}
fs.rmdirSync(sub.archive);
const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && (await fs.pathExists(appendedBasePath))) {
if (sub.archive && (await fs.pathExists(sub.archive))) {
const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists
if (await fs.pathExists(archive_file_path)) {
await fs.unlink(archive_file_path);
}
deleteFolderRecursive(appendedBasePath);
await fs.rmdir(sub.archive);
}
});
await fs.remove(appendedBasePath);
}
}
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
@@ -202,155 +204,159 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
const name = file;
let retrievedID = null;
sub_db.get('videos').remove({uid: file_uid}).write();
return new Promise(resolve => {
let filePath = appendedBasePath;
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+ext);
var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
var altImageFilePath = path.join(__dirname,filePath,name+'.jpg');
jsonExists = fs.existsSync(jsonPath);
videoFileExists = fs.existsSync(videoFilePath);
imageFileExists = fs.existsSync(imageFilePath);
altImageFileExists = fs.existsSync(altImageFilePath);
let filePath = appendedBasePath;
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+ext);
var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
var altImageFilePath = path.join(__dirname,filePath,name+'.jpg');
if (jsonExists) {
retrievedID = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))['id'];
fs.unlinkSync(jsonPath);
}
const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
fs.pathExists(jsonPath),
fs.pathExists(videoFilePath),
fs.pathExists(imageFilePath),
fs.pathExists(altImageFilePath),
]);
if (imageFileExists) {
fs.unlinkSync(imageFilePath);
}
if (jsonExists) {
retrievedID = JSON.parse(await fs.readFile(jsonPath, 'utf8'))['id'];
await fs.unlink(jsonPath);
}
if (altImageFileExists) {
fs.unlinkSync(altImageFilePath);
}
if (imageFileExists) {
await fs.unlink(imageFilePath);
}
if (videoFileExists) {
fs.unlink(videoFilePath, function(err) {
if (fs.existsSync(jsonPath) || fs.existsSync(videoFilePath)) {
resolve(false);
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
if (fs.existsSync(archive_path)) {
removeIDFromArchive(archive_path, retrievedID);
}
}
resolve(true);
}
});
if (altImageFileExists) {
await fs.unlink(altImageFilePath);
}
if (videoFileExists) {
await fs.unlink(videoFilePath);
if ((await fs.pathExists(jsonPath)) || (await fs.pathExists(videoFilePath))) {
return false;
} else {
// TODO: tell user that the file didn't exist
resolve(true);
// check if the user wants the video to be redownloaded (deleteForever === false)
if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
if (await fs.pathExists(archive_path)) {
await removeIDFromArchive(archive_path, retrievedID);
}
}
return true;
}
});
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
async function getVideosForSub(sub, user_uid = null) {
return new Promise(resolve => {
if (!subExists(sub.id, user_uid)) {
resolve(false);
return;
if (!subExists(sub.id, user_uid)) {
return false;
}
// get sub_db
let sub_db = null;
if (user_uid)
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
else
sub_db = db.get('subscriptions').find({id: sub.id});
// get basePath
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
let appendedBasePath = null
appendedBasePath = getAppendedBasePath(sub, basePath);
let multiUserMode = null;
if (user_uid) {
multiUserMode = {
user: user_uid,
file_path: appendedBasePath
}
}
// get sub_db
let sub_db = null;
if (user_uid)
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
else
sub_db = db.get('subscriptions').find({id: sub.id});
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
// get basePath
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
let fullOutput = `${appendedBasePath}/%(title)s.%(ext)s`;
if (sub.custom_output) {
fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
}
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
let downloadConfig = ['-o', fullOutput, '-ciw', '--write-info-json', '--print-json'];
let appendedBasePath = null
appendedBasePath = getAppendedBasePath(sub, basePath);
let qualityPath = null;
if (sub.type && sub.type === 'audio') {
qualityPath = ['-f', 'bestaudio']
qualityPath.push('-x');
qualityPath.push('--audio-format', 'mp3');
} else {
if (!sub.maxQuality || sub.maxQuality === 'best') qualityPath = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'];
else qualityPath = ['-f', `bestvideo[height<=${sub.maxQuality}]+bestaudio/best[height<=${sub.maxQuality}]`, '--merge-output-format', 'mp4'];
}
let multiUserMode = null;
if (user_uid) {
multiUserMode = {
user: user_uid,
file_path: appendedBasePath
}
downloadConfig.push(...qualityPath)
if (sub.custom_args) {
customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) {
// if custom args has a custom quality, replce the original quality with that of custom args
const original_output_index = downloadConfig.indexOf('-f');
downloadConfig.splice(original_output_index, 2);
}
downloadConfig.push(...customArgsArray);
}
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
let archive_dir = null;
let archive_path = null;
let fullOutput = appendedBasePath + '/%(title)s' + ext;
if (sub.custom_output) {
fullOutput = appendedBasePath + '/' + sub.custom_output + ext;
if (useArchive) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
}
downloadConfig.push('--download-archive', archive_path);
}
let downloadConfig = ['-o', fullOutput, '-ciw', '--write-info-json', '--print-json'];
// if streaming only mode, just get the list of videos
if (sub.streamingOnly) {
downloadConfig = ['-f', 'best', '--dump-json'];
}
let qualityPath = null;
if (sub.type && sub.type === 'audio') {
qualityPath = ['-f', 'bestaudio']
qualityPath.push('-x');
qualityPath.push('--audio-format', 'mp3');
if (sub.timerange) {
downloadConfig.push('--dateafter', sub.timerange);
}
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else {
qualityPath = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4']
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
}
}
downloadConfig.push(...qualityPath)
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail');
}
if (sub.custom_args) {
customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) {
// if custom args has a custom quality, replce the original quality with that of custom args
const original_output_index = downloadConfig.indexOf('-f');
downloadConfig.splice(original_output_index, 2);
}
downloadConfig.push(...customArgsArray);
}
// get videos
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
let archive_dir = null;
let archive_path = null;
if (useArchive) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
}
downloadConfig.push('--download-archive', archive_path);
}
// if streaming only mode, just get the list of videos
if (sub.streamingOnly) {
downloadConfig = ['-f', 'best', '--dump-json'];
}
if (sub.timerange) {
downloadConfig.push('--dateafter', sub.timerange);
}
let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) {
if (fs.existsSync(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
}
}
// get videos
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
return new Promise(resolve => {
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr);
logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
@@ -429,6 +435,13 @@ function getSubscription(subID, user_uid = null) {
return db.get('subscriptions').find({id: subID}).value();
}
function getSubscriptionByName(subName, user_uid = null) {
if (user_uid)
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({name: subName}).value();
else
return db.get('subscriptions').find({name: subName}).value();
}
function updateSubscription(sub, user_uid = null) {
if (user_uid) {
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write();
@@ -452,23 +465,8 @@ function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
// https://stackoverflow.com/a/32197381/8088021
const deleteFolderRecursive = function(folder_to_delete) {
if (fs.existsSync(folder_to_delete)) {
fs.readdirSync(folder_to_delete).forEach((file, index) => {
const curPath = path.join(folder_to_delete, file);
if (fs.lstatSync(curPath).isDirectory()) { // recurse
deleteFolderRecursive(curPath);
} else { // delete file
fs.unlinkSync(curPath);
}
});
fs.rmdirSync(folder_to_delete);
}
};
function removeIDFromArchive(archive_path, id) {
let data = fs.readFileSync(archive_path, {encoding: 'utf-8'});
async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
@@ -489,13 +487,14 @@ function removeIDFromArchive(archive_path, id) {
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
fs.writeFileSync(archive_path, updatedData);
await fs.writeFile(archive_path, updatedData);
if (line) return line;
if (err) throw err;
}
module.exports = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,

71
backend/twitch.js Normal file
View File

@@ -0,0 +1,71 @@
var moment = require('moment');
var Axios = require('axios');
async function getCommentsForVOD(clientID, vodId) {
let url = `https://api.twitch.tv/v5/videos/${vodId}/comments?content_offset_seconds=0`,
batch,
cursor;
let comments = null;
try {
do {
batch = (await Axios.get(url, {
headers: {
'Client-ID': clientID,
Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
}
})).data;
const str = batch.comments.map(c => {
let {
created_at: msgCreated,
content_offset_seconds: timestamp,
commenter: {
name,
_id,
created_at: acctCreated
},
message: {
body: msg
}
} = c;
const timestamp_str = moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
acctCreated = moment(acctCreated).utc();
msgCreated = moment(msgCreated).utc();
if (!comments) comments = [];
comments.push({
timestamp: timestamp,
timestamp_str: timestamp_str,
name: name,
message: msg
});
// let line = `${timestamp},${msgCreated.format(tsFormat)},${name},${_id},"${msg.replace(/"/g, '""')}",${acctCreated.format(tsFormat)}`;
// return line;
}).join('\n');
cursor = batch._next;
url = `https://api.twitch.tv/v5/videos/${vodId}/comments?cursor=${cursor}`;
await new Promise(res => setTimeout(res, 300));
} while (cursor);
} catch (err) {
console.error(err);
}
return comments;
}
module.exports = {
getCommentsForVOD: getCommentsForVOD
}

View File

@@ -4,6 +4,7 @@ const config_api = require('./config');
const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension
function getTrueFileName(unfixed_path, type) {
let fixed_path = unfixed_path;
@@ -19,21 +20,21 @@ function getTrueFileName(unfixed_path, type) {
return fixed_path;
}
function getDownloadedFilesByType(basePath, type) {
async function getDownloadedFilesByType(basePath, type) {
// return empty array if the path doesn't exist
if (!fs.existsSync(basePath)) return [];
if (!(await fs.pathExists(basePath))) return [];
let files = [];
const ext = type === 'audio' ? 'mp3' : 'mp4';
var located_files = recFindByExt(basePath, ext);
var located_files = await recFindByExt(basePath, ext);
for (let i = 0; i < located_files.length; i++) {
let file = located_files[i];
var file_path = path.basename(file);
var file_path = file.substring(basePath.includes('\\') ? basePath.length+1 : basePath.length, file.length);
var stats = fs.statSync(file);
var stats = await fs.stat(file);
var id = file_path.substring(0, file_path.length-4);
var jsonobj = getJSONByType(type, id, basePath);
var jsonobj = await getJSONByType(type, id, basePath);
if (!jsonobj) continue;
var title = jsonobj.title;
var url = jsonobj.webpage_url;
@@ -88,13 +89,49 @@ function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
}
function getDownloadedThumbnail(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path') : config_api.getConfigItem('ytdl_video_folder_path');
let jpgPath = path.join(customPath, name + '.jpg');
let webpPath = path.join(customPath, name + '.webp');
let pngPath = path.join(customPath, name + '.png');
if (fs.existsSync(jpgPath))
return jpgPath;
else if (fs.existsSync(webpPath))
return webpPath;
else if (fs.existsSync(pngPath))
return pngPath;
else
return null;
}
function getExpectedFileSize(info_json) {
if (info_json['filesize']) {
return info_json['filesize'];
}
const formats = info_json['format_id'].split('+');
let expected_filesize = 0;
formats.forEach(format_id => {
if (!info_json.formats) return expected_filesize;
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) {
expected_filesize += available_format.filesize;
}
});
});
return expected_filesize;
}
function fixVideoMetadataPerms(name, type, customPath = null) {
if (is_windows) return;
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
const files_to_fix = [
// JSONs
path.join(customPath, name + '.info.json'),
@@ -110,27 +147,38 @@ function fixVideoMetadataPerms(name, type, customPath = null) {
}
}
function recFindByExt(base,ext,files,result)
function deleteJSONFile(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
let json_path = path.join(customPath, name + '.info.json');
let alternate_json_path = path.join(customPath, name + ext + '.info.json');
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
async function recFindByExt(base,ext,files,result)
{
files = files || fs.readdirSync(base)
files = files || (await fs.readdir(base))
result = result || []
files.forEach(
function (file) {
var newbase = path.join(base,file)
if ( fs.statSync(newbase).isDirectory() )
for (const file of files) {
var newbase = path.join(base,file)
if ( (await fs.stat(newbase)).isDirectory() )
{
result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result)
}
else
{
if ( file.substr(-1*(ext.length+1)) == '.' + ext )
{
result = recFindByExt(newbase,ext,fs.readdirSync(newbase),result)
}
else
{
if ( file.substr(-1*(ext.length+1)) == '.' + ext )
{
result.push(newbase)
}
result.push(newbase)
}
}
)
}
return result
}
@@ -153,7 +201,10 @@ module.exports = {
getJSONMp3: getJSONMp3,
getJSONMp4: getJSONMp4,
getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail,
getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms,
deleteJSONFile: deleteJSONFile,
getDownloadedFilesByType: getDownloadedFilesByType,
recFindByExt: recFindByExt,
File: File

15
package-lock.json generated
View File

@@ -1404,6 +1404,21 @@
}
}
},
"@ngneat/content-loader": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@ngneat/content-loader/-/content-loader-5.0.0.tgz",
"integrity": "sha512-XrS53rsiJoQIOy2BwmOHkvgPv0a5cTzXbbM+/3IpgezPFdNqDunpZW+AciWQffVOfgbmL+7cYp1DVb6WM15LhQ==",
"requires": {
"tslib": "^2.0.0"
},
"dependencies": {
"tslib": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz",
"integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ=="
}
}
},
"@ngtools/webpack": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-9.1.0.tgz",

View File

@@ -30,6 +30,7 @@
"@angular/platform-browser": "^9.1.0",
"@angular/platform-browser-dynamic": "^9.1.0",
"@angular/router": "^9.1.0",
"@ngneat/content-loader": "^5.0.0",
"core-js": "^2.4.1",
"file-saver": "^2.0.2",
"filesize": "^6.1.0",

View File

@@ -38,15 +38,15 @@
</div>
<div class="sidenav-container" style="height: calc(100% - 64px)">
<mat-sidenav-container style="height: 100%">
<mat-sidenav [opened]="postsService.sidepanel_mode === 'side' && router.url === '/home'" [mode]="postsService.sidepanel_mode" #sidenav>
<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)="sidenav.close()" 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)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a>
<a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a>
<a *ngIf="postsService.config && allowSubscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))" mat-list-item (click)="sidenav.close()" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
<a *ngIf="postsService.config && enableDownloadsManager && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('downloads_manager')))" mat-list-item (click)="sidenav.close()" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
<a *ngIf="postsService.config && allowSubscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
<a *ngIf="postsService.config && enableDownloadsManager && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('downloads_manager')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))">
<mat-divider></mat-divider>
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="sidenav.close()" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar><ng-container i18n="Navigation menu Downloads Page title">{{subscription.name}}</ng-container></a>
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar>{{subscription.name}}</a>
</ng-container>
</mat-nav-list>
</mat-sidenav>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, ElementRef, ViewChild, HostBinding } from '@angular/core';
import { Component, OnInit, ElementRef, ViewChild, HostBinding, AfterViewInit } from '@angular/core';
import {PostsService} from './posts.services';
import {FileCardComponent} from './file-card/file-card.component';
import { Observable } from 'rxjs/Observable';
@@ -30,11 +30,13 @@ import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dial
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
export class AppComponent implements OnInit, AfterViewInit {
@HostBinding('class') componentCssClass;
THEMES_CONFIG = THEMES_CONFIG;
window = window;
// config items
topBarTitle = 'Youtube Downloader';
defaultTheme = null;
@@ -69,6 +71,29 @@ export class AppComponent implements OnInit {
}
ngOnInit() {
if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme'));
}
this.postsService.open_create_default_admin_dialog.subscribe(open => {
if (open) {
const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent);
dialogRef.afterClosed().subscribe(success => {
if (success) {
if (this.router.url !== '/login') { this.router.navigate(['/login']); }
} else {
console.error('Failed to create default admin account. See logs for details.');
}
});
}
});
}
ngAfterViewInit() {
this.postsService.sidenav = this.sidenav;
}
toggleSidenav() {
this.sidenav.toggle();
}
@@ -89,10 +114,10 @@ export class AppComponent implements OnInit {
// gets the subscriptions
if (this.allowSubscriptions) {
this.postsService.getAllSubscriptions().subscribe(res => {
this.postsService.subscriptions = res['subscriptions'];
})
this.postsService.reloadSubscriptions();
}
this.postsService.reloadCategories();
}
// theme stuff
@@ -124,9 +149,9 @@ export class AppComponent implements OnInit {
this.postsService.setTheme(theme);
this.onSetTheme(this.THEMES_CONFIG[theme]['css_label'], old_theme ? this.THEMES_CONFIG[old_theme]['css_label'] : old_theme);
}
}
onSetTheme(theme, old_theme) {
onSetTheme(theme, old_theme) {
if (old_theme) {
document.body.classList.remove(old_theme);
this.overlayContainer.getContainerElement().classList.remove(old_theme);
@@ -148,27 +173,6 @@ onSetTheme(theme, old_theme) {
event.stopPropagation();
}
ngOnInit() {
if (localStorage.getItem('theme')) {
this.setTheme(localStorage.getItem('theme'));
} else {
//
}
this.postsService.open_create_default_admin_dialog.subscribe(open => {
if (open) {
const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent);
dialogRef.afterClosed().subscribe(success => {
if (success) {
if (this.router.url !== '/login') { this.router.navigate(['/login']); }
} else {
console.error('Failed to create default admin account. See logs for details.');
}
});
}
});
}
getSubscriptions() {
}

View File

@@ -54,6 +54,7 @@ import { SettingsComponent } from './settings/settings.component';
import { MatChipsModule } from '@angular/material/chips';
import { NgxFileDropModule } from 'ngx-file-drop';
import { AvatarModule } from 'ngx-avatar';
import { ContentLoaderModule } from '@ngneat/content-loader';
import es from '@angular/common/locales/es';
import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.component';
@@ -78,6 +79,8 @@ import { UnifiedFileCardComponent } from './components/unified-file-card/unified
import { RecentVideosComponent } from './components/recent-videos/recent-videos.component';
import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.component';
import { EditCategoryDialogComponent } from './dialogs/edit-category-dialog/edit-category-dialog.component';
import { TwitchChatComponent } from './components/twitch-chat/twitch-chat.component';
registerLocaleData(es, 'es');
@@ -122,7 +125,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent,
RecentVideosComponent,
EditSubscriptionDialogComponent,
CustomPlaylistsComponent
CustomPlaylistsComponent,
EditCategoryDialogComponent,
TwitchChatComponent
],
imports: [
CommonModule,
@@ -152,7 +157,6 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
MatMenuModule,
MatDialogModule,
MatSlideToggleModule,
MatMenuModule,
MatAutocompleteModule,
MatTabsModule,
MatTooltipModule,
@@ -164,6 +168,7 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
ClipboardModule,
NgxFileDropModule,
AvatarModule,
ContentLoaderModule,
VgCoreModule,
VgControlsModule,
VgOverlayPlayModule,

View File

@@ -1,8 +1,8 @@
<div *ngIf="playlists && playlists.length > 0">
<div class="container">
<div class="row justify-content-center">
<div *ngFor="let playlist of playlists; let i = index" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 mb-2 mt-2 file-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 mb-2 mt-2 file-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" (goToFile)="goToPlaylist($event)" [file_obj]="playlist" [is_playlist]="true" (editPlaylist)="editPlaylistDialog($event)" (deleteFile)="deletePlaylist($event)"></app-unified-file-card>
<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)" [loading]="false"></app-unified-file-card>
</div>
</div>
</div>

View File

@@ -5,6 +5,14 @@
right: 15px;
}
.file-col {
.large-col {
max-width: 320px;
}
.medium-col {
max-width: 240px;
}
.small-col {
max-width: 240px;
}

View File

@@ -50,7 +50,8 @@ export class CustomPlaylistsComponent implements OnInit {
});
}
goToPlaylist(playlist) {
goToPlaylist(info_obj) {
const playlist = info_obj.file;
const playlistID = playlist.id;
const type = playlist.type;
@@ -82,7 +83,7 @@ export class CustomPlaylistsComponent implements OnInit {
const playlist = args.file;
const index = args.index;
const playlistID = playlist.id;
this.postsService.removePlaylist(playlistID, 'audio').subscribe(res => {
this.postsService.removePlaylist(playlistID, playlist.type).subscribe(res => {
if (res['success']) {
this.playlists.splice(index, 1);
this.postsService.openSnackBar('Playlist successfully removed.', '');

View File

@@ -61,7 +61,8 @@ export class LogsViewerComponent implements OnInit {
data: {
dialogTitle: 'Clear logs',
dialogText: 'Would you like to clear your logs? This will delete all your current logs, permanently.',
submitText: 'Clear'
submitText: 'Clear',
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {

View File

@@ -1,4 +1,4 @@
<div class="container-fluid">
<div class="container-fluid" style="max-width: 941px;">
<div class="row">
<div class="col-12 order-2 col-sm-4 order-sm-1 d-flex justify-content-center">
<div>
@@ -17,7 +17,7 @@
</div>
</div>
<div class="col-12 order-1 col-sm-4 order-sm-2 d-flex justify-content-center">
<h4 style="text-align: center">My videos</h4>
<h4 class="my-videos-title" i18n="My videos title">My videos</h4>
</div>
<div class="col-12 order-3 col-sm-4 order-sm-3 d-flex justify-content-center">
<mat-form-field [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
@@ -30,9 +30,16 @@
<div>
<div class="container">
<div class="row justify-content-center">
<div *ngFor="let file of filtered_files; let i = index" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 mb-2 mt-2 file-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 mb-2 mt-2 file-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" (deleteFile)="deleteFile($event)"></app-unified-file-card>
</div>
<ng-container *ngIf="normal_files_received">
<div *ngFor="let file of filtered_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card>
</div>
</ng-container>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
<div *ngFor="let file of loading_files; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" [loading]="true" [theme]="postsService.theme"></app-unified-file-card>
</div>
</ng-container>
</div>
</div>
</div>

View File

@@ -1,4 +1,12 @@
.file-col {
.large-col {
max-width: 320px;
}
.medium-col {
max-width: 240px;
}
.small-col {
max-width: 240px;
}
@@ -37,4 +45,16 @@
display: inline-block;
position: absolute;
top: 10px;
}
.my-videos-title {
text-align: center;
position: relative;
top: 12px;
}
@media (max-width: 576px) {
.my-videos-title {
top: 0px;
}
}

View File

@@ -9,6 +9,9 @@ import { Router } from '@angular/router';
})
export class RecentVideosComponent implements OnInit {
cached_file_count = 0;
loading_files = null;
normal_files_received = false;
subscription_files_received = false;
files: any[] = null;
@@ -47,15 +50,26 @@ export class RecentVideosComponent implements OnInit {
};
filterProperty = this.filterProperties['upload_date'];
constructor(public postsService: PostsService, private router: Router) { }
constructor(public postsService: PostsService, private router: Router) {
// get cached file count
if (localStorage.getItem('cached_file_count')) {
this.cached_file_count = +localStorage.getItem('cached_file_count');
this.loading_files = Array(this.cached_file_count).fill(0);
}
}
ngOnInit(): void {
if (this.postsService.initialized) {
this.getAllFiles();
}
this.postsService.service_initialized.subscribe(init => {
if (init) {
this.getAllFiles();
}
});
// set filter property to cached
const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && this.filterProperties[cached_filter_property]) {
@@ -114,33 +128,46 @@ export class RecentVideosComponent implements OnInit {
this.filtered_files = this.files;
}
this.filterByProperty(this.filterProperty['property']);
// set cached file count for future use, note that we convert the amount of files to a string
localStorage.setItem('cached_file_count', '' + this.files.length);
this.normal_files_received = true;
});
}
// navigation
goToFile(file) {
goToFile(info_obj) {
const file = info_obj['file'];
const event = info_obj['event'];
if (this.postsService.config['Extra']['download_only_mode']) {
this.downloadFile(file);
} else {
this.navigateToFile(file);
this.navigateToFile(file, event.ctrlKey);
}
}
navigateToFile(file) {
navigateToFile(file, new_tab) {
localStorage.setItem('player_navigator', this.router.url);
if (file.sub_id) {
const sub = this.postsService.getSubscriptionByID(file.sub_id)
const sub = this.postsService.getSubscriptionByID(file.sub_id);
if (sub.streamingOnly) {
this.router.navigate(['/player', {name: file.id,
url: file.requested_formats ? file.requested_formats[0].url : file.url}]);
// streaming only mode subscriptions
!new_tab ? this.router.navigate(['/player', {name: file.id,
url: file.requested_formats ? file.requested_formats[0].url : file.url}])
: window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`);
} else {
this.router.navigate(['/player', {fileNames: file.id,
type: file.isAudio ? 'audio' : 'video', subscriptionName: sub.name,
subPlaylist: sub.isPlaylist}]);
// normal subscriptions
!new_tab ? this.router.navigate(['/player', {fileNames: file.id,
type: file.isAudio ? 'audio' : 'video', subscriptionName: sub.name,
subPlaylist: sub.isPlaylist}])
: window.open(`/#/player;fileNames=${file.id};type=${file.isAudio ? 'audio' : 'video'};subscriptionName=${sub.name};subPlaylist=${sub.isPlaylist}`);
}
} else {
this.router.navigate(['/player', {type: file.isAudio ? 'audio' : 'video', uid: file.uid}]);
// normal files
!new_tab ? this.router.navigate(['/player', {type: file.isAudio ? 'audio' : 'video', uid: file.uid}])
: window.open(`/#/player;type=${file.isAudio ? 'audio' : 'video'};uid=${file.uid}`);
}
}
@@ -162,7 +189,6 @@ export class RecentVideosComponent implements OnInit {
const type = file.isAudio ? 'audio' : 'video';
const ext = type === 'audio' ? '.mp3' : '.mp4'
const sub = this.postsService.getSubscriptionByID(file.sub_id);
console.log(sub.isPlaylist)
this.postsService.downloadFileFromServer(file.id, type, null, null, sub.name, sub.isPlaylist,
this.postsService.user ? this.postsService.user.uid : null, null).subscribe(res => {
const blob: Blob = res;
@@ -184,7 +210,7 @@ export class RecentVideosComponent implements OnInit {
if (!this.postsService.config.Extra.file_manager_enabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => {
this.postsService.deleteFile(name, type).subscribe(delRes => {
// reload mp4s
this.getAllFiles();
});
@@ -207,7 +233,7 @@ export class RecentVideosComponent implements OnInit {
}
deleteNormalFile(file, index, blacklistMode = false) {
this.postsService.deleteFile(file.uid, file.isAudio, blacklistMode).subscribe(result => {
this.postsService.deleteFile(file.uid, file.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
if (result) {
this.postsService.openSnackBar('Delete success!', 'OK.');
this.files.splice(index, 1);

View File

@@ -0,0 +1,11 @@
<div class="chat-container" #scrollContainer *ngIf="visible_chat">
<div style="width: 250px; text-align: center;"><strong>Twitch Chat</strong></div>
<div style="max-width: 250px" *ngFor="let chat of visible_chat">
{{chat.timestamp_str}} - <strong>{{chat.name}}</strong>: {{chat.message}}
</div>
</div>
<ng-container *ngIf="chat_response_received && !full_chat">
<button (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>

View File

@@ -0,0 +1,13 @@
.chat-container {
height: 100%;
overflow-y: scroll;
}
.download-button {
margin: 10px;
}
.downloading-spinner {
top: 50%;
left: 80px;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TwitchChatComponent } from './twitch-chat.component';
describe('TwitchChatComponent', () => {
let component: TwitchChatComponent;
let fixture: ComponentFixture<TwitchChatComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ TwitchChatComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(TwitchChatComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,135 @@
import { AfterViewInit, Component, ElementRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-twitch-chat',
templateUrl: './twitch-chat.component.html',
styleUrls: ['./twitch-chat.component.scss']
})
export class TwitchChatComponent implements OnInit, AfterViewInit {
full_chat = null;
visible_chat = null;
chat_response_received = false;
downloading_chat = false;
current_chat_index = null;
CHAT_CHECK_INTERVAL_MS = 200;
chat_check_interval_obj = null;
scrollContainer = null;
@Input() db_file = null;
@Input() current_timestamp = null;
@ViewChild('scrollContainer') scrollRef: ElementRef;
constructor(private postsService: PostsService) { }
ngOnInit(): void {
this.getFullChat();
}
ngAfterViewInit() {
}
private isUserNearBottom(): boolean {
const threshold = 300;
const position = this.scrollContainer.scrollTop + this.scrollContainer.offsetHeight;
const height = this.scrollContainer.scrollHeight;
return position > height - threshold;
}
scrollToBottom = () => {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}
addNewChatMessages() {
if (!this.scrollContainer) {
this.scrollContainer = this.scrollRef.nativeElement;
}
if (this.current_chat_index === null) {
this.current_chat_index = this.getIndexOfNextChat();
}
const latest_chat_timestamp = this.visible_chat.length ? this.visible_chat[this.visible_chat.length - 1]['timestamp'] : 0;
for (let i = this.current_chat_index + 1; i < this.full_chat.length; i++) {
if (this.full_chat[i]['timestamp'] >= latest_chat_timestamp && this.full_chat[i]['timestamp'] <= this.current_timestamp) {
this.visible_chat.push(this.full_chat[i]);
this.current_chat_index = i;
if (this.isUserNearBottom()) {
this.scrollToBottom();
}
} else if (this.full_chat[i]['timestamp'] > this.current_timestamp) {
break;
}
}
}
getIndexOfNextChat() {
const index = binarySearch(this.full_chat, 'timestamp', this.current_timestamp);
return index;
}
getFullChat() {
this.postsService.getFullTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', null).subscribe(res => {
this.chat_response_received = true;
if (res['chat']) {
this.initializeChatCheck(res['chat']);
}
});
}
renewChat() {
this.visible_chat = [];
this.current_chat_index = this.getIndexOfNextChat();
}
downloadTwitchChat() {
this.downloading_chat = true;
let vodId = this.db_file.url.split('videos/').length > 1 && this.db_file.url.split('videos/')[1];
vodId = vodId.split('?')[0];
if (!vodId) {
this.postsService.openSnackBar('VOD url for this video is not supported. VOD ID must be after "twitch.tv/videos/"');
}
this.postsService.downloadTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', vodId, null).subscribe(res => {
if (res['chat']) {
this.initializeChatCheck(res['chat']);
} else {
this.downloading_chat = false;
this.postsService.openSnackBar('Download failed.')
}
}, err => {
this.downloading_chat = false;
this.postsService.openSnackBar('Chat could not be downloaded.')
});
}
initializeChatCheck(full_chat) {
this.full_chat = full_chat;
this.visible_chat = [];
this.chat_check_interval_obj = setInterval(() => this.addNewChatMessages(), this.CHAT_CHECK_INTERVAL_MS);
}
}
function binarySearch(arr, key, n) {
let min = 0;
let max = arr.length - 1;
let mid;
while (min <= max) {
// tslint:disable-next-line: no-bitwise
mid = (min + max) >>> 1;
if (arr[mid][key] === n) {
return mid;
} else if (arr[mid][key] < n) {
min = mid + 1;
} else {
max = mid - 1;
}
}
return min;
}

View File

@@ -1,8 +1,21 @@
<div (mouseover)="elevated=true" (mouseout)="elevated=false" style="position: relative; width: fit-content;">
<div class="download-time"><mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>&nbsp;&nbsp;{{file_obj.registered | date:'shortDate'}}</div>
<button [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<div (mouseover)="elevated=true" (mouseout)="elevated=false" (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>&nbsp;&nbsp;{{file_obj.registered | date:'shortDate' : undefined : locale.ngID}}</div>
<div *ngIf="loading" class="download-time" style="width: 75%; margin-top: 5px;"><content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" width="250" height="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 [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">
<ng-container *ngIf="!is_playlist && !loading">
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
<button (click)="navigateToSubscription()" mat-menu-item *ngIf="file_obj.sub_id"><mat-icon>{{file_obj.isAudio ? 'library_music' : 'video_library'}}</mat-icon>&nbsp;<ng-container i18n="Go to subscription menu item">Go to subscription</ng-container></button>
<mat-divider></mat-divider>
@@ -15,17 +28,20 @@
<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 blacklist video button">Delete and blacklist</ng-container></button>
</ng-container>
<ng-container *ngIf="is_playlist">
<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()" matRipple class="file-mat-card" [ngClass]="{'small-mat-card': card_size === 'small', 'file-mat-card': card_size === 'medium', 'mat-elevation-z4': !elevated, 'mat-elevation-z8': elevated}">
<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="file_obj.thumbnailURL" class="img-div">
<div *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<div style="position: relative">
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium'}" [src]="file_obj.thumbnailURL" alt="Thumbnail">
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail">
<div class="duration-time">
{{file_length}}
</div>
@@ -33,7 +49,12 @@
</div>
<span [ngClass]="{'max-two-lines': card_size !== 'small', 'max-one-line': card_size === 'small' }"><strong>{{!is_playlist ? file_obj.title : file_obj.name}}</strong></span>
<div *ngIf="loading" class="img-div">
<content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" width="100" height="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 [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" width="250" height="30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
</div>
</mat-card>
</div>

View File

@@ -1,3 +1,10 @@
.large-mat-card {
width: 300px;
height: 250px;
padding: 0px;
cursor: pointer;
}
.file-mat-card {
width: 200px;
height: 200px;
@@ -26,6 +33,12 @@
justify-content: center;
}
.image-large {
width: 300px;
height: 167.5px;
object-fit: cover;
}
.image {
width: 200px;
height: 112.5px;
@@ -90,6 +103,7 @@
background: rgba(255,255,255,0.6);
padding-left: 5px;
padding-right: 5px;
color: black;
}
.download-time {
@@ -104,6 +118,12 @@
top: 6px;
}
.title-loading {
width: 93%;
position: absolute;
bottom: 1px;
}
@media (max-width: 576px){
// .example-card {

View File

@@ -1,6 +1,22 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Component, OnInit, Input, Output, EventEmitter, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
import { DomSanitizer } from '@angular/platform-browser';
import { MatMenuTrigger } from '@angular/material/menu';
import { registerLocaleData } from '@angular/common';
import localeGB from '@angular/common/locales/en-GB';
import localeFR from '@angular/common/locales/fr';
import localeES from '@angular/common/locales/es';
import localeDE from '@angular/common/locales/de';
import localeZH from '@angular/common/locales/zh';
import localeNB from '@angular/common/locales/nb';
registerLocaleData(localeGB);
registerLocaleData(localeFR);
registerLocaleData(localeES);
registerLocaleData(localeDE);
registerLocaleData(localeZH);
registerLocaleData(localeNB);
@Component({
selector: 'app-unified-file-card',
@@ -16,16 +32,29 @@ export class UnifiedFileCardComponent implements OnInit {
type = null;
elevated = false;
// optional vars
thumbnailBlobURL = null;
// input/output
@Input() loading = true;
@Input() theme = null;
@Input() file_obj = null;
@Input() card_size = 'medium';
@Input() use_youtubedl_archive = false;
@Input() is_playlist = false;
@Input() index: number;
@Input() locale = null;
@Input() baseStreamPath = null;
@Input() jwtString = null;
@Output() goToFile = new EventEmitter<any>();
@Output() goToSubscription = new EventEmitter<any>();
@Output() deleteFile = new EventEmitter<any>();
@Output() editPlaylist = new EventEmitter<any>();
@ViewChild(MatMenuTrigger) contextMenu: MatMenuTrigger;
contextMenuPosition = { x: '0px', y: '0px' };
/*
Planned sizes:
small: 150x175
@@ -33,10 +62,20 @@ export class UnifiedFileCardComponent implements OnInit {
big: 250x200
*/
constructor(private dialog: MatDialog) { }
constructor(private dialog: MatDialog, private sanitizer: DomSanitizer) { }
ngOnInit(): void {
this.file_length = fancyTimeFormat(this.file_obj.duration);
if (!this.loading) {
this.file_length = fancyTimeFormat(this.file_obj.duration);
}
if (this.file_obj && this.file_obj.thumbnailPath) {
this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${encodeURIComponent(this.file_obj.thumbnailPath)}${this.jwtString}`;
/*const mime = getMimeByFilename(this.file_obj.thumbnailPath);
const blob = new Blob([new Uint8Array(this.file_obj.thumbnailBlob.data)], {type: mime});
const bloburl = URL.createObjectURL(blob);
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
}
}
emitDeleteFile(blacklistMode = false) {
@@ -47,8 +86,8 @@ export class UnifiedFileCardComponent implements OnInit {
});
}
navigateToFile() {
this.goToFile.emit(this.file_obj);
navigateToFile(event) {
this.goToFile.emit({file: this.file_obj, event: event});
}
navigateToSubscription() {
@@ -71,6 +110,15 @@ export class UnifiedFileCardComponent implements OnInit {
});
}
onRightClick(event) {
event.preventDefault();
this.contextMenuPosition.x = event.clientX + 'px';
this.contextMenuPosition.y = event.clientY + 'px';
this.contextMenu.menuData = { 'item': {id: 1, name: 'hi'} };
this.contextMenu.menu.focusFirstItem('mouse');
this.contextMenu.openMenu();
}
}
function fancyTimeFormat(time) {
@@ -93,3 +141,16 @@ function fancyTimeFormat(time) {
ret += '' + secs;
return ret;
}
function getMimeByFilename(name) {
switch (name.substring(name.length-4, name.length)) {
case '.jpg':
return 'image/jpeg';
case '.png':
return 'image/png';
case 'webp':
return 'image/webp';
default:
return null;
}
}

View File

@@ -40,7 +40,7 @@
<br/>
<mat-form-field placeholder="Card size">
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
<mat-option value="large" [disabled]="true">
<mat-option value="large">
Large
</mat-option>
<mat-option value="medium">

View File

@@ -6,7 +6,7 @@
</mat-dialog-content>
<mat-dialog-actions>
<!-- The mat-dialog-close directive optionally accepts a value as a result for the dialog. -->
<button color="primary" mat-flat-button type="submit" (click)="confirmClicked()">{{submitText}}</button>
<button [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>

View File

@@ -15,12 +15,14 @@ export class ConfirmDialogComponent implements OnInit {
doneEmitter: EventEmitter<any> = null;
onlyEmitOnDone = false;
warnSubmitColor = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any, public dialogRef: MatDialogRef<ConfirmDialogComponent>) {
if (this.data.dialogTitle) { this.dialogTitle = this.data.dialogTitle };
if (this.data.dialogText) { this.dialogText = this.data.dialogText };
if (this.data.submitText) { this.submitText = this.data.submitText };
if (this.data.warnSubmitColor) { this.warnSubmitColor = this.data.warnSubmitColor };
// checks if emitter exists, if so don't autoclose as it should be handled by caller
if (this.data.doneEmitter) {

View File

@@ -17,7 +17,7 @@
</ng-template>
</ngx-file-drop>
<div style="margin-top: 15px;">
<p style="font-size: 14px;" i18n="Cookies upload warning">NOTE: Uploading new cookies will overrride your previous cookies. Also note that cookies are instance-wide, not per-user.</p>
<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">

View File

@@ -0,0 +1,60 @@
<h4 mat-dialog-title><ng-container i18n="Editing category dialog title">Editing category</ng-container>&nbsp;{{category['name']}}</h4>
<mat-dialog-content style="max-height: 50vh">
<mat-form-field style="width: 250px; margin-bottom: 5px;">
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="category['name']" required>
</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>
<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>
</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;">
<input matInput [(ngModel)]="category['custom_output']" placeholder="Custom file output" i18n-placeholder="Category custom file output placeholder">
<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>
<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>

View File

@@ -0,0 +1,16 @@
.operator-select {
width: 55px;
}
.property-select {
margin-left: 10px;
width: 110px;
}
.comparator-select {
margin-left: 10px;
}
.value-input {
margin-left: 10px;
}

View File

@@ -0,0 +1,25 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EditCategoryDialogComponent } from './edit-category-dialog.component';
describe('EditCategoryDialogComponent', () => {
let component: EditCategoryDialogComponent;
let fixture: ComponentFixture<EditCategoryDialogComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ EditCategoryDialogComponent ]
})
.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(EditCategoryDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,111 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-edit-category-dialog',
templateUrl: './edit-category-dialog.component.html',
styleUrls: ['./edit-category-dialog.component.scss']
})
export class EditCategoryDialogComponent implements OnInit {
updating = false;
original_category = null;
category = null;
propertyOptions = [
{
value: 'fulltitle',
label: 'Title'
},
{
value: 'id',
label: 'ID'
},
{
value: 'webpage_url',
label: 'URL'
},
{
value: 'view_count',
label: 'Views'
},
{
value: 'uploader',
label: 'Uploader'
},
{
value: '_filename',
label: 'File Name'
},
{
value: 'tags',
label: 'Tags'
}
];
comparatorOptions = [
{
value: 'includes',
label: 'includes'
},
{
value: 'not_includes',
label: 'not includes'
},
{
value: 'equals',
label: 'equals'
},
{
value: 'not_equals',
label: 'not equals'
},
];
constructor(@Inject(MAT_DIALOG_DATA) public data: any, private postsService: PostsService) {
if (this.data) {
this.original_category = this.data.category;
this.category = JSON.parse(JSON.stringify(this.original_category));
}
}
ngOnInit(): void {
}
addNewRule() {
this.category['rules'].push({
preceding_operator: 'or',
property: 'fulltitle',
comparator: 'includes',
value: ''
});
}
saveClicked() {
this.updating = true;
this.postsService.updateCategory(this.category).subscribe(res => {
this.updating = false;
this.original_category = JSON.parse(JSON.stringify(this.category));
this.postsService.reloadCategories();
}, err => {
this.updating = false;
console.error(err);
});
}
categoryChanged() {
return JSON.stringify(this.category) === JSON.stringify(this.original_category);
}
swapRules(original_index, new_index) {
[this.category.rules[original_index], this.category.rules[new_index]] = [this.category.rules[new_index],
this.category.rules[original_index]];
}
removeRule(index) {
this.category['rules'].splice(index, 1);
}
}

View File

@@ -1,4 +1,4 @@
<h4 mat-dialog-title i18n="Edit subscription dialog title">Editing {{sub.name}}</h4>
<h4 mat-dialog-title i18n="Edit subscription dialog title prefix">Editing</h4>&nbsp;{{sub.name}}
<mat-dialog-content>
<div class="container-fluid">
@@ -24,6 +24,13 @@
<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-select placeholder="Max quality" i18n-placeholder="Max quality placeholder" [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">
<div>
<mat-checkbox [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.streamingOnly"><ng-container i18n="Streaming-only mode">Streaming-only mode</ng-container></mat-checkbox>

View File

@@ -22,6 +22,36 @@ export class EditSubscriptionDialogComponent implements OnInit {
audioOnlyMode = null;
download_all = null;
available_qualities = [
{
'label': 'Best',
'value': 'best'
},
{
'label': '4K',
'value': '2160'
},
{
'label': '1440p',
'value': '1440'
},
{
'label': '1080p',
'value': '1080'
},
{
'label': '720p',
'value': '720'
},
{
'label': '480p',
'value': '480'
},
{
'label': '360p',
'value': '360'
}
];
time_units = [
'day',
@@ -39,16 +69,12 @@ export class EditSubscriptionDialogComponent implements OnInit {
if (this.sub.timerange) {
const timerange_str = this.sub.timerange.split('-')[1];
console.log(timerange_str);
const number = timerange_str.replace(/\D/g,'');
let units = timerange_str.replace(/[0-9]/g, '');
console.log(units);
// // remove plural on units
// if (units[units.length-1] === 's') {
// units = units.substring(0, units.length-1);
// }
if (+number === 1) {
units = units.replace('s', '');
}
this.timerange_amount = parseInt(number);
this.timerange_unit = units;
@@ -71,9 +97,10 @@ export class EditSubscriptionDialogComponent implements OnInit {
}
saveSubscription() {
this.postsService.updateSubscription(this.sub).subscribe(res => {
this.postsService.updateSubscription(this.new_sub).subscribe(res => {
this.sub = this.new_sub;
this.new_sub = JSON.parse(JSON.stringify(this.sub));
this.postsService.reloadSubscriptions();
})
}
@@ -85,12 +112,16 @@ export class EditSubscriptionDialogComponent implements OnInit {
}
timerangeChanged(value, select_changed) {
console.log(this.timerange_amount);
console.log(this.timerange_unit);
if (+this.timerange_amount === 1) {
this.timerange_unit = this.timerange_unit.replace('s', '');
} else {
if (!this.timerange_unit.includes('s')) {
this.timerange_unit += 's';
}
}
if (this.timerange_amount && this.timerange_unit && !this.download_all) {
this.new_sub.timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit;
console.log(this.new_sub.timerange);
} else {
this.new_sub.timerange = null;
}

View File

@@ -14,7 +14,7 @@
</mat-button-toggle-group>
<div class="add-content-button">
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu">Add more content</button>
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add more content">Add more content</ng-container></button>
</div>
<mat-menu #menu="matMenu">
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file}}</button>
@@ -24,5 +24,5 @@
<mat-dialog-actions>
<!-- Save -->
<button [disabled]="!playlistChanged()" (click)="updatePlaylist()" [disabled]="playlistChanged()" mat-raised-button color="accent">Save</button>
<button [disabled]="!playlistChanged()" (click)="updatePlaylist()" [disabled]="playlistChanged()" mat-raised-button color="accent"><ng-container i18n="Save">Save</ng-container></button>
</mat-dialog-actions>

View File

@@ -35,6 +35,13 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-2">
<mat-form-field>
<mat-select placeholder="Max quality" i18n-placeholder="Max quality placeholder" [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>

View File

@@ -17,6 +17,8 @@ export class SubscribeDialogComponent implements OnInit {
url = null;
name = null;
maxQuality = 'best';
// state
subscribing = false;
@@ -29,12 +31,43 @@ export class SubscribeDialogComponent implements OnInit {
customFileOutput = '';
customArgs = '';
available_qualities = [
{
'label': 'Best',
'value': 'best'
},
{
'label': '4K',
'value': '2160'
},
{
'label': '1440p',
'value': '1440'
},
{
'label': '1080p',
'value': '1080'
},
{
'label': '720p',
'value': '720'
},
{
'label': '480p',
'value': '480'
},
{
'label': '360p',
'value': '360'
}
];
time_units = [
'day',
'week',
'month',
'year'
]
];
constructor(private postsService: PostsService,
private snackBar: MatSnackBar,
@@ -57,7 +90,7 @@ export class SubscribeDialogComponent implements OnInit {
if (!this.download_all) {
timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit;
}
this.postsService.createSubscription(this.url, this.name, timerange, this.streamingOnlyMode,
this.postsService.createSubscription(this.url, this.name, timerange, this.streamingOnlyMode, this.maxQuality,
this.audioOnlyMode, this.customArgs, this.customFileOutput).subscribe(res => {
this.subscribing = false;
if (res['new_sub']) {

View File

@@ -56,7 +56,7 @@ export class FileCardComponent implements OnInit {
deleteFile(blacklistMode = false) {
if (!this.playlist) {
this.postsService.deleteFile(this.uid, this.isAudio, blacklistMode).subscribe(result => {
this.postsService.deleteFile(this.uid, this.isAudio ? 'audio' : 'video', blacklistMode).subscribe(result => {
if (result) {
this.openSnackBar('Delete success!', 'OK.');
this.removeFile.emit(this.name);

View File

@@ -164,7 +164,7 @@
<br/>
<div class="centered big" id="bar_div" *ngIf="current_download && current_download.downloading; else nofile">
<div class="margined">
<div [ngClass]="(+percentDownloaded > 99)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="current_download.percent_complete && current_download.percent_complete > 15;else indeterminateprogress">
<div [ngClass]="(+percentDownloaded > 99)?'make-room-for-spinner':'equal-sizes'" style="display: inline-block; width: 100%; padding-left: 20px" *ngIf="current_download.percent_complete && current_download.percent_complete > 1;else indeterminateprogress">
<mat-progress-bar style="border-radius: 5px;" mode="determinate" value="{{percentDownloaded}}"></mat-progress-bar>
<br/>
</div>
@@ -181,10 +181,12 @@
</ng-template>
<app-recent-videos></app-recent-videos>
<br/>
<h4 style="text-align: center">Custom playlists</h4>
<app-custom-playlists></app-custom-playlists>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
<app-recent-videos #recentVideos></app-recent-videos>
<br/>
<h4 style="text-align: center">Custom playlists</h4>
<app-custom-playlists></app-custom-playlists>
</ng-container>
<!--<div style="margin: 20px" *ngIf="fileManagerEnabled && (!postsService.isLoggedIn || postsService.permissions.includes('filemanager'))">
<mat-accordion>

View File

@@ -20,6 +20,7 @@ import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.com
import { Platform } from '@angular/cdk/platform';
import { v4 as uuid } from 'uuid';
import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component';
import { RecentVideosComponent } from 'app/components/recent-videos/recent-videos.component';
export let audioFilesMouseHovering = false;
export let videoFilesMouseHovering = false;
@@ -82,8 +83,9 @@ export class MainComponent implements OnInit {
useDefaultDownloadingAgent = true;
customDownloadingAgent = null;
// formats cache
// cache
cachedAvailableFormats = {};
cachedFileManagerEnabled = localStorage.getItem('cached_filemanager_enabled') === 'true';
// youtube api
youtubeSearchEnabled = false;
@@ -199,6 +201,7 @@ export class MainComponent implements OnInit {
formats_loading = false;
@ViewChild('urlinput', { read: ElementRef }) urlInput: ElementRef;
@ViewChild('recentVideos') recentVideos: RecentVideosComponent;
@ViewChildren('audiofilecard') audioFileCards: QueryList<FileCardComponent>;
@ViewChildren('videofilecard') videoFileCards: QueryList<FileCardComponent>;
last_valid_url = '';
@@ -232,7 +235,8 @@ export class MainComponent implements OnInit {
async loadConfig() {
// loading config
this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled'];
this.fileManagerEnabled = this.postsService.config['Extra']['file_manager_enabled']
&& (!this.postsService.isLoggedIn || this.postsService.permissions.includes('filemanager'));
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
this.allowMultiDownloadMode = this.postsService.config['Extra']['allow_multi_download_mode'];
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
@@ -261,6 +265,10 @@ export class MainComponent implements OnInit {
}
// set final cache items
localStorage.setItem('cached_filemanager_enabled', this.fileManagerEnabled.toString());
this.cachedFileManagerEnabled = this.fileManagerEnabled;
if (this.allowAdvancedDownload) {
if (localStorage.getItem('customArgsEnabled') !== null) {
this.customArgsEnabled = localStorage.getItem('customArgsEnabled') === 'true';
@@ -481,6 +489,7 @@ export class MainComponent implements OnInit {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
@@ -490,6 +499,7 @@ export class MainComponent implements OnInit {
} else {
this.downloadAudioFile(decodeURI(name));
}
this.reloadRecentVideos();
} else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) {
@@ -518,6 +528,7 @@ export class MainComponent implements OnInit {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
@@ -527,6 +538,7 @@ export class MainComponent implements OnInit {
} else {
this.downloadVideoFile(decodeURI(name));
}
this.reloadRecentVideos();
} else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) {
@@ -740,7 +752,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, true).subscribe(delRes => {
this.postsService.deleteFile(name, 'video').subscribe(delRes => {
// reload mp3s
this.getMp3s();
});
@@ -757,7 +769,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => {
this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
// reload mp4s
this.getMp4s();
});
@@ -1033,8 +1045,8 @@ export class MainComponent implements OnInit {
}
} else if (format_obj.type === 'video') {
// check if video format is mp4
const key = format.height.toString();
if (format.ext === 'mp4') {
const key = format.format_note.replace('p', '');
if (format.ext === 'mp4' || format.ext === 'mkv' || format.ext === 'webm') {
format_obj['height'] = format.height;
format_obj['acodec'] = format.acodec;
format_obj['format_id'] = format.format_id;
@@ -1155,4 +1167,10 @@ export class MainComponent implements OnInit {
}
});
}
reloadRecentVideos() {
if (this.recentVideos) {
this.recentVideos.getAllFiles();
}
}
}

View File

@@ -1,35 +1,46 @@
<div *ngIf="playlist.length > 0 && show_player">
<div [ngClass]="(type === 'audio') ? null : 'container-video'">
<div style="max-width: 100%; margin-left: 0px; height: 70vh">
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'">
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</video>
</vg-player>
</div>
<div style="height: fit-content; width: 100%; margin-top: 10px;">
<mat-button-toggle-group cdkDropList [cdkDropListSortingDisabled]="!id" (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<mat-button-toggle cdkDrag *ngFor="let playlist_item of playlist; let i = index" [checked]="currentItem.title === playlist_item.title" (click)="onClickPlaylistItem(playlist_item, i)" class="toggle-button" [value]="playlist_item.title">{{playlist_item.label}}</mat-button-toggle>
</mat-button-toggle-group>
</div>
</div>
</div>
<div style="height: 100%" *ngIf="playlist.length > 0 && show_player">
<div style="height: 100%" [ngClass]="(type === 'audio') ? null : 'container-video'">
<div style="max-width: 100%; margin-left: 0px; height: 100%">
<mat-drawer-container style="height: 100%" class="example-container" autosize>
<div style="height: fit-content" [ngClass]="(type === 'audio') ? 'audio-col' : 'video-col'">
<vg-player style="height: fit-content; max-height: 75vh" (onPlayerReady)="onPlayerReady($event)" [style.background-color]="(type === 'audio') ? 'transparent' : 'black'">
<video [ngClass]="(type === 'audio') ? 'audio-styles' : 'video-styles'" #media class="video-player" [vgMedia]="media" [src]="currentItem.src" id="singleVideo" preload="auto" controls>
</video>
</vg-player>
</div>
<div style="height: fit-content; width: 100%; margin-top: 10px;">
<mat-button-toggle-group cdkDropList [cdkDropListSortingDisabled]="!id" (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<mat-button-toggle cdkDrag *ngFor="let playlist_item of playlist; let i = index" [checked]="currentItem.title === playlist_item.title" (click)="onClickPlaylistItem(playlist_item, i)" class="toggle-button" [value]="playlist_item.title">{{playlist_item.label}}</mat-button-toggle>
</mat-button-toggle-group>
</div>
<mat-drawer #drawer class="example-sidenav" mode="side" position="end" opened="false">
<ng-container *ngIf="api_ready && db_file && db_file.url.includes('twitch.tv/videos/')">
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime"></app-twitch-chat>
</ng-container>
</mat-drawer>
<div *ngIf="db_file && db_file.url.includes('twitch.tv/videos/') && postsService['config']['API']['use_twitch_API']" style="position: absolute; right: 0px">
<button style="right: 0px; top: -46px;" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
</div>
<div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button>
</div>
<div *ngIf="playlist.length > 1">
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!id" color="accent" class="favorite-button" color="primary" (click)="namePlaylistDialog()" mat-fab><mat-icon class="save-icon">favorite</mat-icon></button>
<button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div>
<div *ngIf="playlist.length === 1">
<button class="save-button" color="primary" (click)="downloadFile()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
<div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button>
</div>
<div *ngIf="playlist.length > 1">
<button class="save-button" color="primary" (click)="downloadContent()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!id" color="accent" class="favorite-button" color="primary" (click)="namePlaylistDialog()" mat-fab><mat-icon class="save-icon">favorite</mat-icon></button>
<button *ngIf="!is_shared && id && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div>
<div *ngIf="playlist.length === 1">
<button class="save-button" color="primary" (click)="downloadFile()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="50"></mat-spinner></button>
<button *ngIf="!is_shared && uid && uid !== 'false' && type !== 'subscription' && (!postsService.isLoggedIn || postsService.permissions.includes('sharing'))" class="share-button" color="primary" (click)="openShareDialog()" mat-fab><mat-icon class="save-icon">share</mat-icon></button>
</div>
</mat-drawer-container>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, HostListener, EventEmitter, OnDestroy } from '@angular/core';
import { Component, OnInit, HostListener, EventEmitter, OnDestroy, AfterViewInit, ViewChild } from '@angular/core';
import { VgAPI } from 'ngx-videogular';
import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router';
@@ -7,6 +7,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { TwitchChatComponent } from 'app/components/twitch-chat/twitch-chat.component';
export interface IMedia {
title: string;
@@ -20,7 +21,7 @@ export interface IMedia {
templateUrl: './player.component.html',
styleUrls: ['./player.component.css']
})
export class PlayerComponent implements OnInit, OnDestroy {
export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
playlist: Array<IMedia> = [];
original_playlist: string = null;
@@ -31,6 +32,7 @@ export class PlayerComponent implements OnInit, OnDestroy {
currentIndex = 0;
currentItem: IMedia = null;
api: VgAPI;
api_ready = false;
// params
fileNames: string[];
@@ -65,6 +67,8 @@ export class PlayerComponent implements OnInit, OnDestroy {
save_volume_timer = null;
original_volume = null;
@ViewChild('twitchchat') twitchChat: TwitchChatComponent;
@HostListener('window:resize', ['$event'])
onResize(event) {
this.innerWidth = window.innerWidth;
@@ -95,6 +99,10 @@ export class PlayerComponent implements OnInit, OnDestroy {
}
}
ngAfterViewInit() {
this.postsService.sidenav.close();
}
ngOnDestroy() {
// prevents volume save feature from running in the background
clearInterval(this.save_volume_timer);
@@ -120,6 +128,8 @@ export class PlayerComponent implements OnInit, OnDestroy {
this.getFile();
} else if (this.id) {
this.getPlaylistFiles();
} else if (this.subscriptionName) {
this.getSubscription();
}
if (this.url) {
@@ -135,7 +145,7 @@ export class PlayerComponent implements OnInit, OnDestroy {
this.currentItem = this.playlist[0];
this.currentIndex = 0;
this.show_player = true;
} else if (this.subscriptionName || this.fileNames) {
} else if (this.fileNames && !this.subscriptionName) {
this.show_player = true;
this.parseFileNames();
}
@@ -167,6 +177,25 @@ export class PlayerComponent implements OnInit, OnDestroy {
});
}
getSubscription() {
this.postsService.getSubscription(null, this.subscriptionName).subscribe(res => {
const subscription = res['subscription'];
if (this.fileNames) {
subscription.videos.forEach(video => {
if (video['id'] === this.fileNames[0]) {
this.db_file = video;
this.show_player = true;
this.parseFileNames();
}
});
} else {
console.log('no file name specified');
}
}, err => {
this.openSnackBar(`Failed to find subscription ${this.subscriptionName}`, 'Dismiss');
});
}
getPlaylistFiles() {
this.postsService.getPlaylist(this.id, null, this.uuid).subscribe(res => {
if (res['playlist']) {
@@ -198,23 +227,26 @@ export class PlayerComponent implements OnInit, OnDestroy {
const fileName = this.fileNames[i];
let baseLocation = null;
let fullLocation = null;
if (!this.subscriptionName) {
baseLocation = this.type + '/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName);
} else {
// default to video but include subscription name param
baseLocation = this.type === 'audio' ? 'audio/' : 'video/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist;
}
// adds user token if in multi-user-mode
const uuid_str = this.uuid ? `&uuid=${this.uuid}` : '';
const uid_str = (this.id || !this.db_file) ? '' : `&uid=${this.db_file.uid}`;
const type_str = (this.id || !this.db_file) ? '' : `&type=${this.db_file.type}`
const type_str = (this.type || !this.db_file) ? `&type=${this.type}` : `&type=${this.db_file.type}`
const id_str = this.id ? `&id=${this.id}` : '';
const file_path_str = (!this.db_file) ? '' : `&file_path=${encodeURIComponent(this.db_file.path)}`;
if (!this.subscriptionName) {
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + `?test=test${type_str}${file_path_str}`;
} else {
// default to video but include subscription name param
baseLocation = 'stream/';
fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName +
'&subPlaylist=' + this.subPlaylist + `${file_path_str}${type_str}`;
}
if (this.postsService.isLoggedIn) {
fullLocation += (this.subscriptionName ? '&' : '?') + `jwt=${this.postsService.token}`;
fullLocation += (this.subscriptionName ? '&' : '&') + `jwt=${this.postsService.token}`;
if (this.is_shared) { fullLocation += `${uuid_str}${uid_str}${type_str}${id_str}`; }
} else if (this.is_shared) {
fullLocation += (this.subscriptionName ? '&' : '?') + `test=test${uuid_str}${uid_str}${type_str}${id_str}`;
@@ -242,6 +274,13 @@ export class PlayerComponent implements OnInit, OnDestroy {
onPlayerReady(api: VgAPI) {
this.api = api;
this.api_ready = true;
this.api.subscriptions.seeked.subscribe(data => {
if (this.twitchChat) {
this.twitchChat.renewChat();
}
});
// checks if volume has been previously set. if so, use that as default
if (localStorage.getItem('player_volume')) {
@@ -313,7 +352,8 @@ export class PlayerComponent implements OnInit, OnDestroy {
const zipName = fileNames[0].split(' ')[0] + fileNames[1].split(' ')[0];
this.downloading = true;
this.postsService.downloadFileFromServer(fileNames, this.type, zipName).subscribe(res => {
this.postsService.downloadFileFromServer(fileNames, this.type, zipName, null, null, null, null,
!this.uuid ? this.postsService.user.uid : this.uuid, this.id).subscribe(res => {
this.downloading = false;
const blob: Blob = res;
saveAs(blob, zipName + '.zip');

View File

@@ -11,20 +11,20 @@ import { BehaviorSubject } from 'rxjs';
import { v4 as uuid } from 'uuid';
import { MatSnackBar } from '@angular/material/snack-bar';
import * as Fingerprint2 from 'fingerprintjs2';
import { isoLangs } from './settings/locales_list';
import { Title } from '@angular/platform-browser';
@Injectable()
export class PostsService implements CanActivate {
path = '';
audioFolder = '';
videoFolder = '';
startPath = null; // 'http://localhost:17442/';
startPathSSL = null; // 'https://localhost:17442/'
handShakeComplete = false;
// local settings
THEMES_CONFIG = THEMES_CONFIG;
theme;
card_size = 'medium';
sidepanel_mode = 'over';
settings_changed = new BehaviorSubject<boolean>(false);
// auth
auth_token = '4241b401-7236-493e-92b5-b72696b9d853';
session_id = null;
httpOptions = null;
@@ -41,20 +41,26 @@ export class PostsService implements CanActivate {
available_permissions = null;
// behavior subjects
reload_config = new BehaviorSubject<boolean>(false);
config_reloaded = new BehaviorSubject<boolean>(false);
service_initialized = new BehaviorSubject<boolean>(false);
initialized = false;
settings_changed = new BehaviorSubject<boolean>(false);
open_create_default_admin_dialog = new BehaviorSubject<boolean>(false);
// app status
initialized = false;
// global vars
config = null;
subscriptions = null;
categories = null;
sidenav = null;
locale = isoLangs['en'];
constructor(private http: HttpClient, private router: Router, @Inject(DOCUMENT) private document: Document,
public snackBar: MatSnackBar) {
public snackBar: MatSnackBar, private titleService: Title) {
console.log('PostsService Initialized...');
// this.startPath = window.location.href + '/api/';
// this.startPathSSL = window.location.href + '/api/';
this.path = this.document.location.origin + '/api/';
if (isDevMode()) {
@@ -83,6 +89,7 @@ export class PostsService implements CanActivate {
const result = !this.debugMode ? res['config_file'] : res;
if (result) {
this.config = result['YoutubeDLMaterial'];
this.titleService.setTitle(this.config['Extra']['title_top']);
if (this.config['Advanced']['multi_user_mode']) {
this.checkAdminCreationStatus();
// login stuff
@@ -112,6 +119,17 @@ export class PostsService implements CanActivate {
if (localStorage.getItem('card_size')) {
this.card_size = localStorage.getItem('card_size');
}
// localization
const locale = localStorage.getItem('locale');
if (!locale) {
localStorage.setItem('locale', 'en');
}
if (isoLangs[locale]) {
this.locale = isoLangs[locale];
}
}
canActivate(route, state): Promise<boolean> {
return new Promise(resolve => {
@@ -152,14 +170,6 @@ export class PostsService implements CanActivate {
});
}
getVideoFolder() {
return this.http.get(this.startPath + 'videofolder');
}
getAudioFolder() {
return this.http.get(this.startPath + 'audiofolder');
}
// tslint:disable-next-line: max-line-length
makeMP3(url: string, selectedQuality: string, customQualityConfiguration: string, customArgs: string = null, customOutput: string = null, youtubeUsername: string = null, youtubePassword: string = null, ui_uid = null) {
return this.http.post(this.path + 'tomp3', {url: url,
@@ -204,12 +214,8 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'setConfig', {new_config_file: config}, this.httpOptions);
}
deleteFile(uid: string, isAudio: boolean, blacklistMode = false) {
if (isAudio) {
return this.http.post(this.path + 'deleteMp3', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
} else {
return this.http.post(this.path + 'deleteMp4', {uid: uid, blacklistMode: blacklistMode}, this.httpOptions);
}
deleteFile(uid: string, type: string, blacklistMode = false) {
return this.http.post(this.path + 'deleteFile', {uid: uid, type: type, blacklistMode: blacklistMode}, this.httpOptions);
}
getMp3s() {
@@ -228,8 +234,16 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'getAllFiles', {}, this.httpOptions);
}
getFullTwitchChat(id, type, uuid = null) {
return this.http.post(this.path + 'getFullTwitchChat', {id: id, type: type, uuid: uuid}, this.httpOptions);
}
downloadTwitchChat(id, type, vodId, uuid = null) {
return this.http.post(this.path + 'downloadTwitchChatByVODID', {id: id, type: type, vodId: vodId, uuid: uuid}, this.httpOptions);
}
downloadFileFromServer(fileName, type, outputName = null, fullPathProvided = null, subscriptionName = null, subPlaylist = null,
uid = null, uuid = null) {
uid = null, uuid = null, id = null) {
return this.http.post(this.path + 'downloadFile', {fileNames: fileName,
type: type,
zip_mode: Array.isArray(fileName),
@@ -238,7 +252,8 @@ export class PostsService implements CanActivate {
subscriptionName: subscriptionName,
subPlaylist: subPlaylist,
uuid: uuid,
uid: uid
uid: uid,
id: id
},
{responseType: 'blob', params: this.httpOptions.params});
}
@@ -302,9 +317,39 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}, this.httpOptions);
}
createSubscription(url, name, timerange = null, streamingOnly = false, audioOnly = false, customArgs = null, customFileOutput = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, streamingOnly: streamingOnly,
audioOnly: audioOnly, customArgs: customArgs, customFileOutput: customFileOutput}, this.httpOptions);
// categories
getAllCategories() {
return this.http.post(this.path + 'getAllCategories', {}, this.httpOptions);
}
createCategory(name) {
return this.http.post(this.path + 'createCategory', {name: name}, this.httpOptions);
}
deleteCategory(category_uid) {
return this.http.post(this.path + 'deleteCategory', {category_uid: category_uid}, this.httpOptions);
}
updateCategory(category) {
return this.http.post(this.path + 'updateCategory', {category: category}, this.httpOptions);
}
updateCategories(categories) {
return this.http.post(this.path + 'updateCategories', {categories: categories}, this.httpOptions);
}
reloadCategories() {
this.getAllCategories().subscribe(res => {
this.categories = res['categories'];
});
}
createSubscription(url, name, timerange = null, streamingOnly = false, maxQuality = 'best', audioOnly = false,
customArgs = null, customFileOutput = null) {
return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange, maxQuality: maxQuality,
streamingOnly: streamingOnly, audioOnly: audioOnly, customArgs: customArgs,
customFileOutput: customFileOutput}, this.httpOptions);
}
updateSubscription(subscription) {
@@ -320,8 +365,8 @@ export class PostsService implements CanActivate {
file_uid: file_uid}, this.httpOptions)
}
getSubscription(id) {
return this.http.post(this.path + 'getSubscription', {id: id}, this.httpOptions);
getSubscription(id, name = null) {
return this.http.post(this.path + 'getSubscription', {id: id, name: name}, this.httpOptions);
}
getAllSubscriptions() {
@@ -384,7 +429,7 @@ export class PostsService implements CanActivate {
// user methods
login(username, password) {
const call = this.http.post(this.path + 'auth/login', {userid: username, password: password}, this.httpOptions);
const call = this.http.post(this.path + 'auth/login', {username: username, password: password}, this.httpOptions);
return call;
}
@@ -398,6 +443,8 @@ export class PostsService implements CanActivate {
}, err => {
if (err.status === 401) {
this.sendToLogin();
this.token = null;
this.resetHttpParams();
}
console.log(err);
});
@@ -414,14 +461,7 @@ export class PostsService implements CanActivate {
this.router.navigate(['/login']);
}
// resets http params
this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}`
this.httpOptions = {
params: new HttpParams({
fromString: this.http_params
}),
};
this.resetHttpParams();
}
// user methods
@@ -446,12 +486,29 @@ export class PostsService implements CanActivate {
this.openSnackBar('You must log in to access this page!');
}
resetHttpParams() {
// resets http params
this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}`
this.httpOptions = {
params: new HttpParams({
fromString: this.http_params
}),
};
}
setInitialized() {
this.service_initialized.next(true);
this.initialized = true;
this.config_reloaded.next(true);
}
reloadSubscriptions() {
this.getAllSubscriptions().subscribe(res => {
this.subscriptions = res['subscriptions'];
});
}
adminExists() {
return this.http.post(this.path + 'auth/adminExists', {}, this.httpOptions);
}

View File

@@ -122,7 +122,8 @@ export const isoLangs = {
},
'zh': {
'name': 'Chinese',
'nativeName': '中文 (Zhōngwén), 汉语, 漢語'
'nativeName': '中文 (Zhōngwén), 汉语, 漢語',
'ngID': 'zh'
},
'cv': {
'name': 'Chuvash',
@@ -162,8 +163,14 @@ export const isoLangs = {
},
'en': {
'name': 'English',
'nativeName': 'English'
'nativeName': 'English',
'ngID': 'en-US'
},
'en-GB': {
'name': 'British English',
'nativeName': 'British English',
'ngID': 'en-GB'
},
'eo': {
'name': 'Esperanto',
'nativeName': 'Esperanto'
@@ -190,7 +197,8 @@ export const isoLangs = {
},
'fr': {
'name': 'French',
'nativeName': 'français, langue française'
'nativeName': 'français',
'ngID': 'fr'
},
'ff': {
'name': 'Fula; Fulah; Pulaar; Pular',
@@ -206,7 +214,8 @@ export const isoLangs = {
},
'de': {
'name': 'German',
'nativeName': 'Deutsch'
'nativeName': 'Deutsch',
'ngID': 'de'
},
'el': {
'name': 'Greek, Modern',
@@ -438,7 +447,8 @@ export const isoLangs = {
},
'nb': {
'name': 'Norwegian Bokmål',
'nativeName': 'Norsk bokmål'
'nativeName': 'Norsk bokmål',
'ngID': 'nb'
},
'nd': {
'name': 'North Ndebele',
@@ -594,7 +604,8 @@ export const isoLangs = {
},
'es': {
'name': 'Spanish; Castilian',
'nativeName': 'español'
'nativeName': 'español',
'ngID': 'es'
},
'su': {
'name': 'Sundanese',

View File

@@ -116,19 +116,56 @@
</div>
<div class="col-12 mt-4">
<mat-form-field class="text-field" color="accent">
<input matInput [(ngModel)]="new_config['Downloader']['default_file_output']" matInput placeholder="Default file output" i18n-placeholder="Default file output placeholder">
<mat-hint><a target="_blank" href="https://github.com/ytdl-org/youtube-dl/blob/master/README.md#output-template">
<ng-container i18n="Youtube-dl output template documentation link">Documentation</ng-container></a>.
<ng-container i18n="Custom Output input hint">Path is relative to the above download paths. Don't include extension.</ng-container>
</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-4 mb-5">
<mat-form-field class="text-field" style="margin-right: 12px;" color="accent">
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Custom args" i18n-placeholder="Custom args input placeholder"></textarea>
<textarea matInput [(ngModel)]="new_config['Downloader']['custom_args']" placeholder="Global custom args" i18n-placeholder="Custom args input placeholder"></textarea>
<mat-hint><ng-container i18n="Custom args setting input hint">Global custom args for downloads on the home page. Args are delimited using two commas like so: ,,</ng-container></mat-hint>
<button class="args-edit-button" (click)="openArgsModifierDialog()" mat-icon-button><mat-icon>edit</mat-icon></button>
</mat-form-field>
</div>
<div class="col-12 mt-5">
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3 mb-2">
<h6 i18n="Categories">Categories</h6>
<div cdkDropList class="category-list" (cdkDropListDropped)="dropCategory($event)">
<div class="category-box" *ngFor="let category of postsService.categories" cdkDrag>
<div class="category-custom-placeholder" *cdkDragPlaceholder></div>
{{category['name']}}
<span style="float: right">
<button mat-icon-button (click)="openEditCategoryDialog(category)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button (click)="deleteCategory(category)"><mat-icon>cancel</mat-icon></button>
</span>
</div>
</div>
<button style="margin-top: 10px;" mat-mini-fab (click)="openAddCategoryDialog()"><mat-icon>add</mat-icon></button>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['use_youtubedl_archive']"><ng-container i18n="Use youtubedl archive setting">Use youtube-dl archive</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-2">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['safe_download_override']"><ng-container i18n="Safe download override setting">Safe download override</ng-container></mat-checkbox>
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_thumbnail']"><ng-container i18n="Include thumbnail setting">Include thumbnail</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-2">
<mat-checkbox color="accent" [(ngModel)]="new_config['Downloader']['include_metadata']"><ng-container i18n="Include metadata setting">Include metadata</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-2">
@@ -191,12 +228,24 @@
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_youtube_API']"><ng-container i18n="Use YouTube API setting">Use YouTube API</ng-container></mat-checkbox>
</div>
<div class="col-12 mb-3">
<div class="col-12 mb-2">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_youtube_API']" [(ngModel)]="new_config['API']['youtube_API_key']" matInput placeholder="Youtube API Key" i18n-placeholder="Youtube API Key setting placeholder" required>
<mat-hint><a target="_blank" href="https://developers.google.com/youtube/v3/getting-started"><ng-container i18n="Youtube API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_twitch_API']"><ng-container i18n="Use Twitch API setting">Use Twitch API</ng-container></mat-checkbox>
</div>
<div *ngIf="new_config['API']['use_twitch_API']" class="col-12 mt-1">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
</div>
<div class="col-12 mb-5">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container>&nbsp;<a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field>
</div>
</div>
</div>
<mat-divider></mat-divider>
@@ -231,11 +280,20 @@
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field>
<mat-label><ng-container i18n="Default downloader select label">Select a downloader</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['default_downloader']">
<mat-option value="youtube-dlc">youtube-dlc</mat-option>
<mat-option value="youtube-dl">youtube-dl</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-1">
<mat-checkbox color="accent" [(ngModel)]="new_config['Advanced']['use_default_downloading_agent']"><ng-container i18n="Use default downloading agent setting">Use default downloading agent</ng-container></mat-checkbox>
</div>
<div class="col-12">
<mat-form-field>
<mat-label><ng-container i18n="Custom downloader select label">Select a downloader</ng-container></mat-label>
<mat-label><ng-container i18n="Custom downloader select label">Select a download agent</ng-container></mat-label>
<mat-select [disabled]="new_config['Advanced']['use_default_downloading_agent']" color="accent" [(ngModel)]="new_config['Advanced']['custom_downloading_agent']">
<mat-option value="aria2c">aria2c</mat-option>
<mat-option value="avconv">avconv</mat-option>
@@ -247,9 +305,9 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-2 mb-1">
<div class="col-12 mt-2">
<mat-form-field>
<mat-label><ng-container i18n="Logger level select label">Select a logger level</ng-container></mat-label>
<mat-label><ng-container i18n="Log Level label">Log Level</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['logger_level']">
<mat-option value="debug">Debug</mat-option>
<mat-option value="verbose">Verbose</mat-option>
@@ -259,7 +317,7 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-2 mb-1">
<div class="col-12 mb-1">
<mat-form-field>
<mat-label><ng-container i18n="Login expiration select label">Login expiration</ng-container></mat-label>
<mat-select color="accent" [(ngModel)]="new_config['Advanced']['jwt_expiration']">
@@ -292,8 +350,50 @@
</ng-template>
</mat-tab>
<mat-tab *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode" label="Users" i18n-label="Users settings label">
<div style="margin-left: 48px; margin-top: 24px;">
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
<div style="margin-left: 48px; margin-top: 24px; margin-bottom: -25px;">
<div>
<mat-checkbox color="accent" [(ngModel)]="new_config['Users']['allow_registration']"><ng-container i18n="Allow registration setting">Allow user registration</ng-container></mat-checkbox>
</div>
<mat-divider></mat-divider>
<mat-form-field style="margin-top: 15px;">
<mat-select [(ngModel)]="new_config['Users']['auth_method']" placeholder="Auth method" i18n-placeholder="Auth method select">
<mat-option value="internal">
<ng-container i18n="Internal auth method">Internal</ng-container>
</mat-option>
<mat-option value="ldap">
<ng-container i18n="LDAP auth method">LDAP</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="new_config['Users']['auth_method'] === 'ldap'">
<div>
<mat-form-field>
<input matInput i18n-placeholder="LDAP URL" placeholder="LDAP URL" [(ngModel)]="new_config['Users']['ldap_config']['url']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Bind DN" placeholder="Bind DN" [(ngModel)]="new_config['Users']['ldap_config']['bindDN']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Bind Credentials" placeholder="Bind Credentials" [(ngModel)]="new_config['Users']['ldap_config']['bindCredentials']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Search Base" placeholder="Search Base" [(ngModel)]="new_config['Users']['ldap_config']['searchBase']">
</mat-form-field>
</div>
<div>
<mat-form-field>
<input matInput i18n-placeholder="Search Filter" placeholder="Search Filter" [(ngModel)]="new_config['Users']['ldap_config']['searchFilter']">
</mat-form-field>
</div>
</div>
<mat-divider></mat-divider>
</div>
<app-modify-users></app-modify-users>
</mat-tab>

View File

@@ -30,4 +30,55 @@
margin-left: 15px;
margin-bottom: 12px;
bottom: 4px;
}
.category-list {
width: 500px;
max-width: 100%;
border: solid 1px #ccc;
min-height: 60px;
display: block;
// background: white;
border-radius: 4px;
overflow: hidden;
}
.category-box {
padding: 20px 10px;
border-bottom: solid 1px #ccc;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
box-sizing: border-box;
cursor: move;
// background: white;
font-size: 14px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.category-box:last-child {
border: none;
}
.category-list.cdk-drop-list-dragging .category-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.category-custom-placeholder {
background: #ccc;
border: dotted 3px #999;
min-height: 60px;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}

View File

@@ -9,6 +9,9 @@ import { CURRENT_VERSION } from 'app/consts';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { CookiesUploaderDialogComponent } from 'app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { moveItemInArray, CdkDragDrop } from '@angular/cdk/drag-drop';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/edit-category-dialog.component';
@Component({
selector: 'app-settings',
@@ -17,7 +20,7 @@ import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialo
})
export class SettingsComponent implements OnInit {
all_locales = isoLangs;
supported_locales = ['en', 'es', 'de'];
supported_locales = ['en', 'es', 'de', 'fr', 'zh', 'nb', 'en-GB'];
initialLocale = localStorage.getItem('locale');
initial_config = null;
@@ -77,6 +80,74 @@ export class SettingsComponent implements OnInit {
})
}
dropCategory(event: CdkDragDrop<string[]>) {
moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex);
this.postsService.updateCategories(this.postsService.categories).subscribe(res => {
}, err => {
this.postsService.openSnackBar('Failed to update categories!');
});
}
openAddCategoryDialog() {
const done = new EventEmitter<any>();
const dialogRef = this.dialog.open(InputDialogComponent, {
width: '300px',
data: {
inputTitle: 'Name the category',
inputPlaceholder: 'Name',
submitText: 'Add',
doneEmitter: done
}
});
done.subscribe(name => {
// Eventually do additional checks on name
if (name) {
this.postsService.createCategory(name).subscribe(res => {
if (res['success']) {
this.postsService.reloadCategories();
dialogRef.close();
const new_category = res['new_category'];
this.openEditCategoryDialog(new_category);
}
});
}
});
}
deleteCategory(category) {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Delete category',
dialogText: `Would you like to delete ${category['name']}?`,
submitText: 'Delete',
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.postsService.deleteCategory(category['uid']).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar(`Successfully deleted ${category['name']}!`);
this.postsService.reloadCategories();
}
}, err => {
this.postsService.openSnackBar(`Failed to delete ${category['name']}!`);
});
}
});
}
openEditCategoryDialog(category) {
this.dialog.open(EditCategoryDialogComponent, {
data: {
category: category
}
});
}
generateAPIKey() {
this.postsService.generateNewAPIKey().subscribe(res => {
if (res['new_api_key']) {
@@ -162,7 +233,8 @@ export class SettingsComponent implements OnInit {
dialogTitle: 'Kill downloads',
dialogText: 'Are you sure you want to kill all downloads? Any subscription and non-subscription downloads will end immediately, though this operation may take a minute or so to complete.',
submitText: 'Kill all downloads',
doneEmitter: done
doneEmitter: done,
warnSubmitColor: true
}
});
done.subscribe(confirmed => {

View File

@@ -108,7 +108,7 @@ export class SubscriptionComponent implements OnInit {
} else {
this.router.navigate(['/player', {fileNames: name,
type: this.subscription.type ? this.subscription.type : 'video', subscriptionName: this.subscription.name,
subPlaylist: this.subscription.isPlaylist, uuid: this.postsService.user ? this.postsService.user.uid : null}]);
subPlaylist: this.subscription.isPlaylist}]);
}
}
@@ -164,7 +164,7 @@ export class SubscriptionComponent implements OnInit {
editSubscription() {
this.dialog.open(EditSubscriptionDialogComponent, {
data: {
sub: this.subscription
sub: this.postsService.getSubscriptionByID(this.subscription.id)
}
});
}

View File

@@ -80,6 +80,7 @@ export class SubscriptionsComponent implements OnInit {
} else {
this.channel_subscriptions.push(result);
}
this.postsService.reloadSubscriptions();
}
});
}
@@ -96,6 +97,7 @@ export class SubscriptionsComponent implements OnInit {
if (success) {
this.openSnackBar(`${sub.name} successfully deleted!`)
this.getSubscriptions();
this.postsService.reloadSubscriptions();
}
})
}

View File

@@ -7,9 +7,11 @@
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"use_youtubedl_archive": true,
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false
"safe_download_override": false,
"include_thumbnail": false,
"include_metadata": true
},
"Extra": {
"title_top": "YoutubeDL-Material",
@@ -33,12 +35,20 @@
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "30",
"subscriptions_check_interval": "300",
"subscriptions_use_youtubedl_archive": true
},
"Users": {
"base_path": "users/",
"allow_registration": true
"allow_registration": true,
"auth_method": "internal",
"ldap_config": {
"url": "ldap://localhost:389",
"bindDN": "cn=root",
"bindCredentials": "secret",
"searchBase": "ou=passport-ldapauth",
"searchFilter": "(uid={{username}})"
}
},
"Advanced": {
"use_default_downloading_agent": true,
@@ -47,7 +57,7 @@
"allow_advanced_download": true,
"jwt_expiration": 86400,
"logger_level": "debug",
"use_cookies": true
"use_cookies": false
}
}
}

View File

@@ -1,5 +1,5 @@
{
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Playlist erstellen",
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Wiedergabeliste erstellen",
"cff1428d10d59d14e45edec3c735a27b5482db59": "Name",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "Audiodateien",
"a52dae09be10ca3a65da918533ced3d3f4992238": "Videos",
@@ -35,7 +35,7 @@
"c32ef07f8803a223a83ed17024b38e8d82292407": "Passwort",
"4a0dada6e841a425de3e5006e6a04df26c644fa5": "Audio",
"9779715ac05308973d8f1c8658b29b986e92450f": "Ihre Audiodateien befinden sich hier",
"47546e45bbb476baaaad38244db444c427ddc502": "Playlisten",
"47546e45bbb476baaaad38244db444c427ddc502": "Wiedergabelisten",
"78bd81adb4609b68cfa4c589222bdc233ba1faaa": "Keine Wiedergabelisten verfügbar. Erstellen Sie eine aus Ihren heruntergeladenen Audiodateien, indem Sie auf das blaue Pluszeichen klicken.",
"9d2b62bb0b91e2e17fb4177a7e3d6756a2e6ee33": "Video",
"960582a8b9d7942716866ecfb7718309728f2916": "Ihre Videodateien befinden sich hier",
@@ -83,7 +83,7 @@
"46826331da1949bd6fb74624447057099c9d20cd": "Video Basispfad",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Dateipfad für Video-Downloads. Dieser ist relativ zum Stammordner von YTDL-Material.",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "Globale benutzerdefinierte Argumente für Downloads auf der Startseite. Argumente werden durch zwei Kommata voneinander getrennt: ,,",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Downloader",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Herunterlader",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Titel der Kopfzeile",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Dateimanager aktivieren",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "Download-Manager aktivieren",
@@ -117,14 +117,14 @@
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "Speichern",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {Schließen} false {Abbrechen} other {Andere}}",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "Über YoutubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "ist ein Open-Source YouTube-Downloader, der nach den Material-Design-Richtlinien von Google erstellt wurde. Sie können Ihre Lieblingsvideos reibungslos als Video- oder Audiodateien herunterladen und sogar Ihre Lieblingskanäle und Wiedergabelisten abonnieren, um auf dem Laufenden zu bleiben.",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "ist ein quelloffener YouTube-Downloader, der nach den Material-Design-Richtlinien von Google erstellt wurde. Sie können Ihre Lieblingsvideos reibungslos als Video- oder Audiodateien herunterladen und sogar Ihre Lieblingskanäle und Wiedergabelisten abonnieren, um auf dem Laufenden zu bleiben.",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "beinhaltet viele tolle Funktionen! API, Docker und Lokalisierung werden unter anderem unterstützt. Informieren Sie sich über alle unterstützten Funktionen auf Github.",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "Installierte Version:",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "Suche nach Updates ...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "Aktualisierung verfügbar",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "Sie können über das Einstellungsmenü aktualisieren.",
"b33536f59b94ec935a16bd6869d836895dc5300c": "Haben Sie einen Fehler gefunden oder einen Vorschlag?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "um ein Ticket zu öffnen.",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "um ein Ticket zu öffnen!",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "Ihr Profil",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID:",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "Erstellt:",
@@ -138,8 +138,8 @@
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "Über",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "Startseite",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Abonnements",
"822fab38216f64e8166d368b59fe756ca39d301b": "Downloads",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Playlist teilen",
"822fab38216f64e8166d368b59fe756ca39d301b": "Heruntergeladene",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Wiedergabeliste teilen",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "Video teilen",
"1d540dcd271b316545d070f9d182c372d923aadd": "Audio teilen",
"1f6d14a780a37a97899dc611881e6bc971268285": "Freigabe aktivieren",
@@ -152,8 +152,8 @@
"77b0c73840665945b25bd128709aa64c8f017e1c": "Download Start:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "Download Ende:",
"ad127117f9471612f47d01eae09709da444a36a4": "Dateipfad(e):",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "Abonnieren Sie eine Playlist oder einen Kanal",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "Playlist oder Kanal URL",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "Abonnieren Sie eine Wiedergabeliste oder einen Kanal",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "URL der Wiedergabeliste oder des Kanales",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "Benutzerdefinierter Name",
"f3f62aa84d59f3a8b900cc9a7eec3ef279a7b4e7": "Dies ist optional",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "Alle Uploads herunterladen",
@@ -168,18 +168,18 @@
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "Kanäle",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "Name nicht verfügbar. Kanal wird abgerufen...",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "Sie haben keine Kanäle abonniert.",
"2e0a410652cb07d069f576b61eab32586a18320d": "Name nicht verfügbar. Playlist wird abgerufen...",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "Sie haben keine Playlisten abonniert.",
"2e0a410652cb07d069f576b61eab32586a18320d": "Name nicht verfügbar. Wiedergabeliste wird abgerufen.",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "Sie haben keine Wiedergabeliste abonniert.",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "Suchen",
"2054791b822475aeaea95c0119113de3200f5e1c": "Länge:",
"94e01842dcee90531caa52e4147f70679bac87fe": "Löschen und erneut herunterladen",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Permanent löschen",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Updater",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Aktualisierungsprogramm",
"1372e61c5bd06100844bd43b98b016aabc468f62": "Wählen Sie eine Version:",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "Registrieren",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "Sitzungs-ID:",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(aktuell)",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "Zurzeit sind keine Downloads verfügbar.",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "Zurzeit sind keine Herunterladen-Ereignisse verfügbar!",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "Nutzer registrieren",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "Benutzername",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "Benutzer verwalten",
@@ -194,5 +194,32 @@
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "Rolle",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "Aktionen",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Benutzer hinzufügen",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Rolle bearbeiten"
}
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Rolle bearbeiten",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Zeilen:",
"fd59fb984749fcdb5e386ae85faec82f8e5ac098": "Logs erscheinen hier",
"98b6ec9ec138186d663e64770267b67334353d63": "Benutzerdefinierte Dateiausgabe",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "Diese werden nach den Standardargumenten hinzugefügt.",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Nur-Audio Modus",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "Protokolle",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Cookies setzen",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Cookies verwenden",
"d01715b75228878a773ae6d059acc639d4898a03": "Safe download aufheben",
"85e0725c870b28458fd3bbba905397d890f00a69": "Beachte: Neu hochgeladene Cookies überschreiben die vorherigen. Cookies sind global und gelten nicht auf einer Pro-Benutzer-Basis.",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Ziehen-und-Ablegen",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Neue Cookies hochladen",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Bearbeiten",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Wiedergabeliste bearbeiten",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "Suchfilter",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "Intern",
"db6c192032f4cab809aad35215f0aa4765761897": "Ablauf der Anmeldung",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Protokollebene",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "Dadurch wird Ihr alter API-Schlüssel gelöscht!",
"fb35145bfb84521e21b6385363d59221f436a573": "Alle Herunterladen-Ereignisse abbrechen",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "Metadaten einschließen",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "Miniaturansicht einschließen",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "HINWEIS: Durch das Hochladen neuer Cookies werden Ihre vorherigen Cookies überschrieben. Beachten Sie auch, dass Cookies instanzweit und nicht pro Benutzer sind.",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "Weitere Inhalte hinzufügen",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "Typ",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "Video",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "Audio"
}

File diff suppressed because it is too large Load Diff

View File

@@ -197,19 +197,49 @@
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": " Acciones ",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Agregar Usuarios",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Editar Rol",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Modify playlist",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Editar",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Sube nuevas cookies",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Arrastrar y soltar",
"85e0725c870b28458fd3bbba905397d890f00a69": "NOTA: Cargar nuevas cookies anulará sus cookies anteriores. También tenga en cuenta que las cookies son de toda la instancia, no por usuario.",
"d01715b75228878a773ae6d059acc639d4898a03": "Anulación de descarga segura",
"00e274c496b094a019f0679c3fab3945793f3335": "Seleccione un nivel de registrador",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Utilizar Cookies",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Establecer Cookies",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "Registros",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Solo audio",
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "Search Base",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "Search Filter",
"080cc6abcba236390fc22e79792d0d3443a3bd2a": "Bind Credentials",
"f50fa6c09c8944aed504f6325f2913ee6c7a296a": "Bind DN",
"1db9789b93069861019bd0ccaa5d4706b00afc61": "URL LDAP",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "Método de autenticación",
"e3d7c5f019e79a3235a28ba24df24f11712c7627": "LDAP",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "Interno",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Configurar Cookies",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Usar Cookies",
"db6c192032f4cab809aad35215f0aa4765761897": "Caducidad de inicio de sesión",
"00e274c496b094a019f0679c3fab3945793f3335": "Seleccione un nivel de registrador",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "¡Esto eliminará su vieja clave API!",
"fb35145bfb84521e21b6385363d59221f436a573": "Mata todas las descargas",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "Incluir metadatos",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "Incluir miniatura",
"85e0725c870b28458fd3bbba905397d890f00a69": "NOTA: La carga de cookies nuevas anulará las cookies anteriores y las cookies son para toda la instancia, no por usuario.",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Arrastrar y soltar",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Subir nuevas cookies",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Editar",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Modificar lista de reproducción",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "Tipo",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "Vídeo",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "Audio",
"d02888c485d3aeab6de628508f4a00312a722894": "Mis videos",
"a0720c36ee1057e5c54a86591b722485c62d7b1a": "Ir a suscripción",
"5656a06f17c24b2d7eae9c221567b209743829a9": "Abrir archivo en nueva pestaña",
"ccf5ea825526ac490974336cb5c24352886abc07": "Abrir archivo",
"8a0bda4c47f10b2423ff183acefbf70d4ab52ea2": "Borrar registros",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Líneas:",
"95b95a9c79e4fd9ed41f6855e37b3b06af25bcab": "Eliminar usuario",
"632e8b20c98e8eec4059a605a4b011bb476137af": "Editar usuario",
"98b6ec9ec138186d663e64770267b67334353d63": "Salida de archivo personalizado",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "Estos se agregan después de los argumentos estándar.",
"98b6ec9ec138186d663e64770267b67334353d63": "Salida de archivo personalizada",
"fd59fb984749fcdb5e386ae85faec82f8e5ac098": "Los registros aparecerán aquí",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Líneas:"
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Modo de solo audio",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "Un error ha ocurrido:",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "Un error ha ocurrido",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "La descarga era exitosa",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "Usar rol predeterminado",
"b6c453e0e61faea184bbaf5c5b0a1e164f4de2a2": "Claro todas las descargas",
"3697f8583ea42868aa269489ad366103d94aece7": "Editando",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Nivel de registro",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "NOTA: La carga de cookies nuevas anulará las cookies anteriores y las cookies son para toda la instancia, no por usuario.",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "Agregar más contenido"
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,228 @@
{
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Créer une liste de lecture",
"cff1428d10d59d14e45edec3c735a27b5482db59": "Nom",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "Fichiers audio",
"a52dae09be10ca3a65da918533ced3d3f4992238": "Vidéos",
"d9e83ac17026e70ef6e9c0f3240a3b2450367f40": "Modifier les args de téléchargement",
"7fc1946abe2b40f60059c6cd19975d677095fd19": "Nouveaux args simulés",
"0b71824ae71972f236039bed43f8d2323e8fd570": "Ajouter un arg",
"c8b0e59eb491f2ac7505f0fbab747062e6b32b23": "Rechercher par catégorie",
"9eeb91caef5a50256dd87e1c4b7b3e8216479377": "Utiliser la valeur arg",
"25d8ad5eba2ec24e68295a27d6a4bb9b49e3dacd": "Valeur arg",
"7de2451ed3fb8d8b847979bd3f0c740b970f167b": "Ajouter",
"d7b35c384aecd25a516200d6921836374613dfe7": "Annuler",
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "Modifier",
"038ebcb2a89155d90c24fa1c17bfe83dbadc3c20": "Youtube Downloader",
"a38ae1082fec79ba1f379978337385a539a28e73": "Résolution",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "Utiliser l'URL",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "Voir",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "Audio seulement",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "Activer le téléchargement simultané",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "Télécharger",
"6a3777f913cf3f288664f0632b9f24794fdcc24e": "Annuler",
"322ed150e02666fe2259c5b4614eac7066f4ffa0": "Système",
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "Prévisualisation des arguments :",
"4e4c721129466be9c3862294dc40241b64045998": "Utiliser des arguments personnalisés",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "Arguments personnalisés",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "Pas besoin d'inclure l'URL, seulement ce qui suit. Les arguments sont délimités par deux virgules comme suit ,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "Modifier le chemin de sortie",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "Sortie personnalisée",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "Documentation",
"19d1ae64d94d28a29b2c57ae8671aace906b5401": "Le chemin est relatif au chemin de téléchargement de la config. Ne pas inclure l'extension.",
"8fad10737d3e3735a6699a4d89cbf6c20f6bb55f": "S'authentifier",
"08c74dc9762957593b91f6eb5d65efdfc975bf48": "Identifiant",
"c32ef07f8803a223a83ed17024b38e8d82292407": "Mot de Passe",
"4a0dada6e841a425de3e5006e6a04df26c644fa5": "Audio",
"9779715ac05308973d8f1c8658b29b986e92450f": "Vos fichiers audio sont ici",
"47546e45bbb476baaaad38244db444c427ddc502": "Listes de lecture",
"78bd81adb4609b68cfa4c589222bdc233ba1faaa": "Aucune liste de lecture disponible. Créez-en une à l'aide du bouton \\\"+\\\" bleu de votre fichier audio.",
"9d2b62bb0b91e2e17fb4177a7e3d6756a2e6ee33": "Vídéos",
"960582a8b9d7942716866ecfb7718309728f2916": "Vos fichiers vidéos sont ici",
"0f59c46ca29e9725898093c9ea6b586730d0624e": "Aucune liste de lecture disponible. Créez-en une à l'aide du bouton \\\"+\\\" bleu de votre fichier vidéo.",
"616e206cb4f25bd5885fc35925365e43cf5fb929": "Nom :",
"c52db455cca9109ee47e1a612c3f4117c09eb71b": "URL :",
"c6eb45d085384903e53ab001a3513d1de6a1dbac": "Uploader :",
"109c6f4a5e46efb933612ededfaf52a13178b7e0": "Taille du fichier :",
"bd630d8669b16e5f264ec4649d9b469fe03e5ff4": "Chemin :",
"a67e7d843cef735c79d5ef1c8ba4af3e758912bb": "Mise en ligne :",
"f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8": "Fermer",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Modifier la liste de lecture",
"ca3dbbc7f3e011bffe32a10a3ea45cc84f30ecf1": "ID :",
"e684046d73bcee88e82f7ff01e2852789a05fc32": "Compteur :",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Editer",
"826b25211922a1b46436589233cb6f1a163d89b7": "Effacer",
"321e4419a943044e674beb55b8039f42a9761ca5": "Informations",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "Supprimer et bannir",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Téléverser de nouveaux cookies",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Glisser-déposer",
"85e0725c870b28458fd3bbba905397d890f00a69": "NOTE : le téléversement de nouveaux cookies remplacera vos cookies précédents. Notez également que les cookies sont par instance, et non par utilisateur.",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "Paramètres",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "URL",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "URL à partir de laquelle cette application sera accessible, sans le port.",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "Port",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "Port souhaité. La valeur par défault est 17442.",
"d4477669a560750d2064051a510ef4d7679e2f3e": "Activer Multi-Utilisateurs",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "Chemin d'enregistrement des utilisateurs",
"a64505c41150663968e277ec9b3ddaa5f4838798": "Chemin racine pour les utilisateurs et leurs vidéos téléchargées.",
"cbe16a57be414e84b6a68309d08fad894df797d6": "Activer le cryptage des données",
"0c1875a79b7ecc792cc1bebca3e063e40b5764f9": "Chemin du fichier de certificat",
"736551b93461d2de64b118cf4043eee1d1c2cb2c": "Chemin d'accès au fichier clé",
"4e3120311801c4acd18de7146add2ee4a4417773": "Autoriser les abonnements",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "Chemin d'enregistrement des abonnements",
"bc9892814ee2d119ae94378c905ea440a249b84a": "Chemin racine pour les vidéos des chaînes et des listes de lecture auxquelles vous êtes abonné.",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "Interval de vérification des abonnements",
"0f56a7449b77630c114615395bbda4cab398efd8": "L'unité est la seconde, écricre uniquement un nombre entier.",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "Utiliser youtube-dl archive",
"fa9fe4255231dd1cc6b29d3d254a25cb7c764f0f": "Avec youtube-dl's archive",
"09006404cccc24b7a8f8d1ce0b39f2761ab841d8": "les vidéos téléchargées à partir de vos abonnements sont enregistrées dans un fichier texte dans le sous-répertoire du fichier d'abonnement.",
"29ed79a98fc01e7f9537777598e31dbde3aa7981": "Cela vous permet de supprimer définitivement des vidéos de vos abonnements sans vous désabonner et vous permet d'enregistrer les vidéos que vous avez téléchargées en cas de perte de données.",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "Thème",
"ff7cee38a2259526c519f878e71b964f41db4348": "Default",
"adb4562d2dbd3584370e44496969d58c511ecb63": "Sombre",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "Autoriser le changement du thème",
"fe46ccaae902ce974e2441abe752399288298619": "Choix de la langue",
"82421c3e46a0453a70c42900eab51d58d79e6599": "Principal",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "Chemin du dossier audio",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "Chemin racine pour les téléchargements audio uniquement.",
"46826331da1949bd6fb74624447057099c9d20cd": "Chemin d'accès au dossier vidéo",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Chemin racine de téléchargement des vidéos.",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "Arguments personnalisés globaux pour les téléchargements sur la page d'accueil. Les arguments sont délimités par deux virgules comme suit ,,",
"d01715b75228878a773ae6d059acc639d4898a03": "Désactiver le téléchargement sécurisé",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Téléchargements",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Nom de l'application",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Gestionnaire de fichiers activé",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "Gestionnaire de téléchargement activé",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "Permettre la sélection de la résolution",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "Empêcher le téléchargement local",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "Autoriser les téléchargements simultanés",
"d8b47221b5af9e9e4cd5cb434d76fc0c91611409": "Utiliser un code PIN",
"f5ec7b2cdf87d41154f4fcbc86e856314409dcb9": "Définir le code PIN",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "Activer l'API publique",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "Clé API publique",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "Voir documentation",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "Générer",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "Utiliser l'API YouTube",
"ce10d31febb3d9d60c160750570310f303a22c22": "Clé API YouTube",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "Générer un mot de passe !",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "Cliquez ici",
"7f09776373995003161235c0c8d02b7f91dbc4df": "pour télécharger manuellement l'extension officielle YoutubeDL-Material Chrome.",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "Vous devez charger manuellement l'extension et modifier les paramètres de l'extension pour définir l'URL de l'interface.",
"9a2ec6da48771128384887525bdcac992632c863": "pour installer l'extension officielle YoutubeDL-Material Firefox directement depuis la page des extensions Firefox.",
"eb81be6b49e195e5307811d1d08a19259d411f37": "Installation détaillé ici.",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "Il suffit de modifier les paramètres de l'extension pour définir l'URL de l'interface.",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "Faites glisser le lien ci-dessous vers vos favoris, et le tour est joué ! Il suffit de naviguer vers la vidéo YouTube que vous souhaitez télécharger et de cliquer sur le signet.",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "Générer un signet audio uniquement",
"d5f69691f9f05711633128b5a3db696783266b58": "Avancés",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "Utiliser l'agent de téléchargement par défault",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "Sélectionner une méthode de téléchargement",
"00e274c496b094a019f0679c3fab3945793f3335": "Niveau des logs",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "Autoriser le téléchargement avancé",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Utiliser les Cookies",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Gérer les Cookies",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "Avancé",
"37224420db54d4bc7696f157b779a7225f03ca9d": "Autoriser l'enregistrement des utilisateurs",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "Utilisateurs",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "Journaux",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "Sauvegarder",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {Fermer} false {Annuler}}",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "Sobre YoutubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "est un téléchargeur YouTube open source créé selon le graphique \\\"Material Design\\\" de Google. Vous pouvez facilement télécharger vos vidéos préférées sous forme de fichiers vidéo ou audio, et même vous abonner à vos chaînes et listes de lecture préférées pour vous tenir au courant de vos nouvelles vidéos.",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "comporte des caractéristiques étonnantes ! Une API étendue, un support Docker et un support de localisation (traduction). Pour en savoir plus sur toutes les fonctionnalités prises en charge, cliquez sur l'icône GitHub ci-dessus.",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "Version installée :",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "Vérification des mises à jours ...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "Mise à jour disponible",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "Vous pouvez mettre à jour à partir du menu de configuration.",
"b33536f59b94ec935a16bd6869d836895dc5300c": "Avez-vous trouvé une erreur ou avez-vous une suggestion ?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "pour signaler un problème !",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "Votre profil",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID :",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "Créé le :",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "Vous n'êtes pas identifié.",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "Identifiant",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "Déconnexion",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "Créer un compte administrateur",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "Aucun compte administrateur détecté. Veuillez définir le mot de passe du compte adminstrateur \\\"admin\\\".",
"70a67e04629f6d412db0a12d51820b480788d795": "Créer",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "Profil",
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "À propos",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "Accueil",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Abonnements",
"822fab38216f64e8166d368b59fe756ca39d301b": "Téléchargements",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Partager une liste de lecture",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "Partager vidéo",
"1d540dcd271b316545d070f9d182c372d923aadd": "Partager audio",
"1f6d14a780a37a97899dc611881e6bc971268285": "Activer le partage",
"6580b6a950d952df847cb3d8e7176720a740adc8": "Utiliser l'horodatage",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "Secondes",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "Copier dans le presse-papiers",
"5b3075e8dc3f3921ec316b0bd83b6d14a06c1a4f": "Sauvegarder",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "Détails",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "Une erreur s'est produite :",
"77b0c73840665945b25bd128709aa64c8f017e1c": "Début du téléchargement :",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "Fin du téléchargement :",
"ad127117f9471612f47d01eae09709da444a36a4": "Chemin(s) de fichier :",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "S'abonner à la liste de lecture ou à la chaîne",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "L'URL de la liste de lecture ou de la chaîne",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "Nom personnalisé",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "Télécharger tous les fichiers téléversés",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "Télécharger les dernières vidéos téléversées",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Extraire le son",
"408ca4911457e84a348cecf214f02c69289aa8f1": "Streaming uniquement",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "Ceux-ci sont ajoutés après les arguments standard.",
"98b6ec9ec138186d663e64770267b67334353d63": "Sortie de fichier personnalisé",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "S'abonner",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "Type :",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "Archive :",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "Exporter l'archive",
"3042bd3ad8dffcfeca5fd1ae6159fd1047434e95": "Se désabonner",
"e2319dec5b4ccfb6ed9f55ccabd63650a8fdf547": "Vos abonnements",
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "Chaînes",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "Nom non disponible. Le rétablissement des canaux est en cours...",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "Vous n'êtes abonné à aucune châine.",
"2e0a410652cb07d069f576b61eab32586a18320d": "Ce nom n'est pas disponible. Recherche de chaînes en cours.",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "Vous n'êtes abonné·e à aucune liste de lectures.",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "Chercher",
"2054791b822475aeaea95c0119113de3200f5e1c": "Durée :",
"94e01842dcee90531caa52e4147f70679bac87fe": "Effacer et re-télécharger",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Supprimer définitivement",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Mettre à jour",
"1372e61c5bd06100844bd43b98b016aabc468f62": "Sélectionnez une version :",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "Créer un compte",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "ID de la session :",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(actual)",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "Pas de téléchargements !",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "Ajouter un utilisateur",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "Identifiant",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "Gérer l'utilisateur",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "Identifiant de l'utilisateur :",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "Nouveau mot de passe",
"6498fa1b8f563988f769654a75411bb8060134b9": "Enregistrer le nouveau mot de passe",
"40da072004086c9ec00d125165da91eaade7f541": "Utilisation par défault",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "Oui",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "Non",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "Gérer le groupe",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": "Identifiant",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "Groupe",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "Gérer",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Ajouter",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Gérer les groupes",
"fd59fb984749fcdb5e386ae85faec82f8e5ac098": "Les enregistrements apparaîtront ici",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Lignes :",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "Une erreur s'est produite",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "Le téléchargement a réussi",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "Méthode d'authentification",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "Filtre de recherche",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "Interne",
"db6c192032f4cab809aad35215f0aa4765761897": "Expiration de la connexion",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Niveau de journal",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "Ceci supprimera votre ancienne clé API !",
"fb35145bfb84521e21b6385363d59221f436a573": "Supprimer tous les téléchargements",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "Inclure les métadonnées",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "Inclure une miniature",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "REMARQUE : le téléversement de nouveaux cookies remplacera vos cookies précédents. Notez également que les cookies sont à l'échelle de l'instance et non par utilisateur.",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "Ajouter plus de contenu",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "Type",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "Vidéo",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "Audio",
"3697f8583ea42868aa269489ad366103d94aece7": "Édition"
}

View File

@@ -0,0 +1,222 @@
{
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "Opprett en spilleliste",
"cff1428d10d59d14e45edec3c735a27b5482db59": "Navn",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "Lyd",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "Video",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "Type",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "Lydfiler",
"a52dae09be10ca3a65da918533ced3d3f4992238": "Videoer",
"d9e83ac17026e70ef6e9c0f3240a3b2450367f40": "Endre youtube-dl-argumenter",
"7fc1946abe2b40f60059c6cd19975d677095fd19": "Simulerte nye argumenter",
"0b71824ae71972f236039bed43f8d2323e8fd570": "Legg til argument",
"c8b0e59eb491f2ac7505f0fbab747062e6b32b23": "Søk etter kategori",
"9eeb91caef5a50256dd87e1c4b7b3e8216479377": "Bruk argument-verdi",
"25d8ad5eba2ec24e68295a27d6a4bb9b49e3dacd": "Argument-verdi",
"7de2451ed3fb8d8b847979bd3f0c740b970f167b": "Legg til argument",
"d7b35c384aecd25a516200d6921836374613dfe7": "Avbryt",
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "Endre",
"a38ae1082fec79ba1f379978337385a539a28e73": "Kvalitet",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "Bruk nettadresse",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "Vis",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "Kun lyd",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "Multi-nedlastingsmodus",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "Last ned",
"6a3777f913cf3f288664f0632b9f24794fdcc24e": "Avbryt",
"322ed150e02666fe2259c5b4614eac7066f4ffa0": "Avansert",
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "Simulert kommando:",
"4e4c721129466be9c3862294dc40241b64045998": "Bruk egendefinerte argumenter:",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "Egendefinerte argumenter",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "Du trenger ikke å inkludere nettadressen, kun alt etter. Argumenter skilles ved bruk av to komma, slik: ,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "Bruk defendefinert utdata",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "Egendefinert utdata",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "Dokumentasjon",
"19d1ae64d94d28a29b2c57ae8671aace906b5401": "Sti er relativ til oppsettsnedlastingsstien. Ikke inkluder utvidelse.",
"8fad10737d3e3735a6699a4d89cbf6c20f6bb55f": "Bruk identitetsbekreftelse",
"08c74dc9762957593b91f6eb5d65efdfc975bf48": "Brukernavn",
"c32ef07f8803a223a83ed17024b38e8d82292407": "Passord",
"616e206cb4f25bd5885fc35925365e43cf5fb929": "Navn:",
"c52db455cca9109ee47e1a612c3f4117c09eb71b": "Nettadresse:",
"c6eb45d085384903e53ab001a3513d1de6a1dbac": "Opplaster:",
"109c6f4a5e46efb933612ededfaf52a13178b7e0": "Filstørrelse:",
"bd630d8669b16e5f264ec4649d9b469fe03e5ff4": "Sti:",
"a67e7d843cef735c79d5ef1c8ba4af3e758912bb": "Opplastingsdato:",
"f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8": "Lukk",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "Endre spilleliste",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "Legg til mer innhold",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "Lagre",
"ca3dbbc7f3e011bffe32a10a3ea45cc84f30ecf1": "ID:",
"e684046d73bcee88e82f7ff01e2852789a05fc32": "Antall:",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "Rediger",
"826b25211922a1b46436589233cb6f1a163d89b7": "Slett",
"321e4419a943044e674beb55b8039f42a9761ca5": "Info",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "Slett og svartelist",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "Last opp nye kaker",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "Dra og slipp",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "Merk: Oppasting av nye kaker overskriver tidligere. Merk deg også at kaker gjelder for hele instansen, ikke per bruker.",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "Innstillinger",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "Nettadresse",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "Nettadressen dette programmet nås fra, uten porten.",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "Port",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "Ønsket port. Forvalet er 17442.",
"d4477669a560750d2064051a510ef4d7679e2f3e": "Multi-brukermodus",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "Bruker-basissti",
"a64505c41150663968e277ec9b3ddaa5f4838798": "Basissti for brukere og deres nedlastede videoer.",
"4e3120311801c4acd18de7146add2ee4a4417773": "Tillat abonnementer",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "Abonnements-basissti",
"bc9892814ee2d119ae94378c905ea440a249b84a": "Basissti for videoer fra dine abonnementskanaler og spillelister. Den er relativ til YTDL-Material sin rotmappe.",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "Sjekkintervall",
"0f56a7449b77630c114615395bbda4cab398efd8": "I sekunder, kun tall.",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "Drakt",
"ff7cee38a2259526c519f878e71b964f41db4348": "Forvalg",
"adb4562d2dbd3584370e44496969d58c511ecb63": "Mørk",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "Tillat draktendring",
"fe46ccaae902ce974e2441abe752399288298619": "Språk",
"82421c3e46a0453a70c42900eab51d58d79e6599": "Generelt",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "Lydmappe",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "Sti for lydbaserte nedlastinger. Den er relativ til YTDL-Material sin rotmappe.",
"46826331da1949bd6fb74624447057099c9d20cd": "Videomappe",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "Sti for videonedlastinger. Den er relativ til YTDL-Material sin rotmappe.",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "Egendefinerte argumenter for nedlastninger på hjemmesiden for hele programmet. Argumenter skilles med to komma, slik: ,,",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "Bruk youtube-dl-arktivet",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "Inkluder miniatyrbilde",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "Inkluder metadata",
"fb35145bfb84521e21b6385363d59221f436a573": "Drep alle nedlastinger",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "Nedlaster",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "Topptittel",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "Filbehandler påskrudd",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "Nedlastingsbehandler påskrudd",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "Tillat kvalitetsvalg",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "Modus kun for nedlasting",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "Tillat multi-nedlastingsmodus",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "Skru på offentlig API",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "Offentlig API-nøkkel",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "Vis dokumentasjon",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "Generer",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "Dette vil slette din gamle API-nøkkel!",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "Bruk YouTube-API",
"ce10d31febb3d9d60c160750570310f303a22c22": "YouTube-API-nøkkel",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "Å generere en nøkkel er lett!",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "Klikk her",
"7f09776373995003161235c0c8d02b7f91dbc4df": "for å laste ned den offisielle Chrome-utvidelsen for YouTubeDL-Material selv.",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "Du må manuelt laste ned utvidelsen og endre dens innstillinger for å sette skjermflate-nettadresse.",
"9a2ec6da48771128384887525bdcac992632c863": "for å installere den offisielle Firefox-utvidelsen for YouTubeDL-Material rett fra Firefox sin utvidelsesside.",
"eb81be6b49e195e5307811d1d08a19259d411f37": "Detaljert oppsettsinstruks",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "Ikke mye kreves annet enn å endre utvidelses innstillinger for å sette skjermflate-nettadresse.",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "Dra lenken nedenfor til bokmerker. Naviger til YouTube-videoen du ønsker å laste ned og klikk på bokmerket.",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "Genrer \"kun lyd\"-bookmerke",
"d5f69691f9f05711633128b5a3db696783266b58": "Ekstra",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "Bruk forvalgt nedlastingsagent",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "Velg en nedlaster",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "Loggingsnivå",
"db6c192032f4cab809aad35215f0aa4765761897": "Innloggingsutløp",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "Tillat avansert nedlasting",
"431e5f3a0dde88768d1074baedd65266412b3f02": "Bruk kaker",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "Sett kaker",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "Avansert",
"37224420db54d4bc7696f157b779a7225f03ca9d": "Tillat brukerregistrering",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "Intern",
"e3d7c5f019e79a3235a28ba24df24f11712c7627": "LDAP",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "Autentiseringsmetode",
"1db9789b93069861019bd0ccaa5d4706b00afc61": "LDAP-nettadresse",
"f50fa6c09c8944aed504f6325f2913ee6c7a296a": "BIND-DN",
"080cc6abcba236390fc22e79792d0d3443a3bd2a": "BIND-identitetsdetaljer",
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "Søkebase",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "Søkefilter",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "Brukere",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "Logger",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {Close} false {Cancel} other {otha} }",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "Om YouTubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "er en fri YouTube-nedlaster bygd i henhold til Google sine Materielle spesifikasjoner. Du kan sømløst laste ned dine favorittvideoer som video- eller lydfiler, og tilogmed abonnere på dine favorittkanaler og spillelister for å holde deg oppdatert med nye videoer.",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "har noen flotte funksjoner inkludert. Et vidtfavnende API, Docker-støtte, og lokalisering (oversettelser)-støtte. Les om alle støttede funksjoner ved å klikke på GitHub-ikonet ovenfor.",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "Installert versjon:",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "Ser etter oppdateringer …",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "Oppdatering tilgjengelig",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "Du kan oppdatere fra innstillingsmenyen.",
"b33536f59b94ec935a16bd6869d836895dc5300c": "Funnet en feil eller har et forslag å komme med?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "for å opprette en feilrapport.",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "Din profil",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID:",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "Opprettet:",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "Du er ikke innlogget.",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "Logg inn",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "Logg ut",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "Opprett administratorkonto",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "Fant ingen administratorkonto. Dette vil opprette og sette passord for en slik konto med brukernavn som «admin».",
"70a67e04629f6d412db0a12d51820b480788d795": "Opprett",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "Profil",
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "Om",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "Hjem",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "Abonnementer",
"822fab38216f64e8166d368b59fe756ca39d301b": "Nedlastinger",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "Del spilleliste",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "Del video",
"1d540dcd271b316545d070f9d182c372d923aadd": "Del lyd",
"1f6d14a780a37a97899dc611881e6bc971268285": "Skru på deling",
"6580b6a950d952df847cb3d8e7176720a740adc8": "Bruk tidsstempel",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "Sekunder",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "Kopier til utklippstavle",
"5b3075e8dc3f3921ec316b0bd83b6d14a06c1a4f": "Lagre endringer",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "Nedlastet",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "En feil inntraff",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "Detaljer",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "En feil har inntruffet:",
"77b0c73840665945b25bd128709aa64c8f017e1c": "Nedlastingsstart:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "Nedlastingsslutt:",
"ad127117f9471612f47d01eae09709da444a36a4": "Filsti(er):",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "Abonner på en spilleliste eller kanal",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "Spilleliste- eller kanal-nettadressen",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "Egendefinert navn",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "Last ned alle opplastinger",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "Last ned videoer oppdatert siste",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "Kun lyd-modus",
"408ca4911457e84a348cecf214f02c69289aa8f1": "Kun strømming-modus",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "Disse legges til etter standard-argumentene.",
"98b6ec9ec138186d663e64770267b67334353d63": "Egendefinert filutdata",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "Abonner",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "Type:",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "Arkiv:",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "Eksporter arkiv",
"3042bd3ad8dffcfeca5fd1ae6159fd1047434e95": "Opphev abonnement",
"e2319dec5b4ccfb6ed9f55ccabd63650a8fdf547": "Dine abonnementer",
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "Kanaler",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "Navn ikke tilgjengelig. Henter kanal …",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "Du har ingen kanalabonnementer.",
"47546e45bbb476baaaad38244db444c427ddc502": "Spillelister",
"2e0a410652cb07d069f576b61eab32586a18320d": "Navn ikke tilgjengelig. Henter spilleliste …",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "Du har ingen spillelisteabonnement.",
"3697f8583ea42868aa269489ad366103d94aece7": "Redigering",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "Søk",
"2054791b822475aeaea95c0119113de3200f5e1c": "Lengde:",
"94e01842dcee90531caa52e4147f70679bac87fe": "Slett og last ned igjen",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "Slett for alltid",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "Oppdaterer",
"1372e61c5bd06100844bd43b98b016aabc468f62": "Velg en versjon:",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "Regustrer",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "Økt-ID:",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(nåværende)",
"b6c453e0e61faea184bbaf5c5b0a1e164f4de2a2": "Tøm alle nedlastinger",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "Ingen nedlastninger tilgjengelige!",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "Registrer en bruker",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "Brukernavn",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "Håndter bruker",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "Bruker-UID:",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "Nytt passord",
"6498fa1b8f563988f769654a75411bb8060134b9": "Sett nytt passord",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "Bruk rolle-forvalg",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "Ja",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "Nei",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "Håndter rolle",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": "Brukernavn",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "Rolle",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "Handlinger",
"632e8b20c98e8eec4059a605a4b011bb476137af": "Rediger bruker",
"95b95a9c79e4fd9ed41f6855e37b3b06af25bcab": "Slett bruker",
"4d92a0395dd66778a931460118626c5794a3fc7a": "Legg til brukere",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "Rediger rolle",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "Linjer:",
"8a0bda4c47f10b2423ff183acefbf70d4ab52ea2": "Tøm logger",
"ccf5ea825526ac490974336cb5c24352886abc07": "Åpne fil",
"5656a06f17c24b2d7eae9c221567b209743829a9": "Åpne fil i ny fane",
"a0720c36ee1057e5c54a86591b722485c62d7b1a": "Gå til abonnement",
"d02888c485d3aeab6de628508f4a00312a722894": "Mine videoer"
}

View File

@@ -0,0 +1,242 @@
{
"17f0ea5d2d7a262b0e875acc70475f102aee84e6": "创建播放列表",
"cff1428d10d59d14e45edec3c735a27b5482db59": "名称",
"f47e2d56dd8a145b2e9599da9730c049d52962a2": "音频文件",
"a52dae09be10ca3a65da918533ced3d3f4992238": "视频文件",
"d9e83ac17026e70ef6e9c0f3240a3b2450367f40": "修改youtube-dl参数",
"7fc1946abe2b40f60059c6cd19975d677095fd19": "模拟新参数",
"0b71824ae71972f236039bed43f8d2323e8fd570": "添加参数",
"c8b0e59eb491f2ac7505f0fbab747062e6b32b23": "按类别搜索",
"9eeb91caef5a50256dd87e1c4b7b3e8216479377": "使用参数值",
"25d8ad5eba2ec24e68295a27d6a4bb9b49e3dacd": "参数值",
"7de2451ed3fb8d8b847979bd3f0c740b970f167b": "添加参数",
"d7b35c384aecd25a516200d6921836374613dfe7": "取消",
"b2623aee44b70c9a4ba1fce16c8a593b0a4c7974": "修改",
"038ebcb2a89155d90c24fa1c17bfe83dbadc3c20": "Youtube下载器",
"a38ae1082fec79ba1f379978337385a539a28e73": "质量",
"4be966a9dcfbc9b54dfcc604b831c0289f847fa4": "使用URL",
"d3f02f845e62cebd75fde451ab8479d2a8ad784d": "查看",
"4a9889d36910edc8323d7bab60858ab3da6d91df": "仅音频",
"96a01fafe135afc58b0f8071a4ab00234495ce18": "多下载模式",
"6a21ba5fb0ac804a525bf9ab168038c3ee88e661": "下载",
"6a3777f913cf3f288664f0632b9f24794fdcc24e": "取消",
"322ed150e02666fe2259c5b4614eac7066f4ffa0": "高级",
"b7ffe7c6586d6f3f18a9246806a7c7d5538ab43e": "模拟命令:",
"4e4c721129466be9c3862294dc40241b64045998": "使用自定义参数",
"ad2f8ac8b7de7945b80c8e424484da94e597125f": "自定义参数",
"a6911c2157f1b775284bbe9654ce5eb30cf45d7f": "不必指定URL仅需指定其后的部分。参数用两个逗号分隔,,",
"3a92a3443c65a52f37ca7efb8f453b35dbefbf29": "使用自定义输出",
"d9c02face477f2f9cdaae318ccee5f89856851fb": "自定义输出",
"fcfd4675b4c90f08d18d3abede9a9a4dff4cfdc7": "文档",
"19d1ae64d94d28a29b2c57ae8671aace906b5401": "该路径是相对于配置下载路径的,省略文件扩展名",
"8fad10737d3e3735a6699a4d89cbf6c20f6bb55f": "使用身份验证",
"08c74dc9762957593b91f6eb5d65efdfc975bf48": "用户名",
"c32ef07f8803a223a83ed17024b38e8d82292407": "密码",
"4a0dada6e841a425de3e5006e6a04df26c644fa5": "音频",
"9779715ac05308973d8f1c8658b29b986e92450f": "您的音频文件在这里",
"47546e45bbb476baaaad38244db444c427ddc502": "播放列表",
"78bd81adb4609b68cfa4c589222bdc233ba1faaa": "没有可用的播放列表。 通过单击蓝色加号按钮从您下载的音频文件创建一个。",
"9d2b62bb0b91e2e17fb4177a7e3d6756a2e6ee33": "视频",
"960582a8b9d7942716866ecfb7718309728f2916": "您的视频文件在这里",
"0f59c46ca29e9725898093c9ea6b586730d0624e": "没有可用的播放列表。 通过单击蓝色加号按钮,从下载的视频文件中创建一个。",
"616e206cb4f25bd5885fc35925365e43cf5fb929": "名称:",
"c52db455cca9109ee47e1a612c3f4117c09eb71b": "URL",
"c6eb45d085384903e53ab001a3513d1de6a1dbac": "上传者:",
"109c6f4a5e46efb933612ededfaf52a13178b7e0": "文件大小:",
"bd630d8669b16e5f264ec4649d9b469fe03e5ff4": "路径:",
"a67e7d843cef735c79d5ef1c8ba4af3e758912bb": "上传日期:",
"f4e529ae5ffd73001d1ff4bbdeeb0a72e342e5c8": "关闭",
"4f389e41e4592f7f9bb76abdd8af4afdfb13f4f1": "修改播放列表",
"ca3dbbc7f3e011bffe32a10a3ea45cc84f30ecf1": "ID",
"e684046d73bcee88e82f7ff01e2852789a05fc32": "数量:",
"28f86ffd419b869711aa13f5e5ff54be6d70731c": "编辑",
"826b25211922a1b46436589233cb6f1a163d89b7": "删除",
"321e4419a943044e674beb55b8039f42a9761ca5": "详情",
"34504b488c24c27e68089be549f0eeae6ebaf30b": "删除并拉黑",
"ebadf946ae90f13ecd0c70f09edbc0f983af8a0f": "上传新Cookies",
"98a8a42e5efffe17ab786636ed0139b4c7032d0e": "拖放",
"85e0725c870b28458fd3bbba905397d890f00a69": "注意加载新的Cookies将覆盖您以前的Cookie。并且Cookies的范围是整个实例而不是每个用户单独分开的。",
"121cc5391cd2a5115bc2b3160379ee5b36cd7716": "设置",
"801b98c6f02fe3b32f6afa3ee854c99ed83474e6": "URL",
"54c512cca1923ab72faf1a0bd98d3d172469629a": "设置访问URL无需端口。",
"cb2741a46e3560f6bc6dfd99d385e86b08b26d72": "端口",
"22e8f1d0423a3b784fe40fab187b92c06541b577": "设置目标端口。默认为17442。",
"d4477669a560750d2064051a510ef4d7679e2f3e": "多用户模式",
"2eb03565fcdce7a7a67abc277a936a32fcf51557": "用户文件路径",
"a64505c41150663968e277ec9b3ddaa5f4838798": "用户及其下载视频的文件路径。",
"cbe16a57be414e84b6a68309d08fad894df797d6": "使用加密SSL",
"0c1875a79b7ecc792cc1bebca3e063e40b5764f9": "证书文件路径",
"736551b93461d2de64b118cf4043eee1d1c2cb2c": "密钥文件路径",
"4e3120311801c4acd18de7146add2ee4a4417773": "允许订阅",
"4bee2a4bef2d26d37c9b353c278e24e5cd309ce3": "订阅文件路径",
"bc9892814ee2d119ae94378c905ea440a249b84a": "订阅频道和播放列表中视频的文件路径(相对于根文件夹而言)。",
"5bef4b25ba680da7fff06b86a91b1fc7e6a926e3": "检查间隔",
"0f56a7449b77630c114615395bbda4cab398efd8": "单位是秒,只包含数字。",
"78e49b7339b4fa7184dd21bcaae107ce9b7076f6": "使用youtube-dl存档",
"fa9fe4255231dd1cc6b29d3d254a25cb7c764f0f": "根据youtube-dl的存档功能",
"09006404cccc24b7a8f8d1ce0b39f2761ab841d8": "从您的订阅下载的视频会记录在订阅存档子目录中的文本文件中。",
"29ed79a98fc01e7f9537777598e31dbde3aa7981": "这样一来,您无需取消订阅便可以从订阅中永久删除视频。并且它还可以在数据丢失的情况下记录已经下载了哪些视频。",
"27a56aad79d8b61269ed303f11664cc78bcc2522": "主题",
"ff7cee38a2259526c519f878e71b964f41db4348": "默认",
"adb4562d2dbd3584370e44496969d58c511ecb63": "暗黑",
"7a6bacee4c31cb5c0ac2d24274fb4610d8858602": "允许更改主题",
"fe46ccaae902ce974e2441abe752399288298619": "语言",
"82421c3e46a0453a70c42900eab51d58d79e6599": "常规",
"ab2756805742e84ad0cc0468f4be2d8aa9f855a5": "音频文件夹路径",
"c2c89cdf45d46ea64d2ed2f9ac15dfa4d77e26ca": "音频下载的文件路径。相对于YTDL-Material的根文件夹。",
"46826331da1949bd6fb74624447057099c9d20cd": "视频文件夹路径",
"17c92e6d47a213fa95b5aa344b3f258147123f93": "视频下载的文件路径。相对于YTDL-Material的根文件夹。",
"6b995e7130b4d667eaab6c5f61b362ace486d26d": "开始页面上用于下载的全局自定义参数。参数由两个逗号分隔:,,",
"d01715b75228878a773ae6d059acc639d4898a03": "安全下载覆盖",
"0ba25ad86a240576c4f20a2fada4722ebba77b1e": "下载程序",
"61f8fd90b5f8cb20c70371feb2ee5e1fac5a9095": "首页标题",
"78d3531417c0d4ba4c90f0d4ae741edc261ec8df": "启用文件管理",
"a5a1be0a5df07de9eec57f5d2a86ed0204b2e75a": "启用下载管理",
"c33bd5392b39dbed36b8e5a1145163a15d45835f": "允许选择下载质量",
"bda5508e24e0d77debb28bcd9194d8fefb1cfb92": "仅下载模式",
"09d31c803a7252658694e1e3176b97f5655a3fe3": "开启多下载模式",
"d8b47221b5af9e9e4cd5cb434d76fc0c91611409": "使用PIN码保护设置",
"f5ec7b2cdf87d41154f4fcbc86e856314409dcb9": "设置新PIN码",
"1c4dbce56d96b8974aac24a02f7ab2ee81415014": "启用公共API",
"23bd81dcc30b74d06279a26d7a42e8901c1b124e": "公共API密钥",
"41016a73d8ad85e6cb26dffa0a8fab9fe8f60d8e": "查看文档",
"1b258b258b4cc475ceb2871305b61756b0134f4a": "生成",
"d5d7c61349f3b0859336066e6d453fc35d334fe5": "使用YouTube API",
"ce10d31febb3d9d60c160750570310f303a22c22": "Youtube API密钥",
"8602e313cdfa7c4cc475ccbe86459fce3c3fd986": "生成密钥很简单!",
"9b3cedfa83c6d7acb3210953289d1be4aab115c7": "点击这里",
"7f09776373995003161235c0c8d02b7f91dbc4df": "来手动下载官方的YoutubeDL-Material Chrome扩展程序。",
"5b5296423906ab3371fdb2b5a5aaa83acaa2ee52": "您必须手动安装扩展并且在扩展的设置中输入下载器URL。",
"9a2ec6da48771128384887525bdcac992632c863": "直接从Firefox扩展商店安装官方的YoutubeDL-Material Firefox扩展程序。",
"eb81be6b49e195e5307811d1d08a19259d411f37": "详细的扩展说明。",
"cb17ff8fe3961cf90f44bee97c88a3f3347a7e55": "只需在扩展的设置中输入前端URL。",
"61b81b11aad0b9d970ece2fce18405f07eac69c2": "只需将下面的链接拖放到书签栏中。在YouTube页面上您只需单击书签即可下载视频。",
"c505d6c5de63cc700f0aaf8a4b31fae9e18024e5": "生成“仅音频”书签",
"d5f69691f9f05711633128b5a3db696783266b58": "额外",
"5fab47f146b0a4b809dcebf3db9da94df6299ea1": "使用默认下载代理",
"ec71e08aee647ea4a71fd6b7510c54d84a797ca6": "选择下载器",
"00e274c496b094a019f0679c3fab3945793f3335": "选择日志级别",
"dc3d990391c944d1fbfc7cfb402f7b5e112fb3a8": "开启高级下载选项",
"431e5f3a0dde88768d1074baedd65266412b3f02": "使用Cookies",
"80651a7ad1229ea6613557d3559f702cfa5aecf5": "设置Cookies",
"bc2e854e111ecf2bd7db170da5e3c2ed08181d88": "高级",
"37224420db54d4bc7696f157b779a7225f03ca9d": "允许用户注册",
"4d13a9cd5ed3dcee0eab22cb25198d43886942be": "用户",
"eb3d5aefff38a814b76da74371cbf02c0789a1ef": "日志",
"52c9a103b812f258bcddc3d90a6e3f46871d25fe": "保存",
"fe8fd36dbf5deee1d56564965787a782a66eba44": "{VAR_SELECT, select, true {关} false {取消} }",
"cec82c0a545f37420d55a9b6c45c20546e82f94e": "关于 YoutubeDL-Material",
"199c17e5d6a419313af3c325f06dcbb9645ca618": "是根据Google的Material Design规范构建的开源YouTube下载器。您可以将喜欢的视频下载为视频或音频文件并且可以订阅喜欢的频道和播放列表以便及时下载他们的新视频。",
"bc0ad0ee6630acb7fcb7802ec79f5a0ee943c1a7": "包含很多很棒的功能支持APIDocker和本地化。在Github上查找所有受支持的功能。",
"a45e3b05f0529dc5246d70ef62304c94426d4c81": "安装版本:",
"e22f3a5351944f3a1a10cfc7da6f65dfbe0037fe": "检查更新...",
"a16e92385b4fd9677bb830a4b796b8b79c113290": "更新可用",
"189b28aaa19b3c51c6111ad039c4fd5e2a22e370": "您可以从设置菜单进行更新。",
"b33536f59b94ec935a16bd6869d836895dc5300c": "发现了一个错误或有一些建议?",
"e1f398f38ff1534303d4bb80bd6cece245f24016": "创建新issue",
"42ff677ec14f111e88bd6cdd30145378e994d1bf": "您的个人资料",
"ac9d09de42edca1296371e4d801349c9096ac8de": "UID",
"a5ed099ffc9e96f6970df843289ade8a7d20ab9f": "创建日期:",
"fa96f2137af0a24e6d6d54c598c0af7d5d5ad344": "您尚未登录。",
"6765b4c916060f6bc42d9bb69e80377dbcb5e4e9": "登录",
"bb694b49d408265c91c62799c2b3a7e3151c824d": "注销",
"a1dbca87b9f36d2b06a5cbcffb5814c4ae9b798a": "创建管理员帐户",
"2d2adf3ca26a676bca2269295b7455a26fd26980": "未检测到默认管理员帐户。即将创建一个名为admin的管理员帐户并设置密码。",
"70a67e04629f6d412db0a12d51820b480788d795": "创建",
"994363f08f9fbfa3b3994ff7b35c6904fdff18d8": "个人资料",
"004b222ff9ef9dd4771b777950ca1d0e4cd4348a": "关于",
"92eee6be6de0b11c924e3ab27db30257159c0a7c": "首 页",
"357064ca9d9ac859eb618e28e8126fa32be049e2": "订 阅",
"822fab38216f64e8166d368b59fe756ca39d301b": "下 载",
"a249a5ae13e0835383885aaf697d2890cc3e53e9": "分享播放列表",
"15da89490e04496ca9ea1e1b3d44fb5efd4a75d9": "分享视频",
"1d540dcd271b316545d070f9d182c372d923aadd": "分享音频",
"1f6d14a780a37a97899dc611881e6bc971268285": "启用共享",
"6580b6a950d952df847cb3d8e7176720a740adc8": "使用时间戳",
"4f2ed9e71a7c981db3e50ae2fedb28aff2ec4e6c": "秒",
"3a6e5a6aa78ca864f6542410c5dafb6334538106": "复制到剪贴板",
"5b3075e8dc3f3921ec316b0bd83b6d14a06c1a4f": "保存更改",
"4f8b2bb476981727ab34ed40fde1218361f92c45": "详细",
"e9aff8e6df2e2bf6299ea27bb2894c70bc48bd4d": "发生错误:",
"77b0c73840665945b25bd128709aa64c8f017e1c": "下载开始:",
"08ff9375ec078065bcdd7637b7ea65fce2979266": "下载结束:",
"ad127117f9471612f47d01eae09709da444a36a4": "文件路径:",
"a9806cf78ce00eb2613eeca11354a97e033377b8": "订阅播放列表或频道",
"93efc99ae087fc116de708ecd3ace86ca237cf30": "播放列表或频道URL",
"08f5d0ef937ae17feb1b04aff15ad88911e87baf": "自定义名称",
"ea30873bd3f0d5e4fb2378eec3f0a1db77634a28": "下载所有音视频",
"28a678e9cabf86e44c32594c43fa0e890135c20f": "下载最近多久的视频",
"c76a955642714b8949ff3e4b4990864a2e2cac95": "仅音频模式",
"408ca4911457e84a348cecf214f02c69289aa8f1": "仅视频模式",
"f432e1a8d6adb12e612127978ce2e0ced933959c": "这些是在标准参数之后添加的。",
"98b6ec9ec138186d663e64770267b67334353d63": "自定义文件输出",
"d0336848b0c375a1c25ba369b3481ee383217a4f": "订阅",
"e78c0d60ac39787f62c9159646fe0b3c1ed55a1d": "类型:",
"a44d86aa1e6c20ced07aca3a7c081d8db9ded1c6": "存档:",
"8efc77bf327659c0fec1f518cf48a98cdcd9dddf": "导出存档",
"3042bd3ad8dffcfeca5fd1ae6159fd1047434e95": "取消订阅",
"e2319dec5b4ccfb6ed9f55ccabd63650a8fdf547": "您的订阅",
"807cf11e6ac1cde912496f764c176bdfdd6b7e19": "频道",
"29b89f751593e1b347eef103891b7a1ff36ec03f": "名称不可用。正在检索频道...",
"4636cd4a1379c50d471e98786098c4d39e1e82ad": "您尚未订阅任何频道。",
"2e0a410652cb07d069f576b61eab32586a18320d": "名称不可用。正在检索播放列表...",
"587b57ced54965d8874c3fd0e9dfedb987e5df04": "您尚未订阅任何播放列表。",
"7e892ba15f2c6c17e83510e273b3e10fc32ea016": "搜索",
"2054791b822475aeaea95c0119113de3200f5e1c": "长度:",
"94e01842dcee90531caa52e4147f70679bac87fe": "删除并重新下载",
"2031adb51e07a41844e8ba7704b054e98345c9c1": "永久删除",
"91ecce65f1d23f9419d1c953cd6b7bc7f91c110e": "更新程序",
"1372e61c5bd06100844bd43b98b016aabc468f62": "选择版本:",
"cfc2f436ec2beffb042e7511a73c89c372e86a6c": "注册",
"a1ad8b1be9be43b5183bd2c3186d4e19496f2a0b": "会话ID:",
"eb98135e35af26a9a326ee69bd8ff104d36dd8ec": "(当前)",
"7117fc42f860e86d983bfccfcf2654e5750f3406": "没有下载可用!",
"b7ff2e2b909c53abe088fe60b9f4b6ac7757247f": "注册用户",
"024886ca34a6f309e3e51c2ed849320592c3faaa": "用户名",
"2bd201aea09e43fbfd3cd15ec0499b6755302329": "管理用户",
"29c97c8e76763bb15b6d515648fa5bd1eb0f7510": "用户UID:",
"e70e209561583f360b1e9cefd2cbb1fe434b6229": "新密码",
"6498fa1b8f563988f769654a75411bb8060134b9": "设置新密码",
"40da072004086c9ec00d125165da91eaade7f541": "使用默认值",
"4f20f2d5a6882190892e58b85f6ccbedfa737952": "是",
"3d3ae7deebc5949b0c1c78b9847886a94321d9fd": "否",
"57c6c05d8ebf4ef1180c2705033c044f655bb2c4": "管理用户",
"746f64ddd9001ac456327cd9a3d5152203a4b93c": "用户名",
"52c1447c1ec9570a2a3025c7e566557b8d19ed92": "身份",
"59a8c38db3091a63ac1cb9590188dc3a972acfb3": "动作",
"4d92a0395dd66778a931460118626c5794a3fc7a": "添加用户",
"b0d7dd8a1b0349622d6e0c6e643e24a9ea0efa1d": "编辑用户",
"fd59fb984749fcdb5e386ae85faec82f8e5ac098": "日志将出现在这里",
"5009630cdf32ab4f1c78737b9617b8773512c05a": "行:",
"ffc19f32b1cba0daefc0e5668f89346db1db83ad": "包括缩略图",
"f61c6867295f3b53d23557021f2f4e0aa1d0b8fc": "类型",
"2d1ea268a6a9f483dbc2cbfe19bf4256a57a6af4": "视频",
"f0baeb8b69d120073b6d60d34785889b0c3232c8": "音频",
"ccf5ea825526ac490974336cb5c24352886abc07": "打开文件",
"8a0bda4c47f10b2423ff183acefbf70d4ab52ea2": "清空日志",
"95b95a9c79e4fd9ed41f6855e37b3b06af25bcab": "删除用户",
"632e8b20c98e8eec4059a605a4b011bb476137af": "编辑用户",
"b6c453e0e61faea184bbaf5c5b0a1e164f4de2a2": "清空所有下载",
"080cc6abcba236390fc22e79792d0d3443a3bd2a": "绑定凭证",
"f50fa6c09c8944aed504f6325f2913ee6c7a296a": "绑定DN",
"1db9789b93069861019bd0ccaa5d4706b00afc61": "LDAP链接",
"fa548cee6ea11c160a416cac3e6bdec0363883dc": "认证方式",
"e3d7c5f019e79a3235a28ba24df24f11712c7627": "LDAP认证",
"4f56ced9d6b85aeb1d4346433361d47ea72dac1a": "内部身份验证",
"db6c192032f4cab809aad35215f0aa4765761897": "登录到期",
"0c43af932e6a4ee85500e28f01b3538b4eb27bc4": "日志等级",
"00a94f58d9eb2e3aa561440eabea616d0c937fa2": "这将删除您的旧API密钥",
"384de8f8f112c9e6092eb2698706d391553f3e8d": "包含元数据",
"a8b7b9c168fd936a75e500806a8c0d7755ef1198": "注意加载新的Cookies将覆盖您以前的Cookie。并且Cookies的范围是整个实例而不是每个用户单独分开的。",
"511b600ae4cf037e4eb3b7a58410842cd5727490": "添加更多内容",
"d02888c485d3aeab6de628508f4a00312a722894": "我的视频",
"a0720c36ee1057e5c54a86591b722485c62d7b1a": "前往订阅",
"5656a06f17c24b2d7eae9c221567b209743829a9": "在新标签页打开文件",
"348cc5d553b18e862eb1c1770e5636f6b05ba130": "出现错误",
"4d8a18b04a1f785ecd8021ac824e0dfd5881dbfc": "下载成功",
"e01d54ecc1a0fcf9525a3c100ed8b83d94e61c23": "搜索过滤器",
"cfa67d14d84fe0e9fadf251dc51ffc181173b662": "搜索起点",
"544e09cdc99a8978f48521d45f62db0da6dcf742": "使用角色预设",
"3697f8583ea42868aa269489ad366103d94aece7": "编辑中",
"fb35145bfb84521e21b6385363d59221f436a573": "取消所有下载"
}

View File

@@ -3,6 +3,8 @@ const THEMES_CONFIG = {
'key': 'default',
'background_color': 'ghostwhite',
'alternate_color': 'gray',
'ghost_primary': '#f9f9f9',
'ghost_secondary': '#ecebeb',
'css_label': 'default-theme',
'social_theme': 'material-light'
},
@@ -10,12 +12,16 @@ const THEMES_CONFIG = {
'key': 'dark',
'background_color': '#141414',
'alternate_color': '#695959',
'ghost_primary': '#444444',
'ghost_secondary': '#141414',
'css_label': 'dark-theme',
'social_theme': 'material-dark'
},
'light': {
'key': 'light',
'background_color': 'white',
'ghost_primary': '#f9f9f9',
'ghost_secondary': '#ecebeb',
'css_label': 'light-theme',
'social_theme': 'material-light'
}