feat: Undo bogus formatting changes

This commit is contained in:
Igor Katkov
2025-12-30 23:33:01 -08:00
parent 962929d42d
commit d51f2ce628
5 changed files with 247 additions and 237 deletions

View File

@@ -261,7 +261,7 @@ async def add(request):
if split_by_chapters is None: if split_by_chapters is None:
split_by_chapters = False split_by_chapters = False
if chapter_template is None: if chapter_template is None:
chapter_template = '%(section_number)02d - %(section_title)s.%(ext)s' chapter_template = '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s'
playlist_item_limit = int(playlist_item_limit) playlist_item_limit = int(playlist_item_limit)

View File

@@ -100,8 +100,6 @@ class Download:
)}) )})
def put_status_postprocessor(d): def put_status_postprocessor(d):
log.debug(f"Postprocessor hook called: postprocessor={d.get('postprocessor')}, status={d.get('status')}")
if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished': if d['postprocessor'] == 'MoveFiles' and d['status'] == 'finished':
if '__finaldir' in d['info_dict']: if '__finaldir' in d['info_dict']:
filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath'])) filename = os.path.join(d['info_dict']['__finaldir'], os.path.basename(d['info_dict']['filepath']))
@@ -217,7 +215,7 @@ class Download:
self.info.chapter_files = [] self.info.chapter_files = []
rel_path = os.path.relpath(chapter_file, self.download_dir) rel_path = os.path.relpath(chapter_file, self.download_dir)
file_size = os.path.getsize(chapter_file) if os.path.exists(chapter_file) else None file_size = os.path.getsize(chapter_file) if os.path.exists(chapter_file) else None
# Upsert: update if exists, otherwise append. Postprocessor hook called multiple times with chapters. #Postprocessor hook called multiple times with chapters. Only insert if not already present.
existing = next((cf for cf in self.info.chapter_files if cf['filename'] == rel_path), None) existing = next((cf for cf in self.info.chapter_files if cf['filename'] == rel_path), None)
if not existing: if not existing:
self.info.chapter_files.append({'filename': rel_path, 'size': file_size}) self.info.chapter_files.append({'filename': rel_path, 'size': file_size})
@@ -464,7 +462,7 @@ class DownloadQueue:
return {'status': 'ok'} return {'status': 'ok'}
return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'} return {'status': 'error', 'msg': f'Unsupported resource "{etype}"'}
async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template='%(section_number)02d - %(section_title)s.%(ext)s', already=None): async def add(self, url, quality, format, folder, custom_name_prefix, playlist_strict_mode, playlist_item_limit, auto_start=True, split_by_chapters=False, chapter_template='%(title)s - %(section_number)02d - %(section_title)s.%(ext)s', already=None):
log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}') log.info(f'adding {url}: {quality=} {format=} {already=} {folder=} {custom_name_prefix=} {playlist_strict_mode=} {playlist_item_limit=} {auto_start=} {split_by_chapters=} {chapter_template=}')
already = set() if already is None else already already = set() if already is None else already
if url in already: if url in already:

View File

