add subscriptions; change persistence file format to JSON (closes #901, #76, #113, #170, #242, #444, #503, #555, #566)

This commit is contained in:
Alex Shnitman
2026-04-01 14:18:18 +03:00
parent 84c6418f91
commit 483575d24a
20 changed files with 3168 additions and 744 deletions
+14 -14
View File
@@ -23,21 +23,21 @@
},
"private": true,
"dependencies": {
"@angular/animations": "^21.2.5",
"@angular/common": "^21.2.5",
"@angular/compiler": "^21.2.5",
"@angular/core": "^21.2.5",
"@angular/forms": "^21.2.5",
"@angular/platform-browser": "^21.2.5",
"@angular/platform-browser-dynamic": "^21.2.5",
"@angular/service-worker": "^21.2.5",
"@angular/animations": "^21.2.6",
"@angular/common": "^21.2.6",
"@angular/compiler": "^21.2.6",
"@angular/core": "^21.2.6",
"@angular/forms": "^21.2.6",
"@angular/platform-browser": "^21.2.6",
"@angular/platform-browser-dynamic": "^21.2.6",
"@angular/service-worker": "^21.2.6",
"@fortawesome/angular-fontawesome": "~4.0.0",
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-regular-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.5.2",
"@ng-select/ng-select": "^21.7.0",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
"ngx-cookie-service": "^21.3.1",
@@ -48,16 +48,16 @@
},
"devDependencies": {
"@angular-eslint/builder": "21.1.0",
"@angular/build": "^21.2.3",
"@angular/cli": "^21.2.3",
"@angular/compiler-cli": "^21.2.5",
"@angular/localize": "^21.2.5",
"@angular/build": "^21.2.5",
"@angular/cli": "^21.2.5",
"@angular/compiler-cli": "^21.2.6",
"@angular/localize": "^21.2.6",
"@eslint/js": "^9.39.4",
"angular-eslint": "21.1.0",
"eslint": "^9.39.4",
"jsdom": "^27.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "8.47.0",
"vitest": "^4.1.0"
"vitest": "^4.1.2"
}
}
+485 -474
View File
File diff suppressed because it is too large Load Diff
+200 -31
View File
@@ -89,15 +89,7 @@
<!-- 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">
<ng-template #urlBarActions>
@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>
@@ -115,13 +107,54 @@
title="Cancel adding URL">
<fa-icon [icon]="faTimesCircle" class="me-1" /> Cancel
</button>
} @else if (subscribeInProgress) {
<button class="btn btn-primary btn-lg px-4" type="button" disabled>
Download
</button>
<button class="btn btn-outline-secondary btn-lg px-3" type="button" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
Subscribing...
</button>
} @else {
<button class="btn btn-primary btn-lg px-4" type="submit"
(click)="addDownload()"
[disabled]="downloads.loading">
Download
</button>
<button class="btn btn-outline-secondary btn-lg px-3" type="button"
(click)="addSubscription()"
[disabled]="downloads.loading">
Subscribe
</button>
}
</ng-template>
<!-- Narrow viewports: full-width field, then Bootstrap btn-group (no faux input-group strip) -->
<div class="vstack gap-2 d-md-none">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<div class="btn-group w-100" role="group" aria-label="Download or subscribe">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
<!-- md and up: standard input-group so Bootstrap handles fused borders -->
<div class="input-group input-group-lg shadow-sm d-none d-md-flex">
<input type="text"
autocomplete="off"
spellcheck="false"
class="form-control form-control-lg"
placeholder="Enter video, channel, or playlist URL"
[(ngModel)]="addUrl"
[ngModelOptions]="{standalone: true}"
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
<ng-container [ngTemplateOutlet]="urlBarActions" />
</div>
</div>
</div>
@@ -136,7 +169,7 @@
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
@@ -150,7 +183,7 @@
name="codec"
[(ngModel)]="codec"
(change)="codecChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (vc of videoCodecs; track vc.id) {
<option [ngValue]="vc.id">{{ vc.text }}</option>
}
@@ -164,7 +197,7 @@
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
@@ -178,7 +211,7 @@
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading || !showQualitySelector()">
[disabled]="addInProgress || subscribeInProgress || downloads.loading || !showQualitySelector()">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
@@ -193,7 +226,7 @@
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
@@ -207,7 +240,7 @@
name="format"
[(ngModel)]="format"
(change)="formatChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
}
@@ -221,7 +254,7 @@
name="quality"
[(ngModel)]="quality"
(change)="qualityChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (q of qualities; track q.id) {
<option [ngValue]="q.id">{{ q.text }}</option>
}
@@ -229,28 +262,29 @@
</div>
</div>
} @else if (downloadType === 'captions') {
<div class="col-md-3">
<!-- 4× col-md-3 is too tight at ~768px (long addons wrap the 4th field); 2×2 mdlg, one row lg+ -->
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Type</span>
<select class="form-select"
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
</select>
</div>
</div>
<div class="col-md-3">
<div class="col-12 col-md-6 col-lg-3">
<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"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Subtitle output format for captions mode">
@for (f of formatOptions; track f.id) {
<option [ngValue]="f.id">{{ f.text }}</option>
@@ -258,7 +292,7 @@
</select>
</div>
</div>
<div class="col-md-3">
<div class="col-12 col-md-6 col-lg-3">
<div class="input-group">
<span class="input-group-text">Language</span>
<input class="form-control"
@@ -267,7 +301,7 @@
name="subtitleLanguage"
[(ngModel)]="subtitleLanguage"
(change)="subtitleLanguageChanged()"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
placeholder="e.g. en, es, zh-Hans"
ngbTooltip="Subtitle language (you can type any language code)">
<datalist id="subtitleLanguageOptions">
@@ -277,14 +311,14 @@
</datalist>
</div>
</div>
<div class="col-md-3">
<div class="col-12 col-md-6 col-lg-3">
<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"
[disabled]="addInProgress || subscribeInProgress || 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>
@@ -300,7 +334,7 @@
name="downloadType"
[(ngModel)]="downloadType"
(change)="downloadTypeChanged()"
[disabled]="addInProgress || downloads.loading">
[disabled]="addInProgress || subscribeInProgress || downloads.loading">
@for (type of downloadTypes; track type.id) {
<option [ngValue]="type.id">{{ type.text }}</option>
}
@@ -345,7 +379,7 @@
name="autoStart"
[(ngModel)]="autoStart"
(change)="autoStartChanged()"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Automatically start downloads when added">
<option [ngValue]="true">Yes</option>
<option [ngValue]="false">No</option>
@@ -362,7 +396,7 @@
addTagText="Create directory"
bindLabel="folder"
[(ngModel)]="folder"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
[virtualScroll]="true"
[clearable]="true"
[loading]="downloads.loading"
@@ -381,7 +415,7 @@
placeholder="Default"
name="customNamePrefix"
[(ngModel)]="customNamePrefix"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Add a prefix to downloaded filenames">
</div>
</div>
@@ -395,17 +429,31 @@
name="playlistItemLimit"
(keydown)="isNumber($event)"
[(ngModel)]="playlistItemLimit"
[disabled]="addInProgress || downloads.loading"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Maximum number of items to download from a playlist or channel (0 = no limit)">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Subscription Check (min)</span>
<input type="number"
min="1"
class="form-control"
name="checkIntervalMinutes"
(keydown)="isNumber($event)"
[(ngModel)]="checkIntervalMinutes"
(ngModelChange)="checkIntervalChanged()"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="How often to poll subscriptions for new videos">
</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"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Split video into separate files by chapters">
<label class="form-check-label" for="checkbox-split-chapters">Split by chapters</label>
</div>
@@ -415,7 +463,7 @@
<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"
(change)="chapterTemplateChanged()" [disabled]="addInProgress || subscribeInProgress || downloads.loading"
ngbTooltip="Output template for chapter files">
</div>
</div>
@@ -745,6 +793,127 @@
</tbody>
</table>
</div>
<div class="metube-section-header">Subscriptions</div>
<div class="px-2 py-3 border-bottom">
@if (checkingAllSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check all now
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkAllSubscriptions()"
[disabled]="downloads.loading || cachedSubs.length === 0 || checkingSelectedSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check all now
</button>
}
@if (checkingSelectedSubscriptions) {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4" disabled>
<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Check selected
</button>
} @else {
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="checkSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0 || checkingAllSubscriptions">
<fa-icon [icon]="faRedoAlt" />&nbsp; Check selected
</button>
}
<button type="button" class="btn btn-link text-decoration-none px-0 me-4"
(click)="deleteSelectedSubscriptions()"
[disabled]="downloads.loading || selectedSubscriptionIds.size === 0">
<fa-icon [icon]="faTrashAlt" />&nbsp; Delete selected
</button>
</div>
<div class="overflow-auto">
<table class="table">
<thead>
<tr>
<th scope="col" style="width: 1rem;">
<input type="checkbox" class="form-check-input"
[checked]="allSubsSelected()"
(change)="toggleSubMaster($event)"
[disabled]="downloads.loading || cachedSubs.length === 0"
aria-label="Select all subscriptions" />
</th>
<th scope="col">Name</th>
<th scope="col">URL</th>
<th scope="col" class="text-nowrap">Interval (min)</th>
<th scope="col" class="text-nowrap">Last checked</th>
<th scope="col">Status</th>
<th scope="col" style="width: 8rem;"></th>
</tr>
</thead>
<tbody>
@for (entry of cachedSubs; track entry[0]) {
<tr>
<td>
<input type="checkbox" class="form-check-input"
[checked]="isSubSelected(entry[0])"
(change)="toggleSubSelected(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Select subscription ' + entry[1].name" />
</td>
<td>{{ entry[1].name }}</td>
<td class="text-break"><a [href]="entry[1].url" target="_blank" rel="noopener">{{ entry[1].url }}</a></td>
<td>{{ entry[1].check_interval_minutes }}</td>
<td class="text-nowrap">
@if (entry[1].last_checked !== null) {
<span>{{ entry[1].last_checked! * 1000 | date:'yyyy-MM-dd HH:mm:ss' }}</span>
} @else {
<span class="text-muted"></span>
}
</td>
<td>
@if (entry[1].error) {
<span class="text-danger small">{{ entry[1].error }}</span>
} @else if (entry[1].enabled) {
<span class="text-success">Active</span>
} @else {
<span class="text-secondary">Paused</span>
}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
@if (isSubscriptionChecking(entry[0])) {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
disabled
[attr.aria-label]="'Checking ' + entry[1].name"
ngbTooltip="Checking now">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
} @else {
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="checkSubscriptionNow(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Check now ' + entry[1].name"
ngbTooltip="Check now">
<fa-icon [icon]="faRedoAlt" />
</button>
}
<button type="button" class="btn btn-link btn-sm p-0 me-2"
(click)="toggleSubscriptionEnabled(entry[1])"
[disabled]="downloads.loading"
[attr.aria-label]="(entry[1].enabled ? 'Pause ' : 'Resume ') + entry[1].name"
[ngbTooltip]="entry[1].enabled ? 'Pause' : 'Resume'">
@if (entry[1].enabled) {
<fa-icon [icon]="faPause" />
} @else {
<fa-icon [icon]="faPlay" />
}
</button>
<button type="button" class="btn btn-link btn-sm p-0 text-danger"
(click)="deleteSubscription(entry[0])"
[disabled]="downloads.loading"
[attr.aria-label]="'Delete subscription ' + entry[1].name">
<fa-icon [icon]="faTrashAlt" />
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</main><!-- /.container -->
<footer class="footer navbar-dark bg-dark py-3 mt-5">
+274 -10
View File
@@ -1,16 +1,18 @@
import { AsyncPipe, DatePipe, KeyValuePipe } from '@angular/common';
import { AsyncPipe, DatePipe, KeyValuePipe, NgTemplateOutlet } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, ElementRef, viewChild, inject, OnDestroy, OnInit } from '@angular/core';
import { Observable, map, distinctUntilChanged } from 'rxjs';
import { Observable, Subscription, map, distinctUntilChanged, finalize } from 'rxjs';
import { FormsModule } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { NgSelectModule } from '@ng-select/ng-select';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload } from '@fortawesome/free-solid-svg-icons';
import { faTrashAlt, faCheckCircle, faTimesCircle, faRedoAlt, faSun, faMoon, faCheck, faCircleHalfStroke, faDownload, faExternalLinkAlt, faFileImport, faFileExport, faCopy, faClock, faTachometerAlt, faSortAmountDown, faSortAmountUp, faChevronRight, faChevronDown, faUpload, faPause, faPlay } from '@fortawesome/free-solid-svg-icons';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
import { CookieService } from 'ngx-cookie-service';
import { AddDownloadPayload, DownloadsService } from './services/downloads.service';
import { SubscriptionsService } from './services/subscriptions.service';
import { SubscriptionRow } from './interfaces/subscription';
import { Themes } from './theme';
import {
Download,
@@ -36,6 +38,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
NgTemplateOutlet,
KeyValuePipe,
AsyncPipe,
DatePipe,
@@ -53,6 +56,7 @@ import { SelectAllCheckboxComponent, ItemCheckboxComponent } from './components/
})
export class App implements AfterViewInit, OnInit, OnDestroy {
downloads = inject(DownloadsService);
subscriptionsSvc = inject(SubscriptionsService);
private cookieService = inject(CookieService);
private http = inject(HttpClient);
private cdr = inject(ChangeDetectorRef);
@@ -81,6 +85,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
subtitleMode: string;
addInProgress = false;
cancelRequested = false;
subscribeInProgress = false;
checkIntervalMinutes = 60;
cachedSubs: [string, SubscriptionRow][] = [];
selectedSubscriptionIds = new Set<string>();
checkingSubscriptionIds = new Set<string>();
checkingAllSubscriptions = false;
checkingSelectedSubscriptions = false;
hasCookies = false;
cookieUploadInProgress = false;
themes: Theme[] = Themes;
@@ -101,6 +112,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
cachedSortedDone: [string, Download][] = [];
lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video';
private addRequestSub?: Subscription;
private selectionsByType: Record<string, {
codec: string;
format: string;
@@ -155,6 +167,8 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
faChevronRight = faChevronRight;
faChevronDown = faChevronDown;
faUpload = faUpload;
faPause = faPause;
faPlay = faPlay;
subtitleLanguages = [
{ id: 'en', text: 'English' },
{ id: 'ar', text: 'Arabic' },
@@ -238,6 +252,10 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.saveSelection(this.downloadType);
this.sortAscending = this.cookieService.get('metube_sort_ascending') === 'true';
const ci = parseInt(this.cookieService.get('metube_check_interval') || '', 10);
if (!Number.isNaN(ci) && ci >= 1) {
this.checkIntervalMinutes = ci;
}
this.activeTheme = this.getPreferredTheme(this.cookieService);
// Subscribe to download updates
@@ -255,6 +273,11 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.updateMetrics();
this.cdr.markForCheck();
});
this.subscriptionsSvc.subscriptionsChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.rebuildCachedSubs();
this.cdr.markForCheck();
});
}
ngOnInit() {
@@ -286,6 +309,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
ngOnDestroy() {
this.addRequestSub?.unsubscribe();
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
}
@@ -380,11 +404,239 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
if (!this.chapterTemplate) {
this.chapterTemplate = config['OUTPUT_TEMPLATE_CHAPTER'];
}
if (!this.cookieService.check('metube_check_interval')) {
const dci = parseInt(String(config['SUBSCRIPTION_DEFAULT_CHECK_INTERVAL'] ?? 60), 10);
if (!Number.isNaN(dci) && dci >= 1) {
this.checkIntervalMinutes = dci;
}
}
this.cdr.markForCheck();
}
});
}
private rebuildCachedSubs() {
this.cachedSubs = Array.from(this.subscriptionsSvc.subscriptions.entries());
const validIds = new Set(this.cachedSubs.map(([id]) => id));
for (const id of [...this.selectedSubscriptionIds]) {
if (!validIds.has(id)) {
this.selectedSubscriptionIds.delete(id);
}
}
}
checkIntervalChanged() {
this.cookieService.set('metube_check_interval', String(this.checkIntervalMinutes), {
expires: this.settingsCookieExpiryDays,
});
}
private getStatusError(res: unknown): string | null {
const status = res as { status?: string; msg?: string };
return status?.status === 'error' ? status.msg || null : null;
}
private refreshSubscriptionsWithAlert() {
this.subscriptionsSvc.refreshList().pipe(takeUntilDestroyed(this.destroyRef)).subscribe((refreshRes) => {
const error = this.getStatusError(refreshRes);
if (error) {
alert(error || 'Refresh subscriptions failed');
return;
}
this.cdr.markForCheck();
});
}
isSubSelected(id: string): boolean {
return this.selectedSubscriptionIds.has(id);
}
toggleSubSelected(id: string) {
if (this.selectedSubscriptionIds.has(id)) {
this.selectedSubscriptionIds.delete(id);
} else {
this.selectedSubscriptionIds.add(id);
}
this.cdr.markForCheck();
}
toggleSubMaster(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
this.selectedSubscriptionIds.clear();
if (checked) {
for (const [id] of this.cachedSubs) {
this.selectedSubscriptionIds.add(id);
}
}
this.cdr.markForCheck();
}
allSubsSelected(): boolean {
if (this.cachedSubs.length === 0) {
return false;
}
return this.cachedSubs.every(([id]) => this.selectedSubscriptionIds.has(id));
}
addSubscription() {
if (this.subscribeInProgress) {
return;
}
const payload = this.buildAddPayload();
if (!payload.url?.trim()) {
alert('Please enter a URL');
return;
}
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
return;
}
this.subscribeInProgress = true;
this.subscriptionsSvc
.subscribe({
...payload,
checkIntervalMinutes: this.checkIntervalMinutes,
})
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.subscribeInProgress = false;
this.cdr.markForCheck();
}),
)
.subscribe({
next: (res) => {
const r = res as { status?: string; msg?: string };
if (r.status === 'error') {
alert(r.msg || 'Subscribe failed');
} else {
this.addUrl = '';
}
},
});
}
deleteSubscription(id: string) {
this.subscriptionsSvc.delete([id]).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Delete subscription failed');
return;
}
this.selectedSubscriptionIds.delete(id);
this.cdr.markForCheck();
});
}
deleteSelectedSubscriptions() {
const ids = Array.from(this.selectedSubscriptionIds);
if (!ids.length) {
return;
}
this.subscriptionsSvc.delete(ids).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Delete subscriptions failed');
return;
}
this.selectedSubscriptionIds.clear();
this.cdr.markForCheck();
});
}
checkSubscriptionNow(id: string) {
if (this.checkingSubscriptionIds.has(id)) {
return;
}
this.checkingSubscriptionIds.add(id);
this.cdr.markForCheck();
this.subscriptionsSvc
.checkNow([id])
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
this.checkingSubscriptionIds.delete(id);
this.cdr.markForCheck();
}),
)
.subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Subscription check failed');
return;
}
this.refreshSubscriptionsWithAlert();
});
}
isSubscriptionChecking(id: string): boolean {
return this.checkingSubscriptionIds.has(id);
}
private runBulkSubscriptionCheck(ids: string[] | undefined, mode: 'all' | 'selected') {
const targetIds = ids ?? this.cachedSubs.filter(([, row]) => row.enabled).map(([id]) => id);
if (!targetIds.length) {
return;
}
const checkedIds = new Set(targetIds);
for (const id of checkedIds) {
this.checkingSubscriptionIds.add(id);
}
if (mode === 'all') {
this.checkingAllSubscriptions = true;
} else {
this.checkingSelectedSubscriptions = true;
}
this.cdr.markForCheck();
this.subscriptionsSvc
.checkNow(ids)
.pipe(
takeUntilDestroyed(this.destroyRef),
finalize(() => {
for (const id of checkedIds) {
this.checkingSubscriptionIds.delete(id);
}
if (mode === 'all') {
this.checkingAllSubscriptions = false;
} else {
this.checkingSelectedSubscriptions = false;
}
this.cdr.markForCheck();
}),
)
.subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Subscription check failed');
return;
}
this.refreshSubscriptionsWithAlert();
});
}
checkSelectedSubscriptions() {
const ids = Array.from(this.selectedSubscriptionIds);
if (!ids.length) {
return;
}
this.runBulkSubscriptionCheck(ids, 'selected');
}
checkAllSubscriptions() {
this.runBulkSubscriptionCheck(undefined, 'all');
}
toggleSubscriptionEnabled(row: SubscriptionRow) {
this.subscriptionsSvc.update(row.id, { enabled: !row.enabled }).subscribe((res) => {
const error = this.getStatusError(res);
if (error) {
alert(error || 'Update subscription failed');
}
});
}
getPreferredTheme(cookieService: CookieService) {
let theme = 'auto';
if (cookieService.check('metube_theme')) {
@@ -474,13 +726,13 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
}
queueSelectionChanged(checked: number) {
this.queueDelSelected().nativeElement.disabled = checked == 0;
this.queueDownloadSelected().nativeElement.disabled = checked == 0;
this.queueDelSelected().nativeElement.disabled = checked === 0;
this.queueDownloadSelected().nativeElement.disabled = checked === 0;
}
doneSelectionChanged(checked: number) {
this.doneDelSelected().nativeElement.disabled = checked == 0;
this.doneDownloadSelected().nativeElement.disabled = checked == 0;
this.doneDelSelected().nativeElement.disabled = checked === 0;
this.doneDownloadSelected().nativeElement.disabled = checked === 0;
}
private updateDoneActionButtons() {
@@ -657,26 +909,38 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
console.debug('Downloading:', payload);
this.addInProgress = true;
this.cancelRequested = false;
this.downloads.add(payload).subscribe((status: Status) => {
this.addRequestSub?.unsubscribe();
this.addRequestSub = this.downloads.add(payload).subscribe((status: Status) => {
if (status.status === 'error' && !this.cancelRequested) {
alert(`Error adding URL: ${status.msg}`);
} else if (status.status !== 'error') {
this.addUrl = '';
}
this.addInProgress = false;
this.cancelRequested = false;
this.resetAddState();
});
}
cancelAdding() {
this.cancelRequested = true;
this.downloads.cancelAdd().subscribe({
next: () => {
this.addRequestSub?.unsubscribe();
this.resetAddState();
},
error: (err) => {
this.cancelRequested = false;
console.error('Failed to cancel adding:', err?.message || err);
}
});
}
private resetAddState() {
this.addRequestSub = undefined;
this.addInProgress = false;
this.cancelRequested = false;
this.cdr.markForCheck();
}
downloadItemByKey(id: string) {
this.downloads.startById([id]).subscribe();
}
@@ -33,7 +33,7 @@ export class SelectAllCheckboxComponent {
return;
let checked = 0;
this.list().forEach(item => { if(item.checked) checked++ });
this.selected = checked > 0 && checked == this.list().size;
this.selected = checked > 0 && checked === this.list().size;
masterCheckbox.nativeElement.indeterminate = checked > 0 && checked < this.list().size;
this.changed.emit(checked);
}
+1 -1
View File
@@ -6,4 +6,4 @@ export * from './download';
export * from './checkable';
export * from './format';
export * from './formats';
export * from './subscription';
+15
View File
@@ -0,0 +1,15 @@
export interface SubscriptionRow {
id: string;
name: string;
url: string;
enabled: boolean;
check_interval_minutes: number;
download_type: string;
codec: string;
format: string;
quality: string;
folder: string;
last_checked: number | null;
seen_count: number;
error: string | null;
}
@@ -0,0 +1,128 @@
import { DestroyRef, inject, Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { of, Subject } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MeTubeSocket } from './metube-socket.service';
import { SubscriptionRow } from '../interfaces/subscription';
import { Status } from '../interfaces';
import { AddDownloadPayload } from './downloads.service';
export interface SubscribePayload extends AddDownloadPayload {
checkIntervalMinutes: number;
}
@Injectable({
providedIn: 'root',
})
export class SubscriptionsService {
private http = inject(HttpClient);
private socket = inject(MeTubeSocket);
private destroyRef = inject(DestroyRef);
subscriptions = new Map<string, SubscriptionRow>();
subscriptionsChanged = new Subject<void>();
private publishList(rows: SubscriptionRow[]) {
this.subscriptions.clear();
for (const row of rows) {
this.subscriptions.set(row.id, row);
}
this.subscriptionsChanged.next();
}
constructor() {
this.socket
.fromEvent('subscriptions_all')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const data: SubscriptionRow[] = JSON.parse(strdata);
this.publishList(data);
});
this.socket
.fromEvent('subscription_added')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const row: SubscriptionRow = JSON.parse(strdata);
this.subscriptions.set(row.id, row);
this.subscriptionsChanged.next();
});
this.socket
.fromEvent('subscription_updated')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const row: SubscriptionRow = JSON.parse(strdata);
this.subscriptions.set(row.id, row);
this.subscriptionsChanged.next();
});
this.socket
.fromEvent('subscription_removed')
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((strdata: string) => {
const id: string = JSON.parse(strdata);
this.subscriptions.delete(id);
this.subscriptionsChanged.next();
});
}
handleHTTPError(error: HttpErrorResponse) {
const msg =
error.error instanceof ErrorEvent
? error.error.message
: typeof error.error === 'string'
? error.error
: error.error?.msg || error.message || 'Request failed';
return of({ status: 'error' as const, msg });
}
subscribe(payload: SubscribePayload) {
return this.http
.post<Status>('subscribe', {
url: payload.url,
download_type: payload.downloadType,
codec: payload.codec,
quality: payload.quality,
format: payload.format,
folder: payload.folder,
custom_name_prefix: payload.customNamePrefix,
playlist_item_limit: payload.playlistItemLimit,
auto_start: payload.autoStart,
split_by_chapters: payload.splitByChapters,
chapter_template: payload.chapterTemplate,
subtitle_language: payload.subtitleLanguage,
subtitle_mode: payload.subtitleMode,
check_interval_minutes: payload.checkIntervalMinutes,
})
.pipe(catchError((err) => this.handleHTTPError(err)));
}
delete(ids: string[]) {
return this.http.post('subscriptions/delete', { ids }).pipe(catchError((err) => this.handleHTTPError(err)));
}
update(id: string, changes: Partial<Pick<SubscriptionRow, 'enabled' | 'check_interval_minutes' | 'name'>>) {
return this.http
.post('subscriptions/update', { id, ...changes })
.pipe(catchError((err) => this.handleHTTPError(err)));
}
checkNow(ids?: string[]) {
return this.http
.post('subscriptions/check', ids?.length ? { ids } : {})
.pipe(catchError((err) => this.handleHTTPError(err)));
}
fetchList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(catchError(() => of([])));
}
refreshList() {
return this.http.get<SubscriptionRow[]>('subscriptions').pipe(
tap((rows) => this.publishList(rows)),
catchError((err) => this.handleHTTPError(err)),
);
}
}