feat(groups): sujets émergents + analyse approfondie des chaînes

This commit is contained in:
2026-09-27 13:30:11 -04:00
parent dde0747ba3
commit 9b620aaab6
3 changed files with 204 additions and 23 deletions
@@ -2,11 +2,15 @@ import { ChangeDetectionStrategy, Component, computed, HostListener, inject, sig
import { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import {
GROUP_COLORS,
GROUP_ICONS,
GROUP_ICON_KEYWORDS,
detectEmergentTopics,
normalizeChannelText,
suggestGroupsForChannels,
SubscriptionsService,
type GroupSuggestion,
@@ -93,17 +97,91 @@ export class SubscriptionsComponent {
showSuggestions = signal<boolean>(false);
suggestBusy = signal<boolean>(false);
readonly suggestions = computed<GroupSuggestion[]>(() =>
suggestGroupsForChannels(
this.items().map((item) => ({
subscriptionId: item.subscriptionId,
title: item.channel?.title || '',
handle: item.channel?.handle || null,
groupIds: this.itemGroupIds(item),
})),
this.groups().map((g) => ({ id: g.id, name: g.name })),
)
);
// --- Analyse approfondie : titres des dernières vidéos par chaîne ---
private http = inject(HttpClient);
private deepCancel = false;
deepAnalyzing = signal<boolean>(false);
deepProgress = signal<{ done: number; total: number } | null>(null);
private deepTexts = signal<Record<number, string>>({});
private suggestionBaseItems() {
const extra = this.deepTexts();
return this.items().map((item) => ({
subscriptionId: item.subscriptionId,
title: item.channel?.title || '',
handle: item.channel?.handle || null,
groupIds: this.itemGroupIds(item),
extra: extra[item.subscriptionId] || '',
}));
}
readonly suggestions = computed<GroupSuggestion[]>(() => {
const items = this.suggestionBaseItems();
const groups = this.groups().map((g) => ({ id: g.id, name: g.name }));
const rules = suggestGroupsForChannels(items, groups);
const taken = new Set(rules.map((r) => normalizeChannelText(r.name).trim()));
const topics = detectEmergentTopics(items, {
minSize: 2,
maxGroups: 10,
excludeNames: [...taken, ...groups.map((g) => g.name)],
});
return [...rules, ...topics].sort((a, b) => b.channelIds.length - a.channelIds.length);
});
/** Analyse approfondie : récupère les titres récents des chaînes non groupées. */
async runDeepAnalysis(): Promise<void> {
if (this.deepAnalyzing()) return;
const targets = this.items().filter((item) => {
if (this.deepTexts()[item.subscriptionId] !== undefined) return false;
if (!item.channel?.provider || !item.channel?.externalId) return false;
return (this.itemGroupIds(item).length === 0);
});
if (!targets.length) return;
this.deepCancel = false;
this.deepAnalyzing.set(true);
this.deepProgress.set({ done: 0, total: targets.length });
const texts: Record<number, string> = { ...this.deepTexts() };
const counter = { done: 0 };
const queue = [...targets];
const workers = Array.from({ length: 4 }, () => this.deepWorker(queue, texts, counter, targets.length));
await Promise.all(workers);
this.deepTexts.set(texts);
this.deepProgress.set(null);
this.deepAnalyzing.set(false);
}
stopDeepAnalysis(): void {
this.deepCancel = true;
}
private async deepWorker(
queue: SubscriptionItem[],
texts: Record<number, string>,
counter: { done: number },
total: number,
): Promise<void> {
while (queue.length && !this.deepCancel) {
const item = queue.shift();
if (!item?.channel) continue;
try {
const res: any = await firstValueFrom(
this.http.get(
`/api/channels/${encodeURIComponent(item.channel.provider)}/${encodeURIComponent(item.channel.externalId)}/content`,
{ params: { type: 'videos', limit: '5' } },
),
);
const titles = ((res?.items || []) as any[])
.map((v: any) => String(v?.title || ''))
.filter(Boolean)
.slice(0, 5);
texts[item.subscriptionId] = titles.join(' ');
} catch {
texts[item.subscriptionId] = '';
}
counter.done++;
this.deepProgress.set({ done: Math.min(counter.done, total), total });
}
}
/** Crée le groupe suggéré (ou complète l'existant) et y assigne les chaînes. */
applySuggestion(s: GroupSuggestion): Promise<void> {