@@ -6,34 +6,34 @@
</a> </a>
<div class="download-metrics"> <div class="download-metrics">
@if (activeDownloads > 0) { @if (activeDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faDownload" class="text-primary" /> <fa-icon [icon]="faDownload" class="text-primary" />
<span>{{activeDownloads}} downloading</span> <span>{{activeDownloads}} downloading</span>
</div> </div>
} }
@if (queuedDownloads > 0) { @if (queuedDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faClock" class="text-warning" /> <fa-icon [icon]="faClock" class="text-warning" />
<span>{{queuedDownloads}} queued</span> <span>{{queuedDownloads}} queued</span>
</div> </div>
} }
@if (completedDownloads > 0) { @if (completedDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faCheck" class="text-success" /> <fa-icon [icon]="faCheck" class="text-success" />
<span>{{completedDownloads}} completed</span> <span>{{completedDownloads}} completed</span>
</div> </div>
} }
@if (failedDownloads > 0) { @if (failedDownloads > 0) {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faTimesCircle" class="text-danger" /> <fa-icon [icon]="faTimesCircle" class="text-danger" />
<span>{{failedDownloads}} failed</span> <span>{{failedDownloads}} failed</span>
</div> </div>
} }
@if ((totalSpeed | speed) !== '') { @if ((totalSpeed | speed) !== '') {
<div class="metric"> <div class="metric">
<fa-icon [icon]="faTachometerAlt" class="text-info" /> <fa-icon [icon]="faTachometerAlt" class="text-info" />
<span>{{totalSpeed | speed }}</span> <span>{{totalSpeed | speed }}</span>
</div> </div>
} }
</div> </div>
<!-- <!--
@@ -62,20 +62,20 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select"> <ul class="dropdown-menu dropdown-menu-end position-absolute" aria-labelledby="theme-select">
@for (theme of themes; track theme) { @for (theme of themes; track theme) {
<li> <li>
<button type="button" class="dropdown-item d-flex align-items-center" <button type="button" class="dropdown-item d-flex align-items-center"
[class.active]="activeTheme === theme" [class.active]="activeTheme === theme"
(click)="themeChanged(theme)"> (click)="themeChanged(theme)">
<span class="me-2 opacity-50"> <span class="me-2 opacity-50">
<fa-icon [icon]="theme.icon" /> <fa-icon [icon]="theme.icon" />
</span> </span>
{{ theme.displayName }} {{ theme.displayName }}
<span class="ms-auto" <span class="ms-auto"
[class.d-none]="activeTheme !== theme"> [class.d-none]="activeTheme !== theme">
<fa-icon [icon]="faCheck" /> <fa-icon [icon]="faCheck" />
</span> </span>
</button> </button>
</li> </li>
} }
</ul> </ul>
</div> </div>
@@ -103,7 +103,7 @@
(click)="addDownload()" (click)="addDownload()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@if (addInProgress) { @if (addInProgress) {
<span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span> <span class="spinner-border spinner-border-sm" role="status" id="add-spinner"></span>
} }
{{ addInProgress ? "Adding..." : "Download" }} {{ addInProgress ? "Adding..." : "Download" }}
</button> </button>
@@ -122,7 +122,7 @@
(change)="qualityChanged()" (change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@for (q of qualities; track q) { @for (q of qualities; track q) {
<option [ngValue]="q.id">{{ q.text }}</option> <option [ngValue]="q.id">{{ q.text }}</option>
} }
</select> </select>
</div> </div>
@@ -136,7 +136,7 @@
(change)="formatChanged()" (change)="formatChanged()"
[disabled]="addInProgress || downloads.loading"> [disabled]="addInProgress || downloads.loading">
@for (f of formats; track f) { @for (f of formats; track f) {
<option [ngValue]="f.id">{{ f.text }}</option> <option [ngValue]="f.id">{{ f.text }}</option>
} }
</select> </select>
</div> </div>
@@ -188,9 +188,9 @@
[searchable]="true" [searchable]="true"
[closeOnSelect]="true" [closeOnSelect]="true"
ngbTooltip="Choose where to save downloads. Type to create a new folder." /> ngbTooltip="Choose where to save downloads. Type to create a new folder." />
} }
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="input-group"> <div class="input-group">
@@ -298,7 +298,7 @@
<!-- Batch Import Modal --> <!-- Batch Import Modal -->
<div class="modal fade" tabindex="-1" role="dialog" <div class="modal fade" tabindex="-1" role="dialog"
[class.show]="batchImportModalOpen" [class.show]="batchImportModalOpen"
[style.display]="batchImportModalOpen ? 'block' : 'none'"> [style.display]="batchImportModalOpen ? 'block' : 'none'">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@@ -307,18 +307,18 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<textarea [(ngModel)]="batchImportText" class="form-control" rows="6" <textarea [(ngModel)]="batchImportText" class="form-control" rows="6"
placeholder="Paste one video URL per line"></textarea> placeholder="Paste one video URL per line"></textarea>
<div class="mt-2"> <div class="mt-2">
@if (batchImportStatus) { @if (batchImportStatus) {
<small>{{ batchImportStatus }}</small> <small>{{ batchImportStatus }}</small>
} }
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
@if (importInProgress) { @if (importInProgress) {
<button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()"> <button type="button" class="btn btn-danger me-auto" (click)="cancelBatchImport()">
Cancel Import Cancel Import
</button> </button>
} }
<button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button> <button type="button" class="btn btn-secondary" (click)="closeBatchImportModal()">Close</button>
<button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress"> <button type="button" class="btn btn-primary" (click)="startBatchImport()" [disabled]="importInProgress">
@@ -331,9 +331,9 @@
@if (downloads.loading) { @if (downloads.loading) {
<div class="alert alert-info" role="alert"> <div class="alert alert-info" role="alert">
Connecting to server... Connecting to server...
</div> </div>
} }
<div class="metube-section-header">Downloading</div> <div class="metube-section-header">Downloading</div>
<div class="px-2 py-3 border-bottom"> <div class="px-2 py-3 border-bottom">
@@ -355,29 +355,29 @@
</thead> </thead>
<tbody> <tbody>
@for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) { @for (download of downloads.queue | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='download.value.deleting'>
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" /> <app-slave-checkbox [id]="download.key" [master]="queueMasterCheckboxRef" [checkable]="download.value" />
</td> </td>
<td title="{{ download.value.filename }}"> <td title="{{ download.value.filename }}">
<div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3"> <div class="d-flex flex-column flex-sm-row align-items-center row-gap-2 column-gap-3">
<div>{{ download.value.title }} </div> <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" <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" /> [value]="download.value.status === 'preparing' ? 100 : download.value.percent" class="download-progressbar" />
</div> </div>
</td> </td>
<td>{{ download.value.speed | speed }}</td> <td>{{ download.value.speed | speed }}</td>
<td>{{ download.value.eta | eta }}</td> <td>{{ download.value.eta | eta }}</td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
@if (download.value.status === 'pending') { @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)="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> <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> <a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
</div> </div>
</td> </td>
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
@@ -405,55 +405,55 @@
</thead> </thead>
<tbody> <tbody>
@for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) { @for (download of downloads.done | keyvalue: asIsOrder; track download.value.id) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='download.value.deleting'>
<td> <td>
<app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" /> <app-slave-checkbox [id]="download.key" [master]="doneMasterCheckboxRef" [checkable]="download.value" />
</td> </td>
<td> <td>
<div style="display: inline-block; width: 1.5rem;"> <div style="display: inline-block; width: 1.5rem;">
@if (download.value.status === 'finished') { @if (download.value.status === 'finished') {
<fa-icon [icon]="faCheckCircle" class="text-success" /> <fa-icon [icon]="faCheckCircle" class="text-success" />
} }
@if (download.value.status === 'error') { @if (download.value.status === 'error') {
<fa-icon [icon]="faTimesCircle" class="text-danger" /> <fa-icon [icon]="faTimesCircle" class="text-danger" />
} }
</div> </div>
<span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) { <span ngbTooltip="{{buildResultItemTooltip(download.value)}}">@if (!!download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a> <a href="{{buildDownloadLink(download.value)}}" target="_blank">{{ download.value.title }}</a>
} @else { } @else {
{{download.value.title}} {{download.value.title}}
@if (download.value.msg) { @if (download.value.msg) {
<span><br>{{download.value.msg}}</span> <span><br>{{download.value.msg}}</span>
} }
@if (download.value.error) { @if (download.value.error) {
<span><br>Error: {{download.value.error}}</span> <span><br>Error: {{download.value.error}}</span>
} }
}</span> }</span>
</td> </td>
<td> <td>
@if (download.value.size) { @if (download.value.size) {
<span>{{ download.value.size | fileSize }}</span> <span>{{ download.value.size | fileSize }}</span>
} }
</td> </td>
<td> <td>
<div class="d-flex"> <div class="d-flex">
@if (download.value.status === 'error') { @if (download.value.status === 'error') {
<button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button> <button type="button" class="btn btn-link" (click)="retryDownload(download.key, download.value)"><fa-icon [icon]="faRedoAlt" /></button>
} }
@if (download.value.filename) { @if (download.value.filename) {
<a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a> <a href="{{buildDownloadLink(download.value)}}" download class="btn btn-link"><fa-icon [icon]="faDownload" /></a>
} }
<a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a> <a href="{{download.value.url}}" target="_blank" class="btn btn-link"><fa-icon [icon]="faExternalLinkAlt" /></a>
<button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button> <button type="button" class="btn btn-link" (click)="delDownload('done', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
</div> </div>
</td> </td>
</tr> </tr>
@if (download.value.chapter_files && download.value.chapter_files.length > 0) { @if (download.value.chapter_files && download.value.chapter_files.length > 0) {
@for (chapterFile of download.value.chapter_files; track chapterFile.filename) { @for (chapterFile of download.value.chapter_files; track chapterFile.filename) {
<tr [class.disabled]='download.value.deleting'> <tr [class.disabled]='download.value.deleting'>
<td></td> <td></td>
<td> <td>
<div style="padding-left: 2rem;"> <div style="padding-left: 2rem;">
<fa-icon [icon]="faCheckCircle" class="text-success me-2" /> <fa-icon [icon]="faCheckCircle" class="text-success me-2" />
<a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" target="_blank">{{ <a href="{{buildChapterDownloadLink(download.value, chapterFile.filename)}}" target="_blank">{{
getChapterFileName(chapterFile.filename) }}</a> getChapterFileName(chapterFile.filename) }}</a>
@@ -472,6 +472,8 @@
</td> </td>
</tr> </tr>
} }
}
}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -480,31 +482,31 @@
<footer class="footer navbar-dark bg-dark py-3 mt-5"> <footer class="footer navbar-dark bg-dark py-3 mt-5">
<div class="container text-center"> <div class="container text-center">
@if (ytDlpVersion && metubeVersion) { @if (ytDlpVersion && metubeVersion) {
<div class="footer-content"> <div class="footer-content">
<div class="version-item"> <div class="version-item">
<span class="version-label">yt-dlp</span> <span class="version-label">yt-dlp</span>
<span class="version-value">{{ytDlpVersion}}</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>
<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> </div>
</footer> </footer>

View File

@@ -5,30 +5,30 @@ import { Observable, map, distinctUntilChanged } from 'rxjs';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'; import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select'; import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt } from '@fortawesome/free-solid-svg-icons'; import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, 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 { CookieService } from 'ngx-cookie-service'; import { CookieService } from 'ngx-cookie-service';
import { DownloadsService } from './services/downloads.service'; import { DownloadsService } from './services/downloads.service';
import { Themes } from './theme'; import { Themes } from './theme';
import { Download, Status, Theme, Quality, Format, Formats, State } from './interfaces'; import { Download, Status, Theme , Quality, Format, Formats, State } from './interfaces';
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes'; import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
import { MasterCheckboxComponent, SlaveCheckboxComponent } from './components/'; import { MasterCheckboxComponent , SlaveCheckboxComponent} from './components/';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [ imports: [
FormsModule, FormsModule,
KeyValuePipe, KeyValuePipe,
AsyncPipe, AsyncPipe,
FontAwesomeModule, FontAwesomeModule,
NgbModule, NgbModule,
NgSelectModule, NgSelectModule,
EtaPipe, EtaPipe,
SpeedPipe, SpeedPipe,
FileSizePipe, FileSizePipe,
MasterCheckboxComponent, MasterCheckboxComponent,
SlaveCheckboxComponent, SlaveCheckboxComponent,
], ],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.sass', styleUrl: './app.sass',
@@ -54,7 +54,7 @@ export class App implements AfterViewInit, OnInit {
themes: Theme[] = Themes; themes: Theme[] = Themes;
activeTheme: Theme | undefined; activeTheme: Theme | undefined;
customDirs$!: Observable<string[]>; customDirs$!: Observable<string[]>;
showBatchPanel = false; showBatchPanel = false;
batchImportModalOpen = false; batchImportModalOpen = false;
batchImportText = ''; batchImportText = '';
batchImportStatus = ''; batchImportStatus = '';
@@ -106,7 +106,7 @@ export class App implements AfterViewInit, OnInit {
this.quality = this.cookieService.get('metube_quality') || 'best'; this.quality = this.cookieService.get('metube_quality') || 'best';
this.autoStart = this.cookieService.get('metube_auto_start') !== 'false'; this.autoStart = this.cookieService.get('metube_auto_start') !== 'false';
this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true'; this.splitByChapters = this.cookieService.get('metube_split_chapters') === 'true';
this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '%(section_number)02d - %(section_title)s.%(ext)s'; this.chapterTemplate = this.cookieService.get('metube_chapter_template') || '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s';
this.activeTheme = this.getPreferredTheme(this.cookieService); this.activeTheme = this.getPreferredTheme(this.cookieService);
@@ -131,7 +131,7 @@ export class App implements AfterViewInit, OnInit {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.activeTheme && this.activeTheme.id === 'auto') { if (this.activeTheme && this.activeTheme.id === 'auto') {
this.setTheme(this.activeTheme); this.setTheme(this.activeTheme);
} }
}); });
} }
@@ -158,9 +158,9 @@ export class App implements AfterViewInit, OnInit {
// workaround to allow fetching of Map values in the order they were inserted // workaround to allow fetching of Map values in the order they were inserted
// https://github.com/angular/angular/issues/31420 // https://github.com/angular/angular/issues/31420
asIsOrder() { asIsOrder() {
return 1; return 1;
} }
@@ -183,12 +183,12 @@ export class App implements AfterViewInit, OnInit {
} }
isAudioType() { isAudioType() {
return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac'; return this.quality == 'audio' || this.format == 'mp3' || this.format == 'm4a' || this.format == 'opus' || this.format == 'wav' || this.format == 'flac';
} }
getMatchingCustomDir(): Observable<string[]> { getMatchingCustomDir() : Observable<string[]> {
return this.downloads.customDirsChanged.asObservable().pipe( return this.downloads.customDirsChanged.asObservable().pipe(
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
map((output: any) => { map((output: any) => {
// Keep logic consistent with app/ytdl.py // Keep logic consistent with app/ytdl.py
if (this.isAudioType()) { if (this.isAudioType()) {
@@ -205,20 +205,20 @@ export class App implements AfterViewInit, OnInit {
getYtdlOptionsUpdateTime() { getYtdlOptionsUpdateTime() {
this.downloads.ytdlOptionsChanged.subscribe({ this.downloads.ytdlOptionsChanged.subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (data: any) => { next: (data:any) => {
if (data['success']) { if (data['success']){
const date = new Date(data['update_time'] * 1000); const date = new Date(data['update_time'] * 1000);
this.ytDlpOptionsUpdateTime = date.toLocaleString(); this.ytDlpOptionsUpdateTime=date.toLocaleString();
} else { }else{
alert("Error reload yt-dlp options: " + data['msg']); alert("Error reload yt-dlp options: "+data['msg']);
} }
} }
}); });
} }
getConfiguration() { getConfiguration() {
this.downloads.configurationChanged.subscribe({ this.downloads.configurationChanged.subscribe({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
next: (config: any) => { next: (config: any) => {
this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE']; this.playlistStrictMode = config['DEFAULT_OPTION_PLAYLIST_STRICT_MODE'];
const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT']; const playlistItemLimit = config['DEFAULT_OPTION_PLAYLIST_ITEM_LIMIT'];
@@ -269,6 +269,10 @@ export class App implements AfterViewInit, OnInit {
} }
chapterTemplateChanged() { chapterTemplateChanged() {
// Restore default if template is cleared
if (!this.chapterTemplate || this.chapterTemplate.trim() === '') {
this.chapterTemplate = '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s';
}
this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 }); this.cookieService.set('metube_chapter_template', this.chapterTemplate, { expires: 3650 });
} }
@@ -289,8 +293,8 @@ export class App implements AfterViewInit, OnInit {
this.qualities = format.qualities this.qualities = format.qualities
const exists = this.qualities.find(el => el.id === this.quality) const exists = this.qualities.find(el => el.id === this.quality)
this.quality = exists ? this.quality : 'best' this.quality = exists ? this.quality : 'best'
}
} }
}
addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string) { addDownload(url?: string, quality?: string, format?: string, folder?: string, customNamePrefix?: string, playlistStrictMode?: boolean, playlistItemLimit?: number, autoStart?: boolean, splitByChapters?: boolean, chapterTemplate?: string) {
url = url ?? this.addUrl url = url ?? this.addUrl
@@ -304,6 +308,12 @@ export class App implements AfterViewInit, OnInit {
splitByChapters = splitByChapters ?? this.splitByChapters splitByChapters = splitByChapters ?? this.splitByChapters
chapterTemplate = chapterTemplate ?? this.chapterTemplate chapterTemplate = chapterTemplate ?? this.chapterTemplate
// Validate chapter template if chapter splitting is enabled
if (splitByChapters && !chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
return;
}
console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistStrictMode=' + playlistStrictMode + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate); console.debug('Downloading: url=' + url + ' quality=' + quality + ' format=' + format + ' folder=' + folder + ' customNamePrefix=' + customNamePrefix + ' playlistStrictMode=' + playlistStrictMode + ' playlistItemLimit=' + playlistItemLimit + ' autoStart=' + autoStart + ' splitByChapters=' + splitByChapters + ' chapterTemplate=' + chapterTemplate);
this.addInProgress = true; this.addInProgress = true;
this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart, splitByChapters, chapterTemplate).subscribe((status: Status) => { this.downloads.add(url, quality, format, folder, customNamePrefix, playlistStrictMode, playlistItemLimit, autoStart, splitByChapters, chapterTemplate).subscribe((status: Status) => {
@@ -329,7 +339,7 @@ export class App implements AfterViewInit, OnInit {
this.downloads.delById(where, [id]).subscribe(); this.downloads.delById(where, [id]).subscribe();
} }
startSelectedDownloads(where: State) { startSelectedDownloads(where: State){
this.downloads.startByFilter(where, dl => !!dl.checked).subscribe(); this.downloads.startByFilter(where, dl => !!dl.checked).subscribe();
} }
@@ -413,7 +423,7 @@ export class App implements AfterViewInit, OnInit {
isNumber(event: KeyboardEvent) { isNumber(event: KeyboardEvent) {
const charCode = +event.code || event.keyCode; const charCode = +event.code || event.keyCode;
if (charCode > 31 && (charCode < 48 || charCode > 57)) { if (charCode > 31 && (charCode < 48 || charCode > 57)) {
event.preventDefault(); event.preventDefault();
} }
} }
@@ -579,11 +589,11 @@ export class App implements AfterViewInit, OnInit {
this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length; this.queuedDownloads = Array.from(this.downloads.queue.values()).filter(d => d.status === 'pending').length;
this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length; this.completedDownloads = Array.from(this.downloads.done.values()).filter(d => d.status === 'finished').length;
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);
} }
} }

View File

@@ -27,79 +27,79 @@ export class DownloadsService {
constructor() { constructor() {
this.socket.fromEvent('all') this.socket.fromEvent('all')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
this.loading = false; this.loading = false;
const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata); const data: [[[string, Download]], [[string, Download]]] = JSON.parse(strdata);
this.queue.clear(); this.queue.clear();
data[0].forEach(entry => this.queue.set(...entry)); data[0].forEach(entry => this.queue.set(...entry));
this.done.clear(); this.done.clear();
data[1].forEach(entry => this.done.set(...entry)); data[1].forEach(entry => this.done.set(...entry));
this.queueChanged.next(null); this.queueChanged.next(null);
this.doneChanged.next(null); this.doneChanged.next(null);
}); });
this.socket.fromEvent('added') this.socket.fromEvent('added')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.queueChanged.next(null); this.queueChanged.next(null);
}); });
this.socket.fromEvent('updated') this.socket.fromEvent('updated')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
const dl: Download | undefined = this.queue.get(data.url); const dl: Download | undefined = this.queue.get(data.url);
data.checked = !!dl?.checked; data.checked = !!dl?.checked;
data.deleting = !!dl?.deleting; data.deleting = !!dl?.deleting;
this.queue.set(data.url, data); this.queue.set(data.url, data);
this.updated.next(null); this.updated.next(null);
}); });
this.socket.fromEvent('completed') this.socket.fromEvent('completed')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: Download = JSON.parse(strdata); const data: Download = JSON.parse(strdata);
this.queue.delete(data.url); this.queue.delete(data.url);
this.done.set(data.url, data); this.done.set(data.url, data);
this.queueChanged.next(null); this.queueChanged.next(null);
this.doneChanged.next(null); this.doneChanged.next(null);
}); });
this.socket.fromEvent('canceled') this.socket.fromEvent('canceled')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: string = JSON.parse(strdata); const data: string = JSON.parse(strdata);
this.queue.delete(data); this.queue.delete(data);
this.queueChanged.next(null); this.queueChanged.next(null);
}); });
this.socket.fromEvent('cleared') this.socket.fromEvent('cleared')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data: string = JSON.parse(strdata); const data: string = JSON.parse(strdata);
this.done.delete(data); this.done.delete(data);
this.doneChanged.next(null); this.doneChanged.next(null);
}); });
this.socket.fromEvent('configuration') this.socket.fromEvent('configuration')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
console.debug("got configuration:", data); console.debug("got configuration:", data);
this.configuration = data; this.configuration = data;
this.configurationChanged.next(data); this.configurationChanged.next(data);
}); });
this.socket.fromEvent('custom_dirs') this.socket.fromEvent('custom_dirs')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
console.debug("got custom_dirs:", data); console.debug("got custom_dirs:", data);
this.customDirs = data; this.customDirs = data;
this.customDirsChanged.next(data); this.customDirsChanged.next(data);
}); });
this.socket.fromEvent('ytdl_options_changed') this.socket.fromEvent('ytdl_options_changed')
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
.subscribe((strdata: string) => { .subscribe((strdata: string) => {
const data = JSON.parse(strdata); const data = JSON.parse(strdata);
this.ytdlOptionsChanged.next(data); this.ytdlOptionsChanged.next(data);
}); });
} }
handleHTTPError(error: HttpErrorResponse) { handleHTTPError(error: HttpErrorResponse) {
@@ -123,7 +123,7 @@ export class DownloadsService {
if (obj) { if (obj) {
obj.deleting = true obj.deleting = true
} }
}); });
return this.http.post('delete', {where: where, ids: ids}); return this.http.post('delete', {where: where, ids: ids});
} }
@@ -145,13 +145,13 @@ export class DownloadsService {
}> { }> {
const defaultQuality = 'best'; const defaultQuality = 'best';
const defaultFormat = 'mp4'; const defaultFormat = 'mp4';
const defaultFolder = ''; const defaultFolder = '';
const defaultCustomNamePrefix = ''; const defaultCustomNamePrefix = '';
const defaultPlaylistStrictMode = false; const defaultPlaylistStrictMode = false;
const defaultPlaylistItemLimit = 0; const defaultPlaylistItemLimit = 0;
const defaultAutoStart = true; const defaultAutoStart = true;
const defaultSplitByChapters = false; const defaultSplitByChapters = false;
const defaultChapterTemplate = '%(section_number)02d - %(section_title)s.%(ext)s'; const defaultChapterTemplate = '%(title)s - %(section_number)02d - %(section_title)s.%(ext)s';
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate) this.add(url, defaultQuality, defaultFormat, defaultFolder, defaultCustomNamePrefix, defaultPlaylistStrictMode, defaultPlaylistItemLimit, defaultAutoStart, defaultSplitByChapters, defaultChapterTemplate)
@@ -164,6 +164,6 @@ export class DownloadsService {
public exportQueueUrls(): string[] { public exportQueueUrls(): string[] {
return Array.from(this.queue.values()).map(download => download.url); return Array.from(this.queue.values()).map(download => download.url);
} }
} }