From 9b620aaab69cc22df98dbb63cdaf86fd5b6a28a1 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 27 Sep 2026 13:30:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(groups):=20sujets=20=C3=A9mergents=20+=20a?= =?UTF-8?q?nalyse=20approfondie=20des=20cha=C3=AEnes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../subscriptions.component.html | 46 ++++++-- .../subscriptions/subscriptions.component.ts | 100 ++++++++++++++++-- src/services/subscriptions.service.ts | 81 +++++++++++++- 3 files changed, 204 insertions(+), 23 deletions(-) diff --git a/src/components/library/subscriptions/subscriptions.component.html b/src/components/library/subscriptions/subscriptions.component.html index d5574d7..9019ad0 100644 --- a/src/components/library/subscriptions/subscriptions.component.html +++ b/src/components/library/subscriptions/subscriptions.component.html @@ -41,17 +41,42 @@

✨ Groupes suggérés

-

Analyse des noms de chaînes (mots-clés FR/EN). Vérifiez avant d’appliquer.

+

Noms de chaînes + sujets détectés. Vérifiez avant d’appliquer.

+
+
+ + + +
+
+
+ Analyse des chaînes sans groupe : {{ dp.done }}/{{ dp.total }} +
+
-
Aucune suggestion : chaque thème a moins de 2 chaînes, ou tout est déjà rangé. @@ -65,6 +90,7 @@ {{ s.name }} Nouveau Compléter « {{ s.existingGroupName }} » + Détecté {{ s.channelIds.length }} chaîne(s)

{{ s.sample.join(' • ') }}

