Compare commits

..

247 Commits

Author SHA1 Message Date
Isaac Abadi
ccb4819a94 Adds small timeout to restart server API call
Fixes typo in translation description for video cropping
2021-07-22 20:38:42 -06:00
Isaac Abadi
ce8f90ca1d Reverted python3->python dockerfile changes and re-added python2 to dockerfile 2021-07-22 02:13:11 -06:00
Isaac Abadi
8469ae10ad Fixed issue where backend would crash if the details bin did not exist for youtube-dl 2021-07-22 02:10:14 -06:00
Isaac Abadi
f0e73c1708 python3 now aliases as python in Dockerfile 2021-07-22 01:50:51 -06:00
Isaac Abadi
aa1e36ae35 Updated dockerfile to download python3 for yt-dlp support 2021-07-21 23:59:00 -06:00
Isaac Abadi
a1841e84ca Added translations for Catalan, Czech, Indonesian, Portuguese, and Russian
Updated translations for German and French, and updated source translation files
2021-07-21 23:47:57 -06:00
Isaac Abadi
05909877f4 Fixed translation description typo 2021-07-21 23:27:59 -06:00
Isaac Abadi
90af895552 Updated style of settings for DB
MongoDB connection string test now only tests once
2021-07-21 23:25:59 -06:00
Isaac Abadi
9f908aa3fc Added ability to randomize playlists
Missing videos now show a more verbose error in the logs
2021-07-21 20:03:53 -06:00
Tzahi12345
b56b371ece Merge pull request #398 from Tzahi12345/dependabot/npm_and_yarn/backend/color-string-1.6.0
Bump color-string from 1.5.3 to 1.6.0 in /backend
2021-07-21 19:29:18 -06:00
Isaac Abadi
84e54cb4d5 Updated npm in auto build to v12
Added vscode tasks for launching frontend and backend in dev mode
2021-07-21 18:52:43 -06:00
Isaac Abadi
42aaecc13a Fixed bug where downloaded videos did not have a user_uid field 2021-07-20 23:40:06 -06:00
Isaac Abadi
aac11b2105 Set MongoDB port back to its default 2021-07-20 23:24:28 -06:00
Isaac Abadi
bbf94ef982 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2021-07-20 23:20:43 -06:00
Isaac Abadi
2876cf55db Added env var to docker-compose to enable config mutations by default 2021-07-20 23:20:31 -06:00
Tzahi12345
375d3b4f38 Merge pull request #336 from Tzahi12345/add-yt-dlp
Added yt-dlp support
2021-07-20 22:11:03 -06:00
Isaac Abadi
160cffc737 Added support for yt-dlp's --no-clean-infojson 2021-07-20 22:09:40 -06:00
Isaac Abadi
7aad7b7d24 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into add-yt-dlp 2021-07-20 21:55:18 -06:00
Isaac Abadi
380475b33e Updated tests to include query speed check and removed dubious test 2021-07-20 21:54:49 -06:00
Tzahi12345
384d365cf9 Merge pull request #378 from Tzahi12345/concurrent-streams-and-player-refactor
MongoDB support, concurrent streams, player/backend file handling refactor, and more!
2021-07-20 21:37:06 -06:00
Isaac Abadi
d6a43c76a4 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into concurrent-streams-and-player-refactor 2021-07-20 21:36:48 -06:00
Isaac Abadi
407333a314 Updated dev default.json 2021-07-20 21:34:33 -06:00
Isaac Abadi
0fb01469c4 Fixed issue in player component where errors were displayed in the console due to vars being changed after Angular detection
Fixed spooky issue where recent videos' navigateToFile stopped working
2021-07-20 21:29:49 -06:00
Isaac Abadi
d10eb4f2eb Fixed issue where old DB backup didn't work
Massive insertions to local DB are now split up into 30k chunks
2021-07-20 20:55:47 -06:00
Isaac Abadi
148ed9aa65 Added support for MongoDB indexing to increase query performance
Fixed db backup functionality
2021-07-18 23:18:46 -06:00
dependabot[bot]
1125de43d7 Bump color-string from 1.5.3 to 1.6.0 in /backend
Bumps [color-string](https://github.com/Qix-/color-string) from 1.5.3 to 1.6.0.
- [Release notes](https://github.com/Qix-/color-string/releases)
- [Changelog](https://github.com/Qix-/color-string/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Qix-/color-string/commits/1.6.0)

---
updated-dependencies:
- dependency-name: color-string
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-07-19 01:05:37 +00:00
Tzahi12345
00b591a9a4 Merge pull request #392 from itsthejoker/patch-1
Update default.json to use a longer subscription interval
2021-07-18 18:15:44 -06:00
Tzahi12345
06d9793d1a Merge pull request #389 from Tzahi12345/dependabot/npm_and_yarn/backend/glob-parent-5.1.2
Bump glob-parent from 5.1.1 to 5.1.2 in /backend
2021-07-18 18:13:49 -06:00
Isaac Abadi
0a2529330d Fixes issue in some browsers where the audio player disappears 2021-07-18 18:10:33 -06:00
Tzahi12345
19317dbddb Merge pull request #383 from ErwanGit/master
Update API docs links in settings
2021-07-18 17:46:37 -06:00
Isaac Abadi
3b74a2b5da Updated docker-compose to include mongodb instance 2021-07-18 17:41:46 -06:00
Isaac Abadi
a810628f15 Fixed DB migration for tables with no docs 2021-07-17 20:00:49 -06:00
Isaac Abadi
a7d349a71a Updated ES to 2019/2020 and local default.json is ignored for reloads when in dev mode 2021-07-17 19:42:32 -06:00
Isaac Abadi
f8c4653ae0 Added migration from old to new DB system 2021-07-16 00:10:35 -06:00
Isaac Abadi
bb6503e86d Changed DB structure again
Added support for MongoDB

Added tests relating to new DB system

Category rules are now case insensitive

Fixed playlist modification change state
2021-07-16 00:05:08 -06:00
Joe Kaufeld
dbbfc041a4 Update default.json to use a longer update period
See https://github.com/Tzahi12345/YoutubeDL-Material/issues/385 for context; setting this to a daily value instead of every five minutes means that updates still come in but it doesn't completely trample all other network traffic, especially if you have a lot of subscriptions.
2021-06-23 10:42:12 -04:00
dependabot[bot]
342dafd52a Bump glob-parent from 5.1.1 to 5.1.2 in /backend
Bumps [glob-parent](https://github.com/gulpjs/glob-parent) from 5.1.1 to 5.1.2.
- [Release notes](https://github.com/gulpjs/glob-parent/releases)
- [Changelog](https://github.com/gulpjs/glob-parent/blob/main/CHANGELOG.md)
- [Commits](https://github.com/gulpjs/glob-parent/compare/v5.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: glob-parent
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2021-06-11 14:32:16 +00:00
Isaac Abadi
984e990103 Fixed issue where categories could not be viewed 2021-06-08 16:32:35 -06:00
Isaac Abadi
4ea239170e If multiple videos exist in one URL, a playlist will be auto generated
Removed tomp3 and tomp4 routes, replaced with /downloadFile

Simplified category->playlist conversion

Simplified playlist creation

Simplified file deletion

Playlist duration calculation is now done on the backend (categories uses this now too)

removeIDFromArchive moved from subscriptions->utils

Added plumbing to support type agnostic playlists
2021-05-30 00:39:00 -06:00
Isaac Abadi
e2c31319cf Migrated playlist and subscription (per video and sub-wide) video downloading functionality to new schema
Migrated modify playlist component to new schema

Moved wait function and playlist generation function(s) to utils
- added tests for zip generation
2021-05-23 03:59:38 -06:00
Erwan
b933af03e2 Update API docs links in settings 2021-05-22 14:58:48 +02:00
Isaac Abadi
419fe3c3c6 Fixed frontend security issues for several depepndencies 2021-05-16 02:58:16 -06:00
Isaac Abadi
07b48a4da1 Fixed backend security issues with several dependencies 2021-05-16 02:55:27 -06:00
Isaac Abadi
a11445b80d Added backend tests and made authentication more testable 2021-05-16 02:54:15 -06:00
Isaac Abadi
297a4a3f34 Simplified streaming and file deletion functions 2021-05-16 02:53:36 -06:00
Isaac Abadi
1d2ab0dc41 401 errors will now not cause redirects in the /player route 2021-05-12 22:56:38 -06:00
Isaac Abadi
46f8579439 Refactored player component to utilize uids instead of fileNames to improve maintainability, consistency, and reliability
Playlists now use uids instead of fileNames

Added generic getPlaylist and updatePlaylist functions
2021-05-12 22:56:16 -06:00
Isaac Abadi
b3744e616d Users can now stream videos concurrently with other users with the new concurrent stream component 2021-05-12 22:52:46 -06:00
Isaac Abadi
de154a9c3e Updated dockerfile to fix UID/GID bug related to forever.js 2021-05-12 21:57:42 -06:00
Tzahi12345
9e71b1ff12 Merge pull request #359 from benashby/helm-chart
Helm chart improvements
2021-05-12 21:48:59 -06:00
Tzahi12345
6d318234b6 Merge pull request #360 from s55ma/patch-1
Update README.md
2021-03-28 19:23:04 -04:00
Isaac Abadi
49925848ff Material Icons are now hosted locally to avoid requesting them from Google for proxied users 2021-03-28 15:51:53 -04:00
s55ma
356a807cad Update README.md
Some packages are missing for Ubuntu/Debian install, especially python. Without python package, you get the following error when trying to download from youtube:

2021-03-28T15:28:30.461Z ERROR: Error while retrieving info on video with URL https://www.youtube.com/watch?v=[some_ID] with the following message: Error: Command failed with exit code 127: /root/youtubedl-material/node_modules/youtube-dl/bin/youtube-dl --dump-json -o video/%(title)s.mp4 --write-info-json --print-json -f bestvideo+bestaudio --merge-output-format mp4 --write-thumbnail http://www.youtube.com/watch?v=[some_ID]
2021-03-28T15:28:30.461Z ERROR: /usr/bin/env: 'python': No such file or directory
2021-03-28 17:33:47 +02:00
Ben Ashby
4e07440ed2 Removed Accidental Dir 2021-03-27 16:34:14 -06:00
Ben Ashby
59c9237be5 integrated pvc's 2021-03-26 09:59:02 -06:00
Ben Ashby
4ba4710741 Added helm chart 2021-03-26 09:46:20 -06:00
Isaac Abadi
addd54fefd Switched nodemon to foreverjs to hopefully enable restarting internally and fix runtime errors 2021-03-20 16:22:59 -06:00
Isaac Abadi
aefdde5401 Fixed issue (hopefully) where nodemon is not properly installed on Docker 2021-03-18 20:59:46 -06:00
Isaac Abadi
4c1f975eae Force nodemon to install during the container setup
Docker now starts through nodemon directly
2021-03-18 19:29:03 -06:00
Isaac Abadi
4c06bc750c Fixed issue where on some Docker environments the container failed to start due to the error "nodemon update check failed" 2021-03-17 19:13:52 -06:00
Isaac Abadi
4643efbae0 Added ability to restart the server from the frontend
Dockerfile/entrypoint.sh now uses nodemon enabling restarting from the UI in a container
2021-03-16 22:41:07 -06:00
Isaac Abadi
d11f77a6c9 Updated yt-dlp paths 2021-03-16 22:16:57 -06:00
Isaac Abadi
1f0153b17e Subscription videos being downloaded will get registered into the database as they are added to avoid having to wait until the subscription completes 2021-03-16 20:06:05 -06:00
Isaac Abadi
f32b394715 Added maxBuffer option to all downloads 2021-02-22 12:55:30 -07:00
Isaac Abadi
9d09eeffe3 Added maxbuffer option to subscriptions 2021-02-22 12:54:28 -07:00
Isaac Abadi
c660c28422 youtube-dl now updates in the same way as the other forks 2021-02-22 12:53:21 -07:00
Isaac Abadi
669c87dd1b Removed unecessary suffix in crop file inputs 2021-02-12 21:21:45 -07:00
Isaac Abadi
023df9c29d Fixed issue where playlists couldn't be favorited after downloading 2021-02-12 21:21:09 -07:00
Isaac Abadi
433d08e9df Added ability to crop files
Fixed bug in downloading playlists
2021-02-12 21:20:48 -07:00
Isaac Abadi
e34aa4d9d6 Adds Dutch language support 2021-01-31 19:47:14 -05:00
Isaac Abadi
3f9314a0c3 Fixed bug where categories selection logic had an out of range exception 2021-01-28 22:11:04 -05:00
Isaac Abadi
00a0ab460b Subscription's videos are now stripped from HTTP requests where they are not needed 2021-01-20 08:50:15 -05:00
Isaac Abadi
a1b32e2851 Added yt-dlp support
Simplified update youtube-dl code
2021-01-20 08:32:16 -05:00
Tzahi12345
b8cab673ae Merge pull request #316 from Tzahi12345/categories-playlist-fix
Categories playlist download fix
2021-01-13 16:13:22 -05:00
Isaac Abadi
6481102e01 Changes forEach loops in categorize() to regular for loops to facilitate early breaking 2021-01-13 16:12:11 -05:00
Isaac Abadi
af58854f0e Added info button to the player component 2021-01-13 12:50:18 -05:00
Isaac Abadi
d7d861ef0e Fixed typo in default custom output key for categories 2021-01-12 22:32:27 -05:00
Isaac Abadi
1d5490c0ff Allows playlists to be categorized based on the first video that matches 2021-01-12 22:08:42 -05:00
Isaac Abadi
28ee77cee0 Hotfix that allows playlists to be downloaded with categories 2021-01-12 16:42:30 -05:00
Isaac Abadi
133d848729 Fixed bug where deleting a file card wasn't possible if it was already deleted manually 2021-01-11 13:55:02 -05:00
Isaac Abadi
a78f4e99d0 Removed trivial browser log that occured at file deletion 2021-01-11 01:20:53 -05:00
Isaac Abadi
539bc5094a Fixed bug where sometimes a subscription video's thumbnail would get deleted twice and throw an error 2021-01-11 01:20:07 -05:00
Isaac Abadi
f0f2faa398 Sub's videos are removed from the post request when deleting a video as it's not needed 2021-01-11 01:19:29 -05:00
Isaac Abadi
7835185fe0 Made file card deletion much more reliable by finding out the index of the file on deletion rather than attempting to maintain a valid index 2021-01-11 01:18:58 -05:00
Isaac Abadi
95bb69f16b Fixed bug where videos would not delete in single-user mode 2021-01-10 17:14:10 -05:00
Isaac Abadi
a93aa080b3 Fixed bug where playlistd could not be made 2021-01-09 17:25:46 -05:00
Isaac Abadi
ed1375d40b Fixed bug where deleting videos while searching caused them to still show up in the UI 2021-01-09 14:07:51 -05:00
Isaac Abadi
db78e4ad5e Fixed bug where playlist downloads would fail and progress would not show (for playlist downloads) 2021-01-09 14:07:51 -05:00
Tzahi12345
6ef0082563 Merge pull request #304 from Tzahi12345/dependabot/npm_and_yarn/backend/axios-0.21.1
Bump axios from 0.21.0 to 0.21.1 in /backend
2021-01-06 09:55:01 -05:00
dependabot[bot]
b978007472 Bump axios from 0.21.0 to 0.21.1 in /backend
Bumps [axios](https://github.com/axios/axios) from 0.21.0 to 0.21.1.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v0.21.1/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v0.21.0...v0.21.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-01-06 10:25:29 +00:00
Isaac Abadi
c09dd7a03b Updated Chinese and Spanish translations and added Italian translations 2021-01-01 17:38:08 -05:00
Isaac Abadi
b6c09324d9 Updated error messages to make them more verbose and fixed ID3 tagging for file names 2021-01-01 17:35:14 -05:00
Tzahi12345
1a900399d8 Merge pull request #292 from diveflo/ci/releaseassetnamefix
Fix asset name in automated release creation
2020-12-29 18:13:18 -05:00
Isaac Abadi
ea959547fd Fixed bug where file indices were incorrectly assigned 2020-12-28 00:22:14 -05:00
Isaac Abadi
085849c7ee Fixed bug that prevented the menu for file cards from being opened (2) 2020-12-27 21:13:24 -05:00
Isaac Abadi
cf1dd43d36 Fixed bug that prevented the menu for file cards from being opened 2020-12-26 19:27:03 -05:00
Isaac Abadi
250f150587 Download checker now only runs if the video info was successfully retrieved 2020-12-26 18:56:01 -05:00
Isaac Abadi
dbf08e1276 Fixed bug where audio files that had a stale webm extension in the metadata file path would fail to register 2020-12-26 15:51:13 -05:00
Isaac Abadi
f74ce4b865 Fixed bug that caused the UI to fail loading after creating a user in multi-user mode 2020-12-26 15:35:13 -05:00
Florian Gabsteiger
8e4e0c7908 fix wrongly named ci step 2020-12-25 18:32:32 +01:00
Florian Gabsteiger
b0cb09309d Fix release asset name creation
The complete git ref name was used as part of the release asset filename for tagged commits.
This includes the refs/tags prefix, which fails as "/" characters can't be part of filenames.
To work around this, a step is added that extracts the pure tag name first.
2020-12-25 18:21:05 +01:00
Isaac Abadi
75c1c9e9b7 Fixed name of docker release workflow 2020-12-24 16:10:57 -05:00
Isaac Abadi
c19e0bb881 Adds manually-triggered GH workflow for release builds 2020-12-24 16:09:58 -05:00
Tzahi12345
a1af5496c7 Update README.md
Updated preview images in README
2020-12-24 03:41:38 -05:00
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
6717a59422 Fixed bug that preventing playlists from being downloaded a zip 2020-09-24 02:26:58 -04:00
Isaac Abadi
899633e124 Fixed bug that showed users their subscription videos after subscriptions were disabled 2020-09-20 23:13:56 -04:00
Isaac Abadi
8fdc231f08 Updated new home page UI to support file manager disabling and permissions
- file manager enabled state is now cached for faster loading
2020-09-18 11:22:45 -04:00
Isaac Abadi
ae8f7a2a33 Fixed bug that prevented playlists from being navigated to 2020-09-18 11:05:13 -04:00
Tzahi12345
d0782bb444 Update README.md
Updated API docs

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

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

Thumbnails are now auto downloaded along with the other metadata
2020-08-29 23:05:37 -04:00
Tzahi12345
70159813e5 Merge pull request #205 from Tzahi12345/add-ldap-auth
Added ability to register/login through LDAP
2020-08-26 04:30:43 -04:00
Tzahi12345
d292275956 Unfinished subscriptions will no longer cause an error during server startup 2020-08-24 05:13:27 -04:00
Tzahi12345
ba2acedb94 Files are now reloaded when you navigate back home 2020-08-24 05:13:01 -04:00
Tzahi12345
aa0558b770 Subscriptions are now reloaded on subscribe/unsubscribe in PostsService 2020-08-24 05:11:56 -04:00
Tzahi12345
d7f04fc90a Text for file duration in the unified file card component is now always black 2020-08-24 05:11:04 -04:00
Tzahi12345
087c9f1bb1 Added public directory to the gitignore 2020-08-24 02:44:52 -04:00
Tzahi12345
f874617965 Fixes bug where cached JWT token could prevent default admin creation 2020-08-24 02:44:39 -04:00
Tzahi12345
8fb8543829 Merge pull request #203 from Tzahi12345/arm-autobuild-test
Fix ARM autobuild
2020-08-24 02:20:19 -04:00
Tzahi12345
70d89d310c Removed unneeded hooks 2020-08-24 02:18:39 -04:00
Tzahi12345
c48aaaf13c Possible fix for arm autobuild (2) 2020-08-24 00:25:59 -04:00
Tzahi12345
6cf7ea193a Possible fix for arm autobuild 2020-08-24 00:21:10 -04:00
188 changed files with 43272 additions and 14305 deletions

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

@@ -0,0 +1,98 @@
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@v2
with:
node-version: '12'
cache: 'npm'
- 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: extract tag name
id: tag_name
run: echo ::set-output name=tag_name::${GITHUB_REF#refs/tags/}
- name: prepare release asset
shell: pwsh
run: Compress-Archive -Path ${{runner.temp}}/youtubedl-material -DestinationPath youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
- name: upload release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
asset_name: youtubedl-material-${{ steps.tag_name.outputs.tag_name }}.zip
asset_content_type: application/zip
- name: upload docker-compose asset
uses: actions/upload-release-asset@v1
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

32
.github/workflows/docker-release.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: docker-release
on:
workflow_dispatch:
inputs:
tags:
description: 'Docker tags'
required: true
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v2
- name: setup platform emulator
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: build & push images
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8
push: true
tags: ${{ github.event.inputs.tags }}

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

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

1
.gitignore vendored
View File

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

25
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"problemMatcher": [],
"label": "Dev: start frontend",
"detail": "ng serve"
},
{
"label": "Dev: start backend",
"type": "shell",
"command": "set YTDL_MODE=debug && node app.js",
"options": {
"cwd": "./backend"
},
"presentation": {
"reveal": "always",
"panel": "new"
},
"problemMatcher": []
}
]
}

View File

@@ -21,18 +21,23 @@ ENV UID=1000 \
GID=1000 \ GID=1000 \
USER=youtube USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV FOREVER_ROOT=/app/.forever
RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID RUN addgroup -S $USER -g $GID && adduser -D -S $USER -G $USER -u $UID
RUN apk add --no-cache \ RUN apk add --no-cache \
ffmpeg \ ffmpeg \
npm \ npm \
python2 \ python2 \
python3 \
su-exec \ su-exec \
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \ && apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing/ \
atomicparsley atomicparsley
WORKDIR /app WORKDIR /app
COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ] COPY --chown=$UID:$GID [ "backend/package.json", "backend/package-lock.json", "/app/" ]
RUN npm install forever -g
RUN npm install && chown -R $UID:$GID ./ RUN npm install && chown -R $UID:$GID ./
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ] COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
@@ -40,4 +45,4 @@ COPY --chown=$UID:$GID [ "/backend/", "/app/" ]
EXPOSE 17442 EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ] ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "node", "app.js" ] CMD [ "forever", "app.js" ]

View File

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

View File

@@ -1,12 +1,12 @@
# YoutubeDL-Material # 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! Now with [Docker](#Docker) support!
@@ -16,27 +16,35 @@ Check out the prerequisites, and go to the installation section. Easy as pie!
Here's an image of what it'll look like once you're done: Here's an image of what it'll look like once you're done:
![frontpage](https://i.imgur.com/w8iofbb.png) <img src="https://i.imgur.com/C6vFGbL.png" width="800">
With optional file management enabled (default):
![frontpage_with_files](https://i.imgur.com/FTATqBM.png)
Dark mode: Dark mode:
![dark_mode](https://i.imgur.com/r5ZtBqd.png) <img src="https://i.imgur.com/vOtvH5w.png" width="800">
### Prerequisites ### Prerequisites
NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide. 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 unzip python npm
``` ```
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: Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`) * AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
### Installing ### Installing
@@ -75,14 +83,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. 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. 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. 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 + port, and if so, you are done! 4. Make sure you can connect to the specified URL + *external* port, and if so, you are done!
NOTE: It is currently recommended that you use the `nightly` tag on Docker. To do so, simply update the docker-compose.yml `image` field so that it points to `tzahi12345/youtubedl-material:nightly`.
### Custom UID/GID ### Custom UID/GID
By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so: By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so:
``` ```yml
environment: environment:
UID: YOUR_UID UID: YOUR_UID
GID: YOUR_GID GID: YOUR_GID
@@ -90,7 +100,7 @@ environment:
## API ## API
[API Docs](https://stoplight.io/p/docs/gh/tzahi12345/youtubedl-material?group=master&utm_campaign=publish_dialog&utm_source=studio) [API Docs](https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml)
To get started, go to the settings menu and enable the public API from the *Extra* tab. You can generate an API key if one is missing. To get started, go to the settings menu and enable the public API from the *Extra* tab. You can generate an API key if one is missing.
@@ -109,8 +119,10 @@ If you're interested in translating the app into a new language, check out the [
* **Isaac Grynsztein** (me!) - *Initial work* * **Isaac Grynsztein** (me!) - *Initial work*
Official translators: Official translators:
* Spanish - tzahi12345 * Spanish - tzahi12345
* German - UnlimitedCookies * German - UnlimitedCookies
* Chinese - TyRoyal
See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project. See also the list of [contributors](https://github.com/your/project/contributors) who participated in this project.

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -7,9 +7,12 @@
"Downloader": { "Downloader": {
"path-audio": "audio/", "path-audio": "audio/",
"path-video": "video/", "path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false "safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
@@ -17,13 +20,17 @@
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_multi_download_mode": true,
"enable_downloads_manager": true "enable_downloads_manager": true,
"allow_playlist_categorization": true
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
"API_key": "", "API_key": "",
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "" "youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -32,7 +39,8 @@
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300" "subscriptions_check_interval": "300",
"redownload_fresh_uploads": false
}, },
"Users": { "Users": {
"base_path": "users/", "base_path": "users/",
@@ -46,7 +54,12 @@
"searchFilter": "(uid={{username}})" "searchFilter": "(uid={{username}})"
} }
}, },
"Database": {
"use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": { "Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
"multi_user_mode": false, "multi_user_mode": false,

View File

@@ -1,12 +1,10 @@
const path = require('path'); const path = require('path');
const config_api = require('../config'); const config_api = require('../config');
const consts = require('../consts'); const consts = require('../consts');
var subscriptions_api = require('../subscriptions')
const fs = require('fs-extra'); const fs = require('fs-extra');
var jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4'); const { uuid } = require('uuidv4');
var bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
var LocalStrategy = require('passport-local').Strategy; var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth'); var LdapStrategy = require('passport-ldapauth');
@@ -15,15 +13,15 @@ var JwtStrategy = require('passport-jwt').Strategy,
// other required vars // other required vars
let logger = null; let logger = null;
var users_db = null; let db_api = null;
let SERVER_SECRET = null; let SERVER_SECRET = null;
let JWT_EXPIRATION = null; let JWT_EXPIRATION = null;
let opts = null; let opts = null;
let saltRounds = null; let saltRounds = null;
exports.initialize = function(input_users_db, input_logger) { exports.initialize = function(db_api, input_logger) {
setLogger(input_logger) setLogger(input_logger)
setDB(input_users_db); setDB(db_api);
/************************* /*************************
* Authentication module * Authentication module
@@ -33,21 +31,19 @@ exports.initialize = function(input_users_db, input_logger) {
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration'); JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
SERVER_SECRET = null; SERVER_SECRET = null;
if (users_db.get('jwt_secret').value()) { if (db_api.users_db.get('jwt_secret').value()) {
SERVER_SECRET = users_db.get('jwt_secret').value(); SERVER_SECRET = db_api.users_db.get('jwt_secret').value();
} else { } else {
SERVER_SECRET = uuid(); SERVER_SECRET = uuid();
users_db.set('jwt_secret', SERVER_SECRET).write(); db_api.users_db.set('jwt_secret', SERVER_SECRET).write();
} }
opts = {} opts = {}
opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt'); opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt');
opts.secretOrKey = SERVER_SECRET; opts.secretOrKey = SERVER_SECRET;
/*opts.issuer = 'example.com';
opts.audience = 'example.com';*/
exports.passport.use(new JwtStrategy(opts, function(jwt_payload, done) { exports.passport.use(new JwtStrategy(opts, async function(jwt_payload, done) {
const user = users_db.get('users').find({uid: jwt_payload.user}).value(); const user = await db_api.getRecord('users', {uid: jwt_payload.user});
if (user) { if (user) {
return done(null, user); return done(null, user);
} else { } else {
@@ -61,8 +57,8 @@ function setLogger(input_logger) {
logger = input_logger; logger = input_logger;
} }
function setDB(input_users_db) { function setDB(input_db_api) {
users_db = input_users_db; db_api = input_db_api;
} }
exports.passport = require('passport'); exports.passport = require('passport');
@@ -78,7 +74,7 @@ exports.passport.deserializeUser(function(user, done) {
/*************************************** /***************************************
* Register user with hashed password * Register user with hashed password
**************************************/ **************************************/
exports.registerUser = function(req, res) { exports.registerUser = async function(req, res) {
var userid = req.body.userid; var userid = req.body.userid;
var username = req.body.username; var username = req.body.username;
var plaintextPassword = req.body.password; var plaintextPassword = req.body.password;
@@ -89,21 +85,27 @@ exports.registerUser = function(req, res) {
return; return;
} }
if (plaintextPassword === "") {
res.sendStatus(400);
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
return;
}
bcrypt.hash(plaintextPassword, saltRounds) bcrypt.hash(plaintextPassword, saltRounds)
.then(function(hash) { .then(async function(hash) {
let new_user = generateUserObject(userid, username, hash); let new_user = generateUserObject(userid, username, hash);
// check if user exists // check if user exists
if (users_db.get('users').find({uid: userid}).value()) { if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken! // user id is taken!
logger.error('Registration failed: UID is already taken!'); logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!'); res.status(409).send('UID is already taken!');
} else if (users_db.get('users').find({name: username}).value()) { } else if (await db_api.getRecord('users', {name: username})) {
// user name is taken! // user name is taken!
logger.error('Registration failed: User name is already taken!'); logger.error('Registration failed: User name is already taken!');
res.status(409).send('User name is already taken!'); res.status(409).send('User name is already taken!');
} else { } else {
// add to db // add to db
users_db.get('users').push(new_user).write(); await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`); logger.verbose(`New user created: ${new_user.name}`);
res.send({ res.send({
user: new_user user: new_user
@@ -136,16 +138,18 @@ exports.registerUser = function(req, res) {
************************************************/ ************************************************/
exports.login = async (username, password) => {
const user = await db_api.getRecord('users', {name: username});
if (!user) { logger.error(`User ${username} not found`); false }
if (user.auth_method && user.auth_method !== 'internal') { return false }
return await bcrypt.compare(password, user.passhash) ? user : false;
}
exports.passport.use(new LocalStrategy({ exports.passport.use(new LocalStrategy({
usernameField: 'username', usernameField: 'username',
passwordField: 'password'}, passwordField: 'password'},
function(username, password, done) { async function(username, password, done) {
const user = users_db.get('users').find({name: username}).value(); return done(null, await exports.login(username, password));
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);
}
} }
)); ));
@@ -156,17 +160,17 @@ var getLDAPConfiguration = function(req, callback) {
}; };
exports.passport.use(new LdapStrategy(getLDAPConfiguration, exports.passport.use(new LdapStrategy(getLDAPConfiguration,
function(user, done) { async function(user, done) {
// check if ldap auth is enabled // check if ldap auth is enabled
const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap'; const ldap_enabled = config_api.getConfigItem('ytdl_auth_method') === 'ldap';
if (!ldap_enabled) return done(null, false); if (!ldap_enabled) return done(null, false);
const user_uid = user.uid; const user_uid = user.uid;
let db_user = users_db.get('users').find({uid: user_uid}).value(); let db_user = await db_api.getRecord('users', {uid: user_uid});
if (!db_user) { if (!db_user) {
// generate DB user // generate DB user
let new_user = generateUserObject(user_uid, user_uid, null, 'ldap'); let new_user = generateUserObject(user_uid, user_uid, null, 'ldap');
users_db.get('users').push(new_user).write(); await db_api.insertRecordIntoTable('users', new_user);
db_user = new_user; db_user = new_user;
logger.verbose(`Generated new user ${user_uid} using LDAP`); logger.verbose(`Generated new user ${user_uid} using LDAP`);
} }
@@ -190,11 +194,11 @@ exports.generateJWT = function(req, res, next) {
next(); next();
} }
exports.returnAuthResponse = function(req, res) { exports.returnAuthResponse = async function(req, res) {
res.status(200).json({ res.status(200).json({
user: req.user, user: req.user,
token: req.token, token: req.token,
permissions: exports.userPermissions(req.user.uid), permissions: await exports.userPermissions(req.user.uid),
available_permissions: consts['AVAILABLE_PERMISSIONS'] available_permissions: consts['AVAILABLE_PERMISSIONS']
}); });
} }
@@ -207,7 +211,7 @@ exports.returnAuthResponse = function(req, res) {
* It also passes the user object to the next * It also passes the user object to the next
* middleware through res.locals * middleware through res.locals
**************************************/ **************************************/
exports.ensureAuthenticatedElseError = function(req, res, next) { exports.ensureAuthenticatedElseError = (req, res, next) => {
var token = getToken(req.query); var token = getToken(req.query);
if( token ) { if( token ) {
try { try {
@@ -225,29 +229,26 @@ exports.ensureAuthenticatedElseError = function(req, res, next) {
} }
// change password // change password
exports.changeUserPassword = async function(user_uid, new_pass) { exports.changeUserPassword = async (user_uid, new_pass) => {
return new Promise(resolve => { try {
bcrypt.hash(new_pass, saltRounds) const hash = await bcrypt.hash(new_pass, saltRounds);
.then(function(hash) { await db_api.updateRecord('users', {uid: user_uid}, {passhash: hash});
users_db.get('users').find({uid: user_uid}).assign({passhash: hash}).write(); return true;
resolve(true); } catch (err) {
}).catch(err => { return false;
resolve(false); }
});
});
} }
// change user permissions // change user permissions
exports.changeUserPermissions = function(user_uid, permission, new_value) { exports.changeUserPermissions = async (user_uid, permission, new_value) => {
try { try {
const user_db_obj = users_db.get('users').find({uid: user_uid}); await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permissions', permission);
user_db_obj.get('permissions').pull(permission).write(); await db_api.pullFromRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
user_db_obj.get('permission_overrides').pull(permission).write();
if (new_value === 'yes') { if (new_value === 'yes') {
user_db_obj.get('permissions').push(permission).write(); await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permissions', permission);
user_db_obj.get('permission_overrides').push(permission).write(); await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
} else if (new_value === 'no') { } else if (new_value === 'no') {
user_db_obj.get('permission_overrides').push(permission).write(); await db_api.pushToRecordsArray('users', {uid: user_uid}, 'permission_overrides', permission);
} }
return true; return true;
} catch (err) { } catch (err) {
@@ -257,12 +258,11 @@ exports.changeUserPermissions = function(user_uid, permission, new_value) {
} }
// change role permissions // change role permissions
exports.changeRolePermissions = function(role, permission, new_value) { exports.changeRolePermissions = async (role, permission, new_value) => {
try { try {
const role_db_obj = users_db.get('roles').get(role); await db_api.pullFromRecordsArray('roles', {key: role}, 'permissions', permission);
role_db_obj.get('permissions').pull(permission).write();
if (new_value === 'yes') { if (new_value === 'yes') {
role_db_obj.get('permissions').push(permission).write(); await db_api.pushToRecordsArray('roles', {key: role}, 'permissions', permission);
} }
return true; return true;
} catch (err) { } catch (err) {
@@ -271,68 +271,42 @@ exports.changeRolePermissions = function(role, permission, new_value) {
} }
} }
exports.adminExists = function() { exports.adminExists = async function() {
return !!users_db.get('users').find({uid: 'admin'}).value(); return !!(await db_api.getRecord('users', {uid: 'admin'}));
} }
// video stuff // video stuff
exports.getUserVideos = function(user_uid, type) { exports.getUserVideos = async function(user_uid, type) {
const user = users_db.get('users').find({uid: user_uid}).value(); const files = await db_api.getRecords('files', {user_uid: user_uid});
return user['files'][type]; return type ? files.filter(file => file.isAudio === (type === 'audio')) : files;
} }
exports.getUserVideo = function(user_uid, file_uid, type, requireSharing = false) { exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
if (!type) { let file = await db_api.getRecord('files', {file_uid: file_uid});
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();
// prevent unauthorized users from accessing the file info // prevent unauthorized users from accessing the file info
if (requireSharing && !file['sharingEnabled']) file = null; if (file && !file['sharingEnabled'] && requireSharing) file = null;
return file; return file;
} }
exports.addPlaylist = function(user_uid, new_playlist, type) { exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).push(new_playlist).write(); users_db.get('users').find({uid: user_uid}).get(`playlists`).find({id: playlistID}).assign({fileNames: new_filenames});
return true; return true;
} }
exports.updatePlaylistFiles = function(user_uid, playlistID, new_filenames, type) { exports.removePlaylist = async function(user_uid, playlistID) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).find({id: playlistID}).assign({fileNames: new_filenames}); await db_api.removeRecord('playlist', {playlistID: playlistID});
return true; return true;
} }
exports.removePlaylist = function(user_uid, playlistID, type) { exports.getUserPlaylists = async function(user_uid, user_files = null) {
users_db.get('users').find({uid: user_uid}).get(`playlists.${type}`).remove({id: playlistID}).write(); return await db_api.getRecords('playlists', {user_uid: user_uid});
return true;
} }
exports.getUserPlaylists = function(user_uid, type) { exports.getUserPlaylist = async function(user_uid, playlistID, requireSharing = false) {
const user = users_db.get('users').find({uid: user_uid}).value(); let playlist = await db_api.getRecord('playlists', {id: playlistID});
return user['playlists'][type];
}
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();
// prevent unauthorized users from accessing the file info // prevent unauthorized users from accessing the file info
if (requireSharing && !playlist['sharingEnabled']) playlist = null; if (requireSharing && !playlist['sharingEnabled']) playlist = null;
@@ -340,108 +314,23 @@ exports.getUserPlaylist = function(user_uid, playlistID, type, requireSharing =
return playlist; return playlist;
} }
exports.registerUserFile = function(user_uid, file_object, type) { exports.changeSharingMode = async function(user_uid, file_uid, is_playlist, enabled) {
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
.remove({
path: file_object['path']
}).write();
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
.push(file_object)
.write();
}
exports.deleteUserFile = function(user_uid, file_uid, type, blacklistMode = false) {
let success = false; let success = false;
const file_obj = users_db.get('users').find({uid: user_uid}).get(`files.${type}`).find({uid: file_uid}).value(); is_playlist ? await db_api.updateRecord(`playlists`, {id: file_uid}, {sharingEnabled: enabled}) : await db_api.updateRecord(`files`, {uid: file_uid}, {sharingEnabled: enabled});
if (file_obj) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
// close descriptors
if (config_api.descriptors[file_obj.id]) {
try {
for (let i = 0; i < config_api.descriptors[file_obj.id].length; i++) {
config_api.descriptors[file_obj.id][i].destroy();
}
} catch(e) {
}
}
const full_path = path.join(usersFileFolder, user_uid, type, file_obj.id + ext);
users_db.get('users').find({uid: user_uid}).get(`files.${type}`)
.remove({
uid: file_uid
}).write();
if (fs.existsSync(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);
}
fs.unlinkSync(full_path);
// do archive stuff
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const archive_path = path.join(usersFileFolder, user_uid, 'archives', `archive_${type}.txt`);
// use subscriptions API to remove video from the archive file, and write it to the blacklist
if (fs.existsSync(archive_path)) {
const line = youtube_id ? 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);
}
} else {
logger.info(`Could not find archive file for ${type} files. Creating...`);
fs.ensureFileSync(archive_path);
}
}
}
success = true; success = true;
} else {
success = false;
logger.warn(`User file ${file_uid} does not exist!`);
}
return success; return success;
} }
exports.changeSharingMode = function(user_uid, file_uid, type, is_playlist, enabled) { exports.userHasPermission = async function(user_uid, permission) {
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});
if (file_db_obj.value()) {
success = true;
file_db_obj.assign({sharingEnabled: enabled}).write();
}
}
return success; const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
}
exports.userHasPermission = function(user_uid, permission) {
const user_obj = users_db.get('users').find({uid: user_uid}).value();
const role = user_obj['role']; const role = user_obj['role'];
if (!role) { if (!role) {
// role doesn't exist // role doesn't exist
logger.error('Invalid role ' + role); logger.error('Invalid role ' + role);
return false; return false;
} }
const role_permissions = (users_db.get('roles').value())['permissions']; const role_permissions = (await db_api.getRecords('roles'))['permissions'];
const user_has_explicit_permission = user_obj['permissions'].includes(permission); const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission); const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
@@ -464,16 +353,17 @@ exports.userHasPermission = function(user_uid, permission) {
} }
} }
exports.userPermissions = function(user_uid) { exports.userPermissions = async function(user_uid) {
let user_permissions = []; let user_permissions = [];
const user_obj = users_db.get('users').find({uid: user_uid}).value(); const user_obj = await db_api.getRecord('users', ({uid: user_uid}));
const role = user_obj['role']; const role = user_obj['role'];
if (!role) { if (!role) {
// role doesn't exist // role doesn't exist
logger.error('Invalid role ' + role); logger.error('Invalid role ' + role);
return null; return null;
} }
const role_permissions = users_db.get('roles').get(role).get('permissions').value() const role_obj = await db_api.getRecord('roles', {key: role});
const role_permissions = role_obj['permissions'];
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) { for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i]; let permission = consts['AVAILABLE_PERMISSIONS'][i];
@@ -519,14 +409,8 @@ function generateUserObject(userid, username, hash, auth_method = 'internal') {
name: username, name: username,
uid: userid, uid: userid,
passhash: auth_method === 'internal' ? hash : null, passhash: auth_method === 'internal' ? hash : null,
files: { files: [],
audio: [], playlists: [],
video: []
},
playlists: {
audio: [],
video: []
},
subscriptions: [], subscriptions: [],
created: Date.now(), created: Date.now(),
role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user', role: userid === 'admin' && auth_method === 'internal' ? 'admin' : 'user',

149
backend/categories.js Normal file
View File

@@ -0,0 +1,149 @@
const config_api = require('./config');
const utils = require('./utils');
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_jsons) {
// to make the logic easier, let's assume the file metadata is an array
if (!Array.isArray(file_jsons)) file_jsons = [file_jsons];
let selected_category = null;
const categories = await getCategories();
if (!categories) {
logger.warn('Categories could not be found.');
return null;
}
for (let i = 0; i < file_jsons.length; i++) {
const file_json = file_jsons[i];
for (let j = 0; j < categories.length; j++) {
const category = categories[j];
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;
}
async function getCategories() {
const categories = await db_api.getRecords('categories');
return categories ? categories : null;
}
async function getCategoriesAsPlaylists(files = null) {
const categories_as_playlists = [];
const available_categories = await getCategories();
if (available_categories && files) {
for (category of available_categories) {
const files_that_match = utils.addUIDsToCategory(category, files);
if (files_that_match && files_that_match.length > 0) {
category['thumbnailURL'] = files_that_match[0].thumbnailURL;
category['thumbnailPath'] = files_that_match[0].thumbnailPath;
category['duration'] = files_that_match.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
category['id'] = category['uid'];
categories_as_playlists.push(category);
}
}
}
return categories_as_playlists;
}
function applyCategoryRules(file_json, rules, category_name) {
let rules_apply = false;
for (let i = 0; i < rules.length; i++) {
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']].toLowerCase().includes(rule['value'].toLowerCase());
break;
case 'not_includes':
rule_applies = !(file_json[rule['property']].toLowerCase().includes(rule['value'].toLowerCase()));
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,
getCategories: getCategories,
getCategoriesAsPlaylists: getCategoriesAsPlaylists
}

View File

@@ -184,9 +184,12 @@ DEFAULT_CONFIG = {
"Downloader": { "Downloader": {
"path-audio": "audio/", "path-audio": "audio/",
"path-video": "video/", "path-video": "video/",
"default_file_output": "",
"use_youtubedl_archive": false, "use_youtubedl_archive": false,
"custom_args": "", "custom_args": "",
"safe_download_override": false "safe_download_override": false,
"include_thumbnail": true,
"include_metadata": true
}, },
"Extra": { "Extra": {
"title_top": "YoutubeDL-Material", "title_top": "YoutubeDL-Material",
@@ -194,13 +197,17 @@ DEFAULT_CONFIG = {
"allow_quality_select": true, "allow_quality_select": true,
"download_only_mode": false, "download_only_mode": false,
"allow_multi_download_mode": true, "allow_multi_download_mode": true,
"enable_downloads_manager": true "enable_downloads_manager": true,
"allow_playlist_categorization": true
}, },
"API": { "API": {
"use_API_key": false, "use_API_key": false,
"API_key": "", "API_key": "",
"use_youtube_API": false, "use_youtube_API": false,
"youtube_API_key": "" "youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_auto_download_chat": false
}, },
"Themes": { "Themes": {
"default_theme": "default", "default_theme": "default",
@@ -209,7 +216,8 @@ DEFAULT_CONFIG = {
"Subscriptions": { "Subscriptions": {
"allow_subscriptions": true, "allow_subscriptions": true,
"subscriptions_base_path": "subscriptions/", "subscriptions_base_path": "subscriptions/",
"subscriptions_check_interval": "300" "subscriptions_check_interval": "300",
"redownload_fresh_uploads": false
}, },
"Users": { "Users": {
"base_path": "users/", "base_path": "users/",
@@ -223,7 +231,12 @@ DEFAULT_CONFIG = {
"searchFilter": "(uid={{username}})" "searchFilter": "(uid={{username}})"
} }
}, },
"Database": {
"use_local_db": false,
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": { "Advanced": {
"default_downloader": "youtube-dl",
"use_default_downloading_agent": true, "use_default_downloading_agent": true,
"custom_downloading_agent": "", "custom_downloading_agent": "",
"multi_user_mode": false, "multi_user_mode": false,

View File

@@ -18,6 +18,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_video_folder_path', 'key': 'ytdl_video_folder_path',
'path': 'YoutubeDLMaterial.Downloader.path-video' 'path': 'YoutubeDLMaterial.Downloader.path-video'
}, },
'ytdl_default_file_output': {
'key': 'ytdl_default_file_output',
'path': 'YoutubeDLMaterial.Downloader.default_file_output'
},
'ytdl_use_youtubedl_archive': { 'ytdl_use_youtubedl_archive': {
'key': 'ytdl_use_youtubedl_archive', 'key': 'ytdl_use_youtubedl_archive',
'path': 'YoutubeDLMaterial.Downloader.use_youtubedl_archive' 'path': 'YoutubeDLMaterial.Downloader.use_youtubedl_archive'
@@ -30,6 +34,14 @@ let CONFIG_ITEMS = {
'key': 'ytdl_safe_download_override', 'key': 'ytdl_safe_download_override',
'path': 'YoutubeDLMaterial.Downloader.safe_download_override' 'path': 'YoutubeDLMaterial.Downloader.safe_download_override'
}, },
'ytdl_include_thumbnail': {
'key': 'ytdl_include_thumbnail',
'path': 'YoutubeDLMaterial.Downloader.include_thumbnail'
},
'ytdl_include_metadata': {
'key': 'ytdl_include_metadata',
'path': 'YoutubeDLMaterial.Downloader.include_metadata'
},
// Extra // Extra
'ytdl_title_top': { 'ytdl_title_top': {
@@ -56,6 +68,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_enable_downloads_manager', 'key': 'ytdl_enable_downloads_manager',
'path': 'YoutubeDLMaterial.Extra.enable_downloads_manager' 'path': 'YoutubeDLMaterial.Extra.enable_downloads_manager'
}, },
'ytdl_allow_playlist_categorization': {
'key': 'ytdl_allow_playlist_categorization',
'path': 'YoutubeDLMaterial.Extra.allow_playlist_categorization'
},
// API // API
'ytdl_use_api_key': { 'ytdl_use_api_key': {
@@ -74,6 +90,18 @@ let CONFIG_ITEMS = {
'key': 'ytdl_youtube_api_key', 'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.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 // Themes
'ytdl_default_theme': { 'ytdl_default_theme': {
@@ -102,6 +130,10 @@ let CONFIG_ITEMS = {
'key': 'ytdl_subscriptions_check_interval', 'key': 'ytdl_subscriptions_check_interval',
'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval' 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval'
}, },
'ytdl_subscriptions_redownload_fresh_uploads': {
'key': 'ytdl_subscriptions_redownload_fresh_uploads',
'path': 'YoutubeDLMaterial.Subscriptions.redownload_fresh_uploads'
},
// Users // Users
'ytdl_users_base_path': { 'ytdl_users_base_path': {
@@ -121,7 +153,21 @@ let CONFIG_ITEMS = {
'path': 'YoutubeDLMaterial.Users.ldap_config' 'path': 'YoutubeDLMaterial.Users.ldap_config'
}, },
// Database
'ytdl_use_local_db': {
'key': 'ytdl_use_local_db',
'path': 'YoutubeDLMaterial.Database.use_local_db'
},
'ytdl_mongodb_connection_string': {
'key': 'ytdl_mongodb_connection_string',
'path': 'YoutubeDLMaterial.Database.mongodb_connection_string'
},
// Advanced // Advanced
'ytdl_default_downloader': {
'key': 'ytdl_default_downloader',
'path': 'YoutubeDLMaterial.Advanced.default_downloader'
},
'ytdl_use_default_downloading_agent': { 'ytdl_use_default_downloading_agent': {
'key': 'ytdl_use_default_downloading_agent', 'key': 'ytdl_use_default_downloading_agent',
'path': 'YoutubeDLMaterial.Advanced.use_default_downloading_agent' 'path': 'YoutubeDLMaterial.Advanced.use_default_downloading_agent'
@@ -164,5 +210,5 @@ AVAILABLE_PERMISSIONS = [
module.exports = { module.exports = {
CONFIG_ITEMS: CONFIG_ITEMS, CONFIG_ITEMS: CONFIG_ITEMS,
AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS, AVAILABLE_PERMISSIONS: AVAILABLE_PERMISSIONS,
CURRENT_VERSION: 'v4.1' CURRENT_VERSION: 'v4.2'
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
CMD="node app.js" CMD="forever app.js"
# if the first arg starts with "-" pass it to program # if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then if [ "${1#-}" != "$1" ]; then

1292
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon -q app.js" "start": "nodemon app.js",
"debug": "set YTDL_MODE=debug && node app.js"
}, },
"nodemonConfig": { "nodemonConfig": {
"ignore": [ "ignore": [
@@ -14,7 +15,8 @@
"public/*" "public/*"
], ],
"watch": [ "watch": [
"restart.json" "restart_update.json",
"restart_general.json"
] ]
}, },
"repository": { "repository": {
@@ -30,6 +32,7 @@
"dependencies": { "dependencies": {
"archiver": "^3.1.1", "archiver": "^3.1.1",
"async": "^3.1.0", "async": "^3.1.0",
"axios": "^0.21.1",
"bcryptjs": "^2.4.0", "bcryptjs": "^2.4.0",
"compression": "^1.7.4", "compression": "^1.7.4",
"config": "^3.2.3", "config": "^3.2.3",
@@ -37,14 +40,18 @@
"express": "^4.17.1", "express": "^4.17.1",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0", "fs-extra": "^9.0.0",
"glob": "^7.1.6",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"lowdb": "^1.0.0", "lowdb": "^1.0.0",
"md5": "^2.2.1", "md5": "^2.2.1",
"merge-files": "^0.1.2", "merge-files": "^0.1.2",
"mocha": "^8.4.0",
"moment": "^2.29.1",
"mongodb": "^3.6.9",
"multer": "^1.4.2", "multer": "^1.4.2",
"node-fetch": "^2.6.0", "node-fetch": "^2.6.1",
"node-id3": "^0.1.14", "node-id3": "^0.1.14",
"nodemon": "^2.0.2", "nodemon": "^2.0.7",
"passport": "^0.4.1", "passport": "^0.4.1",
"passport-http": "^0.3.0", "passport-http": "^0.3.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",

View File

@@ -6,20 +6,21 @@ var path = require('path');
var youtubedl = require('youtube-dl'); var youtubedl = require('youtube-dl');
const config_api = require('./config'); 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'; const debugMode = process.env.YTDL_MODE === 'debug';
var logger = null; var logger = null;
var db = null; var db = null;
var users_db = null; var users_db = null;
var db_api = null; let 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 setDB(input_db_api) { db_api = input_db_api }
function setLogger(input_logger) { logger = input_logger; } function setLogger(input_logger) { logger = input_logger; }
function initialize(input_db, input_users_db, input_logger, input_db_api) { function initialize(input_db_api, input_logger) {
setDB(input_db, input_users_db, input_db_api); setDB(input_db_api);
setLogger(input_logger); setLogger(input_logger);
} }
@@ -33,12 +34,7 @@ async function subscribe(sub, user_uid = null) {
sub.isPlaylist = sub.url.includes('playlist'); sub.isPlaylist = sub.url.includes('playlist');
sub.videos = []; sub.videos = [];
let url_exists = false; let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
if (user_uid)
url_exists = !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({url: sub.url}).value()
else
url_exists = !!db.get('subscriptions').find({url: sub.url}).value();
if (!sub.name && url_exists) { if (!sub.name && url_exists) {
logger.error(`Sub with the same URL "${sub.url}" already exists -- please provide a custom name for this new subscription.`); logger.error(`Sub with the same URL "${sub.url}" already exists -- please provide a custom name for this new subscription.`);
@@ -47,19 +43,12 @@ async function subscribe(sub, user_uid = null) {
return; return;
} }
// add sub to db sub['user_uid'] = user_uid ? user_uid : undefined;
let sub_db = null; await db_api.insertRecordIntoTable('subscriptions', sub);
if (user_uid) {
users_db.get('users').find({uid: user_uid}).get('subscriptions').push(sub).write();
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
} else {
db.get('subscriptions').push(sub).write();
sub_db = db.get('subscriptions').find({id: sub.id});
}
let success = await getSubscriptionInfo(sub, user_uid); let success = await getSubscriptionInfo(sub, user_uid);
if (success) { if (success) {
sub = sub_db.value();
getVideosForSub(sub, user_uid); getVideosForSub(sub, user_uid);
} else { } else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.') logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
@@ -79,18 +68,19 @@ async function getSubscriptionInfo(sub, user_uid = null) {
else else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
return new Promise(resolve => {
// get videos // get videos
let downloadConfig = ['--dump-json', '--playlist-end', '1']; let downloadConfig = ['--dump-json', '--playlist-end', '1'];
let useCookies = config_api.getConfigItem('ytdl_use_cookies'); let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) { if (useCookies) {
if (fs.existsSync(path.join(__dirname, 'appdata', 'cookies.txt'))) { if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt')); downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else { } else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.'); logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
} }
} }
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (debugMode) { if (debugMode) {
logger.info('Subscribe: got info for subscription ' + sub.id); logger.info('Subscribe: got info for subscription ' + sub.id);
} }
@@ -113,13 +103,14 @@ async function getSubscriptionInfo(sub, user_uid = null) {
continue; continue;
} }
if (!sub.name) { 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 it's now valid, update
if (sub.name) { if (sub.name) {
if (user_uid) await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub.name});
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
else
db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write();
} }
} }
@@ -135,10 +126,8 @@ async function getSubscriptionInfo(sub, user_uid = null) {
// updates subscription // updates subscription
sub.archive = archive_dir; sub.archive = archive_dir;
if (user_uid)
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write(); await db_api.updateRecord('subscriptions', {id: sub.id}, {archive: archive_dir});
else
db.get('subscriptions').find({id: sub.id}).assign({archive: archive_dir}).write();
} }
// TODO: get even more info // TODO: get even more info
@@ -152,7 +141,6 @@ async function getSubscriptionInfo(sub, user_uid = null) {
} }
async function unsubscribe(sub, deleteMode, user_uid = null) { async function unsubscribe(sub, deleteMode, user_uid = null) {
return new Promise(async resolve => {
let basePath = null; let basePath = null;
if (user_uid) if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions'); basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -161,10 +149,8 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
let result_obj = { success: false, error: '' }; let result_obj = { success: false, error: '' };
let id = sub.id; let id = sub.id;
if (user_uid) await db_api.removeRecord('subscriptions', {id: id});
users_db.get('users').find({uid: user_uid}).get('subscriptions').remove({id: id}).write(); await db_api.removeAllRecords('files', {sub_id: id});
else
db.get('subscriptions').remove({id: id}).write();
// failed subs have no name, on unsubscribe they shouldn't error // failed subs have no name, on unsubscribe they shouldn't error
if (!sub.name) { if (!sub.name) {
@@ -172,99 +158,86 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
} }
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
if (deleteMode && fs.existsSync(appendedBasePath)) { if (deleteMode && (await fs.pathExists(appendedBasePath))) {
if (sub.archive && fs.existsSync(sub.archive)) { if (sub.archive && (await fs.pathExists(sub.archive))) {
const archive_file_path = path.join(sub.archive, 'archive.txt'); const archive_file_path = path.join(sub.archive, 'archive.txt');
// deletes archive if it exists // deletes archive if it exists
if (fs.existsSync(archive_file_path)) { if (await fs.pathExists(archive_file_path)) {
fs.unlinkSync(archive_file_path); await fs.unlink(archive_file_path);
} }
fs.rmdirSync(sub.archive); await fs.rmdir(sub.archive);
} }
deleteFolderRecursive(appendedBasePath); await fs.remove(appendedBasePath);
} }
});
} }
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) { async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
// TODO: combine this with deletefile
let basePath = null; let basePath = null;
let sub_db = null; basePath = user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions')
if (user_uid) { : config_api.getConfigItem('ytdl_subscriptions_base_path');
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
sub_db = users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id});
} else {
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
sub_db = db.get('subscriptions').find({id: sub.id});
}
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
const appendedBasePath = getAppendedBasePath(sub, basePath); const appendedBasePath = getAppendedBasePath(sub, basePath);
const name = file; const name = file;
let retrievedID = null; let retrievedID = null;
sub_db.get('videos').remove({uid: file_uid}).write();
return new Promise(resolve => { await db_api.removeRecord('files', {uid: file_uid});
let filePath = appendedBasePath; let filePath = appendedBasePath;
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4' const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4'
var jsonPath = path.join(__dirname,filePath,name+'.info.json'); var jsonPath = path.join(__dirname,filePath,name+'.info.json');
var videoFilePath = path.join(__dirname,filePath,name+ext); var videoFilePath = path.join(__dirname,filePath,name+ext);
var imageFilePath = path.join(__dirname,filePath,name+'.jpg'); var imageFilePath = path.join(__dirname,filePath,name+'.jpg');
var altImageFilePath = path.join(__dirname,filePath,name+'.jpg'); var altImageFilePath = path.join(__dirname,filePath,name+'.webp');
jsonExists = fs.existsSync(jsonPath); const [jsonExists, videoFileExists, imageFileExists, altImageFileExists] = await Promise.all([
videoFileExists = fs.existsSync(videoFilePath); fs.pathExists(jsonPath),
imageFileExists = fs.existsSync(imageFilePath); fs.pathExists(videoFilePath),
altImageFileExists = fs.existsSync(altImageFilePath); fs.pathExists(imageFilePath),
fs.pathExists(altImageFilePath),
]);
if (jsonExists) { if (jsonExists) {
retrievedID = JSON.parse(fs.readFileSync(jsonPath, 'utf8'))['id']; retrievedID = JSON.parse(await fs.readFile(jsonPath, 'utf8'))['id'];
fs.unlinkSync(jsonPath); await fs.unlink(jsonPath);
} }
if (imageFileExists) { if (imageFileExists) {
fs.unlinkSync(imageFilePath); await fs.unlink(imageFilePath);
} }
if (altImageFileExists) { if (altImageFileExists) {
fs.unlinkSync(altImageFilePath); await fs.unlink(altImageFilePath);
} }
if (videoFileExists) { if (videoFileExists) {
fs.unlink(videoFilePath, function(err) { await fs.unlink(videoFilePath);
if (fs.existsSync(jsonPath) || fs.existsSync(videoFilePath)) { if ((await fs.pathExists(jsonPath)) || (await fs.pathExists(videoFilePath))) {
resolve(false); return false;
} else { } else {
// check if the user wants the video to be redownloaded (deleteForever === false) // check if the user wants the video to be redownloaded (deleteForever === false)
if (!deleteForever && useArchive && sub.archive && retrievedID) { if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt') const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID // if archive exists, remove line with video ID
if (fs.existsSync(archive_path)) { if (await fs.pathExists(archive_path)) {
removeIDFromArchive(archive_path, retrievedID); utils.removeIDFromArchive(archive_path, retrievedID);
} }
} }
resolve(true); return true;
} }
});
} else { } else {
// TODO: tell user that the file didn't exist // TODO: tell user that the file didn't exist
resolve(true); return true;
} }
});
} }
async function getVideosForSub(sub, user_uid = null) { async function getVideosForSub(sub, user_uid = null) {
return new Promise(resolve => { const latest_sub_obj = await getSubscription(sub.id);
if (!subExists(sub.id, user_uid)) { if (!latest_sub_obj || latest_sub_obj['downloading']) {
resolve(false); return false;
return;
} }
// get sub_db updateSubscriptionProperty(sub, {downloading: true}, user_uid);
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 // get basePath
let basePath = null; let basePath = null;
@@ -273,10 +246,8 @@ async function getVideosForSub(sub, user_uid = null) {
else else
basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); basePath = config_api.getConfigItem('ytdl_subscriptions_base_path');
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive'); let appendedBasePath = getAppendedBasePath(sub, basePath);
fs.ensureDirSync(appendedBasePath);
let appendedBasePath = null
appendedBasePath = getAppendedBasePath(sub, basePath);
let multiUserMode = null; let multiUserMode = null;
if (user_uid) { if (user_uid) {
@@ -286,14 +257,104 @@ async function getVideosForSub(sub, user_uid = null) {
} }
} }
const ext = (sub.type && sub.type === 'audio') ? '.mp3' : '.mp4' const downloadConfig = await generateArgsForSubscription(sub, user_uid);
let fullOutput = appendedBasePath + '/%(title)s' + ext; // get videos
if (sub.custom_output) { logger.verbose('Subscription: getting videos for subscription ' + sub.name);
fullOutput = appendedBasePath + '/' + sub.custom_output + ext;
return new Promise(async resolve => {
const preimported_file_paths = [];
const PREIMPORT_INTERVAL = 5000;
const preregister_check = setInterval(async () => {
if (sub.streamingOnly) return;
await db_api.preimportUnregisteredSubscriptionFile(sub, appendedBasePath);
}, PREIMPORT_INTERVAL);
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
const outputs = err.stdout.split(/\r\n|\r|\n/);
for (let i = 0; i < outputs.length; i++) {
const output = JSON.parse(outputs[i]);
await handleOutputJSON(sub, output, i === 0, multiUserMode)
if (err.stderr.includes(output['id']) && archive_path) {
// we found a video that errored! add it to the archive to prevent future errors
if (sub.archive) {
archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt')
fs.appendFileSync(archive_path, output['id']);
}
}
}
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
}
}
resolve(false);
} else if (output) {
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;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
} }
let downloadConfig = ['-o', fullOutput, '-ciw', '--write-info-json', '--print-json']; const reset_videos = i === 0;
await handleOutputJSON(sub, output_json, multiUserMode, preimported_file_paths, reset_videos);
}
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
}
resolve(true);
}
});
}, err => {
logger.error(err);
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
clearInterval(preregister_check);
});
}
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; let qualityPath = null;
if (sub.type && sub.type === 'audio') { if (sub.type && sub.type === 'audio') {
@@ -301,7 +362,8 @@ async function getVideosForSub(sub, user_uid = null) {
qualityPath.push('-x'); qualityPath.push('-x');
qualityPath.push('--audio-format', 'mp3'); qualityPath.push('--audio-format', 'mp3');
} else { } else {
qualityPath = ['-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4'] 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) downloadConfig.push(...qualityPath)
@@ -319,7 +381,7 @@ async function getVideosForSub(sub, user_uid = null) {
let archive_dir = null; let archive_dir = null;
let archive_path = null; let archive_path = null;
if (useArchive) { if (useArchive && !redownload) {
if (sub.archive) { if (sub.archive) {
archive_dir = sub.archive; archive_dir = sub.archive;
archive_path = path.join(archive_dir, 'archive.txt') archive_path = path.join(archive_dir, 'archive.txt')
@@ -332,75 +394,29 @@ async function getVideosForSub(sub, user_uid = null) {
downloadConfig = ['-f', 'best', '--dump-json']; downloadConfig = ['-f', 'best', '--dump-json'];
} }
if (sub.timerange) { if (sub.timerange && !redownload) {
downloadConfig.push('--dateafter', sub.timerange); downloadConfig.push('--dateafter', sub.timerange);
} }
let useCookies = config_api.getConfigItem('ytdl_use_cookies'); let useCookies = config_api.getConfigItem('ytdl_use_cookies');
if (useCookies) { if (useCookies) {
if (fs.existsSync(path.join(__dirname, 'appdata', 'cookies.txt'))) { if (await fs.pathExists(path.join(__dirname, 'appdata', 'cookies.txt'))) {
downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt')); downloadConfig.push('--cookies', path.join('appdata', 'cookies.txt'));
} else { } else {
logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.'); logger.warn('Cookies file could not be found. You can either upload one, or disable \'use cookies\' in the Advanced tab in the settings.');
} }
} }
// get videos if (config_api.getConfigItem('ytdl_include_thumbnail')) {
logger.verbose('Subscription: getting videos for subscription ' + sub.name); downloadConfig.push('--write-thumbnail');
youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) {
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr);
if (err.stderr.includes('This video is unavailable')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
const outputs = err.stdout.split(/\r\n|\r|\n/);
for (let i = 0; i < outputs.length; i++) {
const output = JSON.parse(outputs[i]);
handleOutputJSON(sub, sub_db, output, i === 0, multiUserMode)
if (err.stderr.includes(output['id']) && archive_path) {
// we found a video that errored! add it to the archive to prevent future errors
fs.appendFileSync(archive_path, output['id']);
}
}
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
}
}
resolve(false);
} else if (output) {
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
logger.verbose('No additional videos to download for ' + sub.name);
resolve(true);
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
} }
const reset_videos = i === 0; return downloadConfig;
handleOutputJSON(sub, sub_db, output_json, multiUserMode, reset_videos);
// TODO: Potentially store downloaded files in db?
}
resolve(true);
}
});
}, err => {
logger.error(err);
});
} }
function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_videos = false) { async function handleOutputJSON(sub, output_json, multiUserMode = null, reset_videos = false) {
if (sub.streamingOnly) { // TODO: remove streaming only mode
if (false && sub.streamingOnly) {
if (reset_videos) { if (reset_videos) {
sub_db.assign({videos: []}).write(); sub_db.assign({videos: []}).write();
} }
@@ -411,38 +427,109 @@ function handleOutputJSON(sub, sub_db, output_json, multiUserMode = null, reset_
// add to db // add to db
sub_db.get('videos').push(output_json).write(); sub_db.get('videos').push(output_json).write();
} else { } else {
db_api.registerFileDB(path.basename(output_json['_filename']), sub.type, multiUserMode, sub); path_object = path.parse(output_json['_filename']);
const path_string = path.format(path_object);
const file_exists = await db_api.getRecord('files', {path: path_string, sub_id: sub.id});
if (file_exists) {
// TODO: fix issue where files of different paths due to custom path get downloaded multiple times
// file already exists in DB, return early to avoid reseting the download date
return;
}
await db_api.registerFileDB2(output_json['_filename'], sub.type, sub.user_uid, null, sub.id);
const url = output_json['webpage_url'];
if (sub.type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_use_twitch_api') && config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
const file_name = path.basename(output_json['_filename']);
const id = file_name.substring(0, file_name.length-4);
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, id, sub.type, multiUserMode.user, sub);
}
} }
} }
function getAllSubscriptions(user_uid = null) { async function getSubscriptions(user_uid = null) {
if (user_uid) return await db_api.getRecords('subscriptions', {user_uid: user_uid});
return users_db.get('users').find({uid: user_uid}).get('subscriptions').value();
else
return db.get('subscriptions').value();
} }
function getSubscription(subID, user_uid = null) { async function getAllSubscriptions() {
if (user_uid) const all_subs = await db_api.getRecords('subscriptions');
return users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value(); const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
else return all_subs.filter(sub => !!(sub.user_uid) === multiUserMode);
return db.get('subscriptions').find({id: subID}).value();
} }
function updateSubscription(sub, user_uid = null) { async function getSubscription(subID) {
if (user_uid) { return await db_api.getRecord('subscriptions', {id: subID});
users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: sub.id}).assign(sub).write(); }
} else {
db.get('subscriptions').find({id: sub.id}).assign(sub).write(); async function getSubscriptionByName(subName, user_uid = null) {
} return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
async function updateSubscription(sub, user_uid = null) {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
return true; return true;
} }
function subExists(subID, user_uid = null) { async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
if (user_uid) subs.forEach(async sub => {
return !!users_db.get('users').find({uid: user_uid}).get('subscriptions').find({id: subID}).value(); await updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
else });
return !!db.get('subscriptions').find({id: subID}).value(); }
async function updateSubscriptionProperty(sub, assignment_obj, user_uid = null) {
// TODO: combine with updateSubscription
await db_api.updateRecord('subscriptions', {id: sub.id}, assignment_obj);
return true;
}
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, '')) {
await 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, async (err, output) => {
if (err) {
// video is not available anymore for whatever reason
} else if (output) {
const metric_to_compare = sub.type === 'audio' ? 'abr' : 'height';
if (output[metric_to_compare] > file_obj[metric_to_compare]) {
// download new video as the simulated one is better
youtubedl.exec(file_obj['url'], downloadConfig, {maxBuffer: Infinity}, async (err, output) => {
if (err) {
logger.verbose(`Failed to download better version of video ${file_obj['id']}`);
} else if (output) {
logger.verbose(`Successfully upgraded video ${file_obj['id']}'s ${metric_to_compare} from ${file_obj[metric_to_compare]} to ${output[metric_to_compare]}`);
await db_api.setVideoProperty(file_obj['uid'], {[metric_to_compare]: output[metric_to_compare]}, user_uid, sub['id']);
}
});
}
}
});
await db_api.setVideoProperty(file_obj['uid'], {'fresh_upload': false}, user_uid, sub['id']);
} }
// helper functions // helper functions
@@ -452,57 +539,17 @@ function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name); return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
} }
// 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'});
if (!data) {
logger.error('Archive could not be found.');
return;
}
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
fs.writeFileSync(archive_path, updatedData);
if (line) return line;
if (err) throw err;
}
module.exports = { module.exports = {
getSubscription : getSubscription, getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getSubscriptions : getSubscriptions,
getAllSubscriptions : getAllSubscriptions, getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription, updateSubscription : updateSubscription,
subscribe : subscribe, subscribe : subscribe,
unsubscribe : unsubscribe, unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile, deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub, getVideosForSub : getVideosForSub,
removeIDFromArchive : removeIDFromArchive,
setLogger : setLogger, setLogger : setLogger,
initialize : initialize initialize : initialize,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple
} }

