diff options
-rw-r--r-- | .vscode/launch.json | 2 | ||||
-rw-r--r-- | app/main.py | 24 | ||||
-rw-r--r-- | app/ytdl.py | 49 | ||||
-rw-r--r-- | ui/src/app/app.component.html | 74 | ||||
-rw-r--r-- | ui/src/app/app.component.sass | 34 | ||||
-rw-r--r-- | ui/src/app/app.component.ts | 72 | ||||
-rw-r--r-- | ui/src/app/app.module.ts | 5 | ||||
-rw-r--r-- | ui/src/app/downloads.service.ts | 62 | ||||
-rw-r--r-- | ui/src/app/master-checkbox.component.ts | 53 |
9 files changed, 266 insertions, 109 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json index 3d4107f..4b2e858 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "version": "0.2.0",
"configurations": [
{
- "name": "Python: Youtube-dealer",
+ "name": "Python: MeTube",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/app/main.py",
diff --git a/app/main.py b/app/main.py index 9d5fb2e..4c48aeb 100644 --- a/app/main.py +++ b/app/main.py @@ -43,8 +43,14 @@ class Notifier(DownloadQueueNotifier): async def updated(self, dl):
await sio.emit('updated', serializer.encode(dl))
- async def deleted(self, id):
- await sio.emit('deleted', serializer.encode(id))
+ async def completed(self, dl):
+ await sio.emit('completed', serializer.encode(dl))
+
+ async def canceled(self, id):
+ await sio.emit('canceled', serializer.encode(id))
+
+ async def cleared(self, id):
+ await sio.emit('cleared', serializer.encode(id))
dqueue = DownloadQueue(config, Notifier())
@@ -61,21 +67,15 @@ async def add(request): async def delete(request):
post = await request.json()
ids = post.get('ids')
- if not ids:
+ where = post.get('where')
+ if not ids or where not in ['queue', 'done']:
raise web.HTTPBadRequest()
- status = await dqueue.delete(ids)
+ status = await (dqueue.cancel(ids) if where == 'queue' else dqueue.clear(ids))
return web.Response(text=serializer.encode(status))
-@routes.get('/queue')
-def queue(request):
- ret = dqueue.get()
- return web.Response(text=serializer.encode(ret))
-
@sio.event
async def connect(sid, environ):
- ret = dqueue.get()
- #ret = [["XeNTV0kyHaU", {"id": "XeNTV0kyHaU", "title": "2020 Mercedes ACTROS \u2013 Digital Side Mirrors, Electronic Stability, Auto Braking, Side Guard Safety", "url": "XeNTV0kyHaU", "status": None, "percentage": 0}], ["76wlIusQe9U", {"id": "76wlIusQe9U", "title": "Toyota HIACE 2020 \u2013 Toyota Wagon / Toyota HIACE 2019 and 2020", "url": "76wlIusQe9U", "status": None, "percentage": 0}], ["n_d5LPwflMM", {"id": "n_d5LPwflMM", "title": "2020 Toyota GRANVIA \u2013 Toyota 8 Seater LUXURY VAN / ALL-NEW Toyota GRANVIA 2020", "url": "n_d5LPwflMM", "status": None, "percentage": 0}], ["Dv4ZFhCpF1M", {"id": "Dv4ZFhCpF1M", "title": "Toyota SIENNA 2019 vs Honda ODYSSEY 2019", "url": "Dv4ZFhCpF1M", "status": None, "percentage": 0}], ["GjHJFb3Mgqw", {"id": "GjHJFb3Mgqw", "title": "How It's Made (Buses) \u2013 How Buses are made? SETRA BUS Production", "url": "GjHJFb3Mgqw", "status": None, "percentage": 0}]]
- await sio.emit('queue', serializer.encode(ret), to=sid)
+ await sio.emit('all', serializer.encode(dqueue.get()), to=sid)
@routes.get('/')
def index(request):
diff --git a/app/ytdl.py b/app/ytdl.py index d1512ef..9fe4840 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -78,7 +78,13 @@ class DownloadQueueNotifier: async def updated(self, dl):
raise NotImplementedError
- async def deleted(self, id):
+ async def completed(self, dl):
+ raise NotImplementedError
+
+ async def canceled(self, id):
+ raise NotImplementedError
+
+ async def cleared(self, id):
raise NotImplementedError
class DownloadQueue:
@@ -86,6 +92,7 @@ class DownloadQueue: self.config = config
self.notifier = notifier
self.queue = OrderedDict()
+ self.done = OrderedDict()
self.event = asyncio.Event()
asyncio.ensure_future(self.__download())
@@ -116,20 +123,28 @@ class DownloadQueue: self.event.set()
return {'status': 'ok'}
- async def delete(self, ids):
+ async def cancel(self, ids):
for id in ids:
if id not in self.queue:
+ log.warn(f'requested cancel for non-existent download {id}')
+ continue
+ self.queue[id].cancel()
+ del self.queue[id]
+ await self.notifier.canceled(id)
+ return {'status': 'ok'}
+
+ async def clear(self, ids):
+ for id in ids:
+ if id not in self.done:
log.warn(f'requested delete for non-existent download {id}')
continue
- if self.queue[id].info.status is not None:
- self.queue[id].cancel()
- else:
- del self.queue[id]
- await self.notifier.deleted(id)
+ del self.done[id]
+ await self.notifier.cleared(id)
return {'status': 'ok'}
def get(self):
- return list((k, v.info) for k, v in self.queue.items())
+ return(list((k, v.info) for k, v in self.queue.items()),
+ list((k, v.info) for k, v in self.done.items()))
async def __download(self):
while True:
@@ -144,11 +159,15 @@ class DownloadQueue: async def updated_cb(): await self.notifier.updated(entry.info)
asyncio.ensure_future(entry.update_status(updated_cb))
await start_aw
- if entry.info.status != 'finished' and entry.tmpfilename and os.path.isfile(entry.tmpfilename):
- try:
- os.remove(entry.tmpfilename)
- except:
- pass
+ if entry.info.status != 'finished':
+ if entry.tmpfilename and os.path.isfile(entry.tmpfilename):
+ try:
+ os.remove(entry.tmpfilename)
+ except:
+ pass
+ entry.info.status = 'error'
entry.close()
- del self.queue[id]
- await self.notifier.deleted(id)
+ if id in self.queue:
+ del self.queue[id]
+ self.done[id] = entry
+ await self.notifier.completed(entry.info)
diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index 3796b72..b05aee7 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -1,10 +1,9 @@ <nav class="navbar navbar-expand-md navbar-dark bg-dark"> <a class="navbar-brand" href="#">MeTube</a> + <!-- <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarsDefault" aria-controls="navbarsDefault" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> - - <!-- <div class="collapse navbar-collapse" id="navbarsDefault"> <ul class="navbar-nav mr-auto"> <li class="nav-item active"> @@ -29,47 +28,66 @@ </form> <p *ngIf="downloads.loading">Loading...</p> - <div *ngIf="!downloads.loading"> - <div *ngIf="downloads.empty()" class="jumbotron jumbotron-fluid px-4"> - <div class="container text-center"> - <h1 class="display-4">Welcome to MeTube!</h1> - <p class="lead">Please add some downloads via the URL box above.</p> - </div> - </div> - <table *ngIf="!downloads.empty()" class="table"> - <thead> + <div class="metube-section-header">Downloading</div> + <table class="table"> + <thead> <tr> - <th scope="col" style="width: 1rem; vertical-align: middle;"> - <div class="custom-control custom-checkbox"> - <input type="checkbox" class="custom-control-input" id="select-all" #masterCheckbox [(ngModel)]="masterSelected" (change)="checkUncheckAll()"> - <label class="custom-control-label" for="select-all"></label> - </div> + <th scope="col" style="width: 1rem;"> + <app-master-checkbox #queueMasterCheckbox [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)"></app-master-checkbox> </th> <th scope="col"> - <button type="button" class="btn btn-link px-0" disabled #delSelected (click)="delSelectedDownloads()"><fa-icon [icon]="faTrashAlt"></fa-icon> Clear selected</button> + <button type="button" class="btn btn-link px-0 mr-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt"></fa-icon> Cancel selected</button> </th> <th scope="col" style="width: 14rem;"></th> <th scope="col" style="width: 8rem;">Speed</th> <th scope="col" style="width: 7rem;">ETA</th> <th scope="col" style="width: 2rem;"></th> </tr> - </thead> - <tbody> - <tr *ngFor="let download of downloads.downloads | keyvalue: asIsOrder" [class.disabled]='download.value.deleting'> + </thead> + <tbody> + <tr *ngFor="let download of downloads.queue | keyvalue: asIsOrder" [class.disabled]='download.value.deleting'> <td> - <div class="custom-control custom-checkbox"> - <input type="checkbox" class="custom-control-input" id="select-{{download.key}}" [(ngModel)]="download.value.checked" (change)="selectionChanged()"> - <label class="custom-control-label" for="select-{{download.key}}"></label> - </div> + <app-slave-checkbox [id]="download.key" [master]="queueMasterCheckbox" [checkable]="download.value"></app-slave-checkbox> </td> <td>{{ download.value.title }}</td> <td><ngb-progressbar height="1.5rem" [showValue]="download.value.status != 'preparing'" [striped]="download.value.status == 'preparing'" [animated]="download.value.status == 'preparing'" type="success" [value]="download.value.status == 'preparing' ? 100 : download.value.percent | number:'1.0-0'"></ngb-progressbar></td> <td>{{ download.value.speed | speed }}</td> <td>{{ download.value.eta | eta }}</td> - <td><button type="button" class="btn btn-link" (click)="delDownload(download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td> + <td><button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td> </tr> - </tbody> - </table> - </div> + </tbody> + </table> + + <div class="metube-section-header">Completed</div> + <table class="table"> + <thead> + <tr> + <th scope="col" style="width: 1rem;"> + <app-master-checkbox #doneMasterCheckbox [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)"></app-master-checkbox> + </th> + <th scope="col"> + <button type="button" class="btn btn-link px-0 mr-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt"></fa-icon> Clear selected</button> + <button type="button" class="btn btn-link px-0 mr-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon> Clear completed</button> + <button type="button" class="btn btn-link px-0 mr-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon> Clear failed</button> + </th> + <th scope="col" style="width: 2rem;"></th> + </tr> + </thead> + <tbody> + <tr *ngFor="let download of downloads.done | keyvalue: asIsOrder" [class.disabled]='download.value.deleting'> + <td> + <app-slave-checkbox [id]="download.key" [master]="doneMasterCheckbox" [checkable]="download.value"></app-slave-checkbox> + </td> + <td> + <div style="display: inline-block; width: 1.3rem;"> + <fa-icon *ngIf="download.value.status == 'finished'" [icon]="faCheckCircle" style="color: green;"></fa-icon> + <fa-icon *ngIf="download.value.status == 'error'" [icon]="faTimesCircle" style="color: red;"></fa-icon> + </div> + {{ download.value.title }} + </td> + <td><button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt"></fa-icon></button></td> + </tr> + </tbody> + </table> </main><!-- /.container --> diff --git a/ui/src/app/app.component.sass b/ui/src/app/app.component.sass index 847fc30..71b07f8 100644 --- a/ui/src/app/app.component.sass +++ b/ui/src/app/app.component.sass @@ -1,15 +1,37 @@ .add-url-box
- padding: 5rem 0
max-width: 720px
- margin: auto
+ margin: 4rem auto
-.rounded-box
- border: 1px solid rgba(0,0,0,.125)
- border-radius: .25rem
+$metube-section-color-bg: rgba(0,0,0,.07)
+
+.metube-section-header
+ font-size: 1.8rem
+ font-weight: 300
+ position: relative
+ background: $metube-section-color-bg
+ padding: 0.5rem 0
+ margin-top: 3.5rem
+
+.metube-section-header:before
+ content: ""
+ position: absolute
+ top: 0
+ bottom: 0
+ left: -9999px
+ right: 0
+ border-left: 9999px solid $metube-section-color-bg
+ box-shadow: 9999px 0 0 $metube-section-color-bg
+
+button:hover
+ text-decoration: none
th
border-top: 0
- border-bottom: 3px solid #dee2e6 !important
+ border-bottom-width: 3px !important
+ vertical-align: middle !important
+
+td
+ vertical-align: middle
.disabled
opacity: 0.5
diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts index 53d3a57..e0961ce 100644 --- a/ui/src/app/app.component.ts +++ b/ui/src/app/app.component.ts @@ -1,23 +1,48 @@ -import { Component, ViewChild, ElementRef } from '@angular/core'; -import { faTrashAlt } from '@fortawesome/free-regular-svg-icons'; +import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; +import { faTrashAlt, faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons'; import { DownloadsService, Status } from './downloads.service'; +import { MasterCheckboxComponent } from './master-checkbox.component'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.sass'] }) -export class AppComponent { +export class AppComponent implements AfterViewInit { addUrl: string; addInProgress = false; + + @ViewChild('queueMasterCheckbox', {static: false}) queueMasterCheckbox: MasterCheckboxComponent; + @ViewChild('queueDelSelected', {static: false}) queueDelSelected: ElementRef; + @ViewChild('doneMasterCheckbox', {static: false}) doneMasterCheckbox: MasterCheckboxComponent; + @ViewChild('doneDelSelected', {static: false}) doneDelSelected: ElementRef; + @ViewChild('doneClearCompleted', {static: false}) doneClearCompleted: ElementRef; + @ViewChild('doneClearFailed', {static: false}) doneClearFailed: ElementRef; + faTrashAlt = faTrashAlt; - masterSelected: boolean; - @ViewChild('masterCheckbox', {static: false}) masterCheckbox: ElementRef; - @ViewChild('delSelected', {static: false}) delSelected: ElementRef; + faCheckCircle = faCheckCircle; + faTimesCircle = faTimesCircle; constructor(public downloads: DownloadsService) { - this.downloads.dlChanges.subscribe(() => this.selectionChanged()); + } + + ngAfterViewInit() { + this.downloads.queueChanged.subscribe(() => { + this.queueMasterCheckbox.selectionChanged(); + }); + this.downloads.doneChanged.subscribe(() => { + this.doneMasterCheckbox.selectionChanged(); + let completed: number = 0, failed: number = 0; + this.downloads.done.forEach(dl => { + if (dl.status === 'finished') + completed++; + else if (dl.status === 'error') + failed++; + }); + this.doneClearCompleted.nativeElement.disabled = completed === 0; + this.doneClearFailed.nativeElement.disabled = failed === 0; + }); } // workaround to allow fetching of Map values in the order they were inserted @@ -26,19 +51,12 @@ export class AppComponent { return 1; } - checkUncheckAll() { - this.downloads.downloads.forEach(dl => dl.checked = this.masterSelected); - this.selectionChanged(); + queueSelectionChanged(checked: number) { + this.queueDelSelected.nativeElement.disabled = checked == 0; } - selectionChanged() { - if (!this.masterCheckbox) - return; - let checked: number = 0; - this.downloads.downloads.forEach(dl => { if(dl.checked) checked++ }); - this.masterSelected = checked > 0 && checked == this.downloads.downloads.size; - this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.downloads.downloads.size; - this.delSelected.nativeElement.disabled = checked == 0; + doneSelectionChanged(checked: number) { + this.doneDelSelected.nativeElement.disabled = checked == 0; } addDownload() { @@ -53,13 +71,19 @@ export class AppComponent { }); } - delDownload(id: string) { - this.downloads.del([id]).subscribe(); + delDownload(where: string, id: string) { + this.downloads.delById(where, [id]).subscribe(); + } + + delSelectedDownloads(where: string) { + this.downloads.delByFilter(where, dl => dl.checked).subscribe(); + } + + clearCompletedDownloads() { + this.downloads.delByFilter('done', dl => dl.status === 'finished').subscribe(); } - delSelectedDownloads() { - let ids: string[] = []; - this.downloads.downloads.forEach(dl => { if(dl.checked) ids.push(dl.id) }); - this.downloads.del(ids).subscribe(); + clearFailedDownloads() { + this.downloads.delByFilter('done', dl => dl.status === 'error').subscribe(); } } diff --git a/ui/src/app/app.module.ts b/ui/src/app/app.module.ts index a169f14..126de30 100644 --- a/ui/src/app/app.module.ts +++ b/ui/src/app/app.module.ts @@ -8,6 +8,7 @@ import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { AppComponent } from './app.component'; import { EtaPipe, SpeedPipe } from './downloads.pipe'; +import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkbox.component'; const config: SocketIoConfig = { url: '', options: {} }; @@ -15,7 +16,9 @@ const config: SocketIoConfig = { url: '', options: {} }; declarations: [ AppComponent, EtaPipe, - SpeedPipe + SpeedPipe, + MasterCheckboxComponent, + SlaveCheckboxComponent ], imports: [ BrowserModule, diff --git a/ui/src/app/downloads.service.ts b/ui/src/app/downloads.service.ts index cae16f3..6e19a7a 100644 --- a/ui/src/app/downloads.service.ts +++ b/ui/src/app/downloads.service.ts @@ -26,39 +26,51 @@ interface Download { }) export class DownloadsService { loading = true; - downloads = new Map<string, Download>(); - dlChanges = new Subject(); + queue = new Map<string, Download>(); + done = new Map<string, Download>(); + queueChanged = new Subject(); + doneChanged = new Subject(); constructor(private http: HttpClient, private socket: Socket) { - socket.fromEvent('queue').subscribe((strdata: string) => { + socket.fromEvent('all').subscribe((strdata: string) => { this.loading = false; - this.downloads.clear(); - let data: [[string, Download]] = JSON.parse(strdata); - data.forEach(entry => this.downloads.set(...entry)); - this.dlChanges.next(); + let data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata); + this.queue.clear(); + data[0].forEach(entry => this.queue.set(...entry)); + this.done.clear(); + data[1].forEach(entry => this.done.set(...entry)); + this.queueChanged.next(); + this.doneChanged.next(); }); socket.fromEvent('added').subscribe((strdata: string) => { let data: Download = JSON.parse(strdata); - this.downloads.set(data.id, data); - this.dlChanges.next(); + this.queue.set(data.id, data); + this.queueChanged.next(); }); socket.fromEvent('updated').subscribe((strdata: string) => { let data: Download = JSON.parse(strdata); - let dl: Download = this.downloads.get(data.id); + let dl: Download = this.queue.get(data.id); data.checked = dl.checked; data.deleting = dl.deleting; - this.downloads.set(data.id, data); - this.dlChanges.next(); + this.queue.set(data.id, data); }); - socket.fromEvent('deleted').subscribe((strdata: string) => { + socket.fromEvent('completed').subscribe((strdata: string) => { + let data: Download = JSON.parse(strdata); + this.queue.delete(data.id); + this.done.set(data.id, data); + this.queueChanged.next(); + this.doneChanged.next(); + }); + socket.fromEvent('canceled').subscribe((strdata: string) => { let data: string = JSON.parse(strdata); - this.downloads.delete(data); - this.dlChanges.next(); + this.queue.delete(data); + this.queueChanged.next(); + }); + socket.fromEvent('cleared').subscribe((strdata: string) => { + let data: string = JSON.parse(strdata); + this.done.delete(data); + this.doneChanged.next(); }); - } - - empty() { - return this.downloads.size == 0; } handleHTTPError(error: HttpErrorResponse) { @@ -72,8 +84,14 @@ export class DownloadsService { ); } - public del(ids: string[]) { - ids.forEach(id => this.downloads.get(id).deleting = true); - return this.http.post('delete', {ids: ids}); + public delById(where: string, ids: string[]) { + ids.forEach(id => this[where].get(id).deleting = true); + return this.http.post('delete', {where: where, ids: ids}); + } + + public delByFilter(where: string, filter: (dl: Download) => boolean) { + let ids: string[] = []; + this[where].forEach((dl: Download) => { if (filter(dl)) ids.push(dl.id) }); + return this.delById(where, ids); } } diff --git a/ui/src/app/master-checkbox.component.ts b/ui/src/app/master-checkbox.component.ts new file mode 100644 index 0000000..683ea71 --- /dev/null +++ b/ui/src/app/master-checkbox.component.ts @@ -0,0 +1,53 @@ +import { Component, Input, ViewChild, ElementRef, Output, EventEmitter } from '@angular/core'; + +interface Checkable { + checked: boolean; +} + +@Component({ + selector: 'app-master-checkbox', + template: ` + <div class="custom-control custom-checkbox"> + <input type="checkbox" class="custom-control-input" id="{{id}}-select-all" #masterCheckbox [(ngModel)]="selected" (change)="clicked()"> + <label class="custom-control-label" for="{{id}}-select-all"></label> + </div> +` +}) +export class MasterCheckboxComponent { + @Input() id: string; + @Input() list: Map<String, Checkable>; + @Output() changed = new EventEmitter<number>(); + + @ViewChild('masterCheckbox', {static: false}) masterCheckbox: ElementRef; + selected: boolean; + + clicked() { + this.list.forEach(item => item.checked = this.selected); + this.selectionChanged(); + } + + selectionChanged() { + if (!this.masterCheckbox) + return; + let checked: number = 0; + this.list.forEach(item => { if(item.checked) checked++ }); + this.selected = checked > 0 && checked == this.list.size; + this.masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list.size; + this.changed.emit(checked); + } +} + +@Component({ + selector: 'app-slave-checkbox', + template: ` + <div class="custom-control custom-checkbox"> + <input type="checkbox" class="custom-control-input" id="{{master.id}}-{{id}}-select" [(ngModel)]="checkable.checked" (change)="master.selectionChanged()"> + <label class="custom-control-label" for="{{master.id}}-{{id}}-select"></label> + </div> +` +}) +export class SlaveCheckboxComponent { + @Input() id: string; + @Input() master: MasterCheckboxComponent; + @Input() checkable: Checkable; +} |