Improved archive viewer

Added archive importing
pull/809/head
Isaac Abadi 3 years ago
parent 77a858effa
commit c1fd8047ea

@ -578,18 +578,18 @@ paths:
description: If the archive dir is not found, 404 is sent as a response
security:
- Auth query parameter: []
/api/deleteArchiveItem:
/api/deleteArchiveItems:
post:
tags:
- archive
summary: Delete item from archive
description: 'Deletes an item from the archive'
operationId: post-api-deleteArchiveItem
operationId: post-api-deleteArchiveItems
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteArchiveItemRequest'
$ref: '#/components/schemas/DeleteArchiveItemsRequest'
responses:
'200':
description: OK
@ -608,7 +608,7 @@ paths:
operationId: post-api-importArchive
requestBody:
content:
multipart/form-data:
application/json:
schema:
$ref: '#/components/schemas/ImportArchiveRequest'
responses:
@ -2178,21 +2178,15 @@ components:
type: number
uid:
type: string
DeleteArchiveItemRequest:
DeleteArchiveItemsRequest:
type: object
required:
- extractor
- id
- type
- archives
properties:
extractor:
type: string
id:
type: string
type:
$ref: '#/components/schemas/FileType'
sub_id:
type: string
archives:
type: array
items:
$ref: '#/components/schemas/Archive'
ImportArchiveRequest:
type: object
required:
@ -2201,7 +2195,6 @@ components:
properties:
archive:
type: string
format: binary
type:
$ref: '#/components/schemas/FileType'
sub_id:

@ -1511,6 +1511,22 @@ app.post('/api/downloadFileFromServer', optionalJwt, async (req, res) => {
});
});
app.post('/api/getArchives', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const sub_id = req.body.sub_id;
const filter_obj = {user_uid: uuid, sub_id: sub_id};
const type = req.body.type;
// we do this for file types because if type is null, that means get files of all types
if (type) filter_obj['type'] = type;
const archives = await db_api.getRecords('archives', filter_obj);
res.send({
archives: archives
});
});
app.post('/api/downloadArchive', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const sub_id = req.body.sub_id;
@ -1528,6 +1544,36 @@ app.post('/api/downloadArchive', optionalJwt, async (req, res) => {
});
app.post('/api/importArchive', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const archive = req.body.archive;
const sub_id = req.body.sub_id;
const type = req.body.type;
const archive_text = Buffer.from(archive.split(',')[1], 'base64').toString();
const imported_count = await archive_api.importArchiveFile(archive_text, type, uuid, sub_id);
res.send({
success: !!imported_count,
imported_count: imported_count
});
});
app.post('/api/deleteArchiveItems', optionalJwt, async (req, res) => {
const uuid = req.isAuthenticated() ? req.user.uid : null;
const archives = req.body.archives;
let success = true;
for (const archive of archives) {
success &= await archive_api.removeFromArchive(archive['extractor'], archive['id'], archive['type'], uuid, archive['sub_id']);
}
res.send({
success: success
});
});
var upload_multer = multer({ dest: __dirname + '/appdata/' });
app.post('/api/uploadCookies', upload_multer.single('cookies'), async (req, res) => {
const new_path = path.join(__dirname, 'appdata', 'cookies.txt');

@ -45,7 +45,7 @@ exports.importArchiveFile = async (archive_text, type, user_uid = null, sub_id =
// we can't do a bulk write because we need to avoid duplicate archive items existing in db
const archive_item = createArchiveItem(extractor, id, type, null, user_uid, sub_id);
await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id});
await db_api.insertRecordIntoTable('archives', archive_item, {extractor: extractor, id: id, type: type, sub_id: sub_id, user_uid: user_uid});
archive_import_count++;
}
return archive_import_count;

@ -27,7 +27,7 @@ export type { DatabaseFile } from './models/DatabaseFile';
export { DBBackup } from './models/DBBackup';
export type { DBInfoResponse } from './models/DBInfoResponse';
export type { DeleteAllFilesResponse } from './models/DeleteAllFilesResponse';
export type { DeleteArchiveItemRequest } from './models/DeleteArchiveItemRequest';
export type { DeleteArchiveItemsRequest } from './models/DeleteArchiveItemsRequest';
export type { DeleteCategoryRequest } from './models/DeleteCategoryRequest';
export type { DeleteMp3Mp4Request } from './models/DeleteMp3Mp4Request';
export type { DeleteNotificationRequest } from './models/DeleteNotificationRequest';