290
backend/test/tests.js Normal file
View File

@@ -0,0 +1,290 @@
var assert = require('assert');
const low = require('lowdb')
var winston = require('winston');
process.chdir('./backend')
const FileSync = require('lowdb/adapters/FileSync');
const adapter = new FileSync('./appdata/db.json');
const db = low(adapter)
const users_adapter = new FileSync('./appdata/users.json');
const users_db = low(users_adapter);
const defaultFormat = winston.format.printf(({ level, message, label, timestamp }) => {
return `${timestamp} ${level.toUpperCase()}: ${message}`;
});
let debugMode = process.env.YTDL_MODE === 'debug';
const logger = winston.createLogger({
level: 'info',
format: winston.format.combine(winston.format.timestamp(), defaultFormat),
defaultMeta: {},
transports: [
//
// - Write to all logs with level `info` and below to `combined.log`
// - Write all logs error (and below) to `error.log`.
//
new winston.transports.File({ filename: 'appdata/logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'appdata/logs/combined.log' }),
new winston.transports.Console({level: 'debug', name: 'console'})
]
});
var auth_api = require('../authentication/auth');
var db_api = require('../db');
const utils = require('../utils');
const subscriptions_api = require('../subscriptions');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
db_api.initialize(db, users_db, logger);
describe('Database', async function() {
describe('Import', async function() {
it('Migrate', async function() {
await db_api.connectToDB();
await db_api.removeAllRecords();
const success = await db_api.importJSONToDB(db.value(), users_db.value());
assert(success);
});
it('Transfer to remote', async function() {
await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test: 'test'});
await db_api.transferDB(true);
const success = await db_api.getRecord('test', {test: 'test'});
assert(success);
});
it('Transfer to local', async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test: 'test'});
await db_api.transferDB(false);
const success = await db_api.getRecord('test', {test: 'test'});
assert(success);
});
});
describe('Export', function() {
});
describe('Basic functions', async function() {
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('test');
});
it('Add and read record', async function() {
await db_api.insertRecordIntoTable('test', {test_add: 'test', test_undefined: undefined, test_null: undefined});
const added_record = await db_api.getRecord('test', {test_add: 'test', test_undefined: undefined, test_null: null});
assert(added_record['test_add'] === 'test');
await db_api.removeRecord('test', {test_add: 'test'});
});
it('Update record', async function() {
await db_api.insertRecordIntoTable('test', {test_update: 'test'});
await db_api.updateRecord('test', {test_update: 'test'}, {added_field: true});
const updated_record = await db_api.getRecord('test', {test_update: 'test'});
assert(updated_record['added_field']);
await db_api.removeRecord('test', {test_update: 'test'});
});
it('Remove record', async function() {
await db_api.insertRecordIntoTable('test', {test_remove: 'test'});
const delete_succeeded = await db_api.removeRecord('test', {test_remove: 'test'});
assert(delete_succeeded);
const deleted_record = await db_api.getRecord('test', {test_remove: 'test'});
assert(!deleted_record);
});
it('Push to record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: []});
await db_api.pushToRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 1);
});
it('Pull from record array', async function() {
await db_api.insertRecordIntoTable('test', {test: 'test', test_array: ['test_item']});
await db_api.pullFromRecordsArray('test', {test: 'test'}, 'test_array', 'test_item');
const record = await db_api.getRecord('test', {test: 'test'});
assert(record);
assert(record['test_array'].length === 0);
});
it('Bulk add', async function() {
const NUM_RECORDS_TO_ADD = 2002; // max batch ops is 1000
const test_records = [];
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
test_records.push({
uid: uuid()
});
}
const succcess = await db_api.bulkInsertRecordsIntoTable('test', test_records);
const received_records = await db_api.getRecords('test');
assert(succcess && received_records && received_records.length === NUM_RECORDS_TO_ADD);
});
it('Bulk update', async function() {
// bulk add records
const NUM_RECORDS_TO_ADD = 100; // max batch ops is 1000
const test_records = [];
const update_obj = {};
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
const test_uid = uuid();
test_records.push({
uid: test_uid
});
update_obj[test_uid] = {added_field: true};
}
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
assert(success);
// makes sure they are added
const received_records = await db_api.getRecords('test');
assert(received_records && received_records.length === NUM_RECORDS_TO_ADD);
success = await db_api.bulkUpdateRecords('test', 'uid', update_obj);
assert(success);
const received_updated_records = await db_api.getRecords('test');
for (let i = 0; i < received_updated_records.length; i++) {
success &= received_updated_records[i]['added_field'];
}
assert(success);
});
it('Stats', async function() {
const stats = await db_api.getDBStats();
assert(stats);
});
it('Query speed', async function() {
this.timeout(120000);
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
const test_records = [];
let random_uid = '06241f83-d1b8-4465-812c-618dfa7f2943';
for (let i = 0; i < NUM_RECORDS_TO_ADD; i++) {
const uid = uuid();
if (i === NUM_RECORDS_TO_ADD/2) random_uid = uid;
test_records.push({"id":"A$AP Mob - Yamborghini High (Official Music Video) ft. Juicy J","title":"A$AP Mob - Yamborghini High (Official Music Video) ft. Juicy J","thumbnailURL":"https://i.ytimg.com/vi/tt7gP_IW-1w/maxresdefault.jpg","isAudio":true,"duration":312,"url":"https://www.youtube.com/watch?v=tt7gP_IW-1w","uploader":"asapmobVEVO","size":5060157,"path":"audio\\A$AP Mob - Yamborghini High (Official Music Video) ft. Juicy J.mp3","upload_date":"2016-05-11","description":"A$AP Mob ft. Juicy J - \"Yamborghini High\" Get it now on:\niTunes: http://smarturl.it/iYAMH?IQid=yt\nListen on Spotify: http://smarturl.it/sYAMH?IQid=yt\nGoogle Play: http://smarturl.it/gYAMH?IQid=yt\nAmazon: http://smarturl.it/aYAMH?IQid=yt\n\nFollow A$AP Mob:\nhttps://www.facebook.com/asapmobofficial\nhttps://twitter.com/ASAPMOB\nhttp://instagram.com/asapmob \nhttp://www.asapmob.com/\n\n#AsapMob #YamborghiniHigh #Vevo #HipHop #OfficialMusicVideo #JuicyJ","view_count":118689353,"height":null,"abr":160,"uid": uid,"registered":1626672120632});
}
const insert_start = Date.now();
let success = await db_api.bulkInsertRecordsIntoTable('test', test_records);
const insert_end = Date.now();
console.log(`Insert time: ${(insert_end - insert_start)/1000}s`);
const query_start = Date.now();
const random_record = await db_api.getRecord('test', {uid: random_uid});
const query_end = Date.now();
console.log(random_record)
console.log(`Query time: ${(query_end - query_start)/1000}s`);
success = !!random_record;
assert(success);
});
});
});
describe('Multi User', async function() {
let user = null;
const user_to_test = 'admin';
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const playlist_to_test = 'ysabVZz4x';
beforeEach(async function() {
await db_api.connectToDB();
auth_api.initialize(db_api, logger);
subscriptions_api.initialize(db_api, logger);
user = await auth_api.login('admin', 'pass');
});
describe('Authentication', function() {
it('login', async function() {
assert(user);
});
});
describe('Video player - normal', function() {
const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
it('Get video', async function() {
const video_obj = db_api.getVideo(video_to_test, 'admin');
assert(video_obj);
});
it('Video access - disallowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
assert(!video_obj);
});
it('Video access - allowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
assert(video_obj);
});
});
describe('Zip generators', function() {
it('Playlist zip generator', async function() {
const playlist = await db_api.getPlaylist(playlist_to_test, user_to_test);
assert(playlist);
const playlist_files_to_download = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const playlist_file = await db_api.getVideo(uid, user_to_test);
playlist_files_to_download.push(playlist_file);
}
const zip_path = await utils.createContainerZipFile(playlist, playlist_files_to_download);
const zip_exists = fs.pathExistsSync(zip_path);
assert(zip_exists);
if (zip_exists) fs.unlinkSync(zip_path);
});
it('Subscription zip generator', async function() {
const sub = await subscriptions_api.getSubscription(sub_to_test, user_to_test);
const sub_videos = await db_api.getRecords('files', {sub_id: sub.id});
assert(sub);
const sub_files_to_download = [];
for (let i = 0; i < sub_videos.length; i++) {
const sub_file = sub_videos[i];
sub_files_to_download.push(sub_file);
}
const zip_path = await utils.createContainerZipFile(sub, sub_files_to_download);
const zip_exists = fs.pathExistsSync(zip_path);
assert(zip_exists);
if (zip_exists) fs.unlinkSync(zip_path);
});
});
// describe('Video player - subscription', function() {
// const sub_to_test = '';
// const video_to_test = 'ebbcfffb-d6f1-4510-ad25-d1ec82e0477e';
// it('Get video', async function() {
// const video_obj = db_api.getVideo(video_to_test, 'admin', );
// assert(video_obj);
// });
// it('Video access - disallowed', async function() {
// await db_api.setVideoProperty(video_to_test, {sharingEnabled: false}, user_to_test, sub_to_test);
// const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
// assert(!video_obj);
// });
// it('Video access - allowed', async function() {
// await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test, sub_to_test);
// const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
// assert(video_obj);
// });
// });
});

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

