aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.vscode/launch.json2
-rw-r--r--app/main.py24
-rw-r--r--app/ytdl.py49
-rw-r--r--ui/src/app/app.component.html74
-rw-r--r--ui/src/app/app.component.sass34
-rw-r--r--ui/src/app/app.component.ts72
-rw-r--r--ui/src/app/app.module.ts5
-rw-r--r--ui/src/app/downloads.service.ts62
-rw-r--r--ui/src/app/master-checkbox.component.ts53
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>&nbsp; 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>&nbsp; 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>&nbsp; Clear selected</button>
+ <button type="button" class="btn btn-link px-0 mr-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle"></fa-icon>&nbsp; Clear completed</button>
+ <button type="button" class="btn btn-link px-0 mr-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle"></fa-icon>&nbsp; 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;
+}
bgstack15