diff --git a/db/migrations/20260926_add_subscription_groups.sql b/db/migrations/20260926_add_subscription_groups.sql
new file mode 100644
index 0000000..2dbcc7b
--- /dev/null
+++ b/db/migrations/20260926_add_subscription_groups.sql
@@ -0,0 +1,28 @@
+BEGIN TRANSACTION;
+
+-- Groupes d'abonnements (façon PocketTube) : dossiers thématiques par utilisateur.
+CREATE TABLE IF NOT EXISTS subscription_groups (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#ef4444',
+ icon TEXT NOT NULL DEFAULT '📁',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(user_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_sub_groups_user ON subscription_groups(user_id);
+
+-- Relation plusieurs-à-plusieurs : une chaîne (abonnement) dans plusieurs groupes.
+CREATE TABLE IF NOT EXISTS subscription_group_members (
+ group_id TEXT NOT NULL REFERENCES subscription_groups(id) ON DELETE CASCADE,
+ subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
+ added_at INTEGER NOT NULL,
+ PRIMARY KEY (group_id, subscription_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_sub_group_members_sub ON subscription_group_members(subscription_id);
+CREATE INDEX IF NOT EXISTS idx_sub_group_members_group ON subscription_group_members(group_id);
+
+COMMIT;
diff --git a/db/schema.sql b/db/schema.sql
index d37248d..8496b45 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -109,6 +109,30 @@ CREATE TABLE IF NOT EXISTS subscriptions (
CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions(user_id);
+-- Groupes d'abonnements (façon PocketTube) -----------------------------------
+CREATE TABLE IF NOT EXISTS subscription_groups (
+ id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ color TEXT NOT NULL DEFAULT '#ef4444',
+ icon TEXT NOT NULL DEFAULT '📁',
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ UNIQUE(user_id, name)
+);
+
+CREATE INDEX IF NOT EXISTS idx_sub_groups_user ON subscription_groups(user_id);
+
+CREATE TABLE IF NOT EXISTS subscription_group_members (
+ group_id TEXT NOT NULL REFERENCES subscription_groups(id) ON DELETE CASCADE,
+ subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
+ added_at INTEGER NOT NULL,
+ PRIMARY KEY (group_id, subscription_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_sub_group_members_sub ON subscription_group_members(subscription_id);
+CREATE INDEX IF NOT EXISTS idx_sub_group_members_group ON subscription_group_members(group_id);
+
-- Playlists -----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
diff --git a/server/db.mjs b/server/db.mjs
index 9772c9e..83f09f5 100644
--- a/server/db.mjs
+++ b/server/db.mjs
@@ -978,6 +978,135 @@ export function isSubscribed({ userId, provider, externalId }) {
`).get(userId, provider, externalId) != null;
}
+// -------------------- Subscription groups (façon PocketTube) --------------------
+
+function subscriptionGroupRowToDto(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ name: row.name,
+ color: row.color || '#ef4444',
+ icon: row.icon || '📁',
+ channelCount: typeof row.channelCount === 'number' ? row.channelCount : Number(row.channelCount || 0),
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export function listSubscriptionGroups(userId) {
+ const rows = db.prepare(`
+ SELECT g.id, g.user_id, g.name, g.color, g.icon, g.created_at, g.updated_at,
+ COUNT(m.subscription_id) AS channelCount
+ FROM subscription_groups g
+ LEFT JOIN subscription_group_members m ON m.group_id = g.id
+ WHERE g.user_id = ?
+ GROUP BY g.id
+ ORDER BY g.created_at ASC
+ `).all(userId);
+ return rows.map(subscriptionGroupRowToDto);
+}
+
+export function createSubscriptionGroup({ userId, name, color, icon }) {
+ const cleanName = String(name || '').trim().slice(0, 80);
+ if (!cleanName) throw new Error('group_name_required');
+ const id = cryptoRandomId();
+ const now = Date.now();
+ try {
+ db.prepare(`INSERT INTO subscription_groups (id, user_id, name, color, icon, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
+ .run(id, userId, cleanName, String(color || '#ef4444').slice(0, 16), String(icon || '📁').slice(0, 16), now, now);
+ } catch (e) {
+ if (String(e?.message || '').includes('UNIQUE')) throw new Error('group_name_taken');
+ throw e;
+ }
+ return subscriptionGroupRowToDto(
+ db.prepare(`SELECT *, 0 AS channelCount FROM subscription_groups WHERE id = ?`).get(id)
+ );
+}
+
+export function updateSubscriptionGroup({ userId, groupId, name, color, icon }) {
+ const existing = db.prepare(`SELECT * FROM subscription_groups WHERE id = ? AND user_id = ?`).get(groupId, userId);
+ if (!existing) return null;
+ const next = {
+ name: name === undefined ? existing.name : String(name).trim().slice(0, 80),
+ color: color === undefined ? existing.color : String(color).slice(0, 16),
+ icon: icon === undefined ? existing.icon : String(icon).slice(0, 16),
+ };
+ if (!next.name) throw new Error('group_name_required');
+ try {
+ db.prepare(`UPDATE subscription_groups SET name = ?, color = ?, icon = ?, updated_at = ? WHERE id = ? AND user_id = ?`)
+ .run(next.name, next.color, next.icon, Date.now(), groupId, userId);
+ } catch (e) {
+ if (String(e?.message || '').includes('UNIQUE')) throw new Error('group_name_taken');
+ throw e;
+ }
+ const row = db.prepare(`
+ SELECT g.*, COUNT(m.subscription_id) AS channelCount
+ FROM subscription_groups g
+ LEFT JOIN subscription_group_members m ON m.group_id = g.id
+ WHERE g.id = ? AND g.user_id = ?
+ GROUP BY g.id
+ `).get(groupId, userId);
+ return subscriptionGroupRowToDto(row);
+}
+
+export function deleteSubscriptionGroup({ userId, groupId }) {
+ return db.prepare(`DELETE FROM subscription_groups WHERE id = ? AND user_id = ?`).run(groupId, userId);
+}
+
+/** Remplace le contenu d'un groupe (ids d'abonnements appartenant à l'utilisateur). */
+export function setSubscriptionGroupMembers({ userId, groupId, subscriptionIds }) {
+ const group = db.prepare(`SELECT * FROM subscription_groups WHERE id = ? AND user_id = ?`).get(groupId, userId);
+ if (!group) return null;
+ const ids = [...new Set((subscriptionIds || []).map(Number).filter(Number.isFinite))];
+ const owned = ids.length
+ ? db.prepare(`SELECT id FROM subscriptions WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`).all(userId, ...ids).map(r => r.id)
+ : [];
+ const tx = db.transaction(() => {
+ db.prepare(`DELETE FROM subscription_group_members WHERE group_id = ?`).run(groupId);
+ const now = Date.now();
+ const stmt = db.prepare(`INSERT OR IGNORE INTO subscription_group_members (group_id, subscription_id, added_at) VALUES (?, ?, ?)`);
+ for (const sid of owned) stmt.run(groupId, sid, now);
+ db.prepare(`UPDATE subscription_groups SET updated_at = ? WHERE id = ?`).run(now, groupId);
+ });
+ tx();
+ return updateSubscriptionGroup({ userId, groupId });
+}
+
+/** Remplace les groupes d'un abonnement (relation plusieurs-à-plusieurs). */
+export function setSubscriptionGroups({ userId, subscriptionId, groupIds }) {
+ const sub = db.prepare(`SELECT id FROM subscriptions WHERE id = ? AND user_id = ?`).get(subscriptionId, userId);
+ if (!sub) return null;
+ const ids = [...new Set((groupIds || []).map(String))];
+ const owned = ids.length
+ ? db.prepare(`SELECT id FROM subscription_groups WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`).all(userId, ...ids).map(r => r.id)
+ : [];
+ const tx = db.transaction(() => {
+ db.prepare(`DELETE FROM subscription_group_members WHERE subscription_id = ?`).run(subscriptionId);
+ const now = Date.now();
+ const stmt = db.prepare(`INSERT OR IGNORE INTO subscription_group_members (group_id, subscription_id, added_at) VALUES (?, ?, ?)`);
+ for (const gid of owned) stmt.run(gid, subscriptionId, now);
+ });
+ tx();
+ return owned;
+}
+
+/** Carte subscriptionId -> groupId[] pour tout l'utilisateur (une seule requête). */
+export function listSubscriptionGroupMembersByUser(userId) {
+ const rows = db.prepare(`
+ SELECT m.subscription_id AS subscriptionId, m.group_id AS groupId
+ FROM subscription_group_members m
+ JOIN subscriptions s ON s.id = m.subscription_id AND s.user_id = ?
+ JOIN subscription_groups g ON g.id = m.group_id AND g.user_id = ?
+ `).all(userId, userId);
+ const map = {};
+ for (const r of rows) {
+ if (!map[r.subscriptionId]) map[r.subscriptionId] = [];
+ map[r.subscriptionId].push(r.groupId);
+ }
+ return map;
+}
+
function subscriptionRowToDto(row) {
if (!row) return null;
const channel = channelRowToMeta({
diff --git a/server/index.mjs b/server/index.mjs
index 2d57911..aae2b39 100644
--- a/server/index.mjs
+++ b/server/index.mjs
@@ -66,6 +66,13 @@ import {
subscribeChannel,
unsubscribeChannel,
isSubscribed,
+ listSubscriptionGroups,
+ createSubscriptionGroup,
+ updateSubscriptionGroup,
+ deleteSubscriptionGroup,
+ setSubscriptionGroupMembers,
+ setSubscriptionGroups,
+ listSubscriptionGroupMembersByUser,
insertDownloadJob,
getDownloadJob,
listDownloadJobs,
@@ -334,6 +341,94 @@ r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscripti
}
});
+// -------------------- Subscription groups APIs (façon PocketTube) --------------------
+
+r.get('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
+ try {
+ const groups = listSubscriptionGroups(req.user.id);
+ const members = listSubscriptionGroupMembersByUser(req.user.id);
+ return res.json({ groups, members });
+ } catch (error) {
+ return res.status(500).json({ error: 'groups_list_failed', details: String(error?.message || error) });
+ }
+});
+
+r.post('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
+ try {
+ const group = createSubscriptionGroup({
+ userId: req.user.id,
+ name: req.body?.name,
+ color: req.body?.color,
+ icon: req.body?.icon,
+ });
+ return res.status(201).json(group);
+ } catch (error) {
+ const msg = error?.message || 'group_create_failed';
+ if (msg === 'group_name_required') return res.status(400).json({ error: msg });
+ if (msg === 'group_name_taken') return res.status(409).json({ error: msg });
+ return res.status(500).json({ error: 'group_create_failed' });
+ }
+});
+
+r.patch('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
+ try {
+ const group = updateSubscriptionGroup({
+ userId: req.user.id,
+ groupId: req.params.groupId,
+ name: req.body?.name,
+ color: req.body?.color,
+ icon: req.body?.icon,
+ });
+ if (!group) return res.status(404).json({ error: 'not_found' });
+ return res.json(group);
+ } catch (error) {
+ const msg = error?.message || 'group_update_failed';
+ if (msg === 'group_name_required') return res.status(400).json({ error: msg });
+ if (msg === 'group_name_taken') return res.status(409).json({ error: msg });
+ return res.status(500).json({ error: 'group_update_failed' });
+ }
+});
+
+r.delete('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
+ try {
+ const info = deleteSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId });
+ if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' });
+ return res.status(204).end();
+ } catch (error) {
+ return res.status(500).json({ error: 'group_delete_failed' });
+ }
+});
+
+// Remplace les chaînes d'un groupe : { subscriptionIds: number[] }
+r.put('/subscription-groups/:groupId/members', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
+ try {
+ const group = setSubscriptionGroupMembers({
+ userId: req.user.id,
+ groupId: req.params.groupId,
+ subscriptionIds: req.body?.subscriptionIds,
+ });
+ if (!group) return res.status(404).json({ error: 'not_found' });
+ return res.json(group);
+ } catch (error) {
+ return res.status(500).json({ error: 'group_members_failed' });
+ }
+});
+
+// Remplace les groupes d'un abonnement : { groupIds: string[] }
+r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => {
+ try {
+ const groupIds = setSubscriptionGroups({
+ userId: req.user.id,
+ subscriptionId: Number(req.params.subscriptionId),
+ groupIds: req.body?.groupIds,
+ });
+ if (!groupIds) return res.status(404).json({ error: 'not_found' });
+ return res.json({ subscriptionId: Number(req.params.subscriptionId), groupIds });
+ } catch (error) {
+ return res.status(500).json({ error: 'subscription_groups_failed' });
+ }
+});
+
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
r.get('/playlists/:id/view', (req, res) => {
try {
diff --git a/src/components/library/subscriptions/subscriptions.component.html b/src/components/library/subscriptions/subscriptions.component.html
index 6b43ed1..f91cd1f 100644
--- a/src/components/library/subscriptions/subscriptions.component.html
+++ b/src/components/library/subscriptions/subscriptions.component.html
@@ -2,9 +2,16 @@
Abonnements
-
Gérez vos chaînes suivies multi-fournisseurs
+
Organisez vos chaînes suivies en groupes thématiques
+
-
+
+
+
+
+
+
+
+
+
0; else noGroups" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
+
+
+
+
{{ group.icon }}
+
+
{{ group.name }}
+
{{ group.channelCount }} chaîne(s)
+
+
+
+
+
+
+
+
+
+ Voir les chaînes →
+
+
+
+
+
+
📁
+
Aucun groupe pour le moment
+
Créez des groupes thématiques (Tech, Musique, Sport…) pour organiser vos abonnements.
+
+
+
+
+
+
+
+
-
Filtres
+
Groupe
+
+
+
+
+
+
+
+
+
+
-
+
Trier par :
+
+
+
+
+
0" class="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-sky-500/30 bg-sky-500/10 p-3">
+ {{ selectedCount() }} sélectionnée(s)
+
+
+
0; else emptyState">
@@ -62,7 +248,14 @@
class="flex flex-col gap-4 py-4 md:flex-row md:items-center md:justify-between"
>
-
+
+
+
+
+ {{ groupNameById(gid) }}
+
+
+ Sans groupe
+
+
{{ channel.handle }}
@@ -88,19 +296,42 @@
Abonné depuis {{ item.createdAt | date:'mediumDate' }}
-
- Voir la chaîne externe
-
+
+
+
+
+
+ Aucun groupe.
+
+
+
+
+
-
Aucune chaîne suivie
-
Explorez la page Trending ou effectuez une recherche pour commencer à vous abonner.
+
Aucune chaîne dans ce filtre
+
Changez de groupe, de filtre ou abonnez-vous à de nouvelles chaînes.
+
+
+
+
+
+ {{ editingGroup() ? 'Modifier le groupe' : 'Nouveau groupe' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ groupIcon() }}
+ {{ groupName() || 'Aperçu du groupe' }}
+
+
+
+
+
+
+
diff --git a/src/components/library/subscriptions/subscriptions.component.ts b/src/components/library/subscriptions/subscriptions.component.ts
index 2e645b4..0190cf1 100644
--- a/src/components/library/subscriptions/subscriptions.component.ts
+++ b/src/components/library/subscriptions/subscriptions.component.ts
@@ -1,8 +1,15 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DatePipe, DecimalPipe } from '@angular/common';
-import { Router, RouterLink } from '@angular/router';
-import { SubscriptionsService, type SubscriptionItem } from '../../../services/subscriptions.service';
+import { FormsModule } from '@angular/forms';
+import { ActivatedRoute, Router, RouterLink } from '@angular/router';
+import {
+ GROUP_COLORS,
+ GROUP_ICONS,
+ SubscriptionsService,
+ 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';
@@ -13,17 +20,19 @@ interface FilterChip {
}
type SortKey = 'alpha' | 'recent' | 'subs';
+type TabId = 'groups' | 'channels';
@Component({
selector: 'app-library-subscriptions',
standalone: true,
- imports: [CommonModule, DatePipe, DecimalPipe, RouterLink, ProviderBadgeComponent, SubscribeButtonComponent],
+ 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' },
@@ -41,19 +50,64 @@ export class SubscriptionsComponent {
{ 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);
+
+ // --- Assigneur de groupes (par ligne) ---
+ pickerFor = signal(null);
+
+ // --- Assignation en lot ---
+ bulkGroupId = signal('');
readonly items = computed(() => this.subs.items());
- readonly filteredItems = computed(() => this.applyFilters(this.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 {
@@ -64,6 +118,16 @@ export class SubscriptionsComponent {
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;
@@ -71,13 +135,144 @@ export class SubscriptionsComponent {
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.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.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);
+ }
+
+ 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') {
filtered = filtered.filter((item) => 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));
diff --git a/src/components/sidebar/sidebar.component.html b/src/components/sidebar/sidebar.component.html
index b89d103..5f98d90 100644
--- a/src/components/sidebar/sidebar.component.html
+++ b/src/components/sidebar/sidebar.component.html
@@ -61,6 +61,20 @@
+
+
+
0">
+
{{ 'nav.groups' | t : 'Groupes' }}
+
+
}
diff --git a/src/components/sidebar/sidebar.component.ts b/src/components/sidebar/sidebar.component.ts
index 8e460ca..9073122 100644
--- a/src/components/sidebar/sidebar.component.ts
+++ b/src/components/sidebar/sidebar.component.ts
@@ -1,9 +1,10 @@
-import { ChangeDetectionStrategy, Component, computed, inject, Input } from '@angular/core';
+import { ChangeDetectionStrategy, Component, computed, effect, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { InstanceService } from '../../services/instance.service';
import { ThemesService } from '../../services/themes.service';
import { AuthService } from '../../services/auth.service';
+import { SubscriptionsService } from '../../services/subscriptions.service';
import { TranslatePipe } from '../../pipes/translate.pipe';
@Component({
@@ -16,10 +17,20 @@ import { TranslatePipe } from '../../pipes/translate.pipe';
export class SidebarComponent {
private instances = inject(InstanceService);
private auth = inject(AuthService);
+ private subs = inject(SubscriptionsService);
themes = inject(ThemesService);
-
+
// Check if user is logged in
isLoggedIn = computed(() => !!this.auth.currentUser());
+ subscriptionGroups = computed(() => this.subs.subscriptionGroups());
+
+ constructor() {
+ effect(() => {
+ if (this.isLoggedIn()) {
+ this.subs.listGroups().subscribe();
+ }
+ });
+ }
provider = computed(() => this.instances.selectedProvider());
providerLabel = computed(() => this.instances.selectedProviderLabel());
diff --git a/src/services/i18n.service.ts b/src/services/i18n.service.ts
index 9a998d8..8e685c6 100644
--- a/src/services/i18n.service.ts
+++ b/src/services/i18n.service.ts
@@ -47,6 +47,7 @@ const EN: Record
= {
'nav.home': 'Home',
'nav.shorts': 'Shorts',
'nav.subscriptions': 'Subscriptions',
+ 'nav.groups': 'Groups',
'nav.you': 'You',
'nav.history': 'History',
'nav.playlists': 'Playlists',
@@ -185,6 +186,7 @@ const FR: Record = {
'nav.home': 'Accueil',
'nav.shorts': 'Shorts',
'nav.subscriptions': 'Abonnements',
+ 'nav.groups': 'Groupes',
'nav.you': 'Vous',
'nav.history': 'Historique',
'nav.playlists': 'Listes de lecture',
diff --git a/src/services/subscriptions.service.spec.ts b/src/services/subscriptions.service.spec.ts
index cf0101c..0bff245 100644
--- a/src/services/subscriptions.service.spec.ts
+++ b/src/services/subscriptions.service.spec.ts
@@ -3,7 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DOCUMENT } from '@angular/common';
import { BrowserDynamicTestingModule, platformBrowserDynamicTesting } from '@angular/platform-browser-dynamic/testing';
-import { SubscriptionsService, type SubscriptionItem } from './subscriptions.service';
+import { SubscriptionsService, type SubscriptionGroup, type SubscriptionItem } from './subscriptions.service';
import type { ChannelMeta, ProviderId } from '../app/shared/models/channel.model';
const mockDocument = {
@@ -153,8 +153,78 @@ const tests: Array<[string, () => void]> = [
['list() caches fetched subscriptions', testListCachesResults],
['subscribe() appends to cache', testSubscribeAppendsToCache],
['unsubscribe() removes from cache', testUnsubscribeRemovesFromCache],
+ ['listGroups() caches groups and members', testListGroupsCaches],
+ ['createGroup() appends to cache', testCreateGroupAppends],
+ ['setSubscriptionGroups() updates members map', testSetSubscriptionGroups],
+ ['deleteGroup() removes group and cleans members', testDeleteGroupCleans],
];
+function makeGroup(id: string, name: string): SubscriptionGroup {
+ return { id, name, color: '#3b82f6', icon: '💻', channelCount: 0, createdAt: Date.now(), updatedAt: Date.now() };
+}
+
+function testListGroupsCaches(): void {
+ const { service, httpMock } = setup();
+ const g1 = makeGroup('g1', 'Tech');
+ let received: SubscriptionGroup[] | undefined;
+ service.listGroups().subscribe(groups => (received = groups));
+ const req = httpMock.expectOne('/api/subscription-groups');
+ assert(req.request.method === 'GET', 'listGroups() should perform GET request');
+ req.flush({ groups: [g1], members: { '5': ['g1'] } });
+ assertEqual(received, [g1], 'listGroups() should emit fetched groups');
+ assertEqual(service.subscriptionGroups(), [g1], 'groups signal should reflect fetched groups');
+ assertEqual(service.groupsOf(5), ['g1'], 'members map should be queryable via groupsOf()');
+ httpMock.verify();
+}
+
+function testCreateGroupAppends(): void {
+ const { service, httpMock } = setup();
+ service.listGroups().subscribe();
+ httpMock.expectOne('/api/subscription-groups').flush({ groups: [], members: {} });
+ const created = makeGroup('g2', 'Musique');
+ let result: SubscriptionGroup | null = null;
+ service.createGroup('Musique', '#ef4444', '🎵').subscribe(g => (result = g));
+ const req = httpMock.expectOne('/api/subscription-groups');
+ assert(req.request.method === 'POST', 'createGroup() should perform POST request');
+ req.flush(created);
+ assertEqual(result, created, 'createGroup() should emit created group');
+ assertEqual(service.subscriptionGroups().map(g => g.id), ['g2'], 'createGroup() should append group into cache');
+ httpMock.verify();
+}
+
+function testSetSubscriptionGroups(): void {
+ const { service, httpMock } = setup();
+ service.listGroups().subscribe();
+ httpMock.expectOne('/api/subscription-groups').flush({ groups: [makeGroup('g1', 'Tech')], members: {} });
+ let result: string[] | null = null;
+ service.setSubscriptionGroups(9, ['g1']).subscribe(ids => (result = ids));
+ const req = httpMock.expectOne('/api/subscriptions/9/groups');
+ assert(req.request.method === 'PUT', 'setSubscriptionGroups() should perform PUT request');
+ assertEqual(req.request.body, { groupIds: ['g1'] }, 'setSubscriptionGroups() should send groupIds payload');
+ req.flush({ subscriptionId: 9, groupIds: ['g1'] });
+ assertEqual(result, ['g1'], 'setSubscriptionGroups() should emit group ids');
+ assertEqual(service.groupsOf(9), ['g1'], 'members map should reflect new assignment');
+ httpMock.verify();
+}
+
+function testDeleteGroupCleans(): void {
+ const { service, httpMock } = setup();
+ service.listGroups().subscribe();
+ httpMock.expectOne('/api/subscription-groups').flush({
+ groups: [makeGroup('g1', 'Tech'), makeGroup('g2', 'Musique')],
+ members: { '7': ['g1', 'g2'] },
+ });
+ let result: boolean | null = null;
+ service.deleteGroup('g1').subscribe(ok => (result = ok));
+ const req = httpMock.expectOne('/api/subscription-groups/g1');
+ assert(req.request.method === 'DELETE', 'deleteGroup() should perform DELETE request');
+ req.flush({}, { status: 204, statusText: 'No Content' });
+ assert(result === true, 'deleteGroup() should emit true when deletion succeeds');
+ assertEqual(service.subscriptionGroups().map(g => g.id), ['g2'], 'deleteGroup() should remove group from cache');
+ assertEqual(service.groupsOf(7), ['g2'], 'deleteGroup() should clean members map');
+ httpMock.verify();
+}
+
let failures = 0;
for (const [name, fn] of tests) {
try {
diff --git a/src/services/subscriptions.service.ts b/src/services/subscriptions.service.ts
index 6a340c2..6636612 100644
--- a/src/services/subscriptions.service.ts
+++ b/src/services/subscriptions.service.ts
@@ -10,11 +10,37 @@ export interface SubscriptionItem {
channel: ChannelMeta;
}
+export interface SubscriptionGroup {
+ id: string;
+ name: string;
+ color: string;
+ icon: string;
+ channelCount: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
interface ListResponse {
items: SubscriptionItem[];
ttl?: number;
}
+interface GroupsResponse {
+ groups: SubscriptionGroup[];
+ members: Record;
+}
+
+export const GROUP_COLORS = [
+ '#ef4444', '#f97316', '#f59e0b', '#84cc16', '#22c55e',
+ '#14b8a6', '#06b6d4', '#3b82f6', '#8b5cf6', '#d946ef',
+ '#ec4899', '#64748b',
+];
+
+export const GROUP_ICONS = [
+ '📁', '🎵', '🎮', '📰', '⚽', '🎓', '💻', '🤖',
+ '🏥', '🍳', '✈️', '🎬', '📚', '💡', '🔬', '⭐',
+];
+
@Injectable({ providedIn: 'root' })
export class SubscriptionsService {
private http = inject(HttpClient);
@@ -22,16 +48,26 @@ export class SubscriptionsService {
private ttlMs = signal(null);
private loading = signal(false);
private lastErrorMsg = signal(null);
+ private groups = signal([]);
+ private members = signal>({});
readonly items = computed(() => this.subscriptions());
readonly isLoading = computed(() => this.loading());
/** Dernière erreur d'abonnement/désabonnement (affichée par les pages). */
readonly lastError = computed(() => this.lastErrorMsg());
+ /** Groupes d'abonnements (façon PocketTube), triés par création. */
+ readonly subscriptionGroups = computed(() => this.groups());
+ /** Map subscriptionId -> groupId[] (relation plusieurs-à-plusieurs). */
+ readonly groupMembers = computed(() => this.members());
clearError(): void {
this.lastErrorMsg.set(null);
}
+ groupsOf(subscriptionId: number): string[] {
+ return this.members()[String(subscriptionId)] || [];
+ }
+
list(): Observable {
this.loading.set(true);
return this.http.get('/api/subscriptions').pipe(
@@ -79,6 +115,10 @@ export class SubscriptionsService {
tap(() => {
const current = this.subscriptions();
this.subscriptions.set(current.filter((item) => item.subscriptionId !== subscriptionId));
+ const members = { ...this.members() };
+ delete members[String(subscriptionId)];
+ this.members.set(members);
+ this.refreshGroupCounts();
}),
map(() => true),
catchError((err) => {
@@ -97,4 +137,96 @@ export class SubscriptionsService {
const current = this.subscriptions();
return current.some((item) => item.channel?.provider === provider && item.channel?.externalId === externalId);
}
+
+ // -------------------- Groupes --------------------
+
+ listGroups(): Observable {
+ return this.http.get('/api/subscription-groups').pipe(
+ tap((resp) => {
+ this.groups.set(resp.groups || []);
+ this.members.set(resp.members || {});
+ }),
+ map((resp) => resp.groups || []),
+ catchError((err) => {
+ console.error('Failed to load subscription groups', err);
+ return of([]);
+ })
+ );
+ }
+
+ createGroup(name: string, color?: string, icon?: string): Observable {
+ return this.http.post('/api/subscription-groups', { name, color, icon }).pipe(
+ tap((group) => {
+ if (group?.id) this.groups.set([...this.groups(), group]);
+ }),
+ catchError((err) => {
+ console.error('Failed to create group', err);
+ this.lastErrorMsg.set(
+ err?.status === 409 ? 'Un groupe porte déjà ce nom.' : 'Création du groupe impossible.'
+ );
+ return of(null);
+ })
+ );
+ }
+
+ updateGroup(id: string, patch: { name?: string; color?: string; icon?: string }): Observable {
+ return this.http.patch(`/api/subscription-groups/${id}`, patch).pipe(
+ tap((group) => {
+ if (group?.id) this.groups.set(this.groups().map((g) => (g.id === group.id ? group : g)));
+ }),
+ catchError((err) => {
+ console.error('Failed to update group', err);
+ this.lastErrorMsg.set(
+ err?.status === 409 ? 'Un groupe porte déjà ce nom.' : 'Modification du groupe impossible.'
+ );
+ return of(null);
+ })
+ );
+ }
+
+ deleteGroup(id: string): Observable {
+ return this.http.delete(`/api/subscription-groups/${id}`).pipe(
+ tap(() => {
+ this.groups.set(this.groups().filter((g) => g.id !== id));
+ const members = { ...this.members() };
+ for (const key of Object.keys(members)) {
+ members[key] = members[key].filter((gid) => gid !== id);
+ }
+ this.members.set(members);
+ this.refreshGroupCounts();
+ }),
+ map(() => true),
+ catchError((err) => {
+ console.error('Failed to delete group', err);
+ return of(false);
+ })
+ );
+ }
+
+ /** Remplace les groupes d'un abonnement. */
+ setSubscriptionGroups(subscriptionId: number, groupIds: string[]): Observable {
+ return this.http.put<{ subscriptionId: number; groupIds: string[] }>(`/api/subscriptions/${subscriptionId}/groups`, { groupIds }).pipe(
+ tap((resp) => {
+ const members = { ...this.members() };
+ members[String(subscriptionId)] = resp.groupIds || [];
+ this.members.set(members);
+ this.refreshGroupCounts();
+ }),
+ map((resp) => resp.groupIds || []),
+ catchError((err) => {
+ console.error('Failed to set subscription groups', err);
+ return of(null);
+ })
+ );
+ }
+
+ /** Recalcule les compteurs locaux sans appel réseau. */
+ private refreshGroupCounts(): void {
+ const members = this.members();
+ const counts: Record = {};
+ for (const key of Object.keys(members)) {
+ for (const gid of members[key] || []) counts[gid] = (counts[gid] || 0) + 1;
+ }
+ this.groups.set(this.groups().map((g) => ({ ...g, channelCount: counts[g.id] || 0 })));
+ }
}