@@ -1,9 +1,11 @@
var fs = require('fs-extra') const fs = require('fs-extra')
var path = require('path') const path = require('path')
const config_api = require('./config'); const config_api = require('./config');
const archiver = require('archiver');
const is_windows = process.platform === 'win32'; const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension
function getTrueFileName(unfixed_path, type) { function getTrueFileName(unfixed_path, type) {
let fixed_path = unfixed_path; let fixed_path = unfixed_path;
@@ -19,39 +21,75 @@ function getTrueFileName(unfixed_path, type) {
return fixed_path; return fixed_path;
} }
function getDownloadedFilesByType(basePath, type) { async function getDownloadedFilesByType(basePath, type, full_metadata = false) {
// return empty array if the path doesn't exist // return empty array if the path doesn't exist
if (!fs.existsSync(basePath)) return []; if (!(await fs.pathExists(basePath))) return [];
let files = []; let files = [];
const ext = type === 'audio' ? 'mp3' : 'mp4'; 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++) { for (let i = 0; i < located_files.length; i++) {
let file = located_files[i]; let file = located_files[i];
var file_path = path.basename(file); var file_path = file.substring(basePath.includes('\\') ? basePath.length+1 : basePath.length, file.length);
var stats = fs.statSync(file); var stats = await fs.stat(file);
var id = file_path.substring(0, file_path.length-4); var 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; if (!jsonobj) continue;
var title = jsonobj.title; if (full_metadata) {
var url = jsonobj.webpage_url; jsonobj['id'] = id;
var uploader = jsonobj.uploader; files.push(jsonobj);
continue;
}
var upload_date = jsonobj.upload_date; var upload_date = jsonobj.upload_date;
upload_date = upload_date ? `${upload_date.substring(0, 4)}-${upload_date.substring(4, 6)}-${upload_date.substring(6, 8)}` : null; 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 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); files.push(file_obj);
} }
return files; return files;
} }
async function createContainerZipFile(container_obj, container_file_objs) {
const container_files_to_download = [];
for (let i = 0; i < container_file_objs.length; i++) {
const container_file_obj = container_file_objs[i];
container_files_to_download.push(container_file_obj.path);
}
return await createZipFile(path.join('appdata', container_obj.name + '.zip'), container_files_to_download);
}
async function createZipFile(zip_file_path, file_paths) {
let output = fs.createWriteStream(zip_file_path);
var archive = archiver('zip', {
gzip: true,
zlib: { level: 9 } // Sets the compression level.
});
archive.on('error', function(err) {
logger.error(err);
throw err;
});
// pipe archive data to the output file
archive.pipe(output);
for (let file_path of file_paths) {
const file_name = path.parse(file_path).base;
archive.file(file_path, {name: file_name})
}
await archive.finalize();
// wait a tiny bit for the zip to reload in fs
await wait(100);
return zip_file_path;
}
function getJSONMp4(name, customPath, openReadPerms = false) { function getJSONMp4(name, customPath, openReadPerms = false) {
var obj = null; // output var obj = null; // output
if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path'); if (!customPath) customPath = config_api.getConfigItem('ytdl_video_folder_path');
@@ -84,10 +122,84 @@ function getJSONMp3(name, customPath, openReadPerms = false) {
return obj; return obj;
} }
function getJSON(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
let obj = null;
var jsonPath = removeFileExtension(file_path) + '.info.json';
var alternateJsonPath = removeFileExtension(file_path) + `${ext}.info.json`;
if (fs.existsSync(jsonPath))
{
obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} else if (fs.existsSync(alternateJsonPath)) {
obj = JSON.parse(fs.readFileSync(alternateJsonPath, 'utf8'));
}
else obj = 0;
return obj;
}
function getJSONByType(type, name, customPath, openReadPerms = false) { function getJSONByType(type, name, customPath, openReadPerms = false) {
return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms) return type === 'audio' ? getJSONMp3(name, customPath, openReadPerms) : getJSONMp4(name, customPath, openReadPerms)
} }
function getDownloadedThumbnail(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path') : config_api.getConfigItem('ytdl_video_folder_path');
let jpgPath = path.join(customPath, name + '.jpg');
let webpPath = path.join(customPath, name + '.webp');
let pngPath = path.join(customPath, name + '.png');
if (fs.existsSync(jpgPath))
return jpgPath;
else if (fs.existsSync(webpPath))
return webpPath;
else if (fs.existsSync(pngPath))
return pngPath;
else
return null;
}
function getDownloadedThumbnail2(file_path, type) {
const file_path_no_extension = removeFileExtension(file_path);
let jpgPath = file_path_no_extension + '.jpg';
let webpPath = file_path_no_extension + '.webp';
let pngPath = file_path_no_extension + '.png';
if (fs.existsSync(jpgPath))
return jpgPath;
else if (fs.existsSync(webpPath))
return webpPath;
else if (fs.existsSync(pngPath))
return pngPath;
else
return null;
}
function getExpectedFileSize(input_info_jsons) {
// treat single videos as arrays to have the file sizes checked/added to. makes the code cleaner
const info_jsons = Array.isArray(input_info_jsons) ? input_info_jsons : [input_info_jsons];
let expected_filesize = 0;
info_jsons.forEach(info_json => {
if (info_json['filesize']) {
expected_filesize += info_json['filesize'];
return;
}
const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0;
formats.forEach(format_id => {
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) {
individual_expected_filesize += available_format.filesize;
}
});
});
expected_filesize += individual_expected_filesize;
});
return expected_filesize;
}
function fixVideoMetadataPerms(name, type, customPath = null) { function fixVideoMetadataPerms(name, type, customPath = null) {
if (is_windows) return; if (is_windows) return;
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path') if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
@@ -110,17 +222,109 @@ function fixVideoMetadataPerms(name, type, customPath = null) {
} }
} }
function recFindByExt(base,ext,files,result) function fixVideoMetadataPerms2(file_path, type) {
if (is_windows) return;
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path);
const files_to_fix = [
// JSONs
file_path_no_extension + '.info.json',
file_path_no_extension + ext + '.info.json',
// Thumbnails
file_path_no_extension + '.webp',
file_path_no_extension + '.jpg'
];
for (const file of files_to_fix) {
if (!fs.existsSync(file)) continue;
fs.chmodSync(file, 0o644);
}
}
function deleteJSONFile(name, type, customPath = null) {
if (!customPath) customPath = type === 'audio' ? config_api.getConfigItem('ytdl_audio_folder_path')
: config_api.getConfigItem('ytdl_video_folder_path');
const ext = type === 'audio' ? '.mp3' : '.mp4';
let json_path = path.join(customPath, name + '.info.json');
let alternate_json_path = path.join(customPath, name + ext + '.info.json');
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
function deleteJSONFile2(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path);
let json_path = file_path_no_extension + '.info.json';
let alternate_json_path = file_path_no_extension + ext + '.info.json';
if (fs.existsSync(json_path)) fs.unlinkSync(json_path);
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
}
let dataArray = data.split('\n'); // convert file data in an array
const searchKeyword = id; // we are looking for a line, contains, key word id in the file
let lastIndex = -1; // let say, we have not found the keyword
for (let index=0; index<dataArray.length; index++) {
if (dataArray[index].includes(searchKeyword)) { // check if a line contains the id keyword
lastIndex = index; // found a line includes a id keyword
break;
}
}
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData);
if (line) return line;
if (err) throw err;
}
function durationStringToNumber(dur_str) {
if (typeof dur_str === 'number') return dur_str;
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length-1; i >= 0; i--) {
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
}
return num_sum;
}
function getMatchingCategoryFiles(category, files) {
return files && files.filter(file => file.category && file.category.uid === category.uid);
}
function addUIDsToCategory(category, files) {
const files_that_match = getMatchingCategoryFiles(category, files);
category['uids'] = files_that_match.map(file => file.uid);
return files_that_match;
}
async function recFindByExt(base,ext,files,result)
{ {
files = files || fs.readdirSync(base) files = files || (await fs.readdir(base))
result = result || [] result = result || []
files.forEach( for (const file of files) {
function (file) {
var newbase = path.join(base,file) var newbase = path.join(base,file)
if ( fs.statSync(newbase).isDirectory() ) if ( (await fs.stat(newbase)).isDirectory() )
{ {
result = recFindByExt(newbase,ext,fs.readdirSync(newbase),result) result = await recFindByExt(newbase,ext,await fs.readdir(newbase),result)
} }
else else
{ {
@@ -130,13 +334,28 @@ function recFindByExt(base,ext,files,result)
} }
} }
} }
)
return result return result
} }
function removeFileExtension(filename) {
const filename_parts = filename.split('.');
filename_parts.splice(filename_parts.length - 1);
return filename_parts.join('.');
}
/**
* setTimeout, but its a promise.
* @param {number} ms
*/
async function wait(ms) {
await new Promise(resolve => {
setTimeout(resolve, ms);
});
}
// objects // 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.id = id;
this.title = title; this.title = title;
this.thumbnailURL = thumbnailURL; this.thumbnailURL = thumbnailURL;
@@ -147,14 +366,32 @@ function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, p
this.size = size; this.size = size;
this.path = path; this.path = path;
this.upload_date = upload_date; this.upload_date = upload_date;
this.description = description;
this.view_count = view_count;
this.height = height;
this.abr = abr;
} }
module.exports = { module.exports = {
getJSONMp3: getJSONMp3, getJSONMp3: getJSONMp3,
getJSONMp4: getJSONMp4, getJSONMp4: getJSONMp4,
getJSON: getJSON,
getTrueFileName: getTrueFileName, getTrueFileName: getTrueFileName,
getDownloadedThumbnail: getDownloadedThumbnail,
getDownloadedThumbnail2: getDownloadedThumbnail2,
getExpectedFileSize: getExpectedFileSize,
fixVideoMetadataPerms: fixVideoMetadataPerms, fixVideoMetadataPerms: fixVideoMetadataPerms,
fixVideoMetadataPerms2: fixVideoMetadataPerms2,
deleteJSONFile: deleteJSONFile,
deleteJSONFile2: deleteJSONFile2,
removeIDFromArchive, removeIDFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType, getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
getMatchingCategoryFiles: getMatchingCategoryFiles,
addUIDsToCategory: addUIDsToCategory,
recFindByExt: recFindByExt, recFindByExt: recFindByExt,
removeFileExtension: removeFileExtension,
wait: wait,
File: File File: File
} }

