diff --git a/backend/app.js b/backend/app.js index 2816bdf..e3a42d0 100644 --- a/backend/app.js +++ b/backend/app.js @@ -6,9 +6,23 @@ var config = require('config'); var https = require('https'); var express = require("express"); var bodyParser = require("body-parser"); +var archiver = require('archiver'); +const low = require('lowdb') +var URL = require('url').URL; +const shortid = require('shortid') + var app = express(); -var URL = require('url').URL; +const FileSync = require('lowdb/adapters/FileSync') +const adapter = new FileSync('db.json'); +const db = low(adapter) + +// Set some defaults +db.defaults({ playlists: { + audio: [], + video: [] +}}).write(); + // check if debug mode let debugMode = process.env.YTDL_MODE === 'debug'; @@ -180,6 +194,44 @@ function getVideoFormatID(name) } } +async function createPlaylistZipFile(fileNames, type, outputName) { + return new Promise(async resolve => { + let zipFolderPath = path.join(__dirname, (type === 'audio') ? audioFolderPath : videoFolderPath); + // let name = fileNames[0].split(' ')[0] + fileNames[1].split(' ')[0]; + let ext = (type === 'audio') ? '.mp3' : '.mp4'; + + let output = fs.createWriteStream(path.join(zipFolderPath, outputName + '.zip')); + + var archive = archiver('zip', { + gzip: true, + zlib: { level: 9 } // Sets the compression level. + }); + + archive.on('error', function(err) { + console.log(err); + throw err; + }); + + // pipe archive data to the output file + archive.pipe(output); + + for (let i = 0; i < fileNames.length; i++) { + let fileName = fileNames[i]; + archive.file(zipFolderPath + fileName + ext, {name: fileName + ext}) + } + + await archive.finalize(); + + // wait a tiny bit for the zip to reload in fs + setTimeout(function() { + resolve(path.join(zipFolderPath,outputName + '.zip')); + }, 100); + + }); + + +} + function deleteAudioFile(name) { return new Promise(resolve => { // TODO: split descriptors into audio and video descriptors, as deleting an audio file will close all video file streams @@ -491,6 +543,7 @@ app.post('/fileStatusMp4', function(req, res) { // gets all download mp3s app.post('/getMp3s', function(req, res) { var mp3s = []; + var playlists = db.get('playlists.audio').value(); var fullpath = audioFolderPath; var files = fs.readdirSync(audioFolderPath); @@ -519,7 +572,8 @@ app.post('/getMp3s', function(req, res) { } res.send({ - mp3s: mp3s + mp3s: mp3s, + playlists: playlists }); res.end("yes"); }); @@ -527,6 +581,7 @@ app.post('/getMp3s', function(req, res) { // gets all download mp4s app.post('/getMp4s', function(req, res) { var mp4s = []; + var playlists = db.get('playlists.video').value(); var fullpath = videoFolderPath; var files = fs.readdirSync(videoFolderPath); @@ -555,11 +610,56 @@ app.post('/getMp4s', function(req, res) { } res.send({ - mp4s: mp4s + mp4s: mp4s, + playlists: playlists }); res.end("yes"); }); +app.post('/createPlaylist', async (req, res) => { + let playlistName = req.body.playlistName; + let fileNames = req.body.fileNames; + let type = req.body.type; + let thumbnailURL = req.body.thumbnailURL; + + let new_playlist = { + 'name': playlistName, + fileNames: fileNames, + id: shortid.generate(), + thumbnailURL: thumbnailURL + }; + + db.get(`playlists.${type}`) + .push(new_playlist) + .write(); + + res.send({ + new_playlist: new_playlist, + success: !!new_playlist // always going to be true + }) +}); + +app.post('/deletePlaylist', async (req, res) => { + let playlistID = req.body.playlistID; + let type = req.body.type; + + let success = null; + try { + // removes playlist from playlists + db.get(`playlists.${type}`) + .remove({id: playlistID}) + .write(); + + success = true; + } catch(e) { + success = false; + } + + res.send({ + success: success + }) +}); + // deletes mp3 file app.post('/deleteMp3', async (req, res) => { var name = req.body.name; @@ -600,15 +700,20 @@ app.post('/deleteMp4', async (req, res) => { } }); -app.post('/downloadFile', function(req, res) { - let fileName = req.body.fileName; +app.post('/downloadFile', async (req, res) => { + let fileNames = req.body.fileNames; let is_playlist = req.body.is_playlist; let type = req.body.type; + let outputName = req.body.outputName; let file = null; - if (type === 'audio') { - file = __dirname + '/' + 'audio/' + fileName + '.mp3'; - } else if (type === 'video') { - file = __dirname + '/' + 'video/' + fileName + '.mp4'; + if (!is_playlist) { + if (type === 'audio') { + file = __dirname + '/' + 'audio/' + fileNames + '.mp3'; + } else if (type === 'video') { + file = __dirname + '/' + 'video/' + fileNames + '.mp4'; + } + } else { + file = await createPlaylistZipFile(fileNames, type, outputName); } res.sendFile(file); diff --git a/backend/package.json b/backend/package.json index 544b449..7a54114 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,10 +17,13 @@ }, "homepage": "https://github.com/Tzahi12345/hda-backend#readme", "dependencies": { + "archiver": "^3.1.1", "async": "^3.1.0", "config": "^3.2.3", "exe": "^1.0.2", "express": "^4.17.1", + "lowdb": "^1.0.0", + "shortid": "^2.2.15", "youtube-dl": "^2.3.0" } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index d5aff3a..7245241 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -5,7 +5,8 @@ import {MatNativeDateModule, MatRadioModule, MatInputModule, MatButtonModule, Ma MatProgressBarModule, MatExpansionModule, MatGridList, MatProgressSpinnerModule, - MatButtonToggleModule} from '@angular/material'; + MatButtonToggleModule, + MatDialogModule} from '@angular/material'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import { AppComponent } from './app.component'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; @@ -22,13 +23,15 @@ import {VgCoreModule} from 'videogular2/compiled/core'; import {VgControlsModule} from 'videogular2/compiled/controls'; import {VgOverlayPlayModule} from 'videogular2/compiled/overlay-play'; import {VgBufferingModule} from 'videogular2/compiled/buffering'; +import { InputDialogComponent } from './input-dialog/input-dialog.component'; @NgModule({ declarations: [ AppComponent, FileCardComponent, MainComponent, - PlayerComponent + PlayerComponent, + InputDialogComponent ], imports: [ BrowserModule, @@ -54,6 +57,7 @@ import {VgBufferingModule} from 'videogular2/compiled/buffering'; MatProgressBarModule, MatProgressSpinnerModule, MatButtonToggleModule, + MatDialogModule, VgCoreModule, VgControlsModule, VgOverlayPlayModule, @@ -61,6 +65,9 @@ import {VgBufferingModule} from 'videogular2/compiled/buffering'; RouterModule, AppRoutingModule ], + entryComponents: [ + InputDialogComponent + ], providers: [PostsService], bootstrap: [AppComponent] }) diff --git a/src/app/file-card/file-card.component.html b/src/app/file-card/file-card.component.html index ddb48e0..dcf35a0 100644 --- a/src/app/file-card/file-card.component.html +++ b/src/app/file-card/file-card.component.html @@ -1,9 +1,10 @@
- {{title}} + {{title}}
ID: {{name}} +
Count: {{count}}
diff --git a/src/app/file-card/file-card.component.ts b/src/app/file-card/file-card.component.ts index f35ea4e..742de4e 100644 --- a/src/app/file-card/file-card.component.ts +++ b/src/app/file-card/file-card.component.ts @@ -17,21 +17,30 @@ export class FileCardComponent implements OnInit { @Input() thumbnailURL: string; @Input() isAudio = true; @Output() removeFile: EventEmitter = new EventEmitter(); + @Input() isPlaylist = false; + @Input() count = null; + type; constructor(private postsService: PostsService, public snackBar: MatSnackBar, public mainComponent: MainComponent) { } ngOnInit() { + this.type = this.isAudio ? 'audio' : 'video'; } deleteFile() { - this.postsService.deleteFile(this.name, this.isAudio).subscribe(result => { - if (result === true) { - this.openSnackBar('Delete success!', 'OK.'); - this.removeFile.emit(this.name); - } else { - this.openSnackBar('Delete failed!', 'OK.'); - } - }); + if (!this.isPlaylist) { + this.postsService.deleteFile(this.name, this.isAudio).subscribe(result => { + if (result === true) { + this.openSnackBar('Delete success!', 'OK.'); + this.removeFile.emit(this.name); + } else { + this.openSnackBar('Delete failed!', 'OK.'); + } + }); + } else { + this.removeFile.emit(this.name); + } + } public openSnackBar(message: string, action: string) { diff --git a/src/app/input-dialog/input-dialog.component.css b/src/app/input-dialog/input-dialog.component.css new file mode 100644 index 0000000..3998aee --- /dev/null +++ b/src/app/input-dialog/input-dialog.component.css @@ -0,0 +1,3 @@ +.mat-spinner { + margin-left: 5%; + } \ No newline at end of file diff --git a/src/app/input-dialog/input-dialog.component.html b/src/app/input-dialog/input-dialog.component.html new file mode 100644 index 0000000..9bca104 --- /dev/null +++ b/src/app/input-dialog/input-dialog.component.html @@ -0,0 +1,16 @@ +

{{inputTitle}}

+ +
+ + + +
+
+ + + + +
+ +
+
\ No newline at end of file diff --git a/src/app/input-dialog/input-dialog.component.spec.ts b/src/app/input-dialog/input-dialog.component.spec.ts new file mode 100644 index 0000000..17691ab --- /dev/null +++ b/src/app/input-dialog/input-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InputDialogComponent } from './input-dialog.component'; + +describe('InputDialogComponent', () => { + let component: InputDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ InputDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(InputDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/input-dialog/input-dialog.component.ts b/src/app/input-dialog/input-dialog.component.ts new file mode 100644 index 0000000..9644e13 --- /dev/null +++ b/src/app/input-dialog/input-dialog.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit, Input, Inject, EventEmitter } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material'; + +@Component({ + selector: 'app-input-dialog', + templateUrl: './input-dialog.component.html', + styleUrls: ['./input-dialog.component.css'] +}) +export class InputDialogComponent implements OnInit { + + inputTitle: string; + inputPlaceholder: string; + submitText: string; + + inputText = ''; + + inputSubmitted = false; + + doneEmitter: EventEmitter = null; + onlyEmitOnDone = false; + + constructor(public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any) { } + + ngOnInit() { + this.inputTitle = this.data.inputTitle; + this.inputPlaceholder = this.data.inputPlaceholder; + this.submitText = this.data.submitText; + + // checks if emitter exists, if so don't autoclose as it should be handled by caller + if (this.data.doneEmitter) { + this.doneEmitter = this.data.doneEmitter; + this.onlyEmitOnDone = true; + } + } + + enterPressed() { + // validates input -- TODO: add custom validator + if (this.inputText) { + // only emit if emitter is passed + if (this.onlyEmitOnDone) { + this.doneEmitter.emit(this.inputText); + this.inputSubmitted = true; + } else { + this.dialogRef.close(this.inputText); + } + } + } + +} diff --git a/src/app/main/main.component.html b/src/app/main/main.component.html index 65a3a4e..d9da512 100644 --- a/src/app/main/main.component.html +++ b/src/app/main/main.component.html @@ -91,12 +91,19 @@
- + + + + + + +
@@ -110,12 +117,19 @@
- + + + + + + +
diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 7effbd6..ac491f7 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -51,6 +51,8 @@ export class MainComponent implements OnInit { mp3s: any[] = []; mp4s: any[] = []; files_cols = (window.innerWidth <= 450) ? 2 : 4; + playlists = {'audio': [], 'video': []}; + playlist_thumbnails = {}; urlForm = new FormControl('', [Validators.required]); @@ -194,7 +196,23 @@ export class MainComponent implements OnInit { getMp3s() { this.postsService.getMp3s().subscribe(result => { const mp3s = result['mp3s']; + const playlists = result['playlists']; this.mp3s = mp3s; + this.playlists.audio = playlists; + + // get thumbnail url by using first video. this is a temporary hack + for (let i = 0; i < this.playlists.audio.length; i++) { + const playlist = this.playlists.audio[i]; + let videoToExtractThumbnail = null; + for (let j = 0; j < this.mp3s.length; j++) { + if (this.mp3s[j].id === playlist.fileNames[0]) { + // found the corresponding file + videoToExtractThumbnail = this.mp3s[j]; + } + } + + this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL; + } }, error => { console.log(error); }); @@ -203,7 +221,23 @@ export class MainComponent implements OnInit { getMp4s() { this.postsService.getMp4s().subscribe(result => { const mp4s = result['mp4s']; + const playlists = result['playlists']; this.mp4s = mp4s; + this.playlists.video = playlists; + + // get thumbnail url by using first video. this is a temporary hack + for (let i = 0; i < this.playlists.video.length; i++) { + const playlist = this.playlists.video[i]; + let videoToExtractThumbnail = null; + for (let j = 0; j < this.mp4s.length; j++) { + if (this.mp4s[j].id === playlist.fileNames[0]) { + // found the corresponding file + videoToExtractThumbnail = this.mp4s[j]; + } + } + + this.playlist_thumbnails[playlist.id] = videoToExtractThumbnail.thumbnailURL; + } }, error => { console.log(error); @@ -218,6 +252,17 @@ export class MainComponent implements OnInit { } } + public goToPlaylist(playlistID, type) { + for (let i = 0; i < this.playlists[type].length; i++) { + const playlist = this.playlists[type][i]; + if (playlist.id === playlistID) { + // found the playlist, now go to it + const fileNames = playlist.fileNames; + this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID}]); + } + } + } + public removeFromMp3(name: string) { for (let i = 0; i < this.mp3s.length; i++) { if (this.mp3s[i].id === name) { @@ -226,6 +271,15 @@ export class MainComponent implements OnInit { } } + public removePlaylistMp3(playlistID, index) { + this.postsService.removePlaylist(playlistID, 'audio').subscribe(res => { + if (res['success']) { + this.playlists.audio.splice(index, 1); + } + this.getMp3s(); + }); + } + public removeFromMp4(name: string) { for (let i = 0; i < this.mp4s.length; i++) { if (this.mp4s[i].id === name) { @@ -234,6 +288,16 @@ export class MainComponent implements OnInit { } } + public removePlaylistMp4(playlistID, index) { + this.postsService.removePlaylist(playlistID, 'video').subscribe(res => { + if (res['success']) { + this.playlists.video.splice(index, 1); + } + this.getMp4s(); + }); + } + + // app initialization. ngOnInit() { this.iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window['MSStream']; diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 5dd3c24..a8bea50 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -82,13 +82,28 @@ export class PostsService { return this.http.post(this.path + 'getMp4s', {}); } - downloadFileFromServer(fileName, type) { - return this.http.post(this.path + 'downloadFile', {fileName: fileName, type: type}, {responseType: 'blob'}); + downloadFileFromServer(fileName, type, outputName = null) { + return this.http.post(this.path + 'downloadFile', {fileNames: fileName, + type: type, + is_playlist: Array.isArray(fileName), + outputName: outputName}, + {responseType: 'blob'}); } getFileInfo(fileNames, type, urlMode) { return this.http.post(this.path + 'getVideoInfos', {fileNames: fileNames, type: type, urlMode: urlMode}); } + + createPlaylist(playlistName, fileNames, type, thumbnailURL) { + return this.http.post(this.path + 'createPlaylist', {playlistName: playlistName, + fileNames: fileNames, + type: type, + thumbnailURL: thumbnailURL}); + } + + removePlaylist(playlistID, type) { + return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}); + } }