Compare commits

..

111 Commits

Author SHA1 Message Date
Isaac Abadi
3c206c31d5 Updated translations base file 2020-12-24 03:21:56 -05:00
Tzahi12345
3ffcfac28b Merge pull request #290 from Tzahi12345/updated-player
Updated player & much more (v4.2)
2020-12-24 03:13:50 -05:00
Isaac Abadi
0e7bc1979f Updated versioning info 2020-12-24 02:05:30 -05:00
Isaac Abadi
33fc74b7e7 Updated dev config 2020-12-24 02:04:43 -05:00
Isaac Abadi
c08993e20b Old database files are now backed up prior to migration to simplified structure 2020-12-24 02:02:05 -05:00
Isaac Abadi
4835093606 Fixed issue where some non-YT videos would fail as the pre-check was incompatible 2020-12-24 00:10:54 -05:00
Isaac Abadi
c63a64ebef Categories will now auto-generate playlists 2020-12-23 01:29:22 -05:00
Isaac Abadi
9a57080bb3 Category is now properly stored in the database 2020-12-23 01:24:43 -05:00
Isaac Abadi
1cc4df2829 Updated translation file to v4.2 2020-12-22 01:29:19 -05:00
Isaac Abadi
6eb6ffa5e4 Get user videos now accepts an optional type parameter 2020-12-22 01:25:12 -05:00
Isaac Abadi
2656147570 Optimized get/set subscription process 2020-12-22 01:24:50 -05:00
Isaac Abadi
88a1c31090 Removed unused code in home page 2020-12-22 01:24:27 -05:00
Isaac Abadi
3f1532b4c6 Updated migration
- Fixed bug in migration process for single-user mode
- Changed name of migration

Removed unused code for getmp3/mp4 and fixed bug when retrieving playlist if it didn't exist

Fixed bug in streaming code where playlist audio files would not play if the file path was not present

Fixed bug in getallsubscriptions for single user mode
2020-12-22 01:23:43 -05:00
Isaac Abadi
afb5e3800c In the subscription page, the subscription is now continuously retrieved at an interval of 1s to update for new videos or the downloading state
- There is now a visual indicator for when a subscription is retrieving videos
2020-12-20 00:30:48 -05:00
Isaac Abadi
2971580f91 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into updated-player 2020-12-19 14:37:00 -05:00
Isaac Abadi
4cbfab20e0 Added category info to video info dialog (if present)
- you can now search by category on the home page
2020-12-19 14:33:19 -05:00
Isaac Abadi
7e06d30205 401 unauthorized requests now redirect users to the login page 2020-12-19 13:03:49 -05:00
Isaac Abadi
e75b56ad3f Added ability to pause specific subscriptions 2020-12-19 04:12:27 -05:00
Isaac Abadi
441a470990 Added ability to view playlist in reverse order in the playlist editing dialog 2020-12-19 03:51:30 -05:00
Isaac Abadi
eb7661c14a Fixed bug in file deletion where file indexes became stale 2020-12-19 02:06:52 -05:00
Isaac Abadi
59c38321fd Fixed bug in file deletion 2020-12-19 01:46:19 -05:00
Isaac Abadi
9847577431 Added setting for redownloading fresh uploads
Fixed bug in implementation of fresh upload redownloader
2020-12-19 00:24:36 -05:00
Isaac Abadi
0fec9d71a0 Updated Chrome and Firefox extension zips 2020-12-18 18:41:25 -05:00
Isaac Abadi
5f13205017 Removed background script declaration from the Chrome/Firefox extension 2020-12-18 18:36:39 -05:00
Isaac Abadi
cd93313cfc Updated Chrome/Firefox extension to 0.4 2020-12-18 18:34:30 -05:00
Isaac Abadi
8058b743eb Added support for redownloading fresh uploads, which will eventually be hidden behind an opt-in setting 2020-12-18 18:31:23 -05:00
Isaac Abadi
e3374c573a Args incompatible with video mode and audio-only mode will now get removed 2020-12-15 20:16:53 -05:00
Isaac Abadi
29b8dc227c Updated location/style of the share and download icons on the player 2020-12-15 20:04:02 -05:00
Isaac Abadi
c30350205f Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into updated-player 2020-12-15 18:16:15 -05:00
Isaac Abadi
ff8886d2e0 Simplified rxjs imports on the home page and potentially removed an erroneous error 2020-12-15 17:20:04 -05:00
Isaac Abadi
43b0c2fb9e Fixed bug that prevented subscription videos from being shown in the subscription page 2020-12-15 17:19:15 -05:00
Isaac Abadi
e39e8f3dba Home page paginator no longer disappears for empty pages
Paginator length fixed

Updated styling on paginator

Added new text if videos are not present on the home page (due to filter or no downloads in general)
2020-12-15 01:11:15 -05:00
Isaac Abadi
da3bd2600f Fixed bug where sharing didn't work for some videos
View count now increments on each play unless the video is shared
2020-12-15 00:42:24 -05:00
Isaac Abadi
6ad590497b Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into updated-player 2020-12-14 18:20:33 -05:00
Isaac Abadi
4f693d4eda Added description to player component and simplified the database by un-splitting videos and playlists by type 2020-12-14 18:19:50 -05:00
Isaac Abadi
9de403245b Twitch chat now supports subscriptions
- refactored code to be cleaner and more modularized

Updated scrolling on twitch chat to actually scroll to the bottom with new messages

Fast forwarding in videos with a twitch chat is now faster and provides a smoother transition
2020-12-10 21:04:53 -05:00
Isaac Abadi
ff1bb8dee1 Reduced the max number of ghost cards to 10 for performance reasons 2020-12-10 19:34:15 -05:00
Isaac Abadi
3f10986cdf Updated Angular to version 11
- ngx-videogular was replaced by @videogular/ngx-videogular
2020-12-10 19:33:53 -05:00
Isaac Abadi
c6fc5352c5 Added ability to add more metadata to db through migrations, and added scaffolding for supporting description and play count in the player component 2020-12-09 17:28:00 -05:00
Isaac Abadi
f425b9842f Updated twitch chat component to support user colors and to auto open if the chat has already been downloaded 2020-12-08 22:57:09 -05:00
Isaac Abadi
8c916d8fe4 Fixed bug that prevented search on the home page from working 2020-12-05 04:19:48 -05:00
Isaac Abadi
bb18e1427e Added paginator to the home page 2020-12-05 03:40:59 -05:00
Isaac Abadi
1542436e96 Passwords now must be provided when registering a user 2020-11-29 18:37:16 -05:00
Isaac Abadi
b0acb63123 Updated backend dependencies (caused build to fail) 2020-11-29 13:07:17 -05:00
Tzahi12345
0713eda7e2 Merge pull request #272 from Tzahi12345/twitch-chat
Added chat sidebar for Twitch VODs
2020-11-29 03:21:31 -05:00
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
8595864118 Added basic categorization functionality in the server & UI 2020-09-17 03:14:24 -04:00
127 changed files with 11824 additions and 11311 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

View File

@@ -261,12 +261,12 @@ paths:
$ref: '#/components/schemas/inline_response_200_10'
security:
- Auth query parameter: []
/api/getAllSubscriptions:
/api/getSubscriptions:
post:
tags:
- subscriptions
summary: Get all subscriptions
operationId: post-api-getAllSubscriptions
operationId: post-api-getSubscriptions
requestBody:
content:
application/json:

View File

