Compare commits

..

121 Commits

Author SHA1 Message Date
Tzahi12345
148297d558 Fixed applyFilterLocalDB 2023-06-03 17:16:11 -04:00
Tzahi12345
7167c362d2 Fixed issue where filtering DB item with missing fields with local db would crash the server 2023-06-03 16:09:23 -04:00
Tzahi12345
8ac0ac2976 Updated and complete Twitch emoticon logic in the frontend and backend 2023-06-03 16:08:27 -04:00
Tzahi12345
3a20e03490 Added ability to download twitch emotes in the backend 2023-05-29 23:41:30 -04:00
Tzahi12345
7124792721 Updated recent-videos for angular 16 2023-05-29 22:08:24 -04:00
Tzahi12345
b32396164d Updated ngx-avatars 2023-05-29 21:52:10 -04:00
Tzahi12345
3d633f9e47 Updated angular material to 16 2023-05-29 21:51:18 -04:00
Tzahi12345
cfa0a62587 Updated angular core and cli to 16 2023-05-29 21:48:26 -04:00
Tzahi12345
1d53f6b1b6 Reintroduced twitch API settings
Added twitch-emoticons support, began initial work of adding twitch emoticons to twitch chat
2023-05-29 21:42:13 -04:00
Tzahi12345
398b2c0e1c Updated export method for twitch.js 2023-05-29 21:21:32 -04:00
Tzahi12345
d3cb957991 Merge pull request #933 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-05-28 20:51:12 -04:00
Tzahi12345
098c5a3c25 Merge pull request #936 from Tzahi12345/test-improvements
Test improvements
2023-05-28 19:33:53 -04:00
Isaac Abadi
be71a9bd8c Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into test-improvements 2023-05-28 18:16:44 -04:00
Isaac Abadi
42c600cea9 Improved and fixed tests
Skipped tests just meant for manual testing

mocha now used prod mode

Added automated tests for PRs

Fixed backupdb and youtube-dl tests in gh actions

Fixed downloader tests

Removed erroneous logs in tests

Updated mocha.yml

Removed last console.log in tests

Increased timeout for download video test

Automated tests now force stop after tests complete

Replaced download test url with shorter video

Skip download test... for now

increased download test threshold

added more timing information to tests

unskip download test

Bump docker/setup-buildx-action from 1 to 2 in /.github/workflows

Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Bump actions/setup-node from 2 to 3 in /.github/workflows

