Compare commits

...

63 Commits

Author SHA1 Message Date
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
80 changed files with 3113 additions and 2147 deletions

View File

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

View File

@@ -47,20 +47,21 @@ RUN npm config set strict-ssl false && \
# Final image
FROM base
RUN npm install -g pm2 && \
apt update && \
apt install -y --no-install-recommends gosu python3-minimal python-is-python3 atomicparsley && \
RUN apt update && \
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

@@ -101,7 +101,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;
@@ -500,12 +499,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();
@@ -912,11 +912,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 +929,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 +1272,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 +1282,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 +1326,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 +1355,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

@@ -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',
@@ -301,4 +305,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

@@ -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);
@@ -1111,15 +1103,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
@@ -229,7 +228,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 +242,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 +250,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);
@@ -373,15 +376,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;
@@ -404,8 +415,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) {
@@ -414,7 +423,7 @@ exports.generateArgs = async (url, type, options, user_uid = null, simulated = f
if (customQualityConfiguration) {
qualityPath = ['-f', customQualityConfiguration, '--merge-output-format', 'mp4'];
} else if (selectedHeight && selectedHeight !== '' && !is_audio) {
qualityPath = ['-f', `'(mp4)[height=${selectedHeight}'`];
qualityPath = ['-f', `'(mp4)[height=${selectedHeight}]`];
} else if (is_audio) {
qualityPath = ['--audio-quality', maxBitrate ? maxBitrate : '0']
}
@@ -496,7 +505,6 @@ 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');
}
@@ -506,7 +514,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 +573,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 +635,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,16 +36,16 @@
"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",
"passport-ldapauth": "^3.0.1",
"passport-local": "^1.0.0",
"pm2": "^5.2.0",
"progress": "^2.0.3",
"ps-node": "^0.1.6",
"read-last-lines": "^1.7.2",

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)
@@ -322,7 +316,7 @@ function generateOptionsForSubscriptionDownload(sub, user_uid) {
selectedHeight: 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);
}
@@ -421,6 +410,8 @@ async function generateArgsForSubscription(sub, user_uid, redownload = false, de
downloadConfig.push('--no-clean-infojson');
}
downloadConfig = utils.filterArgs(downloadConfig, ['--write-comments']);
return downloadConfig;
}
@@ -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

