import { ChangeDetectionStrategy, Component, computed, HostListener, inject, signal } from '@angular/core'; 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, type SubscriptionGroup, type SubscriptionItem, } from '../../../services/subscriptions.service'; import type { ProviderId } from '../../../app/shared/models/channel.model'; import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component'; import { SubscribeButtonComponent } from '../../../app/shared/components/subscribe-button/subscribe-button.component'; interface FilterChip { id: 'all' | ProviderId; label: string; } type SortKey = 'alpha' | 'recent' | 'subs'; type TabId = 'groups' | 'channels'; @Component({ selector: 'app-library-subscriptions', standalone: true, imports: [CommonModule, DatePipe, DecimalPipe, FormsModule, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent], templateUrl: './subscriptions.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class SubscriptionsComponent { private subs = inject(SubscriptionsService); private router = inject(Router); private route = inject(ActivatedRoute); filters: FilterChip[] = [ { id: 'all', label: 'Tous' }, { id: 'yt', label: 'YouTube' }, { id: 'dm', label: 'Dailymotion' }, { id: 'tw', label: 'Twitch' }, { id: 'pt', label: 'PeerTube' }, { id: 'od', label: 'Odysee' }, { id: 'ru', label: 'Rumble' }, ]; sorts: { key: SortKey; label: string }[] = [ { key: 'alpha', label: 'A → Z' }, { key: 'recent', label: 'Récents' }, { key: 'subs', label: 'Abonnés' }, ]; readonly colors = GROUP_COLORS; readonly icons = GROUP_ICONS; activeTab = signal('groups'); activeFilter = signal('all'); activeSort = signal('alpha'); activeGroupId = signal('all'); searchQuery = signal(''); selectedIds = signal([]); // --- Modale groupe --- showGroupModal = signal(false); editingGroup = signal(null); groupName = signal(''); groupColor = signal(GROUP_COLORS[0]); groupIcon = signal(GROUP_ICONS[0]); groupSaving = signal(false); iconSearch = signal(''); /** Icônes filtrées par la recherche (emoji ou mot-clé FR/EN). */ readonly filteredIcons = computed(() => { const q = this.iconSearch().trim().toLowerCase(); if (!q) return this.icons; return this.icons.filter((emoji) => { if (emoji.includes(q)) return true; const kw = (GROUP_ICON_KEYWORDS[emoji] || '').toLowerCase(); return kw.split(/\s+/).some((w) => w.startsWith(q)) || kw.includes(q); }); }); // --- Assigneur de groupes (par ligne) --- pickerFor = signal(null); // --- Assignation en lot --- bulkGroupId = signal(''); // --- Suggestions auto de groupes (analyse des chaînes) --- showSuggestions = signal(false); suggestBusy = signal(false); // --- 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 static readonly DEEP_TEXTS_KEY = 'newtube.deepTexts.v1'; private deepTexts = signal>((() => { try { const raw = localStorage.getItem(SubscriptionsComponent.DEEP_TEXTS_KEY); const parsed = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === 'object' ? parsed : {}; } catch { return {}; } })()); private persistDeepTexts(): void { try { localStorage.setItem(SubscriptionsComponent.DEEP_TEXTS_KEY, JSON.stringify(this.deepTexts())); } catch {} } 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.persistDeepTexts(); 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) : 2 requêtes max, pas de tempête. */ applySuggestion(s: GroupSuggestion): Promise { if (this.suggestBusy()) return Promise.resolve(); this.suggestBusy.set(true); const done = () => this.suggestBusy.set(false); const run = (gid: string): Promise => new Promise((resolve) => { this.subs.setGroupMembers(gid, this.memberIdsOf(gid, s)).subscribe({ next: () => resolve(), error: () => resolve() }); }); if (s.kind === 'complete' && s.existingGroupId) { return run(s.existingGroupId).finally(done); } return new Promise((resolve) => { this.subs.createGroup(s.name, s.color, s.icon).subscribe((g) => { if (g?.id) run(g.id).then(() => resolve()); else resolve(); }); }).finally(done); } /** Ids déjà dans le groupe + nouveaux (union), pour l'assignation en lot. */ private memberIdsOf(groupId: string, s: GroupSuggestion): number[] { const members = this.members(); const current = Object.keys(members) .filter((k) => (members[k] || []).includes(groupId)) .map(Number); return [...new Set([...current, ...s.channelIds])]; } /** Applique toutes les suggestions d'un coup, dans l'ordre. */ async applyAllSuggestions(): Promise { if (this.suggestBusy()) return; for (const s of this.suggestions()) { await this.applySuggestion(s); } } copiedUngrouped = signal(false); /** Copie les chaînes sans groupe (une par ligne) pour analyse des règles. */ copyUngrouped(): void { const lines = this.items() .filter((item) => this.itemGroupIds(item).length === 0) .map((item) => item.channel?.title || item.channel?.handle || `#${item.subscriptionId}`); const text = lines.join('\n'); if (!text) return; const done = () => { this.copiedUngrouped.set(true); setTimeout(() => this.copiedUngrouped.set(false), 2000); }; try { const nav = navigator as Navigator & { clipboard?: { writeText(t: string): Promise } }; if (nav?.clipboard?.writeText) { nav.clipboard.writeText(text).then(done, () => this.copyUngroupedFallback(text, done)); } else { this.copyUngroupedFallback(text, done); } } catch { this.copyUngroupedFallback(text, done); } } private copyUngroupedFallback(text: string, done: () => void): void { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done(); } catch {} } // --- trackBy : DOM stable (pas de re-rendu complet + recharge d'images à chaque tick) --- trackBySubscriptionId(_idx: number, item: SubscriptionItem): number { return item.subscriptionId; } trackByGroupId(_idx: number, group: SubscriptionGroup): string { return group.id; } trackBySuggestion(_idx: number, s: GroupSuggestion): string { return `${s.kind}:${s.existingGroupId || s.name}`; } readonly items = computed(() => this.subs.items()); readonly groups = computed(() => this.subs.subscriptionGroups()); readonly members = computed(() => this.subs.groupMembers()); readonly hasCache = computed(() => this.items().length > 0); readonly selectedCount = computed(() => this.selectedIds().length); readonly ungroupedCount = computed(() => { const members = this.members(); return this.items().filter((item) => (members[String(item.subscriptionId)] || []).length === 0).length; }); readonly filteredItems = computed(() => this.applyFilters(this.items())); constructor() { this.refresh(); this.subs.listGroups().subscribe(); this.route.queryParamMap.subscribe((params) => { const group = params.get('group'); const tab = params.get('tab'); if (group) { this.activeGroupId.set(group); this.activeTab.set('channels'); } if (tab === 'groups' || tab === 'channels') this.activeTab.set(tab); }); } refresh(): void { this.subs.list().subscribe(); this.subs.listGroups().subscribe(); } setTab(tab: TabId): void { this.activeTab.set(tab); } setFilter(id: FilterChip['id']): void { this.activeFilter.set(id); } setSort(key: SortKey): void { this.activeSort.set(key); } setGroupFilter(id: string): void { this.activeGroupId.set(id); this.selectedIds.set([]); } openGroupChannel(group: SubscriptionGroup): void { this.activeGroupId.set(group.id); this.activeTab.set('channels'); } openChannel(item: SubscriptionItem): void { if (!item?.channel?.provider || !item?.channel?.externalId) { return; } this.router.navigate(['/channel', item.channel.provider, item.channel.externalId]); } // -------------------- Modale groupe -------------------- openNewGroup(): void { this.editingGroup.set(null); this.groupName.set(''); this.groupColor.set(GROUP_COLORS[this.groups().length % GROUP_COLORS.length]); this.groupIcon.set(GROUP_ICONS[this.groups().length % GROUP_ICONS.length]); this.iconSearch.set(''); this.showGroupModal.set(true); } openEditGroup(group: SubscriptionGroup, event?: Event): void { event?.stopPropagation(); this.editingGroup.set(group); this.groupName.set(group.name); this.groupColor.set(group.color); this.groupIcon.set(group.icon); this.iconSearch.set(''); this.showGroupModal.set(true); } closeGroupModal(): void { this.showGroupModal.set(false); this.editingGroup.set(null); this.groupSaving.set(false); } saveGroup(): void { const name = this.groupName().trim(); if (!name || this.groupSaving()) return; this.groupSaving.set(true); const editing = this.editingGroup(); const done = () => this.closeGroupModal(); if (editing) { this.subs.updateGroup(editing.id, { name, color: this.groupColor(), icon: this.groupIcon() }).subscribe((g) => { this.groupSaving.set(false); if (g) done(); }); } else { this.subs.createGroup(name, this.groupColor(), this.groupIcon()).subscribe((g) => { this.groupSaving.set(false); if (g) done(); }); } } deleteGroup(group: SubscriptionGroup, event?: Event): void { event?.stopPropagation(); if (!confirm(`Supprimer le groupe « ${group.name} » ? Les chaînes seront conservées.`)) return; this.subs.deleteGroup(group.id).subscribe((ok) => { if (ok && this.activeGroupId() === group.id) this.activeGroupId.set('all'); }); } // -------------------- Assignation -------------------- groupNameById(id: string): string { return this.groups().find((g) => g.id === id)?.name || '?'; } itemGroupIds(item: SubscriptionItem): string[] { return this.members()[String(item.subscriptionId)] || []; } togglePicker(subscriptionId: number, event?: Event): void { event?.stopPropagation(); this.pickerFor.set(this.pickerFor() === subscriptionId ? null : subscriptionId); } /** Ferme le menu dès que le focus/clic sort du menu (clic extérieur, Escape). */ @HostListener('document:click', ['$event']) onDocumentClick(event: MouseEvent): void { if (this.pickerFor() === null) return; const el = event.target as HTMLElement | null; if (el?.closest?.('.picker-root')) return; this.pickerFor.set(null); } @HostListener('document:keydown', ['$event']) onDocumentKey(event: KeyboardEvent): void { if (event.key === 'Escape' && this.pickerFor() !== null) this.pickerFor.set(null); } toggleMembership(item: SubscriptionItem, groupId: string): void { const current = this.itemGroupIds(item); const next = current.includes(groupId) ? current.filter((id) => id !== groupId) : [...current, groupId]; this.subs.setSubscriptionGroups(item.subscriptionId, next).subscribe(); } // -------------------- Sélection en lot -------------------- isSelected(id: number): boolean { return this.selectedIds().includes(id); } toggleSelect(id: number): void { const current = this.selectedIds(); this.selectedIds.set(current.includes(id) ? current.filter((s) => s !== id) : [...current, id]); } toggleSelectAll(): void { const visible = this.filteredItems().map((i) => i.subscriptionId); const all = visible.every((id) => this.selectedIds().includes(id)); this.selectedIds.set(all ? [] : visible); } bulkAddToGroup(): void { const gid = this.bulkGroupId(); if (!gid || this.selectedIds().length === 0) return; const selected = this.filteredItems().filter((i) => this.selectedIds().includes(i.subscriptionId)); for (const item of selected) { const current = this.itemGroupIds(item); if (!current.includes(gid)) { this.subs.setSubscriptionGroups(item.subscriptionId, [...current, gid]).subscribe(); } } this.selectedIds.set([]); } bulkRemoveFromActiveGroup(): void { const gid = this.activeGroupId(); if (gid === 'all' || gid === 'ungrouped' || this.selectedIds().length === 0) return; const selected = this.filteredItems().filter((i) => this.selectedIds().includes(i.subscriptionId)); for (const item of selected) { this.subs.setSubscriptionGroups(item.subscriptionId, this.itemGroupIds(item).filter((id) => id !== gid)).subscribe(); } this.selectedIds.set([]); } private applyFilters(list: SubscriptionItem[]): SubscriptionItem[] { const filter = this.activeFilter(); const sort = this.activeSort(); const groupId = this.activeGroupId(); const q = this.searchQuery().trim().toLowerCase(); let filtered = list; if (filter !== 'all') { // Normalise les codes courts ('yt') et longs ('youtube') : les imports // mixtes stockent les deux formes. const alias = (p: string | null | undefined): string => { const v = String(p || '').toLowerCase(); return ( { youtube: 'yt', yt: 'yt', dailymotion: 'dm', dm: 'dm', twitch: 'tw', tw: 'tw', peertube: 'pt', pt: 'pt', odysee: 'od', od: 'od', rumble: 'ru', ru: 'ru', } as Record )[v] || v; }; filtered = filtered.filter((item) => alias(item.channel?.provider) === filter); } if (groupId !== 'all') { const members = this.members(); if (groupId === 'ungrouped') { filtered = filtered.filter((item) => (members[String(item.subscriptionId)] || []).length === 0); } else { filtered = filtered.filter((item) => (members[String(item.subscriptionId)] || []).includes(groupId)); } } if (q) { filtered = filtered.filter((item) => { const hay = `${item.channel?.title || ''} ${item.channel?.handle || ''}`.toLowerCase(); return hay.includes(q); }); } switch (sort) { case 'recent': filtered = [...filtered].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); break; case 'subs': filtered = [...filtered].sort( (a, b) => (b.channel?.subsCount ?? 0) - (a.channel?.subsCount ?? 0) ); break; case 'alpha': default: filtered = [...filtered].sort((a, b) => { const ta = (a.channel?.title || a.channel?.handle || '').toLowerCase(); const tb = (b.channel?.title || b.channel?.handle || '').toLowerCase(); return ta.localeCompare(tb); }); break; } return filtered; } }