Bumps [actions/setup-node](https://github.com/actions/setup-node) from 2 to 3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Bump actions/upload-artifact from 1 to 3 in /.github/workflows

Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 1 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Bump docker/login-action from 1 to 2 in /.github/workflows

Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

Removed additional download button for paused download

Combined output json processing from youtube-dl between subscriptions and one-off downloads

Added workaround for missing video in playlist to one-off downloads

Fixed issue of case where output is empty

Download video code cleanup

Fixed download video test by overriding youtube-dl download method with custom method

Adds ffmpeg to automated tests

Removed test mp3/mp4 file from repo
2023-05-28 18:02:03 -04:00
Tzahi12345
0427f91cfc Merge pull request #935 from Tzahi12345/downloader-ui-fix
Downloader fixes
2023-05-28 14:03:53 -04:00
Tzahi12345
51552b3092 Merge pull request #791 from Tzahi12345/dependabot/github_actions/dot-github/workflows/docker/login-action-2
Bump docker/login-action from 1 to 2 in /.github/workflows
2023-05-28 10:52:36 -04:00
Tzahi12345
1a7ca0343a Merge pull request #792 from Tzahi12345/dependabot/github_actions/dot-github/workflows/actions/upload-artifact-3
Bump actions/upload-artifact from 1 to 3 in /.github/workflows
2023-05-28 10:52:14 -04:00
Tzahi12345
525e8e04e8 Merge pull request #790 from Tzahi12345/dependabot/github_actions/dot-github/workflows/actions/setup-node-3
Bump actions/setup-node from 2 to 3 in /.github/workflows
2023-05-28 10:51:42 -04:00
Tzahi12345
5a824cee82 Merge pull request #793 from Tzahi12345/dependabot/github_actions/dot-github/workflows/docker/setup-buildx-action-2
Bump docker/setup-buildx-action from 1 to 2 in /.github/workflows
2023-05-28 10:51:11 -04:00
Tzahi12345
13a03a722c Subscription metadata fix (#934)
* Fixed issue where metadata was attempting to be written to a dir that didn't exist yet
* Fixed issue where sub was assumed as duplicate always if using local db
2023-05-27 02:38:46 -04:00
Isaac Abadi
f7d3835111 Fixed issue of case where output is empty 2023-05-27 01:49:32 -04:00
Isaac Abadi
8212acbac6 Combined output json processing from youtube-dl between subscriptions and one-off downloads
Added workaround for missing video in playlist to one-off downloads
2023-05-27 01:39:40 -04:00
Isaac Abadi
2a1af69f1f Removed additional download button for paused download 2023-05-27 00:57:27 -04:00
Anjar Yudikta Swareka
1f615a2379 Translated using Weblate (Indonesian)
Currently translated at 81.3% (389 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/id/
2023-05-26 19:51:47 +02:00
Anjar Yudikta Swareka
f50d3104de Translated using Weblate (Indonesian)
Currently translated at 81.1% (388 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/id/
2023-05-26 19:51:47 +02:00
Tzahi12345
f23ca61dab Fixed missing version upgrade 2023-05-25 22:24:11 -04:00
Tzahi12345
6eadb37532 4.3.2 release (#929)
* Upgraded version to 4.3.2
* Prevent null userid/username registration
2023-05-25 22:06:07 -04:00
Tzahi12345
2c61260e0f UI updates (#922)
* Fixed download spinner in player component

* Downloads UI is more mobile friendly (#905)

* Code cleanup

* Fixed size of actions in home screen downloads

* Errored downloads now display their stage as "Error" in the UI

* Moved personal settings from about dialog to profile dialog

* Profile dialog can now be opened without logging in/without multi-user mode

* Fixed issue where archive dialog could be accessed from anywhere

* Misc internationalization improvements

* Combined download stage and download progress columns

* Added back loading spinner to download actions

* Adjusted thresholds for consolidating download action buttons

* Implemented virtual scrolling for notifications (helps if many notifications exist)

* Fixed minor console error
2023-05-25 21:55:35 -04:00
Dedy Martadinata S
ba0de7f95c Fix entrypoint.sh (#921)
* Revert https://github.com/Tzahi12345/YoutubeDL-Material/pull/864

add && make it err and skipping perm check

* Restore armv7 nightly

* Update entrypoint.sh

* simplify, always setup permission regardless uid
2023-05-25 21:54:54 -04:00
Weblate (bot)
3dfdbcb151 Translated using Weblate (Chinese (Simplified)) (#925)
Currently translated at 100.0% (478 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/zh_Hans/

Co-authored-by: yangyangdaji <1504305527@qq.com>
2023-05-25 21:54:04 -04:00
Tzahi12345
c207e56855 Rebuild database task (#900)
* Improved tests for multi-user mode
* Adds task to rebuild database
* Updated subscriptions.js export syntax
* Subscription metadata is now backed up
* Added typing to task key
* Updated api models
* Tasks actions styling update
2023-05-23 22:37:25 -04:00
Tzahi12345
441131e930 Merge pull request #917 from Tzahi12345/archive-count-fix
Subscription temp archive fix
2023-05-23 22:35:49 -04:00
Tzahi12345
84c2b2769b Merge pull request #890 from martadinata666/js-generate
docker : reduce build time and size
2023-05-21 00:35:28 -04:00
Isaac Abadi
e145c9c992 Updated fetch-twitchdownloader.sh to get the latest release for the specific arch 2023-05-20 19:32:00 -06:00
Dedy Martadinata S
2adbc0a02c Update fetch-twitchdownloader.sh 2023-05-20 11:35:12 +07:00
Dedy Martadinata S
fe95f04c18 Update Dockerfile 2023-05-20 11:30:24 +07:00
Dedy Martadinata S
9b3816afce Update Dockerfile 2023-05-20 11:25:16 +07:00
Dedy Martadinata S
07874d9241 Revert 142d708ee3
It become fail to set anything
2023-05-20 11:13:09 +07:00
Isaac Abadi
7447ca038a Fixed issue where empty temp archive files would get generated 2023-05-18 21:22:20 -04:00
Dedy Martadinata S
9fa1aab1e5 Update Dockerfile, Update CI PR, use scripts to download twitchdownloader 2023-05-12 14:46:27 +07:00
Glassed Silver
80b41af620 Merge pull request #910 from Tzahi12345/twitch-chat-fix
Twitch chat downloader fix for Docker
2023-05-12 07:01:12 +02:00
Tzahi12345
ab5d8dc5ca Merge pull request #911 from Tzahi12345/registration-fixes
Registration fixes
2023-05-12 00:03:05 -04:00
Tzahi12345
4b55c39f39 permissions code simplified 2023-05-11 23:14:40 -04:00
Tzahi12345
3ca296f195 Fixed compilation issue 2023-05-11 23:14:25 -04:00
Tzahi12345
d4fa640f0f Added tasks_manager to possible user/role permission in openapi 2023-05-11 02:47:06 -04:00
Tzahi12345
427eecf214 Fixed issue where after resetting the DB, admin would have to be registered twice 2023-05-11 02:44:05 -04:00
Tzahi12345
4f54e408a5 Removed erroneous error after registering admin 2023-05-11 02:43:37 -04:00
Tzahi12345
9e481bbd5f Fixed issue where twitch chat downloader could not be found in docker 2023-05-11 02:27:35 -04:00
Glassed Silver
78b29a76b8 Merge pull request #864 from nardis556/master
Update entrypoint.sh
2023-05-11 03:19:50 +02:00
Glassed Silver
0342d18f76 Merge pull request #906 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-05-11 03:17:40 +02:00
Glassed Silver
70754c580c Merge pull request #908 from Tzahi12345/ffmpeg-force-v5
Force ffmpeg 5.1.1
2023-05-11 03:16:08 +02:00
Tzahi12345
e58b0b8638 Force ffmpeg 5.1.1 2023-05-10 19:04:48 -04:00
YMisterXY
df8f8070ca Translated using Weblate (Polish)
Currently translated at 80.5% (385 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/pl/
2023-05-10 13:49:19 +02:00
gallegonovato
0b8ca31594 Translated using Weblate (Spanish)
Currently translated at 100.0% (478 of 478 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2023-05-10 13:49:19 +02:00
Tzahi12345
658a76dc1c Added missing admin tasks_manager role 2023-05-08 19:23:07 -04:00
Glassed Silver
f363ec5db6 Merge pull request #901 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-05-08 22:12:01 +02:00
gallegonovato
f36d675abf Translated using Weblate (Spanish)
Currently translated at 100.0% (480 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/es/
2023-05-08 17:52:23 +02:00
Tzahi12345
be74377a08 Merge pull request #899 from Tzahi12345/remove-armv7
Remove armv7 support in Docker
2023-05-07 02:13:55 -04:00
Isaac Abadi
808c7e2112 Temporarily remove armv7 support
Revert "Added python3.8-dev/build-essential to dockerfile"

This reverts commit d90434c240.

Revert "Adds token to GH actions for GetTwitchDownloader"

This reverts commit a4ca1abb7c.
2023-05-07 01:23:58 -04:00
Isaac Abadi
d6f39d37b5 Added PR multiarch
Added python3.8-dev/build-essential to dockerfile

Adds token to GH actions for GetTwitchDownloader
2023-05-07 01:23:44 -04:00
Tzahi12345
e573f34cea Merge pull request #893 from Tzahi12345/readme-update
README update
2023-05-05 00:05:03 -04:00
Tzahi12345
52e32d4f0f Changed tcd to Twitch Downloader 2023-05-04 23:44:12 -04:00
Tzahi12345
adb5f2256e Translations source file update 2023-05-04 22:33:48 -04:00
Tzahi12345
59bf6ff86d Merge pull request #888 from Tzahi12345/dependency-updates
Dependency updates
2023-05-04 22:28:50 -04:00
Tzahi12345
5ce2e2a35d Updated name of arm64 TwitchDownloaderCLI asset 2023-05-04 22:19:00 -04:00
Tzahi12345
68fbde8907 Merge pull request #800 from Bastians-Bits/master
Development Documentation
2023-05-03 19:32:07 -04:00
Tzahi12345
62bccb3349 Updated DEVELOPMENT.md to reflect dev config file 2023-05-03 17:01:17 -04:00
Tzahi12345
90d9ac025a Added missing config item from default.json 2023-05-03 16:38:48 -04:00
Tzahi12345
07903131f9 GetTwitchDownloader.py now supports LinuxArm-x64 (not implemeted yet), waiting for this: https://github.com/lay295/TwitchDownloader/pull/703 2023-05-03 14:20:54 -04:00
Tzahi12345
ec3bb3e738 Updated dockerfile to move TwitchDownloaderCLI to /usr/local/bin
Fixed issue that prevented TwitchDownloader from working in windows
2023-05-03 14:04:09 -04:00
Tzahi12345
18fcf4eb61 Added missing copy in dockerfile 2023-05-03 02:01:28 -04:00
Tzahi12345
19f35d6af4 Updated content-loader and ngx-file-drop to improve build times 2023-05-03 01:33:51 -04:00
Tzahi12345
3a918b7059 Updated github actions dependencies (2) 2023-05-03 01:08:52 -04:00
Tzahi12345
7e7da6c0bc Updated github actions dependencies 2023-05-03 01:07:14 -04:00
Tzahi12345
f9f7204deb Updated several dependencies 2023-05-03 01:03:49 -04:00
Tzahi12345
8827d9f3de Added some pruning to shrink docker image size 2023-05-02 23:13:47 -04:00
Tzahi12345
42bc255d6c Removed fingerprintjs2 and sessionID param 2023-05-02 23:04:41 -04:00
Tzahi12345
2df3b9cbfd Removed electron (for now) 2023-05-02 23:02:33 -04:00
Tzahi12345
b859d08d86 Added raspberry pi specific documentation to docker-compose 2023-05-02 22:51:52 -04:00
Tzahi12345
e7325b2dc2 Merge pull request #887 from Tzahi12345/gh-actions-fix
GH actions fix
2023-05-02 19:19:06 -04:00
Tzahi12345
21463762ce Removed rimraf install from package.json 2023-05-02 19:10:40 -04:00
Tzahi12345
b06f6a81bb Force rmraf install 2023-05-01 22:34:25 -04:00
Tzahi12345
82c8146032 Updated nodejs version for backend 2023-05-01 19:56:37 -04:00
Tzahi12345
6f13eab550 Force rimraf to use locally installed version https://stackoverflow.com/questions/49092120/sh-1-rimraf-not-found-whenever-i-run-npm-run-build-within-vagrant-installed-o 2023-05-01 19:52:35 -04:00
Tzahi12345
9d2d70b194 Upgraded node version to v16 2023-05-01 19:43:04 -04:00
Tzahi12345
4e04ceae16 Fixed function call in db.js 2023-05-01 19:31:54 -04:00
Tzahi12345
5eec5ac082 Merge pull request #885 from Tzahi12345/slack-notifications
Added support for slack notifications
2023-05-01 17:21:00 -04:00
Tzahi12345
5253ce8793 Merge pull request #886 from Tzahi12345/archive-improvements
Archive improvements
2023-05-01 17:20:50 -04:00
Tzahi12345
33a99d9c8d Added files_api (migrated functions from db_api that are file related)
Archive dialog can now always be opened
2023-04-29 19:41:34 -04:00
Tzahi12345
0e5c78db0d Modified archive logic to align with #366 2023-04-29 19:34:08 -04:00
Tzahi12345
9a08fc6140 Added support for slack notifications 2023-04-29 15:46:58 -04:00
Glassed Silver
e7b9dfd312 Merge pull request #873 from Tzahi12345/pre-4.3.1-bug-fixes
Pre 4.3.1 bug fixes
2023-04-28 10:26:11 +02:00
Glassed Silver
1e2922559c Merge pull request #868 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2023-04-26 15:30:41 +02:00
Tzahi12345
cfbee6d6f1 Sub name duplicated bug fix 2023-04-25 23:59:13 -04:00
Tzahi12345
c75d58efd5 Fixed issue where duplicate sub names were possible (#801) 2023-04-25 23:36:15 -04:00
Tzahi12345
efbf395368 Fixed settings tab url labels 2023-04-25 22:14:52 -04:00
Tzahi12345
dab9fc83ba Added support for discord webhooks
Improved download error notifications
2023-04-25 22:14:35 -04:00
Tzahi12345
e086bbc301 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into pre-4.3.1-bug-fixes 2023-04-25 21:57:24 -04:00
Tzahi12345
0b3a21b383 Added missing config file settings 2023-04-24 21:17:33 -04:00
Tzahi12345
a4c78e3a3d Minor verbose message update 2023-04-24 21:10:51 -04:00
Tzahi12345
50d3bc183b Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into pre-4.3.1-bug-fixes 2023-04-24 20:14:55 -04:00
Tzahi12345
71692f6b13 Updated dependencies 2023-04-24 19:55:53 -04:00
Tzahi12345
1746b08d4c Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into pre-4.3.1-bug-fixes 2023-04-24 19:32:19 -04:00
Tzahi12345
3bc0ec8bb5 Added category tests
Fixed syntax errors in tests.js
2023-04-24 19:30:25 -04:00
Tzahi12345
2df4dc1bfc Updated file not found error message in /stream 2023-04-24 19:29:35 -04:00
Tzahi12345
0e190fca2a Ghost file card count are now per-sub 2023-04-24 19:28:52 -04:00
Tzahi12345
5aea0b7a3d Archive text file is now temporarily added to a sub dir when checking a sub for speed purposes 2023-04-24 19:28:11 -04:00
Tzahi12345
a996b9f0d2 ng-deeps now only apply to current component
https://stackoverflow.com/questions/46786986/how-and-where-to-use-ng-deep
2023-04-23 22:31:11 -04:00
Ettore Atalan
7a4ae052ed Translated using Weblate (German)
Currently translated at 95.0% (456 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/de/
2023-04-23 23:48:35 +02:00
Ettore Atalan
b65a7b3dd4 Translated using Weblate (German)
Currently translated at 91.8% (441 of 480 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/de/
2023-04-23 23:48:35 +02:00
Tzahi12345
f0a34df7c6 Fixed syntax error in utils 2023-04-21 16:48:24 -04:00
Tzahi12345
e2c68713ba Fixed bug where added args would get injected improperly if they had a value (#745) 2023-04-21 16:34:47 -04:00
Tzahi12345
24cabc1f02 Fixed issue where videos would be downloaded in the collectInfo stage (#774) 2023-04-21 16:12:41 -04:00
Tzahi12345
1edcfca6c3 Fixed bug where notifications wouldn't be set as read if using local db 2023-04-21 15:57:59 -04:00
Tzahi12345
e7fa25cf38 Fixed tasks UI bug 2023-04-21 15:34:14 -04:00
nardis556
26988bd607 Update entrypoint.sh 2023-04-16 15:03:12 -05:00
bastiansbits
575f7eed4e Added a new read me (DEVELOPMENT.md) as starting point for new develope
Added a new VSC launch configuration to start the backend in the debugger
Update the build instruction in README.md (Issue #728)
2022-12-07 14:43:43 +01:00
dependabot[bot]
c5d1b3ffcf Bump docker/setup-buildx-action from 1 to 2 in /.github/workflows
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 1 to 2.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:10:06 +00:00
dependabot[bot]
c64140b605 Bump actions/upload-artifact from 1 to 3 in /.github/workflows
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 1 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v1...v3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:10:04 +00:00
dependabot[bot]
6579f2b59e Bump docker/login-action from 1 to 2 in /.github/workflows
Bumps [docker/login-action](https://github.com/docker/login-action) from 1 to 2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v1...v2)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:10:00 +00:00
dependabot[bot]
9f5b584593 Bump actions/setup-node from 2 to 3 in /.github/workflows
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 2 to 3.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v2...v3)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-11-23 11:09:57 +00:00
102 changed files with 31707 additions and 6260 deletions

View File

@@ -15,9 +15,9 @@ jobs:
- name: checkout code
uses: actions/checkout@v2
- name: setup node
uses: actions/setup-node@v2
uses: actions/setup-node@v3
with:
node-version: '14'
node-version: '16'
cache: 'npm'
- name: install dependencies
run: |
@@ -33,7 +33,7 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
uses: jsdaniell/create-json@v1.2.2
with:
name: "version.json"
json: '{"type": "autobuild", "tag": "N/A", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -55,7 +55,7 @@ jobs:
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
uses: actions/upload-artifact@v3
with:
name: youtubedl-material
path: build

View File

@@ -18,10 +18,21 @@ jobs:
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
uses: jsdaniell/create-json@v1.2.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "nightly", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
dir: 'backend/'
- name: Build docker images
run: docker build . -t tzahi12345/youtubedl-material:nightly-pr
- name: setup platform emulator
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
- name: build & push images
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm/v7,linux/arm64/v8
#platforms: linux/amd64
push: false
tags: tzahi12345/youtubedl-material:nightly-pr

View File

@@ -27,7 +27,7 @@ jobs:
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
uses: jsdaniell/create-json@v1.2.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "latest", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -60,10 +60,10 @@ jobs:
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -80,7 +80,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}

View File

@@ -34,7 +34,7 @@ jobs:
- name: create-json
id: create-json
uses: jsdaniell/create-json@1.1.2
uses: jsdaniell/create-json@v1.2.2
with:
name: "version.json"
json: '{"type": "docker", "tag": "${{secrets.DOCKERHUB_MASTER_TAG}}", "commit": "${{ steps.vars.outputs.sha_short }}", "date": "${{ steps.date.outputs.date }}"}'
@@ -44,7 +44,7 @@ jobs:
uses: docker/setup-qemu-action@v1
- name: setup multi-arch docker build
uses: docker/setup-buildx-action@v1
uses: docker/setup-buildx-action@v2
- name: Generate Docker image metadata
id: docker-meta
@@ -63,7 +63,7 @@ jobs:
type=sha,prefix=sha-,format=short
- name: Login to DockerHub
uses: docker/login-action@v1
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -80,7 +80,7 @@ jobs:
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm,linux/arm64/v8
platforms: linux/amd64,linux/arm64/v8,linux/arm/v7
push: true
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}

40
.github/workflows/mocha.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Tests
'on':
push:
branches:
- master
pull_request:
types:
- opened
- synchronize
- reopened
jobs:
test:
name: 'Backend - mocha'
runs-on: ubuntu-latest
strategy:
matrix:
node:
- 16
steps:
- uses: actions/setup-node@v3
with:
node-version: '${{ matrix.node }}'
- uses: actions/checkout@v2
- name: 'Cache node_modules'
uses: actions/cache@v2
with:
path: ~/.npm
key: ${{ runner.os }}-node-v${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-v${{ matrix.node }}-
working-directory: ./backend
- uses: FedericoCarboni/setup-ffmpeg@v2
id: setup-ffmpeg
- name: Install Dependencies
run: npm install
working-directory: ./backend
- name: Run All Node.js Tests
run: npm run test
working-directory: ./backend

14
.vscode/launch.json vendored
View File

@@ -4,6 +4,20 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Dev: Debug Backend",
"request": "launch",
"runtimeArgs": [
"run-script",
"debug"
],
"runtimeExecutable": "npm",
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"cwd": "${workspaceFolder}/backend"
},
{
"type": "node",
"request": "attach",

View File

@@ -3,6 +3,6 @@
"mochaExplorer.cwd": "backend",
"mochaExplorer.globImplementation": "vscode",
"mochaExplorer.env": {
"YTDL_MODE": "debug"
// "YTDL_MODE": "debug"
}
}

38
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,38 @@
<h1>Development</h1>
- [First time...](#first-time)
- [Setup](#setup)
- [Startup](#startup)
- [Debugging the backend (VSC)](#debugging-the-backend-vsc)
- [Deploy changes](#deploy-changes)
- [Frontend](#frontend)
- [Backend](#backend)
# First time...
## Setup
Checkout the repository and navigate to the `youtubedl-material` directory.
```bash
vim ./src/assets/default.json # Edit settings for your local environment. This config file is just the dev config file, if YTDL_MODE is not set to "debug", then ./backend/appdata/default.json will be used
npm -g install pm2 # Install pm2
npm install # Install dependencies for the frontend
cd ./backend
npm install # Install dependencies for the backend
cd ..
npm run build # Build the frontend
```
This step have to be done only once.
## Startup
Navigate to the `youtubedl-material/backend` directory and run `npm start`.
# Debugging the backend (VSC)
Open the `youtubedl-material` directory in Visual Studio Code and run the launch configuration `Dev: Debug Backend`.
# Deploy changes
## Frontend
Navigate to the `youtubedl-material` directory and run `npm run build`. Restart the backend.
## Backend
Simply restart the backend.

View File

@@ -1,15 +1,17 @@
# Fetching our ffmpeg
FROM ubuntu:22.04 AS ffmpeg
# Fetching our utils
FROM ubuntu:22.04 AS utils
ENV DEBIAN_FRONTEND=noninteractive
# Use script due local build compability
COPY docker-utils/ffmpeg-fetch.sh .
RUN chmod +x ffmpeg-fetch.sh
COPY docker-utils/*.sh .
RUN chmod +x *.sh
RUN sh ./ffmpeg-fetch.sh
RUN sh ./fetch-twitchdownloader.sh
# Create our Ubuntu 22.04 with node 16.14.2 (that specific version is required as per: https://stackoverflow.com/a/72855258/8088021)
# Go to 20.04
FROM ubuntu:20.04 AS base
FROM ubuntu:22.04 AS base
ARG TARGETPLATFORM
ARG DEBIAN_FRONTEND=noninteractive
ENV UID=1000
ENV GID=1000
@@ -17,19 +19,30 @@ ENV USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV PM2_HOME=/app/pm2
ENV ALLOW_CONFIG_MUTATIONS=true
# Directy fetch specific version
## https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_amd64.deb
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt update && \
apt install -y --no-install-recommends curl ca-certificates tzdata && \
curl -fsSL https://deb.nodesource.com/setup_16.x | bash - && \
apt install -y --no-install-recommends nodejs && \
npm -g install npm n && \
n 16.14.2 && \
apt install -y --no-install-recommends curl ca-certificates tzdata libicu70 && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN case ${TARGETPLATFORM} in \
"linux/amd64") NODE_ARCH=amd64 ;; \
"linux/arm") NODE_ARCH=armhf ;; \
"linux/arm/v7") NODE_ARCH=armhf ;; \
"linux/arm64") NODE_ARCH=arm64 ;; \
esac \
&& curl -L https://deb.nodesource.com/node_16.x/pool/main/n/nodejs/nodejs_16.14.2-deb-1nodesource1_$NODE_ARCH.deb -o ./nodejs.deb && \
apt update && \
apt install -y ./nodejs.deb && \
apt clean && \
rm -rf /var/lib/apt/lists/* &&\
rm nodejs.deb;
# Build frontend
FROM base as frontend
ARG BUILDPLATFORM
FROM --platform=${BUILDPLATFORM} node:16 as frontend
RUN npm install -g @angular/cli
WORKDIR /build
COPY [ "package.json", "package-lock.json", "angular.json", "tsconfig.json", "/build/" ]
@@ -37,6 +50,8 @@ COPY [ "src/", "/build/src/" ]
RUN npm install && \
npm run build && \
ls -al /build/backend/public
RUN npm uninstall -g @angular/cli
RUN rm -rf node_modules
# Install backend deps
@@ -47,31 +62,35 @@ RUN npm config set strict-ssl false && \
npm install --prod && \
ls -al
FROM base as python
WORKDIR /app
COPY docker-utils/GetTwitchDownloader.py .
RUN apt update && \
apt install -y --no-install-recommends python3-minimal python-is-python3 python3-pip && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install PyGithub requests
RUN python GetTwitchDownloader.py
#FROM base as python
# armv7 need build from source
#WORKDIR /app
#COPY docker-utils/GetTwitchDownloader.py .
#RUN apt update && \
# apt install -y --no-install-recommends python3-minimal python-is-python3 python3-pip python3-dev build-essential libffi-dev && \
# apt clean && \
# rm -rf /var/lib/apt/lists/*
#RUN pip install PyGithub requests
#RUN python GetTwitchDownloader.py
# Final image
FROM base
RUN npm install -g pm2 && \
apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley build-essential && \
pip install pycryptodomex && \
apt remove -y --purge build-essential && \
apt autoremove -y --purge && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install pycryptodomex
WORKDIR /app
# User 1000 already exist from base image
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=ffmpeg [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffmpeg", "/usr/local/bin/ffmpeg" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/ffprobe", "/usr/local/bin/ffprobe" ]
COPY --chown=$UID:$GID --from=utils [ "/usr/local/bin/TwitchDownloaderCLI", "/usr/local/bin/TwitchDownloaderCLI"]
COPY --chown=$UID:$GID --from=backend ["/app/","/app/"]
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
RUN chown $UID:$GID .
#COPY --chown=$UID:$GID --from=python ["/app/TwitchDownloaderCLI","/usr/local/bin/TwitchDownloaderCLI"]
RUN chmod +x /app/fix-scripts/*.sh
# Add some persistence data
#VOLUME ["/app/appdata"]

View File

@@ -678,6 +678,22 @@ paths:
$ref: '#/components/schemas/SuccessObject'
security:
- Auth query parameter: []
/api/isPinSet:
post:
tags:
- security
summary: Check if pin is set
description: Checks if the pin is set for settings
operationId: post-api-isPinSet
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/inline_response_200_15'
security:
- Auth query parameter: []
/api/generateNewAPIKey:
post:
tags:
@@ -1295,48 +1311,6 @@ paths:
- Auth query parameter: []
tags:
- multi-user mode
/api/setPin:
post:
summary: Set settings pin
operationId: post-api-setPin
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessObject'
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/SetPinRequest'
description: 'Sets a pin for the settings'
security:
- Auth query parameter: []
tags:
- security
/api/auth/pinLogin:
post:
summary: Pin login
operationId: post-api-pin-login
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/PinLoginResponse'
description: Use this endpoint to generate a JWT token for pin authentication. Put anything in the username field.
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/LoginRequest'
security:
- Auth query parameter: []
tags:
- security
/api/getUsers:
post:
summary: Get all users
@@ -1784,14 +1758,14 @@ components:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
required:
- task_key
UpdateTaskScheduleRequest:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
new_schedule:
$ref: '#/components/schemas/Schedule'
required:
@@ -1801,7 +1775,7 @@ components:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
new_data:
type: object
required:
@@ -1811,7 +1785,7 @@ components:
type: object
properties:
task_key:
type: string
$ref: '#/components/schemas/TaskType'
new_options:
type: object
required:
@@ -2752,7 +2726,7 @@ components:
type: object
properties:
key:
type: string
$ref: '#/components/schemas/TaskType'
title:
type: string
last_ran:
@@ -2768,9 +2742,20 @@ components:
error:
type: string
schedule:
type: object
$ref: '#/components/schemas/Schedule'
options:
type: object
TaskType:
type: string
enum:
- backup_local_db
- missing_files_check
- missing_db_records
- duplicate_files_check
- youtubedl_update_check
- delete_old_files
- import_legacy_archives
- rebuild_database
Schedule:
required:
- type
@@ -2903,6 +2888,7 @@ components:
- sharing
- advanced_download
- downloads_manager
- tasks_manager
YesNo:
type: string
enum:
@@ -3051,13 +3037,6 @@ components:
type: string
required:
- role
SetPinRequest:
required:
- new_pin
type: object
properties:
new_pin:
type: string
file:
title: file
type: object
@@ -3107,13 +3086,6 @@ components:
type: array
items:
$ref: '#/components/schemas/UserPermission'
PinLoginResponse:
required:
- pin_token
type: object
properties:
pin_token:
type: string
UpdateUserRequest:
required:
- change_object

View File

@@ -28,13 +28,28 @@ Dark mode:
NOTE: If you would like to use Docker, you can skip down to the [Docker](#Docker) section for a setup guide.
Debian/Ubuntu:
Required dependencies:
* Node.js 16
* Python
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
* [Twitch Downloader CLI](https://github.com/lay295/TwitchDownloader) (for downloading Twitch VOD chats)
<details>
<summary>Debian/Ubuntu</summary>
```bash
curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash -
sudo apt-get install nodejs youtube-dl ffmpeg unzip python npm
```
CentOS 7:
</details>
<details>
<summary>CentOS 7</summary>
```bash
sudo yum install epel-release
@@ -42,13 +57,11 @@ sudo yum localinstall --nogpgcheck https://download1.rpmfusion.org/free/el/rpmfu
sudo yum install centos-release-scl-rh
sudo yum install rh-nodejs12
scl enable rh-nodejs12 bash
curl -fsSL https://rpm.nodesource.com/setup_16.x | sudo bash -
sudo yum install nodejs youtube-dl ffmpeg ffmpeg-devel
```
Optional dependencies:
* AtomicParsley (for embedding thumbnails, package name `atomicparsley`)
* [tcd](https://github.com/PetterKraabol/Twitch-Chat-Downloader) (for downloading Twitch VOD chats)
</details>
### Installing
@@ -72,7 +85,9 @@ If you'd like to install YoutubeDL-Material, go to the Installation section. If
To deploy, simply clone the repository, and go into the `youtubedl-material` directory. Type `npm install` and all the dependencies will install. Then type `cd backend` and again type `npm install` to install the dependencies for the backend.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/appdata`, go back into the `youtubedl-material` directory, and type `npm build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder.
Once you do that, you're almost up and running. All you need to do is edit the configuration in `youtubedl-material/appdata`, go back into the `youtubedl-material` directory, and type `npm run build`. This will build the app, and put the output files in the `youtubedl-material/backend/public` folder.
Lastly, type `npm -g install pm2` to install pm2 globally.
The frontend is now complete. The backend is much easier. Just go into the `backend` folder, and type `npm start`.

View File

@@ -2,7 +2,6 @@ const { uuid } = require('uuidv4');
const fs = require('fs-extra');
const { promisify } = require('util');
const auth_api = require('./authentication/auth');
const winston = require('winston');
const path = require('path');
const compression = require('compression');
const multer = require('multer');
@@ -19,6 +18,7 @@ const CONSTS = require('./consts')
const read_last_lines = require('read-last-lines');
const ps = require('ps-node');
const Feed = require('feed').Feed;
const session = require('express-session');
// needed if bin/details somehow gets deleted
if (!fs.existsSync(CONSTS.DETAILS_BIN_PATH)) fs.writeJSONSync(CONSTS.DETAILS_BIN_PATH, {"version":"2000.06.06","path":"node_modules\\youtube-dl\\bin\\youtube-dl.exe","exec":"youtube-dl.exe","downloader":"youtube-dl"})
@@ -34,6 +34,7 @@ const categories_api = require('./categories');
const twitch_api = require('./twitch');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
var app = express();
@@ -162,6 +163,7 @@ app.use(bodyParser.json());
// use passport
app.use(auth_api.passport.initialize());
app.use(session({ secret: uuid(), resave: true, saveUninitialized: true }))
app.use(auth_api.passport.session());
// actual functions
@@ -173,10 +175,10 @@ async function checkMigrations() {
if (!simplified_db_migration_complete) {
logger.info('Beginning migration: 4.1->4.2+')
let success = await simplifyDBFileStructure();
success = success && await db_api.addMetadataPropertyToDB('view_count');
success = success && await db_api.addMetadataPropertyToDB('description');
success = success && await db_api.addMetadataPropertyToDB('height');
success = success && await db_api.addMetadataPropertyToDB('abr');
success = success && await files_api.addMetadataPropertyToDB('view_count');
success = success && await files_api.addMetadataPropertyToDB('description');
success = success && await files_api.addMetadataPropertyToDB('height');
success = success && await files_api.addMetadataPropertyToDB('abr');
// sets migration to complete
db.set('simplified_db_migration_complete', true).write();
if (success) { logger.info('4.1->4.2+ migration complete!'); }
@@ -533,6 +535,7 @@ async function loadConfig() {
if (allowSubscriptions) {
// set downloading to false
let subscriptions = await subscriptions_api.getAllSubscriptions();
subscriptions.forEach(async sub => subscriptions_api.writeSubscriptionMetadata(sub));
subscriptions_api.updateSubscriptionPropertyMultiple(subscriptions, {downloading: false});
// runs initially, then runs every ${subscriptionCheckInterval} seconds
const watchSubscriptionsInterval = function() {
@@ -703,7 +706,7 @@ app.use(function(req, res, next) {
next();
} else if (req.query.apiKey && config_api.getConfigItem('ytdl_use_api_key') && req.query.apiKey === config_api.getConfigItem('ytdl_api_key')) {
next();
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/rss')) {
} else if (req.path.includes('/api/stream/') || req.path.includes('/api/thumbnail/') || req.path.includes('/api/emote/') || req.path.includes('/api/rss')) {
next();
} else {
logger.verbose(`Rejecting request - invalid API use for endpoint: ${req.path}. API key received: ${req.query.apiKey}`);
@@ -724,7 +727,7 @@ const optionalJwt = async function (req, res, next) {
const uuid = using_body ? req.body.uuid : req.query.uuid;
const uid = using_body ? req.body.uid : req.query.uid;
const playlist_id = using_body ? req.body.playlist_id : req.query.playlist_id;
const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await db_api.getPlaylist(playlist_id, uuid, true);
const file = !playlist_id ? auth_api.getUserVideo(uuid, uid, true) : await files_api.getPlaylist(playlist_id, uuid, true);
if (file) {
req.can_watch = true;
return next();
@@ -742,18 +745,6 @@ const optionalJwt = async function (req, res, next) {
return next();
};
const optionalPin = async function (req, res, next) {
const use_pin = config_api.getConfigItem('ytdl_use_pin');
if (use_pin && req.path.includes('/api/setConfig')) {
if (!req.query.pin_token) {
res.sendStatus(418); // I'm a teapot (RFC 2324)
return;
}
return next();
}
return next();
};
app.get('/api/config', function(req, res) {
let config_file = config_api.getConfigFile();
res.send({
@@ -762,7 +753,7 @@ app.get('/api/config', function(req, res) {
});
});
app.post('/api/setConfig', optionalJwt, optionalPin, function(req, res) {
app.post('/api/setConfig', optionalJwt, function(req, res) {
let new_config_file = req.body.new_config_file;
if (new_config_file && new_config_file['YoutubeDLMaterial']) {
let success = config_api.setConfigFile(new_config_file);
@@ -947,7 +938,7 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
const sub_id = req.body.sub_id;
const uuid = req.isAuthenticated() ? req.user.uid : null;
const {files, file_count} = await db_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const {files, file_count} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
res.send({
files: files,
@@ -1002,11 +993,11 @@ app.post('/api/updateConcurrentStream', optionalJwt, async (req, res) => {
});
app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => {
var id = req.body.id;
var type = req.body.type;
var uuid = req.body.uuid;
var sub = req.body.sub;
var user_uid = null;
const id = req.body.id;
const type = req.body.type;
const uuid = req.body.uuid;
const sub = req.body.sub;
let user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
@@ -1018,12 +1009,12 @@ app.post('/api/getFullTwitchChat', optionalJwt, async (req, res) => {
});
app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => {
var id = req.body.id;
var type = req.body.type;
var vodId = req.body.vodId;
var uuid = req.body.uuid;
var sub = req.body.sub;
var user_uid = null;
const id = req.body.id;
const type = req.body.type;
const vodId = req.body.vodId;
const uuid = req.body.uuid;
const sub = req.body.sub;
let user_uid = null;
if (req.isAuthenticated()) user_uid = req.user.uid;
@@ -1034,17 +1025,47 @@ app.post('/api/downloadTwitchChatByVODID', optionalJwt, async (req, res) => {
return;
}
logger.info(`Downloading Twitch chat for ${id}`);
const full_chat = await twitch_api.downloadTwitchChatByVODID(vodId, id, type, user_uid, sub);
logger.info(`Finished downloading Twitch chat for ${id}`);
res.send({
chat: full_chat
});
});
app.post('/api/getTwitchEmotes', async (req, res) => {
const uid = req.body.uid;
const file = await files_api.getVideo(uid);
const channel_name = file['uploader'];
const emotes_path = path.join('appdata', 'emotes', uid, 'emotes.json')
if (!fs.existsSync(emotes_path)) {
logger.info(`Downloading Twitch emotes for ${channel_name}`);
await twitch_api.downloadTwitchEmotes(channel_name, file.uid);
logger.info(`Finished downloading Twitch emotes for ${channel_name}`);
}
const emotes = await twitch_api.getTwitchEmotes(file.uid);
res.send({
emotes: emotes
});
});
app.get('/api/emote/:uid/:id', async (req, res) => {
const file_uid = decodeURIComponent(req.params.uid);
const emote_id = decodeURIComponent(req.params.id);
const emote_path = path.join('appdata', 'emotes', file_uid, emote_id);
if (fs.existsSync(emote_path)) path.isAbsolute(emote_path) ? res.sendFile(emote_path) : res.sendFile(path.join(__dirname, emote_path));
else res.sendStatus(404);
});
// video sharing
app.post('/api/enableSharing', optionalJwt, async (req, res) => {
var uid = req.body.uid;
var is_playlist = req.body.is_playlist;
const uid = req.body.uid;
const is_playlist = req.body.is_playlist;
let success = false;
// multi-user mode
if (req.isAuthenticated()) {
@@ -1113,7 +1134,7 @@ app.post('/api/incrementViewCount', async (req, res) => {
uuid = req.user.uid;
}
const file_obj = await db_api.getVideo(file_uid, uuid, sub_id);
const file_obj = await files_api.getVideo(file_uid, uuid, sub_id);
const current_view_count = file_obj && file_obj['local_view_count'] ? file_obj['local_view_count'] : 0;
const new_view_count = current_view_count + 1;
@@ -1241,7 +1262,7 @@ app.post('/api/deleteSubscriptionFile', optionalJwt, async (req, res) => {
let deleteForever = req.body.deleteForever;
let file_uid = req.body.file_uid;
let success = await db_api.deleteFile(file_uid, deleteForever);
let success = await files_api.deleteFile(file_uid, deleteForever);
if (success) {
res.send({
@@ -1329,7 +1350,7 @@ app.post('/api/createPlaylist', optionalJwt, async (req, res) => {
let playlistName = req.body.playlistName;
let uids = req.body.uids;
const new_playlist = await db_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null);
const new_playlist = await files_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null);
res.send({
new_playlist: new_playlist,
@@ -1342,13 +1363,13 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
let uuid = req.body.uuid ? req.body.uuid : (req.user && req.user.uid ? req.user.uid : null);
let include_file_metadata = req.body.include_file_metadata;
const playlist = await db_api.getPlaylist(playlist_id, uuid);
const playlist = await files_api.getPlaylist(playlist_id, uuid);
const file_objs = [];
if (playlist && include_file_metadata) {
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await db_api.getVideo(uid, uuid);
const file_obj = await files_api.getVideo(uid, uuid);
if (file_obj) file_objs.push(file_obj);
// TODO: remove file from playlist if could not be found
}
@@ -1386,7 +1407,7 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
playlist.uids.push(file_uid);
let success = await db_api.updatePlaylist(playlist);
let success = await files_api.updatePlaylist(playlist);
res.send({
success: success
});
@@ -1394,7 +1415,7 @@ app.post('/api/addFileToPlaylist', optionalJwt, async (req, res) => {
app.post('/api/updatePlaylist', optionalJwt, async (req, res) => {
let playlist = req.body.playlist;
let success = await db_api.updatePlaylist(playlist, req.user && req.user.uid);
let success = await files_api.updatePlaylist(playlist, req.user && req.user.uid);
res.send({
success: success
});
@@ -1424,7 +1445,7 @@ app.post('/api/deleteFile', optionalJwt, async (req, res) => {
const blacklistMode = req.body.blacklistMode;
let wasDeleted = false;
wasDeleted = await db_api.deleteFile(uid, blacklistMode);
wasDeleted = await files_api.deleteFile(uid, blacklistMode);
res.send(wasDeleted);
});
@@ -1456,7 +1477,7 @@ app.post('/api/deleteAllFiles', optionalJwt, async (req, res) => {
for (let i = 0; i < files.length; i++) {
let wasDeleted = false;
wasDeleted = await db_api.deleteFile(files[i].uid, blacklistMode);
wasDeleted = await files_api.deleteFile(files[i].uid, blacklistMode);
if (wasDeleted) {
delete_count++;
}
@@ -1482,10 +1503,10 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
if (playlist_id) {
zip_file_generated = true;
const playlist_files_to_download = [];
const playlist = await db_api.getPlaylist(playlist_id, uuid);
const playlist = await files_api.getPlaylist(playlist_id, uuid);
for (let i = 0; i < playlist['uids'].length; i++) {
const playlist_file_uid = playlist['uids'][i];
const file_obj = await db_api.getVideo(playlist_file_uid, uuid);
const file_obj = await files_api.getVideo(playlist_file_uid, uuid);
playlist_files_to_download.push(file_obj);
}
@@ -1499,7 +1520,7 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
// generate zip
file_path_to_download = await utils.createContainerZipFile(sub['name'], sub_files_to_download);
} else {
const file_obj = await db_api.getVideo(uid, uuid, sub_id)
const file_obj = await files_api.getVideo(uid, uuid, sub_id)
file_path_to_download = file_obj.path;
}
if (!path.isAbsolute(file_path_to_download)) file_path_to_download = path.join(__dirname, file_path_to_download);
@@ -1646,12 +1667,14 @@ app.get('/api/stream', optionalJwt, async (req, res) => {
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
if (!multiUserMode || req.isAuthenticated() || req.can_watch) {
file_obj = await db_api.getVideo(uid, uuid, sub_id);
file_obj = await files_api.getVideo(uid, uuid, sub_id);
if (file_obj) file_path = file_obj['path'];
else file_path = null;
}
if (!fs.existsSync(file_path)) {
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj.id}`);
logger.error(`File ${file_path} could not be found! UID: ${uid}, ID: ${file_obj && file_obj.id}`);
res.sendStatus(404);
return;
}
const stat = fs.statSync(file_path);
const fileSize = stat.size;
@@ -1938,31 +1961,49 @@ app.post('/api/clearAllLogs', optionalJwt, async function(req, res) {
// user authentication
app.post('/api/auth/register'
, optionalJwt
, auth_api.registerUser);
app.post('/api/auth/register', optionalJwt, async (req, res) => {
const userid = req.body.userid;
const username = req.body.username;
const plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
res.sendStatus(409);
return;
}
if (plaintextPassword === "") {
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
res.sendStatus(409);
return;
}
if (!userid || !username) {
logger.error(`Registration failed for user ${userid}. Username or userid is invalid.`);
}
const new_user = await auth_api.registerUser(userid, username, plaintextPassword);
if (!new_user) {
res.sendStatus(409);
return;
}
res.send({
user: new_user
});
});
app.post('/api/auth/login'
, auth_api.passport.authenticate(['local', 'ldapauth'], {})
, auth_api.generateJWT
, auth_api.returnAuthResponse
);
app.post('/api/auth/pinLogin'
, auth_api.passport.authenticate(['local_pin'], {})
, auth_api.generatePinJWT
, auth_api.returnPinAuthResponse
);
app.post('/api/auth/jwtAuth'
, auth_api.passport.authenticate('jwt', { session: false })
, auth_api.passport.authorize('jwt')
, auth_api.generateJWT
, auth_api.returnAuthResponse
);
app.post('/api/auth/pinAuth'
, auth_api.passport.authenticate('pin', { session: false })
, auth_api.passport.authorize('pin')
, auth_api.generatePinJWT
, auth_api.returnPinAuthResponse
);
app.post('/api/auth/changePassword', optionalJwt, async (req, res) => {
let user_uid = req.body.user_uid;
let password = req.body.new_password;
@@ -2003,18 +2044,7 @@ app.post('/api/updateUser', optionalJwt, async (req, res) => {
app.post('/api/deleteUser', optionalJwt, async (req, res) => {
let uid = req.body.uid;
try {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
const success = await auth_api.deleteUser(uid);
res.send({success: success});
} catch (err) {
logger.error(err);
@@ -2052,13 +2082,6 @@ app.post('/api/changeRolePermissions', optionalJwt, async (req, res) => {
res.send({success: success});
});
app.post('/api/setPin', function(req, res) {
const success = auth_api.setPin(req.body.new_pin);
res.send({
success: success
});
});
// notifications
app.post('/api/getNotifications', optionalJwt, async (req, res) => {
@@ -2112,7 +2135,7 @@ app.get('/api/rss', async function (req, res) {
const sub_id = req.query.sub_id ? decodeURIComponent(req.query.sub_id) : null;
const uuid = req.query.uuid ? decodeURIComponent(req.query.uuid) : null;
const {files} = await db_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const {files} = await files_api.getAllFiles(sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid);
const feed = new Feed({
title: 'Downloads',

View File

@@ -23,7 +23,12 @@
"download_only_mode": false,
"allow_autoplay": true,
"enable_downloads_manager": true,
"allow_playlist_categorization": true
"allow_playlist_categorization": true,
"force_autoplay": false,
"enable_notifications": true,
"enable_all_notifications": true,
"allowed_notification_types": [],
"enable_rss_feed": false
},
"API": {
"use_API_key": false,
@@ -35,7 +40,18 @@
"twitch_client_secret": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
"generate_NFO_files": false,
"use_ntfy_API": false,
"ntfy_topic_URL": "",
"use_gotify_API": false,
"gotify_server_URL": "",
"gotify_app_token": "",
"use_telegram_API": false,
"telegram_bot_token": "",
"telegram_chat_id": "",
"webhook_URL": "",
"discord_webhook_URL": "",
"slack_webhook_URL": ""
},
"Themes": {
"default_theme": "default",

View File

@@ -1,11 +1,13 @@
const config_api = require('../config');
const consts = require('../consts');
const CONSTS = require('../consts');
const logger = require('../logger');
const db_api = require('../db');
const jwt = require('jsonwebtoken');
const { uuid } = require('uuidv4');
const bcrypt = require('bcryptjs');
const fs = require('fs-extra');
const path = require('path');
var LocalStrategy = require('passport-local').Strategy;
var LdapStrategy = require('passport-ldapauth');
@@ -15,7 +17,8 @@ var JwtStrategy = require('passport-jwt').Strategy,
// other required vars
let SERVER_SECRET = null;
let JWT_EXPIRATION = null;
let saltRounds = null;
let opts = null;
let saltRounds = 10;
exports.initialize = function () {
/*************************
@@ -30,8 +33,6 @@ exports.initialize = function () {
});
}
saltRounds = 10;
// Sometimes this value is not properly typed: https://github.com/Tzahi12345/YoutubeDL-Material/issues/813
JWT_EXPIRATION = config_api.getConfigItem('ytdl_jwt_expiration');
if (!(+JWT_EXPIRATION)) {
@@ -49,11 +50,11 @@ exports.initialize = function () {
db_api.users_db.set('jwt_secret', SERVER_SECRET).write();
}
const opts = {}
opts = {}
opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('jwt');
opts.secretOrKey = SERVER_SECRET;
exports.passport.use('jwt', new JwtStrategy(opts, async function(jwt_payload, done) {
exports.passport.use(new JwtStrategy(opts, async function(jwt_payload, done) {
const user = await db_api.getRecord('users', {uid: jwt_payload.user});
if (user) {
return done(null, user);
@@ -62,34 +63,12 @@ exports.initialize = function () {
// or you could create a new account
}
}));
const pin_opts = {}
pin_opts.jwtFromRequest = ExtractJwt.fromUrlQueryParameter('pin_token');
pin_opts.secretOrKey = SERVER_SECRET;
exports.passport.use('pin', new JwtStrategy(pin_opts, {
passwordField: 'pin'},
async function(username, password, done) {
if (await bcrypt.compare(password, config_api.getConfigItem('ytdl_pin_hash'))) {
return done(null, { success: true });
} else {
return done(null, false, { message: 'Incorrect pin' });
}
}
));
}
const setupRoles = async () => {
const required_roles = {
admin: {
permissions: [
'filemanager',
'settings',
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
]
permissions: CONSTS.AVAILABLE_PERMISSIONS
},
user: {
permissions: [
@@ -127,55 +106,41 @@ exports.passport.deserializeUser(function(user, done) {
/***************************************
* Register user with hashed password
**************************************/
exports.registerUser = async function(req, res) {
var userid = req.body.userid;
var username = req.body.username;
var plaintextPassword = req.body.password;
if (userid !== 'admin' && !config_api.getConfigItem('ytdl_allow_registration') && !req.isAuthenticated() && (!req.user || !exports.userHasPermission(req.user.uid, 'settings'))) {
res.sendStatus(409);
logger.error(`Registration failed for user ${userid}. Registration is disabled.`);
return;
exports.registerUser = async (userid, username, plaintextPassword) => {
const hash = await bcrypt.hash(plaintextPassword, saltRounds);
const new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
return null;
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
return null;
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
return new_user;
}
}
if (plaintextPassword === "") {
res.sendStatus(400);
logger.error(`Registration failed for user ${userid}. A password must be provided.`);
return;
exports.deleteUser = async (uid) => {
let success = false;
let usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_folder = path.join(__dirname, usersFileFolder, uid);
const user_db_obj = await db_api.getRecord('users', {uid: uid});
if (user_db_obj) {
// user exists, let's delete
await fs.remove(user_folder);
await db_api.removeRecord('users', {uid: uid});
success = true;
} else {
logger.error(`Could not find user with uid ${uid}`);
}
bcrypt.hash(plaintextPassword, saltRounds)
.then(async function(hash) {
let new_user = generateUserObject(userid, username, hash);
// check if user exists
if (await db_api.getRecord('users', {uid: userid})) {
// user id is taken!
logger.error('Registration failed: UID is already taken!');
res.status(409).send('UID is already taken!');
} else if (await db_api.getRecord('users', {name: username})) {
// user name is taken!
logger.error('Registration failed: User name is already taken!');
res.status(409).send('User name is already taken!');
} else {
// add to db
await db_api.insertRecordIntoTable('users', new_user);
logger.verbose(`New user created: ${new_user.name}`);
res.send({
user: new_user
});
}
})
.then(function(result) {
})
.catch(function(err) {
logger.error(err);
if( err.code == 'ER_DUP_ENTRY' ) {
res.status(409).send('UserId already taken');
} else {
res.sendStatus(409);
}
});
return success;
}
/***************************************
@@ -202,10 +167,6 @@ exports.login = async (username, password) => {
return await bcrypt.compare(password, user.passhash) ? user : false;
}
exports.pinLogin = async (pin) => {
return await bcrypt.compare(pin, config_api.getConfigItem('ytdl_pin_hash'));
}
exports.passport.use(new LocalStrategy({
usernameField: 'username',
passwordField: 'password'},
@@ -214,14 +175,6 @@ exports.passport.use(new LocalStrategy({
}
));
exports.passport.use('local_pin', new LocalStrategy({
usernameField: 'username',
passwordField: 'password'},
async function(username, password, done) {
return done(null, await exports.pinLogin(password));
}
));
var getLDAPConfiguration = function(req, callback) {
const ldap_config = config_api.getConfigItem('ytdl_ldap_config');
const opts = {server: ldap_config};
@@ -263,26 +216,12 @@ exports.generateJWT = function(req, res, next) {
next();
}
exports.generatePinJWT = function(req, res, next) {
var payload = {
exp: Math.floor(Date.now() / 1000) + JWT_EXPIRATION
};
req.token = jwt.sign(payload, SERVER_SECRET);
next();
}
exports.returnAuthResponse = async function(req, res) {
res.status(200).json({
user: req.user,
token: req.token,
permissions: await exports.userPermissions(req.user.uid),
available_permissions: consts['AVAILABLE_PERMISSIONS']
});
}
exports.returnPinAuthResponse = async function(req, res) {
res.status(200).json({
pin_token: req.token
available_permissions: CONSTS.AVAILABLE_PERMISSIONS
});
}
@@ -366,7 +305,7 @@ exports.getUserVideos = async function(user_uid, type) {
}
exports.getUserVideo = async function(user_uid, file_uid, requireSharing = false) {
let file = await db_api.getRecord('files', {file_uid: file_uid});
let file = await db_api.getRecord('files', {uid: file_uid});
// prevent unauthorized users from accessing the file info
if (file && !file['sharingEnabled'] && requireSharing) file = null;
@@ -453,8 +392,8 @@ exports.userPermissions = async function(user_uid) {
const role_obj = await db_api.getRecord('roles', {key: role});
const role_permissions = role_obj['permissions'];
for (let i = 0; i < consts['AVAILABLE_PERMISSIONS'].length; i++) {
let permission = consts['AVAILABLE_PERMISSIONS'][i];
for (let i = 0; i < CONSTS.AVAILABLE_PERMISSIONS.length; i++) {
let permission = CONSTS.AVAILABLE_PERMISSIONS[i];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
@@ -479,13 +418,6 @@ exports.userPermissions = async function(user_uid) {
return user_permissions;
}
// pin
exports.setPin = async (new_pin) => {
const pin_hash = await bcrypt.hash(new_pin, saltRounds);
return config_api.setConfigItem('ytdl_pin_hash', pin_hash);
}
function getToken(queryParams) {
if (queryParams && queryParams.jwt) {
var parted = queryParams.jwt.split(' ');
@@ -497,7 +429,7 @@ function getToken(queryParams) {
} else {
return null;
}
}
};
function generateUserObject(userid, username, hash, auth_method = 'internal') {
let new_user = {

View File

@@ -202,14 +202,15 @@ const DEFAULT_CONFIG = {
"enable_all_notifications": true,
"allowed_notification_types": [],
"enable_rss_feed": false,
"use_pin": false,
"pin_hash": "",
},
"API": {
"use_API_key": false,
"API_key": "",
"use_youtube_API": false,
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_client_ID": "",
"twitch_client_secret": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false,
@@ -221,7 +222,9 @@ const DEFAULT_CONFIG = {
"use_telegram_API": false,
"telegram_bot_token": "",
"telegram_chat_id": "",
"webhook_URL": ""
"webhook_URL": "",
"discord_webhook_URL": "",
"slack_webhook_URL": "",
},
"Themes": {
"default_theme": "default",

View File

@@ -92,14 +92,6 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_enable_rss_feed',
'path': 'YoutubeDLMaterial.Extra.enable_rss_feed'
},
'ytdl_use_pin': {
'key': 'ytdl_use_pin',
'path': 'YoutubeDLMaterial.Extra.use_pin'
},
'ytdl_pin_hash': {
'key': 'ytdl_pin_hash',
'path': 'YoutubeDLMaterial.Extra.pin_hash'
},
// API
'ytdl_use_api_key': {
@@ -118,6 +110,18 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_youtube_api_key',
'path': 'YoutubeDLMaterial.API.youtube_API_key'
},
'ytdl_use_twitch_api': {
'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API'
},
'ytdl_twitch_client_id': {
'key': 'ytdl_twitch_client_id',
'path': 'YoutubeDLMaterial.API.twitch_client_ID'
},
'ytdl_twitch_client_secret': {
'key': 'ytdl_twitch_client_secret',
'path': 'YoutubeDLMaterial.API.twitch_client_secret'
},
'ytdl_twitch_auto_download_chat': {
'key': 'ytdl_twitch_auto_download_chat',
'path': 'YoutubeDLMaterial.API.twitch_auto_download_chat'
@@ -166,6 +170,14 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_webhook_url',
'path': 'YoutubeDLMaterial.API.webhook_URL'
},
'ytdl_discord_webhook_url': {
'key': 'ytdl_discord_webhook_url',
'path': 'YoutubeDLMaterial.API.discord_webhook_URL'
},
'ytdl_slack_webhook_url': {
'key': 'ytdl_slack_webhook_url',
'path': 'YoutubeDLMaterial.API.slack_webhook_URL'
},
// Themes
@@ -347,7 +359,11 @@ const YTDL_ARGS_WITH_VALUES = [
'--convert-subs'
];
exports.SUBSCRIPTION_BACKUP_PATH = 'subscription_backup.json'
// we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.CURRENT_VERSION = 'v4.3.1';
exports.ICON_URL = 'https://i.imgur.com/IKOlr0N.png';
exports.CURRENT_VERSION = 'v4.3.2';

View File

@@ -1,19 +1,18 @@
var fs = require('fs-extra')
var path = require('path')
const fs = require('fs-extra')
const path = require('path')
const { MongoClient } = require("mongodb");
const { uuid } = require('uuidv4');
const _ = require('lodash');
const config_api = require('./config');
var utils = require('./utils')
const utils = require('./utils')
const logger = require('./logger');
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync');
const { BehaviorSubject } = require('rxjs');
const local_adapter = new FileSync('./appdata/local_db.json');
const local_db = low(local_adapter);
let local_db = null;
let database = null;
exports.database_initialized = false;
exports.database_initialized_bs = new BehaviorSubject(false);
@@ -73,10 +72,6 @@ const tables = {
const tables_list = Object.keys(tables);
const local_db_defaults = {}
tables_list.forEach(table => {local_db_defaults[table] = []});
local_db.defaults(local_db_defaults).write();
let using_local_db = null;
function setDB(input_db, input_users_db) {
@@ -85,11 +80,18 @@ function setDB(input_db, input_users_db) {
exports.users_db = input_users_db
}
exports.initialize = (input_db, input_users_db) => {
exports.initialize = (input_db, input_users_db, db_name = 'local_db.json') => {
setDB(input_db, input_users_db);
// must be done here to prevent getConfigItem from being called before init
using_local_db = config_api.getConfigItem('ytdl_use_local_db');
const local_adapter = new FileSync(`./appdata/${db_name}`);
local_db = low(local_adapter);
const local_db_defaults = {}
tables_list.forEach(table => {local_db_defaults[table] = []});
local_db.defaults(local_db_defaults).write();
}
exports.connectToDB = async (retries = 5, no_fallback = false, custom_connection_string = null) => {
@@ -167,82 +169,9 @@ exports._connectToDB = async (custom_connection_string = null) => {
}
}
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (user_uid) file_object['user_uid'] = user_uid;
if (sub_id) file_object['sub_id'] = sub_id;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
}
return file_obj;
}
async function registerFileDBManual(file_object) {
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
await exports.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
function generateFileObject(file_path, type) {
var jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const ext = (type === 'audio') ? '.mp3' : '.mp4'
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
// console.
var stats = fs.statSync(true_file_path);
const file_id = utils.removeFileExtension(path.basename(file_path));
var title = jsonobj.title;
var url = jsonobj.webpage_url;
var uploader = jsonobj.uploader;
var upload_date = utils.formatDateString(jsonobj.upload_date);
var size = stats.size;
var thumbnail = jsonobj.thumbnail;
var duration = jsonobj.duration;
var isaudio = type === 'audio';
var description = jsonobj.description;
var file_obj = new utils.File(file_id, title, thumbnail, isaudio, duration, url, uploader, size, true_file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
function getAppendedBasePathSub(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
exports.setVideoProperty = async (file_uid, assignment_obj) => {
// TODO: check if video exists, throw error if not
await exports.updateRecord('files', {uid: file_uid}, assignment_obj);
}
exports.getFileDirectoriesAndDBs = async () => {
@@ -317,277 +246,6 @@ exports.getFileDirectoriesAndDBs = async () => {
return dirs_to_check;
}
exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
for (let j = 0; j < files.length; j++) {
const file = files[j];
// check if file exists in db, if not add it
const files_with_same_url = await exports.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) {
imported_files.push(file_obj['uid']);
logger.verbose(`Added discovered file to the database: ${file.id}`);
} else {
logger.error(`Failed to import ${file['path']} automatically.`);
}
}
}
}
return imported_files;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await exports.getFileDirectoriesAndDBs();
const update_obj = {};
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true);
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (file[property_key]) {
update_obj[file.uid] = {[property_key]: file[property_key]};
}
}
}
return await exports.bulkUpdateRecordsByKey('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
}
}
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
name: playlist_name,
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
registered: Date.now(),
randomize_order: false
};
new_playlist.user_uid = user_uid ? user_uid : undefined;
await exports.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await exports.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist;
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await exports.getRecord('playlists', {id: playlist_id});
if (!playlist) {
playlist = await exports.getRecord('categories', {uid: playlist_id});
if (playlist) {
const uids = (await exports.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid);
playlist['uids'] = uids;
playlist['auto'] = true;
}
}
// converts playlists to new UID-based schema
if (playlist && playlist['fileNames'] && !playlist['uids']) {
playlist['uids'] = [];
logger.verbose(`Converting playlist ${playlist['name']} to new UID-based schema.`);
for (let i = 0; i < playlist['fileNames'].length; i++) {
const fileName = playlist['fileNames'][i];
const uid = await exports.getVideoUIDByID(fileName, user_uid);
if (uid) playlist['uids'].push(uid);
else logger.warn(`Failed to convert file with name ${fileName} to its UID while converting playlist ${playlist['name']} to the new UID-based schema. The original file is likely missing/deleted and it will be skipped.`);
}
exports.updatePlaylist(playlist, user_uid);
}
// prevent unauthorized users from accessing the file info
if (require_sharing && !playlist['sharingEnabled']) return null;
return playlist;
}
exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await exports.updateRecord('playlists', {id: playlistID}, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await exports.updateRecord('playlists', {id: playlist_id}, assignment_obj);
if (!success) {
success = await exports.updateRecord('categories', {uid: playlist_id}, assignment_obj);
}
if (!success) {
logger.error(`Could not find playlist or category with ID ${playlist_id}`);
}
return success;
}
exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) {
playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid);
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const ext = type === 'audio' ? 'mp3' : 'mp4';
const name = file_obj.id;
const filePathNoExtension = utils.removeFileExtension(file_obj.path);
var jsonPath = `${file_obj.path}.info.json`;
var altJSONPath = `${filePathNoExtension}.info.json`;
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
if (!jsonExists) {
if (await fs.pathExists(altJSONPath)) {
jsonExists = true;
jsonPath = altJSONPath;
}
}
if (!thumbnailExists) {
if (await fs.pathExists(altThumbnailPath)) {
thumbnailExists = true;
thumbnailPath = altThumbnailPath;
}
}
let fileExists = await fs.pathExists(file_obj.path);
if (config_api.descriptors[uid]) {
try {
for (let i = 0; i < config_api.descriptors[uid].length; i++) {
config_api.descriptors[uid][i].destroy();
}
} catch(e) {
}
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
// get id/extractor from JSON
const info_json = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath));
let retrievedID = null;
let retrievedExtractor = null;
if (info_json) {
retrievedID = info_json['id'];
retrievedExtractor = info_json['extractor'];
}
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
if (!blacklistMode) {
// workaround until a files_api is created (using archive_api would make a circular dependency)
await exports.removeAllRecords('archives', {extractor: retrievedExtractor, id: retrievedID, type: type, user_uid: file_obj.user_uid, sub_id: file_obj.sub_id});
// await archive_api.removeFromArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id);
}
}
if (jsonExists) await fs.unlink(jsonPath);
if (thumbnailExists) await fs.unlink(thumbnailPath);
await exports.removeRecord('files', {uid: uid});
if (fileExists) {
await fs.unlink(file_obj.path);
if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) {
return false;
} else {
return true;
}
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await exports.getRecord('files', {id: file_id});
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await exports.getRecord('files', {uid: file_uid});
}
exports.getAllFiles = async (sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid) => {
const filter_obj = {user_uid: uuid};
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (favorite_filter) {
filter_obj['favorite'] = true;
}
if (sub_id) {
filter_obj['sub_id'] = sub_id;
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
const files = JSON.parse(JSON.stringify(await exports.getRecords('files', filter_obj, false, sort, range, text_search)));
const file_count = await exports.getRecords('files', filter_obj, true);
return {files, file_count};
}
exports.setVideoProperty = async (file_uid, assignment_obj) => {
// TODO: check if video exists, throw error if not
await exports.updateRecord('files', {uid: file_uid}, assignment_obj);
}
// Basic DB functions
// Create
@@ -720,7 +378,14 @@ exports.updateRecord = async (table, filter_obj, update_obj, nested_mode = false
exports.updateRecords = async (table, filter_obj, update_obj) => {
// local db override
if (using_local_db) {
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').assign(update_obj).write();
exports.applyFilterLocalDB(local_db.get(table), filter_obj, 'filter').each((record) => {
const props_to_update = Object.keys(update_obj);
for (let i = 0; i < props_to_update.length; i++) {
const prop_to_update = props_to_update[i];
const prop_value = update_obj[prop_to_update];
record[prop_to_update] = prop_value;
}
}).write();
return true;
}
@@ -1150,6 +815,9 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
filtered &= record[filter_prop] === undefined || record[filter_prop] === null;
} else {
if (typeof filter_prop_value === 'object') {
if (!record[filter_prop]) {
continue;
}
if ('$regex' in filter_prop_value) {
filtered &= (record[filter_prop].search(new RegExp(filter_prop_value['$regex'], filter_prop_value['$options'])) !== -1);
} else if ('$ne' in filter_prop_value) {
@@ -1165,10 +833,14 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
}
} else {
// handle case of nested property check
if (filter_prop.includes('.'))
if (filter_prop.includes('.')) {
filtered &= utils.searchObjectByString(record, filter_prop) === filter_prop_value;
else
} else {
if (!record[filter_prop]) {
continue;
}
filtered &= record[filter_prop] === filter_prop_value;
}
}
}
}

View File

@@ -7,12 +7,14 @@ const Mutex = require('async-mutex').Mutex;
const youtubedl = require('youtube-dl');
const logger = require('./logger');
const youtubedl_api = require('./youtube-dl');
const config_api = require('./config');
const twitch_api = require('./twitch');
const { create } = require('xmlbuilder2');
const categories_api = require('./categories');
const utils = require('./utils');
const db_api = require('./db');
const files_api = require('./files');
const notifications_api = require('./notifications');
const archive_api = require('./archive');
@@ -20,10 +22,10 @@ const mutex = new Mutex();
let should_check_downloads = true;
if (db_api.database_initialized) {
setupDownloads();
exports.setupDownloads();
} else {
db_api.database_initialized_bs.subscribe(init => {
if (init) setupDownloads();
if (init) exports.setupDownloads();
});
}
@@ -46,7 +48,7 @@ We use checkDownloads() to move downloads through the steps and call their respe
*/
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null, paused = false) => {
return await mutex.runExclusive(async () => {
const download = {
url: url,
@@ -59,7 +61,7 @@ exports.createDownload = async (url, type, options, user_uid = null, sub_id = nu
options: options,
uid: uuid(),
step_index: 0,
paused: false,
paused: paused,
running: false,
finished_step: true,
error: null,
@@ -128,11 +130,11 @@ exports.clearDownload = async (download_uid) => {
async function handleDownloadError(download, error_message, error_type = null) {
if (!download || !download['uid']) return;
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_type);
notifications_api.sendDownloadErrorNotification(download, download['user_uid'], error_message, error_type);
await db_api.updateRecord('download_queue', {uid: download['uid']}, {error: error_message, finished: true, running: false, error_type: error_type});
}
async function setupDownloads() {
exports.setupDownloads = async () => {
await fixDownloadState();
setInterval(checkDownloads, 1000);
}
@@ -187,7 +189,7 @@ async function checkDownloads() {
if (waiting_download['step_index'] === 0) {
collectInfo(waiting_download['uid']);
} else if (waiting_download['step_index'] === 1) {
downloadQueuedFile(waiting_download['uid']);
exports.downloadQueuedFile(waiting_download['uid']);
}
}
}
@@ -221,6 +223,7 @@ async function collectInfo(download_uid) {
return;
}
// in subscriptions we don't care if archive mode is enabled, but we already removed archived videos from subs by this point
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive && !options.ignoreArchive) {
const exists_in_archive = await archive_api.existsInArchive(info['extractor'], info['id'], type, download['user_uid'], download['sub_id']);
@@ -238,7 +241,7 @@ async function collectInfo(download_uid) {
let category = null;
// check if it fits into a category. If so, then get info again using new args
if (!Array.isArray(info) || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info);
if (info.length === 0 || config_api.getConfigItem('ytdl_allow_playlist_categorization')) category = await categories_api.categorize(info);
// set custom output if the category has one and re-retrieve info so the download manager has the right file name
if (category && category['custom_output']) {
@@ -257,13 +260,9 @@ async function collectInfo(download_uid) {
const files_to_check_for_progress = [];
// store info in download for future use
if (Array.isArray(info)) {
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
} else {
files_to_check_for_progress.push(utils.removeFileExtension(info['_filename']));
}
for (let info_obj of info) files_to_check_for_progress.push(utils.removeFileExtension(info_obj['_filename']));
const playlist_title = Array.isArray(info) ? info[0]['playlist_title'] || info[0]['playlist'] : null;
const playlist_title = info.length > 0 ? info[0]['playlist_title'] || info[0]['playlist'] : null;
await db_api.updateRecord('download_queue', {uid: download_uid}, {args: args,
finished_step: true,
running: false,
@@ -276,7 +275,7 @@ async function collectInfo(download_uid) {
});
}
async function downloadQueuedFile(download_uid) {
exports.downloadQueuedFile = async(download_uid, downloadMethod = youtubedl.exec) => {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
if (download['paused']) {
return;
@@ -304,115 +303,102 @@ async function downloadQueuedFile(download_uid) {
const start_time = Date.now();
const download_checker = setInterval(() => checkDownloadPercent(download['uid']), 1000);
const file_objs = [];
// download file
youtubedl.exec(url, args, {maxBuffer: Infinity}, async function(err, output) {
const file_objs = [];
let end_time = Date.now();
let difference = (end_time - start_time)/1000;
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
clearInterval(download_checker);
if (err) {
logger.error(err.stderr);
await handleDownloadError(download, err.stderr);
const {parsed_output, err} = await youtubedl_api.runYoutubeDL(url, args, downloadMethod);
clearInterval(download_checker);
let end_time = Date.now();
let difference = (end_time - start_time)/1000;
logger.debug(`${type === 'audio' ? 'Audio' : 'Video'} download delay: ${difference} seconds.`);
if (!parsed_output) {
logger.error(err.stderr);
await handleDownloadError(download, err.stderr, 'unknown_error');
resolve(false);
return;
} else if (parsed_output) {
if (parsed_output.length === 0 || parsed_output[0].length === 0) {
// ERROR!
const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download, error_message, 'no_output');
logger.warn(error_message);
resolve(false);
return;
} else if (output) {
if (output.length === 0 || output[0].length === 0) {
// ERROR!
const error_message = `No output received for video download, check if it exists in your archive.`;
await handleDownloadError(download, error_message, 'no_output');
logger.warn(error_message);
resolve(false);
return;
}
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
// we have to do this because sometimes there will be leading characters before the actual json
const start_idx = output[i].indexOf('{"');
const clean_output = output[i].slice(start_idx, output[i].length);
output_json = JSON.parse(clean_output);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
// get filepath with no extension
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
const ext = type === 'audio' ? '.mp3' : '.mp4';
var full_file_path = filepath_no_extension + ext;
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
}
// renames file if necessary due to bug
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
try {
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
} catch(e) {
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
}
}
if (type === 'audio') {
let tags = {
title: output_json['title'],
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
}
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
}
if (config_api.getConfigItem('ytdl_generate_nfo_files')) {
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
}
if (options.cropFileSettings) {
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
}
// registers file in DB
const file_obj = await db_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive && !options.ignoreArchive) await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
file_objs.push(file_obj);
}
let container = null;
if (file_objs.length > 1) {
// create playlist
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
container = await db_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']);
} else if (file_objs.length === 1) {
container = file_objs[0];
} else {
const error_message = 'Downloaded file failed to result in metadata object.';
logger.error(error_message);
await handleDownloadError(download, error_message, 'no_metadata');
}
const file_uids = file_objs.map(file_obj => file_obj.uid);
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
resolve();
}
});
for (const output_json of parsed_output) {
if (!output_json) {
continue;
}
// get filepath with no extension
const filepath_no_extension = utils.removeFileExtension(output_json['_filename']);
const ext = type === 'audio' ? '.mp3' : '.mp4';
var full_file_path = filepath_no_extension + ext;
var file_name = filepath_no_extension.substring(fileFolderPath.length, filepath_no_extension.length);
if (type === 'video' && url.includes('twitch.tv/videos/') && url.split('twitch.tv/videos/').length > 1
&& config_api.getConfigItem('ytdl_twitch_auto_download_chat')) {
let vodId = url.split('twitch.tv/videos/')[1];
vodId = vodId.split('?')[0];
twitch_api.downloadTwitchChatByVODID(vodId, file_name, type, download['user_uid']);
}
// renames file if necessary due to bug
if (!fs.existsSync(output_json['_filename']) && fs.existsSync(output_json['_filename'] + '.webm')) {
try {
fs.renameSync(output_json['_filename'] + '.webm', output_json['_filename']);
logger.info('Renamed ' + file_name + '.webm to ' + file_name);
} catch(e) {
logger.error(`Failed to rename file ${output_json['_filename']} to its appropriate extension.`);
}
}
if (type === 'audio') {
let tags = {
title: output_json['title'],
artist: output_json['artist'] ? output_json['artist'] : output_json['uploader']
}
let success = NodeID3.write(tags, utils.removeFileExtension(output_json['_filename']) + '.mp3');
if (!success) logger.error('Failed to apply ID3 tag to audio file ' + output_json['_filename']);
}
if (config_api.getConfigItem('ytdl_generate_nfo_files')) {
exports.generateNFOFile(output_json, `${filepath_no_extension}.nfo`);
}
if (options.cropFileSettings) {
await utils.cropFile(full_file_path, options.cropFileSettings.cropFileStart, options.cropFileSettings.cropFileEnd, ext);
}
// registers file in DB
const file_obj = await files_api.registerFileDB(full_file_path, type, download['user_uid'], category, download['sub_id'] ? download['sub_id'] : null, options.cropFileSettings);
await archive_api.addToArchive(output_json['extractor'], output_json['id'], type, output_json['title'], download['user_uid'], download['sub_id']);
notifications_api.sendDownloadNotification(file_obj, download['user_uid']);
file_objs.push(file_obj);
}
let container = null;
if (file_objs.length > 1) {
// create playlist
const playlist_name = file_objs.map(file_obj => file_obj.title).join(', ');
container = await files_api.createPlaylist(playlist_name, file_objs.map(file_obj => file_obj.uid), download['user_uid']);
} else if (file_objs.length === 1) {
container = file_objs[0];
} else {
const error_message = 'Downloaded file failed to result in metadata object.';
logger.error(error_message);
await handleDownloadError(download, error_message, 'no_metadata');
}
const file_uids = file_objs.map(file_obj => file_obj.uid);
await db_api.updateRecord('download_queue', {uid: download_uid}, {finished_step: true, finished: true, running: false, step_index: 3, percent_complete: 100, file_uids: file_uids, container: container});
resolve(file_uids);
}
});
}
@@ -563,40 +549,16 @@ exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
new_args.push('--dump-json');
youtubedl.exec(url, new_args, {maxBuffer: Infinity}, async (err, output) => {
if (output) {
let outputs = [];
try {
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
outputs.push(output_json);
}
resolve(outputs.length === 1 ? outputs[0] : outputs);
} catch(e) {
const error = `Error while retrieving info on video with URL ${url} with the following message: output JSON could not be parsed. Output JSON: ${output}`;
logger.error(error);
if (download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error, 'parse_failed');
}
resolve(null);
}
const parsed_output = utils.parseOutputJSON(output, err);
if (parsed_output) {
resolve(parsed_output);
} else {
let error_message = `Error while retrieving info on video with URL ${url} with the following message: ${err}`;
if (err.stderr) error_message += `\n\n${err.stderr}`;
logger.error(error_message);
if (download_uid) {
const download = await db_api.getRecord('download_queue', {uid: download_uid});
await handleDownloadError(download, error_message);
await handleDownloadError(download, error_message, 'info_retrieve_failed');
}
resolve(null);
}

View File

@@ -1,17 +1,7 @@
#!/bin/sh
#!/bin/bash
set -eu
CMD="npm start"
# if the first arg starts with "-" pass it to program
if [ "${1#-}" != "$1" ]; then
set -- "$CMD" "$@"
fi
# chown current working directory to current user
if [ "$*" = "$CMD" ] && [ "$(id -u)" = "0" ]; then
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec gosu "$UID:$GID" "$0" "$@"
fi
exec "$@"
echo "[entrypoint] setup permission, this may take a while"
find . \! -user "$UID" -exec chown "$UID:$GID" '{}' + || echo "WARNING! Could not change directory ownership. If you manage permissions externally this is fine, otherwise you may experience issues when downloading or deleting videos."
exec gosu "$UID:$GID" "$@"

350
backend/files.js Normal file
View File

@@ -0,0 +1,350 @@
const fs = require('fs-extra')
const path = require('path')
const { uuid } = require('uuidv4');
const config_api = require('./config');
const db_api = require('./db');
const archive_api = require('./archive');
const utils = require('./utils')
const logger = require('./logger');
exports.registerFileDB = async (file_path, type, user_uid = null, category = null, sub_id = null, cropFileSettings = null, file_object = null) => {
if (!file_object) file_object = generateFileObject(file_path, type);
if (!file_object) {
logger.error(`Could not find associated JSON file for ${type} file ${file_path}`);
return false;
}
utils.fixVideoMetadataPerms(file_path, type);
// add thumbnail path
file_object['thumbnailPath'] = utils.getDownloadedThumbnail(file_path);
// if category exists, only include essential info
if (category) file_object['category'] = {name: category['name'], uid: category['uid']};
// modify duration
if (cropFileSettings) {
file_object['duration'] = (cropFileSettings.cropFileEnd || file_object.duration) - cropFileSettings.cropFileStart;
}
if (user_uid) file_object['user_uid'] = user_uid;
if (sub_id) file_object['sub_id'] = sub_id;
const file_obj = await registerFileDBManual(file_object);
// remove metadata JSON if needed
if (!config_api.getConfigItem('ytdl_include_metadata')) {
utils.deleteJSONFile(file_path, type)
}
return file_obj;
}
async function registerFileDBManual(file_object) {
// add additional info
file_object['uid'] = uuid();
file_object['registered'] = Date.now();
const path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
await db_api.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
function generateFileObject(file_path, type) {
const jsonobj = utils.getJSON(file_path, type);
if (!jsonobj) {
return null;
} else if (!jsonobj['_filename']) {
logger.error(`Failed to get filename from info JSON! File ${jsonobj['title']} could not be added.`);
return null;
}
const true_file_path = utils.getTrueFileName(jsonobj['_filename'], type);
// console.
const stats = fs.statSync(true_file_path);
const file_id = utils.removeFileExtension(path.basename(file_path));
const title = jsonobj.title;
const url = jsonobj.webpage_url;
const uploader = jsonobj.uploader;
const upload_date = utils.formatDateString(jsonobj.upload_date);
const size = stats.size;
const thumbnail = jsonobj.thumbnail;
const duration = jsonobj.duration;
const isaudio = type === 'audio';
const description = jsonobj.description;
const file_obj = new utils.File(file_id, title, thumbnail, isaudio, duration, url, uploader, size, true_file_path, upload_date, description, jsonobj.view_count, jsonobj.height, jsonobj.abr);
return file_obj;
}
exports.importUnregisteredFiles = async () => {
const imported_files = [];
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
// run through check list and check each file to see if it's missing from the db
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type);
for (let j = 0; j < files.length; j++) {
const file = files[j];
// check if file exists in db, if not add it
const files_with_same_url = await db_api.getRecords('files', {url: file.url, sub_id: dir_to_check.sub_id});
const file_is_registered = !!(files_with_same_url.find(file_with_same_url => path.resolve(file_with_same_url.path) === path.resolve(file.path)));
if (!file_is_registered) {
// add additional info
const file_obj = await exports.registerFileDB(file['path'], dir_to_check.type, dir_to_check.user_uid, null, dir_to_check.sub_id, null);
if (file_obj) {
imported_files.push(file_obj['uid']);
logger.verbose(`Added discovered file to the database: ${file.id}`);
} else {
logger.error(`Failed to import ${file['path']} automatically.`);
}
}
}
}
return imported_files;
}
exports.addMetadataPropertyToDB = async (property_key) => {
try {
const dirs_to_check = await db_api.getFileDirectoriesAndDBs();
const update_obj = {};
for (let i = 0; i < dirs_to_check.length; i++) {
const dir_to_check = dirs_to_check[i];
// recursively get all files in dir's path
const files = await utils.getDownloadedFilesByType(dir_to_check.basePath, dir_to_check.type, true);
for (let j = 0; j < files.length; j++) {
const file = files[j];
if (file[property_key]) {
update_obj[file.uid] = {[property_key]: file[property_key]};
}
}
}
return await db_api.bulkUpdateRecordsByKey('files', 'uid', update_obj);
} catch(err) {
logger.error(err);
return false;
}
}
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
let new_playlist = {
name: playlist_name,
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
registered: Date.now(),
randomize_order: false
};
new_playlist.user_uid = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('playlists', new_playlist);
const duration = await exports.calculatePlaylistDuration(new_playlist);
await db_api.updateRecord('playlists', {id: new_playlist.id}, {duration: duration});
return new_playlist;
}
exports.getPlaylist = async (playlist_id, user_uid = null, require_sharing = false) => {
let playlist = await db_api.getRecord('playlists', {id: playlist_id});
if (!playlist) {
playlist = await db_api.getRecord('categories', {uid: playlist_id});
if (playlist) {
const uids = (await db_api.getRecords('files', {'category.uid': playlist_id})).map(file => file.uid);
playlist['uids'] = uids;
playlist['auto'] = true;
}
}
// converts playlists to new UID-based schema
if (playlist && playlist['fileNames'] && !playlist['uids']) {
playlist['uids'] = [];
logger.verbose(`Converting playlist ${playlist['name']} to new UID-based schema.`);
for (let i = 0; i < playlist['fileNames'].length; i++) {
const fileName = playlist['fileNames'][i];
const uid = await exports.getVideoUIDByID(fileName, user_uid);
if (uid) playlist['uids'].push(uid);
else logger.warn(`Failed to convert file with name ${fileName} to its UID while converting playlist ${playlist['name']} to the new UID-based schema. The original file is likely missing/deleted and it will be skipped.`);
}
exports.updatePlaylist(playlist, user_uid);
}
// prevent unauthorized users from accessing the file info
if (require_sharing && !playlist['sharingEnabled']) return null;
return playlist;
}
exports.updatePlaylist = async (playlist) => {
let playlistID = playlist.id;
const duration = await exports.calculatePlaylistDuration(playlist);
playlist.duration = duration;
return await db_api.updateRecord('playlists', {id: playlistID}, playlist);
}
exports.setPlaylistProperty = async (playlist_id, assignment_obj, user_uid = null) => {
let success = await db_api.updateRecord('playlists', {id: playlist_id}, assignment_obj);
if (!success) {
success = await db_api.updateRecord('categories', {uid: playlist_id}, assignment_obj);
}
if (!success) {
logger.error(`Could not find playlist or category with ID ${playlist_id}`);
}
return success;
}
exports.calculatePlaylistDuration = async (playlist, playlist_file_objs = null) => {
if (!playlist_file_objs) {
playlist_file_objs = [];
for (let i = 0; i < playlist['uids'].length; i++) {
const uid = playlist['uids'][i];
const file_obj = await exports.getVideo(uid);
if (file_obj) playlist_file_objs.push(file_obj);
}
}
return playlist_file_objs.reduce((a, b) => a + utils.durationStringToNumber(b.duration), 0);
}
exports.deleteFile = async (uid, blacklistMode = false) => {
const file_obj = await exports.getVideo(uid);
const type = file_obj.isAudio ? 'audio' : 'video';
const folderPath = path.dirname(file_obj.path);
const name = file_obj.id;
const filePathNoExtension = utils.removeFileExtension(file_obj.path);
var jsonPath = `${file_obj.path}.info.json`;
var altJSONPath = `${filePathNoExtension}.info.json`;
var thumbnailPath = `${filePathNoExtension}.webp`;
var altThumbnailPath = `${filePathNoExtension}.jpg`;
jsonPath = path.join(__dirname, jsonPath);
altJSONPath = path.join(__dirname, altJSONPath);
let jsonExists = await fs.pathExists(jsonPath);
let thumbnailExists = await fs.pathExists(thumbnailPath);
if (!jsonExists) {
if (await fs.pathExists(altJSONPath)) {
jsonExists = true;
jsonPath = altJSONPath;
}
}
if (!thumbnailExists) {
if (await fs.pathExists(altThumbnailPath)) {
thumbnailExists = true;
thumbnailPath = altThumbnailPath;
}
}
let fileExists = await fs.pathExists(file_obj.path);
if (config_api.descriptors[uid]) {
try {
for (let i = 0; i < config_api.descriptors[uid].length; i++) {
config_api.descriptors[uid][i].destroy();
}
} catch(e) {
}
}
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive || file_obj.sub_id) {
// get id/extractor from JSON
const info_json = await (type === 'audio' ? utils.getJSONMp3(name, folderPath) : utils.getJSONMp4(name, folderPath));
let retrievedID = null;
let retrievedExtractor = null;
if (info_json) {
retrievedID = info_json['id'];
retrievedExtractor = info_json['extractor'];
}
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
if (!blacklistMode) {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id)
} else {
const exists_in_archive = await archive_api.existsInArchive(retrievedExtractor, retrievedID, type, file_obj.user_uid, file_obj.sub_id);
if (!exists_in_archive) {
await archive_api.addToArchive(retrievedExtractor, retrievedID, type, file_obj.title, file_obj.user_uid, file_obj.sub_id);
}
}
}
if (jsonExists) await fs.unlink(jsonPath);
if (thumbnailExists) await fs.unlink(thumbnailPath);
await db_api.removeRecord('files', {uid: uid});
if (fileExists) {
await fs.unlink(file_obj.path);
if (await fs.pathExists(jsonPath) || await fs.pathExists(file_obj.path)) {
return false;
} else {
return true;
}
} else {
// TODO: tell user that the file didn't exist
return true;
}
}
// Video ID is basically just the file name without the base path and file extension - this method helps us get away from that
exports.getVideoUIDByID = async (file_id, uuid = null) => {
const file_obj = await db_api.getRecord('files', {id: file_id});
return file_obj ? file_obj['uid'] : null;
}
exports.getVideo = async (file_uid) => {
return await db_api.getRecord('files', {uid: file_uid});
}
exports.getAllFiles = async (sort, range, text_search, file_type_filter, favorite_filter, sub_id, uuid) => {
const filter_obj = {user_uid: uuid};
const regex = true;
if (text_search) {
if (regex) {
filter_obj['title'] = {$regex: `.*${text_search}.*`, $options: 'i'};
} else {
filter_obj['$text'] = { $search: utils.createEdgeNGrams(text_search) };
}
}
if (favorite_filter) {
filter_obj['favorite'] = true;
}
if (sub_id) {
filter_obj['sub_id'] = sub_id;
}
if (file_type_filter === 'audio_only') filter_obj['isAudio'] = true;
else if (file_type_filter === 'video_only') filter_obj['isAudio'] = false;
const files = JSON.parse(JSON.stringify(await db_api.getRecords('files', filter_obj, false, sort, range, text_search)));
const file_count = await db_api.getRecords('files', filter_obj, true);
return {files, file_count};
}

View File

@@ -2,12 +2,16 @@ const db_api = require('./db');
const config_api = require('./config');
const logger = require('./logger');
const utils = require('./utils');
const consts = require('./consts');
const { uuid } = require('uuidv4');
const fetch = require('node-fetch');
const { gotify } = require("gotify");
const TelegramBot = require('node-telegram-bot-api');
const REST = require('@discordjs/rest').REST;
const API = require('@discordjs/core').API;
const EmbedBuilder = require('@discordjs/builders').EmbedBuilder;
const NOTIFICATION_TYPE_TO_TITLE = {
task_finished: 'Task finished',
@@ -18,7 +22,7 @@ const NOTIFICATION_TYPE_TO_TITLE = {
const NOTIFICATION_TYPE_TO_BODY = {
task_finished: (notification) => notification['data']['task_title'],
download_complete: (notification) => {return `${notification['data']['file_title']}\nOriginal URL: ${notification['data']['original_url']}`},
download_error: (notification) => {return `Error: ${notification['data']['download_error_type']}\nURL: ${notification['data']['download_url']}`}
download_error: (notification) => {return `Error: ${notification['data']['download_error_message']}\nError code: ${notification['data']['download_error_type']}\n\nOriginal URL: ${notification['data']['download_url']}`}
}
const NOTIFICATION_TYPE_TO_URL = {
@@ -57,6 +61,12 @@ exports.sendNotification = async (notification) => {
if (config_api.getConfigItem('ytdl_webhook_url')) {
sendGenericNotification(data);
}
if (config_api.getConfigItem('ytdl_discord_webhook_url')) {
sendDiscordNotification(data);
}
if (config_api.getConfigItem('ytdl_slack_webhook_url')) {
sendSlackNotification(data);
}
await db_api.insertRecordIntoTable('notifications', notification);
return notification;
@@ -79,9 +89,9 @@ exports.sendDownloadNotification = async (file, user_uid) => {
return await exports.sendNotification(notification);
}
exports.sendDownloadErrorNotification = async (download, user_uid, error_type = null) => {
exports.sendDownloadErrorNotification = async (download, user_uid, error_message, error_type = null) => {
if (!notificationEnabled('download_error')) return;
const data = {download_uid: download.uid, download_url: download.url, download_error_type: error_type};
const data = {download_uid: download.uid, download_url: download.url, download_error_message: error_message, download_error_type: error_type};
const notification = exports.createNotification('download_error', ['view_download_error', 'retry_download'], data, user_uid);
return await exports.sendNotification(notification);
}
@@ -144,6 +154,88 @@ async function sendTelegramNotification({body, title, type, url, thumbnail}) {
bot.sendMessage(chat_id, `<b>${title}</b>\n\n${body}\n<a href="${url}">${url}</a>`, {parse_mode: 'HTML'});
}
async function sendDiscordNotification({body, title, type, url, thumbnail}) {
const discord_webhook_url = config_api.getConfigItem('ytdl_discord_webhook_url');
const url_split = discord_webhook_url.split('webhooks/');
const [webhook_id, webhook_token] = url_split[1].split('/');
const rest = new REST({ version: '10' });
const api = new API(rest);
const embed = new EmbedBuilder()
.setTitle(title)
.setColor(0x00FFFF)
.setURL(url)
.setDescription(`ID: ${type}`);
if (thumbnail) embed.setThumbnail(thumbnail);
if (type === 'download_error') embed.setColor(0xFC2003);
const result = await api.webhooks.execute(webhook_id, webhook_token, {
content: body,
username: 'YoutubeDL-Material',
avatar_url: consts.ICON_URL,
embeds: [embed],
});
return result;
}
function sendSlackNotification({body, title, type, url, thumbnail}) {
const slack_webhook_url = config_api.getConfigItem('ytdl_slack_webhook_url');
logger.verbose(`Sending slack notification to ${slack_webhook_url}`);
const data = {
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `*${title}*`
}
},
{
type: "section",
text: {
type: "plain_text",
text: body
}
}
]
}
// add thumbnail if exists
if (thumbnail) {
data['blocks'].push({
type: "image",
image_url: thumbnail,
alt_text: "notification_thumbnail"
});
}
data['blocks'].push(
{
type: "section",
text: {
type: "mrkdwn",
text: `<${url}|${url}>`
}
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `*ID:* ${type}`
}
]
}
);
fetch(slack_webhook_url, {
method: 'POST',
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(data),
});
}
function sendGenericNotification(data) {
const webhook_url = config_api.getConfigItem('ytdl_webhook_url');
logger.verbose(`Sending generic notification to ${webhook_url}`);

6539
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "backend for YoutubeDL-Material",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "mocha test --exit -s 1000",
"start": "pm2-runtime --raw pm2.config.js",
"debug": "set YTDL_MODE=debug && node app.js"
},
@@ -17,22 +17,30 @@
"bugs": {
"url": ""
},
"engines": {
"node": "^16",
"npm": "6.14.4"
},
"homepage": "",
"dependencies": {
"@discordjs/builders": "^1.6.1",
"@discordjs/core": "^0.5.2",
"@tzahi12345/twitch-emoticons": "^1.0.9",
"archiver": "^5.3.1",
"async": "^3.2.3",
"async-mutex": "^0.3.1",
"async-mutex": "^0.4.0",
"axios": "^0.21.2",
"bcryptjs": "^2.4.0",
"command-exists": "^1.2.9",
"compression": "^1.7.4",
"config": "^3.2.3",
"express": "^4.17.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
"feed": "^4.2.2",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^9.0.0",
"gotify": "^1.1.0",
"jsonwebtoken": "^8.5.1",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.21",
"lowdb": "^1.0.0",
"md5": "^2.2.1",
@@ -41,10 +49,10 @@
"mongodb": "^3.6.9",
"multer": "1.4.5-lts.1",
"node-fetch": "^2.6.7",
"node-id3": "^0.1.14",
"node-id3": "^0.2.6",
"node-schedule": "^2.1.0",
"node-telegram-bot-api": "^0.61.0",
"passport": "^0.4.1",
"passport": "^0.6.0",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.1",
"passport-ldapauth": "^3.0.1",
@@ -55,7 +63,7 @@
"rxjs": "^7.3.0",
"shortid": "^2.2.15",
"unzipper": "^0.10.10",
"uuidv4": "^6.0.6",
"uuidv4": "^6.2.13",
"winston": "^3.7.2",
"xmlbuilder2": "^3.0.2",
"youtube-dl": "^3.0.2"

View File

@@ -6,20 +6,21 @@ const config_api = require('./config');
const archive_api = require('./archive');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const debugMode = process.env.YTDL_MODE === 'debug';
const db_api = require('./db');
const downloader_api = require('./downloader');
async function subscribe(sub, user_uid = null) {
exports.subscribe = async (sub, user_uid = null, skip_get_info = false) => {
const result_obj = {
success: false,
error: ''
};
return new Promise(async resolve => {
// sub should just have url and name. here we will get isPlaylist and path
sub.isPlaylist = sub.url.includes('playlist');
sub.isPlaylist = sub.isPlaylist || sub.url.includes('playlist');
sub.videos = [];
let url_exists = !!(await db_api.getRecord('subscriptions', {url: sub.url, user_uid: user_uid}));
@@ -32,12 +33,13 @@ async function subscribe(sub, user_uid = null) {
}
sub['user_uid'] = user_uid ? user_uid : undefined;
await db_api.insertRecordIntoTable('subscriptions', sub);
await db_api.insertRecordIntoTable('subscriptions', JSON.parse(JSON.stringify(sub)));
let success = await getSubscriptionInfo(sub);
let success = skip_get_info ? true : await getSubscriptionInfo(sub);
exports.writeSubscriptionMetadata(sub);
if (success) {
getVideosForSub(sub, user_uid);
if (!sub.paused) exports.getVideosForSub(sub, user_uid);
} else {
logger.error('Subscribe: Failed to get subscription info. Subscribe failed.')
}
@@ -92,7 +94,10 @@ async function getSubscriptionInfo(sub) {
}
// if it's now valid, update
if (sub.name) {
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub.name});
let sub_name = sub.name;
const sub_name_exists = await db_api.getRecord('subscriptions', {name: sub.name, isPlaylist: sub.isPlaylist, user_uid: sub.user_uid});
if (sub_name_exists) sub_name += ` - ${sub.id}`;
await db_api.updateRecord('subscriptions', {id: sub.id}, {name: sub_name});
}
}
@@ -106,7 +111,7 @@ async function getSubscriptionInfo(sub) {
});
}
async function unsubscribe(sub, deleteMode, user_uid = null) {
exports.unsubscribe = async (sub, deleteMode, user_uid = null) => {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -145,7 +150,7 @@ async function unsubscribe(sub, deleteMode, user_uid = null) {
await db_api.removeAllRecords('archives', {sub_id: sub.id});
}
async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null, user_uid = null) {
exports.deleteSubscriptionFile = async (sub, file, deleteForever, file_uid = null, user_uid = null) => {
if (typeof sub === 'string') {
// TODO: fix bad workaround where sub is a sub_id
sub = await db_api.getRecord('subscriptions', {sub_id: sub});
@@ -196,8 +201,13 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
return false;
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
const useArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useArchive && !deleteForever) {
if (deleteForever) {
// ensure video is in the archives
const exists_in_archive = await archive_api.existsInArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
if (!exists_in_archive) {
await archive_api.addToArchive(retrievedExtractor, retrievedID, sub.type, file.title, user_uid, sub.id);
}
} else {
await archive_api.removeFromArchive(retrievedExtractor, retrievedID, sub.type, user_uid, sub.id);
}
return true;
@@ -208,8 +218,8 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
}
}
async function getVideosForSub(sub, user_uid = null) {
const latest_sub_obj = await getSubscription(sub.id);
exports.getVideosForSub = async (sub, user_uid = null) => {
const latest_sub_obj = await exports.getSubscription(sub.id);
if (!latest_sub_obj || latest_sub_obj['downloading']) {
return false;
}
@@ -229,34 +239,30 @@ async function getVideosForSub(sub, user_uid = null) {
const downloadConfig = await generateArgsForSubscription(sub, user_uid);
// get videos
logger.verbose(`Subscription: getting videos for subscription ${sub.name} with args: ${downloadConfig.join(',')}`);
logger.verbose(`Subscription: getting list of videos to download for ${sub.name} with args: ${downloadConfig.join(',')}`);
return new Promise(async resolve => {
youtubedl.exec(sub.url, downloadConfig, {maxBuffer: Infinity}, async function(err, output) {
// cleanup
updateSubscriptionProperty(sub, {downloading: false}, user_uid);
// remove temporary archive file if it exists
const archive_path = path.join(appendedBasePath, 'archive.txt');
const archive_exists = await fs.pathExists(archive_path);
if (archive_exists) {
await fs.unlink(archive_path);
}
logger.verbose('Subscription: finished check for ' + sub.name);
if (err && !output) {
logger.error(err.stderr ? err.stderr : err.message);
if (err.stderr.includes('This video is unavailable') || err.stderr.includes('Private video')) {
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
const outputs = err.stdout.split(/\r\n|\r|\n/);
const files_to_download = await handleOutputJSON(outputs, sub, user_uid);
resolve(files_to_download);
} catch(e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
}
} else {
logger.error('Subscription check failed!');
}
resolve(false);
} else if (output) {
const files_to_download = await handleOutputJSON(output, sub, user_uid);
resolve(files_to_download);
}
const parsed_output = utils.parseOutputJSON(output, err);
if (!parsed_output) {
logger.error('Subscription check failed!');
resolve(null);
return;
}
const files_to_download = await handleOutputJSON(parsed_output, sub, user_uid);
resolve(files_to_download);
return;
});
}, err => {
logger.error(err);
@@ -264,44 +270,30 @@ async function getVideosForSub(sub, user_uid = null) {
});
}
async function handleOutputJSON(output, sub, user_uid) {
async function handleOutputJSON(output_jsons, sub, user_uid) {
if (config_api.getConfigItem('ytdl_subscriptions_redownload_fresh_uploads')) {
await setFreshUploads(sub, user_uid);
checkVideosForFreshUploads(sub, user_uid);
}
if (output.length === 0 || (output.length === 1 && output[0] === '')) {
if (output_jsons.length === 0 || (output_jsons.length === 1 && output_jsons[0] === '')) {
logger.verbose('No additional videos to download for ' + sub.name);
return [];
}
const output_jsons = [];
for (let i = 0; i < output.length; i++) {
let output_json = null;
try {
output_json = JSON.parse(output[i]);
output_jsons.push(output_json);
} catch(e) {
output_json = null;
}
if (!output_json) {
continue;
}
}
const files_to_download = await getFilesToDownload(sub, output_jsons);
const base_download_options = generateOptionsForSubscriptionDownload(sub, user_uid);
const base_download_options = exports.generateOptionsForSubscriptionDownload(sub, user_uid);
for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j];
file_to_download['formats'] = utils.stripPropertiesFromObject(file_to_download['formats'], ['format_id', 'filesize', 'filesize_approx']); // prevent download object from blowing up in size
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, file_to_download);
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name, [file_to_download]);
}
return files_to_download;
}
function generateOptionsForSubscriptionDownload(sub, user_uid) {
exports.generateOptionsForSubscriptionDownload = (sub, user_uid) => {
let basePath = null;
if (user_uid)
basePath = path.join(config_api.getConfigItem('ytdl_users_base_path'), user_uid, 'subscriptions');
@@ -354,6 +346,16 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push(...qualityPath)
// skip videos that are in the archive. otherwise sub download can be permanently slow (vs. just the first time)
const archive_text = await archive_api.generateArchive(sub.type, sub.user_uid, sub.id);
const archive_count = archive_text.split('\n').length - 1;
if (archive_count > 0) {
logger.verbose(`Generating temporary archive file for subscription ${sub.name} with ${archive_count} entries.`)
const archive_path = path.join(appendedBasePath, 'archive.txt');
await fs.writeFile(archive_path, archive_text);
downloadConfig.push('--download-archive', archive_path);
}
if (sub.custom_args) {
const customArgsArray = sub.custom_args.split(',,');
if (customArgsArray.indexOf('-f') !== -1) {
@@ -408,11 +410,8 @@ async function getFilesToDownload(sub, output_jsons) {
logger.info(`Skipping adding file ${output_json['_filename']} for subscription ${sub.name} as a file with that path already exists.`)
continue;
}
const useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const exists_in_archive = await archive_api.existsInArchive(output_json['extractor'], output_json['id'], sub.type, sub.user_uid, sub.id);
if (exists_in_archive) continue;
}
const exists_in_archive = await archive_api.existsInArchive(output_json['extractor'], output_json['id'], sub.type, sub.user_uid, sub.id);
if (exists_in_archive) continue;
files_to_download.push(output_json);
}
@@ -421,35 +420,36 @@ async function getFilesToDownload(sub, output_jsons) {
}
async function getSubscriptions(user_uid = null) {
exports.getSubscriptions = async (user_uid = null) => {
return await db_api.getRecords('subscriptions', {user_uid: user_uid});
}
async function getAllSubscriptions() {
exports.getAllSubscriptions = async () => {
const all_subs = await db_api.getRecords('subscriptions');
const multiUserMode = config_api.getConfigItem('ytdl_multi_user_mode');
return all_subs.filter(sub => !!(sub.user_uid) === !!multiUserMode);
}
async function getSubscription(subID) {
exports.getSubscription = async (subID) => {
// stringify and parse because we may override the 'downloading' property
const sub = JSON.parse(JSON.stringify(await db_api.getRecord('subscriptions', {id: subID})));
// now with the download_queue, we may need to override 'downloading'
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: sub.id}, true);
const current_downloads = await db_api.getRecords('download_queue', {running: true, sub_id: subID}, true);
if (!sub['downloading']) sub['downloading'] = current_downloads > 0;
return sub;
}
async function getSubscriptionByName(subName, user_uid = null) {
exports.getSubscriptionByName = async (subName, user_uid = null) => {
return await db_api.getRecord('subscriptions', {name: subName, user_uid: user_uid});
}
async function updateSubscription(sub) {
exports.updateSubscription = async (sub) => {
await db_api.updateRecord('subscriptions', {id: sub.id}, sub);
exports.writeSubscriptionMetadata(sub);
return true;
}
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
exports.updateSubscriptionPropertyMultiple = async (subs, assignment_obj) => {
subs.forEach(async sub => {
await updateSubscriptionProperty(sub, assignment_obj);
});
@@ -461,6 +461,16 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
return true;
}
exports.writeSubscriptionMetadata = (sub) => {
let basePath = sub.user_uid ? path.join(config_api.getConfigItem('ytdl_users_base_path'), sub.user_uid, 'subscriptions')
: config_api.getConfigItem('ytdl_subscriptions_base_path');
const appendedBasePath = getAppendedBasePath(sub, basePath);
const metadata_path = path.join(appendedBasePath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
fs.ensureDirSync(appendedBasePath);
fs.writeJSONSync(metadata_path, sub);
}
async function setFreshUploads(sub) {
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
if (!sub_files) return;
@@ -515,17 +525,3 @@ async function checkVideoIfBetterExists(file_obj, sub, user_uid) {
function getAppendedBasePath(sub, base_path) {
return path.join(base_path, (sub.isPlaylist ? 'playlists/' : 'channels/'), sub.name);
}
module.exports = {
getSubscription : getSubscription,
getSubscriptionByName : getSubscriptionByName,
getSubscriptions : getSubscriptions,
getAllSubscriptions : getAllSubscriptions,
updateSubscription : updateSubscription,
subscribe : subscribe,
unsubscribe : unsubscribe,
deleteSubscriptionFile : deleteSubscriptionFile,
getVideosForSub : getVideosForSub,
updateSubscriptionPropertyMultiple : updateSubscriptionPropertyMultiple,
generateOptionsForSubscriptionDownload: generateOptionsForSubscriptionDownload
}

View File

@@ -2,10 +2,18 @@ const db_api = require('./db');
const notifications_api = require('./notifications');
const youtubedl_api = require('./youtube-dl');
const archive_api = require('./archive');
const files_api = require('./files');
const subscriptions_api = require('./subscriptions');
const config_api = require('./config');
const auth_api = require('./authentication/auth');
const utils = require('./utils');
const logger = require('./logger');
const CONSTS = require('./consts');
const fs = require('fs-extra');
const logger = require('./logger');
const path = require('path');
const scheduler = require('node-schedule');
const { uuid } = require('uuidv4');
const TASKS = {
backup_local_db: {
@@ -20,7 +28,7 @@ const TASKS = {
job: null
},
missing_db_records: {
run: db_api.importUnregisteredFiles,
run: files_api.importUnregisteredFiles,
title: 'Import missing DB records',
job: null
},
@@ -46,6 +54,11 @@ const TASKS = {
run: archive_api.importArchives,
title: 'Import legacy archives',
job: null
},
rebuild_database: {
run: rebuildDB,
title: 'Rebuild database',
job: null
}
}
@@ -259,9 +272,73 @@ async function autoDeleteFiles(data) {
logger.info(`Removing ${data['files_to_remove'].length} old files!`);
for (let i = 0; i < data['files_to_remove'].length; i++) {
const file_to_remove = data['files_to_remove'][i];
await db_api.deleteFile(file_to_remove['uid'], task_obj['options']['blacklist_files'] || (file_to_remove['sub_id'] && file_to_remove['blacklist_subscription_files']));
await files_api.deleteFile(file_to_remove['uid'], task_obj['options']['blacklist_files'] || (file_to_remove['sub_id'] && file_to_remove['blacklist_subscription_files']));
}
}
}
async function rebuildDB() {
await db_api.backupDB();
let subs_to_add = await guessSubscriptions(false);
subs_to_add = subs_to_add.concat(await guessSubscriptions(true));
const users_to_add = await guessUsers();
for (const user_to_add of users_to_add) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const user_exists = await db_api.getRecord('users', {uid: user_to_add});
if (!user_exists) {
await auth_api.registerUser(user_to_add, user_to_add, 'password');
logger.info(`Regenerated user ${user_to_add}`);
}
const user_channel_subs = await guessSubscriptions(false, path.join(usersFileFolder, user_to_add), user_to_add);
const user_playlist_subs = await guessSubscriptions(true, path.join(usersFileFolder, user_to_add), user_to_add);
subs_to_add = subs_to_add.concat(user_channel_subs, user_playlist_subs);
}
for (const sub_to_add of subs_to_add) {
const sub_exists = !!(await subscriptions_api.getSubscriptionByName(sub_to_add['name'], sub_to_add['user_uid']));
// TODO: we shouldn't be creating this here
const new_sub = Object.assign({}, sub_to_add, {paused: true});
if (!sub_exists) {
await subscriptions_api.subscribe(new_sub, sub_to_add['user_uid'], true);
logger.info(`Regenerated subscription ${sub_to_add['name']}`);
}
}
logger.info(`Importing unregistered files`);
await files_api.importUnregisteredFiles();
}
const guessUsers = async () => {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const userPaths = await utils.getDirectoriesInDirectory(usersFileFolder);
return userPaths.map(userPath => path.basename(userPath));
}
const guessSubscriptions = async (isPlaylist, basePath = null) => {
const guessed_subs = [];
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const subsSubPath = basePath ? path.join(basePath, 'subscriptions') : subscriptionsFileFolder;
const subsPath = path.join(subsSubPath, isPlaylist ? 'playlists' : 'channels');
const subs = await utils.getDirectoriesInDirectory(subsPath);
for (const subPath of subs) {
const sub_backup_path = path.join(subPath, CONSTS.SUBSCRIPTION_BACKUP_PATH);
if (!fs.existsSync(sub_backup_path)) continue;
try {
const sub_backup = fs.readJSONSync(sub_backup_path)
delete sub_backup['_id'];
guessed_subs.push(sub_backup);
} catch(err) {
logger.warn(`Failed to reimport subscription in path ${subPath}`)
logger.warn(err);
}
}
return guessed_subs;
}
exports.TASKS = TASKS;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,10 @@
/* eslint-disable no-undef */
const assert = require('assert');
const low = require('lowdb')
const winston = require('winston');
const path = require('path');
const util = require('util');
const exec = util.promisify(require('child_process').exec);
const FileSync = require('lowdb/adapters/FileSync');
@@ -38,11 +40,15 @@ var db_api = require('../db');
const utils = require('../utils');
const subscriptions_api = require('../subscriptions');
const archive_api = require('../archive');
const categories_api = require('../categories');
const files_api = require('../files');
const youtubedl_api = require('../youtube-dl');
const config_api = require('../config');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
const NodeID3 = require('node-id3');
db_api.initialize(db, users_db);
db_api.initialize(db, users_db, 'local_db_test.json');
const sample_video_json = {
id: "Sample Video",
@@ -65,9 +71,9 @@ const sample_video_json = {
}
describe('Database', async function() {
describe('Import', async function() {
describe.skip('Import', async function() {
// it('Migrate', async function() {
// await db_api.connectToDB();
// // await db_api.connectToDB();
// await db_api.removeAllRecords();
// const success = await db_api.importJSONToDB(db.value(), users_db.value());
// assert(success);
@@ -83,7 +89,7 @@ describe('Database', async function() {
});
it('Transfer to local', async function() {
await db_api.connectToDB();
// await db_api.connectToDB();
await db_api.removeAllRecords('test');
await db_api.insertRecordIntoTable('test', {test: 'test'});
@@ -111,7 +117,8 @@ describe('Database', async function() {
for (const local_db_mode of local_db_modes) {
let use_local_db = local_db_mode;
describe(`Use local DB - ${use_local_db}`, async function() {
const describe_skippable = use_local_db ? describe : describe.skip;
describe_skippable(`Use local DB - ${use_local_db}`, async function() {
beforeEach(async function() {
if (!use_local_db) {
this.timeout(120000);
@@ -164,7 +171,7 @@ describe('Database', async function() {
];
await db_api.insertRecordsIntoTable('test', test_duplicates);
const duplicates = await db_api.findDuplicatesByKey('test', 'test');
console.log(duplicates);
assert(duplicates && duplicates.length === 2 && duplicates[0]['key'] === '2' && duplicates[1]['key'] === '4')
});
it('Update record', async function() {
@@ -175,6 +182,15 @@ describe('Database', async function() {
await db_api.removeRecord('test', {test_update: 'test'});
});
it('Update records', async function() {
await db_api.insertRecordIntoTable('test', {test_update: 'test', key: 'test1'});
await db_api.insertRecordIntoTable('test', {test_update: 'test', key: 'test2'});
await db_api.updateRecords('test', {test_update: 'test'}, {added_field: true});
const updated_records = await db_api.getRecords('test', {added_field: true});
assert(updated_records.length === 2);
await db_api.removeRecord('test', {test_update: 'test'});
});
it('Remove property from record', async function() {
await db_api.insertRecordIntoTable('test', {test_keep: 'test', test_remove: 'test'});
await db_api.removePropertyFromRecord('test', {test_keep: 'test'}, {test_remove: true});
@@ -267,7 +283,7 @@ describe('Database', async function() {
assert(stats);
});
it('Query speed', async function() {
it.skip('Query speed', async function() {
this.timeout(120000);
const NUM_RECORDS_TO_ADD = 300004; // max batch ops is 1000
const test_records = [];
@@ -325,48 +341,57 @@ describe('Database', async function() {
});
describe('Multi User', async function() {
let user = null;
const user_to_test = 'admin';
const sub_to_test = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const playlist_to_test = 'ysabVZz4x';
this.timeout(120000);
const user_to_test = 'test_user';
const user_password = 'test_pass';
const sub_to_test = '';
const playlist_to_test = '';
beforeEach(async function() {
await db_api.connectToDB();
user = await auth_api.login('admin', 'pass');
// await db_api.connectToDB();
await auth_api.deleteUser(user_to_test);
});
describe('Authentication', function() {
it('login', async function() {
describe('Basic', function() {
it('Register', async function() {
const user = await auth_api.registerUser(user_to_test, user_to_test, user_password);
assert(user);
});
it('Login', async function() {
await auth_api.registerUser(user_to_test, user_to_test, user_password);
const user = await auth_api.login(user_to_test, user_password);
assert(user);
});
});
describe('Video player - normal', async function() {
await db_api.removeRecord('files', {uid: sample_video_json['uid']});
await db_api.insertRecordIntoTable('files', sample_video_json);
beforeEach(async function() {
await db_api.removeRecord('files', {uid: sample_video_json['uid']});
await db_api.insertRecordIntoTable('files', sample_video_json);
});
const video_to_test = sample_video_json['uid'];
it('Get video', async function() {
const video_obj = await db_api.getVideo(video_to_test);
const video_obj = await files_api.getVideo(video_to_test);
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);
await db_api.setVideoProperty(video_to_test, {sharingEnabled: false});
const video_obj = await auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(!video_obj);
});
it('Video access - allowed', async function() {
await db_api.setVideoProperty(video_to_test, {sharingEnabled: true}, user_to_test);
const video_obj = auth_api.getUserVideo('admin', video_to_test, true);
const video_obj = await auth_api.getUserVideo(user_to_test, video_to_test, true);
assert(video_obj);
});
});
describe('Zip generators', function() {
describe.skip('Zip generators', function() {
it('Playlist zip generator', async function() {
const playlist = await db_api.getPlaylist(playlist_to_test, user_to_test);
const playlist = await files_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);
const playlist_file = await files_api.getVideo(uid, user_to_test);
playlist_files_to_download.push(playlist_file);
}
const zip_path = await utils.createContainerZipFile(playlist, playlist_files_to_download);
@@ -394,7 +419,7 @@ describe('Multi User', async 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', );
// const video_obj = files_api.getVideo(video_to_test, 'admin', );
// assert(video_obj);
// });
@@ -415,35 +440,50 @@ describe('Multi User', async function() {
describe('Downloader', function() {
const downloader_api = require('../downloader');
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
const url = 'https://www.youtube.com/watch?v=hpigjnKl7nI';
const sub_id = 'dc834388-3454-41bf-a618-e11cb8c7de1c';
const options = {
ui_uid: uuid(),
user: 'admin'
ui_uid: uuid()
}
before(async function() {
const update_available = await youtubedl_api.checkForYoutubeDLUpdate();
if (update_available) await youtubedl_api.updateYoutubeDL(update_available);
config_api.setConfigItem('ytdl_max_concurrent_downloads', 0);
});
beforeEach(async function() {
await db_api.connectToDB();
// await db_api.connectToDB();
await db_api.removeAllRecords('download_queue');
});
it('Get file info', async function() {
this.timeout(300000);
const info = await downloader_api.getVideoInfoByURL(url);
assert(!!info);
assert(!!info && info.length > 0);
});
it('Download file', async function() {
this.timeout(300000);
this.timeout(300000);
await downloader_api.setupDownloads();
const args = await downloader_api.generateArgs(url, 'video', options, null, true);
const [info] = await downloader_api.getVideoInfoByURL(url, args);
if (fs.existsSync(info['_filename'])) fs.unlinkSync(info['_filename']);
const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download);
await utils.wait(20000);
assert(returned_download);
const custom_download_method = async (url, args, options, callback) => {
fs.writeJSONSync(utils.getTrueFileName(info['_filename'], 'video', '.info.json'), info);
await generateEmptyVideoFile(info['_filename']);
return await callback(null, [JSON.stringify(info)]);
}
const success = await downloader_api.downloadQueuedFile(returned_download['uid'], custom_download_method);
assert(success);
});
it('Tag file', async function() {
const audio_path = './test/sample.mp3';
const sample_json = fs.readJSONSync('./test/sample.info.json');
const success = await generateEmptyAudioFile('test/sample_mp3.mp3');
const audio_path = './test/sample_mp3.mp3';
const sample_json = fs.readJSONSync('./test/sample_mp3.info.json');
const tags = {
title: sample_json['title'],
artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'],
@@ -451,14 +491,13 @@ describe('Downloader', function() {
}
NodeID3.write(tags, audio_path);
const written_tags = NodeID3.read(audio_path);
assert(written_tags['raw']['TRCK'] === '27');
assert(success && written_tags['raw']['TRCK'] === '27');
});
it('Queue file', async function() {
this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options);
console.log(returned_download);
await utils.wait(20000);
const returned_download = await downloader_api.createDownload(url, 'video', options, null, null, null, null, true);
assert(returned_download);
});
it('Pause file', async function() {
@@ -473,7 +512,7 @@ describe('Downloader', function() {
assert(args.length > 0);
});
it('Generate args - subscription', async function() {
it.skip('Generate args - subscription', async function() {
const sub = await subscriptions_api.getSubscription(sub_id);
const sub_options = subscriptions_api.generateOptionsForSubscriptionDownload(sub, 'admin');
const args_normal = await downloader_api.generateArgs(url, 'video', options);
@@ -486,7 +525,7 @@ describe('Downloader', function() {
if (fs.existsSync(nfo_file_path)) {
fs.unlinkSync(nfo_file_path);
}
const sample_json = fs.readJSONSync('./test/sample.info.json');
const sample_json = fs.readJSONSync('./test/sample_mp4.info.json');
downloader_api.generateNFOFile(sample_json, nfo_file_path);
assert(fs.existsSync(nfo_file_path), true);
fs.unlinkSync(nfo_file_path);
@@ -497,35 +536,90 @@ describe('Downloader', function() {
const new_args1 = ['--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s'];
const updated_args1 = utils.injectArgs(original_args1, new_args1);
const expected_args1 = ['--no-resize-buffer', '--no-mtime', '--age-limit', '25', '--yes-playlist', '--abort-on-error', '-o', '%(id)s'];
assert(JSON.stringify(updated_args1), JSON.stringify(expected_args1));
assert(JSON.stringify(updated_args1) === JSON.stringify(expected_args1));
const original_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3'];
const new_args2 = ['--add-metadata', '--embed-thumbnail', '--convert-thumbnails', 'jpg'];
const updated_args2 = utils.injectArgs(original_args2, new_args2);
const expected_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3', '--add-metadata', '--embed-thumbnail', '--convert_thumbnails', 'jpg'];
console.log(updated_args2);
assert(JSON.stringify(updated_args2), JSON.stringify(expected_args2));
});
describe('Twitch', async function () {
const twitch_api = require('../twitch');
const example_vod = '1710641401';
it('Download VOD', async function() {
const sample_path = path.join('test', 'sample.twitch_chat.json');
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
this.timeout(300000);
await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test');
assert(fs.existsSync(sample_path));
const expected_args2 = ['-o', '%(title)s.%(ext)s', '--write-info-json', '--print-json', '--audio-quality', '0', '-x', '--audio-format', 'mp3', '--add-metadata', '--embed-thumbnail', '--convert-thumbnails', 'jpg'];
assert(JSON.stringify(updated_args2) === JSON.stringify(expected_args2));
// cleanup
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
});
const original_args3 = ['-o', '%(title)s.%(ext)s'];
const new_args3 = ['--min-filesize','1'];
const updated_args3 = utils.injectArgs(original_args3, new_args3);
const expected_args3 = ['-o', '%(title)s.%(ext)s', '--min-filesize', '1'];
assert(JSON.stringify(updated_args3) === JSON.stringify(expected_args3));
});
});
describe('Twitch', async function () {
const twitch_api = require('../twitch');
const example_vod = '1710641401';
const example_channel = 'keffals';
it('Get OAuth Token', async function() {
this.timeout(300000);
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
if (!twitch_client_id || !twitch_client_secret) {
logger.info(`Skipping test 'Get OAuth Token' as Twitch client ID or Twitch client secret is missing.`);
assert(true);
return;
}
const token = await twitch_api.getTwitchOAuthToken(twitch_client_id, twitch_client_secret);
assert(token);
});
it('Get channel ID', async function() {
this.timeout(300000);
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
if (!twitch_client_id || !twitch_client_secret) {
logger.info(`Skipping test 'Get channel ID' as Twitch client ID or Twitch client secret is missing.`);
assert(true);
return;
}
const channel_id = await twitch_api.getChannelID(example_channel);
assert(channel_id === '494493142');
});
it('Download VOD chat', async function() {
this.timeout(300000);
if (!fs.existsSync('TwitchDownloaderCLI')) {
try {
await exec('sh ../docker-utils/fetch-twitchdownloader.sh');
fs.copyFileSync('../docker-utils/TwitchDownloaderCLI', 'TwitchDownloaderCLI');
} catch (e) {
logger.info('TwitchDownloaderCLI fetch failed, file may exist regardless.');
}
}
const sample_path = path.join('test', 'sample.twitch_chat.json');
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
await twitch_api.downloadTwitchChatByVODID(example_vod, 'sample', null, null, null, './test');
assert(fs.existsSync(sample_path));
// cleanup
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
});
it('Download Twitch emotes', async function() {
this.timeout(300000);
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
if (!twitch_client_id || !twitch_client_secret) {
logger.info(`Skipping test 'Download Twitch emotes' as Twitch client ID or Twitch client secret is missing.`);
assert(true);
return;
}
const emotesJSON = await twitch_api.downloadTwitchEmotes(example_channel, 'test_uid');
assert(emotesJSON && emotesJSON.length > 0);
});
});
describe('Tasks', function() {
const tasks_api = require('../tasks');
beforeEach(async function() {
await db_api.connectToDB();
// await db_api.connectToDB();
await db_api.removeAllRecords('tasks');
const dummy_task = {
@@ -544,7 +638,7 @@ describe('Tasks', function() {
await tasks_api.executeTask('backup_local_db');
const backups_new = await utils.recFindByExt('appdata', 'bak');
const new_length = backups_new.length;
assert(original_length, new_length-1);
assert(original_length === new_length-1);
});
it('Check for missing files', async function() {
@@ -554,7 +648,7 @@ describe('Tasks', function() {
await db_api.insertRecordIntoTable('files', test_missing_file);
await tasks_api.executeTask('missing_files_check');
const missing_file_db_record = await db_api.getRecord('files', {uid: 'test'});
assert(!missing_file_db_record, true);
assert(!missing_file_db_record);
});
it('Check for duplicate files', async function() {
@@ -574,27 +668,29 @@ describe('Tasks', function() {
await tasks_api.executeTask('duplicate_files_check');
const duplicated_record_count = await db_api.getRecords('files', {path: 'test/missing_file.mp4'}, true);
assert(duplicated_record_count == 1, true);
assert(duplicated_record_count === 1);
});
it('Import unregistered files', async function() {
this.timeout(300000);
const success = await generateEmptyVideoFile('test/sample_mp4.mp4');
// pre-test cleanup
await db_api.removeAllRecords('files', {title: 'Sample File'});
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
// copies in files
fs.copyFileSync('test/sample.info.json', 'video/sample.info.json');
fs.copyFileSync('test/sample.mp4', 'video/sample.mp4');
fs.copyFileSync('test/sample_mp4.info.json', 'video/sample_mp4.info.json');
fs.copyFileSync('test/sample_mp4.mp4', 'video/sample_mp4.mp4');
await tasks_api.executeTask('missing_db_records');
const imported_file = await db_api.getRecord('files', {title: 'Sample File'});
assert(!!imported_file, true);
assert(success && !!imported_file);
// post-test cleanup
if (fs.existsSync('video/sample.info.json')) fs.unlinkSync('video/sample.info.json');
if (fs.existsSync('video/sample.mp4')) fs.unlinkSync('video/sample.mp4');
if (fs.existsSync('video/sample_mp4.info.json')) fs.unlinkSync('video/sample_mp4.info.json');
if (fs.existsSync('video/sample_mp4.mp4')) fs.unlinkSync('video/sample_mp4.mp4');
});
it('Schedule and cancel task', async function() {
@@ -634,12 +730,12 @@ describe('Tasks', function() {
describe('Archive', async function() {
beforeEach(async function() {
await db_api.connectToDB();
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
// await db_api.connectToDB();
await db_api.removeAllRecords('archives');
});
afterEach(async function() {
await db_api.removeAllRecords('archives', {user_uid: 'test_user'});
await db_api.removeAllRecords('archives');
});
it('Import archive', async function() {
@@ -653,7 +749,6 @@ describe('Archive', async function() {
const count = await archive_api.importArchiveFile(archive_text, 'video', 'test_user', 'test_sub');
assert(count === 4)
const archive_items = await db_api.getRecords('archives', {user_uid: 'test_user', sub_id: 'test_sub'});
console.log(archive_items);
assert(archive_items.length === 4);
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor2').length === 1);
assert(archive_items.filter(archive_item => archive_item.extractor === 'testextractor1').length === 3);
@@ -684,9 +779,9 @@ describe('Archive', async function() {
});
it('Remove from archive', async function() {
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_user');
await archive_api.addToArchive('testextractor1', 'testing1', 'video', 'test_title', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing1', 'video', 'test_title', 'test_user');
await archive_api.addToArchive('testextractor2', 'testing2', 'video', 'test_title', 'test_user');
const success = await archive_api.removeFromArchive('testextractor2', 'testing1', 'video', 'test_user');
assert(success);
@@ -728,4 +823,118 @@ describe('Utils', async function() {
assert(nested_obj2['test1'] && nested_obj2['test1']['test_sub']);
assert(nested_obj2['test1'] && nested_obj2['test1']['test2'] && nested_obj2['test1']['test2']['test_sub']);
});
});
});
describe('Categories', async function() {
beforeEach(async function() {
// await db_api.connectToDB();
const new_category = {
name: 'test_category',
uid: uuid(),
rules: [],
custom_output: ''
};
await db_api.insertRecordIntoTable('categories', new_category);
});
afterEach(async function() {
await db_api.removeAllRecords('categories', {name: 'test_category'});
});
it('Categorize - includes', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(category && category.name === 'test_category');
});
it('Categorize - not includes', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'not_includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(!category);
});
it('Categorize - equals', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'equals',
property: 'uploader',
value: 'Sample Uploader'
});
const category = await categories_api.categorize([sample_video_json]);
assert(category && category.name === 'test_category');
});
it('Categorize - not equals', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'not_equals',
property: 'uploader',
value: 'Sample Uploader'
});
const category = await categories_api.categorize([sample_video_json]);
assert(!category);
});
it('Categorize - AND', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'equals',
property: 'uploader',
value: 'Sample Uploader'
});
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: 'and',
comparator: 'not_includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(!category);
});
it('Categorize - OR', async function() {
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: null,
comparator: 'equals',
property: 'uploader',
value: 'Sample Uploader'
});
await db_api.pushToRecordsArray('categories', {name: 'test_category'}, 'rules', {
preceding_operator: 'or',
comparator: 'not_includes',
property: 'title',
value: 'Sample'
});
const category = await categories_api.categorize([sample_video_json]);
assert(category);
});
});
const generateEmptyVideoFile = async (file_path) => {
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
return await exec(`ffmpeg -t 1 -f lavfi -i color=c=black:s=640x480 -c:v libx264 -tune stillimage -pix_fmt yuv420p "${file_path}"`);
}
const generateEmptyAudioFile = async (file_path) => {
if (fs.existsSync(file_path)) fs.unlinkSync(file_path);
return await exec(`ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 1 -q:a 9 -acodec libmp3lame ${file_path}`);
}

View File

@@ -1,13 +1,32 @@
const config_api = require('./config');
const logger = require('./logger');
const utils = require('./utils');
const moment = require('moment');
const fs = require('fs-extra')
const axios = require('axios');
const { EmoteFetcher } = require('@tzahi12345/twitch-emoticons');
const path = require('path');
const { promisify } = require('util');
const child_process = require('child_process');
const commandExistsSync = require('command-exists').sync;
async function getCommentsForVOD(vodId) {
let auth_timeout = null;
let cached_oauth = null;
exports.ensureTwitchAuth = async () => {
const TIMEOUT_MARGIN_MS = 60*1000;
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
if (cached_oauth && auth_timeout && (Date.now() - TIMEOUT_MARGIN_MS) < auth_timeout) return cached_oauth;
const {token, expires_in} = await exports.getTwitchOAuthToken(twitch_client_id, twitch_client_secret);
cached_oauth = token;
auth_timeout = Date.now() + expires_in;
return token;
}
exports.getCommentsForVOD = async (vodId) => {
const exec = promisify(child_process.exec);
// Reject invalid params to prevent command injection attack
@@ -20,12 +39,12 @@ async function getCommentsForVOD(vodId) {
const cliExt = is_windows ? '.exe' : ''
const cliPath = `TwitchDownloaderCLI${cliExt}`
if (!fs.existsSync(cliPath)) {
if (!commandExistsSync(cliPath)) {
logger.error(`${cliPath} does not exist. Twitch chat download failed! Get it here: https://github.com/lay295/TwitchDownloader`);
return null;
}
const result = await exec(`TwitchDownloaderCLI chatdownload -u ${vodId} -o appdata/${vodId}.json`, {stdio:[0,1,2]});
const result = await exec(`${cliPath} chatdownload -u ${vodId} -o appdata/${vodId}.json`, {stdio:[0,1,2]});
if (result['stderr']) {
logger.error(`Failed to download twitch comments for ${vodId}`);
@@ -51,7 +70,7 @@ async function getCommentsForVOD(vodId) {
return new_json;
}
async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
exports.getTwitchChatByFileID = async (id, type, user_uid, uuid, sub) => {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
let file_path = null;
@@ -79,10 +98,10 @@ async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
return chat_file;
}
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customFileFolderPath = null) {
exports.downloadTwitchChatByVODID = async (vodId, id, type, user_uid, sub, customFileFolderPath = null) => {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
const chat = await getCommentsForVOD(vodId);
const chat = await exports.getCommentsForVOD(vodId);
// save file if needed params are included
let file_path = null;
@@ -107,6 +126,114 @@ async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub, customF
return chat;
}
exports.getTwitchEmotes = async (file_uid) => {
const emotes_path = path.join('appdata', 'emotes', file_uid, 'emotes.json')
if (!fs.existsSync(emotes_path)) return null;
const emote_objs = fs.readJSONSync(emotes_path);
// inject custom url
for (const emote_obj of emote_objs) {
emote_obj.custom_url = `${utils.getBaseURL()}/api/emote/${file_uid}/${emote_obj.id}.${emote_obj.ext}`
}
return emote_objs;
}
exports.downloadTwitchEmotes = async (channel_name, file_uid) => {
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
const channel_id = await exports.getChannelID(channel_name);
const fetcher = new EmoteFetcher(twitch_client_id, twitch_client_secret);
try {
await Promise.allSettled([
fetcher.fetchTwitchEmotes(),
fetcher.fetchTwitchEmotes(channel_id),
fetcher.fetchBTTVEmotes(),
fetcher.fetchBTTVEmotes(channel_id),
fetcher.fetchSevenTVEmotes(),
fetcher.fetchSevenTVEmotes(channel_id),
fetcher.fetchFFZEmotes(),
fetcher.fetchFFZEmotes(channel_id)
]);
const emotes_dir = path.join('appdata', 'emotes', file_uid);
const emote_json_path = path.join(emotes_dir, `emotes.json`);
fs.ensureDirSync(emotes_dir);
const emote_objs = [];
let failed_emote_count = 0;
for (const [, emote] of fetcher.emotes) {
const emote_obj = emote.toObject();
const ext = emote.imageType;
const emote_image_path = path.join(emotes_dir, `${emote.id}.${ext}`);
try {
const link = emote.toLink();
if (!fs.existsSync(emote_image_path)) await utils.fetchFile(link, emote_image_path);
emote_obj['ext'] = ext;
emote_objs.push(emote_obj);
} catch (err) {
failed_emote_count++;
}
}
if (failed_emote_count) logger.warn(`${failed_emote_count} emotes failed to download for channel ${channel_name}`);
await fs.writeJSON(emote_json_path, emote_objs);
return emote_objs;
} catch (err) {
logger.error(err);
return null;
}
}
exports.getTwitchOAuthToken = async (client_id, client_secret) => {
logger.verbose('Generating new Twitch auth token');
const url = `https://id.twitch.tv/oauth2/token`;
try {
const response = await axios.post(url, {client_id: client_id, client_secret: client_secret, grant_type: 'client_credentials'});
const token = response['data']['access_token'];
const expires_in = response['data']['expires_in'];
if (token) return {token, expires_in};
logger.error(`Failed to get token.`);
return null;
} catch (err) {
logger.error(`Failed to get token.`);
logger.error(err);
return null;
}
}
exports.getChannelID = async (channel_name) => {
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const token = await exports.ensureTwitchAuth();
const url = `https://api.twitch.tv/helix/users?login=${channel_name}`;
const headers = {
'Client-ID': twitch_client_id,
'Authorization': 'Bearer ' + token,
// Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8'
};
try {
const response = await axios.get(url, {headers: headers});
const data = response.data.data;
if (data && data.length > 0) {
const channelID = data[0].id;
return channelID;
}
logger.error(`Failed to get channel ID for user ${channel_name}`);
if (data.error) logger.error(data.error);
return null; // User not found
} catch (err) {
logger.error(`Failed to get channel ID for user ${channel_name}`);
logger.error(err);
}
}
const convertTimestamp = (timestamp) => moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
@@ -114,9 +241,3 @@ const convertTimestamp = (timestamp) => moment.duration(timestamp, 'seconds')
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
module.exports = {
getCommentsForVOD: getCommentsForVOD,
getTwitchChatByFileID: getTwitchChatByFileID,
downloadTwitchChatByVODID: downloadTwitchChatByVODID
}

View File

@@ -13,7 +13,7 @@ const CONSTS = require('./consts');
const is_windows = process.platform === 'win32';
// replaces .webm with appropriate extension
exports.getTrueFileName = (unfixed_path, type) => {
exports.getTrueFileName = (unfixed_path, type, force_ext = null) => {
let fixed_path = unfixed_path;
const new_ext = (type === 'audio' ? 'mp3' : 'mp4');
@@ -22,7 +22,7 @@ exports.getTrueFileName = (unfixed_path, type) => {
if (old_ext !== new_ext) {
unfixed_parts[unfixed_parts.length-1] = new_ext;
unfixed_parts[unfixed_parts.length-1] = force_ext || new_ext;
fixed_path = unfixed_parts.join('.');
}
return fixed_path;
@@ -92,7 +92,7 @@ exports.createZipFile = async (zip_file_path, file_paths) => {
await archive.finalize();
// wait a tiny bit for the zip to reload in fs
await wait(100);
await exports.wait(100);
return zip_file_path;
}
@@ -347,7 +347,7 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (!err) {
clearTimeout(timer);
if (watcher) watcher.close();
resolve();
resolve(true);
}
});
@@ -357,33 +357,36 @@ exports.checkExistsWithTimeout = async (filePath, timeout) => {
if (eventType === 'rename' && filename === basename) {
clearTimeout(timer);
if (watcher) watcher.close();
resolve();
resolve(true);
}
});
});
}
// helper function to download file using fetch
exports.fetchFile = async (url, path, file_label) => {
exports.fetchFile = async (url, output_path, file_label = null) => {
var len = null;
const res = await fetch(url);
let bar = null;
if (file_label) {
len = parseInt(res.headers.get("Content-Length"), 10);
len = parseInt(res.headers.get("Content-Length"), 10);
bar = new ProgressBar(` Downloading ${file_label} [:bar] :percent :etas`, {
complete: '=',
incomplete: ' ',
width: 20,
total: len
});
}
var bar = new ProgressBar(` Downloading ${file_label} [:bar] :percent :etas`, {
complete: '=',
incomplete: ' ',
width: 20,
total: len
});
const fileStream = fs.createWriteStream(path);
const fileStream = fs.createWriteStream(output_path);
await new Promise((resolve, reject) => {
res.body.pipe(fileStream);
res.body.on("error", (err) => {
reject(err);
});
res.body.on('data', function (chunk) {
bar.tick(chunk.length);
if (file_label) bar.tick(chunk.length);
});
fileStream.on("finish", function() {
resolve();
@@ -414,10 +417,11 @@ exports.injectArgs = (original_args, new_args) => {
if (CONSTS.YTDL_ARGS_WITH_VALUES.has(new_arg)) {
if (original_args.includes(new_arg)) {
const original_index = original_args.indexOf(new_arg);
original_args.splice(original_index, 2);
updated_args.splice(original_index, 2);
}
updated_args.push(new_arg, new_args[i + 1]);
i++; // we need to skip the arg value on the next loop
} else {
if (!original_args.includes(new_arg)) {
updated_args.push(new_arg);
@@ -518,6 +522,51 @@ exports.convertFlatObjectToNestedObject = (obj) => {
return result;
}
exports.getDirectoriesInDirectory = async (basePath) => {
try {
const files = await fs.readdir(basePath, { withFileTypes: true });
return files
.filter((file) => file.isDirectory())
.map((file) => path.join(basePath, file.name));
} catch (err) {
return [];
}
}
exports.parseOutputJSON = (output, err) => {
let split_output = [];
// const output_jsons = [];
if (err && !output) {
if (!err.stderr.includes('This video is unavailable') && !err.stderr.includes('Private video')) {
return null;
}
logger.info('An error was encountered with at least one video, backup method will be used.')
try {
split_output = err.stdout.split(/\r\n|\r|\n/);
} catch (e) {
logger.error('Backup method failed. See error below:');
logger.error(e);
return null;
}
} else if (output.length === 0 || (output.length === 1 && output[0].length === 0)) {
// output is '' or ['']
return [];
} else {
for (const output_item of output) {
// we have to do this because sometimes there will be leading characters before the actual json
const start_idx = output_item.indexOf('{"');
const clean_output = output_item.slice(start_idx, output_item.length);
split_output.push(clean_output);
}
}
try {
return split_output.map(split_output_str => JSON.parse(split_output_str));
} catch(e) {
return null;
}
}
// objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {

View File

@@ -5,6 +5,7 @@ const logger = require('./logger');
const utils = require('./utils');
const CONSTS = require('./consts');
const config_api = require('./config.js');
const youtubedl = require('youtube-dl');
const OUTDATED_VERSION = "2020.00.00";
@@ -25,6 +26,15 @@ const download_sources = {
}
}
exports.runYoutubeDL = async (url, args, downloadMethod = youtubedl.exec) => {
return new Promise(resolve => {
downloadMethod(url, args, {maxBuffer: Infinity}, async function(err, output) {
const parsed_output = utils.parseOutputJSON(output, err);
resolve({parsed_output, err});
});
});
}
exports.checkForYoutubeDLUpdate = async () => {
return new Promise(async resolve => {
const default_downloader = config_api.getConfigItem('ytdl_default_downloader');

View File

@@ -21,4 +21,4 @@ version: 0.1.0
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
# It is recommended to use it with quotes.
appVersion: "4.3.1"
appVersion: "4.3.2"

View File

@@ -18,6 +18,7 @@ services:
- "8998:17442"
image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db:
# If you are using a Raspberry Pi, use mongo:4.4.18
image: mongo:4
logging:
driver: "none"

View File

@@ -3,13 +3,26 @@ import requests
import shutil
import os
import re
import sys
from collections import OrderedDict
from github import Github
machine = platform.machine()
def isARM():
return True if machine.startswith('arm') else False
# https://stackoverflow.com/questions/45125516/possible-values-for-uname-m
MACHINES_TO_ZIP = OrderedDict([
("x86_64", "Linux-x64"),
("aarch64", "LinuxArm64"),
("armv8", "LinuxArm64"),
("arm", "LinuxArm"),
("AMD64", "Windows-x64")
])
def getZipName():
for possibleMachine, possibleZipName in MACHINES_TO_ZIP.items():
if possibleMachine in machine:
return possibleZipName
def getLatestFileInRepo(repo, search_string):
# Create an unauthenticated instance of the Github object
@@ -46,8 +59,11 @@ def getLatestFileInRepo(repo, search_string):
print(f'No release found with {search_string}')
def getLatestCLIRelease():
isArm = isARM()
searchString = r'.*CLI.*' + "LinuxArm.zip" if isArm else "Linux-x64.zip"
zipName = getZipName()
if not zipName:
print(f"GetTwitchDownloader.py could not get valid path for '{machine}'. Exiting...")
sys.exit(1)
searchString = r'.*CLI.*' + zipName
getLatestFileInRepo("lay295/TwitchDownloader", searchString)
getLatestCLIRelease()

View File

@@ -0,0 +1,39 @@
#!/bin/sh
# THANK YOU TALULAH (https://github.com/nottalulah) for your help in figuring this out
# and also optimizing some code with this commit.
# xoxo :D
case $(uname -m) in
x86_64)
ARCH=Linux-x64;;
aarch64)
ARCH=LinuxArm64;;
armhf)
ARCH=LinuxArm;;
armv7)
ARCH=LinuxArm;;
armv7l)
ARCH=LinuxArm;;
*)
echo "Unsupported architecture: $(uname -m)"
exit 1
esac
echo "(INFO) Architecture detected: $ARCH"
echo "(1/5) READY - Install unzip"
apt-get update && apt-get -y install unzip curl jq libicu70
VERSION=$(curl --silent "https://api.github.com/repos/lay295/TwitchDownloader/releases" | jq -r --arg arch "$ARCH" '[.[] | select(.assets | length > 0) | select(.assets[].name | contains("CLI") and contains($arch))] | max_by(.published_at) | .tag_name')
echo "(2/5) DOWNLOAD - Acquire twitchdownloader"
curl -o twitchdownloader.zip \
--connect-timeout 5 \
--max-time 120 \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
-L "https://github.com/lay295/TwitchDownloader/releases/download/$VERSION/TwitchDownloaderCLI-$VERSION-$ARCH.zip"
unzip twitchdownloader.zip
chmod +x TwitchDownloaderCLI
echo "(3/5) Smoke test"
./TwitchDownloaderCLI --help
cp ./TwitchDownloaderCLI /usr/local/bin/TwitchDownloaderCLI

View File

@@ -30,7 +30,7 @@ curl -o ffmpeg.txz \
--retry 5 \
--retry-delay 0 \
--retry-max-time 40 \
"https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-${ARCH}-static.tar.xz"
"https://johnvansickle.com/ffmpeg/old-releases/ffmpeg-5.1.1-${ARCH}-static.tar.xz"
mkdir /tmp/ffmpeg
tar xf ffmpeg.txz -C /tmp/ffmpeg
echo "(3/5) CLEANUP - Remove temp dependencies from ffmpeg obtain layer"

25270
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "youtube-dl-material",
"version": "4.3.1",
"version": "4.3.2",
"license": "MIT",
"scripts": {
"ng": "ng",
@@ -21,60 +21,59 @@
},
"private": true,
"dependencies": {
"@angular-devkit/core": "^15.0.1",
"@angular/animations": "^15.0.1",
"@angular/cdk": "^15.0.0",
"@angular/common": "^15.0.1",
"@angular/compiler": "^15.0.1",
"@angular/core": "^15.0.1",
"@angular/forms": "^15.0.1",
"@angular/localize": "^15.0.1",
"@angular/material": "^15.0.0",
"@angular/platform-browser": "^15.0.1",
"@angular/platform-browser-dynamic": "^15.0.1",
"@angular/router": "^15.0.1",
"@angular-devkit/core": "^16.0.3",
"@angular/animations": "^16.0.3",
"@angular/cdk": "^16.0.2",
"@angular/common": "^16.0.3",
"@angular/compiler": "^16.0.3",
"@angular/core": "^16.0.3",
"@angular/forms": "^16.0.3",
"@angular/localize": "^16.0.3",
"@angular/material": "^16.0.2",
"@angular/platform-browser": "^16.0.3",
"@angular/platform-browser-dynamic": "^16.0.3",
"@angular/router": "^16.0.3",
"@fontsource/material-icons": "^4.5.4",
"@ngneat/content-loader": "^5.0.0",
"@ngneat/content-loader": "^7.0.0",
"@tzahi12345/twitch-emoticons": "^1.0.9",
"@videogular/ngx-videogular": "^6.0.0",
"core-js": "^2.4.1",
"crypto-js": "^4.1.1",
"file-saver": "^2.0.2",
"filesize": "^6.1.0",
"fingerprintjs2": "^2.1.0",
"filesize": "^10.0.7",
"fs-extra": "^10.0.0",
"material-icons": "^1.10.8",
"nan": "^2.14.1",
"ngx-avatars": "^1.4.1",
"ngx-file-drop": "^13.0.0",
"ngx-avatars": "^1.6.1",
"ngx-file-drop": "^15.0.0",
"rxjs": "^6.6.3",
"rxjs-compat": "^6.0.0-rc.0",
"rxjs-compat": "^6.6.7",
"tslib": "^2.0.0",
"typescript": "~4.8.4",
"typescript": "~5.0.4",
"xliff-to-json": "^1.0.4",
"zone.js": "~0.11.4"
"zone.js": "~0.13.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^15.0.1",
"@angular/cli": "^15.0.1",
"@angular/compiler-cli": "^15.0.1",
"@angular/language-service": "^15.0.1",
"@angular-devkit/build-angular": "^16.0.3",
"@angular/cli": "^16.0.3",
"@angular/compiler-cli": "^16.0.3",
"@angular/language-service": "^16.0.3",
"@types/core-js": "^2.5.2",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "~3.6.0",
"@types/jasmine": "^4.3.1",
"@types/node": "^12.11.1",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"ajv": "^7.2.4",
"codelyzer": "^6.0.0",
"electron": "^19.1.9",
"eslint": "^7.32.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",
"karma": "~6.3.16",
"karma": "~6.4.2",
"karma-chrome-launcher": "~3.1.0",
"karma-cli": "~1.0.1",
"karma-coverage-istanbul-reporter": "~3.0.2",
"karma-jasmine": "~4.0.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "^1.5.0",
"openapi-typescript-codegen": "^0.23.0",
"protractor": "~7.0.0",

View File

@@ -87,7 +87,6 @@ export type { LoginResponse } from './models/LoginResponse';
export type { Notification } from './models/Notification';
export { NotificationAction } from './models/NotificationAction';
export { NotificationType } from './models/NotificationType';
export type { PinLoginResponse } from './models/PinLoginResponse';
export type { Playlist } from './models/Playlist';
export type { RegisterRequest } from './models/RegisterRequest';
export type { RegisterResponse } from './models/RegisterResponse';
@@ -96,7 +95,6 @@ export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest';
export { Schedule } from './models/Schedule';
export type { SetConfigRequest } from './models/SetConfigRequest';
export type { SetNotificationsToReadRequest } from './models/SetNotificationsToReadRequest';
export type { SetPinRequest } from './models/SetPinRequest';
export type { SharingToggle } from './models/SharingToggle';
export type { Sort } from './models/Sort';
export type { SubscribeRequest } from './models/SubscribeRequest';
@@ -106,6 +104,7 @@ export type { SubscriptionRequestData } from './models/SubscriptionRequestData';
export type { SuccessObject } from './models/SuccessObject';
export type { TableInfo } from './models/TableInfo';
export type { Task } from './models/Task';
export { TaskType } from './models/TaskType';
export type { TestConnectionStringRequest } from './models/TestConnectionStringRequest';
export type { TestConnectionStringResponse } from './models/TestConnectionStringResponse';
export type { TransferDBRequest } from './models/TransferDBRequest';

View File

@@ -2,6 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type GetTaskRequest = {
task_key: string;
task_key: TaskType;
};

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type PinLoginResponse = {
pin_token: string;
};

View File

@@ -1,7 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type SetPinRequest = {
new_pin: string;
};

View File

@@ -2,8 +2,11 @@
/* tslint:disable */
/* eslint-disable */
import type { Schedule } from './Schedule';
import type { TaskType } from './TaskType';
export type Task = {
key: string;
key: TaskType;
title?: string;
last_ran: number;
last_confirmed: number;
@@ -11,6 +14,6 @@ export type Task = {
confirming: boolean;
data: any;
error: string;
schedule: any;
schedule: Schedule;
options?: any;
};

View File

@@ -0,0 +1,14 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum TaskType {
BACKUP_LOCAL_DB = 'backup_local_db',
MISSING_FILES_CHECK = 'missing_files_check',
MISSING_DB_RECORDS = 'missing_db_records',
DUPLICATE_FILES_CHECK = 'duplicate_files_check',
YOUTUBEDL_UPDATE_CHECK = 'youtubedl_update_check',
DELETE_OLD_FILES = 'delete_old_files',
IMPORT_LEGACY_ARCHIVES = 'import_legacy_archives',
REBUILD_DATABASE = 'rebuild_database',
}

View File

@@ -2,7 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type UpdateTaskDataRequest = {
task_key: string;
task_key: TaskType;
new_data: any;
};

View File

@@ -2,7 +2,9 @@
/* tslint:disable */
/* eslint-disable */
import type { TaskType } from './TaskType';
export type UpdateTaskOptionsRequest = {
task_key: string;
task_key: TaskType;
new_options: any;
};

View File

@@ -3,8 +3,9 @@
/* eslint-disable */
import type { Schedule } from './Schedule';
import type { TaskType } from './TaskType';
export type UpdateTaskScheduleRequest = {
task_key: string;
task_key: TaskType;
new_schedule: Schedule;
};

View File

@@ -9,4 +9,5 @@ export enum UserPermission {
SHARING = 'sharing',
ADVANCED_DOWNLOAD = 'advanced_download',
DOWNLOADS_MANAGER = 'downloads_manager',
TASKS_MANAGER = 'tasks_manager',
}

View File

@@ -17,11 +17,11 @@
</mat-menu>
<button [matMenuTriggerFor]="menuSettings" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #menuSettings="matMenu">
<button class="top-menu-button" (click)="openProfileDialog()" *ngIf="postsService.isLoggedIn" mat-menu-item>
<button class="top-menu-button" (click)="openProfileDialog()" mat-menu-item>
<mat-icon>person</mat-icon>
<span i18n="Profile menu label">Profile</span>
</button>
<button *ngIf="postsService.config && postsService.config.Downloader.use_youtubedl_archive" class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
<button *ngIf="!postsService.config?.Advanced.multi_user_mode || postsService.isLoggedIn" class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
<mat-icon>topic</mat-icon>
<span i18n="Archives menu label">Archives</span>
</button>
@@ -51,7 +51,7 @@
<a *ngIf="postsService.config && postsService.hasPermission('tasks_manager')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/tasks'><ng-container i18n="Navigation menu Tasks Page title">Tasks</ng-container></a>
<ng-container *ngIf="postsService.config && postsService.hasPermission('settings')">
<mat-divider></mat-divider>
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null; pinConfirm('/settings')" [routerLink]="!postsService.config.Extra.use_pin ? '/settings' : null"><ng-container i18n="Settings menu label">Settings</ng-container></a>
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/settings'><ng-container i18n="Settings menu label">Settings</ng-container></a>
</ng-container>
<ng-container *ngIf="postsService.config && allowSubscriptions && postsService.subscriptions && postsService.hasPermission('subscriptions')">
<mat-divider *ngIf="postsService.subscriptions.length > 0"></mat-divider>

View File

@@ -22,7 +22,6 @@ import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-p
import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
import { NotificationsComponent } from './components/notifications/notifications.component';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
import { PinLoginComponent } from './dialogs/pin-login-dialog/pin-login-dialog.component';
@Component({
selector: 'app-root',
@@ -85,8 +84,8 @@ export class AppComponent implements OnInit, AfterViewInit {
this.postsService.open_create_default_admin_dialog.subscribe(open => {
if (open) {
const dialogRef = this.dialog.open(SetDefaultAdminDialogComponent);
dialogRef.afterClosed().subscribe(success => {
if (success) {
dialogRef.afterClosed().subscribe(res => {
if (!res || !res['user']) {
if (this.router.url !== '/login') { this.router.navigate(['/login']); }
} else {
console.error('Failed to create default admin account. See logs for details.');
@@ -215,16 +214,6 @@ export class AppComponent implements OnInit, AfterViewInit {
});
}
pinConfirm(route: string): void {
if (!this.postsService.config.Extra.use_pin) return;
const dialogRef = this.dialog.open(PinLoginComponent);
dialogRef.afterClosed().subscribe(success => {
if (success) {
this.router.navigate([route]);
}
});
}
notificationCountUpdate(new_count: number): void {
this.notification_count = new_count;
}

View File

@@ -34,6 +34,7 @@ import { MatBadgeModule } from '@angular/material/badge';
import { DragDropModule } from '@angular/cdk/drag-drop';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { TextFieldModule } from '@angular/cdk/text-field';
import { ScrollingModule } from '@angular/cdk/scrolling';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
@@ -95,8 +96,6 @@ import { GenerateRssUrlComponent } from './dialogs/generate-rss-url/generate-rss
import { SortPropertyComponent } from './components/sort-property/sort-property.component';
import { OnlyNumberDirective } from './directives/only-number.directive';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
import { SetPinDialogComponent } from './dialogs/set-pin-dialog/set-pin-dialog.component';
import { PinLoginComponent } from './dialogs/pin-login-dialog/pin-login-dialog.component';
registerLocaleData(es, 'es');
@@ -149,9 +148,7 @@ registerLocaleData(es, 'es');
GenerateRssUrlComponent,
SortPropertyComponent,
OnlyNumberDirective,
ArchiveViewerComponent,
SetPinDialogComponent,
PinLoginComponent
ArchiveViewerComponent
],
imports: [
CommonModule,
@@ -193,6 +190,7 @@ registerLocaleData(es, 'es');
DragDropModule,
ClipboardModule,
TextFieldModule,
ScrollingModule,
NgxFileDropModule,
AvatarModule,
ContentLoaderModule,

View File

@@ -25,7 +25,7 @@
-webkit-line-clamp: 2;
}
::ng-deep .ngx-file-drop__content {
:host ::ng-deep .ngx-file-drop__content {
width: 100%;
top: -12px;
position: relative;

View File

@@ -10,8 +10,8 @@
<!-- Title Column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<mat-header-cell *matHeaderCellDef mat-sort-header style="flex: 2"> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element" style="flex: 2">
<span class="one-line" [matTooltip]="element.title ? element.title : null">
{{element.title}}
</span>
@@ -31,41 +31,47 @@
</mat-cell>
</ng-container>
<!-- Stage Column -->
<ng-container matColumnDef="step_index">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Stage">Stage</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> {{STEP_INDEX_TO_LABEL[element.step_index]}} </mat-cell>
</ng-container>
<!-- Progress Column -->
<ng-container matColumnDef="percent_complete">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Progress">Progress</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<ng-container *ngIf="!element.error && element.step_index !== 2">
{{STEP_INDEX_TO_LABEL[element.step_index]}}
</ng-container>
<ng-container *ngIf="!element.error && element.step_index === 2">
<ng-container *ngIf="element.percent_complete">
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
{{+(element.percent_complete) > 100 ? '100' : element.percent_complete}}%
</ng-container>
<ng-container *ngIf="!element.percent_complete">
N/A
N/A
</ng-container>
</ng-container>
<ng-container *ngIf="element.error" i18n="Error">Error</ng-container>
</mat-cell>
</ng-container>
<!-- Actions Column -->
<ng-container matColumnDef="actions">
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<div>
<ng-container *ngIf="!element.finished">
<button (click)="pauseDownload(element.uid)" *ngIf="!element.paused || !element.finished_step" [disabled]="element.paused && !element.finished_step" mat-icon-button matTooltip="Pause" i18n-matTooltip="Pause"><mat-spinner [diameter]="28" *ngIf="element.paused && !element.finished_step" class="icon-button-spinner"></mat-spinner><mat-icon>pause</mat-icon></button>
<button (click)="resumeDownload(element.uid)" *ngIf="element.paused && element.finished_step" mat-icon-button matTooltip="Resume" i18n-matTooltip="Resume"><mat-icon>play_arrow</mat-icon></button>
<button *ngIf="false && !element.paused" (click)="cancelDownload(element.uid)" mat-icon-button matTooltip="Cancel" i18n-matTooltip="Cancel"><mat-icon>cancel</mat-icon></button>
<mat-header-cell *matHeaderCellDef [ngStyle]="{flex: actionsFlex}"> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element" [ngStyle]="{flex: actionsFlex}">
<div *ngIf="!minimizeButtons">
<ng-container *ngFor="let downloadAction of downloadActions">
<span class="button-span">
<mat-spinner [diameter]="28" *ngIf="downloadAction.loading && downloadAction.loading(element)" class="icon-button-spinner"></mat-spinner>
<button *ngIf="downloadAction.show(element)" (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" [matTooltip]="downloadAction.tooltip" mat-icon-button><mat-icon>{{downloadAction.icon}}</mat-icon></button>
</span>
</ng-container>
<ng-container *ngIf="element.finished">
<button *ngIf="!element.error" (click)="watchContent(element)" mat-icon-button matTooltip="Watch content" i18n-matTooltip="Watch content"><mat-icon>smart_display</mat-icon></button>
<button *ngIf="element.error" (click)="showError(element)" mat-icon-button matTooltip="Show error" i18n-matTooltip="Show error"><mat-icon>warning</mat-icon></button>
<button (click)="restartDownload(element.uid)" mat-icon-button matTooltip="Restart" i18n-matTooltip="Restart"><mat-icon>restart_alt</mat-icon></button>
</ng-container>
<button *ngIf="element.finished || element.paused" (click)="clearDownload(element.uid)" mat-icon-button matTooltip="Clear" i18n-matTooltip="Clear"><mat-icon>delete</mat-icon></button>
</div>
<div *ngIf="minimizeButtons">
<button [matMenuTriggerFor]="download_actions" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #download_actions="matMenu">
<ng-container *ngFor="let downloadAction of downloadActions">
<button *ngIf="downloadAction.show(element)" (click)="downloadAction.action(element)" [disabled]="downloadAction.loading && downloadAction.loading(element)" mat-menu-item>
<mat-icon>{{downloadAction.icon}}</mat-icon>
<span>{{downloadAction.tooltip}}</span>
</button>
</ng-container>
</mat-menu>
</div>
</mat-cell>
</ng-container>
@@ -80,9 +86,9 @@
</mat-paginator>
</div>
<div *ngIf="!uids" class="downloads-action-button-div">
<button [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
<button style="margin-left: 10px;" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
<button color="warn" style="margin-left: 10px;" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear downloads</ng-container></button>
<button class="downloads-action-button" [disabled]="!running_download_exists" mat-stroked-button (click)="pauseAllDownloads()"><ng-container i18n="Pause all downloads">Pause all downloads</ng-container></button>
<button class="downloads-action-button" [disabled]="!paused_download_exists" mat-stroked-button (click)="resumeAllDownloads()"><ng-container i18n="Resume all downloads">Resume all downloads</ng-container></button>
<button class="downloads-action-button" color="warn" mat-stroked-button (click)="clearDownloadsByType()"><ng-container i18n="Clear downloads">Clear downloads</ng-container></button>
</div>
</div>

View File

@@ -10,15 +10,23 @@ mat-header-cell, mat-cell {
.icon-button-spinner {
position: absolute;
top: 7px;
left: 6px;
top: -13px;
left: 10px;
}
.button-span {
position: relative;;
}
.downloads-action-button-div {
margin-top: 10px;
margin-left: 5px;
}
.downloads-action-button {
margin-top: 10px;
margin-right: 10px;
}
.rounded-top {
border-radius: 16px 16px 0px 0px !important;
}

View File

@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChild, Input, EventEmitter, HostListener } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { trigger, transition, animateChild, stagger, query, style, animate } from '@angular/animations';
import { Router } from '@angular/router';
@@ -13,31 +13,7 @@ import { Download } from 'api-types';
@Component({
selector: 'app-downloads',
templateUrl: './downloads.component.html',
styleUrls: ['./downloads.component.scss'],
animations: [
// nice stagger effect when showing existing elements
trigger('list', [
transition(':enter', [
// child animation selector + stagger
query('@items',
stagger(100, animateChild()), { optional: true }
)
]),
]),
trigger('items', [
// cubic-bezier for a tiny bouncing feel
transition(':enter', [
style({ transform: 'scale(0.5)', opacity: 0 }),
animate('500ms cubic-bezier(.8,-0.6,0.2,1.5)',
style({ transform: 'scale(1)', opacity: 1 }))
]),
transition(':leave', [
style({ transform: 'scale(1)', opacity: 1, height: '*' }),
animate('1s cubic-bezier(.8,-0.6,0.2,1.5)',
style({ transform: 'scale(0.5)', opacity: 0, height: '0px', margin: '0px' }))
]),
])
],
styleUrls: ['./downloads.component.scss']
})
export class DownloadsComponent implements OnInit, OnDestroy {
@@ -62,13 +38,73 @@ export class DownloadsComponent implements OnInit, OnDestroy {
3: $localize`Complete`
}
displayedColumns: string[] = ['timestamp_start', 'title', 'step_index', 'sub_name', 'percent_complete', 'actions'];
actionsFlex = 2;
minimizeButtons = false;
displayedColumnsBig: string[] = ['timestamp_start', 'title', 'sub_name', 'percent_complete', 'actions'];
displayedColumnsSmall: string[] = ['title', 'percent_complete', 'actions'];
displayedColumns: string[] = this.displayedColumnsBig;
dataSource = null; // new MatTableDataSource<Download>();
// The purpose of this is to reduce code reuse for displaying these actions as icons or in a menu
downloadActions: DownloadAction[] = [
{
tooltip: $localize`Watch content`,
action: (download: Download) => this.watchContent(download),
show: (download: Download) => download.finished && !download.error,
icon: 'smart_display'
},
{
tooltip: $localize`Show error`,
action: (download: Download) => this.showError(download),
show: (download: Download) => download.finished && !!download.error,
icon: 'warning'
},
{
tooltip: $localize`Restart`,
action: (download: Download) => this.restartDownload(download),
show: (download: Download) => download.finished,
icon: 'restart_alt'
},
{
tooltip: $localize`Pause`,
action: (download: Download) => this.pauseDownload(download),
show: (download: Download) => !download.finished && (!download.paused || !download.finished_step),
icon: 'pause',
loading: (download: Download) => download.paused && !download.finished_step
},
{
tooltip: $localize`Resume`,
action: (download: Download) => this.resumeDownload(download),
show: (download: Download) => !download.finished && download.paused && download.finished_step,
icon: 'play_arrow'
},
{
tooltip: $localize`Cancel`,
action: (download: Download) => this.cancelDownload(download),
show: (download: Download) => false && !download.finished && !download.paused, // TODO: add possibility to cancel download
icon: 'cancel'
},
{
tooltip: $localize`Clear`,
action: (download: Download) => this.clearDownload(download),
show: (download: Download) => download.finished || download.paused,
icon: 'delete'
}
]
downloads_retrieved = false;
innerWidth: number;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@HostListener('window:resize', ['$event'])
onResize(): void {
this.innerWidth = window.innerWidth;
this.recalculateColumns();
}
sort_downloads = (a: Download, b: Download): number => {
const result = b.timestamp_start - a.timestamp_start;
return result;
@@ -77,6 +113,10 @@ export class DownloadsComponent implements OnInit, OnDestroy {
constructor(public postsService: PostsService, private router: Router, private dialog: MatDialog, private clipboard: Clipboard) { }
ngOnInit(): void {
// Remove sub name as it's not necessary for one-off downloads
if (this.uids) this.displayedColumnsBig = this.displayedColumnsBig.filter(col => col !== 'sub_name');
this.innerWidth = window.innerWidth;
this.recalculateColumns();
if (this.postsService.initialized) {
this.getCurrentDownloadsRecurring();
} else {
@@ -164,8 +204,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
pauseDownload(download_uid: string): void {
this.postsService.pauseDownload(download_uid).subscribe(res => {
pauseDownload(download: Download): void {
this.postsService.pauseDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
}
@@ -180,8 +220,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
resumeDownload(download_uid: string): void {
this.postsService.resumeDownload(download_uid).subscribe(res => {
resumeDownload(download: Download): void {
this.postsService.resumeDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to resume download! See server logs for more info.`);
}
@@ -196,8 +236,8 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
restartDownload(download_uid: string): void {
this.postsService.restartDownload(download_uid).subscribe(res => {
restartDownload(download: Download): void {
this.postsService.restartDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`);
} else {
@@ -208,16 +248,16 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
cancelDownload(download_uid: string): void {
this.postsService.cancelDownload(download_uid).subscribe(res => {
cancelDownload(download: Download): void {
this.postsService.cancelDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to cancel download! See server logs for more info.`);
}
});
}
clearDownload(download_uid: string): void {
this.postsService.clearDownload(download_uid).subscribe(res => {
clearDownload(download: Download): void {
this.postsService.clearDownload(download['uid']).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
}
@@ -257,6 +297,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}
showError(download: Download): void {
console.log(download)
const copyToClipboardEmitter = new EventEmitter<boolean>();
this.dialog.open(ConfirmDialogComponent, {
data: {
@@ -276,4 +317,22 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}
});
}
recalculateColumns() {
if (this.innerWidth < 650) this.displayedColumns = this.displayedColumnsSmall;
else this.displayedColumns = this.displayedColumnsBig;
this.actionsFlex = this.uids || this.innerWidth < 800 ? 1 : 2;
if (this.innerWidth < 800 && !this.uids || this.innerWidth < 1100 && this.uids) this.minimizeButtons = true;
else this.minimizeButtons = false;
}
}
interface DownloadAction {
tooltip: string,
action: (download: Download) => void,
show: (download: Download) => boolean,
icon: string,
loading?: (download: Download) => boolean
}

View File

@@ -1,30 +1,32 @@
<div class="card-radius mat-elevation-z2" *ngFor="let notification of notifications; let i = index;">
<mat-card class="notification-card card-radius">
<mat-card-header>
<mat-card-subtitle>
<div>
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
</div>
</mat-card-subtitle>
<mat-card-title>
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]">
{{NOTIFICATION_PREFIX[notification.type]}}
<cdk-virtual-scroll-viewport itemSize="50" class="viewport" minBufferPx="1200" maxBufferPx="1200">
<div #notification_parent class="notification-card-parent card-radius mat-elevation-z2" *cdkVirtualFor="let notification of notifications; let i = index;">
<mat-card class="notification-card card-radius">
<mat-card-header>
<mat-card-subtitle>
<div>
<span class="notification-timestamp">{{notification.timestamp * 1000 | date:'short'}}</span>
</div>
</mat-card-subtitle>
<mat-card-title>
<ng-container *ngIf="NOTIFICATION_PREFIX[notification.type]">
{{NOTIFICATION_PREFIX[notification.type]}}
</ng-container>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]">
<div style="word-break: break-word">
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
</div>
</ng-container>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<ng-container *ngIf="NOTIFICATION_SUFFIX_KEY[notification.type]">
<div style="word-break: break-word">
{{notification['data'][NOTIFICATION_SUFFIX_KEY[notification.type]]}}
</div>
</ng-container>
</mat-card-content>
<mat-card-actions *ngIf="notification.actions?.length > 0">
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
<span *ngFor="let action of notification.actions">
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
</span>
</mat-card-actions>
<span *ngIf="!notification.read" class="dot"></span>
</mat-card>
</div>
</mat-card-content>
<mat-card-actions class="notification-actions" *ngIf="notification.actions?.length > 0">
<button matTooltip="Remove" i18n-matTooltip="Remove" (click)="emitDeleteNotification(notification.uid)" mat-icon-button><mat-icon>close</mat-icon></button>
<span *ngFor="let action of notification.actions">
<button [matTooltip]="NOTIFICATION_ACTION_TO_STRING[action]" (click)="emitNotificationAction(notification, action)" mat-icon-button><mat-icon>{{NOTIFICATION_ICON[action]}}</mat-icon></button>
</span>
</mat-card-actions>
<span *ngIf="!notification.read" class="dot"></span>
</mat-card>
</div>
</cdk-virtual-scroll-viewport>

View File

@@ -13,12 +13,21 @@
font-size: 14px;
}
.notification-card-parent {
margin: 5px;
}
.notification-card {
margin-top: 5px;
}
.notification-actions {
margin-top: auto;
}
.card-radius {
border-radius: 12px;
height: 166px;
}
.dot {
@@ -30,4 +39,8 @@
position: absolute;
right: 8px;
top: 8px;
}
}
.viewport {
height: 100%;
}

View File

@@ -4,7 +4,10 @@
}
.notifications-list-parent {
max-height: 70vh;
overflow-y: auto;
padding: 0px 10px 10px 10px;
}
.notifications-list {
display: block
}

View File

@@ -4,7 +4,7 @@
<mat-chip-listbox [value]="selectedFilters" [multiple]="true" (change)="selectedFiltersChanged($event)">
<mat-chip-option *ngFor="let filter of notificationFilters | keyvalue: originalOrder" [value]="filter.key" [selected]="selectedFilters.includes(filter.key)" color="accent">{{filter.value.label}}</mat-chip-option>
</mat-chip-listbox>
<app-notifications-list (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
<app-notifications-list class="notifications-list" [style.height]="list_height" (notificationAction)="notificationAction($event)" (deleteNotification)="deleteNotification($event)" [notifications]="filtered_notifications"></app-notifications-list>
</div>
<button style="margin: 10px 0px 2px 10px;" *ngIf="notifications?.length > 0" color="warn" (click)="deleteAllNotifications()" mat-stroked-button>Remove all</button>
</div>

View File

@@ -14,6 +14,7 @@ export class NotificationsComponent implements OnInit {
notifications: Notification[] = null;
filtered_notifications: Notification[] = null;
list_height = '65vh';
@Output() notificationCount = new EventEmitter<number>();
@@ -110,6 +111,8 @@ export class NotificationsComponent implements OnInit {
filterNotifications(): void {
this.filtered_notifications = this.notifications.filter(notification => this.selectedFilters.length === 0 || this.selectedFilters.includes(notification.type));
// We need to do this to get the virtual scroll component to have an appropriate height
this.calculateListHeight();
}
selectedFiltersChanged(event: MatChipListboxChange): void {
@@ -117,6 +120,12 @@ export class NotificationsComponent implements OnInit {
this.filterNotifications();
}
calculateListHeight() {
const avgHeight = 166;
const calcHeight = this.filtered_notifications.length * avgHeight;
this.list_height = calcHeight > window.innerHeight*0.65 ? '65vh' : `${calcHeight}px`;
}
originalOrder = (): number => {
return 0;
}

View File

@@ -88,7 +88,7 @@
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
<mat-selection-list *ngIf="!normal_files_received">
<mat-list-option *ngFor="let file of paged_data">
<content-loader class="list-ghosts" [primaryColor]="postsService.theme.ghost_primary" [secondaryColor]="postsService.theme.ghost_secondary" [width]="250" [height]="8"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="8" /></content-loader>
<content-loader class="list-ghosts" [backgroundColor]="postsService.theme.ghost_primary" [foregroundColor]="postsService.theme.ghost_secondary" viewBox="0 0 250 8"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="8" /></content-loader>
</mat-list-option>
</mat-selection-list>
</ng-container>

View File

@@ -76,8 +76,9 @@ export class RecentVideosComponent implements OnInit {
constructor(public postsService: PostsService, private router: Router) {
// get cached file count
if (localStorage.getItem('cached_file_count')) {
this.cached_file_count = +localStorage.getItem('cached_file_count') <= 10 ? +localStorage.getItem('cached_file_count') : 10;
const sub_id_appendix = this.sub_id ? `_${this.sub_id}` : ''
if (localStorage.getItem(`cached_file_count${sub_id_appendix}`)) {
this.cached_file_count = +localStorage.getItem(`cached_file_count${sub_id_appendix}`) <= 10 ? +localStorage.getItem(`cached_file_count${sub_id_appendix}`) : 10;
this.loading_files = Array(this.cached_file_count).fill(0);
}
@@ -379,8 +380,8 @@ export class RecentVideosComponent implements OnInit {
fileSelectionChanged(event: MatSelectionListChange): void {
// TODO: make sure below line is possible (_selected is private)
const adding = event.option['_selected'];
const value = event.option.value;
const adding = event.options[0]['_selected'];
const value = event.options[0].value;
if (adding) {
this.selected_data.push(value.uid);
this.selected_data_objs.push(value);

View File

@@ -1,6 +1,6 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { Task } from 'api-types';
import { Task, TaskType } from 'api-types';
import { PostsService } from 'app/posts.services';
@Component({
@@ -9,7 +9,7 @@ import { PostsService } from 'app/posts.services';
styleUrls: ['./task-settings.component.scss']
})
export class TaskSettingsComponent {
task_key: string;
task_key: TaskType;
new_options = {};
task: Task = null;

View File

@@ -49,7 +49,7 @@
<mat-header-cell *matHeaderCellDef> <ng-container i18n="Actions">Actions</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<div class="container">
<div class="row justify-content-center">
<div class="row justify-content-start">
<div *ngIf="element.data?.uids?.length > 0 || (!element.data?.uids && element.data)" class="col-12 mt-2" style="display: flex; justify-content: center;">
<ng-container>
<button (click)="confirmTask(element.key)" [disabled]="element.running || element.confirming" mat-stroked-button>
@@ -63,7 +63,7 @@
<ng-container i18n="Update binary to">Update binary to:</ng-container>&nbsp;{{element.data}}
</ng-container>
<ng-container *ngIf="element.key == 'delete_old_files'">
<ng-container i18n="Delete old files">Delete old files:</ng-container>&nbsp;{{element.data.uids.length}}
<ng-container i18n="Delete old files">Delete old files:</ng-container>&nbsp;{{element.data.files_to_remove.length}}
</ng-container>
</button>
</ng-container>

View File

@@ -31,6 +31,6 @@ mat-header-cell, mat-cell {
border-radius: 16px 16px 16px 16px !important;
}
::ng-deep mat-row {
:host ::ng-deep mat-row {
height: fit-content !important;
}

View File

@@ -7,7 +7,7 @@ import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialo
import { RestoreDbDialogComponent } from 'app/dialogs/restore-db-dialog/restore-db-dialog.component';
import { UpdateTaskScheduleDialogComponent } from 'app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component';
import { PostsService } from 'app/posts.services';
import { Task } from 'api-types';
import { Task, TaskType } from 'api-types';
import { TaskSettingsComponent } from '../task-settings/task-settings.component';
import { Clipboard } from '@angular/cdk/clipboard';
@@ -28,6 +28,15 @@ export class TasksComponent implements OnInit {
db_backups = [];
TASKS_TO_REQUIRE_DIALOG: { [key in TaskType]? : {dialogTitle: string, dialogText: string, submitText: string, warnSubmitColor: boolean}} = {
[TaskType.REBUILD_DATABASE]: {
dialogTitle: $localize`Rebuild database`,
dialogText: $localize`Are you sure you want to rebuild the database? All missing users, subscriptions, and files will be reimported. Note that if missing users are detected, they will be created with the password: 'password'. A backup of your current database will be created.`,
submitText: $localize`Rebuild database`,
warnSubmitColor: false
}
}
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
@@ -79,7 +88,29 @@ export class TasksComponent implements OnInit {
});
}
runTask(task_key: string): void {
runTask(task_key: TaskType): void {
const taskToRequireDialog = this.TASKS_TO_REQUIRE_DIALOG[task_key];
if (taskToRequireDialog) {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: taskToRequireDialog['dialogTitle'],
dialogText: taskToRequireDialog['dialogText'],
submitText: taskToRequireDialog['submitText'],
warnSubmitColor: taskToRequireDialog['warnSubmitColor']
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this._runTask(task_key);
}
});
return;
}
this._runTask(task_key);
}
_runTask(task_key: TaskType): void {
this.postsService.runTask(task_key).subscribe(res => {
this.getTasks();
this.getDBBackups();
@@ -91,7 +122,7 @@ export class TasksComponent implements OnInit {
});
}
confirmTask(task_key: string): void {
confirmTask(task_key: TaskType): void {
this.postsService.confirmTask(task_key).subscribe(res => {
this.getTasks();
if (res['success']) this.postsService.openSnackBar($localize`Successfully confirmed task!`);

View File

@@ -1,7 +1,7 @@
<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}}
{{chat.timestamp_str}} - <strong [style.color]="chat.user_color ? chat.user_color : null">{{chat.name}}</strong>: <span [innerHTML]="chat.message"></span>
{{last ? scrollToBottom() : ''}}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Component, ElementRef, Input, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
import { DatabaseFile } from 'api-types';
import { PostsService } from 'app/posts.services';
import { EmoteFetcher, EmoteObject, EmoteParser } from '@tzahi12345/twitch-emoticons';
@Component({
selector: 'app-twitch-chat',
@@ -13,6 +14,7 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
visible_chat = null;
chat_response_received = false;
downloading_chat = false;
got_emotes = false;
current_chat_index = null;
@@ -21,6 +23,9 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
scrollContainer = null;
fetcher: EmoteFetcher;
parser: EmoteParser;
@Input() db_file: DatabaseFile = null;
@Input() sub = null;
@Input() current_timestamp = null;
@@ -32,6 +37,7 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.getFullChat();
this.getEmotes();
}
ngOnDestroy(): void {
@@ -69,10 +75,12 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
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]);
const new_chat = this.full_chat[i];
if (new_chat['timestamp'] >= latest_chat_timestamp && new_chat['timestamp'] <= this.current_timestamp) {
new_chat['message'] = this.got_emotes ? this.parseChat(new_chat['message']) : new_chat['message'];
this.visible_chat.push(new_chat);
this.current_chat_index = i;
} else if (this.full_chat[i]['timestamp'] > this.current_timestamp) {
} else if (new_chat['timestamp'] > this.current_timestamp) {
break;
}
}
@@ -118,6 +126,29 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
this.chat_check_interval_obj = setInterval(() => this.addNewChatMessages(), this.CHAT_CHECK_INTERVAL_MS);
}
getEmotes() {
this.postsService.getTwitchEmotes(this.db_file['uid']).subscribe(res => {
const emotes = res['emotes'];
this.processEmotes(emotes);
});
}
processEmotes(emotes: EmoteObject[]) {
this.fetcher = new EmoteFetcher();
this.parser = new EmoteParser(this.fetcher, {
// Custom HTML format
template: `<img class="emote" alt="{name}" src="{link}">`,
// Match without :colons:
match: /(\w+)+?/g
});
this.fetcher.fromObject(emotes);
this.got_emotes = true;
}
parseChat(chat_message: string) {
return this.parser.parse(chat_message);
}
}
function binarySearch(arr, key, n) {

View File

@@ -5,7 +5,7 @@
<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 [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 250 30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></div>
<!-- The context menu trigger must be kept above the "more info" menu -->
<div style="visibility: hidden; position: fixed"
[style.left]="contextMenuPosition.x"
@@ -35,14 +35,9 @@
</ng-container>
</mat-menu>
<mat-divider></mat-divider>
<button *ngIf="file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item>
<mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container>
</button>
<button *ngIf="file_obj.sub_id && use_youtubedl_archive" (click)="emitDeleteFile(true)" mat-menu-item>
<mat-icon>delete_forever</mat-icon><ng-container i18n="Delete forever subscription video button">Delete and don't download again</ng-container>
</button>
<button *ngIf="file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item><mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container></button>
<button *ngIf="!file_obj.sub_id" (click)="emitDeleteFile()" mat-menu-item><mat-icon>delete</mat-icon><ng-container i18n="Delete video button">Delete</ng-container></button>
<button *ngIf="!file_obj.sub_id && use_youtubedl_archive" (click)="emitDeleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and don't download again">Delete and don't download again</ng-container></button>
<button *ngIf="file_obj.sub_id || use_youtubedl_archive" (click)="emitDeleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and don't download again">Delete and don't download again</ng-container></button>
</ng-container>
<ng-container *ngIf="is_playlist && !loading">
<button (click)="emitEditPlaylist()" mat-menu-item><mat-icon>edit</mat-icon><ng-container i18n="Playlist edit button">Edit</ng-container></button>
@@ -68,11 +63,11 @@
</div>
<div *ngIf="loading" class="img-div">
<content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" [width]="100" [height]="55"><svg:rect x="0" y="0" rx="0" ry="0" width="100" height="55" /></content-loader>
<content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 100 55"><svg:rect x="0" y="0" rx="0" ry="0" width="100" height="55" /></content-loader>
</div>
<span *ngIf="!loading" [ngClass]="{'max-two-lines': card_size !== 'small', 'max-one-line': card_size === 'small' }">{{card_size === 'large' && file_obj.uploader ? file_obj.uploader + ' - ' : ''}}<strong>{{!is_playlist ? file_obj.title : file_obj.name}}</strong></span>
<span *ngIf="loading" class="title-loading"><content-loader [primaryColor]="theme.ghost_primary" [secondaryColor]="theme.ghost_secondary" [width]="250" [height]="30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
<span *ngIf="loading" class="title-loading"><content-loader [backgroundColor]="theme.ghost_primary" [foregroundColor]="theme.ghost_secondary" viewBox="0 0 250 30"><svg:rect x="0" y="0" rx="3" ry="3" width="250" height="30" /></content-loader></span>
</div>
</mat-card>
</div>

View File

@@ -171,6 +171,6 @@
}
::ng-deep.mat-mdc-menu-panel {
:host ::ng-deep.mat-mdc-menu-panel {
max-width: none !important;
}

View File

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

View File

@@ -35,36 +35,6 @@
<p>
<ng-container i18n="About bug prefix">Found a bug or have a suggestion?</ng-container>&nbsp;<a [href]="issuesLink" target="_blank"><ng-container i18n="About bug click here">Click here</ng-container></a>&nbsp;<ng-container i18n="About bug suffix">to create an issue!</ng-container>
</p>
<mat-divider></mat-divider>
<div style="margin-top: 10px;">
<h5>Personal settings:</h5>
<mat-form-field>
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
<mat-option value="over">
Over
</mat-option>
<mat-option value="side">
Side
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="File card size">File card size</mat-label>
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
<mat-option value="large">
Large
</mat-option>
<mat-option value="medium">
Medium
</mat-option>
<mat-option value="small">
Small
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</mat-dialog-content>

View File

@@ -16,8 +16,6 @@ export class AboutDialogComponent implements OnInit {
checking_for_updates = true;
current_version_tag = CURRENT_VERSION;
sidepanel_mode = this.postsService.sidepanel_mode;
card_size = this.postsService.card_size;
constructor(public postsService: PostsService) { }
@@ -31,15 +29,4 @@ export class AboutDialogComponent implements OnInit {
this.latestGithubRelease = res;
});
}
sidePanelModeChanged(new_mode) {
localStorage.setItem('sidepanel_mode', new_mode);
this.postsService.sidepanel_mode = new_mode;
}
cardSizeOptionChanged(new_size) {
localStorage.setItem('card_size', new_size);
this.postsService.card_size = new_size;
}
}

View File

@@ -8,10 +8,10 @@
top: -12px;
}
::ng-deep.mat-menu-panel {
:host ::ng-deep.mat-menu-panel {
max-width: none !important;
}
::ng-deep.mdc-list-item__primary-text {
:host ::ng-deep.mdc-list-item__primary-text {
width: 100%;
}

View File

@@ -1,16 +0,0 @@
<h4 mat-dialog-title i18n="Pin required">Pin required</h4>
<mat-dialog-content>
<mat-form-field color="accent">
<mat-label i18n="Enter pin">Enter pin</mat-label>
<input [(ngModel)]="pin" matInput onlyNumber required type="password">
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close i18n="Cancel">Cancel</button>
<button mat-button [disabled]="!pin" (click)="pinLogin()"><ng-container i18n="Enter pin button">Enter pin</ng-container></button>
<div class="mat-spinner" *ngIf="enterClicked">
<mat-spinner [diameter]="25"></mat-spinner>
</div>
</mat-dialog-actions>

View File

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

View File

@@ -1,34 +0,0 @@
import { Component } from '@angular/core';
import { MatDialogRef } from '@angular/material/dialog';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-pin-login',
templateUrl: './pin-login-dialog.component.html',
styleUrls: ['./pin-login-dialog.component.scss']
})
export class PinLoginComponent {
pin: string;
enterClicked = false;
constructor(private postsService: PostsService, private dialogRef: MatDialogRef<PinLoginComponent>) {
}
pinLogin() {
this.enterClicked = true;
this.postsService.pinLogin(this.pin).subscribe(res => {
this.enterClicked = false;
if (!res['pin_token']) {
this.postsService.openSnackBar($localize`Pin failed!`);
} else {
this.postsService.httpOptions.params = this.postsService.httpOptions.params.set('pin_token', res['pin_token']);
}
this.dialogRef.close(res['pin_token']);
}, err => {
this.enterClicked = false;
this.postsService.openSnackBar($localize`Pin failed!`);
console.error(err);
this.dialogRef.close(false);
});
}
}

View File

@@ -1,13 +0,0 @@
<h4 mat-dialog-title i18n="Set pin">Set pin</h4>
<mat-dialog-content>
<mat-form-field color="accent">
<mat-label i18n="Pin">Pin</mat-label>
<input [(ngModel)]="pin" matInput onlyNumber required>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close i18n="Cancel">Cancel</button>
<button mat-button [disabled]="!pin" (click)="setPin()"><ng-container i18n="Set pin button">Set pin</ng-container></button>
</mat-dialog-actions>

View File

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

View File

@@ -1,22 +0,0 @@
import { Component } from '@angular/core';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-set-pin-dialog',
templateUrl: './set-pin-dialog.component.html',
styleUrls: ['./set-pin-dialog.component.scss']
})
export class SetPinDialogComponent {
pin: string;
constructor(private postsService: PostsService) {
}
setPin() {
this.postsService.setPin(this.pin).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar($localize`Successfully set pin!`);
}
});
}
}

View File

@@ -13,19 +13,52 @@
</div>
<div style="margin-top: 20px;">
</div>
<mat-divider style="margin-bottom: 20px"></mat-divider>
</div>
<div *ngIf="!postsService.isLoggedIn || !postsService.user">
<h5><mat-icon>warn</mat-icon><ng-container i18n="Not logged in notification">You are not logged in.</ng-container></h5>
<button (click)="loginClicked()" mat-raised-button color="primary"><ng-container i18n="Login">Login</ng-container></button>
</div>
<mat-form-field color="accent">
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
<ng-container *ngIf="all_locales[locale]">
{{all_locales[locale]['nativeName']}}
</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="Sidepanel mode">Sidepanel mode</mat-label>
<mat-select [(ngModel)]="sidepanel_mode" (selectionChange)="sidePanelModeChanged($event.value)">
<mat-option i18n="Over" value="over">
Over
</mat-option>
<mat-option i18n="Side" value="side">
Side
</mat-option>
</mat-select>
</mat-form-field>
<br/>
<mat-form-field>
<mat-label i18n="File card size">File card size</mat-label>
<mat-select [(ngModel)]="card_size" (selectionChange)="cardSizeOptionChanged($event.value)">
<mat-option i18n="Large" value="large">
Large
</mat-option>
<mat-option i18n="Medium" value="medium">
Medium
</mat-option>
<mat-option i18n="Small" value="small">
Small
</mat-option>
</mat-select>
</mat-form-field>
</mat-dialog-content>
<mat-dialog-actions>
<div style="width: 100%">
<div style="position: relative">
<button mat-stroked-button mat-dialog-close color="primary"><ng-container i18n="Close">Close</ng-container></button>
<button style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
<button *ngIf="postsService.isLoggedIn" style="position: absolute; right: 0px;" (click)="logoutClicked()" mat-stroked-button color="warn"><ng-container i18n="Logout">Logout</ng-container></button>
</div>
</div>
</mat-dialog-actions>

View File

@@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { MatDialogRef } from '@angular/material/dialog';
import { isoLangs } from './locales_list';
@Component({
selector: 'app-user-profile-dialog',
@@ -10,9 +11,24 @@ import { MatDialogRef } from '@angular/material/dialog';
})
export class UserProfileDialogComponent implements OnInit {
all_locales = isoLangs;
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
initialLocale = localStorage.getItem('locale');
sidepanel_mode = this.postsService.sidepanel_mode;
card_size = this.postsService.card_size;
constructor(public postsService: PostsService, private router: Router, public dialogRef: MatDialogRef<UserProfileDialogComponent>) { }
ngOnInit(): void {
this.postsService.getSupportedLocales().subscribe(res => {
if (res && res['supported_locales']) {
this.supported_locales = ['en', 'en-GB']; // required
this.supported_locales = this.supported_locales.concat(res['supported_locales']);
}
}, err => {
console.error(`Failed to retrieve list of supported languages! You may need to run: 'node src/postbuild.mjs'. Error below:`);
console.error(err);
});
}
loginClicked() {
@@ -25,4 +41,19 @@ export class UserProfileDialogComponent implements OnInit {
this.dialogRef.close();
}
localeSelectChanged(new_val: string): void {
localStorage.setItem('locale', new_val);
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
}
sidePanelModeChanged(new_mode) {
localStorage.setItem('sidepanel_mode', new_mode);
this.postsService.sidepanel_mode = new_mode;
}
cardSizeOptionChanged(new_size) {
localStorage.setItem('card_size', new_size);
this.postsService.card_size = new_size;
}
}

View File

@@ -1,5 +1,5 @@
import { Component, OnInit, Inject } from '@angular/core';
import filesize from 'filesize';
import { filesize } from 'filesize';
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
import { PostsService } from 'app/posts.services';
import { Category, DatabaseFile } from 'api-types';

View File

@@ -205,7 +205,7 @@
</div>
<div style="display: flex; justify-content: center;" *ngIf="downloads && downloads.length > 0 && !autoplay">
<app-downloads style="width: 80%; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
<app-downloads style="width: 80%; min-width: 350px; margin-bottom: 10px" [uids]="download_uids"></app-downloads>
</div>
<ng-container *ngIf="cachedFileManagerEnabled || fileManagerEnabled">

View File

@@ -50,7 +50,6 @@ export class MainComponent implements OnInit {
allowQualitySelect = false;
downloadOnlyMode = false;
forceAutoplay = false;
use_youtubedl_archive = false;
globalCustomArgs = null;
allowAdvancedDownload = false;
useDefaultDownloadingAgent = true;
@@ -188,7 +187,6 @@ export class MainComponent implements OnInit {
&& this.postsService.hasPermission('filemanager');
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
this.forceAutoplay = this.postsService.config['Extra']['force_autoplay'];
this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive'];
this.globalCustomArgs = this.postsService.config['Downloader']['custom_args'];
this.youtubeSearchEnabled = this.postsService.config['API'] && this.postsService.config['API']['use_youtube_API'] &&
this.postsService.config['API']['youtube_API_key'];

View File

@@ -37,11 +37,15 @@
}
.spinner {
bottom: 1px;
left: 2px;
bottom: -2px;
left: 6px;
position: absolute;
}
.buttons {
position: relative;
}
.save-button {
right: 25px;
position: fixed;
@@ -85,13 +89,6 @@
margin-bottom: 15px;
}
.spinner-div {
position: relative;
display: inline-block;
margin-right: 12px;
top: 8px;
}
.skip-ad-button {
position: absolute;
right: 20px;

View File

@@ -22,20 +22,22 @@
</p>
</ng-container>
<ng-container *ngIf="!db_file || !db_file['description']">
<p style="text-align: center;">
<p i18n="No description" style="text-align: center;">
No description available.
</p>
</ng-container>
</div>
<div class="col-2">
<ng-container *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
<span class="buttons" *ngIf="db_playlist">
<button (click)="downloadContent()" [disabled]="downloading" mat-icon-button><mat-icon>save</mat-icon></button>
<mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner>
<button *ngIf="(!postsService.isLoggedIn || postsService.permissions.includes('sharing')) && !auto" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
</ng-container>
<ng-container *ngIf="db_file">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon><mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner></button>
</span>
<span class="buttons" *ngIf="db_file">
<button (click)="downloadFile()" [disabled]="downloading" mat-icon-button><mat-icon>cloud_download</mat-icon></button>
<mat-spinner *ngIf="downloading" class="spinner" [diameter]="35"></mat-spinner>
<button *ngIf="!postsService.isLoggedIn || postsService.permissions.includes('sharing')" (click)="openShareDialog()" mat-icon-button><mat-icon>share</mat-icon></button>
</ng-container>
</span>
<ng-container *ngIf="db_file || playlist[currentIndex]"></ng-container>
<button (click)="openFileInfoDialog()" *ngIf="db_file || db_playlist" mat-icon-button><mat-icon>info</mat-icon></button>
<button *ngIf="db_file && db_file.url.includes('twitch.tv')" (click)="drawer.toggle()" mat-icon-button><mat-icon>chat</mat-icon></button>
@@ -56,14 +58,6 @@
<app-twitch-chat #twitchchat [db_file]="db_file" [current_timestamp]="api.currentTime" [sub]="subscription"></app-twitch-chat>
</ng-container>
</mat-drawer>
<!-- <div class="update-playlist-button-div" *ngIf="id && playlistChanged()">
<div class="spinner-div">
<mat-spinner *ngIf="playlist_updating" [diameter]="25"></mat-spinner>
</div>
<button color="primary" [disabled]="playlist_updating" (click)="updatePlaylist()" mat-raised-button><ng-container i18n="Playlist save changes button">Save changes</ng-container>&nbsp;<mat-icon>update</mat-icon></button>
</div> -->
</mat-drawer-container>
</div>
</div>

View File

@@ -61,8 +61,6 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
url = null;
name = null;
innerWidth: number;
downloading = false;
save_volume_timer = null;
@@ -70,14 +68,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
@ViewChild('twitchchat') twitchChat: TwitchChatComponent;
@HostListener('window:resize', ['$event'])
onResize(): void {
this.innerWidth = window.innerWidth;
}
ngOnInit(): void {
this.innerWidth = window.innerWidth;
this.playlist_id = this.route.snapshot.paramMap.get('playlist_id');
this.uid = this.route.snapshot.paramMap.get('uid');
this.sub_id = this.route.snapshot.paramMap.get('sub_id');

View File

@@ -4,11 +4,10 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';
import { THEMES_CONFIG } from '../themes';
import { Router, CanActivate, ActivatedRouteSnapshot } from '@angular/router';
import { Router, ActivatedRouteSnapshot } from '@angular/router';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject, Observable } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
import * as Fingerprint2 from 'fingerprintjs2';
import {
ChangeRolePermissionsRequest,
ChangeUserPermissionsRequest,
@@ -114,14 +113,15 @@ import {
Archive,
Subscription,
RestartDownloadResponse,
PinLoginResponse
TaskType
} from '../api-types';
import { isoLangs } from './settings/locales_list';
import { isoLangs } from './dialogs/user-profile-dialog/locales_list';
import { Title } from '@angular/platform-browser';
import { MatDrawerMode } from '@angular/material/sidenav';
import type { EmoteObject } from '@tzahi12345/twitch-emoticons';
@Injectable()
export class PostsService implements CanActivate {
export class PostsService {
path = '';
// local settings
@@ -132,7 +132,6 @@ export class PostsService implements CanActivate {
// auth
auth_token = '4241b401-7236-493e-92b5-b72696b9d853';
session_id = null;
httpOptions: {
params: HttpParams
};
@@ -188,12 +187,6 @@ export class PostsService implements CanActivate {
})
};
Fingerprint2.get(components => {
// set identity as user id doesn't necessarily exist
this.session_id = Fingerprint2.x64hash128(components.map(function (pair) { return pair.value; }).join(), 31);
this.httpOptions.params = this.httpOptions.params.set('sessionID', this.session_id);
});
const redirect_not_required = window.location.href.includes('/player') || window.location.href.includes('/login');
// get config
@@ -415,6 +408,10 @@ export class PostsService implements CanActivate {
return this.http.post<DownloadTwitchChatByVODIDResponse>(this.path + 'downloadTwitchChatByVODID', body, this.httpOptions);
}
getTwitchEmotes(uid: string) {
return this.http.post<{emotes: EmoteObject[]}>(this.path + 'getTwitchEmotes', {uid: uid}, this.httpOptions);
}
downloadPlaylistFromServer(playlist_id, uuid = null) {
const body: DownloadFileRequest = {uuid: uuid, playlist_id: playlist_id};
return this.http.post(this.path + 'downloadFileFromServer', body, {responseType: 'blob', params: this.httpOptions.params});
@@ -649,32 +646,32 @@ export class PostsService implements CanActivate {
return this.http.post<SuccessObject>(this.path + 'resetTasks', {}, this.httpOptions);
}
getTask(task_key: string) {
getTask(task_key: TaskType) {
const body: GetTaskRequest = {task_key: task_key};
return this.http.post<GetTaskResponse>(this.path + 'getTask', body, this.httpOptions);
}
runTask(task_key: string) {
runTask(task_key: TaskType) {
const body: GetTaskRequest = {task_key: task_key};
return this.http.post<SuccessObject>(this.path + 'runTask', body, this.httpOptions);
}
confirmTask(task_key: string) {
confirmTask(task_key: TaskType) {
const body: GetTaskRequest = {task_key: task_key};
return this.http.post<SuccessObject>(this.path + 'confirmTask', body, this.httpOptions);
}
updateTaskSchedule(task_key: string, schedule: Schedule) {
updateTaskSchedule(task_key: TaskType, schedule: Schedule) {
const body: UpdateTaskScheduleRequest = {task_key: task_key, new_schedule: schedule};
return this.http.post<SuccessObject>(this.path + 'updateTaskSchedule', body, this.httpOptions);
}
updateTaskData(task_key: string, data: any) {
updateTaskData(task_key: TaskType, data: any) {
const body: UpdateTaskDataRequest = {task_key: task_key, new_data: data};
return this.http.post<SuccessObject>(this.path + 'updateTaskData', body, this.httpOptions);
}
updateTaskOptions(task_key: string, options: any) {
updateTaskOptions(task_key: TaskType, options: any) {
const body: UpdateTaskOptionsRequest = {task_key: task_key, new_options: options};
return this.http.post<SuccessObject>(this.path + 'updateTaskOptions', body, this.httpOptions);
}
@@ -735,6 +732,7 @@ export class PostsService implements CanActivate {
return this.http.post<LoginResponse>(this.path + 'auth/login', body, this.httpOptions);
}
// user methods
jwtAuth() {
const call = this.http.post(this.path + 'auth/jwtAuth', {}, this.httpOptions);
call.subscribe(res => {
@@ -742,7 +740,7 @@ export class PostsService implements CanActivate {
this.afterLogin(res['user'], res['token'], res['permissions'], res['available_permissions']);
}
}, err => {
if (err.status === 401) {
if (err === 'Unauthorized') {
this.sendToLogin();
this.token = null;
this.resetHttpParams();
@@ -752,12 +750,6 @@ export class PostsService implements CanActivate {
return call;
}
// pin methods
pinLogin(pin: string) {
const body: LoginRequest = {username: 'username', password: pin};
return this.http.post<PinLoginResponse>(this.path + 'auth/pinLogin', body, this.httpOptions);
}
logout() {
this.user = null;
this.permissions = null;
@@ -802,7 +794,7 @@ export class PostsService implements CanActivate {
resetHttpParams() {
// resets http params
this.http_params = `apiKey=${this.auth_token}&sessionID=${this.session_id}`
this.http_params = `apiKey=${this.auth_token}`
this.httpOptions = {
params: new HttpParams({
@@ -909,11 +901,6 @@ export class PostsService implements CanActivate {
this.httpOptions);
}
setPin(new_pin: string): Observable<SuccessObject> {
return this.http.post<SuccessObject>(this.path + 'setPin', {new_pin: new_pin},
this.httpOptions);
}
public openSnackBar(message: string, action = ''): void {
this.snackBar.open(message, action, {
duration: 2000,

View File

@@ -78,23 +78,6 @@
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-form-field color="accent">
<mat-label><ng-container i18n="Language select label">Language</ng-container></mat-label>
<mat-select (selectionChange)="localeSelectChanged($event.value)" [(value)]="initialLocale">
<mat-option *ngFor="let locale of supported_locales" [value]="locale">
<ng-container *ngIf="all_locales[locale]">
{{all_locales[locale]['nativeName']}}
</ng-container>
</mat-option>
</mat-select>
</mat-form-field>
</div>
</div>
</div>
</ng-template>
</mat-tab>
<!-- Downloader -->
@@ -257,25 +240,6 @@
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['Extra']['use_pin']"><ng-container i18n="Use pin to hide settings setting">Use pin to hide settings</ng-container></mat-checkbox>
</div>
<div class="col-12 mb-3">
<div class="pin-set" *ngIf="new_config['Extra']['pin_hash']">
<mat-icon>done</mat-icon>&nbsp;<ng-container i18n="Pin set">Pin set!</ng-container>
</div>
<div>
<button mat-stroked-button (click)="openSetPinDialog()">
<ng-container *ngIf="!new_config['Extra']['pin_hash']" i18n="Set pin">Set pin</ng-container>
<ng-container *ngIf="new_config['Extra']['pin_hash']" i18n="Reset pin">Reset pin</ng-container>
</button>
</div>
</div>
</div>
</div>
<mat-divider></mat-divider>
<div *ngIf="new_config" class="container-fluid">
<div class="row">
<div class="col-12 mt-3">
@@ -288,9 +252,25 @@
<mat-hint><a target="_blank" href="https://developers.google.com/youtube/v3/getting-started"><ng-container i18n="Youtube API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-3">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_twitch_API']"><ng-container i18n="Use Twitch API setting">Use Twitch API</ng-container></mat-checkbox>
</div>
<div class="col-12 mt-1">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['twitch_auto_download_chat']"><ng-container i18n="Auto download Twitch Chat setting">Auto-download Twitch Chat</ng-container></mat-checkbox>
</div>
<div class="col-12">
<mat-form-field class="text-field" color="accent">
<mat-label i18n="Twitch Client ID">Twitch Client ID</mat-label>
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_client_ID']" matInput required>
<mat-hint><a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch Client ID setting hint">Generating an ID/secret is easy!</ng-container></a></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-2">
<mat-form-field class="text-field" color="accent">
<mat-label i18n="Twitch Client Secret">Twitch Client Secret</mat-label>
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_client_secret']" matInput required>
</mat-form-field>
</div>
<div class="col-12 mt-2">
<mat-checkbox color="accent" [(ngModel)]="new_config['API']['use_sponsorblock_API']" matTooltip="Enables a button to skip ads when viewing supported videos." i18n-matTooltip="SponsorBlock API tooltip"><ng-container i18n="Use SponsorBlock API setting">Use SponsorBlock API</ng-container></mat-checkbox>
</div>
@@ -404,6 +384,20 @@
<mat-hint>Place endpoint URL here to integrate with services like Zapier and Automatisch.</mat-hint>
</mat-form-field>
</div>
<div class="col-12 mb-2 mt-3">
<mat-form-field class="text-field" color="accent">
<mat-label i18n="Discord Webhook URL">Discord Webhook URL</mat-label>
<input placeholder="https://discord.com/api/webhooks/<webhook_id>/<webhook_token>" [(ngModel)]="new_config['API']['discord_webhook_URL']" matInput>
<mat-hint><a target="_blank" href="https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks"><ng-container i18n="Discord API setting hint">See docs here.</ng-container></a></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mb-2 mt-3">
<mat-form-field class="text-field" color="accent">
<mat-label i18n="Slack Webhook URL">Slack Webhook URL</mat-label>
<input placeholder="https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" [(ngModel)]="new_config['API']['slack_webhook_URL']" matInput>
<mat-hint><a target="_blank" href="https://api.slack.com/messaging/webhooks"><ng-container i18n="Slack API setting hint">See docs here.</ng-container></a></mat-hint>
</mat-form-field>
</div>
<div class="col-12 mt-3">
<mat-checkbox color="accent" [disabled]="!new_config['Extra']['enable_notifications']" [(ngModel)]="new_config['API']['use_ntfy_API']"><ng-container i18n="Use ntfy API setting">Use ntfy API</ng-container></mat-checkbox>
</div>

View File

@@ -7,7 +7,7 @@
margin-top: 15px;
}
::ng-deep .mat-mdc-tab-body {
:host ::ng-deep .mat-mdc-tab-body {
margin-left: 15px;
}
@@ -110,10 +110,4 @@
position: relative;
top: 6px;
margin-left: 10px;
}
.pin-set {
display: flex;
align-items: center;
margin-bottom: 10px;
}

View File

@@ -1,6 +1,5 @@
import { Component, OnInit, EventEmitter } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { isoLangs } from './locales_list';
import { MatSnackBar } from '@angular/material/snack-bar';
import {DomSanitizer} from '@angular/platform-browser';
import { MatDialog } from '@angular/material/dialog';
@@ -15,7 +14,6 @@ import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/ed
import { ActivatedRoute, Router } from '@angular/router';
import { Category, DBInfoResponse } from 'api-types';
import { GenerateRssUrlComponent } from 'app/dialogs/generate-rss-url/generate-rss-url.component';
import { SetPinDialogComponent } from 'app/dialogs/set-pin-dialog/set-pin-dialog.component';
@Component({
selector: 'app-settings',
@@ -23,10 +21,6 @@ import { SetPinDialogComponent } from 'app/dialogs/set-pin-dialog/set-pin-dialog
styleUrls: ['./settings.component.scss']
})
export class SettingsComponent implements OnInit {
all_locales = isoLangs;
supported_locales = ['en', 'es', 'de', 'fr', 'nl', 'pt', 'it', 'ca', 'cs', 'nb', 'ru', 'zh', 'ko', 'id', 'en-GB'];
initialLocale = localStorage.getItem('locale');
initial_config = null;
new_config = null
loading_config = false;
@@ -42,7 +36,7 @@ export class SettingsComponent implements OnInit {
latestGithubRelease = null;
CURRENT_VERSION = CURRENT_VERSION
tabs = ['main', 'downloader', 'extra', 'database', 'advanced', 'users', 'logs'];
tabs = ['main', 'downloader', 'extra', 'database', 'notifications', 'advanced', 'users', 'logs'];
tabIndex = 0;
INDEX_TO_TAB = Object.assign({}, this.tabs);
@@ -84,16 +78,6 @@ export class SettingsComponent implements OnInit {
const tab = this.route.snapshot.paramMap.get('tab');
this.tabIndex = tab && this.TAB_TO_INDEX[tab] ? this.TAB_TO_INDEX[tab] : 0;
this.postsService.getSupportedLocales().subscribe(res => {
if (res && res['supported_locales']) {
this.supported_locales = ['en', 'en-GB']; // required
this.supported_locales = this.supported_locales.concat(res['supported_locales']);
}
}, err => {
console.error(`Failed to retrieve list of supported languages! You may need to run: 'node src/postbuild.mjs'. Error below:`);
console.error(err);
});
}
getConfig(): void {
@@ -208,11 +192,6 @@ export class SettingsComponent implements OnInit {
});
}
localeSelectChanged(new_val: string): void {
localStorage.setItem('locale', new_val);
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
}
generateBookmarklet(): void {
this.bookmarksite('YTDL-Material', this.generated_bookmarklet_code);
}
@@ -374,8 +353,4 @@ export class SettingsComponent implements OnInit {
maxWidth: '880px'
});
}
openSetPinDialog(): void {
this.dialog.open(SetPinDialogComponent);
}
}

View File

@@ -4184,6 +4184,612 @@
</context-group>
<note priority="1" from="description">Restore button</note>
</trans-unit>
<trans-unit id="c748ac656af9f13998206ef2c52018dd418b0483" datatype="html">
<source>Archives</source>
<target state="translated">Archive</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Archives menu label</note>
</trans-unit>
<trans-unit id="5ca707824ab93066c7d9b44e1b8bf216725c2c22" datatype="html">
<source>Filter</source>
<target state="translated">Filter</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">3</context>
</context-group>
<note priority="1" from="description">Filter</note>
</trans-unit>
<trans-unit id="51a161ce175abcd44f6c6cbd0e996681bf553ac3" datatype="html">
<source>Delete selected</source>
<target state="translated">Ausgewählte löschen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">77</context>
</context-group>
<note priority="1" from="description">Delete selected</note>
</trans-unit>
<trans-unit id="c41475a25c9f9d9639db9efa56637603a77528b4" datatype="html">
<source>Download archive</source>
<target state="translated">Archiv herunterladen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">80</context>
</context-group>
<note priority="1" from="description">Download archive</note>
</trans-unit>
<trans-unit id="a2f14a73f7a6e94479f67423cc51102da8d6f524" datatype="html">
<source>None</source>
<target state="translated">Kein</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">84</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">126</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
<note priority="1" from="description">None</note>
</trans-unit>
<trans-unit id="4b3972c3e9485218508a95f7a4ce7758e3f09ced" datatype="html">
<source>Upload</source>
<target state="translated">Hochladen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">137</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html</context>
<context context-type="linenumber">30</context>
</context-group>
<note priority="1" from="description">Upload</note>
</trans-unit>
<trans-unit id="6549265851868599441" datatype="html">
<source>Video</source>
<target state="translated">Video</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="347407180135731058" datatype="html">
<source>Audio</source>
<target state="translated">Audio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="8953483585652369683" datatype="html">
<source>Archive successfully imported!</source>
<target state="translated">Archiv erfolgreich importiert!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">130</context>
</context-group>
</trans-unit>
<trans-unit id="3159807825117518005" datatype="html">
<source>Delete archives</source>
<target state="translated">Archive löschen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">152</context>
</context-group>
</trans-unit>
<trans-unit id="7022070615528435141" datatype="html">
<source>Delete</source>
<target state="translated">Löschen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">154</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">175</context>
</context-group>
</trans-unit>
<trans-unit id="019d4bd6a5690f0cfa0ecf346a4e6bf7f0d8debb" datatype="html">
<source>Remove</source>
<target state="translated">Entfernen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
<note priority="1" from="description">Remove</note>
</trans-unit>
<trans-unit id="6219551536751479443" datatype="html">
<source>Finished downloading</source>
<target state="translated">Herunterladen abgeschlossen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="5947241266456580665" datatype="html">
<source>Download failed</source>
<target state="translated">Herunterladen fehlgeschlagen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="8443034725057696949" datatype="html">
<source>Task finished</source>
<target state="translated">Aufgabe abgeschlossen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="8564202903947049539" datatype="html">
<source>Play</source>
<target state="translated">Wiedergabe</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="5a105e7bd7e7db6ea211fe950fc9f317379acceb" datatype="html">
<source>No notifications available</source>
<target state="translated">Keine Benachrichtigungen verfügbar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">No notifications available</note>
</trans-unit>
<trans-unit id="5709555629190115111" datatype="html">
<source>View task</source>
<target state="translated">Aufgabe ansehen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="6876310993601590130" datatype="html">
<source>Download completed</source>
<target state="translated">Herunterladen abgeschlossen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="4578192247039196794" datatype="html">
<source>Task</source>
<target state="translated">Aufgabe</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="7911845622864460134" datatype="html">
<source>Video only</source>
<target state="translated">Nur Video</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">55</context>
</context-group>
</trans-unit>
<trans-unit id="6437411876967154040" datatype="html">
<source>Audio only</source>
<target state="translated">Nur Audio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">60</context>
</context-group>
</trans-unit>
<trans-unit id="6268070779441507380" datatype="html">
<source>Download Date</source>
<target state="translated">Herunterladedatum</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="cdf5297d8d080a78e8b10debc5c38b7845a3cbe7" datatype="html">
<source>Do not ask for confirmation</source>
<target state="translated">Nicht nach einer Bestätigung fragen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
<note priority="1" from="description">Do not ask for confirmation</note>
</trans-unit>
<trans-unit id="7b4585a9072f3c1292972c14a3d0e14978fbfc9c" datatype="html">
<source>Delete old files:</source>
<target state="translated">Alte Dateien löschen:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/tasks/tasks.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<note priority="1" from="description">Delete old files</note>
</trans-unit>
<trans-unit id="9176960997786930103" datatype="html">
<source>Error for: <x id="PH" equiv-text="task['title']"/></source>
<target state="translated">Fehler für: <x id="PH" equiv-text="task['title']"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/tasks/tasks.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="11a0771f88158a540a54e0e4ec5d25733d65fc0e" datatype="html">
<source>Favorite</source>
<target state="translated">Favorit</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Favorite button</note>
</trans-unit>
<trans-unit id="5ac5a0e5ffe8e5623b40696f4c2403c17349271f" datatype="html">
<source>Sidepanel mode</source>
<target state="translated">Seitenleisten-Modus</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/about-dialog/about-dialog.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<note priority="1" from="description">Sidepanel mode</note>
</trans-unit>
<trans-unit id="1a9b415816364f554ee411020e65219092655271" datatype="html">
<source>Title filter</source>
<target state="translated">Titelfilter</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">8</context>
</context-group>
<note priority="1" from="description">Title filter</note>
</trans-unit>
<trans-unit id="9aa62bf1a535a97a4d752bbc5cf1c31af0f0c1f7" datatype="html">
<source>Supports regex</source>
<target state="translated">Unterstützt reguläre Ausdrücke</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">10</context>
</context-group>
<note priority="1" from="description">Supports regex</note>
</trans-unit>
<trans-unit id="e08a77594f3d89311cdf6da5090044270909c194" datatype="html">
<source>User</source>
<target state="translated">Benutzer</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">25</context>
</context-group>
<note priority="1" from="description">User</note>
</trans-unit>
<trans-unit id="d57c023a4cf63b2f12c10328c15b636ff18929aa" datatype="html">
<source>Best</source>
<target state="translated">Beste</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/main/main.component.html</context>
<context context-type="linenumber">24,25</context>
</context-group>
<note priority="1" from="description">Best</note>
</trans-unit>
<trans-unit id="35cf4cdcedc8ef3f94b6100e0d86836e31dbb908" datatype="html">
<source>Force autoplay</source>
<target state="translated">Automatische Wiedergabe erzwingen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">235</context>
</context-group>
<note priority="1" from="description">Force autoplay setting</note>
</trans-unit>
<trans-unit id="8bcabdf6b16cad0313a86c7e940c5e3ad7f9f8ab" datatype="html">
<source>Notifications</source>
<target state="translated">Benachrichtigungen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">376</context>
</context-group>
<note priority="1" from="description">Notifications settings label</note>
</trans-unit>
<trans-unit id="c40370dc182b5e4333828b70f7478bde58bb5cfe" datatype="html">
<source>Enable notifications</source>
<target state="translated">Benachrichtigungen aktivieren</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">382</context>
</context-group>
<note priority="1" from="description">Enable notifications setting</note>
</trans-unit>
<trans-unit id="33a7c6d5ff3515fa237f1fd4e43df8b65373954d" datatype="html">
<source>Enable all notifications</source>
<target state="translated">Alle Benachrichtigungen aktivieren</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">385</context>
</context-group>
<note priority="1" from="description">Enable all notifications setting</note>
</trans-unit>
<trans-unit id="9c562d26e041390ecc3f49dabc51cc50ebba7469" datatype="html">
<source>Allowed notification types</source>
<target state="translated">Erlaubte Benachrichtigungsarten</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">389</context>
</context-group>
<note priority="1" from="description">Allowed notification types</note>
</trans-unit>
<trans-unit id="c5dc5fbcce45e9b1530e2a5c2baa8ebe722aef4c" datatype="html">
<source>Download complete</source>
<target state="translated">Herunterladen abgeschlossen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">391</context>
</context-group>
<note priority="1" from="description">Download complete</note>
</trans-unit>
<trans-unit id="2361a4f76caaa4574803fbcdca8b0a47c91cc7ed" datatype="html">
<source>Task finished</source>
<target state="translated">Aufgabe abgeschlossen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">393</context>
</context-group>
<note priority="1" from="description">Task finished</note>
</trans-unit>
<trans-unit id="38992954440d6afb54aeb58af12ca0123ee5e26e" datatype="html">
<source>Use Telegram API</source>
<target state="translated">Telegram-API verwenden</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">432</context>
</context-group>
<note priority="1" from="description">Use Telegram API setting</note>
</trans-unit>
<trans-unit id="eeb0ba2e4743901d8f5eebd9a3529aa1f236c608" datatype="html">
<source>Create bot here.</source>
<target state="translated">Bot hier erstellen.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">438</context>
</context-group>
<note priority="1" from="description">Telegram bot create link</note>
</trans-unit>
<trans-unit id="a4ed8eba1e057e67d5c2d87b52230f182b3dae4e" datatype="html">
<source>Restart required.</source>
<target state="translated">Neustart erforderlich.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">465</context>
</context-group>
<note priority="1" from="description">Restart required hint</note>
</trans-unit>
<trans-unit id="6785427850041119037" datatype="html">
<source>Delete category</source>
<target state="translated">Kategorie löschen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="7332320960988475089" datatype="html">
<source>Successfully deleted <x id="category name" equiv-text="category['name']"/>!</source>
<target state="translated"><x id="category name" equiv-text="category['name']"/> erfolgreich gelöscht!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="8c6e24eab969d9f63a8a0e9d617aee3b99e28ae6" datatype="html">
<source>Play all</source>
<target state="translated">Alle wiedergeben</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscription/subscription/subscription.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<note priority="1" from="description">Play all</note>
</trans-unit>
<trans-unit id="674a999dd48d7da565ffdd105602261b8a4761ea" datatype="html">
<source>Download zip</source>
<target state="translated">ZIP herunterladen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscription/subscription/subscription.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<note priority="1" from="description">Download zip</note>
</trans-unit>
<trans-unit id="0ed98b4c6ec1db6708a963e8a2699478ac97f55c" datatype="html">
<source>Add subscription</source>
<target state="translated">Abonnement hinzufügen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscriptions/subscriptions.component.html</context>
<context context-type="linenumber">60</context>
</context-group>
<note priority="1" from="description">Add subscription</note>
</trans-unit>
<trans-unit id="3533826530554274875" datatype="html">
<source>Upload Date</source>
<target state="translated">Hochladedatum</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="8953033926734869941" datatype="html">
<source>Name</source>
<target state="translated">Name</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="2492098975665776610" datatype="html">
<source>File Size</source>
<target state="translated">Dateigröße</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="ea2b65121b93921fe54692025da9b9e3ce779ad5" datatype="html">
<source>Task settings - <x id="INTERPOLATION" equiv-text="{{task.title}}"/></source>
<target state="translated">Aufgabeneinstellungen - <x id="INTERPOLATION" equiv-text="{{task.title}}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">Task settings</note>
</trans-unit>
<trans-unit id="7410432243549869948" datatype="html">
<source>Duration</source>
<target state="translated">Dauer</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="c65dd978b3c7566551c0ebefb234c2d41942b847" datatype="html">
<source>Delete files older than</source>
<target state="translated">Löschen von Dateien, die älter sind als</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<note priority="1" from="description">Delete files older than</note>
</trans-unit>
<trans-unit id="45cc8ca94b5a50842a9a8ef804a5ab089a38ae5c" datatype="html">
<source>ID</source>
<target state="translated">Kennung</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">47</context>
</context-group>
<note priority="1" from="description">ID</note>
</trans-unit>
<trans-unit id="c150a30bbbdb175b4d08820196a9acb66b167653" datatype="html">
<source>Archives empty</source>
<target state="translated">Archive leer</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">72</context>
</context-group>
<note priority="1" from="description">Archives empty</note>
</trans-unit>
<trans-unit id="8425787787095143143" datatype="html">
<source>Would you like to delete <x id="selected archives amount" equiv-text="this.selection.selected.length"/> archive(s)?</source>
<target state="translated">Möchten Sie <x id="selected archives amount" equiv-text="this.selection.selected.length"/> Archiv(e) löschen?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="2525880134753073592" datatype="html">
<source>Successfully deleted archive items!</source>
<target state="translated">Archivelemente erfolgreich gelöscht!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="8224301330941792118" datatype="html">
<source>Failed to delete archive items!</source>
<target state="translated">Fehler beim Löschen von Archivelementen!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="8643601595923420698" datatype="html">
<source>Retry download</source>
<target state="translated">Herunterladen erneut versuchen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="8571838164752006148" datatype="html">
<source>View error</source>
<target state="translated">Fehler ansehen</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="1879058637439215882" datatype="html">
<source>Download error</source>
<target state="translated">Herunterladefehler</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="5000203534763292992" datatype="html">
<source>Download restarted!</source>
<target state="translated">Herunterladen neu gestartet!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="1f2809e6a99d511fdb6eaf041d785fe54d0680cc" datatype="html">
<source>File card size</source>
<target state="translated">Dateikartengröße</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/about-dialog/about-dialog.component.html</context>
<context context-type="linenumber">54</context>
</context-group>
<note priority="1" from="description">File card size</note>
</trans-unit>
<trans-unit id="c35ef0f03a863d33b04aae6807f140397a50f491" datatype="html">
<source>Generate RSS URL</source>
<target state="translated">RSS-URL generieren</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">306</context>
</context-group>
<note priority="1" from="description">Generate RSS URL</note>
</trans-unit>
<trans-unit id="1091872159779006651" datatype="html">
<source>You must input a time!</source>
<target state="translated">Sie müssen eine Zeit eingeben!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.ts</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="fd467148a18f0921c10d116d4e0174fe29452be4" datatype="html">
<source>See documentation here.</source>
<target state="translated">Siehe Dokumentation hier.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">307</context>
</context-group>
<note priority="1" from="description">RSS feed documentation</note>
</trans-unit>
<trans-unit id="61d6b5fa4311b1c617b66dad72496f9dd43b07b4" datatype="html">
<source>Be careful enabling this with multi-user mode! User data may be exposed.</source>
<target state="translated">Seien Sie vorsichtig, wenn Sie diese Funktion im Mehrbenutzermodus aktivieren! Benutzerdaten können offengelegt werden.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">305</context>
</context-group>
<note priority="1" from="description">RSS Feed prefix</note>
</trans-unit>
<trans-unit id="3ffd9490f3a4c0b24021d25e1dc71fcfe5d39cd6" datatype="html">
<source>Download error</source>
<target state="translated">Herunterladefehler</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">392</context>
</context-group>
<note priority="1" from="description">Download error</note>
</trans-unit>
</body>
</file>
</xliff>

File diff suppressed because it is too large Load Diff

View File

@@ -2577,7 +2577,7 @@
</context-group>
</trans-unit>
<trans-unit id="2560364143605631750" datatype="html">
<source>Error for<x id="url" equiv-text="download['url']"/></source>
<source>Error for <x id="url" equiv-text="download['url']"/></source>
<target state="translated">Error para <x id="url" equiv-text="download['url']"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
@@ -2733,8 +2733,8 @@
<note priority="1" from="description">Clear missing files from DB</note>
</trans-unit>
<trans-unit id="39921032161993566" datatype="html">
<source>Playlist created.</source>
<target state="translated">Lista de reproducción creada.</target>
<source>Successfully created playlist!</source>
<target state="translated">¡Lista de reproducción creada con éxito!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/custom-playlists/custom-playlists.component.ts</context>
<context context-type="linenumber">56</context>
@@ -3092,6 +3092,848 @@
</context-group>
<note priority="1" from="description">Generate NFO files setting</note>
</trans-unit>
<trans-unit id="c748ac656af9f13998206ef2c52018dd418b0483" datatype="html">
<source>Archives</source>
<target state="translated">Archivos</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Archives menu label</note>
</trans-unit>
<trans-unit id="5ca707824ab93066c7d9b44e1b8bf216725c2c22" datatype="html">
<source>Filter</source>
<target state="translated">Filtros</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">3</context>
</context-group>
<note priority="1" from="description">Filter</note>
</trans-unit>
<trans-unit id="45cc8ca94b5a50842a9a8ef804a5ab089a38ae5c" datatype="html">
<source>ID</source>
<target state="translated">ID</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">47</context>
</context-group>
<note priority="1" from="description">ID</note>
</trans-unit>
<trans-unit id="c150a30bbbdb175b4d08820196a9acb66b167653" datatype="html">
<source>Archives empty</source>
<target state="translated">Archivos vacíos</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">72</context>
</context-group>
<note priority="1" from="description">Archives empty</note>
</trans-unit>
<trans-unit id="51a161ce175abcd44f6c6cbd0e996681bf553ac3" datatype="html">
<source>Delete selected</source>
<target state="translated">Eliminar seleccionado</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">77</context>
</context-group>
<note priority="1" from="description">Delete selected</note>
</trans-unit>
<trans-unit id="c41475a25c9f9d9639db9efa56637603a77528b4" datatype="html">
<source>Download archive</source>
<target state="translated">Descargar archivo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">80</context>
</context-group>
<note priority="1" from="description">Download archive</note>
</trans-unit>
<trans-unit id="a2f14a73f7a6e94479f67423cc51102da8d6f524" datatype="html">
<source>None</source>
<target state="translated">Ninguno</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">84</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">126</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">36</context>
</context-group>
<note priority="1" from="description">None</note>
</trans-unit>
<trans-unit id="4b3972c3e9485218508a95f7a4ce7758e3f09ced" datatype="html">
<source>Upload</source>
<target state="translated">Cargado</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">137</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.html</context>
<context context-type="linenumber">30</context>
</context-group>
<note priority="1" from="description">Upload</note>
</trans-unit>
<trans-unit id="6549265851868599441" datatype="html">
<source>Video</source>
<target state="translated">Video</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">40</context>
</context-group>
</trans-unit>
<trans-unit id="347407180135731058" datatype="html">
<source>Audio</source>
<target state="translated">Audio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">44</context>
</context-group>
</trans-unit>
<trans-unit id="8953483585652369683" datatype="html">
<source>Archive successfully imported!</source>
<target state="translated">¡Archivo importado con éxito!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">130</context>
</context-group>
</trans-unit>
<trans-unit id="3159807825117518005" datatype="html">
<source>Delete archives</source>
<target state="translated">Borrar los archivos</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">152</context>
</context-group>
</trans-unit>
<trans-unit id="7022070615528435141" datatype="html">
<source>Delete</source>
<target state="translated">Borrar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">154</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">175</context>
</context-group>
</trans-unit>
<trans-unit id="2525880134753073592" datatype="html">
<source>Successfully deleted archive items!</source>
<target state="translated">¡Elementos del archivo eliminados correctamente!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">172</context>
</context-group>
</trans-unit>
<trans-unit id="8224301330941792118" datatype="html">
<source>Failed to delete archive items!</source>
<target state="translated">¡No se pudieron eliminar los elementos del archivo!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="019d4bd6a5690f0cfa0ecf346a4e6bf7f0d8debb" datatype="html">
<source>Remove</source>
<target state="translated">Quitar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
<note priority="1" from="description">Remove</note>
</trans-unit>
<trans-unit id="6219551536751479443" datatype="html">
<source>Finished downloading</source>
<target state="translated">Descarga finalizada</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="5947241266456580665" datatype="html">
<source>Download failed</source>
<target state="translated">La descarga fracasó</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="8443034725057696949" datatype="html">
<source>Task finished</source>
<target state="translated">Tarea terminada</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">19</context>
</context-group>
</trans-unit>
<trans-unit id="5709555629190115111" datatype="html">
<source>View task</source>
<target state="translated">Ver la tarea</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="5a105e7bd7e7db6ea211fe950fc9f317379acceb" datatype="html">
<source>No notifications available</source>
<target state="translated">No hay notificaciones disponibles</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">No notifications available</note>
</trans-unit>
<trans-unit id="6876310993601590130" datatype="html">
<source>Download completed</source>
<target state="translated">Descarga completa</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">23</context>
</context-group>
</trans-unit>
<trans-unit id="6437411876967154040" datatype="html">
<source>Audio only</source>
<target state="translated">Solo el audio</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">60</context>
</context-group>
</trans-unit>
<trans-unit id="4665451070906079743" datatype="html">
<source>Favorited</source>
<target state="translated">Favorito</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">65</context>
</context-group>
</trans-unit>
<trans-unit id="6268070779441507380" datatype="html">
<source>Download Date</source>
<target state="translated">Fecha de la descarga</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">13</context>
</context-group>
</trans-unit>
<trans-unit id="3533826530554274875" datatype="html">
<source>Upload Date</source>
<target state="translated">Fecha en la que se subió</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">17</context>
</context-group>
</trans-unit>
<trans-unit id="8953033926734869941" datatype="html">
<source>Name</source>
<target state="translated">Nombre</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">21</context>
</context-group>
</trans-unit>
<trans-unit id="2492098975665776610" datatype="html">
<source>File Size</source>
<target state="translated">Tamaño del archivo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="7410432243549869948" datatype="html">
<source>Duration</source>
<target state="translated">Duración</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/sort-property/sort-property.component.ts</context>
<context context-type="linenumber">29</context>
</context-group>
</trans-unit>
<trans-unit id="ea2b65121b93921fe54692025da9b9e3ce779ad5" datatype="html">
<source>Task settings - <x id="INTERPOLATION" equiv-text="{{task.title}}"/></source>
<target state="translated">Configuración de las tareas - <x id="INTERPOLATION" equiv-text="{{task.title}}"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<note priority="1" from="description">Task settings</note>
</trans-unit>
<trans-unit id="c65dd978b3c7566551c0ebefb234c2d41942b847" datatype="html">
<source>Delete files older than</source>
<target state="translated">Eliminar los archivos anteriores a</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">6</context>
</context-group>
<note priority="1" from="description">Delete files older than</note>
</trans-unit>
<trans-unit id="56b1a3c93fb95fed1805005c561a5e431d57ffae" datatype="html">
<source>Blacklist all files</source>
<target state="translated">Lista negra de todos los archivos</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">11</context>
</context-group>
<note priority="1" from="description">Blacklist deleted files</note>
</trans-unit>
<trans-unit id="9aa1b4779a515170b297d2c0507e6ff9d2e3e0e0" datatype="html">
<source>Blacklist deleted subscription files</source>
<target state="translated">Lista negra de los archivos de la suscripción eliminados</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">14</context>
</context-group>
<note priority="1" from="description">Blacklist deleted subscription files</note>
</trans-unit>
<trans-unit id="cdf5297d8d080a78e8b10debc5c38b7845a3cbe7" datatype="html">
<source>Do not ask for confirmation</source>
<target state="translated">No pedir confirmación</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/task-settings/task-settings.component.html</context>
<context context-type="linenumber">19</context>
</context-group>
<note priority="1" from="description">Do not ask for confirmation</note>
</trans-unit>
<trans-unit id="9176960997786930103" datatype="html">
<source>Error for: <x id="PH" equiv-text="task['title']"/></source>
<target state="translated">Error para: <x id="PH" equiv-text="task['title']"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/tasks/tasks.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="11a0771f88158a540a54e0e4ec5d25733d65fc0e" datatype="html">
<source>Favorite</source>
<target state="translated">Favorito</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Favorite button</note>
</trans-unit>
<trans-unit id="1d4fa01d25990f60abf21c3a451fa8ba262b7912" datatype="html">
<source>Unfavorite</source>
<target state="translated">No es favorito</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">27</context>
</context-group>
<note priority="1" from="description">Unfavorite button</note>
</trans-unit>
<trans-unit id="5ac5a0e5ffe8e5623b40696f4c2403c17349271f" datatype="html">
<source>Sidepanel mode</source>
<target state="translated">Modo del panel lateral</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/about-dialog/about-dialog.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<note priority="1" from="description">Sidepanel mode</note>
</trans-unit>
<trans-unit id="1f2809e6a99d511fdb6eaf041d785fe54d0680cc" datatype="html">
<source>File card size</source>
<target state="translated">Tamaño de la tarjeta del archivo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/about-dialog/about-dialog.component.html</context>
<context context-type="linenumber">54</context>
</context-group>
<note priority="1" from="description">File card size</note>
</trans-unit>
<trans-unit id="d618f383a0ea2458eeb945a85190d4a002ea394b" datatype="html">
<source>Arg</source>
<target state="translated">Realidad virtual</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
<note priority="1" from="description">Arg</note>
</trans-unit>
<trans-unit id="c35ef0f03a863d33b04aae6807f140397a50f491" datatype="html">
<source>Generate RSS URL</source>
<target state="translated">Generar la url para RSS</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">1</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">306</context>
</context-group>
<note priority="1" from="description">Generate RSS URL</note>
</trans-unit>
<trans-unit id="1a9b415816364f554ee411020e65219092655271" datatype="html">
<source>Title filter</source>
<target state="translated">Filtrar por título</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">8</context>
</context-group>
<note priority="1" from="description">Title filter</note>
</trans-unit>
<trans-unit id="9aa62bf1a535a97a4d752bbc5cf1c31af0f0c1f7" datatype="html">
<source>Supports regex</source>
<target state="translated">Admitir expresiones regulares</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">10</context>
</context-group>
<note priority="1" from="description">Supports regex</note>
</trans-unit>
<trans-unit id="e08a77594f3d89311cdf6da5090044270909c194" datatype="html">
<source>User</source>
<target state="translated">Usuario</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">25</context>
</context-group>
<note priority="1" from="description">User</note>
</trans-unit>
<trans-unit id="c2faa86201eab08b5b39b5437f96ab9432e125e7" datatype="html">
<source>Item limit</source>
<target state="translated">Límite del elemento</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">46</context>
</context-group>
<note priority="1" from="description">Item limit</note>
</trans-unit>
<trans-unit id="b78a98bc54259a29cf6250dbaeab5fe11fae91cf" datatype="html">
<source>Favorited</source>
<target state="translated">Favoritos</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/generate-rss-url/generate-rss-url.component.html</context>
<context context-type="linenumber">51</context>
</context-group>
<note priority="1" from="description">Favorited</note>
</trans-unit>
<trans-unit id="8336047719608684263" datatype="html">
<source>Unsubscribe from <x id="subscription name" equiv-text="this.sub['name']"/></source>
<target state="translated">Cancelar la suscripción a <x id="subscription name" equiv-text="this.sub['name']"/></target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="1698114086921246480" datatype="html">
<source>Unsubscribe</source>
<target state="translated">Darse de baja</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="1091872159779006651" datatype="html">
<source>You must input a time!</source>
<target state="translated">¡Debes ingresar una hora!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.ts</context>
<context context-type="linenumber">70</context>
</context-group>
</trans-unit>
<trans-unit id="d57c023a4cf63b2f12c10328c15b636ff18929aa" datatype="html">
<source>Best</source>
<target state="translated">El mejor</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/main/main.component.html</context>
<context context-type="linenumber">24,25</context>
</context-group>
<note priority="1" from="description">Best</note>
</trans-unit>
<trans-unit id="35cf4cdcedc8ef3f94b6100e0d86836e31dbb908" datatype="html">
<source>Force autoplay</source>
<target state="translated">Forzar la reproducción automática</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">235</context>
</context-group>
<note priority="1" from="description">Force autoplay setting</note>
</trans-unit>
<trans-unit id="37469c9f3e31d95087fa22b6c9c3bc64adf1692d" datatype="html">
<source>Enable RSS Feed</source>
<target state="translated">Activar la fuente RSS</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">304</context>
</context-group>
<note priority="1" from="description">Enable RSS Feed setting</note>
</trans-unit>
<trans-unit id="61d6b5fa4311b1c617b66dad72496f9dd43b07b4" datatype="html">
<source>Be careful enabling this with multi-user mode! User data may be exposed.</source>
<target state="translated">¡Ten cuidado al habilitar esto con el modo multiusuario! Los datos del usuario pueden estar expuestos.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">305</context>
</context-group>
<note priority="1" from="description">RSS Feed prefix</note>
</trans-unit>
<trans-unit id="fd467148a18f0921c10d116d4e0174fe29452be4" datatype="html">
<source>See documentation here.</source>
<target state="translated">Consulte la documentación aquí.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">307</context>
</context-group>
<note priority="1" from="description">RSS feed documentation</note>
</trans-unit>
<trans-unit id="8bcabdf6b16cad0313a86c7e940c5e3ad7f9f8ab" datatype="html">
<source>Notifications</source>
<target state="translated">Notificaciones</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">376</context>
</context-group>
<note priority="1" from="description">Notifications settings label</note>
</trans-unit>
<trans-unit id="c40370dc182b5e4333828b70f7478bde58bb5cfe" datatype="html">
<source>Enable notifications</source>
<target state="translated">Activar las notificaciones</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">382</context>
</context-group>
<note priority="1" from="description">Enable notifications setting</note>
</trans-unit>
<trans-unit id="33a7c6d5ff3515fa237f1fd4e43df8b65373954d" datatype="html">
<source>Enable all notifications</source>
<target state="translated">Activar toda las notificaciones</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">385</context>
</context-group>
<note priority="1" from="description">Enable all notifications setting</note>
</trans-unit>
<trans-unit id="9c562d26e041390ecc3f49dabc51cc50ebba7469" datatype="html">
<source>Allowed notification types</source>
<target state="translated">Tipos de notificaciones permitidos</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">389</context>
</context-group>
<note priority="1" from="description">Allowed notification types</note>
</trans-unit>
<trans-unit id="c5dc5fbcce45e9b1530e2a5c2baa8ebe722aef4c" datatype="html">
<source>Download complete</source>
<target state="translated">Descarga completa</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">391</context>
</context-group>
<note priority="1" from="description">Download complete</note>
</trans-unit>
<trans-unit id="3ffd9490f3a4c0b24021d25e1dc71fcfe5d39cd6" datatype="html">
<source>Download error</source>
<target state="translated">Error en la descarga</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">392</context>
</context-group>
<note priority="1" from="description">Download error</note>
</trans-unit>
<trans-unit id="2361a4f76caaa4574803fbcdca8b0a47c91cc7ed" datatype="html">
<source>Task finished</source>
<target state="translated">Tarea finalizada</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">393</context>
</context-group>
<note priority="1" from="description">Task finished</note>
</trans-unit>
<trans-unit id="9e766e11a9de375907aaf566897ecc6dac393ebc" datatype="html">
<source>Webhook URL</source>
<target state="translated">URL del webhook</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">399</context>
</context-group>
<note priority="1" from="description">webhook URL</note>
</trans-unit>
<trans-unit id="8c1bf02206fbc371ff69ab1b7e35a17ba29d169d" datatype="html">
<source>Use ntfy API</source>
<target state="translated">Utilizar la API de ntfy</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">405</context>
</context-group>
<note priority="1" from="description">Use ntfy API setting</note>
</trans-unit>
<trans-unit id="06f503e492d6dbcf59e7b9c412ca86913d718689" datatype="html">
<source>ntfy topic URL</source>
<target state="translated">URL del tema ntfy</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">409</context>
</context-group>
<note priority="1" from="description">ntfy topic URL</note>
</trans-unit>
<trans-unit id="0cfc9cfe7cd8ea14bc053693b28872da739af02c" datatype="html">
<source>See docs here.</source>
<target state="translated">Consulta la documentación aquí.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">411</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">421</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">428</context>
</context-group>
<note priority="1" from="description">ntfy API setting hint</note>
</trans-unit>
<trans-unit id="5827fde8fcafdd55ae80921ad3ad4aa01012e203" datatype="html">
<source>Use gotify API</source>
<target state="translated">Utilizar la Api de gotify</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">415</context>
</context-group>
<note priority="1" from="description">Use gotify API setting</note>
</trans-unit>
<trans-unit id="55f559d6f666b945479f534b0c182f70cd0a8a69" datatype="html">
<source>Gotify server URL</source>
<target state="translated">URL del servidor Gotify</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">419</context>
</context-group>
<note priority="1" from="description">Gotify server URL</note>
</trans-unit>
<trans-unit id="b770c48628d98cb4633d6a17e3f0ba0265376af5" datatype="html">
<source>Gotify app token</source>
<target state="translated">Token de la aplicación Gotify</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">426</context>
</context-group>
<note priority="1" from="description">Gotify app token</note>
</trans-unit>
<trans-unit id="38992954440d6afb54aeb58af12ca0123ee5e26e" datatype="html">
<source>Use Telegram API</source>
<target state="translated">Utilizar la API de Telegram</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">432</context>
</context-group>
<note priority="1" from="description">Use Telegram API setting</note>
</trans-unit>
<trans-unit id="2e076ff9866213d0815961c494aa48b177046b9d" datatype="html">
<source>Telegram bot token</source>
<target state="translated">Tomen del bot de Telegram</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">436</context>
</context-group>
<note priority="1" from="description">Telegram bot token</note>
</trans-unit>
<trans-unit id="eeb0ba2e4743901d8f5eebd9a3529aa1f236c608" datatype="html">
<source>Create bot here.</source>
<target state="translated">Crear un bot aquí.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">438</context>
</context-group>
<note priority="1" from="description">Telegram bot create link</note>
</trans-unit>
<trans-unit id="144e1a21ebe8fa238f88d2ac27515ed711cfc9a0" datatype="html">
<source>Telegram chat ID</source>
<target state="translated">ID del chat de Telegram</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">443</context>
</context-group>
<note priority="1" from="description">Telegram chat ID</note>
</trans-unit>
<trans-unit id="3e420c675b8f3f3702576d52e8bb6e8e1d3feda0" datatype="html">
<source>How do I get the chat ID?</source>
<target state="translated">¿Cómo obtengo la identificación del chat?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">445</context>
</context-group>
<note priority="1" from="description">Telegram chat ID help</note>
</trans-unit>
<trans-unit id="a4ed8eba1e057e67d5c2d87b52230f182b3dae4e" datatype="html">
<source>Restart required.</source>
<target state="translated">Reinicio requerido.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">465</context>
</context-group>
<note priority="1" from="description">Restart required hint</note>
</trans-unit>
<trans-unit id="6785427850041119037" datatype="html">
<source>Delete category</source>
<target state="translated">Borrar la categoría</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="2481374649045841364" datatype="html">
<source>Would you like to delete <x id="category name" equiv-text="category['name']"/>?</source>
<target state="translated">¿Deseas eliminar <x id="category name" equiv-text="category['name']"/>?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">174</context>
</context-group>
</trans-unit>
<trans-unit id="3371159074051387771" datatype="html">
<source>Failed to delete <x id="category name" equiv-text="category['name']"/>!</source>
<target state="translated">¡No se ha podido eliminar <x id="category name" equiv-text="category['name']"/>!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">187</context>
</context-group>
</trans-unit>
<trans-unit id="8c6e24eab969d9f63a8a0e9d617aee3b99e28ae6" datatype="html">
<source>Play all</source>
<target state="translated">Reproducir todo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscription/subscription/subscription.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<note priority="1" from="description">Play all</note>
</trans-unit>
<trans-unit id="674a999dd48d7da565ffdd105602261b8a4761ea" datatype="html">
<source>Download zip</source>
<target state="translated">Descargar en un archivo zip</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscription/subscription/subscription.component.html</context>
<context context-type="linenumber">18</context>
</context-group>
<note priority="1" from="description">Download zip</note>
</trans-unit>
<trans-unit id="0ed98b4c6ec1db6708a963e8a2699478ac97f55c" datatype="html">
<source>Add subscription</source>
<target state="translated">Añadir suscripción</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/subscriptions/subscriptions.component.html</context>
<context context-type="linenumber">60</context>
</context-group>
<note priority="1" from="description">Add subscription</note>
</trans-unit>
<trans-unit id="28da11220a3377ddce3c7948825d33101f142782" datatype="html">
<source>Extractor</source>
<target state="translated">Extractor</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">57</context>
</context-group>
<note priority="1" from="description">Extractor</note>
</trans-unit>
<trans-unit id="8425787787095143143" datatype="html">
<source>Would you like to delete <x id="selected archives amount" equiv-text="this.selection.selected.length"/> archive(s)?</source>
<target state="translated">¿ Quieres borrar el(los) archivo(s) de <x id="selected archives amount" equiv-text="this.selection.selected.length"/> ?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.ts</context>
<context context-type="linenumber">153</context>
</context-group>
</trans-unit>
<trans-unit id="8564202903947049539" datatype="html">
<source>Play</source>
<target state="translated">Reproducir</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="8643601595923420698" datatype="html">
<source>Retry download</source>
<target state="translated">Reintertar la descarga</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="8571838164752006148" datatype="html">
<source>View error</source>
<target state="translated">Ver el error</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications-list/notifications-list.component.ts</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="1879058637439215882" datatype="html">
<source>Download error</source>
<target state="translated">Error al descargar</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="7911845622864460134" datatype="html">
<source>Video only</source>
<target state="translated">Solo el video</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">55</context>
</context-group>
</trans-unit>
<trans-unit id="4578192247039196794" datatype="html">
<source>Task</source>
<target state="translated">Tarea</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="5000203534763292992" datatype="html">
<source>Download restarted!</source>
<target state="translated">¡Descarga reiniciada!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/notifications/notifications.component.ts</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="7b4585a9072f3c1292972c14a3d0e14978fbfc9c" datatype="html">
<source>Delete old files:</source>
<target state="translated">Eliminar los archivos antiguos:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/tasks/tasks.component.html</context>
<context context-type="linenumber">66</context>
</context-group>
<note priority="1" from="description">Delete old files</note>
</trans-unit>
<trans-unit id="784837056777689544" datatype="html">
<source>Would you like to unsubscribe from <x id="subscription name" equiv-text="this.sub['name']"/>?</source>
<target state="translated">¿Deseas anular tu suscripción a <x id="subscription name" equiv-text="this.sub['name']"/>?</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="7332320960988475089" datatype="html">
<source>Successfully deleted <x id="category name" equiv-text="category['name']"/>!</source>
<target state="translated">¡Se ha eliminado correctamente <x id="category name" equiv-text="category['name']"/>!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">183</context>
</context-group>
</trans-unit>
<trans-unit id="7cedb649779673568447b994463b2882c4e0436a" datatype="html">
<source>Slack Webhook URL</source>
<target state="translated">URL del webhook de Slack</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">397</context>
</context-group>
<note priority="1" from="description">Slack Webhook URL</note>
</trans-unit>
<trans-unit id="3264d82792954815be755b3da01e2625458711dc" datatype="html">
<source>Discord Webhook URL</source>
<target state="translated">URL del webhook de Discord</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">390</context>
</context-group>
<note priority="1" from="description">Discord Webhook URL</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -4133,6 +4133,60 @@
<context context-type="linenumber">18</context>
</context-group>
</trans-unit>
<trans-unit id="c748ac656af9f13998206ef2c52018dd418b0483" datatype="html">
<source>Archives</source>
<target state="translated">Arsip</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/app.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Archives menu label</note>
</trans-unit>
<trans-unit id="5ca707824ab93066c7d9b44e1b8bf216725c2c22" datatype="html">
<source>Filter</source>
<target state="translated">Penyaringan</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">3</context>
</context-group>
<note priority="1" from="description">Filter</note>
</trans-unit>
<trans-unit id="45cc8ca94b5a50842a9a8ef804a5ab089a38ae5c" datatype="html">
<source>ID</source>
<target state="translated">ID</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">47</context>
</context-group>
<note priority="1" from="description">ID</note>
</trans-unit>
<trans-unit id="28da11220a3377ddce3c7948825d33101f142782" datatype="html">
<source>Extractor</source>
<target state="needs-translation">Extractor</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">57</context>
</context-group>
<note priority="1" from="description">Extractor</note>
</trans-unit>
<trans-unit id="c150a30bbbdb175b4d08820196a9acb66b167653" datatype="html">
<source>Archives empty</source>
<target state="translated">Arsip kosong</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">72</context>
</context-group>
<note priority="1" from="description">Archives empty</note>
</trans-unit>
<trans-unit id="51a161ce175abcd44f6c6cbd0e996681bf553ac3" datatype="html">
<source>Delete selected</source>
<target state="translated">Hapus yang dipilih</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/archive-viewer/archive-viewer.component.html</context>
<context context-type="linenumber">77</context>
</context-group>
<note priority="1" from="description">Delete selected</note>
</trans-unit>
</body>
</file>
</xliff>

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