@@ -1,12 +1,12 @@
# 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.
YoutubeDL-Material is a Material Design frontend for [youtube-dl](https://rg3.github.io/youtube-dl/). It's coded using [Angular 11](https://angular.io/) for the frontend, and [Node.js](https://nodejs.org/) on the backend.
Now with [Docker](#Docker) support!
@@ -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
@@ -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

@@ -45,8 +45,6 @@
],
"optimization": true,
"outputHashing": "all",
"sourceMap": false,
"extractCss": true,
"namedChunks": false,
"aot": true,
"extractLicenses": true,

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false,
@@ -25,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",
@@ -34,7 +38,8 @@
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300"
"subscriptions_check_interval": "300",
"redownload_fresh_uploads": false
},
"Users": {
"base_path": "users/",
@@ -49,6 +54,7 @@
}
},
"Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -15,15 +15,16 @@ var JwtStrategy = require('passport-jwt').Strategy,
// other required vars
let logger = null;
var users_db = null;
let db = null;
let users_db = null;
let SERVER_SECRET = null;
let JWT_EXPIRATION = null;
let opts = null;
let saltRounds = null;
exports.initialize = function(input_users_db, input_logger) {
exports.initialize = function(input_db, input_users_db, input_logger) {
setLogger(input_logger)
setDB(input_users_db);
setDB(input_db, input_users_db);
/*************************
* Authentication module
@@ -61,7 +62,8 @@ function setLogger(input_logger) {
logger = input_logger;
}
function setDB(input_users_db) {
function setDB(input_db, input_users_db) {
db = input_db;
users_db = input_users_db;
}
@@ -89,6 +91,12 @@ exports.registerUser = function(req, res) {
return;
}
if (plaintextPassword === "") {
res.sendStatus(400);
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
return;
}
bcrypt.hash(plaintextPassword, saltRounds)
.then(function(hash) {
let new_user = generateUserObject(userid, username, hash);
@@ -139,12 +147,12 @@ exports.registerUser = function(req, res) {
exports.passport.use(new LocalStrategy({
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);
}
}
));
@@ -160,7 +168,7 @@ exports.passport.use(new LdapStrategy(getLDAPConfiguration,
// 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) {
@@ -226,15 +234,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
@@ -279,61 +285,70 @@ exports.adminExists = function() {
exports.getUserVideos = function(user_uid, type) {
const user = users_db.get('users').find({uid: user_uid}).value();
return user['files'][type];
return type ? user['files'].filter(file => file.isAudio = (type === 'audio')) : user['files'];
}
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) {
file = users_db.get('users').find({uid: user_uid}).get(`files.video`).find({uid: file_uid}).value();
if (file) type = 'video';
} else {
type = 'audio';
}
}
if (!file && type) file = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value();
exports.getUserVideo = function(user_uid, file_uid, requireSharing = false) {
let file = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value();
// prevent unauthorized users from accessing the file info
if (requireSharing && !file['sharingEnabled']) file = null;
if (file && !file['sharingEnabled'] && requireSharing) file = null;
return file;
}
exports.addPlaylist = function(user_uid, new_playlist, type) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write();
exports.addPlaylist = function(user_uid, new_playlist) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).push(new_playlist).write();
return true;
}
exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames, type) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames});
exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames});
return true;
}
exports.removePlaylist = function(user_uid, playlistID, type) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write();
exports.removePlaylist = function(user_uid, playlistID) {
users_db.get('users').find({uid: user_uid}).get(`playlists`).remove({id: playlistID}).write();
return true;
}
exports.getUserPlaylists = function(user_uid, type) {
exports.getUserPlaylists = function(user_uid, user_files = null) {
const user = users_db.get('users').find({uid: user_uid}).value();
return user['playlists'][type];
const playlists = JSON.parse(JSON.stringify(user['playlists']));
const categories = db.get('categories').value();
if (categories && user_files) {
categories.forEach(category => {
const audio_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && file.isAudio);
const video_files = user_files && user_files.filter(file => file.category && file.category.uid === category.uid && !file.isAudio);
if (audio_files && audio_files.length > 0) {
playlists.push({
name: category['name'],
thumbnailURL: audio_files[0].thumbnailURL,
thumbnailPath: audio_files[0].thumbnailPath,
fileNames: audio_files.map(file => file.id),
type: 'audio',
uid: user_uid,
auto: true
});
}
if (video_files && video_files.length > 0) {
playlists.push({
name: category['name'],
thumbnailURL: video_files[0].thumbnailURL,
thumbnailPath: video_files[0].thumbnailPath,
fileNames: video_files.map(file => file.id),
type: 'video',
uid: user_uid,
auto: true
});
}
});
}
return playlists;
}
exports.getUserPlaylist = function(user_uid, playlistID, type, requireSharing = false) {
let playlist = null;
if (!type) {
playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.audio`).find({id: playlistID}).value();
if (!playlist) {
playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.video`).find({id: playlistID}).value();
if (playlist) type = 'video';
} else {
type = 'audio';
}
}
if (!playlist) playlist = users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).value();
exports.getUserPlaylist = function(user_uid, playlistID, requireSharing = false) {
let playlist = users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).value();
// prevent unauthorized users from accessing the file info
if (requireSharing && !playlist['sharingEnabled']) playlist = null;
@@ -341,21 +356,22 @@ exports.getUserPlaylist = function(user_uid, playlistID, type, requireSharing =
return playlist;
}
exports.registerUserFile = function(user_uid, file_object, type) {
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
exports.registerUserFile = function(user_uid, file_object) {
users_db.get('users').find({uid: user_uid}).get(`files`)
.remove({
path: file_object['path']
}).write();
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
users_db.get('users').find({uid: user_uid}).get(`files`)
.push(file_object)
.write();
}
exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) {
exports.deleteUserFile = async function(user_uid, file_uid, blacklistMode = false) {
let success = false;
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value();
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files`).find({uid: file_uid}).value();
if (file_obj) {
const type = file_obj.isAudio ? 'audio' : 'video';
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
@@ -371,24 +387,24 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals
}
const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext);
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
users_db.get('users').find({uid: user_uid}).get(`files`)
.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
@@ -397,17 +413,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);
}
}
}
@@ -420,11 +436,11 @@ exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = fals
return success;
}
exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enabled) {
exports.changeSharingMode = function(user_uid, file_uid, is_playlist, enabled) {
let success = false;
const user_db_obj = users_db.get('users').find({uid: user_uid});
if (user_db_obj.value()) {
const file_db_obj = is_playlist ? user_db_obj.get(`playlists.${type}`).find({id: file_uid}) : user_db_obj.get(`files.${type}`).find({uid: file_uid});
const file_db_obj = is_playlist ? user_db_obj.get(`playlists`).find({id: file_uid}) : user_db_obj.get(`files`).find({uid: file_uid});
if (file_db_obj.value()) {
success = true;
file_db_obj.assign({sharingEnabled: enabled}).write();
@@ -533,7 +549,7 @@ function generateUserObject(userid, username, hash, auth_method = 'internal') {
role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user',
permissions: [],
permission_overrides: [],
auth_method: auth_method
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

@@ -184,6 +184,7 @@ DEFAULT_CONFIG = {
"Downloader": {
"path-audio": "audio/",
"path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false,
"custom_args": "",
"safe_download_override": false,
@@ -202,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",
@@ -211,7 +215,8 @@ DEFAULT_CONFIG = {
"Subscriptions": {
"allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300"
"subscriptions_check_interval": "300",
"redownload_fresh_uploads": false
},
"Users": {
"base_path": "users/",
@@ -226,6 +231,7 @@ DEFAULT_CONFIG = {
}
},
"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'
@@ -82,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': {
@@ -110,6 +126,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
},
'ytdl_subscriptions_redownload_fresh_uploads': {
'key': 'ytdl_subscriptions_redownload_fresh_uploads',
'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads'
},
// Users
'ytdl_users_base_path': {
@@ -130,6 +150,10 @@ let CONFIG_ITEMS = {
},
// 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'
@@ -172,5 +196,5 @@ AVAILABLE_PERMISSIONS = [
module.exports = {
CONFIG_ITEMS: CONFIG_ITEMS,
AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS,
CURRENT_VERSION: 'v4.1'
CURRENT_VERSION: 'v4.2'
}

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, category = 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;
@@ -27,14 +27,17 @@ function registerFileDB(file_path, type, multiUserMode = null, sub = null) {
utils.fixVideoMetadataPerms(file_id, type, multiUserMode && multiUserMode.file_path);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, multiUserMode && multiUserMode.file_path);
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_id, type, customPath || multiUserMode && multiUserMode.file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
if (!sub) {
if (multiUserMode) {
const user_uid = multiUserMode.user;
db_path = users_db.get('users').find({uid: user_uid}).get(`files.${type}`);
db_path = users_db.get('users').find({uid: user_uid}).get(`files`);
} else {
db_path = db.get(`files.${type}`)
db_path = db.get(`files`);
}
} else {
if (multiUserMode) {
@@ -94,18 +97,18 @@ function generateFileObject(id, type, customPath = null, sub = null) {
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = type === 'audio';
var file_obj = new utils.File(id, title, thumbnail, isaudio, duration, url, uploader, size, file_path, upload_date);
var description = jsonobj.description;
var file_obj = new utils.File(id, title, thumbnail, isaudio, duration, url, uploader, size, file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
function updatePlaylist(playlist, user_uid) {
let playlistID = playlist.id;
let type = playlist.type;
let db_loc = null;
if (user_uid) {
db_loc = users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID});
db_loc = users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID});
} else {
db_loc = db.get(`playlists.${type}`).find({id: playlistID});
db_loc = db.get(`playlists`).find({id: playlistID});
}
db_loc.assign(playlist).write();
return true;
@@ -115,7 +118,7 @@ function getAppendedBasePathSub(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
async function importUnregisteredFiles() {
function getFileDirectoriesAndDBs() {
let dirs_to_check = [];
let subscriptions_to_check = [];
const subscriptions_base_path = config_api.getConfigItem('ytdl_subscriptions_base_path'); // only for single-user mode
@@ -132,14 +135,14 @@ async function importUnregisteredFiles() {
// add user's audio dir to check list
dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'audio'),
dbPath: users_db.get('users').find({uid: user.uid}).get('files.audio'),
dbPath: users_db.get('users').find({uid: user.uid}).get('files'),
type: 'audio'
});
// add user's video dir to check list
dirs_to_check.push({
basePath: path.join(usersFileFolder, user.uid, 'video'),
dbPath: users_db.get('users').find({uid: user.uid}).get('files.video'),
dbPath: users_db.get('users').find({uid: user.uid}).get('files'),
type: 'video'
});
}
@@ -153,14 +156,14 @@ async function importUnregisteredFiles() {
// add audio dir to check list
dirs_to_check.push({
basePath: audioFolderPath,
dbPath: db.get('files.audio'),
dbPath: db.get('files'),
type: 'audio'
});
// add video dir to check list
dirs_to_check.push({
basePath: videoFolderPath,
dbPath: db.get('files.video'),
dbPath: db.get('files'),
type: 'video'
});
}
@@ -181,10 +184,16 @@ async function importUnregisteredFiles() {
});
}
return dirs_to_check;
}
async function importUnregisteredFiles() {
const dirs_to_check = getFileDirectoriesAndDBs();
// 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
@@ -195,13 +204,32 @@ async function importUnregisteredFiles() {
logger.verbose(`Added discovered file to the database: ${file.id}`);
}
});
});
}
}
async function getVideo(file_uid, uuid, sub_id) {
const base_db_path = uuid ? users_db.get('users').find({uid: uuid}) : db;
const sub_db_path = sub_id ? base_db_path.get('subscriptions').find({id: sub_id}).get('videos') : base_db_path.get('files');
return sub_db_path.find({uid: file_uid}).value();
}
async function setVideoProperty(file_uid, assignment_obj, uuid, sub_id) {
const base_db_path = uuid ? users_db.get('users').find({uid: uuid}) : db;
const sub_db_path = sub_id ? base_db_path.get('subscriptions').find({id: sub_id}).get('videos') : base_db_path.get('files');
const file_db_path = sub_db_path.find({uid: file_uid});
if (!(file_db_path.value())) {
logger.error(`Failed to find file with uid ${file_uid}`);
}
sub_db_path.find({uid: file_uid}).assign(assignment_obj).write();
}
module.exports = {
initialize: initialize,
registerFileDB: registerFileDB,
updatePlaylist: updatePlaylist,
importUnregisteredFiles: importUnregisteredFiles
getFileDirectoriesAndDBs: getFileDirectoriesAndDBs,
importUnregisteredFiles: importUnregisteredFiles,
getVideo: getVideo,
setVideoProperty: setVideoProperty
}

View File

@@ -252,6 +252,14 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.9.1.tgz",
"integrity": "sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug=="
},
"axios": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
"integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
"requires": {
"follow-redirects": "^1.10.0"
}
},
"backoff": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz",
@@ -1072,6 +1080,11 @@
}
}
},
"follow-redirects": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.0.tgz",
"integrity": "sha512-aq6gF1BEKje4a9i9+5jimNFIpq4Q1WiwBToeRK5NvZBd/TRsmW8BsJfOEGkr76TbOyPVD3OVDN910EcUNtRYEA=="
},
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
@@ -1850,10 +1863,9 @@
}
},
"moment": {
"version": "2.27.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.27.0.tgz",
"integrity": "sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==",
"optional": true
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"ms": {
"version": "2.0.0",

View File

@@ -30,6 +30,7 @@
"dependencies": {
"archiver": "^3.1.1",
"async": "^3.1.0",
"axios": "^0.21.0",
"bcryptjs": "^2.4.0",
"compression": "^1.7.4",
"config": "^3.2.3",
@@ -42,6 +43,7 @@
"lowdb": "^1.0.0",
"md5": "^2.2.1",
"merge-files": "^0.1.2",
"moment": "^2.29.1",
"multer": "^1.4.2",
"node-fetch": "^2.6.1",
"node-id3": "^0.1.14",

View File

@@ -6,7 +6,8 @@ var path = require('path');
var youtubedl = require('youtube-dl');
const config_api = require('./config');
var utils = require('./utils')
const twitch_api = require('./twitch');
var utils = require('./utils');
const debugMode = process.env.YTDL_MODE === 'debug';
@@ -79,17 +80,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 +115,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 +158,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,159 +205,98 @@ 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) {
// get sub_db
let sub_db = null;
if (user_uid)
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
else
sub_db = db.get('subscriptions').find({id: sub.id});
const latest_sub_obj = sub_db.value();
if (!latest_sub_obj || latest_sub_obj['downloading']) {
return false;
}
updateSubscriptionProperty(sub, {downloading: true}, user_uid);
// 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 appendedBasePath = getAppendedBasePath(sub, basePath);
let multiUserMode = null;
if (user_uid) {
multiUserMode = {
user: user_uid,
file_path: appendedBasePath
}
}
const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
return new Promise(resolve => {
if (!subExists(sub.id, user_uid)) {
resolve(false);
return;
}
// 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
}
}
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
let fullOutput = appendedBasePath + '/%(title)s' + ext;
if (sub.custom_output) {
fullOutput = appendedBasePath + '/' + sub.custom_output + ext;
}
let downloadConfig = ['-o', fullOutput, '-ciw', '--write-info-json', '--print-json'];
let qualityPath = null;
if (sub.type && sub.type === 'audio') {
qualityPath = ['-f', 'bestaudio']
qualityPath.push('-x');
qualityPath.push('--audio-format', 'mp3');
} else {
qualityPath = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4']
}
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);
}
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.');
}
}
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail');
}
// get videos
logger.verbose('Subscription: getting videos for subscription ' + sub.name);
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
youtubedl.exec(sub.url, downloadConfig, {}, async function(err, output) {
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
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 {
@@ -364,7 +306,11 @@ async function getVideosForSub(sub, user_uid = null) {
handleOutputJSON(sub, sub_db, output, i === 0, multiUserMode)
if (err.stderr.includes(output['id']) && archive_path) {
// we found a video that errored! add it to the archive to prevent future errors
fs.appendFileSync(archive_path, output['id']);
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
fs.appendFileSync(archive_path, output['id']);
}
}
}
} catch(e) {
@@ -377,6 +323,7 @@ async function getVideosForSub(sub, user_uid = null) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
logger.verbose('No additional videos to download for ' + sub.name);
resolve(true);
return;
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
@@ -391,18 +338,101 @@ async function getVideosForSub(sub, user_uid = null) {
const reset_videos = i === 0;
handleOutputJSON(sub, sub_db, output_json, multiUserMode, reset_videos);
// TODO: Potentially store downloaded files in db?
}
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
}
resolve(true);
}
});
}, err => {
logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
});
}
async function generateArgsForSubscription(sub, user_uid, redownload = false, desired_path = null) {
// 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 = getAppendedBasePath(sub, basePath);
let fullOutput = `${appendedBasePath}/%(title)s.%(ext)s`;
if (desired_path) {
fullOutput = `${desired_path}.%(ext)s`;
} else if (sub.custom_output) {
fullOutput = `${appendedBasePath}/${sub.custom_output}.%(ext)s`;
}
let downloadConfig = ['-o', fullOutput, !redownload ? '-ciw' : '-ci', '--write-info-json', '--print-json'];
let 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'];
}
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);
}
let archive_dir = null;
let archive_path = null;
if (useArchive && !redownload) {
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
}
downloadConfig.push('--download-archive', archive_path);
}
// if streaming only mode, just get the list of videos
if (sub.streamingOnly) {
downloadConfig = ['-f', 'best', '--dump-json'];
}
if (sub.timerange && !redownload) {
downloadConfig.push('--dateafter', sub.timerange);
}
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.');
}
}
if (config_api.getConfigItem('ytdl_include_thumbnail')) {
downloadConfig.push('--write-thumbnail');
}
return downloadConfig;
}
function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_videos = false) {
if (sub.streamingOnly) {
if (reset_videos) {
@@ -415,17 +445,49 @@ function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_
// add to db
sub_db.get('videos').push(output_json).write();
} else {
path_object = path.parse(output_json['_filename']);
const path_string = path.format(path_object);
if (sub_db.get('videos').find({path: path_string}).value()) {
// file already exists in DB, return early to avoid reseting the download date
return;
}
db_api.registerFileDB(path.basename(output_json['_filename']), sub.type, multiUserMode, sub);
const url = output_json['webpage_url'];
if (sub.type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
const file_name = path.basename(output_json['_filename']);
const id = file_name.substring(0, file_name.length-4);
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, id, sub.type, multiUserMode.user, sub);
}
}
}
function getAllSubscriptions(user_uid = null) {
function getSubscriptions(user_uid = null) {
if (user_uid)
return users_db.get('users').find({uid: user_uid}).get('subscriptions').value();
else
return db.get('subscriptions').value();
}
function getAllSubscriptions() {
let subscriptions = null;
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (multiUserMode) {
subscriptions = [];
let users = users_db.get('users').value();
for (let i = 0; i < users.length; i++) {
if (users[i]['subscriptions']) subscriptions = subscriptions.concat(users[i]['subscriptions']);
}
} else {
subscriptions = getSubscriptions();
}
return subscriptions;
}
function getSubscription(subID, user_uid = null) {
if (user_uid)
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
@@ -433,6 +495,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();
@@ -442,6 +511,21 @@ function updateSubscription(sub, user_uid = null) {
return true;
}
function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
subs.forEach(sub => {
updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
});
}
function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
if (user_uid) {
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(assignment_obj).write();
} else {
db.get('subscriptions').find({id: sub.id}).assign(assignment_obj).write();
}
return true;
}
function subExists(subID, user_uid = null) {
if (user_uid)
return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value();
@@ -449,6 +533,52 @@ function subExists(subID, user_uid = null) {
return !!db.get('subscriptions').find({id: subID}).value();
}
async function setFreshUploads(sub, user_uid) {
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub.videos.forEach(async video => {
if (current_date === video['upload_date'].replace(/-/g, '')) {
// set upload as fresh
const video_uid = video['uid'];
await db_api.setVideoProperty(video_uid, {'fresh_upload': true}, user_uid, sub['id']);
}
});
}
async function checkVideosForFreshUploads(sub, user_uid) {
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub.videos.forEach(async video => {
if (video['fresh_upload'] && current_date > video['upload_date'].replace(/-/g, '')) {
checkVideoIfBetterExists(video, sub, user_uid)
}
});
}
async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
const new_path = file_obj['path'].substring(0, file_obj['path'].length - 4);
const downloadConfig = await generateArgsForSubscription(sub, user_uid, true, new_path);
logger.verbose(`Checking if a better version of the fresh upload ${file_obj['id']} exists.`);
// simulate a download to verify that a better version exists
youtubedl.getInfo(file_obj['url'], downloadConfig, (err, output) => {
if (err) {
// video is not available anymore for whatever reason
} else if (output) {
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
if (output[metric_to_compare] > file_obj[metric_to_compare]) {
// download new video as the simulated one is better
youtubedl.exec(file_obj['url'], downloadConfig, async (err, output) => {
if (err) {
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
} else if (output) {
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${output[metric_to_compare]}`);
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: output[metric_to_compare]}, user_uid, sub['id']);
}
});
}
}
});
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false}, user_uid, sub['id']);
}
// helper functions
function getAppendedBasePath(sub, base_path) {
@@ -456,23 +586,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;
@@ -493,13 +608,15 @@ 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,
getSubscriptions : getSubscriptions,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,
@@ -508,5 +625,6 @@ module.exports = {
getVideosForSub : getVideosForSub,
removeIDFromArchive : removeIDFromArchive,
setLogger : setLogger,
initialize : initialize
initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
}

128
backend/twitch.js Normal file
View File

@@ -0,0 +1,128 @@
var moment = require('moment');
var Axios = require('axios');
var fs = require('fs-extra')
var path = require('path');
const config_api = require('./config');
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,
user_color: user_color
}
} = c;
const timestamp_str = moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
acctCreated = moment(acctCreated).utc();
msgCreated = moment(msgCreated).utc();
if (!comments) comments = [];
comments.push({
timestamp: timestamp,
timestamp_str: timestamp_str,
name: name,
message: msg,
user_color: user_color
});
// let line = `${timestamp},${msgCreated.format(tsFormat)},${name},${_id},"${msg.replace(/"/g, '""')}",${acctCreated.format(tsFormat)}`;
// return line;
}).join('\n');
cursor = batch._next;
url = `https://api.twitch.tv/v5/videos/${vodId}/comments?cursor=${cursor}`;
await new Promise(res => setTimeout(res, 300));
} while (cursor);
} catch (err) {
console.error(err);
}
return comments;
}
async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
let file_path = null;
if (user_uid) {
if (sub) {
file_path = path.join('users', user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
}
} else {
if (sub) {
file_path = path.join('subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join(type, id + '.twitch_chat.json');
}
}
var chat_file = null;
if (fs.existsSync(file_path)) {
chat_file = fs.readJSONSync(file_path);
}
return chat_file;
}
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub) {
const twitch_api_key = config_api.getConfigItem('ytdl_twitch_api_key');
const chat = await getCommentsForVOD(twitch_api_key, vodId);
// save file if needed params are included
let file_path = null;
if (user_uid) {
if (sub) {
file_path = path.join('users', user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
}
} else {
if (sub) {
file_path = path.join('subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
} else {
file_path = path.join(type, id + '.twitch_chat.json');
}
}
if (chat) fs.writeJSONSync(file_path, chat);
return chat;
}
module.exports = {
getCommentsForVOD: getCommentsForVOD,
getTwitchChatByFileID: getTwitchChatByFileID,
downloadTwitchChatByVODID: downloadTwitchChatByVODID
}

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,34 +20,33 @@ function getTrueFileName(unfixed_path, type) {
return fixed_path;
}
function getDownloadedFilesByType(basePath, type) {
async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
// 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 = 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;
var uploader = jsonobj.uploader;
if (full_metadata) {
jsonobj['id'] = id;
files.push(jsonobj);
continue;
}
var upload_date = jsonobj.upload_date;
upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var size = stats.size;
var isaudio = type === 'audio';
var file_obj = new File(id, title, thumbnail, isaudio, duration, url, uploader, size, file, upload_date);
var file_obj = new File(id, jsonobj.title, jsonobj.thumbnail, isaudio, jsonobj.duration, jsonobj.webpage_url, jsonobj.uploader,
stats.size, file, upload_date, jsonobj.description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
files.push(file_obj);
}
return files;
@@ -113,6 +113,7 @@ function getExpectedFileSize(info_json) {
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;
@@ -129,7 +130,7 @@ function fixVideoMetadataPerms(name, type, customPath = null) {
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
const files_to_fix = [
// JSONs
path.join(customPath, name + '.info.json'),
@@ -158,33 +159,31 @@ function deleteJSONFile(name, type, customPath = null) {
}
function recFindByExt(base,ext,files,result)
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
}
// objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date) {
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
this.id = id;
this.title = title;
this.thumbnailURL = thumbnailURL;
@@ -195,6 +194,10 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
this.size = size;
this.path = path;
this.upload_date = upload_date;
this.description = description;
this.view_count = view_count;
this.height = height;
this.abr = abr;
}
module.exports = {

View File

@@ -1,20 +0,0 @@
// background.js
// Called when the user clicks on the browser action.
chrome.browserAction.onClicked.addListener(function(tab) {
// get the frontend_url
chrome.storage.sync.get({
frontend_url: 'http://localhost',
audio_only: false
}, function(items) {
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
var activeTab = tabs[0];
var url = activeTab.url;
if (url.includes('youtube.com')) {
var new_url = items.frontend_url + '/#/home;url=' + encodeURIComponent(url) + ';audioOnly=' + items.audio_only;
chrome.tabs.create({ url: new_url });
}
});
});
});

View File

@@ -1,17 +1,17 @@
{
"manifest_version": 2,
"name": "YoutubeDL-Material",
"version": "0.3",
"version": "0.4",
"description": "The Official Firefox & Chrome Extension of YoutubeDL-Material, an open-source and self-hosted YouTube downloader.",
"background": {
"scripts": ["background.js"]
},
"browser_action": {
"default_icon": "favicon.png"
"default_icon": "favicon.png",
"default_popup": "popup.html",
"default_title": "YoutubeDL-Material"
},
"permissions": [
"tabs",
"storage"
"storage",
"contextMenus"
],
"options_ui": {
"page": "options.html",

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html>
<head>
<!-- Scripts -->
<script src="js/jquery-3.4.1.min.js"></script>
<script src="js/popper.min.js"></script>
<script src="js/bootstrap.min.js"></script>
<!-- Cascading Style Sheets -->
<link href="css/bootstrap.min.css" rel="stylesheet" media="screen">
</head>
<body>
<div style="width: 400px; margin: 0 auto;">
<div style="margin: 10px;">
<div class="checkbox">
<label>
<input type="checkbox" id="audio_only">
Audio only
</label>
</div>
<div class="input-group mb-3">
<input id="url_input" type="text" class="form-control" placeholder="URL" aria-label="URL" aria-describedby="basic-addon2">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="download">Download</button>
</div>
</div>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

50
chrome-extension/popup.js Normal file
View File

@@ -0,0 +1,50 @@
function audioOnlyClicked() {
console.log('audio only clicked');
var audio_only = document.getElementById("audio_only").checked;
// save state
chrome.storage.sync.set({
audio_only: audio_only
}, function() {});
}
function downloadVideo() {
var input_url = document.getElementById("url_input").value
// get the frontend_url
chrome.storage.sync.get({
frontend_url: 'http://localhost',
audio_only: false
}, function(items) {
var download_url = items.frontend_url + '/#/home;url=' + encodeURIComponent(input_url) + ';audioOnly=' + items.audio_only;
chrome.tabs.create({ url: download_url });
});
}
function loadInputs() {
// load audio-only input
chrome.storage.sync.get({
frontend_url: 'http://localhost',
audio_only: false
}, function(items) {
document.getElementById("audio_only").checked = items.audio_only;
});
// load url input
chrome.tabs.query({active: true, currentWindow: true}, function(tabs) {
var activeTab = tabs[0];
var current_url = activeTab.url;
console.log(current_url);
if (current_url && current_url.includes('youtube.com')) {
document.getElementById("url_input").value = current_url;
}
});
}
document.getElementById('download').addEventListener('click',
downloadVideo);
document.getElementById('audio_only').addEventListener('click',
audioOnlyClicked);
document.addEventListener('DOMContentLoaded', loadInputs);

13012
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtube-dl-material",
"version": "4.1.0",
"version": "4.2.0",
"license": "MIT",
"scripts": {
"ng": "ng",
@@ -18,19 +18,20 @@
},
"private": true,
"dependencies": {
"@angular-devkit/core": "^9.0.6",
"@angular/animations": "^9.1.0",
"@angular/cdk": "^9.2.0",
"@angular/common": "^9.1.0",
"@angular/compiler": "^9.1.0",
"@angular/core": "^9.0.7",
"@angular/forms": "^9.1.0",
"@angular/localize": "^9.1.0",
"@angular/material": "^9.2.0",
"@angular/platform-browser": "^9.1.0",
"@angular/platform-browser-dynamic": "^9.1.0",
"@angular/router": "^9.1.0",
"@angular-devkit/core": "^11.0.4",
"@angular/animations": "^11.0.4",
"@angular/cdk": "^11.0.2",
"@angular/common": "^11.0.4",
"@angular/compiler": "^11.0.4",
"@angular/core": "^11.0.4",
"@angular/forms": "^11.0.4",
"@angular/localize": "^11.0.4",
"@angular/material": "^11.0.2",
"@angular/platform-browser": "^11.0.4",
"@angular/platform-browser-dynamic": "^11.0.4",
"@angular/router": "^11.0.4",
"@ngneat/content-loader": "^5.0.0",
"@videogular/ngx-videogular": "^2.1.0",
"core-js": "^2.4.1",
"file-saver": "^2.0.2",
"filesize": "^6.1.0",
@@ -39,35 +40,34 @@
"ng-lazyload-image": "^7.0.1",
"ngx-avatar": "^4.0.0",
"ngx-file-drop": "^9.0.1",
"ngx-videogular": "^9.0.1",
"rxjs": "^6.5.3",
"rxjs": "^6.6.3",
"rxjs-compat": "^6.0.0-rc.0",
"tslib": "^1.10.0",
"typescript": "~3.7.5",
"tslib": "^2.0.0",
"typescript": "~4.0.5",
"web-animations-js": "^2.3.2",
"zone.js": "~0.10.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.901.0",
"@angular/cli": "^9.0.7",
"@angular/compiler-cli": "^9.0.7",
"@angular/language-service": "^9.0.7",
"@angular-devkit/build-angular": "^0.1100.4",
"@angular/cli": "^11.0.4",
"@angular/compiler-cli": "^11.0.4",
"@angular/language-service": "^11.0.4",
"@types/core-js": "^2.5.2",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "2.5.45",
"@types/jasmine": "~3.6.0",
"@types/node": "^12.11.1",
"codelyzer": "^5.1.2",
"codelyzer": "^6.0.0",
"electron": "^8.0.1",
"jasmine-core": "~2.6.2",
"jasmine-spec-reporter": "~4.1.0",
"karma": "~1.7.0",
"karma-chrome-launcher": "~2.1.1",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~5.0.0",
"karma-chrome-launcher": "~3.1.0",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "^1.2.1",
"karma-jasmine": "~1.1.0",
"karma-jasmine-html-reporter": "^0.2.2",
"protractor": "~5.1.2",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0",
"karma-jasmine-html-reporter": "^1.5.0",
"protractor": "~7.0.0",
"ts-node": "~3.0.4",
"tslint": "~5.3.2"
"tslint": "~6.1.0"
}
}

View File

@@ -19,7 +19,7 @@ const routes: Routes = [
];
@NgModule({
imports: [RouterModule.forRoot(routes, { useHash: true })],
imports: [RouterModule.forRoot(routes, { useHash: true, relativeLinkResolution: 'legacy' })],
exports: [RouterModule]
})
export class AppRoutingModule { }

View File

@@ -46,7 +46,7 @@
<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)="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><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,9 +1,9 @@
import { TestBed, async } from '@angular/core/testing';
import { TestBed, waitForAsync } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [
AppComponent
@@ -11,19 +11,19 @@ describe('AppComponent', () => {
}).compileComponents();
}));
it('should create the app', async(() => {
it('should create the app', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy();
}));
it(`should have as title 'app'`, async(() => {
it(`should have as title 'app'`, waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('app');
}));
it('should render title in a h1 tag', async(() => {
it('should render title in a h1 tag', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement;

View File

@@ -116,6 +116,8 @@ export class AppComponent implements OnInit, AfterViewInit {
if (this.allowSubscriptions) {
this.postsService.reloadSubscriptions();
}
this.postsService.reloadCategories();
}
// theme stuff

View File

@@ -32,14 +32,17 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { HttpClientModule, HttpClient } from '@angular/common/http';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { PostsService } from 'app/posts.services';
import { FileCardComponent } from './file-card/file-card.component';
import { RouterModule } from '@angular/router';
import { AppRoutingModule } from './app-routing.module';
import { MainComponent } from './main/main.component';
import { PlayerComponent } from './player/player.component';
import { VgCoreModule, VgControlsModule, VgOverlayPlayModule, VgBufferingModule } from 'ngx-videogular';
import { VgControlsModule } from '@videogular/ngx-videogular/controls';
import { VgBufferingModule } from '@videogular/ngx-videogular/buffering';
import { VgOverlayPlayModule } from '@videogular/ngx-videogular/overlay-play';
import { VgCoreModule } from '@videogular/ngx-videogular/core';
import { InputDialogComponent } from './input-dialog/input-dialog.component';
import { LazyLoadImageModule, IsVisibleProps } from 'ng-lazyload-image';
import { audioFilesMouseHovering, videoFilesMouseHovering, audioFilesOpened, videoFilesOpened } from './main/main.component';
@@ -79,6 +82,10 @@ 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';
import { LinkifyPipe, SeeMoreComponent } from './components/see-more/see-more.component';
import { H401Interceptor } from './http.interceptor';
registerLocaleData(es, 'es');
@@ -105,6 +112,7 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
VideoInfoDialogComponent,
ArgModifierDialogComponent,
HighlightPipe,
LinkifyPipe,
UpdaterComponent,
UpdateProgressDialogComponent,
ShareMediaDialogComponent,
@@ -123,7 +131,10 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent,
RecentVideosComponent,
EditSubscriptionDialogComponent,
CustomPlaylistsComponent
CustomPlaylistsComponent,
EditCategoryDialogComponent,
TwitchChatComponent,
SeeMoreComponent
],
imports: [
CommonModule,
@@ -181,10 +192,12 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
SettingsComponent
],
providers: [
PostsService
PostsService,
{ provide: HTTP_INTERCEPTORS, useClass: H401Interceptor, multi: true }
],
exports: [
HighlightPipe
HighlightPipe,
LinkifyPipe
],
bootstrap: [AppComponent]
})

View File

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

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CustomPlaylistsComponent } from './custom-playlists.component';
@@ -6,7 +6,7 @@ describe('CustomPlaylistsComponent', () => {
let component: CustomPlaylistsComponent;
let fixture: ComponentFixture<CustomPlaylistsComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CustomPlaylistsComponent ]
})

View File

@@ -62,7 +62,7 @@ export class CustomPlaylistsComponent implements OnInit {
} else {
localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames;
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID}]);
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID, auto: playlist.auto}]);
}
} else {
// playlist not found
@@ -83,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

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DownloadsComponent } from './downloads.component';
@@ -6,7 +6,7 @@ describe('DownloadsComponent', () => {
let component: DownloadsComponent;
let fixture: ComponentFixture<DownloadsComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ DownloadsComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { LoginComponent } from './login.component';
@@ -6,7 +6,7 @@ describe('LoginComponent', () => {
let component: LoginComponent;
let fixture: ComponentFixture<LoginComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ LoginComponent ]
})

View File

@@ -27,7 +27,7 @@ export class LoginComponent implements OnInit {
constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { }
ngOnInit(): void {
if (this.postsService.isLoggedIn) {
if (this.postsService.isLoggedIn && localStorage.getItem('jwt_token') !== 'null') {
this.router.navigate(['/home']);
}
this.postsService.service_initialized.subscribe(init => {

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { LogsViewerComponent } from './logs-viewer.component';
@@ -6,7 +6,7 @@ describe('LogsViewerComponent', () => {
let component: LogsViewerComponent;
let fixture: ComponentFixture<LogsViewerComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ LogsViewerComponent ]
})

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 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ManageRoleComponent } from './manage-role.component';
@@ -6,7 +6,7 @@ describe('ManageRoleComponent', () => {
let component: ManageRoleComponent;
let fixture: ComponentFixture<ManageRoleComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ManageRoleComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ManageUserComponent } from './manage-user.component';
@@ -6,7 +6,7 @@ describe('ManageUserComponent', () => {
let component: ManageUserComponent;
let fixture: ComponentFixture<ManageUserComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ManageUserComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ModifyUsersComponent } from './modify-users.component';
@@ -6,7 +6,7 @@ describe('ModifyUsersComponent', () => {
let component: ModifyUsersComponent;
let fixture: ComponentFixture<ModifyUsersComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ModifyUsersComponent ]
})

View File

@@ -30,16 +30,24 @@
<div>
<div class="container">
<div class="row justify-content-center">
<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" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)"></app-unified-file-card>
<ng-container *ngIf="normal_files_received && paged_data">
<div *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? '?jwt=' + this.postsService.token : ''"></app-unified-file-card>
</div>
<div *ngIf="filtered_files.length === 0">
<ng-container i18n="No videos found">No videos found.</ng-container>
</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" [loading]="true" [theme]="postsService.theme"></app-unified-file-card>
<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>
<mat-paginator class="paginator" #paginator *ngIf="filtered_files && filtered_files.length > 0" (page)="pageChangeEvent($event)" [length]="filtered_files.length"
[pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
</mat-paginator>
</div>

View File

@@ -47,6 +47,10 @@
top: 10px;
}
.paginator {
margin-top: 5px;
}
.my-videos-title {
text-align: center;
position: relative;

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { RecentVideosComponent } from './recent-videos.component';
@@ -6,7 +6,7 @@ describe('RecentVideosComponent', () => {
let component: RecentVideosComponent;
let fixture: ComponentFixture<RecentVideosComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ RecentVideosComponent ]
})

View File

@@ -1,6 +1,7 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, ViewChild } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
@Component({
selector: 'app-recent-videos',
@@ -50,10 +51,16 @@ export class RecentVideosComponent implements OnInit {
};
filterProperty = this.filterProperties['upload_date'];
pageSize = 10;
paged_data = null;
@ViewChild('paginator') paginator: MatPaginator
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.cached_file_count = +localStorage.getItem('cached_file_count') <= 10 ? +localStorage.getItem('cached_file_count') : 10;
this.loading_files = Array(this.cached_file_count).fill(0);
}
}
@@ -91,7 +98,8 @@ export class RecentVideosComponent implements OnInit {
private filterFiles(value: string) {
const filterValue = value.toLowerCase();
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue));
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue) || option.category?.name?.toLowerCase().includes(filterValue));
this.pageChangeEvent({pageSize: this.pageSize, pageIndex: this.paginator.pageIndex});
}
filterByProperty(prop) {
@@ -100,6 +108,7 @@ export class RecentVideosComponent implements OnInit {
} else {
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? 1 : -1));
}
if (this.paginator) { this.pageChangeEvent({pageSize: this.pageSize, pageIndex: this.paginator.pageIndex}) };
}
filterOptionChanged(value) {
@@ -118,9 +127,11 @@ export class RecentVideosComponent implements OnInit {
this.normal_files_received = false;
this.postsService.getAllFiles().subscribe(res => {
this.files = res['files'];
this.files.forEach(file => {
for (let i = 0; i < this.files.length; i++) {
const file = this.files[i];
file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration);
});
file.index = i;
}
this.files.sort(this.sortFiles);
if (this.search_mode) {
this.filterFiles(this.search_text);
@@ -133,6 +144,8 @@ export class RecentVideosComponent implements OnInit {
localStorage.setItem('cached_file_count', '' + this.files.length);
this.normal_files_received = true;
this.paged_data = this.filtered_files.slice(0, 10);
});
}
@@ -210,7 +223,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();
});
@@ -233,10 +246,12 @@ 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);
this.files.splice(file.index, 1);
for (let i = 0; i < this.files.length; i++) { this.files[i].index = i }
this.filterByProperty(this.filterProperty['property']);
} else {
this.postsService.openSnackBar('Delete failed!', 'OK.');
}
@@ -276,13 +291,18 @@ export class RecentVideosComponent implements OnInit {
const result = b.registered - a.registered;
return result;
}
durationStringToNumber(dur_str) {
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length-1; i >= 0; i--) {
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
for (let i = dur_str_parts.length - 1; i >= 0; i--) {
num_sum += parseInt(dur_str_parts[i]) * (60 ** (dur_str_parts.length - 1 - i));
}
return num_sum;
}
pageChangeEvent(event) {
const offset = ((event.pageIndex + 1) - 1) * event.pageSize;
this.paged_data = this.filtered_files.slice(offset).slice(0, event.pageSize);
}
}

View File

@@ -0,0 +1,11 @@
<span class="text" [ngStyle]="{'-webkit-line-clamp': !see_more_active ? line_limit : null}" [innerHTML]="text | linkify"></span>
<span>
<a [routerLink]="" (click)="toggleSeeMore()">
<ng-container *ngIf="!see_more_active" i18n="See more">
See more.
</ng-container>
<ng-container *ngIf="see_more_active" i18n="See less">
See less.
</ng-container>
</a>
</span>

View File

@@ -0,0 +1,7 @@
.text {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
word-wrap: break-word;
}

View File

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

View File

@@ -0,0 +1,60 @@
import { Component, Input, OnInit, Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer } from '@angular/platform-browser';
@Pipe({ name: 'linkify' })
export class LinkifyPipe implements PipeTransform {
constructor(private _domSanitizer: DomSanitizer) {}
transform(value: any, args?: any): any {
return this._domSanitizer.bypassSecurityTrustHtml(this.stylize(value));
}
// Modify this method according to your custom logic
private stylize(text: string): string {
let stylizedText: string = '';
if (text && text.length > 0) {
for (let line of text.split("\n")) {
for (let t of line.split(" ")) {
if (t.startsWith("http") && t.length>7) {
stylizedText += `<a target="_blank" href="${t}">${t}</a> `;
}
else
stylizedText += t + " ";
}
stylizedText += '<br>';
}
return stylizedText;
}
else return text;
}
}
@Component({
selector: 'app-see-more',
templateUrl: './see-more.component.html',
providers: [LinkifyPipe],
styleUrls: ['./see-more.component.scss']
})
export class SeeMoreComponent implements OnInit {
see_more_active = false;
@Input() text = '';
@Input() line_limit = 2;
constructor() { }
ngOnInit(): void {
}
toggleSeeMore() {
this.see_more_active = !this.see_more_active;
}
parseText() {
return this.text.replace(/(http.*?\s)/, "<a href=\"$1\">$1</a>")
}
}

View File

@@ -0,0 +1,12 @@
<div class="chat-container" #scrollContainer *ngIf="visible_chat">
<div style="width: 250px; text-align: center;"><strong>Twitch Chat</strong></div>
<div #chat style="max-width: 250px" *ngFor="let chat of visible_chat; let last = last">
{{chat.timestamp_str}} - <strong [style.color]="chat.user_color ? chat.user_color : null">{{chat.name}}</strong>: {{chat.message}}
{{last ? scrollToBottom() : ''}}
</div>
</div>
<ng-container *ngIf="chat_response_received && !full_chat">
<button [disabled]="downloading_chat" (click)="downloadTwitchChat()" class="download-button" mat-raised-button color="accent"><ng-container i18n="Download Twitch Chat button">Download Twitch Chat</ng-container></button>
<mat-spinner *ngIf="downloading_chat" class="downloading-spinner" [diameter]="30"></mat-spinner>
</ng-container>

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 { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { TwitchChatComponent } from './twitch-chat.component';
describe('TwitchChatComponent', () => {
let component: TwitchChatComponent;
let fixture: ComponentFixture<TwitchChatComponent>;
beforeEach(waitForAsync(() => {
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,138 @@
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() sub = null;
@Input() current_timestamp = null;
@ViewChild('scrollContainer') scrollRef: ElementRef;
@ViewChildren('chat') chat: QueryList<any>;
constructor(private postsService: PostsService) { }
ngOnInit(): void {
this.getFullChat();
}
ngAfterViewInit() {
}
private isUserNearBottom(): boolean {
const threshold = 150;
const position = this.scrollContainer.scrollTop + this.scrollContainer.offsetHeight;
const height = this.scrollContainer.scrollHeight;
return position > height - threshold;
}
scrollToBottom = (force_scroll) => {
if (force_scroll || this.isUserNearBottom()) {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
}
}
addNewChatMessages() {
const next_chat_index = this.getIndexOfNextChat();
if (!this.scrollContainer) {
this.scrollContainer = this.scrollRef.nativeElement;
}
if (this.current_chat_index === null) {
this.current_chat_index = next_chat_index;
}
if (Math.abs(next_chat_index - this.current_chat_index) > 25) {
this.visible_chat = [];
this.current_chat_index = next_chat_index - 25;
setTimeout(() => this.scrollToBottom(true), 100);
}
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;
} 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, this.sub).subscribe(res => {
this.chat_response_received = true;
if (res['chat']) {
this.initializeChatCheck(res['chat']);
}
});
}
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, this.sub).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,7 +1,24 @@
<div (mouseover)="elevated=true" (mouseout)="elevated=false" 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'}}</div>
<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;
<ng-container i18n="Auto-generated label" *ngIf="file_obj.auto">Auto-generated</ng-container>
<ng-container *ngIf="!file_obj.auto">{{file_obj.registered | date:'shortDate' : undefined : locale.ngID}}</ng-container>
</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>
<button [disabled]="loading" [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<!-- The context menu trigger must be kept above the "more info" menu -->
<div style="visibility: hidden; position: fixed"
[style.left]="contextMenuPosition.x"
[style.top]="contextMenuPosition.y"
[matMenuTriggerFor]="context_menu">
</div>
<button *ngIf="!file_obj || !file_obj.auto" [disabled]="loading" [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #context_menu>
<ng-container *ngIf="!loading">
<button (click)="navigateToFile($event)" mat-menu-item><mat-icon>open_in_browser</mat-icon><ng-container i18n="Open file button">Open file</ng-container></button>
<button (click)="navigateToFile({ctrlKey: true})" mat-menu-item><mat-icon>open_in_new</mat-icon><ng-container i18n="Open file in new tab">Open file in new tab</ng-container></button>
</ng-container>
</mat-menu>
<mat-menu #action_menu="matMenu">
<ng-container *ngIf="!is_playlist && !loading">
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
@@ -29,7 +46,7 @@
<div style="padding:5px">
<div *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<div style="position: relative">
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailBlob ? thumbnailBlobURL : 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>

View File

@@ -111,6 +111,11 @@
top: 1px;
left: 5px;
z-index: 99999;
width: calc(100% - 8px);
white-space: nowrap;
overflow: hidden;
display: block;
text-overflow: ellipsis;
}
.audio-video-icon {

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { UnifiedFileCardComponent } from './unified-file-card.component';
@@ -6,7 +6,7 @@ describe('UnifiedFileCardComponent', () => {
let component: UnifiedFileCardComponent;
let fixture: ComponentFixture<UnifiedFileCardComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ UnifiedFileCardComponent ]
})

View File

@@ -1,7 +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',
@@ -28,11 +43,18 @@ export class UnifiedFileCardComponent implements OnInit {
@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
@@ -47,11 +69,12 @@ export class UnifiedFileCardComponent implements OnInit {
this.file_length = fancyTimeFormat(this.file_obj.duration);
}
if (this.file_obj && this.file_obj.thumbnailBlob) {
const mime = getMimeByFilename(this.file_obj.thumbnailPath);
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);
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
}
}
@@ -87,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) {

View File

@@ -1 +1 @@
export const CURRENT_VERSION = 'v4.1';
export const CURRENT_VERSION = 'v4.2';

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CreatePlaylistComponent } from './create-playlist.component';
@@ -6,7 +6,7 @@ describe('CreatePlaylistComponent', () => {
let component: CreatePlaylistComponent;
let fixture: ComponentFixture<CreatePlaylistComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CreatePlaylistComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AboutDialogComponent } from './about-dialog.component';
@@ -6,7 +6,7 @@ describe('AboutDialogComponent', () => {
let component: AboutDialogComponent;
let fixture: ComponentFixture<AboutDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ AboutDialogComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { AddUserDialogComponent } from './add-user-dialog.component';
@@ -6,7 +6,7 @@ describe('AddUserDialogComponent', () => {
let component: AddUserDialogComponent;
let fixture: ComponentFixture<AddUserDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ AddUserDialogComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ArgModifierDialogComponent } from './arg-modifier-dialog.component';
@@ -6,7 +6,7 @@ describe('ArgModifierDialogComponent', () => {
let component: ArgModifierDialogComponent;
let fixture: ComponentFixture<ArgModifierDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ArgModifierDialogComponent ]
})

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

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ConfirmDialogComponent } from './confirm-dialog.component';
@@ -6,7 +6,7 @@ describe('ConfirmDialogComponent', () => {
let component: ConfirmDialogComponent;
let fixture: ComponentFixture<ConfirmDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ConfirmDialogComponent ]
})

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

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { CookiesUploaderDialogComponent } from './cookies-uploader-dialog.component';
@@ -6,7 +6,7 @@ describe('CookiesUploaderDialogComponent', () => {
let component: CookiesUploaderDialogComponent;
let fixture: ComponentFixture<CookiesUploaderDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ CookiesUploaderDialogComponent ]
})

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 { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditCategoryDialogComponent } from './edit-category-dialog.component';
describe('EditCategoryDialogComponent', () => {
let component: EditCategoryDialogComponent;
let fixture: ComponentFixture<EditCategoryDialogComponent>;
beforeEach(waitForAsync(() => {
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,8 +1,11 @@
<h4 mat-dialog-title i18n="Edit subscription dialog title">Editing {{sub.name}}</h4>
<h4 mat-dialog-title><ng-container i18n="Edit subscription dialog title prefix">Editing</ng-container>&nbsp;{{sub.name}}&nbsp;<ng-container *ngIf="sub.paused" i18n="Paused suffix">(Paused)</ng-container></h4>
<mat-dialog-content>
<div class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-checkbox [(ngModel)]="new_sub.paused"><ng-container i18n="Paused subscription setting">Paused</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-3">
<mat-checkbox (change)="downloadAllToggled()" [(ngModel)]="download_all"><ng-container i18n="Download all uploads subscription setting">Download all uploads</ng-container></mat-checkbox>
</div>
@@ -24,7 +27,14 @@
<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">
<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 mt-1">
<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>
</div>

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { EditSubscriptionDialogComponent } from './edit-subscription-dialog.component';
@@ -6,7 +6,7 @@ describe('EditSubscriptionDialogComponent', () => {
let component: EditSubscriptionDialogComponent;
let fixture: ComponentFixture<EditSubscriptionDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ EditSubscriptionDialogComponent ]
})

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',
@@ -31,24 +61,24 @@ export class EditSubscriptionDialogComponent implements OnInit {
];
constructor(@Inject(MAT_DIALOG_DATA) public data: any, private dialog: MatDialog, private postsService: PostsService) {
this.sub = this.data.sub;
this.sub = JSON.parse(JSON.stringify(this.data.sub));
this.new_sub = JSON.parse(JSON.stringify(this.sub));
// ignore videos to keep requests small
delete this.sub['videos'];
delete this.new_sub['videos'];
this.audioOnlyMode = this.sub.type === 'audio';
this.download_all = !this.sub.timerange;
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 +101,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 +116,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

@@ -8,14 +8,24 @@
</mat-form-field>
</div>
<div style="margin-bottom: 10px; height: 40px;">
<div style="float: left">
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order&nbsp;</span>
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order&nbsp;</span>
<button (click)="togglePlaylistOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div>
<div style="float: right">
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add content">Add content</ng-container></button>
</div>
</div>
<!-- Playlist order -->
<mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of playlist.fileNames; let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
<mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of (reverse_order ? playlist.fileNames.slice().reverse() : playlist.fileNames); let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
</mat-button-toggle-group>
<div class="add-content-button">
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu">Add more content</button>
</div>
<mat-menu #menu="matMenu">
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file}}</button>
</mat-menu>
@@ -24,5 +34,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

@@ -30,11 +30,6 @@ border: none;
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.add-content-button {
margin-top: 15px;
margin-bottom: 10px;
}
.remove-item-button {
right: 10px;
position: absolute;

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ModifyPlaylistComponent } from './modify-playlist.component';
@@ -6,7 +6,7 @@ describe('ModifyPlaylistComponent', () => {
let component: ModifyPlaylistComponent;
let fixture: ComponentFixture<ModifyPlaylistComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ModifyPlaylistComponent ]
})

View File

@@ -15,6 +15,7 @@ export class ModifyPlaylistComponent implements OnInit {
available_files = [];
all_files = [];
playlist_updated = false;
reverse_order = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any,
private postsService: PostsService,
@@ -26,6 +27,8 @@ export class ModifyPlaylistComponent implements OnInit {
this.original_playlist = JSON.parse(JSON.stringify(this.data.playlist));
this.getFiles();
}
this.reverse_order = localStorage.getItem('default_playlist_order_reversed') === 'true';
}
getFiles() {
@@ -72,11 +75,23 @@ export class ModifyPlaylistComponent implements OnInit {
}
removeContent(index) {
if (this.reverse_order) {
index = this.playlist.fileNames.length - 1 - index;
}
this.playlist.fileNames.splice(index, 1);
this.processFiles();
}
togglePlaylistOrder() {
this.reverse_order = !this.reverse_order;
localStorage.setItem('default_playlist_order_reversed', '' + this.reverse_order);
}
drop(event: CdkDragDrop<string[]>) {
if (this.reverse_order) {
event.previousIndex = this.playlist.fileNames.length - 1 - event.previousIndex;
event.currentIndex = this.playlist.fileNames.length - 1 - event.currentIndex;
}
moveItemInArray(this.playlist.fileNames, event.previousIndex, event.currentIndex);
}

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SetDefaultAdminDialogComponent } from './set-default-admin-dialog.component';
@@ -6,7 +6,7 @@ describe('SetDefaultAdminDialogComponent', () => {
let component: SetDefaultAdminDialogComponent;
let fixture: ComponentFixture<SetDefaultAdminDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ SetDefaultAdminDialogComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ShareMediaDialogComponent } from './share-media-dialog.component';
@@ -6,7 +6,7 @@ describe('ShareMediaDialogComponent', () => {
let component: ShareMediaDialogComponent;
let fixture: ComponentFixture<ShareMediaDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ ShareMediaDialogComponent ]
})

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

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SubscribeDialogComponent } from './subscribe-dialog.component';
@@ -6,7 +6,7 @@ describe('SubscribeDialogComponent', () => {
let component: SubscribeDialogComponent;
let fixture: ComponentFixture<SubscribeDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ SubscribeDialogComponent ]
})

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

@@ -1,4 +1,4 @@
<h4 mat-dialog-title>{{sub.name}}</h4>
<h4 mat-dialog-title>{{sub.name}}&nbsp;<ng-container *ngIf="sub.paused" i18n="Paused suffix">(Paused)</ng-container></h4>
<mat-dialog-content>
<div class="info-item">

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { SubscriptionInfoDialogComponent } from './subscription-info-dialog.component';
@@ -6,7 +6,7 @@ describe('SubscriptionInfoDialogComponent', () => {
let component: SubscriptionInfoDialogComponent;
let fixture: ComponentFixture<SubscriptionInfoDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ SubscriptionInfoDialogComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { UpdateProgressDialogComponent } from './update-progress-dialog.component';
@@ -6,7 +6,7 @@ describe('UpdateProgressDialogComponent', () => {
let component: UpdateProgressDialogComponent;
let fixture: ComponentFixture<UpdateProgressDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ UpdateProgressDialogComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { UserProfileDialogComponent } from './user-profile-dialog.component';
@@ -6,7 +6,7 @@ describe('UserProfileDialogComponent', () => {
let component: UserProfileDialogComponent;
let fixture: ComponentFixture<UserProfileDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ UserProfileDialogComponent ]
})

View File

@@ -25,6 +25,10 @@
<div class="info-item-label"><strong><ng-container i18n="Video upload date property">Upload Date:</ng-container>&nbsp;</strong></div>
<div class="info-item-value">{{file.upload_date ? file.upload_date : 'N/A'}}</div>
</div>
<div class="info-item">
<div class="info-item-label"><strong><ng-container i18n="Category property">Category:</ng-container>&nbsp;</strong></div>
<div class="info-item-value"><ng-container *ngIf="file.category"><mat-chip-list><mat-chip>{{file.category.name}}</mat-chip></mat-chip-list></ng-container><ng-container *ngIf="!file.category">N/A</ng-container></div>
</div>
</mat-dialog-content>
<mat-dialog-actions>

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { VideoInfoDialogComponent } from './video-info-dialog.component';
@@ -6,7 +6,7 @@ describe('VideoInfoDialogComponent', () => {
let component: VideoInfoDialogComponent;
let fixture: ComponentFixture<VideoInfoDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ VideoInfoDialogComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { DownloadItemComponent } from './download-item.component';
@@ -6,7 +6,7 @@ describe('DownloadItemComponent', () => {
let component: DownloadItemComponent;
let fixture: ComponentFixture<DownloadItemComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ DownloadItemComponent ]
})

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { FileCardComponent } from './file-card.component';
@@ -6,7 +6,7 @@ describe('FileCardComponent', () => {
let component: FileCardComponent;
let fixture: ComponentFixture<FileCardComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ FileCardComponent ]
})

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

@@ -0,0 +1,34 @@
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable()
export class H401Interceptor implements HttpInterceptor {
constructor(private router: Router, private snackBar: MatSnackBar) { }
intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(catchError(err => {
if (err.status === 401) {
localStorage.setItem('jwt_token', null);
if (this.router.url !== '/login') {
this.router.navigate(['/login']).then(() => {
this.openSnackBar('Login expired, please login again.');
});
}
}
const error = err.error.message || err.statusText;
return throwError(error);
}));
}
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { InputDialogComponent } from './input-dialog.component';
@@ -6,7 +6,7 @@ describe('InputDialogComponent', () => {
let component: InputDialogComponent;
let fixture: ComponentFixture<InputDialogComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ InputDialogComponent ]
})

View File

@@ -182,97 +182,8 @@
</ng-template>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">
<app-recent-videos></app-recent-videos>
<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>
<mat-expansion-panel (opened)="accordionOpened('audio')" (closed)="accordionClosed('audio')" (mouseleave)="accordionLeft('audio')" (mouseenter)="accordionEntered('audio')" class="big">
<mat-expansion-panel-header>
<mat-panel-title>
<ng-container i18n="Audio files title">
Audio
</ng-container>
</mat-panel-title>
<mat-panel-description>
<ng-container i18n="Audio files description">
Your audio files are here
</ng-container>
</mat-panel-description>
</mat-expansion-panel-header>
<div *ngIf="mp3s.length > 0;else nomp3s">
<mat-grid-list style="margin-bottom: 15px;" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let file of mp3s; index as i;">
<app-file-card #audiofilecard (removeFile)="removeFromMp3($event)" [file]="file" [title]="file.title" [name]="file.id" [uid]="file.uid" [thumbnailURL]="file.thumbnailURL"
[length]="file.duration" [isAudio]="true" [use_youtubedl_archive]="use_youtubedl_archive"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['audio'][file.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<mat-divider></mat-divider>
<div style="width: 100%; text-align: center; margin-top: 10px;">
<h6 i18n="Playlists title">Playlists</h6>
</div>
<mat-grid-list *ngIf="playlists.audio.length > 0" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let playlist of playlists.audio; let i = index;">
<app-file-card #audiofilecard (removeFile)="removePlaylistMp3(playlist.id, i)" [title]="playlist.name" [name]="playlist.id" [thumbnailURL]="playlist_thumbnails[playlist.id]"
[length]="null" [isAudio]="true" [playlist]="playlist" [count]="playlist.fileNames.length" [use_youtubedl_archive]="use_youtubedl_archive"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['audio'][playlist.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<div class="add-playlist-button"><button (click)="openCreatePlaylistDialog('audio')" mat-fab><mat-icon>add</mat-icon></button></div>
<div *ngIf="playlists.audio.length === 0">
<ng-container i18n="No video playlists available text">
No playlists available. Create one from your downloading audio files by clicking the blue plus button.
</ng-container>
</div>
</div>
</mat-expansion-panel>
<mat-expansion-panel (opened)="accordionOpened('video')" (closed)="accordionClosed('video')" (mouseleave)="accordionLeft('video')" (mouseenter)="accordionEntered('video')" class="big">
<mat-expansion-panel-header>
<mat-panel-title>
<ng-container i18n="Video files title">
Video
</ng-container>
</mat-panel-title>
<mat-panel-description>
<ng-container i18n="Video files description">
Your video files are here
</ng-container>
</mat-panel-description>
</mat-expansion-panel-header>
<div *ngIf="mp4s.length > 0;else nomp4s">
<mat-grid-list style="margin-bottom: 15px;" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let file of mp4s; index as i;">
<app-file-card #videofilecard (removeFile)="removeFromMp4($event)" [file]="file" [title]="file.title" [name]="file.id" [uid]="file.uid" [thumbnailURL]="file.thumbnailURL"
[length]="file.duration" [isAudio]="false" [use_youtubedl_archive]="use_youtubedl_archive"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['video'][file.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<mat-divider></mat-divider>
<div style="width: 100%; text-align: center; margin-top: 10px;">
<h6 i18n="Playlists title">Playlists</h6>
</div>
<mat-grid-list *ngIf="playlists.video.length > 0" (window:resize)="onResize($event)" [cols]="files_cols" rowHeight="150px">
<mat-grid-tile *ngFor="let playlist of playlists.video; let i = index;">
<app-file-card #videofilecard (removeFile)="removePlaylistMp4(playlist.id, i)" [title]="playlist.name" [name]="playlist.id" [thumbnailURL]="playlist_thumbnails[playlist.id]"
[length]="null" [isAudio]="false" [playlist]="playlist" [count]="playlist.fileNames.length" [use_youtubedl_archive]="use_youtubedl_archive"></app-file-card>
<mat-progress-bar *ngIf="downloading_content['video'][playlist.id]" class="download-progress-bar" mode="indeterminate"></mat-progress-bar>
</mat-grid-tile>
</mat-grid-list>
<!-- Add video playlist button --<
<div class="add-playlist-button"><button (click)="openCreatePlaylistDialog('video')" mat-fab><mat-icon>add</mat-icon></button></div>
<div *ngIf="playlists.video.length === 0">
<ng-container i18n="No video playlists available text">
No playlists available. Create one from your downloading video files by clicking the blue plus button.
</ng-container>
</div>
</div>
</mat-expansion-panel>
</mat-accordion>
</div>-->

View File

@@ -1,4 +1,4 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { MainComponent } from './main.component';
@@ -6,7 +6,7 @@ describe('MainComponent', () => {
let component: MainComponent;
let fixture: ComponentFixture<MainComponent>;
beforeEach(async(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [ MainComponent ]
})

View File

@@ -1,25 +1,17 @@
import { Component, OnInit, ElementRef, ViewChild, ViewChildren, QueryList } from '@angular/core';
import {PostsService} from '../posts.services';
import {FileCardComponent} from '../file-card/file-card.component';
import { Observable } from 'rxjs/Observable';
import { Observable } from 'rxjs';
import {FormControl, Validators} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { saveAs } from 'file-saver';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/mapTo';
import 'rxjs/add/operator/toPromise';
import 'rxjs/add/observable/fromEvent'
import 'rxjs/add/operator/filter'
import 'rxjs/add/operator/debounceTime'
import 'rxjs/add/operator/do'
import 'rxjs/add/operator/switch'
import { YoutubeSearchService, Result } from '../youtube-search.service';
import { Router, ActivatedRoute } from '@angular/router';
import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.component';
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;
@@ -200,6 +192,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 = '';
@@ -250,13 +243,6 @@ export class MainComponent implements OnInit {
this.useDefaultDownloadingAgent = this.postsService.config['Advanced']['use_default_downloading_agent'];
this.customDownloadingAgent = this.postsService.config['Advanced']['custom_downloading_agent'];
if (this.fileManagerEnabled) {
this.getMp3s();
this.getMp4s();
}
if (this.youtubeSearchEnabled && this.youtubeAPIKey) {
this.youtubeSearch.initializeAPI(this.youtubeAPIKey);
this.attachToInput();
@@ -341,61 +327,6 @@ export class MainComponent implements OnInit {
this.setCols();
}
// file manager stuff
getMp3s() {
this.postsService.getMp3s().subscribe(result => {
const mp3s = result['mp3s'];
const playlists = result['playlists'];
// if they are different
if (JSON.stringify(this.mp3s) !== JSON.stringify(mp3s)) { this.mp3s = mp3s };
this.playlists.audio = playlists;
// get thumbnail url by using first video. this is a temporary hack
for (let i = 0; i < this.playlists.audio.length; i++) {
const playlist = this.playlists.audio[i];
let videoToExtractThumbnail = null;
for (let j = 0; j < this.mp3s.length; j++) {
if (this.mp3s[j].id === playlist.fileNames[0]) {
// found the corresponding file
videoToExtractThumbnail = this.mp3s[j];
}
}
if (videoToExtractThumbnail) { this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL; }
}
}, error => {
console.log(error);
});
}
getMp4s() {
this.postsService.getMp4s().subscribe(result => {
const mp4s = result['mp4s'];
const playlists = result['playlists'];
// if they are different
if (JSON.stringify(this.mp4s) !== JSON.stringify(mp4s)) { this.mp4s = mp4s };
this.playlists.video = playlists;
// get thumbnail url by using first video. this is a temporary hack
for (let i = 0; i < this.playlists.video.length; i++) {
const playlist = this.playlists.video[i];
let videoToExtractThumbnail = null;
for (let j = 0; j < this.mp4s.length; j++) {
if (this.mp4s[j].id === playlist.fileNames[0]) {
// found the corresponding file
videoToExtractThumbnail = this.mp4s[j];
}
}
if (videoToExtractThumbnail) { this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL; }
}
},
error => {
console.log(error);
});
}
public setCols() {
if (window.innerWidth <= 350) {
this.files_cols = 1;
@@ -443,50 +374,13 @@ export class MainComponent implements OnInit {
return null;
}
public removeFromMp3(name: string) {
for (let i = 0; i < this.mp3s.length; i++) {
if (this.mp3s[i].id === name || this.mp3s[i].id + '.mp3' === name) {
this.mp3s.splice(i, 1);
}
}
this.getMp3s();
}
public removePlaylistMp3(playlistID, index) {
this.postsService.removePlaylist(playlistID, 'audio').subscribe(res => {
if (res['success']) {
this.playlists.audio.splice(index, 1);
this.openSnackBar('Playlist successfully removed.', '');
}
this.getMp3s();
});
}
public removeFromMp4(name: string) {
for (let i = 0; i < this.mp4s.length; i++) {
if (this.mp4s[i].id === name || this.mp4s[i].id + '.mp4' === name) {
this.mp4s.splice(i, 1);
}
}
this.getMp4s();
}
public removePlaylistMp4(playlistID, index) {
this.postsService.removePlaylist(playlistID, 'video').subscribe(res => {
if (res['success']) {
this.playlists.video.splice(index, 1);
this.openSnackBar('Playlist successfully removed.', '');
}
this.getMp4s();
});
}
// download helpers
downloadHelperMp3(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode && !this.iOS) {
@@ -496,6 +390,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) {
@@ -508,22 +403,13 @@ export class MainComponent implements OnInit {
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
// reloads mp3s
if (this.fileManagerEnabled) {
this.getMp3s();
setTimeout(() => {
this.audioFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
}, 200);
}
}
downloadHelperMp4(name, uid, is_playlist = false, forceView = false, new_download = null, navigate_mode = false) {
this.downloadingfile = false;
if (this.multiDownloadMode && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
} else {
// if download only mode, just download the file. no redirect
if (forceView === false && this.downloadOnlyMode) {
@@ -533,6 +419,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) {
@@ -545,16 +432,6 @@ export class MainComponent implements OnInit {
// remove download from current downloads
this.removeDownloadFromCurrentDownloads(new_download);
// reloads mp4s
if (this.fileManagerEnabled) {
this.getMp4s();
setTimeout(() => {
this.videoFileCards.forEach(filecard => {
filecard.onHoverResponse();
});
}, 200);
}
}
// download click handler
@@ -746,9 +623,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, true).subscribe(delRes => {
// reload mp3s
this.getMp3s();
this.postsService.deleteFile(name, 'video').subscribe(delRes => {
});
}
});
@@ -763,9 +638,7 @@ export class MainComponent implements OnInit {
if (!this.fileManagerEnabled) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => {
// reload mp4s
this.getMp4s();
this.postsService.deleteFile(name, 'audio').subscribe(delRes => {
});
}
});
@@ -1112,25 +985,6 @@ export class MainComponent implements OnInit {
}
}
// creating a playlist
openCreatePlaylistDialog(type) {
const dialogRef = this.dialog.open(CreatePlaylistComponent, {
data: {
filesToSelectFrom: (type === 'audio') ? this.mp3s : this.mp4s,
type: type
}
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
if (type === 'audio') { this.getMp3s() };
if (type === 'video') { this.getMp4s() };
this.openSnackBar('Successfully created playlist!', '');
} else if (result === false) {
this.openSnackBar('ERROR: failed to create playlist!', '');
}
});
}
// modify custom args
openArgsModifierDialog() {
const dialogRef = this.dialog.open(ArgModifierDialogComponent, {
@@ -1161,4 +1015,10 @@ export class MainComponent implements OnInit {
}
});
}
reloadRecentVideos() {
if (this.recentVideos) {
this.recentVideos.getAllFiles();
}
}
}

View File

@@ -37,10 +37,8 @@
}
.spinner {
width: 50px;
height: 50px;
bottom: 3px;
left: 3px;
bottom: 1px;
left: 2px;
position: absolute;
}

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