mirror of
https://github.com/alexta69/metube.git
synced 2026-06-13 16:40:05 +00:00
56826d33fd
Allow users to prefer a specific video codec (H.264, H.265, AV1, VP9) when adding downloads. The selector filters available formats via yt-dlp format strings, falling back to best available if the preferred codec is not found. The completed downloads table now shows Quality and Codec columns.
668 lines
30 KiB
HTML
668 lines
30 KiB
HTML
<nav class="navbar navbar-expand-md navbar-dark">
|
|
<div class="container-fluid">
|
|
<a class="navbar-brand d-flex align-items-center" href="#">
|
|
<img src="assets/icons/android-chrome-192x192.png" alt="MeTube Logo" height="32" class="me-2">
|
|
MeTube
|
|
</a>
|
|
<div class="download-metrics">
|
|
@if (activeDownloads > 0) {
|
|
<div class="metric">
|
|
<fa-icon [icon]="faDownload" class="text-primary" />
|
|
<span>{{activeDownloads}} downloading</span>
|
|
</div>
|
|
}
|
|
@if (queuedDownloads > 0) {
|
|
<div class="metric">
|
|
<fa-icon [icon]="faClock" class="text-warning" />
|
|
<span>{{queuedDownloads}} queued</span>
|
|
</div>
|
|
}
|
|
@if (completedDownloads > 0) {
|
|
<div class="metric">
|
|
<fa-icon [icon]="faCheck" class="text-success" />
|
|
<span>{{completedDownloads}} completed</span>
|
|
</div>
|
|
}
|
|
@if (failedDownloads > 0) {
|
|
<div class="metric">
|
|
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
|
<span>{{failedDownloads}} failed</span>
|
|
</div>
|
|
}
|
|
@if ((totalSpeed | speed) !== '') {
|
|
<div class="metric">
|
|
<fa-icon [icon]="faTachometerAlt" class="text-info" />
|
|
<span>{{totalSpeed | speed }}</span>
|
|
</div>
|
|
}
|
|
</div>
|
|
<!--
|
|
<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">
|
|
<a class="nav-link" href="#">Home <span class="sr-only">(current)</span></a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
-->
|
|
<div class="navbar-nav ms-auto">
|
|
<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="theme-select"
|
|
type="button"
|
|
aria-expanded="false"
|
|
data-bs-toggle="dropdown"
|
|
data-bs-display="static">
|
|
@if(activeTheme){
|
|
<fa-icon [icon]="activeTheme.icon" />
|
|
}
|
|
</button>
|
|
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
|
|
@for (theme of themes; track theme) {
|
|
<li>
|
|
<button type="button" class="dropdown-item d-flex align-items-center"
|
|
[class.active]="activeTheme === theme"
|
|
(click)="themeChanged(theme)">
|
|
<span class="me-2 opacity-50">
|
|
<fa-icon [icon]="theme.icon" />
|
|
</span>
|
|
{{ theme.displayName }}
|
|
<span class="ms-auto"
|
|
[class.d-none]="activeTheme !== theme">
|
|
<fa-icon [icon]="faCheck" />
|
|
</span>
|
|
</button>
|
|
</li>
|
|
}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<main role="main" class="container container-xl">
|
|
<form #f="ngForm">
|
|
<div class="container add-url-box">
|
|
<!-- Main URL Input with Download Button -->
|
|
<div class="row mb-4">
|
|
<div class="col">
|
|
<div class="input-group input-group-lg shadow-sm">
|
|
<input type="text"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
class="form-control form-control-lg"
|
|
placeholder="Enter video, channel, or playlist URL"
|
|
name="addUrl"
|
|
[(ngModel)]="addUrl"
|
|
[disabled]="addInProgress || downloads.loading">
|
|
@if (addInProgress && cancelRequested) {
|
|
<button class="btn btn-warning btn-lg px-3" type="button" disabled>
|
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
|
Canceling...
|
|
</button>
|
|
} @else if (addInProgress) {
|
|
<button class="btn btn-danger btn-lg px-3" type="button" (click)="cancelAdding()">
|
|
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
|
|
</button>
|
|
} @else {
|
|
<button class="btn btn-primary btn-lg px-4" type="submit"
|
|
(click)="addDownload()"
|
|
[disabled]="downloads.loading">
|
|
Download
|
|
</button>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Options Row -->
|
|
<div class="row mb-3 g-3">
|
|
<div class="col-md-4">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Quality</span>
|
|
<select class="form-select"
|
|
name="quality"
|
|
[(ngModel)]="quality"
|
|
(change)="qualityChanged()"
|
|
[disabled]="addInProgress || downloads.loading">
|
|
@for (q of qualities; track q) {
|
|
<option [ngValue]="q.id">{{ q.text }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Format</span>
|
|
<select class="form-select"
|
|
name="format"
|
|
[(ngModel)]="format"
|
|
(change)="formatChanged()"
|
|
[disabled]="addInProgress || downloads.loading">
|
|
@for (f of formats; track f) {
|
|
<option [ngValue]="f.id">{{ f.text }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<button type="button"
|
|
class="btn btn-outline-secondary w-100 h-100"
|
|
(click)="toggleAdvanced()">
|
|
Advanced Options
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Options Panel -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<div class="collapse show" id="advancedOptions" [ngbCollapse]="!isAdvancedOpen">
|
|
<div class="card card-body">
|
|
<!-- Advanced Settings -->
|
|
<div class="row g-3 mb-2">
|
|
<div class="col-md-6">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Auto Start</span>
|
|
<select class="form-select"
|
|
name="autoStart"
|
|
[(ngModel)]="autoStart"
|
|
(change)="autoStartChanged()"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Automatically start downloads when added">
|
|
<option [ngValue]="true">Yes</option>
|
|
<option [ngValue]="false">No</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Download Folder</span>
|
|
@if (customDirs$ | async; as customDirs) {
|
|
<ng-select [items]="customDirs"
|
|
placeholder="Default"
|
|
[addTag]="allowCustomDir.bind(this)"
|
|
addTagText="Create directory"
|
|
bindLabel="folder"
|
|
[(ngModel)]="folder"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
[virtualScroll]="true"
|
|
[clearable]="true"
|
|
[loading]="downloads.loading"
|
|
[searchable]="true"
|
|
[closeOnSelect]="true"
|
|
ngbTooltip="Choose where to save downloads. Type to create a new folder." />
|
|
}
|
|
</div>
|
|
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Custom Name Prefix</span>
|
|
<input type="text"
|
|
class="form-control"
|
|
placeholder="Default"
|
|
name="customNamePrefix"
|
|
[(ngModel)]="customNamePrefix"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Add a prefix to downloaded filenames">
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Items Limit</span>
|
|
<input type="number"
|
|
min="0"
|
|
class="form-control"
|
|
placeholder="Default"
|
|
name="playlistItemLimit"
|
|
(keydown)="isNumber($event)"
|
|
[(ngModel)]="playlistItemLimit"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
|
|
</div>
|
|
</div>
|
|
@if (format === 'captions') {
|
|
<div class="col-md-4">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Subtitles</span>
|
|
<select class="form-select"
|
|
name="subtitleFormat"
|
|
[(ngModel)]="subtitleFormat"
|
|
(change)="subtitleFormatChanged()"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Subtitle output format for captions mode">
|
|
@for (fmt of subtitleFormats; track fmt.id) {
|
|
<option [ngValue]="fmt.id">{{ fmt.text }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
@if (subtitleFormat === 'txt') {
|
|
<div class="form-text">TXT is generated from SRT by stripping timestamps and cue numbers.</div>
|
|
}
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Language</span>
|
|
<input class="form-control"
|
|
type="text"
|
|
list="subtitleLanguageOptions"
|
|
name="subtitleLanguage"
|
|
[(ngModel)]="subtitleLanguage"
|
|
(change)="subtitleLanguageChanged()"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
placeholder="e.g. en, es, zh-Hans"
|
|
ngbTooltip="Subtitle language (you can type any language code)">
|
|
<datalist id="subtitleLanguageOptions">
|
|
@for (lang of subtitleLanguages; track lang.id) {
|
|
<option [value]="lang.id">{{ lang.text }}</option>
|
|
}
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-4">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Subtitle Source</span>
|
|
<select class="form-select"
|
|
name="subtitleMode"
|
|
[(ngModel)]="subtitleMode"
|
|
(change)="subtitleModeChanged()"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Choose manual, auto, or fallback preference for captions mode">
|
|
@for (mode of subtitleModes; track mode.id) {
|
|
<option [ngValue]="mode.id">{{ mode.text }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
}
|
|
@if (isVideoType()) {
|
|
<div class="col-md-4">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Video Codec</span>
|
|
<select class="form-select"
|
|
name="videoCodec"
|
|
[(ngModel)]="videoCodec"
|
|
(change)="videoCodecChanged()"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Prefer a specific video codec. Falls back to best available if not found.">
|
|
@for (vc of videoCodecs; track vc.id) {
|
|
<option [ngValue]="vc.id">{{ vc.text }}</option>
|
|
}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
}
|
|
<div class="col-12">
|
|
<div class="row g-2 align-items-center">
|
|
<div class="col-auto">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" role="switch" id="checkbox-split-chapters"
|
|
name="splitByChapters" [(ngModel)]="splitByChapters" (change)="splitByChaptersChanged()"
|
|
[disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Split video into separate files by chapters">
|
|
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
|
|
</div>
|
|
</div>
|
|
@if (splitByChapters) {
|
|
<div class="col">
|
|
<div class="input-group">
|
|
<span class="input-group-text">Template</span>
|
|
<input type="text" class="form-control" name="chapterTemplate" [(ngModel)]="chapterTemplate"
|
|
(change)="chapterTemplateChanged()" [disabled]="addInProgress || downloads.loading"
|
|
ngbTooltip="Output template for chapter files">
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Actions -->
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<hr class="my-3">
|
|
<div class="row g-3">
|
|
<div class="col-md-4">
|
|
<div class="action-group-label">Cookies</div>
|
|
<input type="file" id="cookie-upload" class="d-none" accept=".txt"
|
|
(change)="onCookieFileSelect($event)"
|
|
[disabled]="cookieUploadInProgress || addInProgress">
|
|
<div class="btn-group w-100" role="group">
|
|
<label class="btn mb-0"
|
|
[class]="hasCookies ? 'btn cookie-active-btn mb-0' : 'btn cookie-btn mb-0'"
|
|
[class.disabled]="cookieUploadInProgress || addInProgress"
|
|
for="cookie-upload"
|
|
ngbTooltip="Upload a cookies.txt file for authenticated downloads">
|
|
@if (cookieUploadInProgress) {
|
|
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
|
|
} @else {
|
|
<fa-icon [icon]="faUpload" class="me-2" />
|
|
}
|
|
{{ hasCookies ? 'Replace Cookies' : 'Upload Cookies' }}
|
|
</label>
|
|
@if (hasCookies) {
|
|
<button type="button" class="btn btn-outline-danger"
|
|
(click)="deleteCookies()"
|
|
[disabled]="cookieUploadInProgress || addInProgress"
|
|
ngbTooltip="Remove uploaded cookies">
|
|
<fa-icon [icon]="faTrashAlt" />
|
|
</button>
|
|
}
|
|
</div>
|
|
<div class="cookie-status" [class.active]="hasCookies">
|
|
@if (hasCookies) {
|
|
<fa-icon [icon]="faCheckCircle" class="me-1" />
|
|
Cookies active
|
|
} @else {
|
|
No cookies configured
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="col-md-8">
|
|
<div class="action-group-label">Bulk Actions</div>
|
|
<div class="row g-2">
|
|
<div class="col-4">
|
|
<button type="button"
|
|
class="btn btn-secondary w-100"
|
|
(click)="openBatchImportModal()">
|
|
<fa-icon [icon]="faFileImport" class="me-2" />
|
|
Import URLs
|
|
</button>
|
|
</div>
|
|
<div class="col-4">
|
|
<button type="button"
|
|
class="btn btn-secondary w-100"
|
|
(click)="exportBatchUrls('all')">
|
|
<fa-icon [icon]="faFileExport" class="me-2" />
|
|
Export URLs
|
|
</button>
|
|
</div>
|
|
<div class="col-4">
|
|
<button type="button"
|
|
class="btn btn-secondary w-100"
|
|
(click)="copyBatchUrls('all')">
|
|
<fa-icon [icon]="faCopy" class="me-2" />
|
|
Copy URLs
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<!-- Batch Import Modal -->
|
|
<div class="modal fade" tabindex="-1" role="dialog"
|
|
[class.show]="batchImportModalOpen"
|
|
[style.display]="batchImportModalOpen ? 'block' : 'none'">
|
|
<div class="modal-dialog" role="document">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Batch Import URLs</h5>
|
|
<button type="button" class="btn-close" aria-label="Close" (click)="closeBatchImportModal()"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
|
|
placeholder="Paste one video URL per line"></textarea>
|
|
<div class="mt-2">
|
|
@if (batchImportStatus) {
|
|
<small>{{ batchImportStatus }}</small>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
@if (importInProgress) {
|
|
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
|
|
Cancel Import
|
|
</button>
|
|
}
|
|
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
|
|
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
|
|
Import URLs
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
@if (downloads.loading) {
|
|
<div class="alert alert-info" role="alert">
|
|
Connecting to server...
|
|
</div>
|
|
}
|
|
<div class="metube-section-header">Downloading</div>
|
|
<div class="px-2 py-3 border-bottom">
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDelSelected (click)="delSelectedDownloads('queue')"><fa-icon [icon]="faTrashAlt" /> Cancel selected</button>
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #queueDownloadSelected (click)="startSelectedDownloads('queue')"><fa-icon [icon]="faDownload" /> Download selected</button>
|
|
</div>
|
|
<div class="overflow-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" style="width: 1rem;">
|
|
<app-master-checkbox #queueMasterCheckboxRef [id]="'queue'" [list]="downloads.queue" (changed)="queueSelectionChanged($event)" />
|
|
</th>
|
|
<th scope="col">Video</th>
|
|
<th scope="col" style="width: 8rem;">Speed</th>
|
|
<th scope="col" style="width: 7rem;">ETA</th>
|
|
<th scope="col" style="width: 6rem;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
|
|
<tr [class.disabled]='download.value.deleting'>
|
|
<td>
|
|
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
|
|
</td>
|
|
<td title="{{ download.value.filename }}">
|
|
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
|
|
<div>{{ download.value.title }} </div>
|
|
<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" class="download-progressbar" />
|
|
</div>
|
|
</td>
|
|
<td>{{ download.value.speed | speed }}</td>
|
|
<td>{{ download.value.eta | eta }}</td>
|
|
<td>
|
|
<div class="d-flex">
|
|
@if (download.value.status === 'pending') {
|
|
<button type="button" class="btn btn-link" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
|
|
}
|
|
<button type="button" class="btn btn-link" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
|
|
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="metube-section-header">Completed</div>
|
|
<div class="px-2 py-3 border-bottom">
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" (click)="toggleSortOrder()" ngbTooltip="{{ sortAscending ? 'Oldest first' : 'Newest first' }}"><fa-icon [icon]="sortAscending ? faSortAmountUp : faSortAmountDown" /> {{ sortAscending ? 'Oldest first' : 'Newest first' }}</button>
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDelSelected (click)="delSelectedDownloads('done')"><fa-icon [icon]="faTrashAlt" /> Clear selected</button>
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearCompleted (click)="clearCompletedDownloads()"><fa-icon [icon]="faCheckCircle" /> Clear completed</button>
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneClearFailed (click)="clearFailedDownloads()"><fa-icon [icon]="faTimesCircle" /> Clear failed</button>
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneRetryFailed (click)="retryFailedDownloads()"><fa-icon [icon]="faRedoAlt" /> Retry failed</button>
|
|
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled #doneDownloadSelected (click)="downloadSelectedFiles()"><fa-icon [icon]="faDownload" /> Download Selected</button>
|
|
</div>
|
|
<div class="overflow-auto">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th scope="col" style="width: 1rem;">
|
|
<app-master-checkbox #doneMasterCheckboxRef [id]="'done'" [list]="downloads.done" (changed)="doneSelectionChanged($event)" />
|
|
</th>
|
|
<th scope="col">Video</th>
|
|
<th scope="col">Quality</th>
|
|
<th scope="col">Codec</th>
|
|
<th scope="col">File Size</th>
|
|
<th scope="col">Downloaded</th>
|
|
<th scope="col" style="width: 8rem;"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@for (entry of cachedSortedDone; track entry[1].id) {
|
|
<tr [class.disabled]='entry[1].deleting'>
|
|
<td>
|
|
<app-slave-checkbox [id]="entry[0]" [master]="doneMasterCheckboxRef" [checkable]="entry[1]" />
|
|
</td>
|
|
<td>
|
|
<div style="display: inline-block; width: 1.5rem;">
|
|
@if (entry[1].status === 'finished') {
|
|
<fa-icon [icon]="faCheckCircle" class="text-success" />
|
|
}
|
|
@if (entry[1].status === 'error') {
|
|
<button type="button" class="btn btn-link p-0"
|
|
(click)="toggleErrorDetail(entry[0])"
|
|
[attr.aria-label]="'Toggle error details for ' + entry[1].title"
|
|
[attr.aria-expanded]="isErrorExpanded(entry[0])">
|
|
<fa-icon [icon]="faTimesCircle" class="text-danger" />
|
|
</button>
|
|
}
|
|
</div>
|
|
<span ngbTooltip="{{buildResultItemTooltip(entry[1])}}">@if (!!entry[1].filename) {
|
|
<a href="{{buildDownloadLink(entry[1])}}" target="_blank">{{ entry[1].title }}</a>
|
|
} @else {
|
|
<span [style.cursor]="entry[1].status === 'error' ? 'pointer' : 'default'"
|
|
(click)="entry[1].status === 'error' ? toggleErrorDetail(entry[0]) : null">
|
|
{{entry[1].title}}
|
|
@if (entry[1].status === 'error' && !isErrorExpanded(entry[0])) {
|
|
<small class="text-danger ms-2">
|
|
<fa-icon [icon]="faChevronRight" size="xs" class="me-1" />Click for details
|
|
</small>
|
|
}
|
|
</span>
|
|
}</span>
|
|
@if (entry[1].status === 'error' && isErrorExpanded(entry[0])) {
|
|
<div class="alert alert-danger py-2 px-3 mt-2 mb-0 small" style="border-left: 4px solid var(--bs-danger);">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div class="flex-grow-1">
|
|
@if (entry[1].msg) {
|
|
<div class="mb-1"><strong>Message:</strong> {{entry[1].msg}}</div>
|
|
}
|
|
@if (entry[1].error) {
|
|
<div class="mb-1"><strong>Error:</strong> {{entry[1].error}}</div>
|
|
}
|
|
<div class="text-muted" style="word-break: break-all;"><strong>URL:</strong> {{entry[1].url}}</div>
|
|
</div>
|
|
<button type="button" class="btn btn-sm btn-outline-danger ms-2 flex-shrink-0"
|
|
(click)="copyErrorMessage(entry[0], entry[1]); $event.stopPropagation()"
|
|
ngbTooltip="Copy error details to clipboard">
|
|
@if (lastCopiedErrorId === entry[0]) {
|
|
<span class="text-success">Copied!</span>
|
|
} @else {
|
|
<fa-icon [icon]="faCopy" />
|
|
}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
</td>
|
|
<td class="text-nowrap">
|
|
{{ formatQualityLabel(entry[1]) }}
|
|
</td>
|
|
<td class="text-nowrap">
|
|
{{ formatCodecLabel(entry[1]) }}
|
|
</td>
|
|
<td>
|
|
@if (entry[1].size) {
|
|
<span>{{ entry[1].size | fileSize }}</span>
|
|
}
|
|
</td>
|
|
<td class="text-nowrap">
|
|
@if (entry[1].timestamp) {
|
|
<span>{{ entry[1].timestamp / 1000000 | date:'yyyy-MM-dd HH:mm' }}</span>
|
|
}
|
|
</td>
|
|
<td>
|
|
<div class="d-flex">
|
|
@if (entry[1].status === 'error') {
|
|
<button type="button" class="btn btn-link" (click)="retryDownload(entry[0], entry[1])"><fa-icon [icon]="faRedoAlt" /></button>
|
|
}
|
|
@if (entry[1].filename) {
|
|
<a href="{{buildDownloadLink(entry[1])}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
|
}
|
|
<a href="{{entry[1].url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
|
|
<button type="button" class="btn btn-link" (click)="delDownload('done', entry[0])"><fa-icon [icon]="faTrashAlt" /></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
@if (entry[1].chapter_files && entry[1].chapter_files.length > 0) {
|
|
@for (chapterFile of entry[1].chapter_files; track chapterFile.filename) {
|
|
<tr [class.disabled]='entry[1].deleting'>
|
|
<td></td>
|
|
<td>
|
|
<div style="padding-left: 2rem;">
|
|
<fa-icon [icon]="faCheckCircle" class="text-success me-2" />
|
|
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" target="_blank">{{
|
|
getChapterFileName(chapterFile.filename) }}</a>
|
|
</div>
|
|
</td>
|
|
<td></td>
|
|
<td></td>
|
|
<td>
|
|
@if (chapterFile.size) {
|
|
<span>{{ chapterFile.size | fileSize }}</span>
|
|
}
|
|
</td>
|
|
<td></td>
|
|
<td>
|
|
<div class="d-flex">
|
|
<a href="{{buildChapterDownloadLink(entry[1], chapterFile.filename)}}" download
|
|
class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</main><!-- /.container -->
|
|
|
|
<footer class="footer navbar-dark bg-dark py-3 mt-5">
|
|
<div class="container text-center">
|
|
@if (ytDlpVersion && metubeVersion) {
|
|
<div class="footer-content">
|
|
<div class="version-item">
|
|
<span class="version-label">yt-dlp</span>
|
|
<span class="version-value">{{ytDlpVersion}}</span>
|
|
</div>
|
|
<div class="version-separator"></div>
|
|
<div class="version-item">
|
|
<span class="version-label">MeTube</span>
|
|
<span class="version-value">{{metubeVersion}}</span>
|
|
</div>
|
|
<div class="version-separator"></div>
|
|
@if (ytDlpOptionsUpdateTime) {
|
|
<div class="version-item">
|
|
<span class="version-label">yt-dlp-options</span>
|
|
<span class="version-value">{{ytDlpOptionsUpdateTime}}</span>
|
|
</div>
|
|
}
|
|
@if (ytDlpOptionsUpdateTime) {
|
|
<div class="version-separator"></div>
|
|
}
|
|
<a href="https://github.com/alexta69/metube" target="_blank" class="github-link">
|
|
<fa-icon [icon]="faGithub" />
|
|
<span>GitHub</span>
|
|
</a>
|
|
</div>
|
|
}
|
|
</div>
|
|
</footer>
|