support live streams (closes #302, closes #752, closes #978)

This commit is contained in:
Alex Shnitman
2026-06-13 17:39:14 +03:00
parent 72d60ea55a
commit 5429200fba
6 changed files with 524 additions and 14 deletions
+19 -4
View File
@@ -706,16 +706,31 @@
</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 class="d-flex align-items-center flex-wrap gap-2">
<span>{{ download.value.title }}</span>
@if (download.value.live_status === 'is_live' && download.value.status !== 'scheduled') {
<span class="badge bg-danger">LIVE</span>
}
</div>
@if (download.value.status === 'scheduled') {
<span class="badge bg-warning text-dark">
<fa-icon [icon]="faClock" />
Waiting for stream
@if (liveCountdownSeconds(download.value); as secs) {
- starts in {{ secs | eta }}
}
</span>
} @else {
<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') {
@if (download.value.status === 'pending' || download.value.status === 'scheduled') {
<button type="button" class="btn btn-link" [attr.aria-label]="'Start download for ' + download.value.title" (click)="downloadItemByKey(download.key)"><fa-icon [icon]="faDownload" /></button>
}
<button type="button" class="btn btn-link" [attr.aria-label]="'Remove ' + download.value.title + ' from queue'" (click)="delDownload('queue', download.key)"><fa-icon [icon]="faTrashAlt" /></button>
+31
View File
@@ -182,6 +182,37 @@ describe('App', () => {
expect(payload.ytdlOptionsOverrides).toBe('');
});
it('shows waiting badge for scheduled live stream', () => {
downloads.queue.set('https://example.com/live', {
id: 'live1',
title: 'Upcoming Stream',
url: 'https://example.com/live',
download_type: 'video',
quality: 'best',
format: 'any',
folder: '',
custom_name_prefix: '',
playlist_item_limit: 0,
status: 'scheduled',
live_status: 'is_upcoming',
live_release_timestamp: Date.now() / 1000 + 3600,
msg: '',
percent: 0,
speed: 0,
eta: 0,
filename: '',
checked: false,
});
downloads.queueChanged.next();
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const root = fixture.nativeElement as HTMLElement;
expect(root.textContent).toContain('Waiting for stream');
expect(root.textContent).toContain('starts in');
});
it('includes titleRegex in subscribe payload', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
+27 -1
View File
@@ -132,6 +132,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
lastCopiedErrorId: string | null = null;
private previousDownloadType = 'video';
private addRequestSub?: Subscription;
private liveCountdownTimer?: ReturnType<typeof setInterval>;
private selectionsByType: Record<string, {
codec: string;
format: string;
@@ -285,6 +286,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
// Subscribe to download updates
this.downloads.queueChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.updateMetrics();
this.syncLiveCountdownTimer();
this.cdr.markForCheck();
});
this.downloads.doneChanged.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
@@ -295,6 +297,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
// Subscribe to real-time updates
this.downloads.updated.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.updateMetrics();
this.syncLiveCountdownTimer();
this.cdr.markForCheck();
});
@@ -337,6 +340,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
ngOnDestroy() {
this.addRequestSub?.unsubscribe();
if (this.liveCountdownTimer) {
clearInterval(this.liveCountdownTimer);
}
this.colorSchemeMediaQuery.removeEventListener('change', this.onColorSchemeChanged);
}
@@ -1106,6 +1112,26 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
this.downloads.startById([id]).subscribe();
}
liveCountdownSeconds(download: Download): number | null {
const ts = download.live_release_timestamp;
if (ts == null || download.status !== 'scheduled') {
return null;
}
return Math.max(0, ts - Date.now() / 1000);
}
private syncLiveCountdownTimer() {
const hasScheduled = Array.from(this.downloads.queue.values()).some(
(download) => download.status === 'scheduled',
);
if (hasScheduled && !this.liveCountdownTimer) {
this.liveCountdownTimer = setInterval(() => this.cdr.markForCheck(), 1000);
} else if (!hasScheduled && this.liveCountdownTimer) {
clearInterval(this.liveCountdownTimer);
this.liveCountdownTimer = undefined;
}
}
retryDownload(key: string, download: Download) {
this.addDownload({
url: download.url,
@@ -1631,7 +1657,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
speed += download.speed || 0;
} else if (download.status === 'preparing') {
active++;
} else if (download.status === 'pending') {
} else if (download.status === 'pending' || download.status === 'scheduled') {
queued++;
}
});
+2
View File
@@ -18,6 +18,8 @@ export interface Download {
ytdl_options_overrides?: Record<string, unknown>;
clip_start?: number;
clip_end?: number;
live_status?: string;
live_release_timestamp?: number;
status: string;
msg: string;
percent: number;