feat(subscriptions): gestion des abonnements par groupes façon PocketTube
CI / build-and-test (push) Failing after 8m18s
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:
+129
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user