@ -1,12 +0,0 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { FileType } from './FileType';
export type DeleteArchiveItemRequest = {
extractor: string;
id: string;
type: FileType;
sub_id?: string;
};

@ -0,0 +1,9 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */
import type { Archive } from './Archive';
export type DeleteArchiveItemsRequest = {
archives: Array<Archive>;
};

@ -5,7 +5,7 @@
import type { FileType } from './FileType';
export type ImportArchiveRequest = {
archive: Blob;
archive: string;
type: FileType;
sub_id?: string;
};
};

@ -21,6 +21,10 @@
<mat-icon>person</mat-icon>
<span i18n="Profile menu label">Profile</span>
</button>
<button *ngIf="postsService.config && postsService.config.Downloader.use_youtubedl_archive" class="top-menu-button" (click)="openArchivesDialog()" mat-menu-item>
<mat-icon>topic</mat-icon>
<span i18n="Archives menu label">Archives</span>
</button>
<button class="top-menu-button" (click)="themeMenuItemClicked($event)" *ngIf="allowThemeChange" mat-menu-item>
<mat-icon>{{(postsService.theme.key === 'default') ? 'brightness_5' : 'brightness_2'}}</mat-icon>
<span i18n="Dark mode toggle label">Dark</span>

@ -21,6 +21,7 @@ import { AboutDialogComponent } from './dialogs/about-dialog/about-dialog.compon
import { UserProfileDialogComponent } from './dialogs/user-profile-dialog/user-profile-dialog.component';
import { SetDefaultAdminDialogComponent } from './dialogs/set-default-admin-dialog/set-default-admin-dialog.component';
import { NotificationsComponent } from './components/notifications/notifications.component';
import { ArchiveViewerComponent } from './components/archive-viewer/archive-viewer.component';
@Component({
selector: 'app-root',
@ -207,6 +208,12 @@ export class AppComponent implements OnInit, AfterViewInit {
});
}
openArchivesDialog(): void {
this.dialog.open(ArchiveViewerComponent, {
width: '85vw'
});
}
notificationCountUpdate(new_count: number): void {
this.notification_count = new_count;
}

