add ytdlp-option setting in web

This commit is contained in:
xerdream 2025-07-17 13:38:33 +08:00
parent ca0aac4051
commit ae4ebacc27
9 changed files with 228 additions and 3 deletions

View file

@ -92,6 +92,20 @@ class Config:
sys.exit(1) sys.exit(1)
self.YTDL_OPTIONS.update(opts) self.YTDL_OPTIONS.update(opts)
def update_ytdl_options_file(self):
if not self.YTDL_OPTIONS_FILE:
log.info('YTDL_OPTIONS_FILE not set, writing to default location')
self.YTDL_OPTIONS_FILE = os.path.join(self.DOWNLOAD_DIR,'ytdl_options.json')
log.info('Writing YTDL_OPTIONS to %s', self.YTDL_OPTIONS_FILE)
try:
with open(config.YTDL_OPTIONS_FILE, 'w') as f:
f.write(serializer.encode(config.YTDL_OPTIONS))
except:
log.error('Failed to write YTDL_OPTIONS to %s', self.YTDL_OPTIONS_FILE)
self.YTDL_OPTIONS_FILE = ''
return False
return True
config = Config() config = Config()
class ObjectSerializer(json.JSONEncoder): class ObjectSerializer(json.JSONEncoder):
@ -242,6 +256,27 @@ def get_custom_dirs():
"audio_download_dir": audio_download_dir "audio_download_dir": audio_download_dir
} }
@routes.get(config.URL_PREFIX + 'ytdl_options')
async def ytdl_options(request):
log.info("Sending yt-dlp options")
return web.Response(text=serializer.encode(config.YTDL_OPTIONS))
@routes.post(config.URL_PREFIX + 'ytdl_options')
async def update_ytdl_options(request):
try:
post = await request.json()
assert isinstance(post, dict)
except (json.decoder.JSONDecodeError, AssertionError):
log.info("Bad request: invalid data")
raise web.HTTPBadRequest(reason="invalid data,need json")
config.YTDL_OPTIONS = post
msg = 'Options updated'
if not config.update_ytdl_options_file():
msg += ', but failed to write to file'
return web.Response(text=serializer.encode({'msg':msg,'data':config.YTDL_OPTIONS}))
@routes.get(config.URL_PREFIX) @routes.get(config.URL_PREFIX)
def index(request): def index(request):
response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html')) response = web.FileResponse(os.path.join(config.BASE_DIR, 'ui/dist/metube/browser/index.html'))

View file

@ -62,6 +62,26 @@
</li> </li>
</ul> </ul>
</div> </div>
<div class="nav-item dropdown">
<button class="btn btn-link nav-link py-2 px-0 px-sm-2 dropdown-toggle d-flex align-items-center"
id="settingsDropdown"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
data-bs-display="static">
<fa-icon [icon]="faGear"></fa-icon>
</button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
<li *ngFor="let setting of settings">
<button type="button" class="dropdown-item d-flex align-items-center" (click)="handleSettingClick(setting)">
<span class="me-2 opacity-50">
<fa-icon [icon]="setting.icon"></fa-icon>
</span>
{{ setting.displayName }}
</button>
</li>
</ul>
</div>
</div> </div>
</div> </div>
</nav> </nav>

View file

@ -1,8 +1,10 @@
import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core'; import { Component, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons'; import { faTrashAlt, faCheckCircle, faTimesCircle, IconDefinition } from '@fortawesome/free-regular-svg-icons';
import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons'; import { faRedoAlt, faSun, faMoon, faCircleHalfStroke, faCheck, faExternalLinkAlt, faDownload, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons'; import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { faGear } from "@fortawesome/free-solid-svg-icons";
import { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { map, Observable, of, distinctUntilChanged } from 'rxjs'; import { map, Observable, of, distinctUntilChanged } from 'rxjs';
@ -11,6 +13,8 @@ import { MasterCheckboxComponent } from './master-checkbox.component';
import { Formats, Format, Quality } from './formats'; import { Formats, Format, Quality } from './formats';
import { Theme, Themes } from './theme'; import { Theme, Themes } from './theme';
import {KeyValue} from "@angular/common"; import {KeyValue} from "@angular/common";
import { Setting, Settings } from './settings';
import { YtdlpOptionComponent } from './settings/ytdlp-option/ytdlp-option.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -31,6 +35,7 @@ export class AppComponent implements AfterViewInit {
playlistItemLimit: number; playlistItemLimit: number;
addInProgress = false; addInProgress = false;
themes: Theme[] = Themes; themes: Theme[] = Themes;
settings: Setting[] = Settings;
activeTheme: Theme; activeTheme: Theme;
customDirs$: Observable<string[]>; customDirs$: Observable<string[]>;
showBatchPanel: boolean = false; showBatchPanel: boolean = false;
@ -66,6 +71,7 @@ export class AppComponent implements AfterViewInit {
faRedoAlt = faRedoAlt; faRedoAlt = faRedoAlt;
faSun = faSun; faSun = faSun;
faMoon = faMoon; faMoon = faMoon;
faGear = faGear;
faCheck = faCheck; faCheck = faCheck;
faCircleHalfStroke = faCircleHalfStroke; faCircleHalfStroke = faCircleHalfStroke;
faDownload = faDownload; faDownload = faDownload;
@ -77,7 +83,7 @@ export class AppComponent implements AfterViewInit {
faClock = faClock; faClock = faClock;
faTachometerAlt = faTachometerAlt; faTachometerAlt = faTachometerAlt;
constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient) { constructor(public downloads: DownloadsService, private cookieService: CookieService, private http: HttpClient,private modalService: NgbModal) {
this.format = cookieService.get('metube_format') || 'any'; this.format = cookieService.get('metube_format') || 'any';
// Needs to be set or qualities won't automatically be set // Needs to be set or qualities won't automatically be set
this.setQualities() this.setQualities()
@ -497,9 +503,19 @@ export class AppComponent implements AfterViewInit {
this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length; this.failedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'error').length;
// Calculate total speed from downloading items // Calculate total speed from downloading items
const downloadingItems = Array.from(this.downloads.queue.values()) const downloadingItems = Array.from(this.downloads.queue.values()).filter(d => d.status === 'downloading');
.filter(d => d.status === 'downloading');
this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0); this.totalSpeed = downloadingItems.reduce((total, item) => total + (item.speed || 0), 0);
} }
openSettingsModal() {
const modalRef = this.modalService.open(YtdlpOptionComponent, {
size: 'lg',
windowClass: 'modal-responsive'
});
}
handleSettingClick(setting: Setting){
if (setting.id === 'ytdl_options') {
this.openSettingsModal();
}
}
} }

