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
@@ -41,17 +41,42 @@
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="font-semibold text-slate-100">✨ Groupes suggérés</h3>
<p class="text-xs text-slate-400">Analyse des noms de chaînes (mots-clés FR/EN). Vérifiez avant d’appliquer.</p>
<p class="text-xs text-slate-400">Noms de chaînes + sujets détectés. Vérifiez avant d’appliquer.</p>
</div>
<div class="flex flex-wrap gap-2">
<button
*ngIf="!deepAnalyzing()"
type="button"
(click)="runDeepAnalysis()"
class="rounded-md border border-violet-500/50 bg-transparent px-3 py-1.5 text-sm text-violet-200 transition hover:bg-violet-600/20"
title="Lit les titres récents des chaînes sans groupe pour affiner les suggestions"
>
🔍 Analyse approfondie
</button>
<button
*ngIf="deepAnalyzing()"
type="button"
(click)="stopDeepAnalysis()"
class="rounded-md border border-slate-600 px-3 py-1.5 text-sm text-slate-300 transition hover:border-red-400 hover:text-red-200"
>
Arrêter
</button>
<button
*ngIf="suggestions().length > 1"
type="button"
(click)="applyAllSuggestions()"
[disabled]="suggestBusy()"
class="rounded-md bg-violet-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-violet-500 disabled:opacity-40"
>
{{ suggestBusy() ? 'Application…' : 'Tout appliquer' }}
</button>
</div>
</div>
<div *ngIf="deepProgress() as dp" class="mt-2 text-xs text-slate-300">
Analyse des chaînes sans groupe : {{ dp.done }}/{{ dp.total }}
<div class="mt-1 h-1.5 w-full max-w-md overflow-hidden rounded bg-slate-700">
<div class="h-full bg-violet-500 transition-all" [style.width.%]="dp.total ? (dp.done / dp.total * 100) : 0"></div>
</div>
<button
*ngIf="suggestions().length > 1"
type="button"
(click)="applyAllSuggestions()"
[disabled]="suggestBusy()"
class="rounded-md bg-violet-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-violet-500 disabled:opacity-40"
>
{{ suggestBusy() ? 'Application…' : 'Tout appliquer' }}
</button>
</div>
<div *ngIf="suggestions().length === 0" class="mt-3 rounded-lg border border-dashed border-slate-700 p-4 text-center text-sm text-slate-400">
Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé.
@@ -65,6 +90,7 @@
<span class="font-semibold text-slate-100">{{ s.name }}</span>
<span *ngIf="s.kind === 'create'" class="rounded-full bg-green-900/40 border border-green-700 px-2 py-0.5 text-[11px] text-green-200">Nouveau</span>
<span *ngIf="s.kind === 'complete'" class="rounded-full bg-sky-900/40 border border-sky-700 px-2 py-0.5 text-[11px] text-sky-200">Compléter « {{ s.existingGroupName }} »</span>
<span *ngIf="s.origin === 'topics'" class="rounded-full bg-amber-900/40 border border-amber-700 px-2 py-0.5 text-[11px] text-amber-200">Détecté</span>
<span class="text-xs text-slate-400">{{ s.channelIds.length }} chaîne(s)</span>
</div>
<p class="truncate text-xs text-slate-500">{{ s.sample.join(' • ') }}</p>
@@ -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> {