23
chart/.helmignore Normal file
View File

@@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

24
chart/Chart.yaml Normal file
View File

@@ -0,0 +1,24 @@
apiVersion: v2
name: youtubedl-material
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.1.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "4.2"

22
chart/templates/NOTES.txt Normal file
View File

@@ -0,0 +1,22 @@
1. Get the application URL by running these commands:
{{- if .Values.ingress.enabled }}
{{- range $host := .Values.ingress.hosts }}
{{- range .paths }}
http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }}
{{- end }}
{{- end }}
{{- else if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "youtubedl-material.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "youtubedl-material.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "youtubedl-material.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "youtubedl-material.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@@ -0,0 +1,62 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "youtubedl-material.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "youtubedl-material.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "youtubedl-material.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "youtubedl-material.labels" -}}
helm.sh/chart: {{ include "youtubedl-material.chart" . }}
{{ include "youtubedl-material.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "youtubedl-material.selectorLabels" -}}
app.kubernetes.io/name: {{ include "youtubedl-material.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "youtubedl-material.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "youtubedl-material.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.appdata.enabled (not .Values.persistence.appdata.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-appdata
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.appdata.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.appdata.size | quote }}
{{- if .Values.persistence.appdata.storageClass }}
{{- if (eq "-" .Values.persistence.appdata.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.appdata.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.audio.enabled (not .Values.persistence.audio.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-audio
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.audio.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.audio.size | quote }}
{{- if .Values.persistence.audio.storageClass }}
{{- if (eq "-" .Values.persistence.audio.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.audio.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,121 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "youtubedl-material.fullname" . }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "youtubedl-material.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "youtubedl-material.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "youtubedl-material.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 17442
protocol: TCP
livenessProbe:
httpGet:
path: /
port: http
readinessProbe:
httpGet:
path: /
port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- mountPath: /app/appdata
name: appdata
{{- if .Values.persistence.appdata.subPath }}
subPath: {{ .Values.persistence.appdata.subPath }}
{{- end }}
- mountPath: /app/audio
name: audio
{{- if .Values.persistence.audio.subPath }}
subPath: {{ .Values.persistence.audio.subPath }}
{{- end }}
- mountPath: /app/video
name: video
{{- if .Values.persistence.video.subPath }}
subPath: {{ .Values.persistence.video.subPath }}
{{- end }}
- mountPath: /app/subscriptions
name: subscriptions
{{- if .Values.persistence.subscriptions.subPath }}
subPath: {{ .Values.persistence.subscriptions.subPath }}
{{- end }}
- mountPath: /app/users
name: users
{{- if .Values.persistence.users.subPath }}
subPath: {{ .Values.persistence.users.subPath }}
{{- end }}
volumes:
- name: appdata
{{- if .Values.persistence.appdata.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.appdata.existingClaim }}{{ .Values.persistence.appdata.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-appdata{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: audio
{{- if .Values.persistence.audio.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.audio.existingClaim }}{{ .Values.persistence.audio.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-audio{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: subscriptions
{{- if .Values.persistence.subscriptions.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.subscriptions.existingClaim }}{{ .Values.persistence.subscriptions.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-subscriptions{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: users
{{- if .Values.persistence.users.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.users.existingClaim }}{{ .Values.persistence.users.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-users{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
- name: video
{{- if .Values.persistence.video.enabled}}
persistentVolumeClaim:
claimName: {{ if .Values.persistence.video.existingClaim }}{{ .Values.persistence.video.existingClaim }}{{- else }}{{ template "youtubedl-material.fullname" . }}-video{{- end }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@@ -0,0 +1,41 @@
{{- if .Values.ingress.enabled -}}
{{- $fullName := include "youtubedl-material.fullname" . -}}
{{- $svcPort := .Values.service.port -}}
{{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
apiVersion: networking.k8s.io/v1beta1
{{- else -}}
apiVersion: extensions/v1beta1
{{- end }}
kind: Ingress
metadata:
name: {{ $fullName }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
backend:
serviceName: {{ $fullName }}
servicePort: {{ $svcPort }}
{{- end }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "youtubedl-material.fullname" . }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "youtubedl-material.selectorLabels" . | nindent 4 }}

View File

@@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "youtubedl-material.serviceAccountName" . }}
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.subscriptions.enabled (not .Values.persistence.subscriptions.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-subscriptions
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.subscriptions.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.subscriptions.size | quote }}
{{- if .Values.persistence.subscriptions.storageClass }}
{{- if (eq "-" .Values.persistence.subscriptions.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.subscriptions.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "youtubedl-material.fullname" . }}-test-connection"
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "youtubedl-material.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.users.enabled (not .Values.persistence.users.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-users
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.users.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.users.size | quote }}
{{- if .Values.persistence.users.storageClass }}
{{- if (eq "-" .Values.persistence.users.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.users.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

View File

@@ -0,0 +1,21 @@
{{- if and .Values.persistence.video.enabled (not .Values.persistence.video.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
name: {{ template "youtubedl-material.fullname" . }}-video
labels:
{{- include "youtubedl-material.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.video.accessMode | quote }}
resources:
requests:
storage: {{ .Values.persistence.video.size | quote }}
{{- if .Values.persistence.video.storageClass }}
{{- if (eq "-" .Values.persistence.video.storageClass) }}
storageClassName: ""
{{- else }}
storageClassName: "{{ .Values.persistence.video.storageClass }}"
{{- end }}
{{- end }}
{{- end -}}

153
chart/values.yaml Normal file
View File

@@ -0,0 +1,153 @@
# Default values for youtubedl-material.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: tzahi12345/youtubedl-material
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext: {}
# fsGroup: 2000
securityContext: {}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
service:
type: ClusterIP
port: 17442
ingress:
enabled: false
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths: []
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
persistence:
appdata:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 1Gi
audio:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
video:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
subscriptions:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
users:
enabled: true
## If defined, storageClassName: <storageClass>
## If set to "-", storageClassName: "", which disables dynamic provisioning
## If undefined (the default) or set to null, no storageClassName spec is
## set, choosing the default provisioner. (gp2 on AWS, standard on
## GKE, AWS & OpenStack)
##
# storageClass: "-"
##
## If you want to reuse an existing claim, you can pass the name of the PVC using
## the existingClaim variable
# existingClaim: your-claim
# subPath: some-subpath
accessMode: ReadWriteOnce
size: 50Gi
nodeSelector: {}
tolerations: []
affinity: {}

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, "manifest_version": 2,
"name": "YoutubeDL-Material", "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.", "description": "The Official Firefox & Chrome Extension of YoutubeDL-Material, an open-source and self-hosted YouTube downloader.",
"background": {
"scripts": ["background.js"]
},
"browser_action": { "browser_action": {
"default_icon": "favicon.png" "default_icon": "favicon.png",
"default_popup": "popup.html",
"default_title": "YoutubeDL-Material"
}, },
"permissions": [ "permissions": [
"tabs", "tabs",
"storage" "storage",
"contextMenus"
], ],
"options_ui": { "options_ui": {
"page": "options.html", "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);

View File

@@ -3,6 +3,9 @@ services:
ytdl_material: ytdl_material:
environment: environment:
ALLOW_CONFIG_MUTATIONS: 'true' ALLOW_CONFIG_MUTATIONS: 'true'
ytdl_mongodb_connection_string: 'mongodb://ytdl-mongo-db:27017'
ytdl_use_local_db: 'false'
write_ytdl_config: 'true'
restart: always restart: always
volumes: volumes:
- ./appdata:/app/appdata - ./appdata:/app/appdata
@@ -13,3 +16,12 @@ services:
ports: ports:
- "8998:17442" - "8998:17442"
image: tzahi12345/youtubedl-material:latest image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db:
image: mongo
ports:
- "27017:27017"
logging:
driver: "none"
container_name: mongo-db
volumes:
- ./db/:/data/db

View File

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

View File

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

10989
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -38,15 +38,15 @@
</div> </div>
<div class="sidenav-container" style="height: calc(100% - 64px)"> <div class="sidenav-container" style="height: calc(100% - 64px)">
<mat-sidenav-container style="height: 100%"> <mat-sidenav-container style="height: 100%">
<mat-sidenav [opened]="postsService.sidepanel_mode === 'side' && router.url === '/home'" [mode]="postsService.sidepanel_mode" #sidenav> <mat-sidenav [opened]="postsService.sidepanel_mode === 'side' && !window.location.href.includes('/player')" [mode]="postsService.sidepanel_mode" #sidenav>
<mat-nav-list> <mat-nav-list>
<a *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)" mat-list-item (click)="sidenav.close()" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a> <a *ngIf="postsService.config && (!postsService.config.Advanced.multi_user_mode || postsService.isLoggedIn)" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/home'><ng-container i18n="Navigation menu Home Page title">Home</ng-container></a>
<a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a> <a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a>
<a *ngIf="postsService.config && allowSubscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))" mat-list-item (click)="sidenav.close()" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a> <a *ngIf="postsService.config && allowSubscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
<a *ngIf="postsService.config && enableDownloadsManager && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('downloads_manager')))" mat-list-item (click)="sidenav.close()" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a> <a *ngIf="postsService.config && enableDownloadsManager && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('downloads_manager')))" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))"> <ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && (!postsService.config.Advanced.multi_user_mode || (postsService.isLoggedIn && postsService.permissions.includes('subscriptions')))">
<mat-divider></mat-divider> <mat-divider></mat-divider>
<a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="sidenav.close()" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar><ng-container i18n="Navigation menu Downloads Page title">{{subscription.name}}</ng-container></a> <a *ngFor="let subscription of postsService.subscriptions" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" [routerLink]="['/subscription', { id: subscription.id }]"><ngx-avatar [style.margin-right]="'10px'" size="32" [name]="subscription.name"></ngx-avatar>{{subscription.name}}</a>
</ng-container> </ng-container>
</mat-nav-list> </mat-nav-list>
</mat-sidenav> </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'; import { AppComponent } from './app.component';
describe('AppComponent', () => { describe('AppComponent', () => {
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ declarations: [
AppComponent AppComponent
@@ -11,19 +11,19 @@ describe('AppComponent', () => {
}).compileComponents(); }).compileComponents();
})); }));
it('should create the app', async(() => { it('should create the app', waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance; const app = fixture.debugElement.componentInstance;
expect(app).toBeTruthy(); expect(app).toBeTruthy();
})); }));
it(`should have as title 'app'`, async(() => { it(`should have as title 'app'`, waitForAsync(() => {
const fixture = TestBed.createComponent(AppComponent); const fixture = TestBed.createComponent(AppComponent);
const app = fixture.debugElement.componentInstance; const app = fixture.debugElement.componentInstance;
expect(app.title).toEqual('app'); 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); const fixture = TestBed.createComponent(AppComponent);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.debugElement.nativeElement; const compiled = fixture.debugElement.nativeElement;

View File

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

View File

@@ -32,14 +32,17 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { ClipboardModule } from '@angular/cdk/clipboard'; import { ClipboardModule } from '@angular/cdk/clipboard';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component'; 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 { PostsService } from 'app/posts.services';
import { FileCardComponent } from './file-card/file-card.component'; import { FileCardComponent } from './file-card/file-card.component';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { AppRoutingModule } from './app-routing.module'; import { AppRoutingModule } from './app-routing.module';
import { MainComponent } from './main/main.component'; import { MainComponent } from './main/main.component';
import { PlayerComponent } from './player/player.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 { InputDialogComponent } from './input-dialog/input-dialog.component';
import { LazyLoadImageModule, IsVisibleProps } from 'ng-lazyload-image'; import { LazyLoadImageModule, IsVisibleProps } from 'ng-lazyload-image';
import { audioFilesMouseHovering, videoFilesMouseHovering, audioFilesOpened, videoFilesOpened } from './main/main.component'; import { audioFilesMouseHovering, videoFilesMouseHovering, audioFilesOpened, videoFilesOpened } from './main/main.component';
@@ -79,6 +82,11 @@ import { UnifiedFileCardComponent } from './components/unified-file-card/unified
import { RecentVideosComponent } from './components/recent-videos/recent-videos.component'; import { RecentVideosComponent } from './components/recent-videos/recent-videos.component';
import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component'; import { EditSubscriptionDialogComponent } from './dialogs/edit-subscription-dialog/edit-subscription-dialog.component';
import { CustomPlaylistsComponent } from './components/custom-playlists/custom-playlists.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';
import { ConcurrentStreamComponent } from './components/concurrent-stream/concurrent-stream.component';
registerLocaleData(es, 'es'); registerLocaleData(es, 'es');
@@ -105,6 +113,7 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
VideoInfoDialogComponent, VideoInfoDialogComponent,
ArgModifierDialogComponent, ArgModifierDialogComponent,
HighlightPipe, HighlightPipe,
LinkifyPipe,
UpdaterComponent, UpdaterComponent,
UpdateProgressDialogComponent, UpdateProgressDialogComponent,
ShareMediaDialogComponent, ShareMediaDialogComponent,
@@ -123,7 +132,11 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
UnifiedFileCardComponent, UnifiedFileCardComponent,
RecentVideosComponent, RecentVideosComponent,
EditSubscriptionDialogComponent, EditSubscriptionDialogComponent,
CustomPlaylistsComponent CustomPlaylistsComponent,
EditCategoryDialogComponent,
TwitchChatComponent,
SeeMoreComponent,
ConcurrentStreamComponent
], ],
imports: [ imports: [
CommonModule, CommonModule,
@@ -181,10 +194,12 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible
SettingsComponent SettingsComponent
], ],
providers: [ providers: [
PostsService PostsService,
{ provide: HTTP_INTERCEPTORS, useClass: H401Interceptor, multi: true }
], ],
exports: [ exports: [
HighlightPipe HighlightPipe,
LinkifyPipe
], ],
bootstrap: [AppComponent] bootstrap: [AppComponent]
}) })

View File

@@ -0,0 +1,6 @@
<div class="buttons-container">
<button (click)="startWatching()" *ngIf="!watch_together_clicked" mat-flat-button>Watch together</button>
<button (click)="startServer()" *ngIf="watch_together_clicked && !started && server_mode && server_already_exists === false" mat-flat-button>Start stream</button>
<button (click)="startClient()" *ngIf="watch_together_clicked && !started && server_already_exists === true" mat-flat-button>Join stream</button>
<button style="margin-left: 10px;" (click)="stop()" *ngIf="watch_together_clicked" mat-flat-button>Stop</button>
</div>

View File

@@ -0,0 +1,7 @@
.buttons-container {
display: flex;
align-items: center;
justify-content: center;
margin-top: 15px;
margin-bottom: 15px;
}

View File

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

View File

@@ -0,0 +1,140 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-concurrent-stream',
templateUrl: './concurrent-stream.component.html',
styleUrls: ['./concurrent-stream.component.scss']
})
export class ConcurrentStreamComponent implements OnInit {
@Input() server_mode = false;
@Input() playback_timestamp;
@Input() playing;
@Input() uid;
@Output() setPlaybackTimestamp = new EventEmitter<any>();
@Output() togglePlayback = new EventEmitter<boolean>();
@Output() setPlaybackRate = new EventEmitter<number>();
started = false;
server_started = false;
watch_together_clicked = false;
server_already_exists = null;
check_timeout: any;
update_timeout: any;
PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_PLAYBACK_MODIFICATION = 0.5;
PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_SKIP = 2;
PLAYBACK_MODIFIER = 0.1;
playback_rate_modified = false;
constructor(private postsService: PostsService) { }
// flow: click start watching -> check for available stream to enable join button and if user, display "start stream"
// users who join a stream will send continuous requests for info on playback
ngOnInit(): void {
}
startServer() {
this.started = true;
this.server_started = true;
this.update_timeout = setInterval(() => {
this.updateStream();
}, 1000);
}
updateStream() {
this.postsService.updateConcurrentStream(this.uid, this.playback_timestamp, Date.now()/1000, this.playing).subscribe(res => {
});
}
startClient() {
this.started = true;
}
checkStream() {
if (this.server_started) { return; }
const current_playback_timestamp = this.playback_timestamp;
const current_unix_timestamp = Date.now()/1000;
this.postsService.checkConcurrentStream(this.uid).subscribe(res => {
const stream = res['stream'];
if (!stream) {
this.server_already_exists = false;
return;
}
this.server_already_exists = true;
// check whether client has joined the stream
if (!this.started) { return; }
if (!stream['playing'] && this.playing) {
// tell client to pause and set the timestamp to sync
this.togglePlayback.emit(false);
this.setPlaybackTimestamp.emit(stream['playback_timestamp']);
} else if (stream['playing']) {
// sync unpause state
if (!this.playing) { this.togglePlayback.emit(true); }
// sync time
const zeroed_local_unix_timestamp = current_unix_timestamp - current_playback_timestamp;
const zeroed_server_unix_timestamp = stream['unix_timestamp'] - stream['playback_timestamp'];
const seconds_behind_locally = zeroed_local_unix_timestamp - zeroed_server_unix_timestamp;
if (Math.abs(seconds_behind_locally) > this.PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_SKIP) {
// skip to playback timestamp because the difference is too high
this.setPlaybackTimestamp.emit(this.playback_timestamp + seconds_behind_locally + 0.3);
this.playback_rate_modified = false;
} else if (!this.playback_rate_modified && Math.abs(seconds_behind_locally) > this.PLAYBACK_TIMESTAMP_DIFFERENCE_THRESHOLD_PLAYBACK_MODIFICATION) {
// increase playback speed to avoid skipping
let seconds_to_wait = (Math.abs(seconds_behind_locally)/this.PLAYBACK_MODIFIER);
seconds_to_wait += 0.3/this.PLAYBACK_MODIFIER;
this.playback_rate_modified = true;
if (seconds_behind_locally > 0) {
// increase speed
this.setPlaybackRate.emit(1 + this.PLAYBACK_MODIFIER);
setTimeout(() => {
this.setPlaybackRate.emit(1);
this.playback_rate_modified = false;
}, seconds_to_wait * 1000);
} else {
// decrease speed
this.setPlaybackRate.emit(1 - this.PLAYBACK_MODIFIER);
setTimeout(() => {
this.setPlaybackRate.emit(1);
this.playback_rate_modified = false;
}, seconds_to_wait * 1000);
}
}
}
});
}
startWatching() {
this.watch_together_clicked = true;
this.check_timeout = setInterval(() => {
this.checkStream();
}, 1000);
}
stop() {
if (this.check_timeout) { clearInterval(this.check_timeout); }
if (this.update_timeout) { clearInterval(this.update_timeout); }
this.started = false;
this.server_started = false;
this.watch_together_clicked = false;
}
}

View File

@@ -2,7 +2,7 @@
<div class="container"> <div class="container">
<div class="row justify-content-center"> <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' : '' ]"> <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> </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'; import { CustomPlaylistsComponent } from './custom-playlists.component';
@@ -6,7 +6,7 @@ describe('CustomPlaylistsComponent', () => {
let component: CustomPlaylistsComponent; let component: CustomPlaylistsComponent;
let fixture: ComponentFixture<CustomPlaylistsComponent>; let fixture: ComponentFixture<CustomPlaylistsComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ CustomPlaylistsComponent ] declarations: [ CustomPlaylistsComponent ]
}) })

View File

@@ -50,18 +50,18 @@ export class CustomPlaylistsComponent implements OnInit {
}); });
} }
goToPlaylist(playlist) { goToPlaylist(info_obj) {
const playlist = info_obj.file;
const playlistID = playlist.id; const playlistID = playlist.id;
const type = playlist.type;
if (playlist) { if (playlist) {
if (this.postsService.config['Extra']['download_only_mode']) { if (this.postsService.config['Extra']['download_only_mode']) {
this.downloading_content[type][playlistID] = true; this.downloadPlaylist(playlist.id, playlist.name);
this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID);
} else { } else {
localStorage.setItem('player_navigator', this.router.url); localStorage.setItem('player_navigator', this.router.url);
const fileNames = playlist.fileNames; const routeParams = {playlist_id: playlistID};
this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID, uid: playlistID}]); if (playlist.auto) { routeParams['auto'] = playlist.auto; }
this.router.navigate(['/player', routeParams]);
} }
} else { } else {
// playlist not found // playlist not found
@@ -69,11 +69,12 @@ export class CustomPlaylistsComponent implements OnInit {
} }
} }
downloadPlaylist(fileNames, type, zipName = null, playlistID = null) { downloadPlaylist(playlist_id, playlist_name) {
this.postsService.downloadFileFromServer(fileNames, type, zipName).subscribe(res => { this.downloading_content[playlist_id] = true;
if (playlistID) { this.downloading_content[type][playlistID] = false }; this.postsService.downloadPlaylistFromServer(playlist_id).subscribe(res => {
const blob: Blob = res; this.downloading_content[playlist_id] = false;
saveAs(blob, zipName + '.zip'); const blob: any = res;
saveAs(blob, playlist_name + '.zip');
}); });
} }
@@ -82,7 +83,7 @@ export class CustomPlaylistsComponent implements OnInit {
const playlist = args.file; const playlist = args.file;
const index = args.index; const index = args.index;
const playlistID = playlist.id; const playlistID = playlist.id;
this.postsService.removePlaylist(playlistID, 'audio').subscribe(res => { this.postsService.removePlaylist(playlistID, playlist.type).subscribe(res => {
if (res['success']) { if (res['success']) {
this.playlists.splice(index, 1); this.playlists.splice(index, 1);
this.postsService.openSnackBar('Playlist successfully removed.', ''); this.postsService.openSnackBar('Playlist successfully removed.', '');
@@ -96,7 +97,7 @@ export class CustomPlaylistsComponent implements OnInit {
const index = args.index; const index = args.index;
const dialogRef = this.dialog.open(ModifyPlaylistComponent, { const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
data: { data: {
playlist: playlist, playlist_id: playlist.id,
width: '65vw' width: '65vw'
} }
}); });

View File

@@ -1,21 +1,21 @@
<div style="padding: 20px;"> <div style="padding: 20px;">
<div *ngFor="let session_downloads of downloads | keyvalue"> <div *ngFor="let session_downloads of downloads">
<ng-container *ngIf="keys(session_downloads.value).length > 0"> <ng-container *ngIf="keys(session_downloads).length > 2">
<mat-card style="padding-bottom: 30px; margin-bottom: 15px;"> <mat-card style="padding-bottom: 30px; margin-bottom: 15px;">
<h4 style="text-align: center;"><ng-container i18n="Session ID">Session ID:</ng-container>&nbsp;{{session_downloads.key}} <h4 style="text-align: center;"><ng-container i18n="Session ID">Session ID:</ng-container>&nbsp;{{session_downloads['session_id']}}
<span *ngIf="session_downloads.key === postsService.session_id">&nbsp;<ng-container i18n="Current session">(current)</ng-container></span> <span *ngIf="session_downloads['session_id'] === postsService.session_id">&nbsp;<ng-container i18n="Current session">(current)</ng-container></span>
</h4> </h4>
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div *ngFor="let download of session_downloads.value | keyvalue: sort_downloads; let i = index;" class="col-12 my-1"> <div *ngFor="let download of session_downloads | keyvalue: sort_downloads; let i = index;" class="col-12 my-1">
<mat-card *ngIf="download.value" class="mat-elevation-z3"> <mat-card *ngIf="download.key !== 'session_id' && download.key !== '_id' && download.value" class="mat-elevation-z3">
<app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads.key, download.value.uid)"></app-download-item> <app-download-item [download]="download.value" [queueNumber]="i+1" (cancelDownload)="clearDownload(session_downloads['session_id'], download.value.uid)"></app-download-item>
</mat-card> </mat-card>
</div> </div>
</div> </div>
</div> </div>
<div> <div>
<button style="top: 15px;" (click)="clearDownloads(session_downloads.key)" mat-stroked-button color="warn"><ng-container i18n="clear all downloads action button">Clear all downloads</ng-container></button> <button style="top: 15px;" (click)="clearDownloads(session_downloads['session_id'])" mat-stroked-button color="warn"><ng-container i18n="clear all downloads action button">Clear all downloads</ng-container></button>
</div> </div>
</mat-card> </mat-card>
</ng-container> </ng-container>

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

View File

@@ -35,7 +35,7 @@ import { Router } from '@angular/router';
export class DownloadsComponent implements OnInit, OnDestroy { export class DownloadsComponent implements OnInit, OnDestroy {
downloads_check_interval = 1000; downloads_check_interval = 1000;
downloads = {}; downloads = [];
interval_id = null; interval_id = null;
keys = Object.keys; keys = Object.keys;
@@ -137,6 +137,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
this.downloads[session_id] = session_downloads_by_id; this.downloads[session_id] = session_downloads_by_id;
} else { } else {
for (let j = 0; j < session_download_ids.length; j++) { for (let j = 0; j < session_download_ids.length; j++) {
if (session_download_ids[j] === 'session_id' || session_download_ids[j] === '_id') continue;
const download_id = session_download_ids[j]; const download_id = session_download_ids[j];
const download = new_downloads_by_session[session_id][download_id] const download = new_downloads_by_session[session_id][download_id]
if (!this.downloads[session_id][download_id]) { if (!this.downloads[session_id][download_id]) {
@@ -156,11 +157,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
downloadsValid() { downloadsValid() {
let valid = false; let valid = false;
const keys = this.keys(this.downloads); for (let i = 0; i < this.downloads.length; i++) {
for (let i = 0; i < keys.length; i++) { const session_downloads = this.downloads[i];
const key = keys[i]; if (!session_downloads) continue;
const value = this.downloads[key]; if (this.keys(session_downloads).length > 2) {
if (this.keys(value).length > 0) {
valid = true; valid = true;
break; break;
} }

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

View File

@@ -27,7 +27,7 @@ export class LoginComponent implements OnInit {
constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { } constructor(private postsService: PostsService, private snackBar: MatSnackBar, private router: Router) { }
ngOnInit(): void { ngOnInit(): void {
if (this.postsService.isLoggedIn) { if (this.postsService.isLoggedIn && localStorage.getItem('jwt_token') !== 'null') {
this.router.navigate(['/home']); this.router.navigate(['/home']);
} }
this.postsService.service_initialized.subscribe(init => { 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'; import { LogsViewerComponent } from './logs-viewer.component';
@@ -6,7 +6,7 @@ describe('LogsViewerComponent', () => {
let component: LogsViewerComponent; let component: LogsViewerComponent;
let fixture: ComponentFixture<LogsViewerComponent>; let fixture: ComponentFixture<LogsViewerComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ LogsViewerComponent ] declarations: [ LogsViewerComponent ]
}) })

View File

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

View File

@@ -5,7 +5,7 @@
<mat-list-item role="listitem" *ngFor="let permission of available_permissions"> <mat-list-item role="listitem" *ngFor="let permission of available_permissions">
<h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3> <h3 matLine>{{permissionToLabel[permission] ? permissionToLabel[permission] : permission}}</h3>
<span matLine> <span matLine>
<mat-radio-group [disabled]="permission === 'settings' && role.name === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission"> <mat-radio-group [disabled]="permission === 'settings' && role.key === 'admin'" (change)="changeRolePermissions($event, permission, permissions[permission])" [(ngModel)]="permissions[permission]" [attr.aria-label]="'Give role permission for ' + permission">
<mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button> <mat-radio-button value="yes"><ng-container i18n="Yes">Yes</ng-container></mat-radio-button>
<mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button> <mat-radio-button value="no"><ng-container i18n="No">No</ng-container></mat-radio-button>
</mat-radio-group> </mat-radio-group>

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

View File

@@ -47,7 +47,7 @@ export class ManageRoleComponent implements OnInit {
} }
changeRolePermissions(change, permission) { changeRolePermissions(change, permission) {
this.postsService.setRolePermission(this.role.name, permission, change.value).subscribe(res => { this.postsService.setRolePermission(this.role.key, permission, change.value).subscribe(res => {
if (res['success']) { if (res['success']) {
} else { } else {

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

View File

@@ -94,7 +94,7 @@
</div> </div>
<button color="primary" [matMenuTriggerFor]="edit_roles_menu" class="edit-role" mat-raised-button><ng-container i18n="Edit role">Edit Role</ng-container></button> <button color="primary" [matMenuTriggerFor]="edit_roles_menu" class="edit-role" mat-raised-button><ng-container i18n="Edit role">Edit Role</ng-container></button>
<mat-menu #edit_roles_menu="matMenu"> <mat-menu #edit_roles_menu="matMenu">
<button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.name}}</button> <button (click)="openModifyRole(role)" mat-menu-item *ngFor="let role of roles">{{role.key}}</button>
</mat-menu> </mat-menu>
</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 { ModifyUsersComponent } from './modify-users.component'; import { ModifyUsersComponent } from './modify-users.component';
@@ -6,7 +6,7 @@ describe('ModifyUsersComponent', () => {
let component: ModifyUsersComponent; let component: ModifyUsersComponent;
let fixture: ComponentFixture<ModifyUsersComponent>; let fixture: ComponentFixture<ModifyUsersComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ ModifyUsersComponent ] declarations: [ ModifyUsersComponent ]
}) })

View File

@@ -78,16 +78,7 @@ export class ModifyUsersComponent implements OnInit, AfterViewInit {
getRoles() { getRoles() {
this.postsService.getRoles().subscribe(res => { this.postsService.getRoles().subscribe(res => {
this.roles = []; this.roles = res['roles'];
const roles = res['roles'];
const role_names = Object.keys(roles);
for (let i = 0; i < role_names.length; i++) {
const role_name = role_names[i];
this.roles.push({
name: role_name,
permissions: roles[role_name]['permissions']
});
}
}); });
} }

View File

@@ -30,16 +30,24 @@
<div> <div>
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<ng-container *ngIf="normal_files_received"> <ng-container *ngIf="normal_files_received && paged_data">
<div *ngFor="let file of filtered_files; 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' : '' ]"> <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" (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> <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> </div>
</ng-container> </ng-container>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0"> <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" [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' : '' ]"> <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> </div>
</ng-container> </ng-container>
</div> </div>
</div> </div>
<mat-paginator class="paginator" #paginator *ngIf="filtered_files && filtered_files.length > 0" (page)="pageChangeEvent($event)" [length]="filtered_files.length"
[pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
</mat-paginator>
</div> </div>

View File

@@ -47,6 +47,10 @@
top: 10px; top: 10px;
} }
.paginator {
margin-top: 5px;
}
.my-videos-title { .my-videos-title {
text-align: center; text-align: center;
position: relative; 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'; import { RecentVideosComponent } from './recent-videos.component';
@@ -6,7 +6,7 @@ describe('RecentVideosComponent', () => {
let component: RecentVideosComponent; let component: RecentVideosComponent;
let fixture: ComponentFixture<RecentVideosComponent>; let fixture: ComponentFixture<RecentVideosComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ RecentVideosComponent ] 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 { PostsService } from 'app/posts.services';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { MatPaginator } from '@angular/material/paginator';
@Component({ @Component({
selector: 'app-recent-videos', selector: 'app-recent-videos',
@@ -50,22 +51,32 @@ export class RecentVideosComponent implements OnInit {
}; };
filterProperty = this.filterProperties['upload_date']; filterProperty = this.filterProperties['upload_date'];
pageSize = 10;
paged_data = null;
@ViewChild('paginator') paginator: MatPaginator
constructor(public postsService: PostsService, private router: Router) { constructor(public postsService: PostsService, private router: Router) {
// get cached file count // get cached file count
if (localStorage.getItem('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); this.loading_files = Array(this.cached_file_count).fill(0);
console.log(this.loading_files);
} }
} }
ngOnInit(): void { ngOnInit(): void {
if (this.postsService.initialized) {
this.getAllFiles();
}
this.postsService.service_initialized.subscribe(init => { this.postsService.service_initialized.subscribe(init => {
if (init) { if (init) {
this.getAllFiles(); this.getAllFiles();
} }
}); });
// set filter property to cached // set filter property to cached
const cached_filter_property = localStorage.getItem('filter_property'); const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && this.filterProperties[cached_filter_property]) { if (cached_filter_property && this.filterProperties[cached_filter_property]) {
@@ -87,7 +98,8 @@ export class RecentVideosComponent implements OnInit {
private filterFiles(value: string) { private filterFiles(value: string) {
const filterValue = value.toLowerCase(); 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) { filterByProperty(prop) {
@@ -96,6 +108,7 @@ export class RecentVideosComponent implements OnInit {
} else { } else {
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? 1 : -1)); 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) { filterOptionChanged(value) {
@@ -114,10 +127,11 @@ export class RecentVideosComponent implements OnInit {
this.normal_files_received = false; this.normal_files_received = false;
this.postsService.getAllFiles().subscribe(res => { this.postsService.getAllFiles().subscribe(res => {
this.files = res['files']; this.files = res['files'];
this.files.forEach(file => {
file.duration = typeof file.duration !== 'string' ? file.duration : this.durationStringToNumber(file.duration);
});
this.files.sort(this.sortFiles); this.files.sort(this.sortFiles);
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);
}
if (this.search_mode) { if (this.search_mode) {
this.filterFiles(this.search_text); this.filterFiles(this.search_text);
} else { } else {
@@ -129,33 +143,42 @@ export class RecentVideosComponent implements OnInit {
localStorage.setItem('cached_file_count', '' + this.files.length); localStorage.setItem('cached_file_count', '' + this.files.length);
this.normal_files_received = true; this.normal_files_received = true;
this.paged_data = this.filtered_files.slice(0, 10);
}); });
} }
// navigation // navigation
goToFile(file) { goToFile(info_obj) {
const file = info_obj['file'];
const event = info_obj['event'];
if (this.postsService.config['Extra']['download_only_mode']) { if (this.postsService.config['Extra']['download_only_mode']) {
this.downloadFile(file); this.downloadFile(file);
} else { } else {
this.navigateToFile(file); this.navigateToFile(file, event.ctrlKey);
} }
} }
navigateToFile(file) { navigateToFile(file, new_tab) {
localStorage.setItem('player_navigator', this.router.url); localStorage.setItem('player_navigator', this.router.url);
if (file.sub_id) { if (file.sub_id) {
const sub = this.postsService.getSubscriptionByID(file.sub_id) const sub = this.postsService.getSubscriptionByID(file.sub_id);
if (sub.streamingOnly) { if (sub.streamingOnly) {
this.router.navigate(['/player', {name: file.id, // streaming only mode subscriptions
url: file.requested_formats ? file.requested_formats[0].url : file.url}]); // !new_tab ? this.router.navigate(['/player', {name: file.id,
// url: file.requested_formats ? file.requested_formats[0].url : file.url}])
// : window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`);
} else { } else {
this.router.navigate(['/player', {fileNames: file.id, // normal subscriptions
type: file.isAudio ? 'audio' : 'video', subscriptionName: sub.name, !new_tab ? this.router.navigate(['/player', {uid: file.uid,
subPlaylist: sub.isPlaylist}]); type: file.isAudio ? 'audio' : 'video', sub_id: sub.id}])
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'};sub_id=${sub.id}`);
} }
} else { } else {
this.router.navigate(['/player', {type: file.isAudio ? 'audio' : 'video', uid: file.uid}]); // normal files
!new_tab ? this.router.navigate(['/player', {type: file.isAudio ? 'audio' : 'video', uid: file.uid}])
: window.open(`/#/player;type=${file.isAudio ? 'audio' : 'video'};uid=${file.uid}`);
} }
} }
@@ -177,9 +200,7 @@ export class RecentVideosComponent implements OnInit {
const type = file.isAudio ? 'audio' : 'video'; const type = file.isAudio ? 'audio' : 'video';
const ext = type === 'audio' ? '.mp3' : '.mp4' const ext = type === 'audio' ? '.mp3' : '.mp4'
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
console.log(sub.isPlaylist) this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.postsService.downloadFileFromServer(file.id, type, null, null, sub.name, sub.isPlaylist,
this.postsService.user ? this.postsService.user.uid : null, null).subscribe(res => {
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, file.id + ext); saveAs(blob, file.id + ext);
}, err => { }, err => {
@@ -192,14 +213,14 @@ export class RecentVideosComponent implements OnInit {
const ext = type === 'audio' ? '.mp3' : '.mp4' const ext = type === 'audio' ? '.mp3' : '.mp4'
const name = file.id; const name = file.id;
this.downloading_content[type][name] = true; this.downloading_content[type][name] = true;
this.postsService.downloadFileFromServer(name, type).subscribe(res => { this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.downloading_content[type][name] = false; this.downloading_content[type][name] = false;
const blob: Blob = res; const blob: Blob = res;
saveAs(blob, decodeURIComponent(name) + ext); saveAs(blob, decodeURIComponent(name) + ext);
if (!this.postsService.config.Extra.file_manager_enabled) { if (!this.postsService.config.Extra.file_manager_enabled) {
// tell server to delete the file once downloaded // tell server to delete the file once downloaded
this.postsService.deleteFile(name, false).subscribe(delRes => { this.postsService.deleteFile(file.uid).subscribe(delRes => {
// reload mp4s // reload mp4s
this.getAllFiles(); this.getAllFiles();
}); });
@@ -215,17 +236,17 @@ export class RecentVideosComponent implements OnInit {
const blacklistMode = args.blacklistMode; const blacklistMode = args.blacklistMode;
if (file.sub_id) { if (file.sub_id) {
this.deleteSubscriptionFile(file, index, blacklistMode); this.deleteSubscriptionFile(file, blacklistMode);
} else { } else {
this.deleteNormalFile(file, index, blacklistMode); this.deleteNormalFile(file, blacklistMode);
} }
} }
deleteNormalFile(file, index, blacklistMode = false) { deleteNormalFile(file, blacklistMode = false) {
this.postsService.deleteFile(file.uid, file.isAudio, blacklistMode).subscribe(result => { this.postsService.deleteFile(file.uid, blacklistMode).subscribe(result => {
if (result) { if (result) {
this.postsService.openSnackBar('Delete success!', 'OK.'); this.postsService.openSnackBar('Delete success!', 'OK.');
this.files.splice(index, 1); this.removeFileCard(file);
} else { } else {
this.postsService.openSnackBar('Delete failed!', 'OK.'); this.postsService.openSnackBar('Delete failed!', 'OK.');
} }
@@ -234,30 +255,39 @@ export class RecentVideosComponent implements OnInit {
}); });
} }
deleteSubscriptionFile(file, index, blacklistMode = false) { deleteSubscriptionFile(file, blacklistMode = false) {
if (blacklistMode) { if (blacklistMode) {
this.deleteForever(file, index); this.deleteForever(file);
} else { } else {
this.deleteAndRedownload(file, index); this.deleteAndRedownload(file);
} }
} }
deleteAndRedownload(file, index) { deleteAndRedownload(file) {
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(res => { this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(res => {
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`); this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
this.files.splice(index, 1); this.removeFileCard(file);
}); });
} }
deleteForever(file, index) { deleteForever(file) {
const sub = this.postsService.getSubscriptionByID(file.sub_id); const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(res => { this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(res => {
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`); this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
this.files.splice(index, 1); this.removeFileCard(file);
}); });
} }
removeFileCard(file_to_remove) {
const index = this.files.map(e => e.uid).indexOf(file_to_remove.uid);
this.files.splice(index, 1);
if (this.search_mode) {
this.filterFiles(this.search_text);
}
this.filterByProperty(this.filterProperty['property']);
}
// sorting and filtering // sorting and filtering
sortFiles(a, b) { sortFiles(a, b) {
@@ -269,9 +299,14 @@ export class RecentVideosComponent implements OnInit {
durationStringToNumber(dur_str) { durationStringToNumber(dur_str) {
let num_sum = 0; let num_sum = 0;
const dur_str_parts = dur_str.split(':'); const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length-1; i >= 0; 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)); num_sum += parseInt(dur_str_parts[i]) * (60 ** (dur_str_parts.length - 1 - i));
} }
return num_sum; 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 (mouseover)="elevated=true" (mouseout)="elevated=false" (contextmenu)="onRightClick($event)" style="position: relative; width: fit-content;">
<div *ngIf="!loading" class="download-time"><mat-icon class="audio-video-icon">{{(file_obj.type === 'audio' || file_obj.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>&nbsp;&nbsp;{{file_obj.registered | date:'shortDate'}}</div> <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> <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"> <mat-menu #action_menu="matMenu">
<ng-container *ngIf="!is_playlist && !loading"> <ng-container *ngIf="!is_playlist && !loading">
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button> <button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
@@ -25,11 +42,11 @@
<button mat-menu-item>Placeholder</button> <button mat-menu-item>Placeholder</button>
</ng-container> </ng-container>
</mat-menu> </mat-menu>
<mat-card [matTooltip]="null" (click)="navigateToFile()" matRipple class="file-mat-card" [ngClass]="{'small-mat-card': card_size === 'small', 'file-mat-card': card_size === 'medium', 'large-mat-card': card_size === 'large', 'mat-elevation-z4': !elevated, 'mat-elevation-z8': elevated}"> <mat-card [matTooltip]="null" (click)="navigateToFile($event)" matRipple class="file-mat-card" [ngClass]="{'small-mat-card': card_size === 'small', 'file-mat-card': card_size === 'medium', 'large-mat-card': card_size === 'large', 'mat-elevation-z4': !elevated, 'mat-elevation-z8': elevated}">
<div style="padding:5px"> <div style="padding:5px">
<div *ngIf="!loading && file_obj.thumbnailURL" class="img-div"> <div *ngIf="!loading && file_obj.thumbnailURL" class="img-div">
<div style="position: relative"> <div style="position: relative">
<img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailURL" alt="Thumbnail"> <img [ngClass]="{'image-small': card_size === 'small', 'image': card_size === 'medium', 'image-large': card_size === 'large'}" [src]="file_obj.thumbnailPath ? thumbnailBlobURL : file_obj.thumbnailURL" alt="Thumbnail">
<div class="duration-time"> <div class="duration-time">
{{file_length}} {{file_length}}
</div> </div>

View File

@@ -103,13 +103,19 @@
background: rgba(255,255,255,0.6); background: rgba(255,255,255,0.6);
padding-left: 5px; padding-left: 5px;
padding-right: 5px; padding-right: 5px;
color: black;
} }
.download-time { .download-time {
position: absolute; position: absolute;
top: 1px; top: 1px;
left: 5px; left: 5px;
z-index: 99999; z-index: 999;
width: calc(100% - 8px);
white-space: nowrap;
overflow: hidden;
display: block;
text-overflow: ellipsis;
} }
.audio-video-icon { .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'; import { UnifiedFileCardComponent } from './unified-file-card.component';
@@ -6,7 +6,7 @@ describe('UnifiedFileCardComponent', () => {
let component: UnifiedFileCardComponent; let component: UnifiedFileCardComponent;
let fixture: ComponentFixture<UnifiedFileCardComponent>; let fixture: ComponentFixture<UnifiedFileCardComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ UnifiedFileCardComponent ] declarations: [ UnifiedFileCardComponent ]
}) })

View File

@@ -1,6 +1,22 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; import { Component, OnInit, Input, Output, EventEmitter, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component'; 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({ @Component({
selector: 'app-unified-file-card', selector: 'app-unified-file-card',
@@ -16,6 +32,10 @@ export class UnifiedFileCardComponent implements OnInit {
type = null; type = null;
elevated = false; elevated = false;
// optional vars
thumbnailBlobURL = null;
// input/output
@Input() loading = true; @Input() loading = true;
@Input() theme = null; @Input() theme = null;
@Input() file_obj = null; @Input() file_obj = null;
@@ -23,11 +43,18 @@ export class UnifiedFileCardComponent implements OnInit {
@Input() use_youtubedl_archive = false; @Input() use_youtubedl_archive = false;
@Input() is_playlist = false; @Input() is_playlist = false;
@Input() index: number; @Input() index: number;
@Input() locale = null;
@Input() baseStreamPath = null;
@Input() jwtString = null;
@Output() goToFile = new EventEmitter<any>(); @Output() goToFile = new EventEmitter<any>();
@Output() goToSubscription = new EventEmitter<any>(); @Output() goToSubscription = new EventEmitter<any>();
@Output() deleteFile = new EventEmitter<any>(); @Output() deleteFile = new EventEmitter<any>();
@Output() editPlaylist = new EventEmitter<any>(); @Output() editPlaylist = new EventEmitter<any>();
@ViewChild(MatMenuTrigger) contextMenu: MatMenuTrigger;
contextMenuPosition = { x: '0px', y: '0px' };
/* /*
Planned sizes: Planned sizes:
small: 150x175 small: 150x175
@@ -35,12 +62,20 @@ export class UnifiedFileCardComponent implements OnInit {
big: 250x200 big: 250x200
*/ */
constructor(private dialog: MatDialog) { } constructor(private dialog: MatDialog, private sanitizer: DomSanitizer) { }
ngOnInit(): void { ngOnInit(): void {
if (!this.loading) { if (!this.loading) {
this.file_length = fancyTimeFormat(this.file_obj.duration); this.file_length = fancyTimeFormat(this.file_obj.duration);
} }
if (this.file_obj && this.file_obj.thumbnailPath) {
this.thumbnailBlobURL = `${this.baseStreamPath}thumbnail/${encodeURIComponent(this.file_obj.thumbnailPath)}${this.jwtString}`;
/*const mime = getMimeByFilename(this.file_obj.thumbnailPath);
const blob = new Blob([new Uint8Array(this.file_obj.thumbnailBlob.data)], {type: mime});
const bloburl = URL.createObjectURL(blob);
this.thumbnailBlobURL = this.sanitizer.bypassSecurityTrustUrl(bloburl);*/
}
} }
emitDeleteFile(blacklistMode = false) { emitDeleteFile(blacklistMode = false) {
@@ -51,8 +86,8 @@ export class UnifiedFileCardComponent implements OnInit {
}); });
} }
navigateToFile() { navigateToFile(event) {
this.goToFile.emit(this.file_obj); this.goToFile.emit({file: this.file_obj, event: event});
} }
navigateToSubscription() { navigateToSubscription() {
@@ -75,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) { function fancyTimeFormat(time) {
@@ -97,3 +141,16 @@ function fancyTimeFormat(time) {
ret += '' + secs; ret += '' + secs;
return ret; return ret;
} }
function getMimeByFilename(name) {
switch (name.substring(name.length-4, name.length)) {
case '.jpg':
return 'image/jpeg';
case '.png':
return 'image/png';
case 'webp':
return 'image/webp';
default:
return null;
}
}

View File

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

View File

@@ -19,9 +19,9 @@
<mat-label *ngIf="type === 'audio'"><ng-container i18n="Audio files title">Audio files</ng-container></mat-label> <mat-label *ngIf="type === 'audio'"><ng-container i18n="Audio files title">Audio files</ng-container></mat-label>
<mat-label *ngIf="type === 'video'"><ng-container i18n="Videos title">Videos</ng-container></mat-label> <mat-label *ngIf="type === 'video'"><ng-container i18n="Videos title">Videos</ng-container></mat-label>
<mat-select [formControl]="filesSelect" multiple required aria-required> <mat-select [formControl]="filesSelect" multiple required aria-required>
<ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container> <ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
<ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container> <ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
<ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.id">{{file.id}}</mat-option></ng-container> <ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
<!-- No videos available --> <!-- No videos available -->

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

View File

@@ -51,9 +51,8 @@ export class CreatePlaylistComponent implements OnInit {
createPlaylist() { createPlaylist() {
const thumbnailURL = this.getThumbnailURL(); const thumbnailURL = this.getThumbnailURL();
const duration = this.calculateDuration();
this.create_in_progress = true; this.create_in_progress = true;
this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL, duration).subscribe(res => { this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL).subscribe(res => {
this.create_in_progress = false; this.create_in_progress = false;
if (res['success']) { if (res['success']) {
this.dialogRef.close(true); this.dialogRef.close(true);
@@ -78,36 +77,4 @@ export class CreatePlaylistComponent implements OnInit {
} }
return null; return null;
} }
getDuration(file_id) {
let properFilesToSelectFrom = this.filesToSelectFrom;
if (!this.filesToSelectFrom) {
properFilesToSelectFrom = this.type === 'audio' ? this.audiosToSelectFrom : this.videosToSelectFrom;
}
for (let i = 0; i < properFilesToSelectFrom.length; i++) {
const file = properFilesToSelectFrom[i];
if (file.id === file_id) {
return file.duration;
}
}
return null;
}
calculateDuration() {
let sum = 0;
for (let i = 0; i < this.filesSelect.value.length; i++) {
const duration_val = this.getDuration(this.filesSelect.value[i]);
sum += typeof duration_val === 'string' ? this.durationStringToNumber(duration_val) : duration_val;
}
return sum;
}
durationStringToNumber(dur_str) {
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length-1; i >= 0; i--) {
num_sum += parseInt(dur_str_parts[i])*(60**(dur_str_parts.length-1-i));
}
return num_sum;
}
} }

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'; import { AboutDialogComponent } from './about-dialog.component';
@@ -6,7 +6,7 @@ describe('AboutDialogComponent', () => {
let component: AboutDialogComponent; let component: AboutDialogComponent;
let fixture: ComponentFixture<AboutDialogComponent>; let fixture: ComponentFixture<AboutDialogComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ AboutDialogComponent ] 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'; import { AddUserDialogComponent } from './add-user-dialog.component';
@@ -6,7 +6,7 @@ describe('AddUserDialogComponent', () => {
let component: AddUserDialogComponent; let component: AddUserDialogComponent;
let fixture: ComponentFixture<AddUserDialogComponent>; let fixture: ComponentFixture<AddUserDialogComponent>;
beforeEach(async(() => { beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ AddUserDialogComponent ] declarations: [ AddUserDialogComponent ]
}) })

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