View file

@ -12,12 +12,14 @@ import { MasterCheckboxComponent, SlaveCheckboxComponent } from './master-checkb
import { MeTubeSocket } from './metube-socket'; import { MeTubeSocket } from './metube-socket';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { ServiceWorkerModule } from '@angular/service-worker'; import { ServiceWorkerModule } from '@angular/service-worker';
import { YtdlpOptionComponent } from './settings/ytdlp-option/ytdlp-option.component';
@NgModule({ declarations: [ @NgModule({ declarations: [
AppComponent, AppComponent,
EtaPipe, EtaPipe,
SpeedPipe, SpeedPipe,
FileSizePipe, FileSizePipe,
YtdlpOptionComponent,
EncodeURIComponent, EncodeURIComponent,
MasterCheckboxComponent, MasterCheckboxComponent,
SlaveCheckboxComponent SlaveCheckboxComponent

16
ui/src/app/settings.ts Normal file
View file

@ -0,0 +1,16 @@
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faWrench } from "@fortawesome/free-solid-svg-icons";
export interface Setting {
id: string;
displayName: string;
icon: IconDefinition;
}
export const Settings: Setting[] = [
{
id: 'ytdl_options',
displayName: 'yt-dlp Options',
icon: faWrench,
},
];

View file

@ -0,0 +1,33 @@
<div class="container mt-4">
<div class="row justify-content-center">
<!-- <div class="col-12 col-sm-11 col-md-10 col-lg-9 col-xl-8"> -->
<div class="p-4 bg-body rounded shadow-sm">
<h3>yt-dlp Options (JSON)</h3>
<p>Edit the yt-dlp configuration below.</p>
<form (ngSubmit)="saveOptions()" #form="ngForm">
<div class="mb-3">
<!-- <label for="jsonInput" class="form-label">yt-dlp Options (JSON)</label> -->
<textarea
id="jsonInput"
class="form-control"
rows="15"
[(ngModel)]="ytdlpOptions"
name="ytdlpOptions"
required
></textarea>
</div>
<div class="d-flex justify-content-end mt-3">
<button type="submit" class="btn btn-primary" [disabled]="isSaving">
<fa-icon [icon]="faSave" *ngIf="!isSaving"></fa-icon>
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
</div>
<div [ngClass]="{'text-success': isSuccess, 'text-danger': !isSuccess}" class="mt-2">{{ msg }}</div>
</form>
</div>
<!-- </div> -->
</div>
</div>

View file

@ -0,0 +1,19 @@
.modal-responsive
.modal-dialog
max-width: 95vw
@media (min-width: 576px)
max-width: 80vw
@media (min-width: 768px)
max-width: 70vw
@media (min-width: 992px)
max-width: 60vw
@media (min-width: 1200px)
max-width: 50vw
.modal-body
max-height: 80vh
overflow-y: auto

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { YtdlpOptionComponent } from './ytdlp-option.component';
describe('YtdlpOptionComponent', () => {
let component: YtdlpOptionComponent;
let fixture: ComponentFixture<YtdlpOptionComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [YtdlpOptionComponent]
})
.compileComponents();
fixture = TestBed.createComponent(YtdlpOptionComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,61 @@
import { Component, OnInit } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { faSave } from '@fortawesome/free-solid-svg-icons';
@Component({
selector: 'app-ytdlp-option',
templateUrl: './ytdlp-option.component.html',
styleUrls: ['./ytdlp-option.component.sass'],
standalone: false
})
export class YtdlpOptionComponent implements OnInit {
faSave = faSave;
ytdlpOptions: string = '';
isSuccess: boolean = true;
isSaving: boolean = false;
msg: string = '';
constructor(private http: HttpClient) {}
ngOnInit(): void {
this.loadOptions();
}
async loadOptions(): Promise<void> {
this.msg = '';
try {
const data = await firstValueFrom(this.http.get('ytdl_options'));
this.ytdlpOptions = JSON.stringify(data, null, 2);
} catch (error) {
this.msg = 'Failed to load options.';
alert(`Error loading yt-dlp options: ${error.message}`);
this.isSuccess=false;
}
}
async saveOptions(): Promise<void> {
this.isSaving = true;
this.isSuccess=true;
this.msg = '';
if (!this.ytdlpOptions) {
this.ytdlpOptions='{}';
}
try {
const payload = JSON.parse(this.ytdlpOptions);
const data = await firstValueFrom(this.http.post('ytdl_options', payload));
this.msg = data['msg'];
} catch (error) {
if (error instanceof SyntaxError) {
this.msg = 'Invalid JSON format. Please check your input.';
} else {
this.msg = 'Failed to save options.';
alert(`Error saving yt-dlp options: ${error.message}`);
}
this.isSuccess=false;
} finally {
this.isSaving = false;
}
}
}