review fixes

This commit is contained in:
Alex Shnitman
2026-06-16 21:35:07 +03:00
parent 5429200fba
commit 5aa7d033e2
13 changed files with 441 additions and 101 deletions
+2
View File
@@ -1078,3 +1078,5 @@
}
</div>
</footer>
<app-toast-container />
+5 -3
View File
@@ -4,6 +4,7 @@ import { Subject, of } from 'rxjs';
import { App } from './app';
import { DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { ToastService } from './services/toast.service';
import { CookieService } from 'ngx-cookie-service';
class DownloadsServiceStub {
@@ -263,7 +264,8 @@ describe('App', () => {
});
it('blocks subscribe with invalid title regex', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
const toasts = TestBed.inject(ToastService);
const errorSpy = vi.spyOn(toasts, 'error').mockImplementation(() => undefined);
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
@@ -271,7 +273,7 @@ describe('App', () => {
app.titleRegex = '[';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(0);
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
alertSpy.mockRestore();
expect(errorSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
errorSpy.mockRestore();
});
});
+39 -82
View File
@@ -13,6 +13,8 @@ import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
import { MeTubeSocket } from './services/metube-socket.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { ToastService } from './services/toast.service';
import { BatchUrlsService, BatchUrlFilter } from './services/batch-urls.service';
import { SubscriptionRow } from './interfaces/subscription';
import { Themes } from './theme';
import {
@@ -32,7 +34,7 @@ import {
State,
} from './interfaces';
import { EtaPipe, SpeedPipe, FileSizePipe } from './pipes';
import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/';
import { SelectAllCheckboxComponent, ItemCheckboxComponent, ToastContainerComponent } from './components/';
@Component({
selector: 'app-root',
@@ -50,6 +52,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
FileSizePipe,
SelectAllCheckboxComponent,
ItemCheckboxComponent,
ToastContainerComponent,
],
templateUrl: './app.html',
styleUrl: './app.sass',
@@ -57,6 +60,8 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
export class App implements AfterViewInit, OnInit, OnDestroy {
downloads = inject(DownloadsService);
subscriptionsSvc = inject(SubscriptionsService);
private toasts = inject(ToastService);
private batchUrls = inject(BatchUrlsService);
private socket = inject(MeTubeSocket);
private cookieService = inject(CookieService);
private http = inject(HttpClient);
@@ -415,7 +420,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
const date = new Date(data['update_time'] * 1000);
this.ytDlpOptionsUpdateTime=date.toLocaleString();
}else{
alert("Error reload yt-dlp options: "+data['msg']);
this.toasts.error("Error reloading yt-dlp options: " + data['msg']);
}
this.cdr.markForCheck();
}
@@ -490,11 +495,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
try {
const parsed = JSON.parse(trimmed);
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
alert('Custom yt-dlp options must be a JSON object');
this.toasts.error('Custom yt-dlp options must be a JSON object');
return false;
}
} catch {
alert('Custom yt-dlp options must be valid JSON');
this.toasts.error('Custom yt-dlp options must be valid JSON');
return false;
}
return true;
@@ -525,7 +530,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
const error = this.getStatusError(refreshRes);
if (error) {
alert(error || 'Refresh subscriptions failed');
this.toasts.error(error || 'Refresh subscriptions failed');
return;
}
this.cdr.markForCheck();
@@ -569,7 +574,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
const payload = this.buildAddPayload();
if (!payload.url?.trim()) {
alert('Please enter a URL');
this.toasts.error('Please enter a URL');
return;
}
const tr = (this.titleRegex || '').trim();
@@ -577,12 +582,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
try {
void RegExp(tr);
} catch {
alert('Invalid subscription title filter (regex)');
this.toasts.error('Invalid subscription title filter (regex)');
return;
}
}
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
this.toasts.error('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
@@ -611,7 +616,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
next: (res) => {
const r = res as { status?: string; msg?: string };
if (r.status === 'error') {
alert(r.msg || 'Subscribe failed');
this.toasts.error(r.msg || 'Subscribe failed');
} else {
this.addUrl = '';
this.titleRegex = '';
@@ -639,14 +644,14 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
try {
void RegExp(raw);
} catch {
alert('Invalid subscription title filter (regex)');
this.toasts.error('Invalid subscription title filter (regex)');
return;
}
}
this.subscriptionsSvc.update(id, { title_regex: raw }).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Update subscription failed');
this.toasts.error(error || 'Update subscription failed');
return;
}
this.cancelEditTitleRegex();
@@ -657,7 +662,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.subscriptionsSvc.delete([id]).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Delete subscription failed');
this.toasts.error(error || 'Delete subscription failed');
return;
}
this.selectedSubscriptionIds.delete(id);
@@ -673,7 +678,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.subscriptionsSvc.delete(ids).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Delete subscriptions failed');
this.toasts.error(error || 'Delete subscriptions failed');
return;
}
this.selectedSubscriptionIds.clear();
@@ -699,7 +704,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
.subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Subscription check failed');
this.toasts.error(error || 'Subscription check failed');
return;
}
this.refreshSubscriptionsWithAlert();
@@ -746,7 +751,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
.subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Subscription check failed');
this.toasts.error(error || 'Subscription check failed');
return;
}
this.refreshSubscriptionsWithAlert();
@@ -769,7 +774,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Update subscription failed');
this.toasts.error(error || 'Update subscription failed');
}
});
}
@@ -1066,20 +1071,19 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
// Validate chapter template if chapter splitting is enabled
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
this.toasts.error('Chapter template must include %(section_number)');
return;
}
if (!this.validateYtdlOptionsOverrides(payload.ytdlOptionsOverrides)) {
return;
}
console.debug('Downloading:', payload);
this.addInProgress = true;
this.cancelRequested = false;
this.addRequestSub?.unsubscribe();
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`);
this.toasts.error(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') {
this.addUrl = '';
}
@@ -1241,10 +1245,12 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
// file into memory only to have navigator.canShare reject it.
if (download.size && download.size > App.SHARE_SIZE_WARN_BYTES) {
const sizeMb = Math.round(download.size / 1024 / 1024);
const proceed = window.confirm(
const proceed = await this.toasts.confirm(
`This file is ${sizeMb} MB. iOS' share sheet often refuses files ` +
`larger than ~100 MB and the share will silently fail. ` +
`Try anyway? (Use the download button instead if it fails.)`
`Try anyway? (Use the download button instead if it fails.)`,
'Try anyway',
'Cancel',
);
if (!proceed) return;
}
@@ -1265,7 +1271,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
// download button right next to this one instead of staring at
// a button that quietly did nothing.
console.warn('navigator.canShare rejected payload for', download.filename);
window.alert(
this.toasts.error(
`Your device's share sheet doesn't accept this file ` +
`(most likely because it's too large). ` +
`Please use the download button instead.`
@@ -1278,7 +1284,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
// AbortError = user dismissed the share sheet → silent no-op.
if (e.name === 'AbortError') return;
console.error('Share failed:', err);
window.alert(
this.toasts.error(
`Share failed: ${e.message || 'unknown error'}. ` +
`Please use the download button instead.`
);
@@ -1370,7 +1376,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
.map(url => url.trim())
.filter(url => url.length > 0);
if (urls.length === 0) {
alert('No valid URLs found.');
this.toasts.error('No valid URLs found.');
return;
}
this.importInProgress = true;
@@ -1435,62 +1441,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
// Export URLs based on filter: 'pending', 'completed', 'failed', or 'all'
exportBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
let urls: string[];
if (filter === 'pending') {
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
} else if (filter === 'completed') {
// Only finished downloads in the "done" Map
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
} else if (filter === 'failed') {
// Only error downloads from the "done" Map
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
} else {
// All: pending + both finished and error in done
urls = [
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
...Array.from(this.downloads.done.values()).map(dl => dl.url)
];
}
if (!urls.length) {
alert('No URLs found for the selected filter.');
return;
}
const content = urls.join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'metube_urls.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
exportBatchUrls(filter: BatchUrlFilter): void {
this.batchUrls.export(filter);
}
// Copy URLs to clipboard based on filter: 'pending', 'completed', 'failed', or 'all'
copyBatchUrls(filter: 'pending' | 'completed' | 'failed' | 'all'): void {
let urls: string[];
if (filter === 'pending') {
urls = Array.from(this.downloads.queue.values()).map(dl => dl.url);
} else if (filter === 'completed') {
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'finished').map(dl => dl.url);
} else if (filter === 'failed') {
urls = Array.from(this.downloads.done.values()).filter(dl => dl.status === 'error').map(dl => dl.url);
} else {
urls = [
...Array.from(this.downloads.queue.values()).map(dl => dl.url),
...Array.from(this.downloads.done.values()).map(dl => dl.url)
];
}
if (!urls.length) {
alert('No URLs found for the selected filter.');
return;
}
const content = urls.join('\n');
navigator.clipboard.writeText(content)
.then(() => alert('URLs copied to clipboard.'))
.catch(() => alert('Failed to copy URLs.'));
copyBatchUrls(filter: BatchUrlFilter): void {
this.batchUrls.copy(filter);
}
fetchVersionInfo(): void {
@@ -1550,7 +1507,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
};
const fail = (err?: unknown) => {
console.error('Clipboard write failed:', err);
alert('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
this.toasts.error('Failed to copy to clipboard. Your browser may require HTTPS for clipboard access.');
};
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(done).catch(fail);
@@ -1586,7 +1543,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.hasCookies = true;
} else {
this.refreshCookieStatus();
alert(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
this.toasts.error(`Error uploading cookies: ${this.formatErrorMessage(response?.msg)}`);
}
this.cookieUploadInProgress = false;
input.value = '';
@@ -1595,7 +1552,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.refreshCookieStatus();
this.cookieUploadInProgress = false;
input.value = '';
alert('Error uploading cookies.');
this.toasts.error('Error uploading cookies.');
}
});
}
@@ -1629,11 +1586,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
return;
}
this.refreshCookieStatus();
alert(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
this.toasts.error(`Error deleting cookies: ${this.formatErrorMessage(response?.msg)}`);
},
error: () => {
this.refreshCookieStatus();
alert('Error deleting cookies.');
this.toasts.error('Error deleting cookies.');
}
});
}
+2 -1
View File
@@ -1,2 +1,3 @@
export { SelectAllCheckboxComponent } from './master-checkbox.component';
export { ItemCheckboxComponent } from './slave-checkbox.component';
export { ItemCheckboxComponent } from './slave-checkbox.component';
export { ToastContainerComponent } from './toast-container.component';
@@ -0,0 +1,58 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faCheckCircle, faTimesCircle, faInfoCircle, faXmark } from '@fortawesome/free-solid-svg-icons';
import { ToastService } from '../services/toast.service';
@Component({
selector: 'app-toast-container',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FontAwesomeModule],
template: `
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 1100;" aria-live="polite" aria-atomic="true">
@for (toast of toasts.toasts(); track toast.id) {
<div class="toast show align-items-center border-0 mb-2"
[class.text-bg-danger]="toast.level === 'error'"
[class.text-bg-success]="toast.level === 'success'"
[class.text-bg-primary]="toast.level === 'info'"
role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body d-flex align-items-start gap-2">
@if (toast.level === 'error') {
<fa-icon [icon]="faTimesCircle" class="mt-1" />
} @else if (toast.level === 'success') {
<fa-icon [icon]="faCheckCircle" class="mt-1" />
} @else {
<fa-icon [icon]="faInfoCircle" class="mt-1" />
}
<span style="white-space: pre-line;">{{ toast.message }}</span>
</div>
@if (!toast.actions) {
<button type="button" class="btn-close btn-close-white me-2 m-auto"
aria-label="Close" (click)="toasts.dismiss(toast.id)"></button>
}
</div>
@if (toast.actions) {
<div class="d-flex justify-content-end gap-2 px-3 pb-2">
@for (action of toast.actions; track action.label) {
<button type="button"
class="btn btn-sm"
[class.btn-light]="!action.primary"
[class.btn-outline-light]="action.primary"
(click)="toasts.respond(toast.id, action.value)">
{{ action.label }}
</button>
}
</div>
}
</div>
}
</div>
`,
})
export class ToastContainerComponent {
protected readonly toasts = inject(ToastService);
protected readonly faCheckCircle = faCheckCircle;
protected readonly faTimesCircle = faTimesCircle;
protected readonly faInfoCircle = faInfoCircle;
protected readonly faXmark = faXmark;
}
+62
View File
@@ -0,0 +1,62 @@
import { inject, Injectable } from '@angular/core';
import { DownloadsService } from './downloads.service';
import { ToastService } from './toast.service';
export type BatchUrlFilter = 'pending' | 'completed' | 'failed' | 'all';
/**
* Encapsulates collecting download URLs by status and exporting/copying them.
* Extracted from the main app component to keep it focused on view concerns.
*/
@Injectable({ providedIn: 'root' })
export class BatchUrlsService {
private downloads = inject(DownloadsService);
private toasts = inject(ToastService);
collect(filter: BatchUrlFilter): string[] {
const queueUrls = () => Array.from(this.downloads.queue.values()).map((dl) => dl.url);
const doneUrls = (status?: string) =>
Array.from(this.downloads.done.values())
.filter((dl) => status === undefined || dl.status === status)
.map((dl) => dl.url);
switch (filter) {
case 'pending':
return queueUrls();
case 'completed':
return doneUrls('finished');
case 'failed':
return doneUrls('error');
default:
return [...queueUrls(), ...doneUrls()];
}
}
export(filter: BatchUrlFilter): void {
const urls = this.collect(filter);
if (!urls.length) {
this.toasts.info('No URLs found for the selected filter.');
return;
}
const blob = new Blob([urls.join('\n')], { type: 'text/plain' });
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = 'metube_urls.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
}
copy(filter: BatchUrlFilter): void {
const urls = this.collect(filter);
if (!urls.length) {
this.toasts.info('No URLs found for the selected filter.');
return;
}
navigator.clipboard
.writeText(urls.join('\n'))
.then(() => this.toasts.success('URLs copied to clipboard.'))
.catch(() => this.toasts.error('Failed to copy URLs.'));
}
}
+3 -1
View File
@@ -1,2 +1,4 @@
export { DownloadsService } from './downloads.service';
export { MeTubeSocket } from './metube-socket.service';
export { MeTubeSocket } from './metube-socket.service';
export { ToastService } from './toast.service';
export { BatchUrlsService } from './batch-urls.service';
+86
View File
@@ -0,0 +1,86 @@
import { Injectable, signal } from '@angular/core';
export type ToastLevel = 'info' | 'success' | 'error';
export interface ToastAction {
label: string;
value: boolean;
primary?: boolean;
}
export interface Toast {
id: number;
level: ToastLevel;
message: string;
actions?: ToastAction[];
/** Resolver for confirm() toasts; resolved when the user picks an action or dismisses. */
_resolve?: (value: boolean) => void;
}
/**
* Lightweight non-blocking notification service. Replaces the blocking
* window.alert()/confirm() dialogs that previously littered the app component.
*/
@Injectable({ providedIn: 'root' })
export class ToastService {
private counter = 0;
readonly toasts = signal<Toast[]>([]);
info(message: string): void {
this.show('info', message, 4000);
}
success(message: string): void {
this.show('success', message, 4000);
}
error(message: string): void {
this.show('error', message, 8000);
}
/**
* Show a confirmation toast with confirm/cancel actions. Resolves true when
* confirmed, false when cancelled or auto-dismissed.
*/
confirm(message: string, confirmLabel = 'OK', cancelLabel = 'Cancel'): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const id = ++this.counter;
this.toasts.update((list) => [
...list,
{
id,
level: 'info',
message,
actions: [
{ label: cancelLabel, value: false },
{ label: confirmLabel, value: true, primary: true },
],
_resolve: resolve,
},
]);
});
}
respond(id: number, value: boolean): void {
const toast = this.toasts().find((t) => t.id === id);
toast?._resolve?.(value);
this.remove(id);
}
dismiss(id: number): void {
const toast = this.toasts().find((t) => t.id === id);
// A confirm toast dismissed without an explicit choice resolves to false.
toast?._resolve?.(false);
this.remove(id);
}
private remove(id: number): void {
this.toasts.update((list) => list.filter((t) => t.id !== id));
}
private show(level: ToastLevel, message: string, autoDismissMs: number): void {
const id = ++this.counter;
this.toasts.update((list) => [...list, { id, level, message }]);
setTimeout(() => this.remove(id), autoDismissMs);
}
}