@ -1,20 +1,42 @@
<!-- (selectionChange)="sidePanelModeChanged($event.value)" -->
<mat-form-field class="filter">
<mat-icon matPrefix>search</mat-icon>
<mat-label i18n="Filter">Filter</mat-label>
<input matInput (keyup)="applyFilter($event)" #input>
</mat-form-field>
<div [hidden]="!(archives && archives.length > 0)">
<div style="overflow: hidden;" class="mat-elevation-z8">
<mat-table style="overflow: hidden" matSort [dataSource]="dataSource">
<div class="mat-elevation-z8">
<mat-table matSort [dataSource]="dataSource">
<!-- Select Column -->
<!-- Checkbox Column -->
<ng-container matColumnDef="select">
<mat-header-cell *matHeaderCellDef>
<mat-checkbox (change)="$event ? toggleAllRows() : null"
[checked]="selection.hasValue() && isAllSelected()"
[indeterminate]="selection.hasValue() && !isAllSelected()">
</mat-checkbox>
</mat-header-cell>
<mat-cell *matCellDef="let row">
<mat-checkbox (click)="$event.stopPropagation()"
(change)="$event ? selection.toggle(row) : null"
[checked]="selection.isSelected(row)">
</mat-checkbox>
<mat-icon class="audio-video-icon">{{(row.type === 'audio') ? 'audiotrack' : 'movie'}}</mat-icon>
</mat-cell>
</ng-container>
<!-- Date Column -->
<ng-container matColumnDef="timestamp">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Date">Date</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element"> {{element.timestamp | date: 'short'}} </mat-cell>
<mat-cell *matCellDef="let element"> {{element.timestamp*1000 | date: 'short'}} </mat-cell>
</ng-container>
<!-- Title Column -->
<ng-container matColumnDef="title">
<mat-header-cell *matHeaderCellDef mat-sort-header> <ng-container i18n="Title">Title</ng-container> </mat-header-cell>
<mat-cell *matCellDef="let element">
<span class="one-line" [matTooltip]="element.title ? element.title : null">
<span class="max-two-lines" [matTooltip]="element.title ? element.title : null">
{{element.title}}
</span>
</mat-cell>
@ -40,7 +62,7 @@
</mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="displayedColumns"></mat-header-row>
<mat-header-row *matHeaderRowDef="displayedColumns; sticky: true"></mat-header-row>
<mat-row *matRowDef="let row; columns: displayedColumns;"></mat-row>
</mat-table>
</div>
@ -48,4 +70,71 @@
<div *ngIf="(!archives || archives.length === 0)">
<h4 style="text-align: center; margin-top: 10px;" i18n="Archives empty">Archives empty</h4>
</div>
</div>
<div style="margin: 10px 10px 10px 0px;">
<button [disabled]="selection.selected.length === 0" color="warn" style="margin: 10px;" mat-stroked-button i18n="Delete selected" (click)="openDeleteSelectedArchivesDialog()">Delete selected</button>
<span style="float: right">
<mat-form-field style="width: 150px;">
<mat-label i18n="Subscription">Subscription</mat-label>
<mat-select [ngModel]="sub_id" (ngModelChange)="subFilterSelectionChanged($event)">
<mat-option [value]="'none'" i18n="None">None</mat-option>
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id">{{sub.name}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field style="width: 100px; margin-left: 10px;">
<mat-label i18n="File type">File type</mat-label>
<mat-select [ngModel]="type" (ngModelChange)="typeFilterSelectionChanged($event)" [disabled]="sub_id !== 'none'">
<mat-option [value]="'both'" i18n="Both">Both</mat-option>
<mat-option [value]="'video'" i18n="Video">Video</mat-option>
<mat-option [value]="'audio'" i18n="Audio">Audio</mat-option>
</mat-select>
</mat-form-field>
</span>
</div>
<div>
<ngx-file-drop [multiple]="false" accept=".txt" dropZoneLabel="Drop file here" (onFileDrop)="dropped($event)">
<ng-template class="file-drop" ngx-file-drop-content-tmp let-openFileSelector="openFileSelector">
<div style="text-align: center">
<div>
<ng-container i18n="Drag and Drop">Drag and Drop</ng-container>
</div>
<div style="margin-top: 6px;">
<button mat-stroked-button (click)="openFileSelector()">Browse Files</button>
</div>
</div>
</ng-template>
</ngx-file-drop>
</div>
<div style="margin-top: 10px; color: white">
<table class="table">
<tbody class="upload-name-style">
<tr *ngFor="let item of files; let i=index">
<td style="vertical-align: middle; border-top: unset">
<strong>{{ item.relativePath }}</strong>
</td>
<td style="border-top: unset">
<div style="float: right">
<mat-form-field style="width: 150px;">
<mat-label i18n="Subscription">Subscription</mat-label>
<mat-select [ngModel]="upload_sub_id" (ngModelChange)="subUploadFilterSelectionChanged($event)">
<mat-option [value]="'none'" i18n="None">None</mat-option>
<mat-option *ngFor="let sub of postsService.subscriptions" [value]="sub.id">{{sub.name}}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field style="width: 100px; margin-left: 10px">
<mat-label i18n="File type">File type</mat-label>
<mat-select [(ngModel)]="upload_type" [value]="upload_type" [disabled]="upload_sub_id !== 'none'">
<mat-option [value]="'video'" i18n="Video">Video</mat-option>
<mat-option [value]="'audio'" i18n="Audio">Audio</mat-option>
</mat-select>
</mat-form-field>
<button style="margin-left: 10px" [disabled]="uploading_archive || uploaded_archive" (click)="importArchive()" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading_archive" class="spinner" [diameter]="38"></mat-spinner></button>
</div>
</td>
</tr>
</tbody>
</table>
</div>

@ -0,0 +1,32 @@
.filter {
width: 100%;
}
.spinner {
bottom: 1px;
left: 0.5px;
position: absolute;
}
.mat-mdc-table {
width: 100%;
max-height: 60vh;
overflow: auto;
}
.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;
}
::ng-deep .ngx-file-drop__content {
width: 100%;
top: -12px;
position: relative;
}

