541 lines
18 KiB
TypeScript
541 lines
18 KiB
TypeScript
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<TabId>('groups');
|
|
activeFilter = signal<FilterChip['id']>('all');
|
|
activeSort = signal<SortKey>('alpha');
|
|
activeGroupId = signal<string>('all');
|
|
searchQuery = signal<string>('');
|
|
selectedIds = signal<number[]>([]);
|
|
|
|
// --- Modale groupe ---
|
|
showGroupModal = signal<boolean>(false);
|
|
editingGroup = signal<SubscriptionGroup | null>(null);
|
|
groupName = signal<string>('');
|
|
groupColor = signal<string>(GROUP_COLORS[0]);
|
|
groupIcon = signal<string>(GROUP_ICONS[0]);
|
|
groupSaving = signal<boolean>(false);
|
|
iconSearch = signal<string>('');
|
|
|
|
/** 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<number | null>(null);
|
|
|
|
// --- Assignation en lot ---
|
|
bulkGroupId = signal<string>('');
|
|
|
|
// --- Suggestions auto de groupes (analyse des chaînes) ---
|
|
showSuggestions = signal<boolean>(false);
|
|
suggestBusy = signal<boolean>(false);
|
|
|
|
// --- 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 static readonly DEEP_TEXTS_KEY = 'newtube.deepTexts.v1';
|
|
private deepTexts = signal<Record<number, string>>((() => {
|
|
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<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.persistDeepTexts();
|
|
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) : 2 requêtes max, pas de tempête. */
|
|
applySuggestion(s: GroupSuggestion): Promise<void> {
|
|
if (this.suggestBusy()) return Promise.resolve();
|
|
this.suggestBusy.set(true);
|
|
const done = () => this.suggestBusy.set(false);
|
|
const run = (gid: string): Promise<void> => new Promise<void>((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<void>((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<void> {
|
|
if (this.suggestBusy()) return;
|
|
for (const s of this.suggestions()) {
|
|
await this.applySuggestion(s);
|
|
}
|
|
}
|
|
|
|
copiedUngrouped = signal<boolean>(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<void> } };
|
|
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<string, string>
|
|
)[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;
|
|
}
|
|
}
|