@@ -173,8 +173,8 @@ function getExpectedFileSize(input_info_jsons) {
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 (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);
}
});
});
@@ -218,8 +218,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 +239,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) {
@@ -418,7 +443,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);
}
@@ -456,6 +481,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 +500,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 +564,8 @@ module.exports = {
fixVideoMetadataPerms: fixVideoMetadataPerms,
deleteJSONFile: deleteJSONFile,
removeIDFromArchive: removeIDFromArchive,
writeToBlacklist: writeToBlacklist,
deleteFileFromArchive: deleteFileFromArchive,
getDownloadedFilesByType: getDownloadedFilesByType,
createContainerZipFile: createContainerZipFile,
durationStringToNumber: durationStringToNumber,
@@ -515,6 +581,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

@@ -17,7 +17,7 @@ services:
- ./users:/app/users
ports:
- "8998:17442"
image: tzahi12345/youtubedl-material:nightly
image: tzahi12345/youtubedl-material:latest
ytdl-mongo-db:
image: mongo
ports:

76
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "youtube-dl-material",
"version": "4.2.0",
"version": "4.3.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -3675,7 +3675,7 @@
"buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
"integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
"dev": true
},
"buffer-from": {
@@ -3785,12 +3785,6 @@
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
"integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==",
"dev": true
},
"normalize-url": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
"integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==",
"dev": true
}
}
},
@@ -3945,7 +3939,7 @@
"clone-response": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz",
"integrity": "sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=",
"integrity": "sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==",
"dev": true,
"requires": {
"mimic-response": "^1.0.0"
@@ -4666,7 +4660,7 @@
"decompress-response": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
"integrity": "sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=",
"integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==",
"dev": true,
"requires": {
"mimic-response": "^1.0.0"
@@ -4956,7 +4950,7 @@
"duplexer3": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz",
"integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=",
"integrity": "sha512-CEj8FwwNA4cVH2uFCoHUrmojhYh1vmCdOaneKJXwkeY1i9jnlslVo9dx+hQ5Hl9GnH/Bwy/IjxAyOePyPKYnzA==",
"dev": true
},
"ecc-jsbn": {
@@ -4976,20 +4970,20 @@
"dev": true
},
"electron": {
"version": "13.6.6",
"resolved": "https://registry.npmjs.org/electron/-/electron-13.6.6.tgz",
"integrity": "sha512-TP2Bl1nTxaH1yRmlYiF7imzvKE/NASE0cl6wOYA3AaP/UrBGc4L3NwJfn5Z55o+1t4TH8vCRxENufESyb32HhA==",
"version": "19.0.6",
"resolved": "https://registry.npmjs.org/electron/-/electron-19.0.6.tgz",
"integrity": "sha512-S9Yud32nKhB0iWC0lGl2JXz4FQnCiLCnP5Vehm1/CqyeICcQGmgQaZl2HYpCJ2pesKIsYL9nsgmku/10cxm/gg==",
"dev": true,
"requires": {
"@electron/get": "^1.0.1",
"@types/node": "^14.6.2",
"@electron/get": "^1.14.1",
"@types/node": "^16.11.26",
"extract-zip": "^1.0.3"
},
"dependencies": {
"@types/node": {
"version": "14.18.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.12.tgz",
"integrity": "sha512-q4jlIR71hUpWTnGhXWcakgkZeHa3CCjcQcnuzU8M891BAWA2jHiziiWEPEkdS5pFsz7H9HJiy8BrK7tBRNrY7A==",
"version": "16.11.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.41.tgz",
"integrity": "sha512-mqoYK2TnVjdkGk8qXAVGc/x9nSaTpSrFaGFm43BUH3IdoBV0nta6hYaGmdOvIMlbHJbUEVen3gvwpwovAZKNdQ==",
"dev": true
}
}
@@ -5956,7 +5950,7 @@
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"dev": true
}
}
@@ -6024,7 +6018,7 @@
"fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"requires": {
"pend": "~1.2.0"
@@ -6412,9 +6406,9 @@
},
"dependencies": {
"semver": {
"version": "7.3.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
"version": "7.3.7",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.7.tgz",
"integrity": "sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==",
"dev": true,
"optional": true,
"requires": {
@@ -6442,9 +6436,9 @@
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
},
"globalthis": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.2.tgz",
"integrity": "sha512-ZQnSFO1la8P7auIOQECnm0sSuoMeaSq0EEdXMBFF2QJO4uNcwbyhSgG3MruWNbFTqCLmxVwGOl7LZ9kASvHdeQ==",
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz",
"integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==",
"dev": true,
"optional": true,
"requires": {
@@ -7455,7 +7449,7 @@
"json-buffer": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz",
"integrity": "sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=",
"integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==",
"dev": true
},
"json-parse-better-errors": {
@@ -7511,7 +7505,7 @@
"jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"requires": {
"graceful-fs": "^4.1.6"
@@ -8561,6 +8555,12 @@
"integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=",
"dev": true
},
"normalize-url": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz",
"integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==",
"dev": true
},
"npm-bundled": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz",
@@ -8584,7 +8584,7 @@
"pify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz",
"integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=",
"integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==",
"dev": true,
"optional": true
}
@@ -9370,7 +9370,7 @@
"pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true
},
"performance-now": {
@@ -9817,7 +9817,7 @@
"prepend-http": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz",
"integrity": "sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=",
"integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==",
"dev": true
},
"pretty-bytes": {
@@ -9865,7 +9865,7 @@
"proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha1-IS1b/hMYMGpCD2QCuOJv85ZHqEk=",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"dev": true,
"optional": true
},
@@ -10576,7 +10576,7 @@
"responselike": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz",
"integrity": "sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=",
"integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==",
"dev": true,
"requires": {
"lowercase-keys": "^1.0.0"
@@ -10826,7 +10826,7 @@
"semver-compare": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz",
"integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=",
"integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==",
"dev": true,
"optional": true
},
@@ -11937,7 +11937,7 @@
"typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"dev": true
},
"typescript": {
@@ -12027,7 +12027,7 @@
"url-parse-lax": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz",
"integrity": "sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=",
"integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==",
"dev": true,
"requires": {
"prepend-http": "^2.0.0"
@@ -12576,7 +12576,7 @@
"yauzl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"requires": {
"buffer-crc32": "~0.2.3",

View File

@@ -1,6 +1,6 @@
{
"name": "youtube-dl-material",
"version": "4.2.0",
"version": "4.3.0",
"license": "MIT",
"scripts": {
"ng": "ng",
@@ -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

@@ -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;
@@ -76,7 +79,7 @@ export class CustomPlaylistsComponent implements OnInit {
}
}
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 +89,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

@@ -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>

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

@@ -2,6 +2,7 @@ import { Component, OnInit, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { FormControl } from '@angular/forms';
import { PostsService } from 'app/posts.services';
import { Playlist } from 'api-types';
@Component({
selector: 'app-create-playlist',
@@ -9,7 +10,7 @@ import { PostsService } from 'app/posts.services';
styleUrls: ['./create-playlist.component.scss']
})
export class CreatePlaylistComponent implements OnInit {
// really "createPlaylistDialogComponent"
// really "createAndModifyPlaylistDialogComponent"
filesToSelectFrom = null;
type = null;
@@ -17,64 +18,86 @@ export class CreatePlaylistComponent implements OnInit {
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

@@ -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

@@ -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,11 +67,8 @@ 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;
@@ -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,7 +302,7 @@ 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
@@ -325,7 +313,7 @@ export class MainComponent implements OnInit {
if (is_playlist) {
this.downloadPlaylist(container['uid']);
} else {
this.downloadFileFromServer(container, type);
this.downloadFileFromServer(container as DatabaseFile, type);
}
this.reloadRecentVideos();
} else {
@@ -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');

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

@@ -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';
@@ -355,8 +359,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 +448,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 +475,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 +597,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