feat(subscriptions): gestion des abonnements par groupes façon PocketTube
CI / build-and-test (push) Failing after 8m18s

- Tables subscription_groups + subscription_group_members (migration + schema)
- API CRUD groupes, membres par groupe, groupes par abonnement
- Page Abonnements: onglets Groupes/Chaines, modale creation/edition (icone/couleur), assignation unitaire + lot, filtre par groupe + recherche
- Sidebar: section Groupes avec navigation directe (queryParams)
- i18n nav.groups FR/EN + 4 tests unitaires service
This commit is contained in:
2026-09-26 10:45:34 -04:00
parent 95e4358b01
commit 49c3b5dfdf
11 changed files with 1058 additions and 28 deletions
+129
View File
@@ -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({
+95
View File
@@ -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 {