@ -1,8 +1,13 @@
import { SelectionModel } from '@angular/cdk/collections';
import { Component, ViewChild } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { FileType } from 'api-types';
import { Archive } from 'api-types/models/Archive';
import { ConfirmDialogComponent } from 'app/dialogs/confirm-dialog/confirm-dialog.component';
import { PostsService } from 'app/posts.services';
import { NgxFileDropEntry } from 'ngx-file-drop';
@Component({
selector: 'app-archive-viewer',
@ -10,23 +15,89 @@ import { PostsService } from 'app/posts.services';
styleUrls: ['./archive-viewer.component.scss']
})
export class ArchiveViewerComponent {
archives = null;
displayedColumns: string[] = ['timestamp', 'title', 'id', 'extractor'];
// table
displayedColumns: string[] = ['select', 'timestamp', 'title', 'id', 'extractor'];
dataSource = null;
selection = new SelectionModel<Archive>(true, []);
// general
archives = null;
archives_retrieved = false;
sub_id = 'none';
upload_sub_id = 'none';
type: FileType | 'both' = 'both';
upload_type: FileType = FileType.VIDEO;
// importing
uploading_archive = false;
uploaded_archive = false;
files = [];
typeSelectOptions = {
video: {
key: 'video',
label: $localize`Video`
},
audio: {
key: 'audio',
label: $localize`Audio`
}
};
@ViewChild(MatSort) sort: MatSort;
constructor(private postsService: PostsService) {
constructor(public postsService: PostsService, private dialog: MatDialog) {
}
filterSelectionChanged(value: string): void {
this.getArchives(value);
ngOnInit() {
this.getArchives();
}
getArchives(sub_id: string = null): void {
this.postsService.getArchives(sub_id).subscribe(res => {
applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.data.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
toggleAllRows() {
if (this.isAllSelected()) {
this.selection.clear();
return;
}
this.selection.select(...this.dataSource.data);
}
typeFilterSelectionChanged(value): void {
this.type = value;
this.getArchives();
}
subFilterSelectionChanged(value): void {
this.sub_id = value;
if (this.sub_id !== 'none') {
this.type = this.postsService.getSubscriptionByID(this.sub_id)['type'];
}
this.getArchives();
}
subUploadFilterSelectionChanged(value): void {
this.upload_sub_id = value;
if (this.upload_sub_id !== 'none') {
this.upload_type = this.postsService.getSubscriptionByID(this.upload_sub_id)['type'];
}
}
getArchives(): void {
this.postsService.getArchives(this.type === 'both' ? null : this.type, this.sub_id === 'none' ? null : this.sub_id).subscribe(res => {
if (res['archives'] !== null
&& res['archives'] !== undefined
&& JSON.stringify(this.archives) !== JSON.stringify(res['archives'])) {
@ -38,4 +109,78 @@ export class ArchiveViewerComponent {
}
});
}
importArchive(): void {
this.uploading_archive = true;
for (const droppedFile of this.files) {
// Is it a file?
if (droppedFile.fileEntry.isFile) {
const fileEntry = droppedFile.fileEntry as FileSystemFileEntry;
fileEntry.file(async (file: File) => {
const archive_base64 = await blobToBase64(file);
this.postsService.importArchive(archive_base64 as string, this.upload_type, this.upload_sub_id === 'none' ? null : this.upload_sub_id).subscribe(res => {
this.uploading_archive = false;
if (res['success']) {
this.uploaded_archive = true;
this.postsService.openSnackBar($localize`Archive successfully imported!`);
}
this.getArchives();
}, err => {
console.error(err);
this.uploading_archive = false;
});
});
}
}
}
openDeleteSelectedArchivesDialog(): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
dialogTitle: $localize`Delete archives`,
dialogText: $localize`Would you like to delete ${this.selection.selected.length}:selected archives amount: archive(s)?`,
submitText: $localize`Delete`,
warnSubmitColor: true
}
});
dialogRef.afterClosed().subscribe(confirmed => {
if (confirmed) {
this.deleteSelectedArchives();
}
});
}
deleteSelectedArchives(): void {
for (const archive of this.selection.selected) {
this.archives = this.archives.filter((_archive: Archive) => !(archive['extractor'] === _archive['extractor'] && archive['id'] !== _archive['id']));
}
this.postsService.deleteArchiveItems(this.selection.selected).subscribe(res => {
if (res['success']) {
this.postsService.openSnackBar($localize`Successfully deleted archive items!`);
} else {
this.postsService.openSnackBar($localize`Failed to delete archive items!`);
}
this.getArchives();
});
this.selection.clear();
}
public dropped(files: NgxFileDropEntry[]) {
this.files = files;
this.uploading_archive = false;
this.uploaded_archive = false;
}
originalOrder = (): number => {
return 0;
}
}
function blobToBase64(blob: Blob) {
return new Promise((resolve, _) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.readAsDataURL(blob);
});
}

@ -27,7 +27,7 @@
<strong>{{ item.relativePath }}</strong>
</td>
<td>
<button [disabled]="uploading || uploaded" (click)="uploadFile()" style="float: right" matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading" class="spinner" [diameter]="38"></mat-spinner></button>
<button [disabled]="uploading || uploaded" (click)="uploadFile()" style="float: right" matTooltip="Upload" i18n-matTooltip="Upload" mat-mini-fab><mat-icon>publish</mat-icon><mat-spinner *ngIf="uploading" class="spinner" [diameter]="38"></mat-spinner></button>
</td>
</tr>
</tbody>

@ -39,6 +39,7 @@ export class CookiesUploaderDialogComponent implements OnInit {
this.postsService.openSnackBar($localize`Cookies successfully uploaded!`);
}
}, err => {
console.error(err);
this.uploading = false;
});
});

