Compare commits

...

98 Commits

Author SHA1 Message Date
Isaac Abadi
3e3a552392 Updated Angular to v14 2022-07-06 01:19:07 -04:00
Isaac Abadi
24475386f9 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material 2022-07-01 00:55:03 -04:00
Isaac Abadi
55268301f6 Removed config env vars set message if no items were set 2022-07-01 00:54:38 -04:00
Isaac Abadi
faa76abbbd Fixed issue where setting resolution in a sub would instead require that resolution to exist (#678 and #330) 2022-07-01 00:51:30 -04:00
Glassed Silver
b827f8f0cc Merge pull request #687 from Tzahi12345/add-permissions-for-tasks-manager
Add permissions for tasks manager
2022-06-30 16:34:08 +02:00
Glassed Silver
b6b61c42d4 Merge pull request #677 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2022-06-30 16:22:45 +02:00
Glassed Silver
6af1ce4092 Merge pull request #683 from adripo/patch-1
fix: #682 install tzdata
2022-06-30 16:20:40 +02:00
Glassed Silver
303d0015c6 Merge pull request #688 from adripo/patch-2
fix: remove exposed ports for mongo
2022-06-30 16:04:45 +02:00
adripo
56db43da79 fix: remove exposed ports for mongo
exposed ports between services in the same stack is not needed
2022-06-30 13:11:21 +02:00
Isaac Abadi
64b1a9e5c0 Updated mangled translations
Improved automatic translations command
2022-06-30 01:34:52 -04:00
Isaac Abadi
48f0a700ab Paginator is now always visible to avoid case where file type filter permanently disappears 2022-06-30 01:30:06 -04:00
Isaac Abadi
768798c6b3 Fixed issue where one-off playlist downlaods would only include the first video 2022-06-30 01:29:18 -04:00
Isaac Abadi
9d1f93acfb Added translations for manage-role component 2022-06-30 00:44:11 -04:00
Isaac Abadi
077a0d8fdb Added migration to add tasks manager permission for admin role
All routes are now properly protected against logged in users w/o permissions
2022-06-30 00:43:57 -04:00
Tzahi12345
c9359f172e Merge pull request #681 from adripo/node-config-fix
fix: node-config fix environment variable
2022-06-29 23:46:45 -04:00
Tzahi12345
d6dc4756a7 Merge pull request #680 from adripo/remove-container-config-env
fix: remove write_ytdl_config
2022-06-29 23:44:50 -04:00
adripo
9bc9b17294 fix: #682 install tzdata 2022-06-29 23:52:25 +02:00
adripo
80d3580447 fix: node-config fix environment variable 2022-06-29 20:20:27 +02:00
adripo
3f15f3bcaf fix: remove env variable check 2022-06-29 19:44:13 +02:00
Tzahi12345
703848e4e5 Merge pull request #675 from FoxxMD/unknownFormatsFix
Fixed parsing expected file size for videos with no known formats
2022-06-29 11:31:32 -04:00
Sebastian Danielsson
934965720e Translated using Weblate (Swedish)
Currently translated at 31.4% (120 of 381 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/sv/
2022-06-29 17:17:36 +02:00
AbsurdUsername
bb4a882d19 Translated using Weblate (Italian)
Currently translated at 100.0% (381 of 381 strings)

Translation: YoutubeDL-Material/ytdl-material
Translate-URL: https://hosted.weblate.org/projects/youtubedl-material/ytdl-material/it/
2022-06-29 17:17:36 +02:00
FoxxMD
74315b8c76 Fixed parsing expected file size for videos with no known formats 2022-06-29 09:37:26 -04:00
Glassed Silver
a9e95c5bb8 Merge pull request #672 from weblate/weblate-youtubedl-material-ytdl-material
Translations update from Hosted Weblate
2022-06-28 07:20:04 +02:00
Sebastian
fe45a889c9 Added translation using Weblate (Swedish) 2022-06-27 22:14:59 +02:00
Isaac Abadi
e726e991cc Partially reverted fecefde3ad 2022-06-27 13:12:53 -04:00
Isaac Abadi
940267651d Hotfix for bug where a sub with missing videos would cause a crash during migration #670 2022-06-27 03:52:05 -04:00
Isaac Abadi
2dc68139f7 Streaming-only subs are now actually paused
DB transfers in any direction now generate backups and associated logs are set to info
2022-06-27 00:08:41 -04:00
Isaac Abadi
301451d021 Fixed issue where tasks would not initialize properly after migration until server restart
streaming-only subscriptions now autopause due to deprecated
2022-06-26 23:47:03 -04:00
Isaac Abadi
a7f8795e7e Fixed missing latest tag on docker-release 2022-06-26 23:10:47 -04:00
Isaac Abadi
162094a9b9 File type filter now only appears with the paginator 2022-06-26 23:06:15 -04:00
Isaac Abadi
e843b4c97f Fixed crash during docker-release action 2022-06-26 22:07:21 -04:00
Isaac Abadi
c784091ad6 Fixed issue where docker-release action couldn't accept inputs (2) 2022-06-26 22:04:42 -04:00
Isaac Abadi
fb404d3cee Fixed issue where docker-release action couldn't accept inputs 2022-06-26 22:02:23 -04:00
Tzahi12345
68c2ee26ff Merge pull request #657 from Tzahi12345/4.3-prep
4.3 Prep
2022-06-26 21:13:51 -04:00
Isaac Abadi
742129bf6a Updated source translation file 2022-06-26 20:58:59 -04:00
Glassed Silver
9a250b5c58 Update unified-file-card.component.html 2022-06-27 02:06:13 +02:00
Isaac Abadi
86cbfea08f UI & logs now use proper fork name rather than just youtube-dl 2022-06-25 17:22:08 -04:00
Isaac Abadi
19a3ffc118 Minor code cleanup 2022-06-24 19:27:46 -04:00
Isaac Abadi
d225e84a03 Fixed issue where errored single videos in playlist/sub downloads in yt-dlp would cause the entire sub check to fail (#493) 2022-06-24 19:26:34 -04:00
Isaac Abadi
a4a0045475 Fixed error where sub with no videos would crash if none existed and redownload fresh uploads was enabled (#480) 2022-06-24 17:33:10 -04:00
Isaac Abadi
573cca0b2f Completed deprecation of streamingOnly mode for subscriptions 2022-06-24 17:29:06 -04:00
Isaac Abadi
fecefde3ad Removed pm2 global install in favor of local node_modules (#662) 2022-06-24 13:14:28 -04:00
Isaac Abadi
d300a8a3c6 Merge branch 'master' of https://github.com/Tzahi12345/YoutubeDL-Material into 4.3-prep 2022-06-24 13:02:26 -04:00
Isaac Abadi
c6b7e7bc4c Added rpi 4 as a "special case" in the readme 2022-06-24 00:13:36 -04:00
Isaac Abadi
0ffd7022d0 Removed nodemon
Fixed several dependency vulnerabilities
2022-06-23 19:49:48 -04:00
Isaac Abadi
a0c36bf1a1 Subscription videos now skip the collect info step during download process, reducing calls to video host 2022-06-23 18:37:54 -04:00
Isaac Abadi
64b4b5a2b4 Fixed #600, where selecting a max resolution for subscriptions was broken 2022-06-23 01:44:55 -04:00
Isaac Abadi
b1448d95e5 Added resolution and audio bitrate to video info dialog 2022-06-23 01:44:19 -04:00
Isaac Abadi
a2d1b154a3 Fixed broken translations for snackbars 2022-06-23 01:22:22 -04:00
Isaac Abadi
2ba1dc6333 Archive refactor to improve reliability and consistency 2022-06-23 01:10:18 -04:00
Isaac Abadi
314a5047d6 Updated translations source file 2022-06-21 22:48:03 -04:00
Isaac Abadi
fb6cf8548d Added spinner during db connection test 2022-06-21 22:45:54 -04:00
Isaac Abadi
adbe7f95d5 Fixed issue where reopening file info dialog after editing metadata would show stale data 2022-06-21 22:45:38 -04:00
Isaac Abadi
55d4f746c3 Fixed issue where mongodb attempted to connect even when using local DB 2022-06-21 22:28:30 -04:00
Isaac Abadi
6d3f5e6c94 Improved UI for download only mode
Updated API model for DatabaseFile
2022-06-21 21:38:58 -04:00
Isaac Abadi
bec158f65d Fixed an issue where if file manager and download only mode were enabled, files would download twice 2022-06-21 21:07:34 -04:00
Isaac Abadi
6fa4296edf Hotfix for #661 startup crash 2022-06-21 20:24:48 -04:00
Isaac Abadi
636f7b16a8 Changed default downloader to yt-dlp 2022-06-21 02:23:35 -04:00
Isaac Abadi
39abc3efcf Utilize yt-dlp filesize approximations if available 2022-06-21 02:21:04 -04:00
Glassed Silver
2edc00c950 Merge pull request #659 from GlassedSilver/master
Remove releases folder from root dir -> fixes #625
2022-06-21 08:11:15 +02:00
Isaac Abadi
6fe0cd5649 Updated pip target 2022-06-21 02:01:36 -04:00
Isaac Abadi
b6de6d08fa Fixed potential command injection vulnerability 2022-06-21 01:58:35 -04:00
Isaac Abadi
cddd280206 Fixed issue where pip was missing in Docker
Temp twitch chat files now get auto removed
2022-06-21 01:51:32 -04:00
Isaac Abadi
9d1624d569 Removed recommendation of nightly, updated language in README, and updated SECURITY.md 2022-06-21 01:42:15 -04:00
Isaac Abadi
da8c23d3ef Updated dockerfile to include tcd installation 2022-06-21 01:40:42 -04:00
Isaac Abadi
901e87a681 Fixed loading spinner in create playlist dialog 2022-06-21 01:23:28 -04:00
Isaac Abadi
7bfb2976fe Fixed twitch chat downloads, tcd is now required and client secret must be supplied 2022-06-21 01:22:58 -04:00
GlassedSilver
295781b1f1 Remove releases folder from root dir -> fixes #625 2022-06-21 00:55:36 +02:00
Isaac Abadi
cbdd1a6253 Improved snackbar translations support 2022-06-20 16:25:55 -04:00
Tzahi12345
c91e51de15 Merge pull request #655 from Tzahi12345/improved-downloads-management
Improved downloads management
2022-06-20 16:07:02 -04:00
Isaac Abadi
4c6c15d3a3 Code cleanup 2022-06-20 16:05:54 -04:00
Isaac Abadi
0951e445ac Removed modify playlsit component 2022-06-20 16:05:37 -04:00
Isaac Abadi
e1cb56e8e9 Unified create and modify playlist components 2022-06-20 16:02:15 -04:00
Glassed Silver
05ee48ffb6 Merge pull request #604 from GlassedSilver/master
Added weekly (Tuesday) Build and Push Docker image + Added second fix-script
2022-06-20 15:08:39 +02:00
Isaac Abadi
7f47fb339b Updated modify playlist size
Fixed issue where playlist order could not be rearranged
2022-06-19 23:46:24 -04:00
Isaac Abadi
690cc38899 Updated playlist file selection to use recent videos component
Playlists are now file type agnostic

Updated translations
2022-06-19 23:09:30 -04:00
GlassedSilver
d912e44484 Moved Docker Weekly build to docker.yml 2022-06-20 01:50:35 +02:00
GlassedSilver
93e3dafb03 Merge branch 'master' of https://github.com/GlassedSilver/YoutubeDL-Material into master 2022-06-20 01:44:59 +02:00
Isaac Abadi
b5ee0d365c Subscription file cards are now replaced with unified file cards
GetAllFiles can now filter by sub_id

Improved API models and added request body docs for GetAllFiles
2022-06-19 01:14:59 -04:00
Isaac Abadi
0dd617b438 Updated translation source file 2022-06-18 20:11:47 -04:00
Isaac Abadi
aca86e0228 Added test for ID3 tagging 2022-06-18 20:09:11 -04:00
Isaac Abadi
895c385d6b Updated version to 4.3 2022-06-18 19:36:20 -04:00
Glassed Silver
b861e54a51 Merge branch 'Tzahi12345:master' into master 2022-05-23 01:34:37 +02:00
GlassedSilver
8d8c52e009 002-fix_dupes_per_archive: prep consistency w/ 003 2022-05-15 05:48:06 +02:00
GlassedSilver
37107148eb Fix Scripts: Small improvements to usage instr. 2022-05-15 04:47:16 +02:00
GlassedSilver
29273e2775 fix-scripts: specifically target bash over POSIX 2022-05-14 03:58:46 +02:00
GlassedSilver
24659213c2 Make fix-scripts executable by default 2022-05-09 22:41:51 +02:00
GlassedSilver
2354749c2f 002-fix_dupes_per_archive fix script: small impr. 2022-05-09 07:10:15 +02:00
GlassedSilver
5cd3669634 002-fix_dupes_per_archive: Added helpful advice 2022-05-09 06:33:14 +02:00
GlassedSilver
2328502c0d 002-fix_dupes_per_archive: Add info messsages 2022-05-09 06:16:32 +02:00
GlassedSilver
301d8a6ae3 Adding fix script for dupe lines in archives 2022-05-09 06:04:21 +02:00
Glassed Silver
8529fe152c Merge branch 'Tzahi12345:master' into master 2022-05-08 08:12:52 +02:00
Glassed Silver
feff8b2461 revert pm2 config cluster_mode
at least for now, implementation needs more research
2022-05-05 18:24:06 +02:00
GlassedSilver
9ab15dd5dd Added weekly (TUES) Docker Build and Push 2022-05-05 13:24:35 +02:00
Glassed Silver
76e4635338 Merge branch 'Tzahi12345:master' into master 2022-05-05 13:20:27 +02:00
Glassed Silver
c97b88614f Merge branch 'Tzahi12345:master' into master 2022-05-05 00:39:16 +02:00
GlassedSilver
8738b13cd1 Usage of PM2 Cluster Mode to increase performance 2022-05-05 00:38:40 +02:00
89 changed files with 9224 additions and 3886 deletions

View File

@@ -36,7 +36,7 @@ jobs:
- name: Set image tag
id: tags
run: |
if [ ${{ github.event.action }} == "workflow_dispatch" ]; then
if [ "${{ github.event.inputs.tags }}" != "" ]; then
echo "::set-output name=tags::${{ github.event.inputs.tags }}"
elif [ ${{ github.event.action }} == "release" ]; then
echo "::set-output name=tags::${{ github.event.release.tag_name }}"
@@ -53,8 +53,8 @@ jobs:
${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_REPO }}
ghcr.io/${{ github.repository_owner }}/${{ secrets.DOCKERHUB_REPO }}
tags: |
raw=${{ steps.tags.outputs.tags }}
raw=latest
type=raw,value=${{ steps.tags.outputs.tags }}
type=raw,value=latest
- name: setup platform emulator
uses: docker/setup-qemu-action@v1

View File

@@ -13,6 +13,9 @@ on:
- '**.pem'
- '.dockerignore'
- '.gitignore'
schedule:
- cron: '34 4 * * 2'
workflow_dispatch:
jobs:
build-and-push:

View File

@@ -9,15 +9,16 @@ RUN sh ./ffmpeg-fetch.sh
# Create our Ubuntu 22.04 with node 16
# Go to 20.04
FROM ubuntu:20.04 AS base
ENV DEBIAN_FRONTEND=noninteractive
ARG DEBIAN_FRONTEND=noninteractive
ENV UID=1000
ENV GID=1000
ENV USER=youtube
ENV NO_UPDATE_NOTIFIER=true
ENV PM2_HOME=/app/pm2
ENV ALLOW_CONFIG_MUTATIONS=true
RUN groupadd -g $GID $USER && useradd --system -m -g $USER --uid $UID $USER && \
apt update && \
apt install -y --no-install-recommends curl ca-certificates && \
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 && \
@@ -49,18 +50,20 @@ RUN npm config set strict-ssl false && \
FROM base
RUN npm install -g pm2 && \
apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 atomicparsley && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 python3-pip atomicparsley && \
apt clean && \
rm -rf /var/lib/apt/lists/*
RUN pip install tcd
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=backend ["/app/","/app/"]
COPY --chown=$UID:$GID --from=frontend [ "/build/backend/public/", "/app/public/" ]
RUN chmod +x /app/fix-scripts/*.sh
# Add some persistence data
#VOLUME ["/app/appdata"]
EXPOSE 17442
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD [ "pm2-runtime","--raw","pm2.config.js" ]
CMD [ "npm","start" ]

View File

@@ -1,2 +1,2 @@
FROM tzahi12345/youtubedl-material:nightly
CMD [ "pm2-runtime", "pm2.config.js" ]
FROM tzahi12345/youtubedl-material:latest
CMD [ "npm", "start" ]

View File

@@ -97,6 +97,11 @@ paths:
summary: Get all files
description: Gets all files and playlists stored in the db
operationId: get-getAllFiles
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GetAllFilesRequest'
responses:
'200':
description: OK
@@ -1724,6 +1729,41 @@ components:
description: All video playlists
items:
$ref: '#/components/schemas/Playlist'
GetAllFilesRequest:
type: object
properties:
sort:
$ref: '#/components/schemas/Sort'
range:
type: array
items:
type: number
description: Two elements allowed, start index and end index
minItems: 2
maxItems: 2
text_search:
type: string
description: Filter files by title
file_type_filter:
$ref: '#/components/schemas/FileTypeFilter'
sub_id:
type: string
description: Include if you want to filter by subscription
Sort:
type: object
properties:
by:
type: string
description: Property to sort by
order:
type: number
description: 1 for ascending, -1 for descending
FileTypeFilter:
type: string
enum:
- audio_only
- video_only
- both
GetAllFilesResponse:
required:
- files
@@ -1786,7 +1826,6 @@ components:
required:
- name
- url
- streamingOnly
type: object
properties:
name:
@@ -1899,7 +1938,6 @@ components:
- uids
- playlistName
- thumbnailURL
- type
type: object
properties:
playlistName:
@@ -1908,8 +1946,6 @@ components:
type: array
items:
type: string
type:
$ref: '#/components/schemas/FileType'
thumbnailURL:
type: string
CreatePlaylistResponse:
@@ -1939,15 +1975,17 @@ components:
required:
- playlist
- success
- type
type: object
properties:
playlist:
$ref: '#/components/schemas/Playlist'
type:
$ref: '#/components/schemas/FileType'
success:
type: boolean
file_objs:
type: array
description: File objects for every uid in the playlist's uids property, in the same order
items:
$ref: '#/components/schemas/DatabaseFile'
GetPlaylistsRequest:
type: object
properties:
@@ -1972,13 +2010,10 @@ components:
DeletePlaylistRequest:
required:
- playlist_id
- type
type: object
properties:
playlist_id:
type: string
type:
$ref: '#/components/schemas/FileType'
DownloadFileRequest:
type: object
properties:
@@ -2385,6 +2420,16 @@ components:
type: number
local_view_count:
type: number
sub_id:
type: string
registered:
type: number
height:
type: number
description: In pixels, only for videos
abr:
type: number
description: In Kbps
Playlist:
required:
- uids
@@ -2466,6 +2511,8 @@ components:
type: string
sub_name:
type: string
prefetched_info:
type: object
Task:
required:
- key
@@ -2480,6 +2527,8 @@ components:
properties:
key:
type: string
title:
type: string
last_ran:
type: number
last_confirmed:
@@ -2560,7 +2609,6 @@ components:
- url
- type
- user_uid
- streamingOnly
- isPlaylist
- videos
type: object
@@ -2576,8 +2624,6 @@ components:
user_uid:
type: string
nullable: true
streamingOnly:
type: boolean
isPlaylist:
type: boolean
archive:

View File

@@ -12,16 +12,6 @@ Now with [Docker](#Docker) support!
<hr>
### USAGE OF THE NIGHTLY BUILDS IS HIGHLY RECOMMENDED.
For much better scaling with large datasets please run your YTDL-M instance with a MongoDB backend rather than the json file-based default.
It will fix a lot of performance problems (especially with datasets in the tens of thousands videos/audios)!
The (closed) issues as well as the project's Wiki will give you good starting points for your journey!
For MongoDB specifically there is [this little guide](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Setting-a-MongoDB-backend-to-use-as-database-provider-for-YTDL-M).
<hr>
## Getting Started
Check out the prerequisites, and go to the installation section. Easy as pie!
@@ -58,6 +48,7 @@ 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)
### Installing
@@ -91,7 +82,7 @@ Alternatively, you can port forward the port specified in the config (defaults t
### Host-specific instructions
If you're on a Synology NAS, unRAID or any other possible special case you can check if there's known issues or instructions both in the issue tracker and in the [Wiki!](https://github.com/Tzahi12345/YoutubeDL-Material/wiki#environment-specific-guideshelp)
If you're on a Synology NAS, unRAID, Raspberry Pi 4 or any other possible special case you can check if there's known issues or instructions both in the issue tracker and in the [Wiki!](https://github.com/Tzahi12345/YoutubeDL-Material/wiki#environment-specific-guideshelp)
### Setup
@@ -102,8 +93,6 @@ If you are looking to setup YoutubeDL-Material with Docker, this section is for
3. Run `docker-compose up` to start it up. If successful, it should say "HTTP(S): Started on port 17443" or something similar. This tells you the *container-internal* port of the application. Please check your `docker-compose.yml` file for the *external* port. If you downloaded the file as described above, it defaults to **8998**.
4. Make sure you can connect to the specified URL + *external* port, and if so, you are done!
NOTE: It is currently recommended that you use the `nightly` tag on Docker. To do so, simply update the docker-compose.yml `image` field so that it points to `tzahi12345/youtubedl-material:nightly`.
### Custom UID/GID
By default, the Docker container runs as non-root with UID=1000 and GID=1000. To set this to your own UID/GID, simply update the `environment` section in your `docker-compose.yml` like so:
@@ -114,6 +103,12 @@ environment:
GID: YOUR_GID
```
## MongoDB
For much better scaling with large datasets please run your YoutubeDL-Material instance with MongoDB backend rather than the json file-based default. It will fix a lot of performance problems (especially with datasets in the tens of thousands videos/audios)!
[Tutorial](https://github.com/Tzahi12345/YoutubeDL-Material/wiki/Setting-a-MongoDB-backend-to-use-as-database-provider-for-YTDL-M).
## API
[API Docs](https://youtubedl-material.stoplight.io/docs/youtubedl-material/Public%20API%20v1.yaml)

View File

@@ -2,16 +2,16 @@
## Supported Versions
Currently all work on this project goes into the nightly builds.
4.2's RELEASE build is now quite old and should be considered legacy.
We urge users to use the nightly releases, because the project
constantly sees fixes.
If you would like to see the latest updates, use the `nightly` tag on Docker.
| Version | Supported |
| ------------- | ------------------ |
| 4.2 Nightlies | :white_check_mark: |
| 4.2 Release | :x: |
| < 4.2 | :x: |
If you'd like to stick with more stable releases, use the `latest` tag on Docker or download the [latest release here](https://github.com/Tzahi12345/YoutubeDL-Material/releases/latest).
| Version | Supported |
| -------------------- | ------------------ |
| 4.3 Docker Nightlies | :white_check_mark: |
| 4.3 Release | :white_check_mark: |
| 4.2 Release | :x: |
| < 4.2 | :x: |
## Reporting a Vulnerability

View File

@@ -182,7 +182,6 @@
}
}
},
"defaultProject": "youtube-dl-material",
"schematics": {
"@schematics/angular:component": {
"prefix": "app",

View File

@@ -68,7 +68,8 @@ db.defaults(
configWriteFlag: false,
downloads: {},
subscriptions: [],
files_to_db_migration_complete: false
files_to_db_migration_complete: false,
tasks_manager_role_migration_complete: false
}).write();
users_db.defaults(
@@ -101,7 +102,6 @@ let backendPort = null;
let useDefaultDownloadingAgent = null;
let customDownloadingAgent = null;
let allowSubscriptions = null;
let archivePath = path.join(__dirname, 'appdata', 'archives');
// other needed values
let url_domain = null;
@@ -148,16 +148,11 @@ if (fs.existsSync('version.json')) {
// don't overwrite config if it already happened.. NOT
// let alreadyWritten = db.get('configWriteFlag').value();
let writeConfigMode = process.env.write_ytdl_config;
// checks if config exists, if not, a config is auto generated
config_api.configExistsCheck();
if (writeConfigMode) {
setAndLoadConfig();
} else {
loadConfig();
}
setAndLoadConfig();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
@@ -188,13 +183,22 @@ async function checkMigrations() {
if (!new_db_system_migration_complete) {
logger.info('Beginning migration: 4.2->4.3+')
let success = await db_api.importJSONToDB(db.value(), users_db.value());
await tasks_api.setupTasks(); // necessary as tasks were not properly initialized at first
// sets migration to complete
db.set('new_db_system_migration_complete', true).write();
if (success) { logger.info('4.2->4.3+ migration complete!'); }
else { logger.error('Migration failed: 4.2->4.3+'); }
}
const tasks_manager_role_migration_complete = db.get('tasks_manager_role_migration_complete').value();
if (!tasks_manager_role_migration_complete) {
logger.info('Checking if tasks manager role permissions exist for admin user...');
const success = await auth_api.changeRolePermissions('admin', 'tasks_manager', 'yes');
if (success) logger.info('Task manager permissions check complete!');
else logger.error('Failed to auto add tasks manager permissions to admin role!');
db.set('tasks_manager_role_migration_complete', true).write();
}
return true;
}
@@ -484,8 +488,9 @@ async function setAndLoadConfig() {
}
async function setConfigFromEnv() {
let config_items = getEnvConfigItems();
let success = config_api.setConfigItems(config_items);
const config_items = getEnvConfigItems();
if (!config_items || config_items.length === 0) return true;
const success = config_api.setConfigItems(config_items);
if (success) {
logger.info('Config items set using ENV variables.');
await utils.wait(100);
@@ -500,12 +505,13 @@ async function loadConfig() {
loadConfigValues();
// connect to DB
await db_api.connectToDB();
if (!config_api.getConfigItem('ytdl_use_local_db'))
await db_api.connectToDB();
db_api.database_initialized = true;
db_api.database_initialized_bs.next(true);
// creates archive path if missing
await fs.ensureDir(archivePath);
await fs.ensureDir(utils.getArchiveFolder());
// check migrations
await checkMigrations();
@@ -575,7 +581,11 @@ async function watchSubscriptions() {
if (!subscriptions) return;
const valid_subscriptions = subscriptions.filter(sub => !sub.paused);
// auto pause deprecated streamingOnly mode
const streaming_only_subs = subscriptions.filter(sub => sub.streamingOnly);
subscriptions_api.updateSubscriptionPropertyMultiple(streaming_only_subs, {paused: true});
const valid_subscriptions = subscriptions.filter(sub => !sub.paused && !sub.streamingOnly);
let subscriptions_amount = valid_subscriptions.length;
let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount);
@@ -912,11 +922,11 @@ app.post('/api/getFile', optionalJwt, async function (req, res) {
app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
// these are returned
let files = null;
let playlists = null;
let sort = req.body.sort;
let range = req.body.range;
let text_search = req.body.text_search;
let file_type_filter = req.body.file_type_filter;
const sort = req.body.sort;
const range = req.body.range;
const text_search = req.body.text_search;
const file_type_filter = req.body.file_type_filter;
const sub_id = req.body.sub_id;
const uuid = req.isAuthenticated() ? req.user.uid : null;
const filter_obj = {user_uid: uuid};
@@ -929,6 +939,10 @@ app.post('/api/getAllFiles', optionalJwt, async function (req, res) {
}
}
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;
@@ -1268,7 +1282,7 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
subscription = JSON.parse(JSON.stringify(subscription));
// get sub videos
if (subscription.name && !subscription.streamingOnly) {
if (subscription.name) {
var parsed_files = await db_api.getRecords('files', {sub_id: subscription.id}); // subscription.videos;
subscription['videos'] = parsed_files;
// loop through files for extra processing
@@ -1278,19 +1292,6 @@ app.post('/api/getSubscription', optionalJwt, async (req, res) => {
if (file && file['url'].includes('twitch.tv')) file['chat_exists'] = fs.existsSync(file['path'].substring(0, file['path'].length - 4) + '.twitch_chat.json');
}
res.send({
subscription: subscription,
files: parsed_files
});
} else if (subscription.name && subscription.streamingOnly) {
// return list of videos
let parsed_files = [];
if (subscription.videos) {
for (let i = 0; i < subscription.videos.length; i++) {
const video = subscription.videos[i];
parsed_files.push(new utils.File(video.title, video.title, video.thumbnail, false, video.duration, video.url, video.uploader, video.size, null, null, video.upload_date, video.view_count, video.height, video.abr));
}
}
res.send({
subscription: subscription,
files: parsed_files
@@ -1335,9 +1336,8 @@ app.post('/api/getSubscriptions', optionalJwt, async (req, res) => {
app.post('/api/createPlaylist', optionalJwt, async (req, res) => {
let playlistName = req.body.playlistName;
let uids = req.body.uids;
let type = req.body.type;
const new_playlist = await db_api.createPlaylist(playlistName, uids, type, req.isAuthenticated() ? req.user.uid : null);
const new_playlist = await db_api.createPlaylist(playlistName, uids, req.isAuthenticated() ? req.user.uid : null);
res.send({
new_playlist: new_playlist,
@@ -1365,7 +1365,6 @@ app.post('/api/getPlaylist', optionalJwt, async (req, res) => {
res.send({
playlist: playlist,
file_objs: file_objs,
type: playlist && playlist.type,
success: !!playlist
});
});

View File

@@ -31,7 +31,8 @@
"use_youtube_API": false,
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_client_ID": "",
"twitch_client_secret": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
@@ -63,7 +64,7 @@
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {
"default_downloader": "youtube-dl",
"default_downloader": "yt-dlp",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -361,7 +361,6 @@ exports.userHasPermission = async function(user_uid, permission) {
logger.error('Invalid role ' + role);
return false;
}
const role_permissions = (await db_api.getRecords('roles'))['permissions'];
const user_has_explicit_permission = user_obj['permissions'].includes(permission);
const permission_in_overrides = user_obj['permission_overrides'].includes(permission);
@@ -376,7 +375,8 @@ exports.userHasPermission = async function(user_uid, permission) {
}
// no overrides, let's check if the role has the permission
if (role_permissions.includes(permission)) {
const role_has_permission = await exports.roleHasPermissions(role, permission);
if (role_has_permission) {
return true;
} else {
logger.verbose(`User ${user_uid} failed to get permission ${permission}`);
@@ -384,6 +384,16 @@ exports.userHasPermission = async function(user_uid, permission) {
}
}
exports.roleHasPermissions = async function(role, permission) {
const role_obj = await db_api.getRecord('roles', {key: role})
if (!role) {
logger.error(`Role ${role} does not exist!`);
}
const role_permissions = role_obj['permissions'];
if (role_permissions && role_permissions.includes(permission)) return true;
else return false;
}
exports.userPermissions = async function(user_uid) {
let user_permissions = [];
const user_obj = await db_api.getRecord('users', ({uid: user_uid}));

View File

@@ -206,7 +206,8 @@ const DEFAULT_CONFIG = {
"use_youtube_API": false,
"youtube_API_key": "",
"use_twitch_API": false,
"twitch_API_key": "",
"twitch_client_ID": "",
"twitch_client_secret": "",
"twitch_auto_download_chat": false,
"use_sponsorblock_API": false,
"generate_NFO_files": false
@@ -238,7 +239,7 @@ const DEFAULT_CONFIG = {
"mongodb_connection_string": "mongodb://127.0.0.1:27017/?compressors=zlib"
},
"Advanced": {
"default_downloader": "youtube-dl",
"default_downloader": "yt-dlp",
"use_default_downloading_agent": true,
"custom_downloading_agent": "",
"multi_user_mode": false,

View File

@@ -102,9 +102,13 @@ exports.CONFIG_ITEMS = {
'key': 'ytdl_use_twitch_api',
'path': 'YoutubeDLMaterial.API.use_twitch_API'
},
'ytdl_twitch_api_key': {
'key': 'ytdl_twitch_api_key',
'path': 'YoutubeDLMaterial.API.twitch_API_key'
'ytdl_twitch_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',
@@ -217,7 +221,8 @@ exports.AVAILABLE_PERMISSIONS = [
'subscriptions',
'sharing',
'advanced_download',
'downloads_manager'
'downloads_manager',
'tasks_manager'
];
exports.DETAILS_BIN_PATH = 'node_modules/youtube-dl/bin/details'
@@ -301,4 +306,4 @@ const YTDL_ARGS_WITH_VALUES = [
// we're using a Set here for performance
exports.YTDL_ARGS_WITH_VALUES = new Set(YTDL_ARGS_WITH_VALUES);
exports.CURRENT_VERSION = 'v4.2';
exports.CURRENT_VERSION = 'v4.3';

View File

@@ -198,7 +198,7 @@ async function registerFileDBManual(file_object) {
path_object = path.parse(file_object['path']);
file_object['path'] = path.format(path_object);
exports.insertRecordIntoTable('files', file_object, {path: file_object['path']})
await exports.insertRecordIntoTable('files', file_object, {path: file_object['path']})
return file_object;
}
@@ -357,7 +357,7 @@ exports.addMetadataPropertyToDB = async (property_key) => {
}
}
exports.createPlaylist = async (playlist_name, uids, type, user_uid = null) => {
exports.createPlaylist = async (playlist_name, uids, user_uid = null) => {
const first_video = await exports.getVideo(uids[0]);
const thumbnailToUse = first_video['thumbnailURL'];
@@ -366,7 +366,6 @@ exports.createPlaylist = async (playlist_name, uids, type, user_uid = null) => {
uids: uids,
id: uuid(),
thumbnailURL: thumbnailToUse,
type: type,
registered: Date.now(),
randomize_order: false
};
@@ -495,8 +494,7 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
let useYoutubeDLArchive = config_api.getConfigItem('ytdl_use_youtubedl_archive');
if (useYoutubeDLArchive) {
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
const archive_path = uuid ? path.join(usersFileFolder, uuid, 'archives', `archive_${type}.txt`) : path.join('appdata', 'archives', `archive_${type}.txt`);
const archive_path = utils.getArchiveFolder(type, uuid);
// get ID from JSON
@@ -504,14 +502,8 @@ exports.deleteFile = async (uid, uuid = null, blacklistMode = false) => {
let id = null;
if (jsonobj) id = jsonobj.id;
// use subscriptions API to remove video from the archive file, and write it to the blacklist
if (await fs.pathExists(archive_path)) {
const line = id ? await utils.removeIDFromArchive(archive_path, id) : null;
if (blacklistMode && line) await writeToBlacklist(type, line);
} else {
logger.info('Could not find archive file for audio files. Creating...');
await fs.close(await fs.open(archive_path, 'w'));
}
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
await utils.deleteFileFromArchive(uid, type, archive_path, id, blacklistMode);
}
if (jsonExists) await fs.unlink(jsonPath);
@@ -933,6 +925,7 @@ exports.importJSONToDB = async (db_json, users_json) => {
const createFilesRecords = (files, subscriptions) => {
for (let i = 0; i < subscriptions.length; i++) {
const subscription = subscriptions[i];
if (!subscription['videos']) continue;
subscription['videos'] = subscription['videos'].map(file => ({ ...file, sub_id: subscription['id'], user_uid: subscription['user_uid'] ? subscription['user_uid'] : undefined}));
files = files.concat(subscriptions[i]['videos']);
}
@@ -993,7 +986,7 @@ exports.backupDB = async () => {
const backup_file_name = `${using_local_db ? 'local' : 'remote'}_db.json.${Date.now()/1000}.bak`;
const path_to_backups = path.join(backup_dir, backup_file_name);
logger.verbose(`Backing up ${using_local_db ? 'local' : 'remote'} DB to ${path_to_backups}`);
logger.info(`Backing up ${using_local_db ? 'local' : 'remote'} DB to ${path_to_backups}`);
const table_to_records = {};
for (let i = 0; i < tables_list.length; i++) {
@@ -1040,10 +1033,11 @@ exports.transferDB = async (local_to_remote) => {
table_to_records[table] = await exports.getRecords(table);
}
logger.info('Backup up DB...');
await exports.backupDB(); // should backup always
using_local_db = !local_to_remote;
if (local_to_remote) {
logger.debug('Backup up DB...');
await exports.backupDB();
const db_connected = await exports.connectToDB(5, true);
if (!db_connected) {
logger.error('Failed to transfer database - could not connect to MongoDB. Verify that your connection URL is valid.');
@@ -1111,15 +1105,3 @@ exports.applyFilterLocalDB = (db_path, filter_obj, operation) => {
});
return return_val;
}
// archive helper functions
async function writeToBlacklist(type, line) {
const archivePath = path.join(__dirname, 'appdata', 'archives');
let blacklistPath = path.join(archivePath, (type === 'audio') ? 'blacklist_audio.txt' : 'blacklist_video.txt');
// adds newline to the beginning of the line
line.replace('\n', '');
line.replace('\r', '');
line = '\n' + line;
await fs.appendFile(blacklistPath, line);
}

View File

@@ -18,8 +18,6 @@ const db_api = require('./db');
const mutex = new Mutex();
let should_check_downloads = true;
const archivePath = path.join(__dirname, 'appdata', 'archives');
if (db_api.database_initialized) {
setupDownloads();
} else {
@@ -28,7 +26,7 @@ if (db_api.database_initialized) {
});
}
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null) => {
exports.createDownload = async (url, type, options, user_uid = null, sub_id = null, sub_name = null, prefetched_info = null) => {
return await mutex.runExclusive(async () => {
const download = {
url: url,
@@ -37,6 +35,7 @@ exports.createDownload = async (url, type, options, user_uid = null, sub_id = nu
user_uid: user_uid,
sub_id: sub_id,
sub_name: sub_name,
prefetched_info: prefetched_info,
options: options,
uid: uuid(),
step_index: 0,
@@ -187,7 +186,7 @@ async function collectInfo(download_uid) {
let args = await exports.generateArgs(url, type, options, download['user_uid']);
// get video info prior to download
let info = await exports.getVideoInfoByURL(url, args, download_uid);
let info = download['prefetched_info'] ? download['prefetched_info'] : await exports.getVideoInfoByURL(url, args, download_uid);
if (!info) {
// info failed, error presumably already recorded
@@ -204,6 +203,7 @@ async function collectInfo(download_uid) {
options.customOutput = category['custom_output'];
options.noRelativePath = true;
args = await exports.generateArgs(url, type, options, download['user_uid']);
args = utils.filterArgs(args, ['--no-simulate']);
info = await exports.getVideoInfoByURL(url, args, download_uid);
}
@@ -229,7 +229,8 @@ async function collectInfo(download_uid) {
options: options,
files_to_check_for_progress: files_to_check_for_progress,
expected_file_size: expected_file_size,
title: playlist_title ? playlist_title : info['title']
title: playlist_title ? playlist_title : info['title'],
prefetched_info: null
});
}
@@ -242,6 +243,7 @@ async function downloadQueuedFile(download_uid) {
return new Promise(async resolve => {
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
await db_api.updateRecord('download_queue', {uid: download_uid}, {step_index: 2, finished_step: false, running: true});
const url = download['url'];
@@ -249,9 +251,11 @@ async function downloadQueuedFile(download_uid) {
const options = download['options'];
const args = download['args'];
const category = download['category'];
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; // TODO: fix
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath;
if (options.customFileFolderPath) {
fileFolderPath = options.customFileFolderPath;
} else if (download['user_uid']) {
fileFolderPath = path.join(usersFolderPath, download['user_uid'], type);
}
fs.ensureDirSync(fileFolderPath);
@@ -353,7 +357,7 @@ async function downloadQueuedFile(download_uid) {
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), type, download['user_uid']);
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 {
@@ -373,15 +377,23 @@ async function downloadQueuedFile(download_uid) {
// helper functions
exports.generateArgs = async (url, type, options, user_uid = null, simulated = false) => {
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
const audioFolderPath = config_api.getConfigItem('ytdl_audio_folder_path');
const videoFolderPath = config_api.getConfigItem('ytdl_video_folder_path');
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
const videopath = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const globalArgs = config_api.getConfigItem('ytdl_custom_args');
const useCookies = config_api.getConfigItem('ytdl_use_cookies');
const is_audio = type === 'audio';
let fileFolderPath = is_audio ? audioFolderPath : videoFolderPath;
let fileFolderPath = type === 'audio' ? audioFolderPath : videoFolderPath; // TODO: fix
if (options.customFileFolderPath) {
fileFolderPath = options.customFileFolderPath;
} else if (user_uid) {
fileFolderPath = path.join(usersFolderPath, user_uid, fileFolderPath);
}
if (options.customFileFolderPath) fileFolderPath = options.customFileFolderPath;
@@ -391,6 +403,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
// video-specific args
const selectedHeight = options.selectedHeight;
const maxHeight = options.maxHeight;
const heightParam = selectedHeight || maxHeight;
// audio-specific args
const maxBitrate = options.maxBitrate;
@@ -404,8 +418,6 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
if (!is_audio && !is_youtube) {
// tiktok videos fail when using the default format
qualityPath = null;
} else if (!is_audio && !is_youtube && (url.includes('reddit') || url.includes('pornhub'))) {
qualityPath = ['-f', 'bestvideo+bestaudio']
}
if (customArgs) {
@@ -413,8 +425,8 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
} else {
if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration, '--merge-output-format', 'mp4'];
} else if (selectedHeight && selectedHeight !== '' && !is_audio) {
qualityPath = ['-f', `'(mp4)[height=${selectedHeight}'`];
} else if (heightParam && heightParam !== '' && !is_audio) {
qualityPath = ['-f', `'(mp4)[height${maxHeight ? '<' : ''}=${heightParam}]`];
} else if (is_audio) {
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
}
@@ -496,9 +508,11 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
downloadConfig.push('-r', rate_limit);
}
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-infojson');
downloadConfig = utils.filterArgs(downloadConfig, ['--print-json']);
// in yt-dlp -j --no-simulate is preferable
downloadConfig.push('--no-clean-info-json', '-j', '--no-simulate');
}
}
@@ -506,7 +520,7 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
// filter out incompatible args
downloadConfig = filterArgs(downloadConfig, is_audio);
if (!simulated) logger.verbose(`youtube-dl args being used: ${downloadConfig.join(',')}`);
if (!simulated) logger.verbose(`${default_downloader} args being used: ${downloadConfig.join(',')}`);
return downloadConfig;
}
@@ -565,8 +579,7 @@ exports.getVideoInfoByURL = async (url, args = [], download_uid = null) => {
function filterArgs(args, isAudio) {
const video_only_args = ['--add-metadata', '--embed-subs', '--xattrs'];
const audio_only_args = ['-x', '--extract-audio', '--embed-thumbnail'];
const args_to_remove = isAudio ? video_only_args : audio_only_args;
return args.filter(x => !args_to_remove.includes(x));
return utils.filterArgs(args, isAudio ? video_only_args : audio_only_args);
}
async function checkDownloadPercent(download_uid) {
@@ -628,6 +641,6 @@ function getArchiveFolder(fileFolderPath, options, user_uid) {
} else if (user_uid) {
return path.join(fileFolderPath, 'archives');
} else {
return path.join(archivePath);
return path.join('appdata', 'archives');
}
}

View File

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

View File

@@ -1,4 +1,4 @@
#!/bin/sh
#!/bin/bash
# INTERACTIVE PERMISSIONS FIX SCRIPT FOR YTDL-M
# Date: 2022-05-03
@@ -6,8 +6,7 @@
# If you want to run this script on a bare-metal installation instead of within Docker
# make sure that the paths configured below match your paths! (it's wise to use the full paths)
# USAGE: within your container's bash shell:
# chmod -R +x ./fix-scripts/
# ./fix-scripts/001-fix_download_permissions.sh
# ./fix-scripts/<name of fix-script>
# User defines / Docker env defaults
PATH_SUBS=/app/subscriptions

View File

@@ -0,0 +1,142 @@
#!/bin/bash
# INTERACTIVE ARCHIVE-DUPE-ENTRY FIX SCRIPT FOR YTDL-M
# Date: 2022-05-09
# If you want to run this script on a bare-metal installation instead of within Docker
# make sure that the paths configured below match your paths! (it's wise to use the full paths)
# USAGE: within your container's bash shell:
# ./fix-scripts/<name of fix-script>
# User defines (NO TRAILING SLASHES) / Docker env defaults
PATH_SUBSARCHIVE=/app/subscriptions/archives
PATH_ONEOFFARCHIVE=/app/appdata/archives
# Backup paths (substitute with your personal preference if you like)
PATH_SUBSARCHIVEBKP=$PATH_SUBSARCHIVE-BKP-$(date +%Y%m%d%H%M%S)
PATH_ONEOFFARCHIVEBKP=$PATH_ONEOFFARCHIVE-BKP-$(date +%Y%m%d%H%M%S)
# Define Colors for TUI
yellow=$(tput setaf 3)
normal=$(tput sgr0)
tput civis # hide the cursor
clear -x
printf "\n"
printf '%*s\n' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - # horizontal line
printf "Welcome to the INTERACTIVE ARCHIVE-DUPE-ENTRY FIX SCRIPT FOR YTDL-M."
printf "\nThis script will cycle through the archive files in the folders mentioned"
printf "\nbelow and remove within each archive the dupe entries. (compact them)"
printf "\nDuring some older builds of YTDL-M the archives could receive dupe"
printf "\nentries and blow up in size, sometimes causing conflicts with download management."
printf '\n%*s' "${COLUMNS:-$(tput cols)}" '' | tr ' ' - # horizontal line
printf "\n"
# check whether dirs exist
i=0
[ -d $PATH_SUBSARCHIVE ] && i=$((i+1)) && printf "\n✔ (${i}/2) Found Subscriptions archive directory at ${PATH_SUBSARCHIVE}"
[ -d $PATH_ONEOFFARCHIVE ] && i=$((i+1)) && printf "\n✔ (${i}/2) Found one-off archive directory at ${PATH_ONEOFFARCHIVE}"
# Ask to proceed or cancel, exit on missing paths
case $i in
0)
printf "\n\n Couldn't find any archive location path! \n\nPlease edit this script to configure!"
tput cnorm
exit 2;;
2)
printf "\n\n Found all archive locations. \n\nProceed? (Y/N)";;
*)
printf "\n\n Only found ${i} out of 2 archive locations! Something about this script's config must be wrong. \n\nProceed anyways? (Y/N)";;
esac
old_stty_cfg=$(stty -g)
stty raw -echo ; answer=$(head -c 1) ; stty $old_stty_cfg # Careful playing with stty
if echo "$answer" | grep -iq "^y" ;then
printf "\n\nRunning jobs now... (this may take a while)\n"
printf "\nBacking up directories...\n"
chars="⣾⣽⣻⢿⡿⣟⣯⣷"
cp -R $PATH_SUBSARCHIVE $PATH_SUBSARCHIVEBKP &
PID=$!
i=1
echo -n ' '
while [ -d /proc/$PID ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.15
done
[ -d $PATH_SUBSARCHIVEBKP ] && printf "\r✔ Backed up ${PATH_SUBSARCHIVE} to ${PATH_SUBSARCHIVEBKP} ($(du -sh $PATH_SUBSARCHIVEBKP | cut -f1))\n"
cp -R $PATH_ONEOFFARCHIVE $PATH_ONEOFFARCHIVEBKP &
PID2=$!
i=1
echo -n ' '
while [ -d /proc/$PID2 ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.1
done
[ -d $PATH_ONEOFFARCHIVEBKP ] && printf "\r✔ Backed up ${PATH_ONEOFFARCHIVE} to ${PATH_ONEOFFARCHIVEBKP} ($(du -sh $PATH_ONEOFFARCHIVEBKP | cut -f1))\n"
printf "\nCompacting files...\n"
tmpfile=$(mktemp) &&
[ -d $PATH_SUBSARCHIVE ] &&
find $PATH_SUBSARCHIVE -name '*.txt' -print0 | while read -d $'\0' file # Set delimiter to null because we want to catch all possible filenames (WE CANNOT CHANGE IFS HERE) - https://stackoverflow.com/a/15931055
do
cp "$file" "$tmpfile"
{ awk '!x[$0]++' "$tmpfile" > "$file"; } & # https://unix.stackexchange.com/questions/159695/how-does-awk-a0-work
PID3=$!
i=1
echo -n ''
while [ -d /proc/$PID3 ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.1
done
BEFORE=$(wc -l < $tmpfile)
AFTER=$(wc -l < $file)
if [[ "$AFTER" -ne "$BEFORE" ]]; then
printf "\b✔ Compacted down to ${AFTER} lines from ${BEFORE}: ${file}\n"
else
printf "\b No action needed for file: ${file}\n"
fi
done
[ -d $PATH_ONEOFFARCHIVE ] &&
find $PATH_ONEOFFARCHIVE -name '*.txt' -print0 | while read -d $'\0' file
do
cp "$file" "$tmpfile" &
awk '!x[$0]++' "$tmpfile" > "$file" &
PID4=$!
i=1
echo -n ''
while [ -d /proc/$PID4 ]
do
printf "${yellow}\b${chars:i++%${#chars}:1}${normal}"
sleep 0.1
done
BEFORE=$(wc -l < $tmpfile)
AFTER=$(wc -l < $file)
if [ "$BEFORE" -ne "$AFTER" ]; then
printf "\b✔ Compacted down to ${AFTER} lines from ${BEFORE}: ${file}\n"
else
printf "\b No action ran for file: ${file}\n"
fi
done
tput cnorm # show the cursor
rm "$tmpfile"
printf "\n\n✔ Done."
printf "\n Please keep in mind that you may still want to"
printf "\n run corruption checks against your archives!\n\n"
exit
else
tput cnorm
printf "\nOkay, bye.\n\n"
exit
fi

1825
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,20 +5,9 @@
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app.js",
"start": "pm2-runtime --raw pm2.config.js",
"debug": "set YTDL_MODE=debug && node app.js"
},
"nodemonConfig": {
"ignore": [
"*.js",
"appdata/*",
"public/*"
],
"watch": [
"restart_update.json",
"restart_general.json"
]
},
"repository": {
"type": "git",
"url": ""
@@ -47,11 +36,10 @@
"mocha": "^9.2.2",
"moment": "^2.29.2",
"mongodb": "^3.6.9",
"multer": "^1.4.2",
"multer": "1.4.5-lts.1",
"node-fetch": "^2.6.7",
"node-id3": "^0.1.14",
"node-schedule": "^2.1.0",
"nodemon": "^2.0.7",
"passport": "^0.4.1",
"passport-http": "^0.3.0",
"passport-jwt": "^4.0.0",

View File

@@ -6,4 +6,4 @@ module.exports = {
out_file: "/dev/null",
error_file: "/dev/null"
}]
}
}

View File

@@ -178,7 +178,7 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
]);
if (jsonExists) {
retrievedID = JSON.parse(await fs.readFile(jsonPath, 'utf8'))['id'];
retrievedID = fs.readJSONSync(jsonPath)['id'];
await fs.unlink(jsonPath);
}
@@ -196,12 +196,11 @@ async function deleteSubscriptionFile(sub, file, deleteForever, file_uid = null,
return false;
} else {
// check if the user wants the video to be redownloaded (deleteForever === false)
if (!deleteForever && useArchive && sub.archive && retrievedID) {
const archive_path = path.join(sub.archive, 'archive.txt')
// if archive exists, remove line with video ID
if (await fs.pathExists(archive_path)) {
utils.removeIDFromArchive(archive_path, retrievedID);
}
if (useArchive && retrievedID) {
const archive_path = utils.getArchiveFolder(sub.type, user_uid, sub);
// Remove file ID from the archive file, and write it to the blacklist (if enabled)
await utils.deleteFileFromArchive(file_uid, sub.type, archive_path, retrievedID, deleteForever);
}
return true;
}
@@ -242,64 +241,22 @@ async function getVideosForSub(sub, user_uid = null) {
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')) {
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 {
// TODO: reimplement
// const outputs = err.stdout.split(/\r\n|\r|\n/);
// for (let i = 0; i < outputs.length; i++) {
// const output = JSON.parse(outputs[i]);
// await handleOutputJSON(sub, output, i === 0, multiUserMode)
// if (err.stderr.includes(output['id']) && archive_path) {
// // we found a video that errored! add it to the archive to prevent future errors
// if (sub.archive) {
// archive_dir = sub.archive;
// archive_path = path.join(archive_dir, 'archive.txt')
// fs.appendFileSync(archive_path, output['id']);
// }
// }
// }
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) {
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] === '')) {
logger.verbose('No additional videos to download for ' + sub.name);
resolve(true);
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);
for (let j = 0; j < files_to_download.length; j++) {
const file_to_download = files_to_download[j];
await downloader_api.createDownload(file_to_download['webpage_url'], sub.type || 'video', base_download_options, user_uid, sub.id, sub.name);
}
const files_to_download = await handleOutputJSON(output, sub, user_uid);
resolve(files_to_download);
}
});
@@ -309,6 +266,43 @@ async function getVideosForSub(sub, user_uid = null) {
});
}
async function handleOutputJSON(output, 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] === '')) {
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);
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);
}
return files_to_download;
}
function generateOptionsForSubscriptionDownload(sub, user_uid) {
let basePath = null;
if (user_uid)
@@ -319,10 +313,10 @@ function generateOptionsForSubscriptionDownload(sub, user_uid) {
let default_output = config_api.getConfigItem('ytdl_default_file_output') ? config_api.getConfigItem('ytdl_default_file_output') : '%(title)s';
const base_download_options = {
selectedHeight: sub.maxQuality && sub.maxQuality !== 'best' ? sub.maxQuality : null,
maxHeight: sub.maxQuality && sub.maxQuality !== 'best' ? sub.maxQuality : null,
customFileFolderPath: getAppendedBasePath(sub, basePath),
customOutput: sub.custom_output ? `${sub.custom_output}` : `${default_output}`,
customArchivePath: path.join(__dirname, basePath, 'archives', sub.name),
customArchivePath: path.join(basePath, 'archives', sub.name),
additionalArgs: sub.custom_args
}
@@ -389,11 +383,6 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push('--download-archive', archive_path);
}
// if streaming only mode, just get the list of videos
if (sub.streamingOnly) {
downloadConfig = ['-f', 'best', '--dump-json'];
}
if (sub.timerange && !redownload) {
downloadConfig.push('--dateafter', sub.timerange);
}
@@ -418,9 +407,11 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
const default_downloader = utils.getCurrentDownloader() || config_api.getConfigItem('ytdl_default_downloader');
if (default_downloader === 'yt-dlp') {
downloadConfig.push('--no-clean-infojson');
downloadConfig.push('--no-clean-info-json');
}
downloadConfig = utils.filterArgs(downloadConfig, ['--write-comments']);
return downloadConfig;
}
@@ -467,7 +458,7 @@ async function updateSubscription(sub) {
async function updateSubscriptionPropertyMultiple(subs, assignment_obj) {
subs.forEach(async sub => {
await updateSubscriptionProperty(sub, assignment_obj, sub.user_uid);
await updateSubscriptionProperty(sub, assignment_obj);
});
}
@@ -479,6 +470,7 @@ async function updateSubscriptionProperty(sub, assignment_obj) {
async function setFreshUploads(sub) {
const sub_files = await db_api.getRecords('files', {sub_id: sub.id});
if (!sub_files) return;
const current_date = new Date().toISOString().split('T')[0].replace(/-/g, '');
sub_files.forEach(async file => {
if (current_date === file['upload_date'].replace(/-/g, '')) {

View File

@@ -1,6 +1,7 @@
var assert = require('assert');
const assert = require('assert');
const low = require('lowdb')
var winston = require('winston');
const winston = require('winston');
const path = require('path');
process.chdir('./backend')
@@ -39,6 +40,7 @@ const utils = require('../utils');
const subscriptions_api = require('../subscriptions');
const fs = require('fs-extra');
const { uuid } = require('uuidv4');
const NodeID3 = require('node-id3');
db_api.initialize(db, users_db);
@@ -399,6 +401,19 @@ describe('Downloader', function() {
});
it('Tag file', async function() {
const audio_path = './test/sample.mp3';
const sample_json = fs.readJSONSync('./test/sample.info.json');
const tags = {
title: sample_json['title'],
artist: sample_json['artist'] ? sample_json['artist'] : sample_json['uploader'],
TRCK: '27'
}
NodeID3.write(tags, audio_path);
const written_tags = NodeID3.read(audio_path);
assert(written_tags['raw']['TRCK'] === '27');
});
it('Queue file', async function() {
this.timeout(300000);
const returned_download = await downloader_api.createDownload(url, 'video', options);
@@ -451,6 +466,20 @@ describe('Downloader', function() {
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 = '1493770675';
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));
// cleanup
if (fs.existsSync(sample_path)) fs.unlinkSync(sample_path);
});
});
});
describe('Tasks', function() {
@@ -561,4 +590,40 @@ describe('Tasks', function() {
const dummy_task_obj = await db_api.getRecord('tasks', {key: 'dummy_task'});
assert(dummy_task_obj['data']);
});
});
describe('Archive', async function() {
const archive_path = path.join('test', 'archives');
fs.ensureDirSync(archive_path);
const archive_file_path = path.join(archive_path, 'archive_video.txt');
const blacklist_file_path = path.join(archive_path, 'blacklist_video.txt');
beforeEach(async function() {
if (fs.existsSync(archive_file_path)) fs.unlinkSync(archive_file_path);
fs.writeFileSync(archive_file_path, 'youtube testing1\nyoutube testing2\nyoutube testing3\n');
if (fs.existsSync(blacklist_file_path)) fs.unlinkSync(blacklist_file_path);
fs.writeFileSync(blacklist_file_path, '');
});
it('Delete from archive', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', false);
const new_archive = fs.readFileSync(archive_file_path);
assert(!new_archive.includes('testing2'));
});
it('Delete from archive - blacklist', async function() {
await utils.deleteFileFromArchive('N/A', 'video', archive_path, 'testing2', true);
const new_archive = fs.readFileSync(archive_file_path);
const new_blacklist = fs.readFileSync(blacklist_file_path);
assert(!new_archive.includes('testing2'));
assert(new_blacklist.includes('testing2'));
});
});
describe('Utils', async function() {
it('Strip properties', async function() {
const test_obj = {test1: 'test1', test2: 'test2', test3: 'test3'};
const stripped_obj = utils.stripPropertiesFromObject(test_obj, ['test1', 'test3']);
assert(!stripped_obj['test1'] && stripped_obj['test2'] && !stripped_obj['test3'])
});
});

View File

@@ -1,90 +1,64 @@
var moment = require('moment');
var Axios = require('axios');
var fs = require('fs-extra')
var path = require('path');
const config_api = require('./config');
const logger = require('./logger');
async function getCommentsForVOD(clientID, vodId) {
let url = `https://api.twitch.tv/v5/videos/${vodId}/comments?content_offset_seconds=0`,
batch,
cursor;
const moment = require('moment');
const fs = require('fs-extra')
const path = require('path');
let comments = null;
try {
do {
batch = (await Axios.get(url, {
headers: {
'Client-ID': clientID,
Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8',
'Content-Type': 'application/json; charset=UTF-8',
}
})).data;
const str = batch.comments.map(c => {
let {
created_at: msgCreated,
content_offset_seconds: timestamp,
commenter: {
name,
_id,
created_at: acctCreated
},
message: {
body: msg,
user_color: user_color
}
} = c;
const timestamp_str = moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
acctCreated = moment(acctCreated).utc();
msgCreated = moment(msgCreated).utc();
if (!comments) comments = [];
comments.push({
timestamp: timestamp,
timestamp_str: timestamp_str,
name: name,
message: msg,
user_color: user_color
});
// let line = `${timestamp},${msgCreated.format(tsFormat)},${name},${_id},"${msg.replace(/"/g, '""')}",${acctCreated.format(tsFormat)}`;
// return line;
}).join('\n');
cursor = batch._next;
url = `https://api.twitch.tv/v5/videos/${vodId}/comments?cursor=${cursor}`;
await new Promise(res => setTimeout(res, 300));
} while (cursor);
} catch (err) {
console.error(err);
async function getCommentsForVOD(clientID, clientSecret, vodId) {
const { promisify } = require('util');
const child_process = require('child_process');
const exec = promisify(child_process.exec);
// Reject invalid params to prevent command injection attack
if (!clientID.match(/^[0-9a-z]+$/) || !clientSecret.match(/^[0-9a-z]+$/) || !vodId.match(/^[0-9a-z]+$/)) {
logger.error('Client ID, client secret, and VOD ID must be purely alphanumeric. Twitch chat download failed!');
return null;
}
return comments;
const result = await exec(`tcd --video ${vodId} --client-id ${clientID} --client-secret ${clientSecret} --format json -o appdata`, {stdio:[0,1,2]});
if (result['stderr']) {
logger.error(`Failed to download twitch comments for ${vodId}`);
logger.error(result['stderr']);
return null;
}
const temp_chat_path = path.join('appdata', `${vodId}.json`);
const raw_json = fs.readJSONSync(temp_chat_path);
const new_json = raw_json.comments.map(comment_obj => {
return {
timestamp: comment_obj.content_offset_seconds,
timestamp_str: convertTimestamp(comment_obj.content_offset_seconds),
name: comment_obj.commenter.name,
message: comment_obj.message.body,
user_color: comment_obj.message.user_color
}
});
fs.unlinkSync(temp_chat_path);
return new_json;
}
async function getTwitchChatByFileID(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;
if (user_uid) {
if (sub) {
file_path = path.join('users', user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
}
} else {
if (sub) {
file_path = path.join('subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join(type, id + '.twitch_chat.json');
const typeFolder = config_api.getConfigItem(`ytdl_${type}_folder_path`);
file_path = path.join(typeFolder, `${id}.twitch_chat.json`);
}
}
@@ -96,23 +70,28 @@ async function getTwitchChatByFileID(id, type, user_uid, uuid, sub) {
return chat_file;
}
async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub) {
const twitch_api_key = config_api.getConfigItem('ytdl_twitch_api_key');
const chat = await getCommentsForVOD(twitch_api_key, vodId);
async function downloadTwitchChatByVODID(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 twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
const chat = await getCommentsForVOD(twitch_client_id, twitch_client_secret, vodId);
// save file if needed params are included
let file_path = null;
if (user_uid) {
if (customFileFolderPath) {
file_path = path.join(customFileFolderPath, `${id}.twitch_chat.json`)
} else if (user_uid) {
if (sub) {
file_path = path.join('users', user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join('users', user_uid, type, id + '.twitch_chat.json');
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
}
} else {
if (sub) {
file_path = path.join('subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, id + '.twitch_chat.json');
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
} else {
file_path = path.join(type, id + '.twitch_chat.json');
file_path = path.join(type, `${id}.twitch_chat.json`);
}
}
@@ -121,6 +100,14 @@ async function downloadTwitchChatByVODID(vodId, id, type, user_uid, sub) {
return chat;
}
const convertTimestamp = (timestamp) => moment.duration(timestamp, 'seconds')
.toISOString()
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
(_, ...ms) => {
const seg = v => v ? v.padStart(2, '0') : '00';
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
});
module.exports = {
getCommentsForVOD: getCommentsForVOD,
getTwitchChatByFileID: getTwitchChatByFileID,

View File

@@ -172,11 +172,13 @@ function getExpectedFileSize(input_info_jsons) {
const formats = info_json['format_id'].split('+');
let individual_expected_filesize = 0;
formats.forEach(format_id => {
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && available_format.filesize) {
individual_expected_filesize += available_format.filesize;
}
});
if (info_json.formats !== undefined) {
info_json.formats.forEach(available_format => {
if (available_format.format_id === format_id && (available_format.filesize || available_format.filesize_approx)) {
individual_expected_filesize += (available_format.filesize ? available_format.filesize : available_format.filesize_approx);
}
});
}
});
expected_filesize += individual_expected_filesize;
});
@@ -210,7 +212,7 @@ function deleteJSONFile(file_path, type) {
const ext = type === 'audio' ? '.mp3' : '.mp4';
const file_path_no_extension = removeFileExtension(file_path);
let json_path = file_path_no_extension + '.info.json';
let alternate_json_path = file_path_no_extension + ext + '.info.json';
@@ -218,8 +220,11 @@ function deleteJSONFile(file_path, type) {
if (fs.existsSync(alternate_json_path)) fs.unlinkSync(alternate_json_path);
}
async function removeIDFromArchive(archive_path, id) {
let data = await fs.readFile(archive_path, {encoding: 'utf-8'});
// archive helper functions
async function removeIDFromArchive(archive_path, type, id) {
const archive_file = path.join(archive_path, `archive_${type}.txt`);
const data = await fs.readFile(archive_file, {encoding: 'utf-8'});
if (!data) {
logger.error('Archive could not be found.');
return;
@@ -236,12 +241,34 @@ async function removeIDFromArchive(archive_path, id) {
}
}
if (lastIndex === -1) return null;
const line = dataArray.splice(lastIndex, 1); // remove the keyword id from the data Array
// UPDATE FILE WITH NEW DATA
const updatedData = dataArray.join('\n');
await fs.writeFile(archive_path, updatedData);
if (line) return line;
await fs.writeFile(archive_file, updatedData);
if (line) return Array.isArray(line) && line.length === 1 ? line[0] : line;
}
async function writeToBlacklist(archive_folder, type, line) {
let blacklistPath = path.join(archive_folder, (type === 'audio') ? 'blacklist_audio.txt' : 'blacklist_video.txt');
// adds newline to the beginning of the line
line.replace('\n', '');
line.replace('\r', '');
line = '\n' + line;
await fs.appendFile(blacklistPath, line);
}
async function deleteFileFromArchive(uid, type, archive_path, id, blacklistMode) {
const archive_file = path.join(archive_path, `archive_${type}.txt`);
if (await fs.pathExists(archive_path)) {
const line = id ? await removeIDFromArchive(archive_path, type, id) : null;
if (blacklistMode && line) await writeToBlacklist(archive_path, type, line);
} else {
logger.info(`Could not find archive file for file ${uid}. Creating...`);
await fs.close(await fs.open(archive_file, 'w'));
}
}
function durationStringToNumber(dur_str) {
@@ -306,9 +333,9 @@ function createEdgeNGrams(str) {
if (str && str.length > 3) {
const minGram = 3
const maxGram = str.length
return str.split(" ").reduce((ngrams, token) => {
if (token.length > minGram) {
if (token.length > minGram) {
for (let i = minGram; i <= maxGram && i <= token.length; ++i) {
ngrams = [...ngrams, token.substr(0, i)]
}
@@ -318,7 +345,7 @@ function createEdgeNGrams(str) {
return ngrams
}, []).join(" ")
}
return str
}
@@ -418,7 +445,7 @@ async function fetchFile(url, path, file_label) {
async function restartServer(is_update = false) {
logger.info(`${is_update ? 'Update complete! ' : ''}Restarting server...`);
// the following line restarts the server through nodemon
// the following line restarts the server through pm2
fs.writeFileSync(`restart${is_update ? '_update' : '_general'}.json`, 'internal use only');
process.exit(1);
}
@@ -434,7 +461,7 @@ function injectArgs(original_args, new_args) {
for (let i = 0; i < new_args.length; i++) {
const new_arg = new_args[i];
if (!new_arg.startsWith('-') && !new_arg.startsWith('--') && i > 0 && original_args.includes(new_args[i - 1])) continue;
if (CONSTS.YTDL_ARGS_WITH_VALUES.has(new_arg)) {
if (original_args.includes(new_arg)) {
const original_index = original_args.indexOf(new_arg);
@@ -456,6 +483,10 @@ function injectArgs(original_args, new_args) {
return updated_args;
}
function filterArgs(args, args_to_remove) {
return args.filter(x => !args_to_remove.includes(x));
}
const searchObjectByString = function(o, s) {
s = s.replace(/\[(\w+)\]/g, '.$1'); // convert indexes to properties
s = s.replace(/^\./, ''); // strip a leading dot
@@ -471,6 +502,41 @@ const searchObjectByString = function(o, s) {
return o;
}
function stripPropertiesFromObject(obj, properties, whitelist = false) {
if (!whitelist) {
const new_obj = JSON.parse(JSON.stringify(obj));
for (let field of properties) {
delete new_obj[field];
}
return new_obj;
}
const new_obj = {};
for (let field of properties) {
new_obj[field] = obj[field];
}
return new_obj;
}
function getArchiveFolder(type, user_uid = null, sub = null) {
const usersFolderPath = config_api.getConfigItem('ytdl_users_base_path');
const subsFolderPath = config_api.getConfigItem('ytdl_subscriptions_base_path');
if (user_uid) {
if (sub) {
return path.join(usersFolderPath, user_uid, 'subscriptions', 'archives', sub.name);
} else {
return path.join(usersFolderPath, user_uid, type, 'archives');
}
} else {
if (sub) {
return path.join(subsFolderPath, 'archives', sub.name);
} else {
return path.join('appdata', 'archives');
}
}
}
// objects
function File(id, title, thumbnailURL, isAudio, duration, url, uploader, size, path, upload_date, description, view_count, height, abr) {
@@ -500,6 +566,8 @@ module.exports = {
fixVideoMetadataPerms: fixVideoMetadataPerms,
deleteJSONFile: deleteJSONFile,
removeIDFromArchive: removeIDFromArchive,
writeToBlacklist: writeToBlacklist,
deleteFileFromArchive: deleteFileFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
@@ -515,6 +583,9 @@ module.exports = {
fetchFile: fetchFile,
restartServer: restartServer,
injectArgs: injectArgs,
filterArgs: filterArgs,
searchObjectByString: searchObjectByString,
stripPropertiesFromObject: stripPropertiesFromObject,
getArchiveFolder: getArchiveFolder,
File: File
}

View File

@@ -90,7 +90,7 @@ exports.updateYoutubeDL = async (latest_update_version) => {
exports.verifyBinaryExistsLinux = () => {
const details_json = fs.readJSONSync(CONSTS.DETAILS_BIN_PATH);
if (!is_windows && details_json && (details_json['path'].includes('.exe') || !details_json['path'])) {
if (!is_windows && details_json && (!details_json['path'] || details_json['path'].includes('.exe'))) {
details_json['path'] = 'node_modules/youtube-dl/bin/youtube-dl';
details_json['exec'] = 'youtube-dl';
details_json['version'] = OUTDATED_VERSION;

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.2"
appVersion: "4.3"

View File

@@ -2,7 +2,6 @@ version: "2"
services:
ytdl_material:
environment:
ALLOW_CONFIG_MUTATIONS: 'true'
ytdl_mongodb_connection_string: 'mongodb://ytdl-mongo-db:27017'
ytdl_use_local_db: 'false'
write_ytdl_config: 'true'
@@ -17,14 +16,12 @@ services:
- ./users:/app/users
ports:
- "8998:17442"
image: tzahi12345/youtubedl-material:nightly
image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db:
image: mongo
ports:
- "27017:27017"
logging:
driver: "none"
container_name: mongo-db
restart: always
volumes:
- ./db/:/data/db
- ./db/:/data/db

3740
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.2.0",
"version": "4.3.0",
"license": "MIT",
"scripts": {
"ng": "ng",
@@ -13,7 +13,7 @@
"e2e": "ng e2e",
"electron": "ng build --base-href ./ && electron .",
"generate": "openapi --input ./\"Public API v1.yaml\" --output ./src/api-types --exportCore false --exportServices false --exportModels true",
"i18n-source": "ng extract-i18n --output-path=src/assets/i18n"
"i18n-source": "ng extract-i18n --output-path=src/assets/i18n --out-file=messages.en.xlf"
},
"engines": {
"node": "12.3.1",
@@ -21,18 +21,18 @@
},
"private": true,
"dependencies": {
"@angular-devkit/core": "^13.3.3",
"@angular/animations": "^13.3.4",
"@angular/cdk": "^13.3.4",
"@angular/common": "^13.3.4",
"@angular/compiler": "^13.3.4",
"@angular/core": "^13.3.4",
"@angular/forms": "^13.3.4",
"@angular/localize": "^13.3.4",
"@angular/material": "^13.3.4",
"@angular/platform-browser": "^13.3.4",
"@angular/platform-browser-dynamic": "^13.3.4",
"@angular/router": "^13.3.4",
"@angular-devkit/core": "^14.0.4",
"@angular/animations": "^14.0.4",
"@angular/cdk": "^14.0.4",
"@angular/common": "^14.0.4",
"@angular/compiler": "^14.0.4",
"@angular/core": "^14.0.4",
"@angular/forms": "^14.0.4",
"@angular/localize": "^14.0.4",
"@angular/material": "^14.0.4",
"@angular/platform-browser": "^14.0.4",
"@angular/platform-browser-dynamic": "^14.0.4",
"@angular/router": "^14.0.4",
"@fontsource/material-icons": "^4.5.4",
"@ngneat/content-loader": "^5.0.0",
"@videogular/ngx-videogular": "^5.0.1",
@@ -55,10 +55,10 @@
"zone.js": "~0.11.4"
},
"devDependencies": {
"@angular-devkit/build-angular": "^13.3.3",
"@angular/cli": "^13.3.3",
"@angular/compiler-cli": "^13.3.4",
"@angular/language-service": "^13.3.4",
"@angular-devkit/build-angular": "^14.0.4",
"@angular/cli": "^14.0.4",
"@angular/compiler-cli": "^14.0.4",
"@angular/language-service": "^14.0.4",
"@types/core-js": "^2.5.2",
"@types/file-saver": "^2.0.1",
"@types/jasmine": "~3.6.0",
@@ -66,7 +66,7 @@
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0",
"codelyzer": "^6.0.0",
"electron": "^13.6.6",
"electron": "^19.0.6",
"eslint": "^7.32.0",
"jasmine-core": "~3.6.0",
"jasmine-spec-reporter": "~5.0.0",

View File

@@ -40,11 +40,13 @@ export type { DownloadTwitchChatByVODIDRequest } from './models/DownloadTwitchCh
export type { DownloadTwitchChatByVODIDResponse } from './models/DownloadTwitchChatByVODIDResponse';
export type { DownloadVideosForSubscriptionRequest } from './models/DownloadVideosForSubscriptionRequest';
export { FileType } from './models/FileType';
export { FileTypeFilter } from './models/FileTypeFilter';
export type { GenerateArgsResponse } from './models/GenerateArgsResponse';
export type { GenerateNewApiKeyResponse } from './models/GenerateNewApiKeyResponse';
export type { GetAllCategoriesResponse } from './models/GetAllCategoriesResponse';
export type { GetAllDownloadsRequest } from './models/GetAllDownloadsRequest';
export type { GetAllDownloadsResponse } from './models/GetAllDownloadsResponse';
export type { GetAllFilesRequest } from './models/GetAllFilesRequest';
export type { GetAllFilesResponse } from './models/GetAllFilesResponse';
export type { GetAllSubscriptionsResponse } from './models/GetAllSubscriptionsResponse';
export type { GetAllTasksResponse } from './models/GetAllTasksResponse';
@@ -82,6 +84,7 @@ export type { RestoreDBBackupRequest } from './models/RestoreDBBackupRequest';
export { Schedule } from './models/Schedule';
export type { SetConfigRequest } from './models/SetConfigRequest';
export type { SharingToggle } from './models/SharingToggle';
export type { Sort } from './models/Sort';
export type { SubscribeRequest } from './models/SubscribeRequest';
export type { SubscribeResponse } from './models/SubscribeResponse';
export type { Subscription } from './models/Subscription';

View File

@@ -2,11 +2,8 @@
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type CreatePlaylistRequest = {
playlistName: string;
uids: Array<string>;
type: FileType;
thumbnailURL: string;
};

View File

@@ -30,4 +30,14 @@ export type DatabaseFile = {
category?: Category;
view_count?: number;
local_view_count?: number;
sub_id?: string;
registered?: number;
/**
* In pixels, only for videos
*/
height?: number;
/**
* In Kbps
*/
abr?: number;
};

View File

@@ -2,9 +2,6 @@
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type DeletePlaylistRequest = {
playlist_id: string;
type: FileType;
};

View File

@@ -22,4 +22,5 @@ export type Download = {
user_uid?: string;
sub_id?: string;
sub_name?: string;
prefetched_info?: any;
};

View File

@@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export enum FileTypeFilter {
AUDIO_ONLY = 'audio_only',
VIDEO_ONLY = 'video_only',
BOTH = 'both',
}

View File

@@ -0,0 +1,20 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileTypeFilter } from './FileTypeFilter';
import type { Sort } from './Sort';
export type GetAllFilesRequest = {
sort?: Sort;
range?: Array<number>;
/**
* Filter files by title
*/
text_search?: string;
file_type_filter?: FileTypeFilter;
/**
* Include if you want to filter by subscription
*/
sub_id?: string;
};

View File

@@ -2,11 +2,14 @@
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
import type { DatabaseFile } from './DatabaseFile';
import type { Playlist } from './Playlist';
export type GetPlaylistResponse = {
playlist: Playlist;
type: FileType;
success: boolean;
/**
* File objects for every uid in the playlist's uids property, in the same order
*/
file_objs?: Array<DatabaseFile>;
};

View File

@@ -0,0 +1,14 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
export type Sort = {
/**
* Property to sort by
*/
by?: string;
/**
* 1 for ascending, -1 for descending
*/
order?: number;
};

View File

@@ -10,7 +10,6 @@ export type Subscription = {
id: string;
type: FileType;
user_uid: string | null;
streamingOnly: boolean;
isPlaylist: boolean;
archive?: string;
timerange?: string;

View File

@@ -4,6 +4,7 @@
export type Task = {
key: string;
title?: string;
last_ran: number;
last_confirmed: number;
running: boolean;

View File

@@ -44,7 +44,7 @@
<a *ngIf="postsService.config && postsService.config.Advanced.multi_user_mode && !postsService.isLoggedIn" mat-list-item (click)="sidenav.close()" routerLink='/login'><ng-container i18n="Navigation menu Login Page title">Login</ng-container></a>
<a *ngIf="postsService.config && allowSubscriptions && postsService.hasPermission('subscriptions')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/subscriptions'><ng-container i18n="Navigation menu Subscriptions Page title">Subscriptions</ng-container></a>
<a *ngIf="postsService.config && enableDownloadsManager && postsService.hasPermission('downloads_manager')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/downloads'><ng-container i18n="Navigation menu Downloads Page title">Downloads</ng-container></a>
<a *ngIf="postsService.config && enableDownloadsManager && postsService.hasPermission('downloads_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>
<a *ngIf="postsService.config && postsService.hasPermission('tasks_manager')" mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/tasks'><ng-container i18n="Navigation menu Tasks Page title">Tasks</ng-container></a>
<ng-container *ngIf="postsService.config && postsService.hasPermission('settings')">
<mat-divider></mat-divider>
<a mat-list-item (click)="postsService.sidepanel_mode === 'over' ? sidenav.close() : null" routerLink='/settings'><ng-container i18n="Settings menu label">Settings</ng-container></a>

View File

@@ -49,7 +49,6 @@ import { CreatePlaylistComponent } from './create-playlist/create-playlist.compo
import { SubscriptionsComponent } from './subscriptions/subscriptions.component';
import { SubscribeDialogComponent } from './dialogs/subscribe-dialog/subscribe-dialog.component';
import { SubscriptionComponent } from './subscription//subscription/subscription.component';
import { SubscriptionFileCardComponent } from './subscription/subscription-file-card/subscription-file-card.component';
import { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component';
import { SettingsComponent } from './settings/settings.component';
import { MatChipsModule } from '@angular/material/chips';
@@ -74,7 +73,6 @@ import { ManageUserComponent } from './components/manage-user/manage-user.compon
import { ManageRoleComponent } from './components/manage-role/manage-role.component';
import { CookiesUploaderDialogComponent } from './dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component';
import { LogsViewerComponent } from './components/logs-viewer/logs-viewer.component';
import { ModifyPlaylistComponent } from './dialogs/modify-playlist/modify-playlist.component';
import { ConfirmDialogComponent } from './dialogs/confirm-dialog/confirm-dialog.component';
import { UnifiedFileCardComponent } from './components/unified-file-card/unified-file-card.component';
import { RecentVideosComponent } from './components/recent-videos/recent-videos.component';
@@ -102,7 +100,6 @@ registerLocaleData(es, 'es');
SubscriptionsComponent,
SubscribeDialogComponent,
SubscriptionComponent,
SubscriptionFileCardComponent,
SubscriptionInfoDialogComponent,
SettingsComponent,
AboutDialogComponent,
@@ -123,7 +120,6 @@ registerLocaleData(es, 'es');
ManageRoleComponent,
CookiesUploaderDialogComponent,
LogsViewerComponent,
ModifyPlaylistComponent,
ConfirmDialogComponent,
UnifiedFileCardComponent,
RecentVideosComponent,

View File

@@ -3,7 +3,7 @@ import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { CreatePlaylistComponent } from 'app/create-playlist/create-playlist.component';
import { ModifyPlaylistComponent } from 'app/dialogs/modify-playlist/modify-playlist.component';
import { Playlist } from 'api-types';
@Component({
selector: 'app-custom-playlists',
@@ -32,7 +32,7 @@ export class CustomPlaylistsComponent implements OnInit {
});
}
getAllPlaylists() {
getAllPlaylists(): void {
this.playlists_received = false;
// must call getAllFiles as we need to get category playlists as well
this.postsService.getPlaylists(true).subscribe(res => {
@@ -42,22 +42,25 @@ export class CustomPlaylistsComponent implements OnInit {
}
// creating a playlist
openCreatePlaylistDialog() {
openCreatePlaylistDialog(): void {
const dialogRef = this.dialog.open(CreatePlaylistComponent, {
data: {
}
create_mode: true
},
minWidth: '90vw',
minHeight: '95vh'
});
dialogRef.afterClosed().subscribe(result => {
if (result) {
this.getAllPlaylists();
this.postsService.openSnackBar('Successfully created playlist!', '');
this.postsService.openSnackBar($localize`Successfully created playlist!`);
} else if (result === false) {
this.postsService.openSnackBar('ERROR: failed to create playlist!', '');
this.postsService.openSnackBar($localize`ERROR: failed to create playlist!`);
}
});
}
goToPlaylist(info_obj) {
goToPlaylist(info_obj: { file: Playlist; }): void {
const playlist = info_obj.file;
const playlistID = playlist.id;
@@ -72,11 +75,12 @@ export class CustomPlaylistsComponent implements OnInit {
}
} else {
// playlist not found
// TODO: Make translatable
console.error(`Playlist with ID ${playlistID} not found!`);
}
}
downloadPlaylist(playlist_id, playlist_name) {
downloadPlaylist(playlist_id: string, playlist_name: string): void {
this.downloading_content[playlist_id] = true;
this.postsService.downloadPlaylistFromServer(playlist_id).subscribe(res => {
this.downloading_content[playlist_id] = false;
@@ -86,33 +90,34 @@ export class CustomPlaylistsComponent implements OnInit {
}
deletePlaylist(args) {
deletePlaylist(args: { file: Playlist; index: number; }): void {
const playlist = args.file;
const index = args.index;
const playlistID = playlist.id;
this.postsService.removePlaylist(playlistID, playlist.type).subscribe(res => {
this.postsService.removePlaylist(playlistID).subscribe(res => {
if (res['success']) {
this.playlists.splice(index, 1);
this.postsService.openSnackBar('Playlist successfully removed.', '');
this.postsService.openSnackBar($localize`Playlist successfully removed.`);
}
this.getAllPlaylists();
});
}
editPlaylistDialog(args) {
editPlaylistDialog(args: { playlist: Playlist; index: number; }): void {
const playlist = args.playlist;
const index = args.index;
const dialogRef = this.dialog.open(ModifyPlaylistComponent, {
const dialogRef = this.dialog.open(CreatePlaylistComponent, {
data: {
playlist_id: playlist.id,
width: '65vw'
}
create_mode: false
},
minWidth: '85vw'
});
dialogRef.afterClosed().subscribe(res => {
dialogRef.afterClosed().subscribe(() => {
// updates playlist in file manager if it changed
if (dialogRef.componentInstance.playlist_updated) {
this.playlists[index] = dialogRef.componentInstance.original_playlist;
this.playlists[index] = dialogRef.componentInstance.playlist;
}
});
}

View File

@@ -8,6 +8,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { MatSort } from '@angular/material/sort';
import { Clipboard } from '@angular/cdk/clipboard';
import { Download } from 'api-types';
@Component({
selector: 'app-downloads',
@@ -68,7 +69,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
sort_downloads = (a, b) => {
sort_downloads = (a: Download, b: Download): number => {
const result = b.timestamp_start - a.timestamp_start;
return result;
}
@@ -166,7 +167,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
pauseDownload(download_uid: string): void {
this.postsService.pauseDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
}
});
}
@@ -174,7 +175,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
pauseAllDownloads(): void {
this.postsService.pauseAllDownloads().subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to pause all downloads! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to pause all downloads! See server logs for more info.`);
}
});
}
@@ -182,7 +183,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
resumeDownload(download_uid: string): void {
this.postsService.resumeDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to resume download! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to resume download! See server logs for more info.`);
}
});
}
@@ -190,7 +191,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
resumeAllDownloads(): void {
this.postsService.resumeAllDownloads().subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to resume all downloads! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to resume all downloads! See server logs for more info.`);
}
});
}
@@ -198,7 +199,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
restartDownload(download_uid: string): void {
this.postsService.restartDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to restart download! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to restart download! See server logs for more info.`);
}
});
}
@@ -206,7 +207,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
cancelDownload(download_uid: string): void {
this.postsService.cancelDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to cancel download! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to cancel download! See server logs for more info.`);
}
});
}
@@ -214,12 +215,12 @@ export class DownloadsComponent implements OnInit, OnDestroy {
clearDownload(download_uid: string): void {
this.postsService.clearDownload(download_uid).subscribe(res => {
if (!res['success']) {
this.postsService.openSnackBar('Failed to pause download! See server logs for more info.');
this.postsService.openSnackBar($localize`Failed to pause download! See server logs for more info.`);
}
});
}
watchContent(download): void {
watchContent(download: Download): void {
const container = download['container'];
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
const is_playlist = container['uids']; // hacky, TODO: fix
@@ -230,7 +231,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
}
}
combineDownloads(downloads_old, downloads_new) {
combineDownloads(downloads_old: Download[], downloads_new: Download[]): Download[] {
// only keeps downloads that exist in the new set
downloads_old = downloads_old.filter(download_old => downloads_new.some(download_new => download_new.uid === download_old.uid));
@@ -251,7 +252,7 @@ export class DownloadsComponent implements OnInit, OnDestroy {
return downloads_old;
}
showError(download) {
showError(download: Download): void {
const copyToClipboardEmitter = new EventEmitter<boolean>();
this.dialog.open(ConfirmDialogComponent, {
data: {
@@ -272,10 +273,3 @@ export class DownloadsComponent implements OnInit, OnDestroy {
});
}
}
export interface Download {
timestamp_start: number;
title: string;
step_index: number;
progress: string;
}

View File

@@ -43,17 +43,17 @@ export class LogsViewerComponent implements OnInit {
})
});
} else {
this.postsService.openSnackBar('Failed to retrieve logs!');
this.postsService.openSnackBar($localize`Failed to retrieve logs!`);
}
}, err => {
this.logs_loading = false;
console.error(err);
this.postsService.openSnackBar('Failed to retrieve logs!');
this.postsService.openSnackBar($localize`Failed to retrieve logs!`);
});
}
copiedLogsToClipboard() {
this.postsService.openSnackBar('Logs copied to clipboard!');
this.postsService.openSnackBar($localize`Logs copied to clipboard!`);
}
clearLogs() {
@@ -72,12 +72,12 @@ export class LogsViewerComponent implements OnInit {
this.logs = [];
this.logs_text = '';
this.getLogs();
this.postsService.openSnackBar('Logs successfully cleared!');
this.postsService.openSnackBar($localize`Logs successfully cleared!`);
} else {
this.postsService.openSnackBar('Failed to clear logs!');
this.postsService.openSnackBar($localize`Failed to clear logs!`);
}
}, err => {
this.postsService.openSnackBar('Failed to clear logs!');
this.postsService.openSnackBar($localize`Failed to clear logs!`);
});
}
});

View File

@@ -14,12 +14,13 @@ export class ManageRoleComponent implements OnInit {
permissions = null;
permissionToLabel = {
'filemanager': 'File manager',
'settings': 'Settings access',
'subscriptions': 'Subscriptions',
'sharing': 'Share files',
'advanced_download': 'Use advanced download mode',
'downloads_manager': 'Use downloads manager'
'filemanager': $localize`File manager`,
'settings': $localize`Settings access`,
'subscriptions': $localize`Subscriptions`,
'sharing': $localize`Share files`,
'advanced_download': $localize`Use advanced download mode`,
'downloads_manager': $localize`Use downloads manager`,
'tasks_manager': $localize`Use tasks manager`,
}
constructor(public postsService: PostsService, private dialogRef: MatDialogRef<ManageRoleComponent>,

View File

@@ -17,7 +17,8 @@
</div>
</div>
<div class="col-12 order-1 col-sm-4 order-sm-2 d-flex justify-content-center">
<h4 class="my-videos-title" i18n="My videos title">My videos</h4>
<h4 *ngIf="!customHeader" class="my-videos-title" i18n="My files title">My files</h4>
<h4 *ngIf="customHeader" class="my-videos-title">{{customHeader}}</h4>
</div>
<div class="col-12 order-3 col-sm-4 order-sm-3 d-flex justify-content-center">
<mat-form-field [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
@@ -28,14 +29,15 @@
</div>
</div>
<div>
<div class="container" style="margin-bottom: 16px">
<div *ngIf="!selectMode" class="container" style="margin-bottom: 16px">
<div class="row justify-content-center">
<ng-container *ngIf="normal_files_received && paged_data">
<div *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [availablePlaylists]="playlists" (addFileToPlaylist)="addFileToPlaylist($event)" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''"></app-unified-file-card>
<div style="display: flex; align-items: center;" *ngFor="let file of paged_data; let i = index" class="mb-2 mt-2 d-flex justify-content-center" [ngClass]="[ postsService.card_size === 'small' ? 'col-2 small-col' : '', postsService.card_size === 'medium' ? 'col-6 col-lg-4 medium-col' : '', postsService.card_size === 'large' ? 'col-12 large-col' : '' ]">
<app-unified-file-card [ngClass]="downloading_content[file.uid] ? 'blurred' : ''" [index]="i" [card_size]="postsService.card_size" [locale]="postsService.locale" (goToFile)="goToFile($event)" (goToSubscription)="goToSubscription($event)" [file_obj]="file" [use_youtubedl_archive]="postsService.config['Downloader']['use_youtubedl_archive']" [availablePlaylists]="playlists" (addFileToPlaylist)="addFileToPlaylist($event)" [loading]="false" (deleteFile)="deleteFile($event)" [baseStreamPath]="postsService.path" [jwtString]="postsService.isLoggedIn ? this.postsService.token : ''"></app-unified-file-card>
<mat-spinner *ngIf="downloading_content[file.uid]" class="downloading-spinner" [diameter]="32"></mat-spinner>
</div>
<div *ngIf="paged_data.length === 0">
<ng-container i18n="No videos found">No videos found.</ng-container>
<ng-container i18n="No files found">No files found.</ng-container>
</div>
</ng-container>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
@@ -46,7 +48,54 @@
</div>
</div>
<div>
<div *ngIf="selectMode">
<mat-tab-group [(selectedIndex)]="selectedIndex">
<mat-tab label="Order" i18n-label="Order">
<div *ngIf="selected_data.length">
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order&nbsp;</span>
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order&nbsp;</span>
<button (click)="toggleSelectionOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div>
<!-- Selection order -->
<mat-button-toggle-group *ngIf="selected_data.length" class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical #group="matButtonToggleGroup">
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
<mat-button-toggle class="media-box" cdkDrag *ngFor="let file of (reverse_order ? selected_data_objs.slice().reverse() : selected_data_objs); let i = index" [checked]="false"><div><div class="playlist-item-text">{{file.title}}</div> <button (click)="removeSelectedFile(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
</mat-button-toggle-group>
<div style="margin-top: 20px;" *ngIf="!selected_data.length">
<h4 style="text-align: center;">No files selected!</h4>
</div>
</mat-tab>
<mat-tab label="Select files" i18n-label="Select files">
<mat-selection-list *ngIf="normal_files_received" (selectionChange)="fileSelectionChanged($event)">
<mat-list-option [selected]="selected_data.includes(file.uid)" *ngFor="let file of paged_data" [value]="file">
<div class="container">
<div class="row justify-content-center">
<div class="col-10">
<mat-icon class="audio-video-icon">{{(file.type === 'audio' || file.isAudio) ? 'audiotrack' : 'movie'}}</mat-icon>
{{file.title}}
</div>
<div class="col-2">{{file.registered | date:'shortDate'}}</div>
</div>
</div>
</mat-list-option>
</mat-selection-list>
<ng-container *ngIf="!normal_files_received && loading_files && loading_files.length > 0">
<mat-selection-list *ngIf="!normal_files_received">
<mat-list-option *ngFor="let file of paged_data">
<content-loader class="list-ghosts" [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>
</mat-list-option>
</mat-selection-list>
</ng-container>
</mat-tab>
</mat-tab-group>
</div>
<div style="position: relative;" *ngIf="usePaginator && selectedIndex > 0">
<div style="position: absolute; margin-left: 8px; margin-top: 5px; scale: 0.8">
<mat-form-field>
<mat-label><ng-container i18n="File type">File type</ng-container></mat-label>
@@ -57,7 +106,7 @@
</mat-select>
</mat-form-field>
</div>
<mat-paginator class="paginator" #paginator *ngIf="paged_data && paged_data.length > 0" (page)="pageChangeEvent($event)" [length]="file_count"
<mat-paginator class="paginator" #paginator (page)="pageChangeEvent($event)" [length]="file_count"
[pageSize]="pageSize"
[pageSizeOptions]="[5, 10, 25, 100, this.paged_data && this.paged_data.length > 100 ? this.paged_data.length : 250]">
</mat-paginator>

View File

@@ -61,4 +61,61 @@
.my-videos-title {
top: 0px;
}
}
.list-ghosts {
position: relative;
top: 4px;
}
.audio-video-icon {
position: relative;
top: 6px;
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.media-box:last-child {
border: none;
}
.media-list.cdk-drop-list-dragging .media-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.remove-item-button {
right: 10px;
position: absolute;
top: 4px;
}
.playlist-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
margin: 0 auto;
}
.blurred {
filter: blur(2px);
}
.downloading-spinner {
align-self: center;
position: absolute;
}

View File

@@ -1,10 +1,11 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { Router } from '@angular/router';
import { FileType } from '../../../api-types';
import { DatabaseFile, FileType, FileTypeFilter } from '../../../api-types';
import { MatPaginator } from '@angular/material/paginator';
import { Subject } from 'rxjs';
import { distinctUntilChanged } from 'rxjs/operators';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
@Component({
selector: 'app-recent-videos',
@@ -13,6 +14,26 @@ import { distinctUntilChanged } from 'rxjs/operators';
})
export class RecentVideosComponent implements OnInit {
@Input() usePaginator = true;
// File selection
@Input() selectMode = false;
@Input() defaultSelected: DatabaseFile[] = [];
@Input() sub_id = null;
@Input() customHeader = null;
@Input() selectedIndex = 1;
@Output() fileSelectionEmitter = new EventEmitter<{new_selection: string[], thumbnailURL: string}>();
pageSize = 10;
paged_data: DatabaseFile[] = null;
selected_data: string[] = [];
selected_data_objs: DatabaseFile[] = [];
reverse_order = false;
// File listing (with cards)
cached_file_count = 0;
loading_files = null;
@@ -20,7 +41,7 @@ export class RecentVideosComponent implements OnInit {
subscription_files_received = false;
file_count = 10;
searchChangedSubject: Subject<string> = new Subject<string>();
downloading_content = {'video': {}, 'audio': {}};
downloading_content = {};
search_mode = false;
search_text = '';
searchIsFocused = false;
@@ -57,18 +78,32 @@ export class RecentVideosComponent implements OnInit {
playlists = null;
pageSize = 10;
paged_data = null;
@ViewChild('paginator') paginator: MatPaginator
constructor(public postsService: PostsService, private router: Router) {
// get cached file count
if (localStorage.getItem('cached_file_count')) {
this.cached_file_count = +localStorage.getItem('cached_file_count') <= 10 ? +localStorage.getItem('cached_file_count') : 10;
this.loading_files = Array(this.cached_file_count).fill(0);
}
// set filter property to cached value
const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && this.filterProperties[cached_filter_property]) {
this.filterProperty = this.filterProperties[cached_filter_property];
}
// set file type filter to cached value
const cached_file_type_filter = localStorage.getItem('file_type_filter');
if (this.usePaginator && cached_file_type_filter) {
this.fileTypeFilter = cached_file_type_filter;
}
const sort_order = localStorage.getItem('recent_videos_sort_order');
if (sort_order) {
this.descendingMode = sort_order === 'descending';
}
}
ngOnInit(): void {
@@ -96,23 +131,9 @@ export class RecentVideosComponent implements OnInit {
}
});
// set filter property to cached value
const cached_filter_property = localStorage.getItem('filter_property');
if (cached_filter_property && this.filterProperties[cached_filter_property]) {
this.filterProperty = this.filterProperties[cached_filter_property];
}
// set file type filter to cached value
const cached_file_type_filter = localStorage.getItem('file_type_filter');
if (cached_file_type_filter) {
this.fileTypeFilter = cached_file_type_filter;
}
const sort_order = localStorage.getItem('recent_videos_sort_order');
if (sort_order) {
this.descendingMode = sort_order === 'descending';
}
this.selected_data = this.defaultSelected.map(file => file.uid);
this.selected_data_objs = this.defaultSelected;
this.searchChangedSubject
.debounceTime(500)
@@ -127,7 +148,7 @@ export class RecentVideosComponent implements OnInit {
});
}
getAllPlaylists() {
getAllPlaylists(): void {
this.postsService.getPlaylists().subscribe(res => {
this.playlists = res['playlists'];
});
@@ -135,22 +156,22 @@ export class RecentVideosComponent implements OnInit {
// search
onSearchInputChanged(newvalue) {
onSearchInputChanged(newvalue: string): void {
this.normal_files_received = false;
this.searchChangedSubject.next(newvalue);
}
filterOptionChanged(value) {
filterOptionChanged(value: string): void {
localStorage.setItem('filter_property', value['key']);
this.getAllFiles();
}
fileTypeFilterChanged(value) {
fileTypeFilterChanged(value: string): void {
localStorage.setItem('file_type_filter', value);
this.getAllFiles();
}
toggleModeChange() {
toggleModeChange(): void {
this.descendingMode = !this.descendingMode;
localStorage.setItem('recent_videos_sort_order', this.descendingMode ? 'descending' : 'ascending');
this.getAllFiles();
@@ -158,12 +179,12 @@ export class RecentVideosComponent implements OnInit {
// get files
getAllFiles(cache_mode = false) {
getAllFiles(cache_mode = false): void {
this.normal_files_received = cache_mode;
const current_file_index = (this.paginator?.pageIndex ? this.paginator.pageIndex : 0)*this.pageSize;
const sort = {by: this.filterProperty['property'], order: this.descendingMode ? -1 : 1};
const range = [current_file_index, current_file_index + this.pageSize];
this.postsService.getAllFiles(sort, range, this.search_mode ? this.search_text : null, this.fileTypeFilter).subscribe(res => {
this.postsService.getAllFiles(sort, range, this.search_mode ? this.search_text : null, this.fileTypeFilter as FileTypeFilter, this.sub_id).subscribe(res => {
this.file_count = res['file_count'];
this.paged_data = res['files'];
for (let i = 0; i < this.paged_data.length; i++) {
@@ -191,21 +212,12 @@ export class RecentVideosComponent implements OnInit {
}
}
navigateToFile(file, new_tab) {
navigateToFile(file: DatabaseFile, new_tab: boolean): void {
localStorage.setItem('player_navigator', this.router.url);
if (file.sub_id) {
const sub = this.postsService.getSubscriptionByID(file.sub_id);
if (sub.streamingOnly) {
// streaming only mode subscriptions
// !new_tab ? this.router.navigate(['/player', {name: file.id,
// url: file.requested_formats ? file.requested_formats[0].url : file.url}])
// : window.open(`/#/player;name=${file.id};url=${file.requested_formats ? file.requested_formats[0].url : file.url}`);
} else {
// normal subscriptions
!new_tab ? this.router.navigate(['/player', {uid: file.uid,
type: file.isAudio ? 'audio' : 'video'}])
type: file.isAudio ? 'audio' : 'video'}])
: window.open(`/#/player;uid=${file.uid};type=${file.isAudio ? 'audio' : 'video'}`);
}
} else {
// normal files
!new_tab ? this.router.navigate(['/player', {type: file.isAudio ? 'audio' : 'video', uid: file.uid}])
@@ -213,46 +225,26 @@ export class RecentVideosComponent implements OnInit {
}
}
goToSubscription(file) {
goToSubscription(file: DatabaseFile): void {
this.router.navigate(['/subscription', {id: file.sub_id}]);
}
// downloading
downloadFile(file) {
if (file.sub_id) {
this.downloadSubscriptionFile(file);
} else {
this.downloadNormalFile(file);
}
}
downloadSubscriptionFile(file) {
const type = (file.isAudio ? 'audio' : 'video') as FileType;
const ext = type === 'audio' ? '.mp3' : '.mp4'
const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
const blob: Blob = res;
saveAs(blob, file.id + ext);
}, err => {
console.log(err);
});
}
downloadNormalFile(file) {
downloadFile(file: DatabaseFile): void {
const type = (file.isAudio ? 'audio' : 'video') as FileType;
const ext = type === 'audio' ? '.mp3' : '.mp4'
const name = file.id;
this.downloading_content[type][name] = true;
this.downloading_content[file.uid] = true;
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.downloading_content[type][name] = false;
this.downloading_content[file.uid] = false;
const blob: Blob = res;
saveAs(blob, decodeURIComponent(name) + ext);
if (!this.postsService.config.Extra.file_manager_enabled) {
if (!this.postsService.config.Extra.file_manager_enabled && !file.sub_id) {
// tell server to delete the file once downloaded
this.postsService.deleteFile(file.uid).subscribe(delRes => {
// reload mp4s
this.postsService.deleteFile(file.uid).subscribe(() => {
// reload files
this.getAllFiles();
});
}
@@ -263,7 +255,6 @@ export class RecentVideosComponent implements OnInit {
deleteFile(args) {
const file = args.file;
const index = args.index;
const blacklistMode = args.blacklistMode;
if (file.sub_id) {
@@ -273,20 +264,20 @@ export class RecentVideosComponent implements OnInit {
}
}
deleteNormalFile(file, blacklistMode = false) {
deleteNormalFile(file: DatabaseFile, blacklistMode = false): void {
this.postsService.deleteFile(file.uid, blacklistMode).subscribe(result => {
if (result) {
this.postsService.openSnackBar('Delete success!', 'OK.');
this.postsService.openSnackBar($localize`Delete success!`, $localize`OK.`);
this.removeFileCard(file);
} else {
this.postsService.openSnackBar('Delete failed!', 'OK.');
this.postsService.openSnackBar($localize`Delete failed!`, $localize`OK.`);
}
}, err => {
this.postsService.openSnackBar('Delete failed!', 'OK.');
}, () => {
this.postsService.openSnackBar($localize`Delete failed!`, $localize`OK.`);
});
}
deleteSubscriptionFile(file, blacklistMode = false) {
deleteSubscriptionFile(file: DatabaseFile, blacklistMode = false): void {
if (blacklistMode) {
this.deleteForever(file);
} else {
@@ -294,28 +285,29 @@ export class RecentVideosComponent implements OnInit {
}
}
deleteAndRedownload(file) {
deleteAndRedownload(file: DatabaseFile): void {
const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(res => {
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
this.postsService.deleteSubscriptionFile(sub, file.id, false, file.uid).subscribe(() => {
this.postsService.openSnackBar($localize`Successfully deleted file: ` + file.id);
this.removeFileCard(file);
});
}
deleteForever(file) {
deleteForever(file: DatabaseFile): void {
const sub = this.postsService.getSubscriptionByID(file.sub_id);
this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(res => {
this.postsService.openSnackBar(`Successfully deleted file: '${file.id}'`);
this.postsService.deleteSubscriptionFile(sub, file.id, true, file.uid).subscribe(() => {
this.postsService.openSnackBar($localize`Successfully deleted file: ` + file.id);
this.removeFileCard(file);
});
}
removeFileCard(file_to_remove) {
removeFileCard(file_to_remove: DatabaseFile): void {
const index = this.paged_data.map(e => e.uid).indexOf(file_to_remove.uid);
this.paged_data.splice(index, 1);
this.getAllFiles(true);
}
// TODO: Add translation support for these snackbars
addFileToPlaylist(info_obj) {
const file = info_obj['file'];
const playlist_id = info_obj['playlist_id'];
@@ -335,13 +327,13 @@ export class RecentVideosComponent implements OnInit {
// sorting and filtering
sortFiles(a, b) {
sortFiles(a: DatabaseFile, b: DatabaseFile): number {
// uses the 'registered' flag as the timestamp
const result = b.registered - a.registered;
return result;
}
durationStringToNumber(dur_str) {
durationStringToNumber(dur_str: string): number {
let num_sum = 0;
const dur_str_parts = dur_str.split(':');
for (let i = dur_str_parts.length - 1; i >= 0; i--) {
@@ -355,4 +347,42 @@ export class RecentVideosComponent implements OnInit {
this.loading_files = Array(this.pageSize).fill(0);
this.getAllFiles();
}
fileSelectionChanged(event: { option: { _selected: boolean; value: DatabaseFile; } }): void {
const adding = event.option._selected;
const value = event.option.value;
if (adding) {
this.selected_data.push(value.uid);
this.selected_data_objs.push(value);
} else {
this.selected_data = this.selected_data.filter(e => e !== value.uid);
this.selected_data_objs = this.selected_data_objs.filter(e => e.uid !== value.uid);
}
this.fileSelectionEmitter.emit({new_selection: this.selected_data, thumbnailURL: this.selected_data_objs[0].thumbnailURL});
}
toggleSelectionOrder(): void {
this.reverse_order = !this.reverse_order;
localStorage.setItem('default_playlist_order_reversed', '' + this.reverse_order);
}
drop(event: CdkDragDrop<string[]>): void {
if (this.reverse_order) {
event.previousIndex = this.selected_data.length - 1 - event.previousIndex;
event.currentIndex = this.selected_data.length - 1 - event.currentIndex;
}
moveItemInArray(this.selected_data, event.previousIndex, event.currentIndex);
moveItemInArray(this.selected_data_objs, event.previousIndex, event.currentIndex);
this.fileSelectionEmitter.emit({new_selection: this.selected_data, thumbnailURL: this.selected_data_objs[0].thumbnailURL});
}
removeSelectedFile(index: number): void {
if (this.reverse_order) {
index = this.selected_data.length - 1 - index;
}
this.selected_data.splice(index, 1);
this.selected_data_objs.splice(index, 1);
this.fileSelectionEmitter.emit({new_selection: this.selected_data, thumbnailURL: this.selected_data_objs[0].thumbnailURL});
}
}

View File

@@ -7,6 +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';
@Component({
selector: 'app-tasks',
@@ -17,7 +18,7 @@ export class TasksComponent implements OnInit {
interval_id = null;
tasks_check_interval = 1500;
tasks = null;
tasks: Task[] = null;
tasks_retrieved = false;
displayedColumns: string[] = ['title', 'last_ran', 'last_confirmed', 'status', 'actions'];
@@ -55,6 +56,11 @@ export class TasksComponent implements OnInit {
getTasks(): void {
this.postsService.getTasks().subscribe(res => {
for (const task of res['tasks']) {
if (task.title.includes('youtube-dl')) {
task.title = task.title.replace('youtube-dl', this.postsService.config.Advanced.default_downloader);
}
}
if (this.tasks) {
if (JSON.stringify(this.tasks) === JSON.stringify(res['tasks'])) return;
for (const task of res['tasks']) {
@@ -94,7 +100,7 @@ export class TasksComponent implements OnInit {
});
}
scheduleTask(task: any): void {
scheduleTask(task: Task): void {
// open dialog
const dialogRef = this.dialog.open(UpdateTaskScheduleDialogComponent, {
data: {
@@ -152,13 +158,3 @@ export class TasksComponent implements OnInit {
}
}
export interface Task {
key: string;
title: string;
last_ran: number;
last_confirmed: number;
running: boolean;
confirming: boolean;
data: unknown;
}

View File

@@ -96,18 +96,18 @@ export class TwitchChatComponent implements OnInit, OnDestroy {
let vodId = this.db_file.url.split('videos/').length > 1 && this.db_file.url.split('videos/')[1];
vodId = vodId.split('?')[0];
if (!vodId) {
this.postsService.openSnackBar('VOD url for this video is not supported. VOD ID must be after "twitch.tv/videos/"');
this.postsService.openSnackBar($localize`VOD url for this video is not supported. VOD ID must be after "twitch.tv/videos/"`);
}
this.postsService.downloadTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', vodId, null, this.sub).subscribe(res => {
if (res['chat']) {
this.initializeChatCheck(res['chat']);
} else {
this.downloading_chat = false;
this.postsService.openSnackBar('Download failed.')
this.postsService.openSnackBar($localize`Download failed.`)
}
}, err => {
this.downloading_chat = false;
this.postsService.openSnackBar('Chat could not be downloaded.')
this.postsService.openSnackBar($localize`Chat could not be downloaded.`)
});
}

View File

@@ -23,7 +23,7 @@
<ng-container *ngIf="!is_playlist && !loading">
<button (click)="openFileInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Video info button">Info</ng-container></button>
<button (click)="navigateToSubscription()" mat-menu-item *ngIf="file_obj.sub_id"><mat-icon>{{file_obj.isAudio ? 'library_music' : 'video_library'}}</mat-icon>&nbsp;<ng-container i18n="Go to subscription menu item">Go to subscription</ng-container></button>
<button *ngIf="availablePlaylists" [matMenuTriggerFor]="addtoplaylist" mat-menu-item><mat-icon>playlist_add</mat-icon>&nbsp;<ng-container i18n="Add to playlist menu item">Add to playlist</ng-container></button>
<button [disabled]="!availablePlaylists || availablePlaylists.length === 0" [matMenuTriggerFor]="addtoplaylist" mat-menu-item><mat-icon>playlist_add</mat-icon>&nbsp;<ng-container i18n="Add to playlist menu item">Add to playlist</ng-container></button>
<mat-menu #addtoplaylist="matMenu">
<ng-container *ngFor="let playlist of availablePlaylists">
<button *ngIf="(playlist.type === 'audio') === file_obj.isAudio" [disabled]="playlist.uids?.includes(file_obj.uid)" (click)="emitAddFileToPlaylist(playlist.id)" mat-menu-item>{{playlist.name}}</button>
@@ -34,10 +34,10 @@
<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 forever</ng-container>
<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>delete</mat-icon><ng-container i18n="Delete video button">Delete</ng-container></button>
<button *ngIf="!file_obj.sub_id && use_youtubedl_archive" (click)="emitDeleteFile(true)" mat-menu-item><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete and blacklist video button">Delete and blacklist</ng-container></button>
<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>

View File

@@ -9,7 +9,6 @@ import localeES from '@angular/common/locales/es';
import localeDE from '@angular/common/locales/de';
import localeZH from '@angular/common/locales/zh';
import localeNB from '@angular/common/locales/nb';
import { DatabaseFile, Playlist } from 'api-types';
registerLocaleData(localeGB);
registerLocaleData(localeFR);
@@ -105,12 +104,16 @@ export class UnifiedFileCardComponent implements OnInit {
}
openFileInfoDialog() {
this.dialog.open(VideoInfoDialogComponent, {
const dialogRef = this.dialog.open(VideoInfoDialogComponent, {
data: {
file: this.file_obj,
},
minWidth: '50vw'
})
});
dialogRef.afterClosed().subscribe(() => {
this.file_obj = dialogRef.componentInstance.file;
});
}
emitEditPlaylist() {

View File

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

View File

@@ -1,36 +1,29 @@
<h4 mat-dialog-title i18n="Create a playlist dialog title">Create a playlist</h4>
<form>
<div *ngIf="filesToSelectFrom || (audiosToSelectFrom && videosToSelectFrom)">
<div>
<mat-form-field color="accent">
<input [(ngModel)]="name" matInput placeholder="Name" i18n-placeholder="Playlist name placeholder" type="text" required aria-required [ngModelOptions]="{standalone: true}">
</mat-form-field>
</div>
<div *ngIf="!filesToSelectFrom">
<mat-form-field color="accent">
<mat-select placeholder="Type" i18n-placeholder="Type select" [(ngModel)]="type" [ngModelOptions]="{standalone: true}">
<mat-option value="audio"><ng-container i18n="Audio">Audio</ng-container></mat-option>
<mat-option value="video"><ng-container i18n="Video">Video</ng-container></mat-option>
</mat-select>
</mat-form-field>
</div>
<div>
<mat-form-field *ngIf="type && ((filesToSelectFrom && filesToSelectFrom.length > 0) || (type === 'audio' && audiosToSelectFrom && audiosToSelectFrom.length > 0) || (type === 'video' && videosToSelectFrom && videosToSelectFrom.length > 0))" color="accent">
<mat-label *ngIf="type === 'audio'"><ng-container i18n="Audio files title">Audio files</ng-container></mat-label>
<mat-label *ngIf="type === 'video'"><ng-container i18n="Videos title">Videos</ng-container></mat-label>
<mat-select [formControl]="filesSelect" multiple required aria-required>
<ng-container *ngIf="filesToSelectFrom"><mat-option *ngFor="let file of filesToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
<ng-container *ngIf="audiosToSelectFrom && type === 'audio'"><mat-option *ngFor="let file of audiosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
<ng-container *ngIf="videosToSelectFrom && type === 'video'"><mat-option *ngFor="let file of videosToSelectFrom" [value]="file.uid">{{file.id}}</mat-option></ng-container>
</mat-select>
</mat-form-field>
<!-- No videos available -->
<div style="margin-bottom: 15px;" *ngIf="type && ((filesToSelectFrom && filesToSelectFrom.length === 0) || (type === 'audio' && audiosToSelectFrom && audiosToSelectFrom.length === 0) || (type === 'video' && videosToSelectFrom && videosToSelectFrom.length === 0))">
No files available.
</div>
</div>
</div>
</form>
<div class="fixActionRow">
<h4 mat-dialog-title *ngIf="create_mode" ><ng-container i18n="Create a playlist dialog title">Create a playlist</ng-container></h4>
<h4 mat-dialog-title *ngIf="!create_mode"><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4>
<div *ngIf="create_in_progress" style="float: left"><mat-spinner [diameter]="25"></mat-spinner></div>
<button (click)="createPlaylist()" [disabled]="!name || !filesSelect.value || filesSelect.value.length === 0" color="primary" style="float: right" mat-flat-button>Create</button>
<mat-dialog-content style="max-height: 85vh;">
<form>
<div *ngIf="create_mode || playlist">
<div>
<mat-form-field color="accent">
<input [(ngModel)]="name" matInput placeholder="Name" i18n-placeholder="Playlist name placeholder" type="text" required aria-required [ngModelOptions]="{standalone: true}">
</mat-form-field>
</div>
<app-recent-videos [selectMode]="true" [defaultSelected]="preselected_files" [customHeader]="'Select files'" (fileSelectionEmitter)="fileSelectionChanged($event)" [selectedIndex]="create_mode ? 1 : 0"></app-recent-videos>
</div>
</form>
</mat-dialog-content>
<div class="spacer"></div>
<mat-dialog-actions>
<button *ngIf="create_mode" (click)="createPlaylist()" [disabled]="!name || !filesSelect.value || filesSelect.value.length === 0" color="primary" style="float: right" mat-flat-button>
<ng-container i18n="Create button">Create</ng-container>
</button>
<button *ngIf="!create_mode" (click)="updatePlaylist()" [disabled]="!name || !playlistChanged()" color="primary" style="float: right" mat-flat-button>
<ng-container i18n="Save button">Save</ng-container>
</button>
<div *ngIf="create_in_progress" style="margin-left: 10px"><mat-spinner [diameter]="25"></mat-spinner></div>
</mat-dialog-actions>
</div>

View File

@@ -0,0 +1,9 @@
.fixActionRow {
height: 89vh;
display: flex;
flex-direction: column;
}
.spacer {
flex-grow: 1;
}

View File

@@ -1,7 +1,8 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormControl } from '@angular/forms';
import { UntypedFormControl } from '@angular/forms';
import { PostsService } from 'app/posts.services';
import { Playlist } from 'api-types';
@Component({
selector: 'app-create-playlist',
@@ -9,72 +10,94 @@ import { PostsService } from 'app/posts.services';
styleUrls: ['./create-playlist.component.scss']
})
export class CreatePlaylistComponent implements OnInit {
// really "createPlaylistDialogComponent"
// really "createAndModifyPlaylistDialogComponent"
filesToSelectFrom = null;
type = null;
filesSelect = new FormControl();
filesSelect = new UntypedFormControl();
audiosToSelectFrom = null;
videosToSelectFrom = null;
name = '';
cached_thumbnail_url = null;
create_in_progress = false;
create_mode = false;
// playlist modify mode
playlist: Playlist = null;
playlist_id: string = null;
preselected_files = [];
playlist_updated = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any,
private postsService: PostsService,
public dialogRef: MatDialogRef<CreatePlaylistComponent>) { }
ngOnInit() {
if (this.data) {
this.filesToSelectFrom = this.data.filesToSelectFrom;
this.type = this.data.type;
}
if (!this.filesToSelectFrom) {
this.getMp3s();
this.getMp4s();
}
public dialogRef: MatDialogRef<CreatePlaylistComponent>) {
if (this.data?.create_mode) this.create_mode = true;
if (this.data?.playlist_id) {
this.playlist_id = this.data.playlist_id;
this.getPlaylist();
}
}
getMp3s() {
this.postsService.getMp3s().subscribe(result => {
this.audiosToSelectFrom = result['mp3s'];
});
}
getMp4s() {
this.postsService.getMp4s().subscribe(result => {
this.videosToSelectFrom = result['mp4s'];
});
}
ngOnInit(): void {}
createPlaylist() {
createPlaylist(): void {
const thumbnailURL = this.getThumbnailURL();
this.create_in_progress = true;
this.postsService.createPlaylist(this.name, this.filesSelect.value, this.type, thumbnailURL).subscribe(res => {
this.postsService.createPlaylist(this.name, this.filesSelect.value, thumbnailURL).subscribe(res => {
this.create_in_progress = false;
if (res['success']) {
this.dialogRef.close(true);
} else {
this.dialogRef.close(false);
}
}, err => {
this.create_in_progress = false;
console.error(err);
});
}
getThumbnailURL() {
let properFilesToSelectFrom = this.filesToSelectFrom;
if (!this.filesToSelectFrom) {
properFilesToSelectFrom = this.type === 'audio' ? this.audiosToSelectFrom : this.videosToSelectFrom;
}
for (let i = 0; i < properFilesToSelectFrom.length; i++) {
const file = properFilesToSelectFrom[i];
if (file.id === this.filesSelect.value[0]) {
// different services store the thumbnail in different places
if (file.thumbnailURL) { return file.thumbnailURL };
if (file.thumbnail) { return file.thumbnail };
updatePlaylist(): void {
this.create_in_progress = true;
this.playlist['name'] = this.name;
this.playlist['uids'] = this.filesSelect.value;
this.playlist_updated = true;
this.postsService.updatePlaylist(this.playlist).subscribe(() => {
this.create_in_progress = false;
this.postsService.openSnackBar($localize`Playlist updated successfully.`);
this.getPlaylist();
this.postsService.playlists_changed.next(true);
}, err => {
this.create_in_progress = false;
console.error(err)
this.postsService.openSnackBar($localize`Playlist updated successfully.`);
});
}
getThumbnailURL(): string {
return this.cached_thumbnail_url;
}
fileSelectionChanged({new_selection, thumbnailURL}: {new_selection: string[], thumbnailURL: string}): void {
this.filesSelect.setValue(new_selection);
if (new_selection.length) this.cached_thumbnail_url = thumbnailURL;
else this.cached_thumbnail_url = null;
}
playlistChanged(): boolean {
return JSON.stringify(this.playlist.uids) !== JSON.stringify(this.filesSelect.value) || this.name !== this.playlist.name;
}
getPlaylist(): void {
this.postsService.getPlaylist(this.playlist_id, null, true).subscribe(res => {
if (res['playlist']) {
this.filesSelect.setValue(res['file_objs'].map(file => file.uid));
this.preselected_files = res['file_objs'];
this.playlist = res['playlist'];
this.name = this.playlist['name'];
}
}
return null;
});
}
}

View File

@@ -2,7 +2,7 @@ import { Component, OnInit, Inject, Pipe, PipeTransform, ViewChild, AfterViewIni
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialog } from '@angular/material/dialog';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { FormControl } from '@angular/forms';
import { UntypedFormControl } from '@angular/forms';
import { args, args_info } from './youtubedl_args';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators/map';
@@ -30,13 +30,13 @@ export class HighlightPipe implements PipeTransform {
styleUrls: ['./arg-modifier-dialog.component.scss'],
})
export class ArgModifierDialogComponent implements OnInit, AfterViewInit {
myGroup = new FormControl();
myGroup = new UntypedFormControl();
firstArg = '';
secondArg = '';
secondArgEnabled = false;
modified_args = '';
stateCtrl = new FormControl();
chipCtrl = new FormControl();
stateCtrl = new UntypedFormControl();
chipCtrl = new UntypedFormControl();
availableArgs = null;
argsByCategory = null;
argsByKey = null;

View File

@@ -39,7 +39,7 @@ export class CookiesUploaderDialogComponent implements OnInit {
this.uploading = false;
if (res['success']) {
this.uploaded = true;
this.postsService.openSnackBar('Cookies successfully uploaded!');
this.postsService.openSnackBar($localize`Cookies successfully uploaded!`);
}
}, err => {
this.uploading = false;

View File

@@ -34,11 +34,6 @@
</mat-select>
</mat-form-field>
</div>
<div class="col-12 mt-1">
<div>
<mat-checkbox [disabled]="new_sub.type === 'audio'" [(ngModel)]="new_sub.streamingOnly"><ng-container i18n="Streaming-only mode">Streaming-only mode</ng-container></mat-checkbox>
</div>
</div>
<div class="col-12 mb-3">
<mat-form-field color="accent">
<input [(ngModel)]="new_sub.custom_args" matInput placeholder="Custom args" i18n-placeholder="Subscription custom args placeholder">

View File

@@ -1,44 +0,0 @@
<h4 mat-dialog-title><ng-container i18n="Modify playlist dialog title">Modify playlist</ng-container></h4>
<mat-dialog-content>
<div *ngIf="playlist">
<!-- Playlist info -->
<div>
<mat-form-field color="accent">
<input matInput placeholder="Name" i18n-placeholder="Name" [(ngModel)]="playlist.name">
</mat-form-field>
</div>
<div>
<mat-checkbox [(ngModel)]="playlist.randomize_order"><ng-container i18n="Randomize order when playing checkbox label">Randomize order when playing</ng-container></mat-checkbox>
</div>
<div style="margin-bottom: 10px; height: 40px;">
<div style="float: left">
<span *ngIf="reverse_order === false" i18n="Normal order">Normal order&nbsp;</span>
<span *ngIf="reverse_order === true" i18n="Reverse order">Reverse order&nbsp;</span>
<button (click)="togglePlaylistOrder()" mat-icon-button><mat-icon>{{!reverse_order ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div>
<div style="float: right">
<button [disabled]="available_files.length === 0" mat-stroked-button [matMenuTriggerFor]="menu"><ng-container i18n="Add content">Add content</ng-container></button>
</div>
</div>
<!-- Playlist order -->
<mat-button-toggle-group class="media-list" cdkDropList (cdkDropListDropped)="drop($event)" style="width: 80%; left: 9%" vertical name="videoSelect" aria-label="Video Select" #group="matButtonToggleGroup">
<!-- The following for loop can be optimized but it requires a pipe https://stackoverflow.com/a/35703364/8088021 -->
<mat-button-toggle class="media-box" cdkDrag *ngFor="let playlist_item of (reverse_order ? playlist_file_objs.slice().reverse() : playlist_file_objs); let i = index" [checked]="false"><div><div class="playlist-item-text">{{playlist_item.title}}</div> <button (click)="removeContent(i)" class="remove-item-button" mat-icon-button><mat-icon>cancel</mat-icon></button></div></mat-button-toggle>
</mat-button-toggle-group>
<mat-menu #menu="matMenu">
<button *ngFor="let file of available_files" (click)="addContent(file)" mat-menu-item>{{file.title}}</button>
</mat-menu>
</div>
</mat-dialog-content>
<mat-dialog-actions>
<!-- Save -->
<button [disabled]="!playlist || !playlistChanged()" (click)="updatePlaylist()" mat-raised-button color="accent"><ng-container i18n="Save">Save</ng-container></button>
</mat-dialog-actions>

View File

@@ -1,45 +0,0 @@
.media-list {
}
.media-box {
}
.cdk-drag-preview {
box-sizing: border-box;
border-radius: 4px;
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
0 8px 10px 1px rgba(0, 0, 0, 0.14),
0 3px 14px 2px rgba(0, 0, 0, 0.12);
}
.cdk-drag-placeholder {
opacity: 0;
}
.cdk-drag-animating {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.media-box:last-child {
border: none;
}
.media-list.cdk-drop-list-dragging .media-box:not(.cdk-drag-placeholder) {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
.remove-item-button {
right: 10px;
position: absolute;
top: 4px;
}
.playlist-item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 70%;
margin: 0 auto;
}

View File

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

View File

@@ -1,107 +0,0 @@
import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { PostsService } from 'app/posts.services';
@Component({
selector: 'app-modify-playlist',
templateUrl: './modify-playlist.component.html',
styleUrls: ['./modify-playlist.component.scss']
})
export class ModifyPlaylistComponent implements OnInit {
playlist_id = null;
original_playlist = null;
playlist = null;
playlist_file_objs = null;
available_files = [];
all_files = [];
playlist_updated = false;
reverse_order = false;
constructor(@Inject(MAT_DIALOG_DATA) public data: any,
private postsService: PostsService,
public dialogRef: MatDialogRef<ModifyPlaylistComponent>) { }
ngOnInit(): void {
if (this.data) {
this.playlist_id = this.data.playlist_id;
this.getPlaylist();
}
this.reverse_order = localStorage.getItem('default_playlist_order_reversed') === 'true';
}
getFiles() {
if (this.playlist.type === 'audio') {
this.postsService.getMp3s().subscribe(res => {
this.processFiles(res['mp3s']);
});
} else {
this.postsService.getMp4s().subscribe(res => {
this.processFiles(res['mp4s']);
});
}
}
processFiles(new_files = null) {
if (new_files) { this.all_files = new_files; }
this.available_files = this.all_files.filter(e => !this.playlist_file_objs.includes(e))
}
updatePlaylist() {
this.playlist['uids'] = this.playlist_file_objs.map(playlist_file_obj => playlist_file_obj['uid'])
this.postsService.updatePlaylist(this.playlist).subscribe(res => {
this.playlist_updated = true;
this.postsService.openSnackBar('Playlist updated successfully.');
this.getPlaylist();
this.postsService.playlists_changed.next(true);
});
}
playlistChanged() {
return JSON.stringify(this.playlist) !== JSON.stringify(this.original_playlist);
}
getPlaylist() {
this.postsService.getPlaylist(this.playlist_id, null, true).subscribe(res => {
if (res['playlist']) {
this.playlist = res['playlist'];
this.playlist_file_objs = res['file_objs'];
this.original_playlist = JSON.parse(JSON.stringify(this.playlist));
this.getFiles();
}
});
}
addContent(file) {
this.playlist_file_objs.push(file);
this.playlist.uids.push(file.uid);
this.processFiles();
}
removeContent(index) {
if (this.reverse_order) {
index = this.playlist_file_objs.length - 1 - index;
}
this.playlist_file_objs.splice(index, 1);
this.playlist.uids.splice(index, 1);
this.processFiles();
}
togglePlaylistOrder() {
this.reverse_order = !this.reverse_order;
localStorage.setItem('default_playlist_order_reversed', '' + this.reverse_order);
}
drop(event: CdkDragDrop<string[]>) {
if (this.reverse_order) {
event.previousIndex = this.playlist_file_objs.length - 1 - event.previousIndex;
event.currentIndex = this.playlist_file_objs.length - 1 - event.currentIndex;
}
moveItemInArray(this.playlist_file_objs, event.previousIndex, event.currentIndex);
}
}

View File

@@ -36,14 +36,14 @@ export class RestoreDbDialogComponent implements OnInit {
this.postsService.restoreDBBackup(this.selected_backup[0]).subscribe(res => {
this.restoring = false;
if (res['success']) {
this.postsService.openSnackBar('Database successfully restored!');
this.postsService.openSnackBar($localize`Database successfully restored!`);
this.dialogRef.close();
} else {
this.postsService.openSnackBar('Failed to restore database! See logs for more info.');
this.postsService.openSnackBar($localize`Failed to restore database! See logs for more info.`);
}
}, err => {
this.restoring = false;
this.postsService.openSnackBar('Failed to restore database! See browser console for more info.');
this.postsService.openSnackBar($localize`Failed to restore database! See browser console for more info.`);
console.error(err);
});
}

View File

@@ -58,31 +58,31 @@ export class ShareMediaDialogComponent implements OnInit {
}
copiedToClipboard() {
this.openSnackBar('Copied to clipboard!');
this.postsService.openSnackBar($localize`Copied to clipboard!`);
}
sharingChanged(event) {
if (event.checked) {
this.postsService.enableSharing(this.uid, this.is_playlist).subscribe(res => {
if (res['success']) {
this.openSnackBar('Sharing enabled.');
this.postsService.openSnackBar($localize`Sharing enabled.`);
this.sharing_enabled = true;
} else {
this.openSnackBar('Failed to enable sharing.');
this.postsService.openSnackBar($localize`Failed to enable sharing.`);
}
}, err => {
this.openSnackBar('Failed to enable sharing - server error.');
this.postsService.openSnackBar($localize`Failed to enable sharing - server error.`);
});
} else {
this.postsService.disableSharing(this.uid, this.is_playlist).subscribe(res => {
if (res['success']) {
this.openSnackBar('Sharing disabled.');
this.postsService.openSnackBar($localize`Sharing disabled.`);
this.sharing_enabled = false;
} else {
this.openSnackBar('Failed to disable sharing.');
this.postsService.openSnackBar($localize`Failed to disable sharing.`);
}
}, err => {
this.openSnackBar('Failed to disable sharing - server error.');
this.postsService.openSnackBar($localize`Failed to disable sharing - server error.`);
});
}
}

View File

@@ -47,11 +47,6 @@
<mat-checkbox [(ngModel)]="audioOnlyMode"><ng-container i18n="Streaming-only mode">Audio-only mode</ng-container></mat-checkbox>
</div>
</div>
<div class="col-12">
<div>
<mat-checkbox [disabled]="audioOnlyMode" [(ngModel)]="streamingOnlyMode"><ng-container i18n="Streaming-only mode">Streaming-only mode</ng-container></mat-checkbox>
</div>
</div>
<div class="col-12 mb-3">
<mat-form-field color="accent">
<input [(ngModel)]="customArgs" matInput placeholder="Custom args" i18n-placeholder="Subscription custom args placeholder">

View File

@@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { MatDialogRef, MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { PostsService } from 'app/posts.services';
import { ArgModifierDialogComponent } from '../arg-modifier-dialog/arg-modifier-dialog.component';
@@ -22,9 +21,6 @@ export class SubscribeDialogComponent implements OnInit {
// state
subscribing = false;
// no videos actually downloaded, just streamed
streamingOnlyMode = false;
// audio only mode
audioOnlyMode = false;
@@ -70,7 +66,6 @@ export class SubscribeDialogComponent implements OnInit {
];
constructor(private postsService: PostsService,
private snackBar: MatSnackBar,
private dialog: MatDialog,
public dialogRef: MatDialogRef<SubscribeDialogComponent>) { }
@@ -81,7 +76,7 @@ export class SubscribeDialogComponent implements OnInit {
if (this.url && this.url !== '') {
// timerange must be specified if download_all is false
if (!this.download_all && !this.timerange_amount) {
this.openSnackBar('You must specify an amount of time');
this.postsService.openSnackBar($localize`You must specify an amount of time`);
return;
}
this.subscribing = true;
@@ -97,7 +92,7 @@ export class SubscribeDialogComponent implements OnInit {
this.dialogRef.close(res['new_sub']);
} else {
if (res['error']) {
this.openSnackBar('ERROR: ' + res['error']);
this.postsService.openSnackBar($localize`ERROR: ` + res['error']);
}
this.dialogRef.close();
}
@@ -118,11 +113,4 @@ export class SubscribeDialogComponent implements OnInit {
}
});
}
public openSnackBar(message: string, action = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { PostsService } from 'app/posts.services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { UpdaterStatus } from '../../../api-types';
@Component({
@@ -14,7 +13,7 @@ export class UpdateProgressDialogComponent implements OnInit {
updateInterval = 250;
errored = false;
constructor(private postsService: PostsService, private snackBar: MatSnackBar) { }
constructor(private postsService: PostsService) { }
ngOnInit(): void {
this.getUpdateProgress();
@@ -28,16 +27,9 @@ export class UpdateProgressDialogComponent implements OnInit {
if (res) {
this.updateStatus = res;
if (this.updateStatus && this.updateStatus['error']) {
this.openSnackBar('Update failed. Check logs for more details.');
this.postsService.openSnackBar($localize`Update failed. Check logs for more details.`);
}
}
});
}
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -47,6 +47,14 @@
<mat-divider style="margin-bottom: 16px;"></mat-divider>
<div *ngIf="!new_file.isAudio" class="info-item">
<div class="info-item-label"><strong><ng-container i18n="Video resolution property">Resolution:</ng-container>&nbsp;</strong></div>
<div class="info-item-value">{{new_file.height ? new_file.height + 'p' : 'N/A'}}</div>
</div>
<div class="info-item">
<div class="info-item-label"><strong><ng-container i18n="Video audio bitrate property">Audio bitrate:</ng-container>&nbsp;</strong></div>
<div class="info-item-value">{{new_file.abr ? new_file.abr + ' Kbps' : 'N/A'}}</div>
</div>
<div class="info-item">
<div class="info-item-label"><strong><ng-container i18n="Video file size property">File size:</ng-container>&nbsp;</strong></div>
<div class="info-item-value">{{new_file.size ? filesize(new_file.size) : 'N/A'}}</div>

View File

@@ -60,7 +60,7 @@
</div>
</form>
<br/>
<mat-checkbox [disabled]="current_download" (change)="videoModeChanged($event)" [(ngModel)]="audioOnly" style="float: left; margin-top: -12px">
<mat-checkbox [disabled]="autoplay && current_download" (change)="videoModeChanged($event)" [(ngModel)]="audioOnly" style="float: left; margin-top: -12px">
<ng-container i18n="Only Audio checkbox">
Only Audio
</ng-container>

View File

@@ -1,7 +1,7 @@
import { Component, OnInit, ElementRef, ViewChild, ViewChildren, QueryList } from '@angular/core';
import {PostsService} from '../posts.services';
import { Observable, Subject } from 'rxjs';
import {FormControl, Validators} from '@angular/forms';
import {UntypedFormControl, Validators} from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { saveAs } from 'file-saver';
@@ -10,12 +10,7 @@ import { Router, ActivatedRoute } from '@angular/router';
import { Platform } from '@angular/cdk/platform';
import { ArgModifierDialogComponent } from 'app/dialogs/arg-modifier-dialog/arg-modifier-dialog.component';
import { RecentVideosComponent } from 'app/components/recent-videos/recent-videos.component';
import { Download, FileType } from 'api-types';
export let audioFilesMouseHovering = false;
export let videoFilesMouseHovering = false;
export let audioFilesOpened = false;
export let videoFilesOpened = false;
import { DatabaseFile, Download, FileType, Playlist } from 'api-types';
@Component({
selector: 'app-root',
@@ -55,8 +50,6 @@ export class MainComponent implements OnInit {
allowQualitySelect = false;
downloadOnlyMode = false;
allowAutoplay = false;
audioFolderPath;
videoFolderPath;
use_youtubedl_archive = false;
globalCustomArgs = null;
allowAdvancedDownload = false;
@@ -74,16 +67,13 @@ export class MainComponent implements OnInit {
results_showing = true;
results = [];
mp3s: any[] = [];
mp4s: any[] = [];
playlists = {'audio': [], 'video': []};
playlist_thumbnails = {};
downloading_content = {'audio': {}, 'video': {}};
downloads: Download[] = [];
download_uids: string[] = [];
current_download: Download = null;
urlForm = new FormControl('', [Validators.required]);
urlForm = new UntypedFormControl('', [Validators.required]);
qualityOptions = {
'video': [
@@ -206,8 +196,6 @@ export class MainComponent implements OnInit {
&& this.postsService.hasPermission('filemanager');
this.downloadOnlyMode = this.postsService.config['Extra']['download_only_mode'];
this.allowAutoplay = this.postsService.config['Extra']['allow_autoplay'];
this.audioFolderPath = this.postsService.config['Downloader']['path-audio'];
this.videoFolderPath = this.postsService.config['Downloader']['path-video'];
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'] &&
@@ -314,20 +302,20 @@ export class MainComponent implements OnInit {
}
// download helpers
downloadHelper(container, type: string, is_playlist = false, force_view = false, navigate_mode = false): void {
downloadHelper(container: DatabaseFile | Playlist, type: string, is_playlist = false, force_view = false, navigate_mode = false): void {
this.downloadingfile = false;
if (!this.autoplay && !this.downloadOnlyMode && !navigate_mode) {
// do nothing
this.reloadRecentVideos();
this.reloadRecentVideos(is_playlist);
} else {
// if download only mode, just download the file. no redirect
if (force_view === false && this.downloadOnlyMode && !this.iOS) {
if (is_playlist) {
this.downloadPlaylist(container['uid']);
} else {
this.downloadFileFromServer(container, type);
this.downloadFileFromServer(container as DatabaseFile, type);
}
this.reloadRecentVideos();
this.reloadRecentVideos(is_playlist);
} else {
localStorage.setItem('player_navigator', this.router.url.split(';')[0]);
if (is_playlist) {
@@ -396,7 +384,7 @@ export class MainComponent implements OnInit {
}, () => { // can't access server
this.downloadingfile = false;
this.current_download = null;
this.postsService.openSnackBar('Download failed!', 'OK.');
this.postsService.openSnackBar($localize`Download failed!`, 'OK.');
});
if (!this.autoplay && urls.length === 1) {
@@ -444,7 +432,7 @@ export class MainComponent implements OnInit {
return null;
}
getDownloadByUID(uid: string) {
getDownloadByUID(uid: string): Download {
const index = this.downloads.findIndex(download => download.uid === uid);
if (index !== -1) {
return this.downloads[index];
@@ -453,7 +441,7 @@ export class MainComponent implements OnInit {
}
}
removeDownloadFromCurrentDownloads(download_to_remove): boolean {
removeDownloadFromCurrentDownloads(download_to_remove: Download): boolean {
if (this.current_download === download_to_remove) {
this.current_download = null;
}
@@ -466,11 +454,9 @@ export class MainComponent implements OnInit {
}
}
downloadFileFromServer(file, type: string): void {
downloadFileFromServer(file: DatabaseFile, type: string): void {
const ext = type === 'audio' ? 'mp3' : 'mp4'
this.downloading_content[type][file.id] = true;
this.postsService.downloadFileFromServer(file.uid).subscribe(res => {
this.downloading_content[type][file.id] = false;
const blob: Blob = res;
saveAs(blob, decodeURIComponent(file.id) + `.${ext}`);
@@ -481,9 +467,8 @@ export class MainComponent implements OnInit {
});
}
downloadPlaylist(playlist): void {
downloadPlaylist(playlist: Playlist): void {
this.postsService.downloadPlaylistFromServer(playlist.id).subscribe(res => {
if (playlist.id) { this.downloading_content[playlist.type][playlist.id] = false };
const blob: Blob = res;
saveAs(blob, playlist.name + '.zip');
});
@@ -603,11 +588,11 @@ export class MainComponent implements OnInit {
if (simulated_args) {
// hide password if needed
const passwordIndex = simulated_args.indexOf('--password');
console.log(passwordIndex);
if (passwordIndex !== -1 && passwordIndex !== simulated_args.length - 1) {
simulated_args[passwordIndex + 1] = simulated_args[passwordIndex + 1].replace(/./g, '*');
}
this.simulatedOutput = `youtube-dl ${this.url} ${simulated_args.join(' ')}`;
const downloader = this.postsService.config.Advanced.default_downloader;
this.simulatedOutput = `${downloader} ${this.url} ${simulated_args.join(' ')}`;
}
});
}
@@ -780,13 +765,14 @@ export class MainComponent implements OnInit {
if (this.current_download['finished'] && !this.current_download['error']) {
const container = this.current_download['container'];
const is_playlist = this.current_download['file_uids'].length > 1;
this.downloadHelper(container, this.current_download['type'], is_playlist, false);
this.current_download = null;
const is_playlist = this.current_download['file_uids'].length > 1;
const type = this.current_download['type'];
this.current_download = null;
this.downloadHelper(container, type, is_playlist, false);
} else if (this.current_download['finished'] && this.current_download['error']) {
this.downloadingfile = false;
this.current_download = null;
this.postsService.openSnackBar('Download failed!', 'OK.');
this.postsService.openSnackBar($localize`Download failed!`, 'OK.');
}
} else {
// console.log('failed to get new download');
@@ -794,8 +780,9 @@ export class MainComponent implements OnInit {
});
}
reloadRecentVideos(): void {
reloadRecentVideos(is_playlist = false): void {
this.postsService.files_changed.next(true);
if (is_playlist) this.postsService.playlists_changed.next(true);
}
getURLArray(url_str: string): Array<string> {

View File

@@ -3,7 +3,6 @@ import { VgApiService } from '@videogular/ngx-videogular/core';
import { PostsService } from 'app/posts.services';
import { ActivatedRoute, Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { ShareMediaDialogComponent } from '../dialogs/share-media-dialog/share-media-dialog.component';
import { FileType } from '../../api-types';
@@ -109,7 +108,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}
constructor(public postsService: PostsService, private route: ActivatedRoute, private dialog: MatDialog, private router: Router,
public snackBar: MatSnackBar, private cdr: ChangeDetectorRef) {
private cdr: ChangeDetectorRef) {
}
processConfig(): void {
@@ -147,7 +146,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.postsService.getFile(this.uid, this.uuid).subscribe(res => {
this.db_file = res['file'];
if (!this.db_file) {
this.postsService.openSnackBar('Failed to get file information from the server.', 'Dismiss');
this.postsService.openSnackBar($localize`Failed to get file information from the server.`, 'Dismiss');
return;
}
this.postsService.incrementViewCount(this.db_file['uid'], null, this.uuid).subscribe(() => undefined, err => {
@@ -169,6 +168,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.uids = this.subscription.videos.map(video => video['uid']);
this.parseFileNames();
}, () => {
// TODO: Make translatable
this.postsService.openSnackBar(`Failed to find subscription ${this.sub_id}`, 'Dismiss');
});
}
@@ -183,10 +183,10 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
this.show_player = true;
this.parseFileNames();
} else {
this.postsService.openSnackBar('Failed to load playlist!', '');
this.postsService.openSnackBar($localize`Failed to load playlist!`);
}
}, () => {
this.postsService.openSnackBar('Failed to load playlist!', '');
this.postsService.openSnackBar($localize`Failed to load playlist!`);
});
}
@@ -351,7 +351,7 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
width: '60vw'
});
dialogRef.afterClosed().subscribe(res => {
dialogRef.afterClosed().subscribe(() => {
if (!this.playlist_id) {
this.getFile();
} else {
@@ -361,12 +361,16 @@ export class PlayerComponent implements OnInit, AfterViewInit, OnDestroy {
}
openFileInfoDialog(): void {
this.dialog.open(VideoInfoDialogComponent, {
const dialogRef = this.dialog.open(VideoInfoDialogComponent, {
data: {
file: this.db_file,
},
minWidth: '50vw'
})
});
dialogRef.afterClosed().subscribe(() => {
this.db_file = dialogRef.componentInstance.file;
});
}
setPlaybackTimestamp(time: number): void {

View File

@@ -4,7 +4,7 @@ import 'rxjs/add/operator/map';
import 'rxjs/add/operator/catch';
import 'rxjs/add/observable/throw';
import { THEMES_CONFIG } from '../themes';
import { Router, CanActivate } from '@angular/router';
import { Router, CanActivate, ActivatedRouteSnapshot } from '@angular/router';
import { DOCUMENT } from '@angular/common';
import { BehaviorSubject } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -97,7 +97,11 @@ import {
Schedule,
ClearDownloadsRequest,
Category,
UpdateFileRequest
UpdateFileRequest,
Sort,
FileTypeFilter,
GetAllFilesRequest,
GetAllTasksResponse
} from '../api-types';
import { isoLangs } from './settings/locales_list';
import { Title } from '@angular/platform-browser';
@@ -225,12 +229,15 @@ export class PostsService implements CanActivate {
}
}
canActivate(route, state): Promise<boolean> {
return new Promise(resolve => {
resolve(true);
})
console.log(route);
throw new Error('Method not implemented.');
canActivate(route: ActivatedRouteSnapshot, state): Promise<boolean> {
const PATH_TO_REQUIRED_PERM = {
settings: 'settings',
subscriptions: 'subscriptions',
downloads: 'downloads_manager',
tasks: 'tasks_manager'
}
const required_perm = PATH_TO_REQUIRED_PERM[route.routeConfig.path];
return required_perm ? this.hasPermission(required_perm) : true;
}
setTheme(theme) {
@@ -355,8 +362,9 @@ export class PostsService implements CanActivate {
return this.http.post<GetFileResponse>(this.path + 'getFile', body, this.httpOptions);
}
getAllFiles(sort, range, text_search, file_type_filter) {
return this.http.post<GetAllFilesResponse>(this.path + 'getAllFiles', {sort: sort, range: range, text_search: text_search, file_type_filter: file_type_filter}, this.httpOptions);
getAllFiles(sort: Sort = null, range: number[] = null, text_search: string = null, file_type_filter: FileTypeFilter = FileTypeFilter.BOTH, sub_id: string = null) {
const body: GetAllFilesRequest = {sort: sort, range: range, text_search: text_search, file_type_filter: file_type_filter, sub_id: sub_id};
return this.http.post<GetAllFilesResponse>(this.path + 'getAllFiles', body, this.httpOptions);
}
updateFile(uid: string, change_obj: Object) {
@@ -443,10 +451,9 @@ export class PostsService implements CanActivate {
return this.http.post<SuccessObject>(this.path + 'disableSharing', body, this.httpOptions);
}
createPlaylist(playlistName: string, uids: string[], type: FileType, thumbnailURL: string) {
createPlaylist(playlistName: string, uids: string[], thumbnailURL: string) {
const body: CreatePlaylistRequest = {playlistName: playlistName,
uids: uids,
type: type,
thumbnailURL: thumbnailURL};
return this.http.post<CreatePlaylistResponse>(this.path + 'createPlaylist', body, this.httpOptions);
}
@@ -471,8 +478,8 @@ export class PostsService implements CanActivate {
return this.http.post<SuccessObject>(this.path + 'updatePlaylist', body, this.httpOptions);
}
removePlaylist(playlist_id: string, type: FileType) {
const body: DeletePlaylistRequest = {playlist_id: playlist_id, type: type};
removePlaylist(playlist_id: string) {
const body: DeletePlaylistRequest = {playlist_id: playlist_id};
return this.http.post<SuccessObject>(this.path + 'deletePlaylist', body, this.httpOptions);
}
@@ -593,7 +600,7 @@ export class PostsService implements CanActivate {
}
getTasks() {
return this.http.post<SuccessObject>(this.path + 'getTasks', {}, this.httpOptions);
return this.http.post<GetAllTasksResponse>(this.path + 'getTasks', {}, this.httpOptions);
}
resetTasks() {

View File

@@ -263,11 +263,16 @@
</div>
<div class="col-12">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_API_key']" matInput placeholder="Twitch API Key" i18n-placeholder="Twitch API Key setting placeholder" required>
<mat-hint><ng-container i18n="Twitch API Key setting hint AKA preamble">Also known as a Client ID.</ng-container>&nbsp;<a target="_blank" href="https://dev.twitch.tv/docs/api/"><ng-container i18n="Twitch API Key setting hint">Generating a key is easy!</ng-container></a></mat-hint>
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_client_ID']" matInput placeholder="Twitch Client ID" i18n-placeholder="Twitch Client ID setting placeholder" 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-4">
<div class="col-12 mt-2">
<mat-form-field class="text-field" color="accent">
<input [disabled]="!new_config['API']['use_twitch_API']" [(ngModel)]="new_config['API']['twitch_client_secret']" matInput placeholder="Twitch Client Secret" i18n-placeholder="Twitch Client Secret setting placeholder" 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>
<div class="col-12 mt-2 mb-3">
@@ -323,6 +328,7 @@
<div class="test-connection-div">
<button (click)="testConnectionString(new_config['Database']['mongodb_connection_string'])" [disabled]="testing_connection_string" mat-flat-button color="accent"><ng-container i18n="Test connection string button">Test connection string</ng-container></button>
<mat-spinner class="test-connection-spinner" style="margin-left: 10px" *ngIf="testing_connection_string" [diameter]="25"></mat-spinner>
</div>
<div class="transfer-db-div">

View File

@@ -105,4 +105,11 @@
.action-buttons {
position: absolute;
bottom: 15px;
}
.test-connection-spinner {
display: inline-block;
position: relative;
top: 6px;
margin-left: 10px;
}

View File

@@ -13,6 +13,7 @@ import { moveItemInArray, CdkDragDrop } from '@angular/cdk/drag-drop';
import { InputDialogComponent } from 'app/input-dialog/input-dialog.component';
import { EditCategoryDialogComponent } from 'app/dialogs/edit-category-dialog/edit-category-dialog.component';
import { ActivatedRoute, Router } from '@angular/router';
import { Category } from 'api-types';
@Component({
selector: 'app-settings',
@@ -62,7 +63,7 @@ export class SettingsComponent implements OnInit {
Object.keys(this.INDEX_TO_TAB).forEach(key => { this.TAB_TO_INDEX[this.INDEX_TO_TAB[key]] = key; });
}
ngOnInit() {
ngOnInit(): void {
if (this.postsService.initialized) {
this.getConfig();
this.getDBInfo();
@@ -90,16 +91,16 @@ export class SettingsComponent implements OnInit {
});
}
getConfig() {
getConfig(): void {
this.initial_config = this.postsService.config;
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
}
settingsSame() {
settingsSame(): boolean {
return JSON.stringify(this.new_config) === JSON.stringify(this.initial_config);
}
saveSettings() {
saveSettings(): void {
const settingsToSave = {'YoutubeDLMaterial': this.new_config};
this.postsService.setConfig(settingsToSave).subscribe(res => {
if (res['success']) {
@@ -111,31 +112,31 @@ export class SettingsComponent implements OnInit {
this.initial_config = JSON.parse(JSON.stringify(this.new_config));
this.postsService.reload_config.next(true);
}
}, err => {
}, () => {
console.error('Failed to save config!');
})
}
cancelSettings() {
cancelSettings(): void {
this.new_config = JSON.parse(JSON.stringify(this.initial_config));
}
tabChanged(event) {
tabChanged(event): void {
const index = event['index'];
this.router.navigate(['/settings', {tab: this.INDEX_TO_TAB[index]}]);
}
dropCategory(event: CdkDragDrop<string[]>) {
dropCategory(event: CdkDragDrop<string[]>): void {
moveItemInArray(this.postsService.categories, event.previousIndex, event.currentIndex);
this.postsService.updateCategories(this.postsService.categories).subscribe(res => {
}, err => {
this.postsService.openSnackBar('Failed to update categories!');
}, () => {
this.postsService.openSnackBar($localize`Failed to update categories!`);
});
}
openAddCategoryDialog() {
const done = new EventEmitter<any>();
openAddCategoryDialog(): void {
const done = new EventEmitter<boolean>();
const dialogRef = this.dialog.open(InputDialogComponent, {
width: '300px',
data: {
@@ -162,7 +163,7 @@ export class SettingsComponent implements OnInit {
});
}
deleteCategory(category) {
deleteCategory(category: Category): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Delete category',
@@ -175,17 +176,18 @@ export class SettingsComponent implements OnInit {
if (confirmed) {
this.postsService.deleteCategory(category['uid']).subscribe(res => {
if (res['success']) {
// TODO: Make translatable
this.postsService.openSnackBar(`Successfully deleted ${category['name']}!`);
this.postsService.reloadCategories();
}
}, err => {
}, () => {
this.postsService.openSnackBar(`Failed to delete ${category['name']}!`);
});
}
});
}
openEditCategoryDialog(category) {
openEditCategoryDialog(category: Category): void {
this.dialog.open(EditCategoryDialogComponent, {
data: {
category: category
@@ -193,7 +195,7 @@ export class SettingsComponent implements OnInit {
});
}
generateAPIKey() {
generateAPIKey(): void {
this.postsService.generateNewAPIKey().subscribe(res => {
if (res['new_api_key']) {
this.initial_config.API.API_key = res['new_api_key'];
@@ -202,16 +204,16 @@ export class SettingsComponent implements OnInit {
});
}
localeSelectChanged(new_val) {
localeSelectChanged(new_val: string): void {
localStorage.setItem('locale', new_val);
this.openSnackBar('Language successfully changed! Reload to update the page.')
this.postsService.openSnackBar($localize`Language successfully changed! Reload to update the page.`)
}
generateBookmarklet() {
generateBookmarklet(): void {
this.bookmarksite('YTDL-Material', this.generated_bookmarklet_code);
}
generateBookmarkletCode() {
generateBookmarkletCode(): string {
const currentURL = window.location.href.split('#')[0];
const homePageWithArgsURL = currentURL + '#/home;url=';
const audioOnly = this.bookmarkletAudioOnly;
@@ -226,13 +228,13 @@ export class SettingsComponent implements OnInit {
}
// not currently functioning on most platforms. hence not in use
bookmarksite(title, url) {
bookmarksite(title: string, url: string): void {
// Internet Explorer
if (document.all) {
window['external']['AddFavorite'](url, title);
} else if (window['chrome']) {
// Google Chrome
this.openSnackBar('Chrome users must drag the \'Alternate URL\' link to your bookmarks.');
this.postsService.openSnackBar($localize`Chrome users must drag the 'Alternate URL' link to your bookmarks.`);
} else if (window['sidebar']) {
// Firefox
window['sidebar'].addPanel(title, url, '');
@@ -246,7 +248,7 @@ export class SettingsComponent implements OnInit {
}
}
openArgsModifierDialog() {
openArgsModifierDialog(): void {
const dialogRef = this.dialog.open(ArgModifierDialogComponent, {
data: {
initial_args: this.new_config['Downloader']['custom_args']
@@ -259,20 +261,20 @@ export class SettingsComponent implements OnInit {
});
}
getLatestGithubRelease() {
getLatestGithubRelease(): void {
this.postsService.getLatestGithubRelease().subscribe(res => {
this.latestGithubRelease = res;
});
}
openCookiesUploaderDialog() {
openCookiesUploaderDialog(): void {
this.dialog.open(CookiesUploaderDialogComponent, {
width: '65vw'
});
}
killAllDownloads() {
const done = new EventEmitter<any>();
killAllDownloads(): void {
const done = new EventEmitter<boolean>();
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Kill downloads',
@@ -287,34 +289,34 @@ export class SettingsComponent implements OnInit {
this.postsService.killAllDownloads().subscribe(res => {
if (res['success']) {
dialogRef.close();
this.postsService.openSnackBar('Successfully killed all downloads!');
this.postsService.openSnackBar($localize`Successfully killed all downloads!`);
} else {
dialogRef.close();
this.postsService.openSnackBar('Failed to kill all downloads! Check logs for details.');
this.postsService.openSnackBar($localize`Failed to kill all downloads! Check logs for details.`);
}
}, err => {
}, () => {
dialogRef.close();
this.postsService.openSnackBar('Failed to kill all downloads! Check logs for details.');
this.postsService.openSnackBar($localize`Failed to kill all downloads! Check logs for details.`);
});
}
});
}
restartServer() {
this.postsService.restartServer().subscribe(res => {
this.postsService.openSnackBar('Restarting!');
}, err => {
this.postsService.openSnackBar('Failed to restart the server.');
restartServer(): void {
this.postsService.restartServer().subscribe(() => {
this.postsService.openSnackBar($localize`Restarting!`);
}, () => {
this.postsService.openSnackBar($localize`Failed to restart the server.`);
});
}
getDBInfo() {
getDBInfo(): void {
this.postsService.getDBInfo().subscribe(res => {
this.db_info = res['db_info'];
});
}
transferDB() {
transferDB(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: 'Transfer DB',
@@ -329,44 +331,36 @@ export class SettingsComponent implements OnInit {
});
}
_transferDB() {
_transferDB(): void {
this.db_transferring = true;
this.postsService.transferDB(this.db_info['using_local_db']).subscribe(res => {
this.db_transferring = false;
const success = res['success'];
if (success) {
this.openSnackBar('Successfully transfered DB! Reloading info...');
this.postsService.openSnackBar($localize`Successfully transfered DB! Reloading info...`);
this.getDBInfo();
} else {
this.openSnackBar('Failed to transfer DB -- transfer was aborted. Error: ' + res['error']);
this.postsService.openSnackBar($localize`Failed to transfer DB -- transfer was aborted. Error: ` + res['error']);
}
}, err => {
this.db_transferring = false;
this.openSnackBar('Failed to transfer DB -- API call failed. See browser logs for details.');
this.postsService.openSnackBar($localize`Failed to transfer DB -- API call failed. See browser logs for details.`);
console.error(err);
});
}
testConnectionString(connection_string) {
testConnectionString(connection_string: string): void {
this.testing_connection_string = true;
this.postsService.testConnectionString(connection_string).subscribe(res => {
this.testing_connection_string = false;
if (res['success']) {
this.postsService.openSnackBar('Connection successful!');
this.postsService.openSnackBar($localize`Connection successful!`);
} else {
this.postsService.openSnackBar('Connection failed! Error: ' + res['error']);
this.postsService.openSnackBar($localize`Connection failed! Error: ` + res['error']);
}
}, err => {
}, () => {
this.testing_connection_string = false;
this.postsService.openSnackBar('Connection failed! Error: Server error. See logs for more info.');
this.postsService.openSnackBar($localize`Connection failed! Error: Server error. See logs for more info.`);
});
}
// snackbar helper
public openSnackBar(message: string, action: string = '') {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}

View File

@@ -1,20 +0,0 @@
<div style="position: relative; width: fit-content;">
<div class="duration-time">
<ng-container i18n="Video duration label">Length:</ng-container>&nbsp;{{formattedDuration}}
</div>
<button [matMenuTriggerFor]="action_menu" class="menuButton" mat-icon-button><mat-icon>more_vert</mat-icon></button>
<mat-menu #action_menu="matMenu">
<button (click)="openSubscriptionInfoDialog()" mat-menu-item><mat-icon>info</mat-icon><ng-container i18n="Subscription video info button">Info</ng-container></button>
<button (click)="deleteAndRedownload()" mat-menu-item><mat-icon>restore</mat-icon><ng-container i18n="Delete and redownload subscription video button">Delete and redownload</ng-container></button>
<button (click)="deleteForever()" mat-menu-item *ngIf="sub.archive && use_youtubedl_archive"><mat-icon>delete_forever</mat-icon><ng-container i18n="Delete forever subscription video button">Delete forever</ng-container></button>
</mat-menu>
<mat-card (click)="goToFile()" matRipple class="example-card mat-elevation-z6">
<div style="padding:5px">
<div *ngIf="!image_errored && file.thumbnailURL" class="img-div">
<img class="image" (error)="onImgError($event)" [src]="file.thumbnailURL" alt="Thumbnail">
</div>
<span class="max-two-lines"><strong>{{file.title}}</strong></span>
</div>
</mat-card>
</div>

View File

@@ -1,76 +0,0 @@
.example-card {
width: 200px;
height: 200px;
padding: 0px;
cursor: pointer;
}
.menuButton {
right: 0px;
top: -1px;
position: absolute;
z-index: 999;
}
/* Coerce the <span> icon container away from display:inline */
.mat-icon-button .mat-button-wrapper {
display: flex;
justify-content: center;
}
.image {
width: 200px;
height: 112.5px;
object-fit: cover;
}
.example-full-width-height {
width: 100%;
height: 100%
}
.centered {
margin: 0 auto;
top: 50%;
left: 50%;
}
.img-div {
max-height: 80px;
padding: 0px;
margin: 32px 0px 0px -5px;
width: calc(100% + 5px + 5px);
}
.max-two-lines {
display: -webkit-box;
display: -moz-box;
max-height: 2.4em;
line-height: 1.2em;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
bottom: 5px;
position: absolute;
}
.duration-time {
position: absolute;
left: 5px;
top: 5px;
z-index: 99999;
}
@media (max-width: 576px){
.example-card {
width: 175px !important;
}
.image {
width: 175px;
}
}

View File

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

View File

@@ -1,98 +0,0 @@
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router } from '@angular/router';
import { PostsService } from 'app/posts.services';
import { MatDialog } from '@angular/material/dialog';
import { VideoInfoDialogComponent } from 'app/dialogs/video-info-dialog/video-info-dialog.component';
@Component({
selector: 'app-subscription-file-card',
templateUrl: './subscription-file-card.component.html',
styleUrls: ['./subscription-file-card.component.scss']
})
export class SubscriptionFileCardComponent implements OnInit {
image_errored = false;
image_loaded = false;
formattedDuration = null;
@Input() file;
@Input() sub;
@Input() use_youtubedl_archive = false;
@Output() goToFileEmit = new EventEmitter<any>();
@Output() reloadSubscription = new EventEmitter<boolean>();
constructor(private snackBar: MatSnackBar, private postsService: PostsService, private dialog: MatDialog) {}
ngOnInit() {
if (this.file.duration) {
this.formattedDuration = fancyTimeFormat(this.file.duration);
}
}
onImgError(event) {
this.image_errored = true;
}
imageLoaded(loaded) {
this.image_loaded = true;
}
goToFile() {
const emit_obj = {
uid: this.file.uid,
url: this.file.requested_formats ? this.file.requested_formats[0].url : this.file.url
}
this.goToFileEmit.emit(emit_obj);
}
openSubscriptionInfoDialog() {
const dialogRef = this.dialog.open(VideoInfoDialogComponent, {
data: {
file: this.file,
},
minWidth: '50vw'
});
}
deleteAndRedownload() {
this.postsService.deleteSubscriptionFile(this.sub, this.file.id, false, this.file.uid).subscribe(res => {
this.reloadSubscription.emit(true);
this.openSnackBar(`Successfully deleted file: '${this.file.id}'`, 'Dismiss.');
});
}
deleteForever() {
this.postsService.deleteSubscriptionFile(this.sub, this.file.id, true, this.file.uid).subscribe(res => {
this.reloadSubscription.emit(true);
this.openSnackBar(`Successfully deleted file: '${this.file.id}'`, 'Dismiss.');
});
}
public openSnackBar(message: string, action: string) {
this.snackBar.open(message, action, {
duration: 2000,
});
}
}
function fancyTimeFormat(time) {
// Hours, minutes and seconds
const hrs = ~~(time / 3600);
const mins = ~~((time % 3600) / 60);
const secs = ~~time % 60;
// Output like "1:01" or "4:03:59" or "123:03:59"
let ret = '';
if (hrs > 0) {
ret += '' + hrs + ':' + (mins < 10 ? '0' : '');
}
ret += '' + mins + ':' + (secs < 10 ? '0' : '');
ret += '' + secs;
return ret;
}

View File

@@ -10,38 +10,7 @@
<br/>
<div *ngIf="subscription">
<div class="flex-grid">
<div class="filter-select-parent">
<div style="display: inline-block;">
<mat-select style="width: 110px;" [(ngModel)]="this.filterProperty" (selectionChange)="filterOptionChanged($event.value)">
<mat-option *ngFor="let filterOption of filterProperties | keyvalue" [value]="filterOption.value">
{{filterOption['value']['label']}}
</mat-option>
</mat-select>
</div>
<div style="display: inline-block;">
<button (click)="toggleModeChange()" mat-icon-button><mat-icon>{{descendingMode ? 'arrow_downward' : 'arrow_upward'}}</mat-icon></button>
</div>
</div>
<div class="col">
</div>
<div class="col">
<h4 i18n="Subscription videos title" style="text-align: center; margin-bottom: 20px;">Videos</h4>
</div>
<div style="top: -12px;" class="col">
<mat-form-field [ngClass]="searchIsFocused ? 'search-bar-focused' : 'search-bar-unfocused'" class="search-bar" color="accent">
<input (focus)="searchIsFocused = true" (blur)="searchIsFocused = false" class="search-input" type="text" placeholder="Search" i18n-placeholder="Subscription videos search placeholder" [(ngModel)]="search_text" (ngModelChange)="onSearchInputChanged($event)" matInput>
<mat-icon matSuffix>search</mat-icon>
</mat-form-field>
</div>
</div>
<div class="container">
<div class="row justify-content-center">
<div *ngFor="let file of filtered_files" class="col-6 col-lg-4 mb-2 mt-2 sub-file-col">
<app-subscription-file-card (reloadSubscription)="getSubscription()" (goToFileEmit)="goToFile($event)" [file]="file" [sub]="subscription" [use_youtubedl_archive]="use_youtubedl_archive"></app-subscription-file-card>
</div>
</div>
</div>
<app-recent-videos [sub_id]="subscription.id" [usePaginator]="false"></app-recent-videos>
</div>
<button class="edit-button" color="primary" (click)="editSubscription()" [disabled]="downloading" mat-fab><mat-icon class="save-icon">edit</mat-icon></button>
<button class="watch-button" color="primary" (click)="watchSubscription()" mat-fab><mat-icon class="save-icon">video_library</mat-icon></button>

View File

@@ -100,22 +100,12 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
});
}
getConfig() {
getConfig(): void {
this.use_youtubedl_archive = this.postsService.config['Downloader']['use_youtubedl_archive'];
}
goToFile(emit_obj) {
const uid = emit_obj['uid'];
const url = emit_obj['url'];
localStorage.setItem('player_navigator', this.router.url);
if (this.subscription.streamingOnly) {
this.router.navigate(['/player', {uid: uid, url: url}]);
} else {
this.router.navigate(['/player', {uid: uid}]);
}
}
onSearchInputChanged(newvalue) {
onSearchInputChanged(newvalue: string): void {
if (newvalue.length > 0) {
this.search_mode = true;
this.filterFiles(newvalue);
@@ -129,7 +119,7 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
this.filtered_files = this.files.filter(option => option.id.toLowerCase().includes(filterValue));
}
filterByProperty(prop) {
filterByProperty(prop: string): void {
if (this.descendingMode) {
this.filtered_files = this.filtered_files.sort((a, b) => (a[prop] > b[prop] ? -1 : 1));
} else {
@@ -142,17 +132,12 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
localStorage.setItem('filter_property', value['key']);
}
toggleModeChange() {
toggleModeChange(): void {
this.descendingMode = !this.descendingMode;
this.filterByProperty(this.filterProperty['property']);
}
downloadContent() {
const fileNames = [];
for (let i = 0; i < this.files.length; i++) {
fileNames.push(this.files[i].path);
}
downloadContent(): void {
this.downloading = true;
this.postsService.downloadSubFromServer(this.subscription.id).subscribe(res => {
this.downloading = false;
@@ -164,7 +149,7 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
});
}
editSubscription() {
editSubscription(): void {
this.dialog.open(EditSubscriptionDialogComponent, {
data: {
sub: this.postsService.getSubscriptionByID(this.subscription.id)
@@ -172,7 +157,7 @@ export class SubscriptionComponent implements OnInit, OnDestroy {
});
}
watchSubscription() {
watchSubscription(): void {
this.router.navigate(['/player', {sub_id: this.subscription.id}])
}

File diff suppressed because it is too large Load Diff

View File

@@ -3332,7 +3332,7 @@
</trans-unit>
<trans-unit id="b1c08387975e6feada407c9b5f5f564261b8192b" datatype="html">
<source>Database information could not be retrieved. Check the server logs for more information.</source>
<target state="translated">Impossibile recuperare le informazioni del database. Controllare i registri del server per ulteriori informazioni.</target>
<target state="translated">Impossibile recuperare le informazioni del database. Controllare il registro del server per ulteriori informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">333</context>
@@ -3347,6 +3347,698 @@
<context context-type="linenumber">48</context>
</context-group>
</trans-unit>
<trans-unit id="5070854963159885174" datatype="html">
<source>Playlist successfully removed.', '</source>
<target state="translated">Playlist rimossa con successo.', '</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/custom-playlists/custom-playlists.component.ts</context>
<context context-type="linenumber">99</context>
</context-group>
</trans-unit>
<trans-unit id="5215119607776782829" datatype="html">
<source>Select downloads to clear</source>
<target state="translated">Seleziona i download da cancellare</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">132</context>
</context-group>
</trans-unit>
<trans-unit id="2723988842145709249" datatype="html">
<source>Errored downloads</source>
<target state="translated">Download errati</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">146</context>
</context-group>
</trans-unit>
<trans-unit id="3961621815065792326" datatype="html">
<source>Failed to clear finished downloads!</source>
<target state="translated">Cancellazione dei download finiti non riuscita!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">157</context>
</context-group>
</trans-unit>
<trans-unit id="5823550543348347814" datatype="html">
<source>Cleared downloads!</source>
<target state="translated">Download cancellati!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">159</context>
</context-group>
</trans-unit>
<trans-unit id="2293081271355999967" datatype="html">
<source>Logs successfully cleared!</source>
<target state="translated">Registro cancellato con successo!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/logs-viewer/logs-viewer.component.ts</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="4516710756538206828" datatype="html">
<source>Failed to clear logs!</source>
<target state="translated">Impossibile cancellare il registro!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/logs-viewer/logs-viewer.component.ts</context>
<context context-type="linenumber">77</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/logs-viewer/logs-viewer.component.ts</context>
<context context-type="linenumber">80</context>
</context-group>
</trans-unit>
<trans-unit id="52e0fa8ada52c3f29774a4508582fd98250b9f93" datatype="html">
<source>My files</source>
<target state="translated">I miei file</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
<note priority="1" from="description">My files title</note>
</trans-unit>
<trans-unit id="6827066f436adfc56a142d5816a8be6113d73b01" datatype="html">
<source>No files found.</source>
<target state="translated">Nessun file trovato.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.html</context>
<context context-type="linenumber">40</context>
</context-group>
<note priority="1" from="description">No files found</note>
</trans-unit>
<trans-unit id="5704ec2049d007c5f5fb495a5d8b607e68d58081" datatype="html">
<source>Order</source>
<target state="translated">Ordina</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.html</context>
<context context-type="linenumber">53</context>
</context-group>
<note priority="1" from="description">Order</note>
</trans-unit>
<trans-unit id="ae9a5141f5c6bd62cee4ce837598ea8b0904e5cf" datatype="html">
<source>Select files</source>
<target state="translated">Seleziona file</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.html</context>
<context context-type="linenumber">71</context>
</context-group>
<note priority="1" from="description">Select files</note>
</trans-unit>
<trans-unit id="2159130950882492111" datatype="html">
<source>Cancel</source>
<target state="translated">Annulla</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/confirm-dialog/confirm-dialog.component.ts</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="1709839462010459086" datatype="html">
<source>Cookies successfully uploaded!</source>
<target state="translated">Cookie caricati con successo!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/cookies-uploader-dialog/cookies-uploader-dialog.component.ts</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="3480433876298276350" datatype="html">
<source>Database successfully restored!</source>
<target state="translated">Database ripristinato con successo!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/restore-db-dialog/restore-db-dialog.component.ts</context>
<context context-type="linenumber">39</context>
</context-group>
</trans-unit>
<trans-unit id="1946323844380374711" datatype="html">
<source>Failed to restore database! See browser console for more info.</source>
<target state="translated">Ripristino del database non riuscito! Guarda la console del browser per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/restore-db-dialog/restore-db-dialog.component.ts</context>
<context context-type="linenumber">46</context>
</context-group>
</trans-unit>
<trans-unit id="4960870191807928282" datatype="html">
<source>Sharing enabled.</source>
<target state="translated">Condivisione abilitata.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/share-media-dialog/share-media-dialog.component.ts</context>
<context context-type="linenumber">68</context>
</context-group>
</trans-unit>
<trans-unit id="2720327817780634026" datatype="html">
<source>Failed to enable sharing.</source>
<target state="translated">Abilitazione della condivisione non riuscita.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/share-media-dialog/share-media-dialog.component.ts</context>
<context context-type="linenumber">71</context>
</context-group>
</trans-unit>
<trans-unit id="5397815846940616259" datatype="html">
<source>You must specify an amount of time</source>
<target state="translated">Devi specificare un periodo di tempo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscribe-dialog/subscribe-dialog.component.ts</context>
<context context-type="linenumber">79</context>
</context-group>
</trans-unit>
<trans-unit id="3544790314111256717" datatype="html">
<source>ERROR:</source>
<target state="translated">ERRORE:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/subscribe-dialog/subscribe-dialog.component.ts</context>
<context context-type="linenumber">95</context>
</context-group>
</trans-unit>
<trans-unit id="f4003b626fcbf3a871778d4dba166e109d02f87c" datatype="html">
<source>Thumbnail URL</source>
<target state="translated">URL della miniatura</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">29</context>
</context-group>
<note priority="1" from="description">Thumbnail URL</note>
</trans-unit>
<trans-unit id="607de17c2a755f65775881c19e276e7c933bcf94" datatype="html">
<source>Category</source>
<target state="translated">Categoria</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
<note priority="1" from="description">Category</note>
</trans-unit>
<trans-unit id="3f741a2c015bb728088b630296ca401e823c6af8" datatype="html">
<source>View count</source>
<target state="translated">Numero di visualizzazioni</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">42</context>
</context-group>
<note priority="1" from="description">View count</note>
</trans-unit>
<trans-unit id="989f5aa799ee9672675d68109bff29d1d88ebd49" datatype="html">
<source>Local view count</source>
<target state="translated">Numero di visualizzazioni locale</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">45</context>
</context-group>
<note priority="1" from="description">Local view count</note>
</trans-unit>
<trans-unit id="9fa37704969eeebd496a172c5077370f569df3ae" datatype="html">
<source>Resolution:</source>
<target state="translated">Risoluzione:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">51</context>
</context-group>
<note priority="1" from="description">Video resolution property</note>
</trans-unit>
<trans-unit id="9fc54db2830fbbd332b1adebe28e9283069107ef" datatype="html">
<source>Audio bitrate:</source>
<target state="translated">Bitrate audio:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">55</context>
</context-group>
<note priority="1" from="description">Video audio bitrate property</note>
</trans-unit>
<trans-unit id="2734512985872312443" datatype="html">
<source>Failed to get file information from the server.</source>
<target state="translated">Impossibile ottenere le informazioni del file dal server.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/player/player.component.ts</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="5d78fe9ba69a8710613d3f7c35b22e9c8226e4dc" datatype="html">
<source>Twitch Client ID</source>
<target state="translated">ID Client Twitch</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">266</context>
</context-group>
<note priority="1" from="description">Twitch Client ID setting placeholder</note>
</trans-unit>
<trans-unit id="9208873922277364009" datatype="html">
<source>Failed to update categories!</source>
<target state="translated">Aggiornamento delle categorie non riuscito!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">134</context>
</context-group>
</trans-unit>
<trans-unit id="7180231139026789468" datatype="html">
<source>Language successfully changed! Reload to update the page.</source>
<target state="translated">Lingua cambiata con successo! Ricarica per aggiornare la pagina.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">209</context>
</context-group>
</trans-unit>
<trans-unit id="6123898845299902958" datatype="html">
<source>Successfully transfered DB! Reloading info...</source>
<target state="translated">DB trasferito con successo! Ricaricando informazioni...</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">340</context>
</context-group>
</trans-unit>
<trans-unit id="5681417617361245213" datatype="html">
<source>Failed to transfer DB -- transfer was aborted. Error:</source>
<target state="translated">Trasferimento del DB non riuscito -- transferimento interrotto. Errore:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">343</context>
</context-group>
</trans-unit>
<trans-unit id="6519219215739537829" datatype="html">
<source>Connection failed! Error: Server error. See logs for more info.</source>
<target state="translated">Connessione non riuscita! Errore: Errore server. Guarda il registro per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">363</context>
</context-group>
</trans-unit>
<trans-unit id="65918861159071115" datatype="html">
<source>Successfully created playlist!', '</source>
<target state="translated">Playlist creata con successo!', '</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>
</context-group>
</trans-unit>
<trans-unit id="7114033980971410157" datatype="html">
<source>Failed to pause download! See server logs for more info.</source>
<target state="translated">Interruzione del download non riuscita! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">170</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">218</context>
</context-group>
</trans-unit>
<trans-unit id="8348223454028662277" datatype="html">
<source>OK.</source>
<target state="translated">OK.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">270</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">273</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">276</context>
</context-group>
</trans-unit>
<trans-unit id="22bac71dbdc1ac62607135994f81cca8094cb251" datatype="html">
<source>Upload date</source>
<target state="translated">Data caricamento</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
<note priority="1" from="description">Upload date</note>
</trans-unit>
<trans-unit id="1019978815798793544" datatype="html">
<source>Failed to retrieve logs!</source>
<target state="translated">Recupero dei registro non riuscito!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/logs-viewer/logs-viewer.component.ts</context>
<context context-type="linenumber">46</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/logs-viewer/logs-viewer.component.ts</context>
<context context-type="linenumber">51</context>
</context-group>
</trans-unit>
<trans-unit id="3481862581074838726" datatype="html">
<source>VOD url for this video is not supported. VOD ID must be after "twitch.tv/videos/"</source>
<target state="translated">L'url del VOD di questo video non è supportato. L'ID del VOD deve essere dopo "twitch.tv/videos/"</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/twitch-chat/twitch-chat.component.ts</context>
<context context-type="linenumber">99</context>
</context-group>
</trans-unit>
<trans-unit id="7241816854520039909" datatype="html">
<source>ERROR: failed to create playlist!', '</source>
<target state="translated">ERRORE: creazione della playlist non riuscita!', '</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/custom-playlists/custom-playlists.component.ts</context>
<context context-type="linenumber">58</context>
</context-group>
</trans-unit>
<trans-unit id="a4a4a5f03d7d0831ccf6774094e66a9507a42b58" datatype="html">
<source>Clear downloads</source>
<target state="translated">Cancella i download</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.html</context>
<context context-type="linenumber">85</context>
</context-group>
<note priority="1" from="description">Clear downloads</note>
</trans-unit>
<trans-unit id="3299455901271096793" datatype="html">
<source>Clear downloads</source>
<target state="translated">Cancella i download</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">131</context>
</context-group>
</trans-unit>
<trans-unit id="4050356167294261426" datatype="html">
<source>Delete success!</source>
<target state="translated">Cancellazione avvenuta con successo!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">270</context>
</context-group>
</trans-unit>
<trans-unit id="7405156667148936748" datatype="html">
<source>Delete failed!</source>
<target state="translated">Cancellazione non riuscita!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">273</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">276</context>
</context-group>
</trans-unit>
<trans-unit id="8485375438204712002" datatype="html">
<source>Finished downloads</source>
<target state="translated">Download terminati</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">138</context>
</context-group>
</trans-unit>
<trans-unit id="5223827577229167333" datatype="html">
<source>Failed to pause all downloads! See server logs for more info.</source>
<target state="translated">Interruzione di tutti i download non riuscita! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">178</context>
</context-group>
</trans-unit>
<trans-unit id="5801924165267871854" datatype="html">
<source>Paused downloads</source>
<target state="translated">Download interrotti</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">142</context>
</context-group>
</trans-unit>
<trans-unit id="7157191502004604261" datatype="html">
<source>Chat could not be downloaded.</source>
<target state="translated">Impossibile scaricare la chat.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/twitch-chat/twitch-chat.component.ts</context>
<context context-type="linenumber">110</context>
</context-group>
</trans-unit>
<trans-unit id="87406377200084623" datatype="html">
<source>Logs copied to clipboard!</source>
<target state="translated">Registro copiato negli appunti!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/logs-viewer/logs-viewer.component.ts</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="5456775416888155476" datatype="html">
<source>Failed to resume download! See server logs for more info.</source>
<target state="translated">Ripresa del download non riuscita! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">186</context>
</context-group>
</trans-unit>
<trans-unit id="6268791413935580107" datatype="html">
<source>Failed to resume all downloads! See server logs for more info.</source>
<target state="translated">Ripresa di tutti i download non riuscita! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">194</context>
</context-group>
</trans-unit>
<trans-unit id="571023367671104036" datatype="html">
<source>Failed to restart download! See server logs for more info.</source>
<target state="translated">Riavvio del download non riuscito! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">202</context>
</context-group>
</trans-unit>
<trans-unit id="4529487534884306633" datatype="html">
<source>Failed to cancel download! See server logs for more info.</source>
<target state="translated">Annullamento del download non riuscito! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/downloads/downloads.component.ts</context>
<context context-type="linenumber">210</context>
</context-group>
</trans-unit>
<trans-unit id="c1b7e6d75ff4285c7636c67e5ef259629b81725b" datatype="html">
<source>Confirm Password</source>
<target state="translated">Conferma password</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/login/login.component.html</context>
<context context-type="linenumber">28</context>
</context-group>
<note priority="1" from="description">Confirm Password</note>
</trans-unit>
<trans-unit id="8937901770314883418" datatype="html">
<source>Successfully deleted file:</source>
<target state="translated">File eliminati con successo:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">291</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/recent-videos/recent-videos.component.ts</context>
<context context-type="linenumber">299</context>
</context-group>
</trans-unit>
<trans-unit id="e58f5716d6c08b6a841eb003c9f9774b5c5d34a9" datatype="html">
<source>Delete and don't download again</source>
<target state="translated">Elimina e non scaricare di nuovo</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">37</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/unified-file-card/unified-file-card.component.html</context>
<context context-type="linenumber">40</context>
</context-group>
<note priority="1" from="description">Delete forever subscription video button</note>
</trans-unit>
<trans-unit id="9203653061903371757" datatype="html">
<source>Playlist updated successfully.</source>
<target state="translated">Playlist aggiornata con successo.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/create-playlist/create-playlist.component.ts</context>
<context context-type="linenumber">69</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/create-playlist/create-playlist.component.ts</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="880407735794041263" datatype="html">
<source>Download failed.</source>
<target state="translated">Download non riuscito.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/twitch-chat/twitch-chat.component.ts</context>
<context context-type="linenumber">106</context>
</context-group>
</trans-unit>
<trans-unit id="643438049907907768" datatype="html">
<source>Failed to restore database! See logs for more info.</source>
<target state="translated">Ripristino del database non riuscito! Guarda il registro del server per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/restore-db-dialog/restore-db-dialog.component.ts</context>
<context context-type="linenumber">42</context>
</context-group>
</trans-unit>
<trans-unit id="2859348955905483094" datatype="html">
<source>Failed to enable sharing - server error.</source>
<target state="translated">Abilitazione della condivisione non riuscita - errore server.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/share-media-dialog/share-media-dialog.component.ts</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="3561468911579213356" datatype="html">
<source>Sharing disabled.</source>
<target state="translated">Condivisione disabilitata.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/share-media-dialog/share-media-dialog.component.ts</context>
<context context-type="linenumber">79</context>
</context-group>
</trans-unit>
<trans-unit id="8692976466689769553" datatype="html">
<source>Failed to disable sharing - server error.</source>
<target state="translated">Disabilitazione della condivisione non riuscita - errore server.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/share-media-dialog/share-media-dialog.component.ts</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="2876893175497409225" datatype="html">
<source>Update failed. Check logs for more details.</source>
<target state="translated">Aggiornamento non riuscito. Guarda il registro per più dettagli.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/update-progress-dialog/update-progress-dialog.component.ts</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="3317b8688eb2cfabc4021cd7b2926b32f3864ad2" datatype="html">
<source>Choose a date</source>
<target state="translated">Seleziona una data</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/update-task-schedule-dialog/update-task-schedule-dialog.component.html</context>
<context context-type="linenumber">22</context>
</context-group>
<note priority="1" from="description">Choose a date</note>
</trans-unit>
<trans-unit id="7840375760456214518" datatype="html">
<source>Failed to disable sharing.</source>
<target state="translated">Disabilitazione della condivisione non riuscita.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/share-media-dialog/share-media-dialog.component.ts</context>
<context context-type="linenumber">82</context>
</context-group>
</trans-unit>
<trans-unit id="f8c7be184fefd6750e4e5d0c7a90e74721c58f8a" datatype="html">
<source>Uploader</source>
<target state="translated">Caricato da</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">17</context>
</context-group>
<note priority="1" from="description">Uploader</note>
</trans-unit>
<trans-unit id="d49d5d6786b69d140e20cfddfe29690a19641a88" datatype="html">
<source>Thumbnail path</source>
<target state="translated">Percorso della miniatura</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/dialogs/video-info-dialog/video-info-dialog.component.html</context>
<context context-type="linenumber">26</context>
</context-group>
<note priority="1" from="description">Thumbnail path</note>
</trans-unit>
<trans-unit id="8314249599019746316" datatype="html">
<source>Download failed!</source>
<target state="translated">Download non riuscito!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/main/main.component.ts</context>
<context context-type="linenumber">387</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/main/main.component.ts</context>
<context context-type="linenumber">775</context>
</context-group>
</trans-unit>
<trans-unit id="6789263921624845085" datatype="html">
<source>Failed to load playlist!</source>
<target state="translated">Caricamento della playlist non riuscito!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/player/player.component.ts</context>
<context context-type="linenumber">186</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/player/player.component.ts</context>
<context context-type="linenumber">189</context>
</context-group>
</trans-unit>
<trans-unit id="4c9a15ab7fb3dce1002ea7aea4ecada3c1ee12e9" datatype="html">
<source>Generating an ID/secret is easy!</source>
<target state="translated">Generare un ID/secret è facile!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">267</context>
</context-group>
<note priority="1" from="description">Twitch Client ID setting hint</note>
</trans-unit>
<trans-unit id="8506540da14d205ea092b4c856e242ed7f500643" datatype="html">
<source>Twitch Client Secret</source>
<target state="translated">Secret Client Twitch</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.html</context>
<context context-type="linenumber">272</context>
</context-group>
<note priority="1" from="description">Twitch Client Secret setting placeholder</note>
</trans-unit>
<trans-unit id="4604336107574138791" datatype="html">
<source>Chrome users must drag the 'Alternate URL' link to your bookmarks.</source>
<target state="translated">Gli utenti di Chrome devono trascinare il link del 'Alternate URL' nei preferiti.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="4257962986336738751" datatype="html">
<source>Successfully killed all downloads!</source>
<target state="translated">Uccisi con successo tutti i download!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">292</context>
</context-group>
</trans-unit>
<trans-unit id="1942965859829798388" datatype="html">
<source>Restarting!</source>
<target state="translated">Riavviando!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">307</context>
</context-group>
</trans-unit>
<trans-unit id="6224607866493148072" datatype="html">
<source>Failed to restart the server.</source>
<target state="translated">Riavvio del server non riuscito.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">309</context>
</context-group>
</trans-unit>
<trans-unit id="2600933489084742998" datatype="html">
<source>Failed to kill all downloads! Check logs for details.</source>
<target state="translated">Impossibile uccidere tutti i download! Guarda il registro per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">295</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">299</context>
</context-group>
</trans-unit>
<trans-unit id="1716030487077666916" datatype="html">
<source>Failed to transfer DB -- API call failed. See browser logs for details.</source>
<target state="translated">Trasferimento del DB non riuscito -- Errore chiamata API. Guarda il registro del browser per più informazioni.</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">347</context>
</context-group>
</trans-unit>
<trans-unit id="6018050954136387828" datatype="html">
<source>Connection successful!</source>
<target state="translated">Connessione avvenuta con successo!</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">357</context>
</context-group>
</trans-unit>
<trans-unit id="4021495815084152271" datatype="html">
<source>Connection failed! Error:</source>
<target state="translated">Connessione non riuscita! Errore:</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/settings/settings.component.ts</context>
<context context-type="linenumber">359</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2019",
"target": "es2020",
"resolveJsonModule": true,
"esModuleInterop": true,
"typeRoots": [