allow selecting MP4 in the GUI
This commit is contained in:
parent
19be41be1c
commit
ee0fcc3993
|
|
@ -76,7 +76,8 @@ async def add(request):
|
||||||
quality = post.get('quality')
|
quality = post.get('quality')
|
||||||
if not url or not quality:
|
if not url or not quality:
|
||||||
raise web.HTTPBadRequest()
|
raise web.HTTPBadRequest()
|
||||||
status = await dqueue.add(url, quality)
|
format = post.get('format')
|
||||||
|
status = await dqueue.add(url, quality, format)
|
||||||
return web.Response(text=serializer.encode(status))
|
return web.Response(text=serializer.encode(status))
|
||||||
|
|
||||||
@routes.post(config.URL_PREFIX + 'delete')
|
@routes.post(config.URL_PREFIX + 'delete')
|
||||||
|
|
|
||||||
29
app/ytdl.py
29
app/ytdl.py
|
|
@ -24,24 +24,28 @@ class DownloadQueueNotifier:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
class DownloadInfo:
|
class DownloadInfo:
|
||||||
def __init__(self, id, title, url, quality):
|
def __init__(self, id, title, url, quality, format):
|
||||||
self.id, self.title, self.url = id, title, url
|
self.id, self.title, self.url = id, title, url
|
||||||
self.quality = quality
|
self.quality = quality
|
||||||
|
self.format = format
|
||||||
self.status = self.msg = self.percent = self.speed = self.eta = None
|
self.status = self.msg = self.percent = self.speed = self.eta = None
|
||||||
|
|
||||||
class Download:
|
class Download:
|
||||||
manager = None
|
manager = None
|
||||||
|
|
||||||
def __init__(self, download_dir, output_template, quality, ytdl_opts, info):
|
def __init__(self, download_dir, output_template, quality, format, ytdl_opts, info):
|
||||||
self.download_dir = download_dir
|
self.download_dir = download_dir
|
||||||
self.output_template = output_template
|
self.output_template = output_template
|
||||||
|
vfmt, afmt = '', ''
|
||||||
|
if format == 'mp4':
|
||||||
|
vfmt, afmt = '[ext=mp4]', '[ext=m4a]'
|
||||||
if quality == 'best':
|
if quality == 'best':
|
||||||
self.format = 'bestvideo+bestaudio/best[ext=mp4]/best'
|
self.format = f'bestvideo{vfmt}+bestaudio{afmt}/best{vfmt}'
|
||||||
elif quality in ('1440p', '1080p', '720p', '480p'):
|
elif quality in ('1440p', '1080p', '720p', '480p'):
|
||||||
res = quality[:-1]
|
res = quality[:-1]
|
||||||
self.format = f'bestvideo[height<={res}]+bestaudio/best[height<={res}][ext=mp4]/best[height<={res}]'
|
self.format = f'bestvideo[height<={res}]{vfmt}+bestaudio{afmt}/best[height<={res}]{vfmt}'
|
||||||
elif quality == 'audio':
|
elif quality == 'audio':
|
||||||
self.format = 'bestaudio'
|
self.format = f'bestaudio{afmt}'
|
||||||
elif quality.startswith('custom:'):
|
elif quality.startswith('custom:'):
|
||||||
self.format = quality[7:]
|
self.format = quality[7:]
|
||||||
else:
|
else:
|
||||||
|
|
@ -74,7 +78,6 @@ class Download:
|
||||||
#'skip_download': True,
|
#'skip_download': True,
|
||||||
'outtmpl': os.path.join(self.download_dir, self.output_template),
|
'outtmpl': os.path.join(self.download_dir, self.output_template),
|
||||||
'format': self.format,
|
'format': self.format,
|
||||||
'merge_output_format': 'mp4',
|
|
||||||
'cachedir': False,
|
'cachedir': False,
|
||||||
'socket_timeout': 30,
|
'socket_timeout': 30,
|
||||||
'progress_hooks': [put_status],
|
'progress_hooks': [put_status],
|
||||||
|
|
@ -148,30 +151,30 @@ class DownloadQueue:
|
||||||
'extract_flat': True,
|
'extract_flat': True,
|
||||||
}).extract_info(url, download=False)
|
}).extract_info(url, download=False)
|
||||||
|
|
||||||
async def __add_entry(self, entry, quality, already):
|
async def __add_entry(self, entry, quality, format, already):
|
||||||
etype = entry.get('_type') or 'video'
|
etype = entry.get('_type') or 'video'
|
||||||
if etype == 'playlist':
|
if etype == 'playlist':
|
||||||
entries = entry['entries']
|
entries = entry['entries']
|
||||||
log.info(f'playlist detected with {len(entries)} entries')
|
log.info(f'playlist detected with {len(entries)} entries')
|
||||||
results = []
|
results = []
|
||||||
for etr in entries:
|
for etr in entries:
|
||||||
results.append(await self.__add_entry(etr, quality, already))
|
results.append(await self.__add_entry(etr, quality, format, already))
|
||||||
if any(res['status'] == 'error' for res in results):
|
if any(res['status'] == 'error' for res in results):
|
||||||
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
return {'status': 'error', 'msg': ', '.join(res['msg'] for res in results if res['status'] == 'error' and 'msg' in res)}
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
elif etype == 'video' or etype.startswith('url') and 'id' in entry:
|
elif etype == 'video' or etype.startswith('url') and 'id' in entry:
|
||||||
if entry['id'] not in self.queue:
|
if entry['id'] not in self.queue:
|
||||||
dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality)
|
dl = DownloadInfo(entry['id'], entry['title'], entry.get('webpage_url') or entry['url'], quality, format)
|
||||||
dldirectory = self.config.DOWNLOAD_DIR if quality != 'audio' else self.config.AUDIO_DOWNLOAD_DIR
|
dldirectory = self.config.DOWNLOAD_DIR if quality != 'audio' else self.config.AUDIO_DOWNLOAD_DIR
|
||||||
self.queue[entry['id']] = Download(dldirectory, self.config.OUTPUT_TEMPLATE, quality, self.config.YTDL_OPTIONS, dl)
|
self.queue[entry['id']] = Download(dldirectory, self.config.OUTPUT_TEMPLATE, quality, format, self.config.YTDL_OPTIONS, dl)
|
||||||
self.event.set()
|
self.event.set()
|
||||||
await self.notifier.added(dl)
|
await self.notifier.added(dl)
|
||||||
return {'status': 'ok'}
|
return {'status': 'ok'}
|
||||||
elif etype == 'url':
|
elif etype == 'url':
|
||||||
return await self.add(entry['url'], quality, already)
|
return await self.add(entry['url'], quality, format, already)
|
||||||
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
|
||||||
|
|
||||||
async def add(self, url, quality, already=None):
|
async def add(self, url, quality, format, already=None):
|
||||||
log.info(f'adding {url}')
|
log.info(f'adding {url}')
|
||||||
already = set() if already is None else already
|
already = set() if already is None else already
|
||||||
if url in already:
|
if url in already:
|
||||||
|
|
@ -183,7 +186,7 @@ class DownloadQueue:
|
||||||
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
entry = await asyncio.get_running_loop().run_in_executor(None, self.__extract_info, url)
|
||||||
except yt_dlp.utils.YoutubeDLError as exc:
|
except yt_dlp.utils.YoutubeDLError as exc:
|
||||||
return {'status': 'error', 'msg': str(exc)}
|
return {'status': 'error', 'msg': str(exc)}
|
||||||
return await self.__add_entry(entry, quality, already)
|
return await self.__add_entry(entry, quality, format, already)
|
||||||
|
|
||||||
async def cancel(self, ids):
|
async def cancel(self, ids):
|
||||||
for id in ids:
|
for id in ids:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^10.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^10.0.0",
|
||||||
"bootstrap": "^4.5.0",
|
"bootstrap": "^4.5.0",
|
||||||
|
"ngx-cookie-service": "^12.0.3",
|
||||||
"ngx-socket-io": "^4.1.0",
|
"ngx-socket-io": "^4.1.0",
|
||||||
"rxjs": "~6.6.0",
|
"rxjs": "~6.6.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
|
@ -9692,6 +9693,18 @@
|
||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/ngx-cookie-service": {
|
||||||
|
"version": "12.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-12.0.3.tgz",
|
||||||
|
"integrity": "sha512-F5xJBTrrreI2DERGOrO6U+L7s031HxTER+3Z4gDCwxdTl4AXmtWddMxxQVw7KflOLZ4InYEs6FjQsXmKU4HsJg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@angular/common": "^12.0.0",
|
||||||
|
"@angular/core": "^12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ngx-socket-io": {
|
"node_modules/ngx-socket-io": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ngx-socket-io/-/ngx-socket-io-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ngx-socket-io/-/ngx-socket-io-4.1.0.tgz",
|
||||||
|
|
@ -24937,6 +24950,14 @@
|
||||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"ngx-cookie-service": {
|
||||||
|
"version": "12.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ngx-cookie-service/-/ngx-cookie-service-12.0.3.tgz",
|
||||||
|
"integrity": "sha512-F5xJBTrrreI2DERGOrO6U+L7s031HxTER+3Z4gDCwxdTl4AXmtWddMxxQVw7KflOLZ4InYEs6FjQsXmKU4HsJg==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"ngx-socket-io": {
|
"ngx-socket-io": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ngx-socket-io/-/ngx-socket-io-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ngx-socket-io/-/ngx-socket-io-4.1.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^10.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^10.0.0",
|
||||||
"bootstrap": "^4.5.0",
|
"bootstrap": "^4.5.0",
|
||||||
|
"ngx-cookie-service": "^12.0.3",
|
||||||
"ngx-socket-io": "^4.1.0",
|
"ngx-socket-io": "^4.1.0",
|
||||||
"rxjs": "~6.6.0",
|
"rxjs": "~6.6.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,41 @@
|
||||||
|
|
||||||
<main role="main" class="container">
|
<main role="main" class="container">
|
||||||
<form #f="ngForm">
|
<form #f="ngForm">
|
||||||
<div class="input-group add-url-box">
|
<div class="container add-url-box">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col add-url-component input-group">
|
||||||
<input type="text" class="form-control" placeholder="Video or playlist URL" name="addUrl" [(ngModel)]="addUrl" [disabled]="addInProgress || downloads.loading">
|
<input type="text" class="form-control" placeholder="Video or playlist URL" name="addUrl" [(ngModel)]="addUrl" [disabled]="addInProgress || downloads.loading">
|
||||||
<div class="input-group-append">
|
</div>
|
||||||
<select class="custom-select" name="quality" [(ngModel)]="quality" [disabled]="addInProgress || downloads.loading">
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5 add-url-component">
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<span class="input-group-text">Video quality</span>
|
||||||
|
</div>
|
||||||
|
<select class="custom-select" name="quality" [(ngModel)]="quality" (change)="qualityChanged()" [disabled]="addInProgress || downloads.loading">
|
||||||
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
|
<option *ngFor="let q of qualities" [ngValue]="q.id">{{ q.text }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 add-url-component">
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<span class="input-group-text">Format</span>
|
||||||
|
</div>
|
||||||
|
<select class="custom-select" name="format" [(ngModel)]="format" (change)="formatChanged()" [disabled]="addInProgress || downloads.loading">
|
||||||
|
<option *ngFor="let f of formats" [ngValue]="f.id">{{ f.text }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 add-url-component">
|
||||||
<button class="btn btn-primary add-url" type="submit" (click)="addDownload()" [disabled]="addInProgress || downloads.loading">
|
<button class="btn btn-primary add-url" type="submit" (click)="addDownload()" [disabled]="addInProgress || downloads.loading">
|
||||||
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner" *ngIf="addInProgress"></span>
|
||||||
{{ addInProgress ? "Adding..." : "Add" }}
|
{{ addInProgress ? "Adding..." : "Add" }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
<div *ngIf="downloads.loading" class="alert alert-info" role="alert">
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@
|
||||||
max-width: 720px
|
max-width: 720px
|
||||||
margin: 4rem auto
|
margin: 4rem auto
|
||||||
|
|
||||||
|
.add-url-component
|
||||||
|
margin: 0.5rem auto
|
||||||
|
|
||||||
button.add-url
|
button.add-url
|
||||||
min-width: 7rem
|
zmin-width: 7rem
|
||||||
|
width: 100%
|
||||||
|
|
||||||
$metube-section-color-bg: rgba(0,0,0,.07)
|
$metube-section-color-bg: rgba(0,0,0,.07)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
|
||||||
import { faTrashAlt, faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
import { faTrashAlt, faCheckCircle, faTimesCircle } from '@fortawesome/free-regular-svg-icons';
|
||||||
import { faRedoAlt } from '@fortawesome/free-solid-svg-icons';
|
import { faRedoAlt } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
|
||||||
import { DownloadsService, Status } from './downloads.service';
|
import { DownloadsService, Status } from './downloads.service';
|
||||||
import { MasterCheckboxComponent } from './master-checkbox.component';
|
import { MasterCheckboxComponent } from './master-checkbox.component';
|
||||||
|
|
@ -20,7 +21,12 @@ export class AppComponent implements AfterViewInit {
|
||||||
{id: "480p", text: "480p"},
|
{id: "480p", text: "480p"},
|
||||||
{id: "audio", text: "Audio only"}
|
{id: "audio", text: "Audio only"}
|
||||||
];
|
];
|
||||||
quality: string = "best";
|
quality: string;
|
||||||
|
formats: Array<Object> = [
|
||||||
|
{id: "any", text: "Any"},
|
||||||
|
{id: "mp4", text: "MP4"}
|
||||||
|
];
|
||||||
|
format: string;
|
||||||
addInProgress = false;
|
addInProgress = false;
|
||||||
|
|
||||||
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
@ViewChild('queueMasterCheckbox') queueMasterCheckbox: MasterCheckboxComponent;
|
||||||
|
|
@ -35,7 +41,9 @@ export class AppComponent implements AfterViewInit {
|
||||||
faTimesCircle = faTimesCircle;
|
faTimesCircle = faTimesCircle;
|
||||||
faRedoAlt = faRedoAlt;
|
faRedoAlt = faRedoAlt;
|
||||||
|
|
||||||
constructor(public downloads: DownloadsService) {
|
constructor(public downloads: DownloadsService, private cookieService: CookieService) {
|
||||||
|
this.quality = cookieService.get('metube_quality') || 'best';
|
||||||
|
this.format = cookieService.get('metube_format') || 'any';
|
||||||
}
|
}
|
||||||
|
|
||||||
ngAfterViewInit() {
|
ngAfterViewInit() {
|
||||||
|
|
@ -62,6 +70,14 @@ export class AppComponent implements AfterViewInit {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qualityChanged() {
|
||||||
|
this.cookieService.set('metube_quality', this.quality, { expires: 3650 });
|
||||||
|
}
|
||||||
|
|
||||||
|
formatChanged() {
|
||||||
|
this.cookieService.set('metube_format', this.format, { expires: 3650 });
|
||||||
|
}
|
||||||
|
|
||||||
queueSelectionChanged(checked: number) {
|
queueSelectionChanged(checked: number) {
|
||||||
this.queueDelSelected.nativeElement.disabled = checked == 0;
|
this.queueDelSelected.nativeElement.disabled = checked == 0;
|
||||||
}
|
}
|
||||||
|
|
@ -70,12 +86,13 @@ export class AppComponent implements AfterViewInit {
|
||||||
this.doneDelSelected.nativeElement.disabled = checked == 0;
|
this.doneDelSelected.nativeElement.disabled = checked == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
addDownload(url?: string, quality?: string) {
|
addDownload(url?: string, quality?: string, format?: string) {
|
||||||
url = url ?? this.addUrl
|
url = url ?? this.addUrl
|
||||||
quality = quality ?? this.quality
|
quality = quality ?? this.quality
|
||||||
|
format = format ?? this.format
|
||||||
|
|
||||||
this.addInProgress = true;
|
this.addInProgress = true;
|
||||||
this.downloads.add(url, quality).subscribe((status: Status) => {
|
this.downloads.add(url, quality, format).subscribe((status: Status) => {
|
||||||
if (status.status === 'error') {
|
if (status.status === 'error') {
|
||||||
alert(`Error adding URL: ${status.msg}`);
|
alert(`Error adding URL: ${status.msg}`);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -85,8 +102,8 @@ export class AppComponent implements AfterViewInit {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
retryDownload(key: string, quality:string){
|
retryDownload(key: string, quality: string, format: string) {
|
||||||
this.addDownload(key, quality);
|
this.addDownload(key, quality, format);
|
||||||
this.downloads.delById('done', [key]).subscribe();
|
this.downloads.delById('done', [key]).subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { FormsModule } from '@angular/forms';
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
|
||||||
|
import { CookieService } from 'ngx-cookie-service';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { EtaPipe, SpeedPipe } from './downloads.pipe';
|
import { EtaPipe, SpeedPipe } from './downloads.pipe';
|
||||||
|
|
@ -25,7 +26,7 @@ import { MeTubeSocket } from './metube-socket';
|
||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
FontAwesomeModule
|
FontAwesomeModule
|
||||||
],
|
],
|
||||||
providers: [MeTubeSocket],
|
providers: [CookieService, MeTubeSocket],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
})
|
})
|
||||||
export class AppModule { }
|
export class AppModule { }
|
||||||
|
|
|
||||||
|
|
@ -80,8 +80,8 @@ export class DownloadsService {
|
||||||
return of({status: 'error', msg: msg})
|
return of({status: 'error', msg: msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
public add(url: string, quality: string) {
|
public add(url: string, quality: string, format: string) {
|
||||||
return this.http.post<Status>('add', {url: url, quality: quality}).pipe(
|
return this.http.post<Status>('add', {url: url, quality: quality, format: format}).pipe(
|
||||||
catchError(this.handleHTTPError)
|
catchError(this.handleHTTPError)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue