title filter for subscriptions (closes #968)

This commit is contained in:
Alex Shnitman
2026-04-26 22:51:48 +03:00
parent d89a5ddbe5
commit 91ee8312bf
8 changed files with 470 additions and 8 deletions
+40
View File
@@ -475,6 +475,18 @@
ngbTooltip="How often to poll subscriptions for new videos">
</div>
</div>
<div class="col-md-6">
<div class="input-group">
<span class="input-group-text">Subscription Title Filter</span>
<input type="text"
class="form-control"
name="titleRegex"
[(ngModel)]="titleRegex"
[disabled]="addInProgress || subscribeInProgress || downloads.loading"
placeholder="Optional regex"
ngbTooltip="In subscriptions, only titles matching this Python-style regex are queued. Empty = all. Case-sensitive; use (?i) in the pattern for case-insensitive.">
</div>
</div>
</div>
<!-- yt-dlp -->
@@ -887,6 +899,8 @@
</th>
<th scope="col">Name</th>
<th scope="col">URL</th>
<th scope="col" class="text-nowrap"
ngbTooltip="Subscriptions only — which new video titles to queue when this feed is checked. Does not affect manual downloads.">Sub. title filter</th>
<th scope="col" class="text-nowrap">Interval (min)</th>
<th scope="col" class="text-nowrap">Last checked</th>
<th scope="col">Status</th>
@@ -905,6 +919,32 @@
</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>
@if (editingTitleRegexId === entry[0]) {
<div class="d-flex flex-wrap gap-1 align-items-center">
<input type="text"
class="form-control form-control-sm flex-grow-1"
[name]="'subTitleRegex' + entry[0]"
[(ngModel)]="titleRegexEditDraft"
[disabled]="downloads.loading" />
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="saveTitleRegex(entry[0])"
[disabled]="downloads.loading">Save</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
(click)="cancelEditTitleRegex()"
[disabled]="downloads.loading">Cancel</button>
</div>
} @else {
<div class="d-flex flex-wrap gap-1 align-items-center">
<span class="text-muted small text-break"
[class.text-secondary]="!entry[1].title_regex">{{ entry[1].title_regex || '—' }}</span>
<button type="button" class="btn btn-link btn-sm p-0"
(click)="beginEditTitleRegex(entry[0], entry[1].title_regex)"
[disabled]="downloads.loading"
ngbTooltip="Edit subscription title filter (subscriptions only; not for one-off downloads)">Edit</button>
</div>
}
</td>
<td>{{ entry[1].check_interval_minutes }}</td>
<td class="text-nowrap">
@if (entry[1].last_checked !== null) {
+32 -1
View File
@@ -63,8 +63,10 @@ class DownloadsServiceStub {
class SubscriptionsServiceStub {
subscriptions = new Map();
subscriptionsChanged = new Subject<void>();
subscribeCalls: unknown[] = [];
subscribe() {
subscribe(payload: unknown) {
this.subscribeCalls.push(payload);
return of({ status: 'ok' as const });
}
@@ -72,6 +74,10 @@ class SubscriptionsServiceStub {
return of({});
}
update() {
return of({ status: 'ok' as const });
}
refreshList() {
return of([]);
}
@@ -175,4 +181,29 @@ describe('App', () => {
expect(payload.ytdlOptionsOverrides).toBe('');
});
it('includes titleRegex in subscribe payload', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.titleRegex = 'EPISODE';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(1);
const payload = subs.subscribeCalls[0] as { titleRegex: string };
expect(payload.titleRegex).toBe('EPISODE');
});
it('blocks subscribe with invalid title regex', () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => undefined);
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
const subs = TestBed.inject(SubscriptionsService) as unknown as SubscriptionsServiceStub;
app.addUrl = 'https://example.com/channel';
app.titleRegex = '[';
app.addSubscription();
expect(subs.subscribeCalls.length).toBe(0);
expect(alertSpy).toHaveBeenCalledWith('Invalid subscription title filter (regex)');
alertSpy.mockRestore();
});
});
+46
View File
@@ -90,6 +90,9 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
cancelRequested = false;
subscribeInProgress = false;
checkIntervalMinutes = 60;
titleRegex = '';
editingTitleRegexId: string | null = null;
titleRegexEditDraft = '';
cachedSubs: [string, SubscriptionRow][] = [];
selectedSubscriptionIds = new Set<string>();
checkingSubscriptionIds = new Set<string>();
@@ -560,6 +563,15 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert('Please enter a URL');
return;
}
const tr = (this.titleRegex || '').trim();
if (tr) {
try {
void RegExp(tr);
} catch {
alert('Invalid subscription title filter (regex)');
return;
}
}
if (payload.splitByChapters && !payload.chapterTemplate.includes('%(section_number)')) {
alert('Chapter template must include %(section_number)');
return;
@@ -572,6 +584,7 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
.subscribe({
...payload,
checkIntervalMinutes: this.checkIntervalMinutes,
titleRegex: tr,
})
.pipe(
takeUntilDestroyed(this.destroyRef),
@@ -587,11 +600,44 @@ export class App implements AfterViewInit, OnInit, OnDestroy {
alert(r.msg || 'Subscribe failed');
} else {
this.addUrl = '';
this.titleRegex = '';
}
},
});
}
beginEditTitleRegex(id: string, current: string | undefined) {
this.editingTitleRegexId = id;
this.titleRegexEditDraft = current ?? '';
this.cdr.markForCheck();
}
cancelEditTitleRegex() {
this.editingTitleRegexId = null;
this.titleRegexEditDraft = '';
this.cdr.markForCheck();
}
saveTitleRegex(id: string) {
const raw = (this.titleRegexEditDraft || '').trim();
if (raw) {
try {
void RegExp(raw);
} catch {
alert('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');
return;
}
this.cancelEditTitleRegex();
});
}
deleteSubscription(id: string) {
this.subscriptionsSvc.delete([id]).subscribe((res) => {
const error = this.getStatusError(res);
+1
View File
@@ -9,6 +9,7 @@ export interface SubscriptionRow {
format: string;
quality: string;
folder: string;
title_regex?: string;
last_checked: number | null;
seen_count: number;
error: string | null;
+6 -1
View File
@@ -10,6 +10,7 @@ import { AddDownloadPayload } from './downloads.service';
export interface SubscribePayload extends AddDownloadPayload {
checkIntervalMinutes: number;
titleRegex: string;
}
@Injectable({
@@ -97,6 +98,7 @@ export class SubscriptionsService {
ytdl_options_presets: payload.ytdlOptionsPresets,
ytdl_options_overrides: payload.ytdlOptionsOverrides,
check_interval_minutes: payload.checkIntervalMinutes,
title_regex: payload.titleRegex,
})
.pipe(catchError((err) => this.handleHTTPError(err)));
}
@@ -105,7 +107,10 @@ export class SubscriptionsService {
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'>>) {
update(
id: string,
changes: Partial<Pick<SubscriptionRow, 'enabled' | 'check_interval_minutes' | 'name' | 'title_regex'>>,
) {
return this.http
.post('subscriptions/update', { id, ...changes })
.pipe(catchError((err) => this.handleHTTPError(err)));