diff --git a/src/components/library/subscriptions/subscriptions.component.ts b/src/components/library/subscriptions/subscriptions.component.ts index cd6f6d2..66b0215 100644 --- a/src/components/library/subscriptions/subscriptions.component.ts +++ b/src/components/library/subscriptions/subscriptions.component.ts @@ -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(false); suggestBusy = signal(false); - readonly suggestions = computed(() => - 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(false); + deepProgress = signal<{ done: number; total: number } | null>(null); + private deepTexts = signal>({}); + + 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(() => { + 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 { + 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 = { ...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, + counter: { done: number }, + total: number, + ): Promise { + 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 { diff --git a/src/services/subscriptions.service.ts b/src/services/subscriptions.service.ts index 2bf6a18..e935e33 100644 --- a/src/services/subscriptions.service.ts +++ b/src/services/subscriptions.service.ts @@ -173,6 +173,8 @@ export const GROUP_ICON_KEYWORDS: Record = { /** Suggestion de rangement : créer un groupe ou compléter un groupe existant. */ export interface GroupSuggestion { kind: 'create' | 'complete'; + /** 'rules' = règles mot-clé, 'topics' = sujet émergent détecté. */ + origin: 'rules' | 'topics'; name: string; icon: string; color: string; @@ -221,12 +223,85 @@ export function normalizeChannelText(value: string | null | undefined): string { .toLowerCase(); } +/** Mots vides FR/EN + termes YouTube génériques (évitent les méga-groupes inutiles). */ +const TOPIC_STOPWORDS = new Set([ + 'avec', 'dans', 'pour', 'sans', 'sous', 'vers', 'chez', 'entre', 'contre', 'depuis', + 'cette', 'celui', 'celle', 'ceux', 'celles', 'notre', 'votre', 'leur', 'leurs', 'tous', + 'toute', 'toutes', 'aussi', 'encore', 'tres', 'bien', 'plus', 'moins', 'comme', 'mais', + 'donc', 'alors', 'quand', 'comment', 'pourquoi', 'parce', 'sont', 'etes', 'font', 'vont', + 'cest', 'nest', 'plus', 'tout', 'tous', 'aux', 'ces', 'ses', 'mon', 'mes', 'ton', 'tes', + 'son', 'nous', 'vous', 'ils', 'elles', 'elle', 'celui', 'dont', 'ou', 'parce', + 'the', 'and', 'for', 'with', 'from', 'your', 'you', 'your', 'this', 'that', 'these', + 'those', 'are', 'was', 'were', 'been', 'have', 'has', 'will', 'would', 'there', + 'their', 'what', 'when', 'where', 'which', 'while', 'about', 'into', 'over', + 'after', 'before', 'your', 'videos', 'video', 'tube', 'youtube', 'channel', 'chaine', + 'chanel', 'official', 'officiel', 'tv', 'media', 'network', 'studio', 'production', + 'streaming', 'live', 'clips', 'shorts', 'france', 'francais', 'quebec', 'canada', + 'montreal', 'paris', 'best', 'top', 'new', 'show', 'central', 'daily', 'hub', 'one', +]); + +/** Devine une icône pour un mot (recherche inverse dans les mots-clés). */ +export function guessIconForWord(word: string): string { + const w = normalizeChannelText(word).trim(); + if (!w) return '📁'; + for (const [emoji, kws] of Object.entries(GROUP_ICON_KEYWORDS)) { + if (String(kws).toLowerCase().split(/\s+/).includes(w)) return emoji; + } + return '📁'; +} + +/** + * Sujets émergents : mots significatifs partagés par ≥2 chaînes (titres, + * handles, + texte enrichi de l'analyse approfondie). Attrape ce que les + * règles ratent (noms de marques, communautés, sujets de niche). + * Pur et testable : ne touche pas au réseau. + */ +export function detectEmergentTopics( + items: Array<{ subscriptionId: number; title: string; handle?: string | null; extra?: string }>, + opts?: { minSize?: number; maxGroups?: number; excludeNames?: string[] }, +): GroupSuggestion[] { + const minSize = Math.max(2, opts?.minSize || 2); + const maxGroups = Math.max(1, opts?.maxGroups || 10); + const excluded = new Set((opts?.excludeNames || []).map((n) => normalizeChannelText(n).trim())); + const byWord = new Map(); + for (const item of items) { + const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''} ${item.extra || ''}`); + const words = new Set( + hay.split(/[^a-z0-9]+/).filter((w) => w.length >= 4 && !/^\d+$/.test(w) && !TOPIC_STOPWORDS.has(w)), + ); + for (const w of words) { + const list = byWord.get(w) || []; + list.push(item); + byWord.set(w, list); + } + } + const out: GroupSuggestion[] = []; + const sorted = [...byWord.entries()] + .filter(([, list]) => list.length >= minSize) + .sort((a, b) => b[1].length - a[1].length) + .slice(0, maxGroups); + sorted.forEach(([word, list], idx) => { + const name = word.charAt(0).toUpperCase() + word.slice(1); + if (excluded.has(normalizeChannelText(name))) return; + out.push({ + kind: 'create', + origin: 'topics', + name, + icon: guessIconForWord(word), + color: GROUP_COLORS[(idx * 5 + 3) % GROUP_COLORS.length], + channelIds: list.map((m) => m.subscriptionId), + sample: list.slice(0, 3).map((m) => m.title).filter(Boolean), + }); + }); + return out; +} + /** * Analyse les chaînes et propose des groupes (création ou complétion). * Pur et testable : ne touche pas au réseau. */ export function suggestGroupsForChannels( - items: Array<{ subscriptionId: number; title: string; handle?: string | null; groupIds: string[] }>, + items: Array<{ subscriptionId: number; title: string; handle?: string | null; groupIds: string[]; extra?: string }>, existingGroups: Array<{ id: string; name: string }>, ): GroupSuggestion[] { const byRule = new Map(); @@ -237,7 +312,7 @@ export function suggestGroupsForChannels( return kw.length <= 4 ? new RegExp(`\\b${esc}(s|x)?\\b`) : new RegExp(`\\b${esc}\\w*`); }; for (const item of items) { - const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''}`); + const hay = normalizeChannelText(`${item.title || ''} ${item.handle || ''} ${item.extra || ''}`); if (!hay.trim()) continue; let best = -1; let bestScore = 0; @@ -268,6 +343,7 @@ export function suggestGroupsForChannels( if (!missing.length) continue; suggestions.push({ kind: 'complete', + origin: 'rules', name: existing.name, icon: rule.icon, color: GROUP_COLORS[rule.color % GROUP_COLORS.length], @@ -279,6 +355,7 @@ export function suggestGroupsForChannels( } else { suggestions.push({ kind: 'create', + origin: 'rules', name: rule.name, icon: rule.icon, color: GROUP_COLORS[rule.color % GROUP_COLORS.length],