@ -107,9 +107,12 @@ import {
GetNotificationsResponse,
UpdateTaskOptionsRequest,
User,
DeleteArchiveItemRequest,
DeleteArchiveItemsRequest,
GetArchivesRequest,
GetArchivesResponse
GetArchivesResponse,
ImportArchiveRequest,
Archive,
Subscription
} from '../api-types';
import { isoLangs } from './settings/locales_list';
import { Title } from '@angular/platform-browser';
@ -159,7 +162,7 @@ export class PostsService implements CanActivate {
// global vars
config = null;
subscriptions = null;
subscriptions: Subscription[] = null;
categories: Category[] = null;
sidenav = null;
locale = isoLangs['en'];
@ -265,7 +268,7 @@ export class PostsService implements CanActivate {
this.theme = this.THEMES_CONFIG[theme];
}
getSubscriptionByID(sub_id) {
getSubscriptionByID(sub_id: string): Subscription {
for (let i = 0; i < this.subscriptions.length; i++) {
if (this.subscriptions[i]['id'] === sub_id) {
return this.subscriptions[i];
@ -446,22 +449,19 @@ export class PostsService implements CanActivate {
return this.http.post(this.path + 'downloadArchive', body, {responseType: 'blob', params: this.httpOptions.params});
}
getArchives(sub_id: string) {
const body: GetArchivesRequest = {sub_id: sub_id};
getArchives(type: FileType = null, sub_id: string = null) {
const body: GetArchivesRequest = {type: type, sub_id: sub_id};
return this.http.post<GetArchivesResponse>(this.path + 'getArchives', body, this.httpOptions);
}
importArchive(archiveFile: File, type: FileType, sub_id: string = null) {
const formData = new FormData()
formData.append('archive', archiveFile, 'archive.txt');
formData.append('type', type);
formData.append('sub_id', sub_id);
return this.http.post<SuccessObject>(this.path + 'importArchive', formData, this.httpOptions);
importArchive(archive_base64: string, type: FileType, sub_id: string = null) {
const body: ImportArchiveRequest = {archive: archive_base64, type: type, sub_id: sub_id}
return this.http.post<SuccessObject>(this.path + 'importArchive', body, this.httpOptions);
}
deleteArchiveItem(extractor: string, id: string, type: FileType, sub_id: string = null) {
const body: DeleteArchiveItemRequest = {extractor: extractor, id: id, type: type, sub_id: sub_id};
return this.http.post<SuccessObject>(this.path + 'deleteArchiveItem', body, this.httpOptions);
deleteArchiveItems(archives: Archive[]) {
const body: DeleteArchiveItemsRequest = {archives: archives};
return this.http.post<SuccessObject>(this.path + 'deleteArchiveItems', body, this.httpOptions);
}
getFileFormats(url) {

Loading…
Cancel
Save