chore: update Angular TypeScript build info cache

This commit is contained in:
2025-09-17 22:29:57 -04:00
parent f3a78b7d7e
commit fff9b88663
15 changed files with 1002 additions and 11 deletions
+160
View File
@@ -24,6 +24,36 @@ if (fs.existsSync(schemaFile)) {
}
}
// Lightweight schema upgrades for existing databases (SQLite is permissive)
(function ensurePlaylistSchemaUpgrades() {
try {
const colsPlaylists = db.prepare(`PRAGMA table_info(playlists)`).all();
const have = new Set(colsPlaylists.map(c => c.name));
if (!have.has('description')) db.exec(`ALTER TABLE playlists ADD COLUMN description TEXT`);
if (!have.has('thumbnail')) db.exec(`ALTER TABLE playlists ADD COLUMN thumbnail TEXT`);
if (!have.has('is_private')) db.exec(`ALTER TABLE playlists ADD COLUMN is_private INTEGER NOT NULL DEFAULT 1`);
} catch {}
try {
const colsItems = db.prepare(`PRAGMA table_info(playlist_items)`).all();
const have2 = new Set(colsItems.map(c => c.name));
if (!have2.has('thumbnail')) db.exec(`ALTER TABLE playlist_items ADD COLUMN thumbnail TEXT`);
} catch {}
try {
db.exec(`CREATE TABLE IF NOT EXISTS playlist_metrics (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
action TEXT NOT NULL,
meta_json TEXT,
created_at TEXT NOT NULL
);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_metrics_user_time ON playlist_metrics(user_id, created_at DESC);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_playlist_metrics_playlist_time ON playlist_metrics(playlist_id, created_at DESC);`);
} catch {}
})();
// (duplicate schema exec removed)
// Helpers
export function nowIso() {
return new Date().toISOString();
@@ -390,3 +420,133 @@ export function listLikedVideos({ userId, limit = 100, q }) {
throw error; // Renvoyer l'erreur pour qu'elle soit gérée par le routeur
}
}
// -------------------- Playlists --------------------
export function recordPlaylistMetric({ userId, playlistId, action, meta }) {
const id = cryptoRandomId();
const created_at = nowIso();
const meta_json = meta ? JSON.stringify(meta) : null;
db.prepare(`INSERT INTO playlist_metrics (id, user_id, playlist_id, action, meta_json, created_at)
VALUES (?, ?, ?, ?, ?, ?)`)
.run(id, userId, playlistId, action, meta_json, created_at);
return { id, created_at };
}
export function createPlaylist({ userId, title, description, thumbnail, isPrivate = true }) {
const id = cryptoRandomUUID();
const now = nowIso();
const name = String(title || '').trim();
if (!name) throw new Error('title_required');
db.prepare(`INSERT INTO playlists (id, user_id, name, description, thumbnail, is_private, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, userId, name, description || null, thumbnail || null, isPrivate ? 1 : 0, now, now);
recordPlaylistMetric({ userId, playlistId: id, action: 'create', meta: { title: name } });
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt,
(SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount
FROM playlists WHERE id = ?`).get(id);
}
export function listPlaylists({ userId, limit = 50, offset = 0, q }) {
const like = `%${(q || '').trim()}%`;
const hasQ = typeof q === 'string' && q.trim().length > 0;
const base = `SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt,
(SELECT COUNT(1) FROM playlist_items WHERE playlist_id = playlists.id) AS itemsCount
FROM playlists WHERE user_id = ?`;
const sql = hasQ ? `${base} AND (name LIKE ? OR COALESCE(description,'') LIKE ?)
ORDER BY updated_at DESC LIMIT ? OFFSET ?` :
`${base} ORDER BY updated_at DESC LIMIT ? OFFSET ?`;
return hasQ
? db.prepare(sql).all(userId, like, like, limit, offset)
: db.prepare(sql).all(userId, limit, offset);
}
export function getPlaylistRaw(id) {
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
FROM playlists WHERE id = ?`).get(id);
}
export function updatePlaylist({ userId, id, patch }) {
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
if (!cur) return null;
if (cur.user_id !== userId) return 'forbidden';
const next = {
name: patch.title != null ? String(patch.title).trim() : cur.name,
description: patch.description != null ? String(patch.description).trim() : cur.description,
thumbnail: patch.thumbnail != null ? String(patch.thumbnail).trim() : cur.thumbnail,
is_private: typeof patch.isPrivate === 'boolean' ? (patch.isPrivate ? 1 : 0) : cur.is_private,
updated_at: nowIso(),
};
db.prepare(`UPDATE playlists SET name=@name, description=@description, thumbnail=@thumbnail, is_private=@is_private, updated_at=@updated_at WHERE id = ?`)
.run(id, next);
recordPlaylistMetric({ userId, playlistId: id, action: 'update', meta: { title: next.name } });
return db.prepare(`SELECT id, user_id AS userId, name AS title, description, thumbnail, is_private AS isPrivate, created_at AS createdAt, updated_at AS updatedAt
FROM playlists WHERE id = ?`).get(id);
}
export function deletePlaylist({ userId, id }) {
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
if (!cur) return { removed: false };
if (cur.user_id !== userId) return 'forbidden';
const info = db.prepare(`DELETE FROM playlists WHERE id = ?`).run(id);
recordPlaylistMetric({ userId, playlistId: id, action: 'delete' });
return { removed: (info.changes || 0) > 0 };
}
export function listPlaylistItems({ playlistId, limit = 200, offset = 0 }) {
return db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
FROM playlist_items WHERE playlist_id = ?
ORDER BY position ASC LIMIT ? OFFSET ?`).all(playlistId, limit, offset);
}
export function addPlaylistVideo({ userId, playlistId, provider, videoId, title, thumbnail }) {
const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId);
if (!pl) return 'not_found';
if (pl.user_id !== userId) return 'forbidden';
const now = nowIso();
const posRow = db.prepare(`SELECT COALESCE(MAX(position), 0) + 1 AS nextPos FROM playlist_items WHERE playlist_id = ?`).get(playlistId);
const position = Number(posRow?.nextPos || 1);
const id = cryptoRandomId();
db.prepare(`INSERT OR IGNORE INTO playlist_items (id, playlist_id, provider, video_id, title, thumbnail, added_at, position)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(id, playlistId, provider, videoId, title || null, thumbnail || null, now, position);
recordPlaylistMetric({ userId, playlistId, action: 'add_video', meta: { provider, videoId } });
// Return the row (if it existed we need to fetch by key)
const row = db.prepare(`SELECT id, playlist_id AS playlistId, provider, video_id AS videoId, title, thumbnail, added_at AS addedAt, position
FROM playlist_items WHERE playlist_id = ? AND provider = ? AND video_id = ?`).get(playlistId, provider, videoId);
return row;
}
export function removePlaylistVideo({ userId, playlistId, provider, videoId }) {
const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId);
if (!pl) return 'not_found';
if (pl.user_id !== userId) return 'forbidden';
const info = db.prepare(`DELETE FROM playlist_items WHERE playlist_id = ? AND provider = ? AND video_id = ?`).run(playlistId, provider, videoId);
recordPlaylistMetric({ userId, playlistId, action: 'remove_video', meta: { provider, videoId } });
return { removed: (info.changes || 0) > 0 };
}
export function reorderPlaylistVideos({ userId, playlistId, order }) {
const pl = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(playlistId);
if (!pl) return 'not_found';
if (pl.user_id !== userId) return 'forbidden';
if (!Array.isArray(order) || order.length === 0) return { changed: 0 };
const tx = db.transaction((rows) => {
let i = 1, changed = 0;
for (const it of rows) {
// Support either item ids or provider+videoId pairs
if (it && typeof it === 'string') {
const info = db.prepare(`UPDATE playlist_items SET position = ? WHERE id = ? AND playlist_id = ?`).run(i, it, playlistId);
changed += info.changes || 0;
} else if (it && (it.id || (it.provider && it.videoId))) {
const info = it.id
? db.prepare(`UPDATE playlist_items SET position = ? WHERE id = ? AND playlist_id = ?`).run(i, it.id, playlistId)
: db.prepare(`UPDATE playlist_items SET position = ? WHERE provider = ? AND video_id = ? AND playlist_id = ?`).run(i, it.provider, it.videoId, playlistId);
changed += info.changes || 0;
}
i++;
}
return changed;
});
const changed = tx(order);
recordPlaylistMetric({ userId, playlistId, action: 'reorder', meta: { count: order.length } });
return { changed };
}
+185
View File
@@ -41,6 +41,16 @@ import {
unlikeVideo,
listLikedVideos,
isVideoLiked,
// Playlists
createPlaylist,
listPlaylists,
getPlaylistRaw,
updatePlaylist,
deletePlaylist,
listPlaylistItems,
addPlaylistVideo,
removePlaylistVideo,
reorderPlaylistVideos,
} from './db.mjs';
const app = express();
@@ -1286,3 +1296,178 @@ try {
app.listen(PORT, () => {
console.log(`[newtube-api] listening on http://localhost:${PORT}`);
});
// --- Playlists ---
// Create a new playlist
r.post('/playlists', authMiddleware, (req, res) => {
try {
const { title, description, thumbnail, isPrivate } = req.body || {};
if (!title || String(title).trim().length === 0) {
return res.status(400).json({ error: 'title_required' });
}
const pl = createPlaylist({ userId: req.user.id, title: String(title).trim(), description, thumbnail, isPrivate: !!isPrivate });
return res.status(201).json(pl);
} catch (e) {
const msg = String(e?.message || e);
if (msg === 'title_required') return res.status(400).json({ error: msg });
return res.status(500).json({ error: 'create_failed', details: msg });
}
});
// List current user's playlists (pagination + search)
r.get('/playlists', authMiddleware, (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
const offset = Math.max(0, Number(req.query.offset || 0));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listPlaylists({ userId: req.user.id, limit, offset, q });
return res.json(rows);
} catch (e) {
return res.status(500).json({ error: 'list_failed', details: String(e?.message || e) });
}
});
// Get playlist details (owner only for now)
r.get('/playlists/:id', authMiddleware, (req, res) => {
try {
const id = String(req.params.id || '');
const pl = getPlaylistRaw(id);
if (!pl) return res.status(404).json({ error: 'not_found' });
if (pl.userId !== req.user.id) return res.status(404).json({ error: 'not_found' });
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
const offset = Math.max(0, Number(req.query.offset || 0));
const items = listPlaylistItems({ playlistId: id, limit, offset });
return res.json({ ...pl, items });
} catch (e) {
return res.status(500).json({ error: 'get_failed', details: String(e?.message || e) });
}
});
// Update a playlist (title/description/thumbnail/isPrivate)
r.put('/playlists/:id', authMiddleware, (req, res) => {
try {
const id = String(req.params.id || '');
const patch = req.body || {};
const result = updatePlaylist({ userId: req.user.id, id, patch });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
if (!result) return res.status(404).json({ error: 'not_found' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'update_failed', details: String(e?.message || e) });
}
});
// Delete a playlist
r.delete('/playlists/:id', authMiddleware, (req, res) => {
try {
const id = String(req.params.id || '');
const result = deletePlaylist({ userId: req.user.id, id });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
if (!result || !result.removed) return res.status(404).json({ error: 'not_found' });
return res.status(204).end();
} catch (e) {
return res.status(500).json({ error: 'delete_failed', details: String(e?.message || e) });
}
});
// Add a video to a playlist (enrich title/thumbnail if missing)
r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
try {
const playlistId = String(req.params.id || '');
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
provider = String(provider || '').trim();
videoId = String(videoId || '').trim();
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
// Optional enrichment like likes route
try {
const needTitle = !(typeof title === 'string' && title.trim().length > 0);
const needThumb = !(typeof thumbnail === 'string' && thumbnail.trim().length > 0);
if (needTitle || needThumb) {
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
try {
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
if (needTitle) title = meta?.title || title || '';
if (needThumb) thumbnail = meta?.thumbnail || (Array.isArray(meta?.thumbnails) && meta.thumbnails.length ? meta.thumbnails[0].url : thumbnail || '');
} catch {}
}
} catch {}
const row = addPlaylistVideo({ userId: req.user.id, playlistId, provider, videoId, title, thumbnail });
if (row === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (row === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.status(201).json(row);
} catch (e) {
return res.status(500).json({ error: 'add_video_failed', details: String(e?.message || e) });
}
});
// Remove a video from a playlist (provider required via query)
r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const videoId = String(req.params.videoId || '');
const provider = req.query.provider ? String(req.query.provider) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider_and_videoId_required' });
const result = removePlaylistVideo({ userId: req.user.id, playlistId, provider, videoId });
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'remove_video_failed', details: String(e?.message || e) });
}
});
// Reorder playlist items
r.put('/playlists/:id/reorder', authMiddleware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const order = Array.isArray(req.body?.order) ? req.body.order : [];
if (!order.length) return res.status(400).json({ error: 'order_required' });
const result = reorderPlaylistVideos({ userId: req.user.id, playlistId, order });
if (result === 'not_found') return res.status(404).json({ error: 'playlist_not_found' });
if (result === 'forbidden') return res.status(403).json({ error: 'forbidden' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'reorder_failed', details: String(e?.message || e) });
}
});
// --- OpenAPI (minimal for playlists) ---
r.get('/openapi.json', (_req, res) => {
const doc = {
openapi: '3.0.0',
info: { title: 'NewTube API', version: '1.0.0' },
paths: {
'/playlists': {
get: { summary: 'List user playlists', security: [{ bearerAuth: [] }], parameters: [
{ name: 'limit', in: 'query', schema: { type: 'integer' } },
{ name: 'offset', in: 'query', schema: { type: 'integer' } },
{ name: 'q', in: 'query', schema: { type: 'string' } },
] },
post: { summary: 'Create playlist', security: [{ bearerAuth: [] }], requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', properties: { title: { type: 'string' }, description: { type: 'string' }, thumbnail: { type: 'string' }, isPrivate: { type: 'boolean' } }, required: ['title'] } } } } }
},
'/playlists/{id}': {
get: { summary: 'Get playlist details', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
put: { summary: 'Update playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] },
delete: { summary: 'Delete playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/playlists/{id}/videos': {
post: { summary: 'Add video to playlist', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/playlists/{id}/videos/{videoId}': {
delete: { summary: 'Remove video from playlist', security: [{ bearerAuth: [] }], parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'videoId', in: 'path', required: true, schema: { type: 'string' } },
{ name: 'provider', in: 'query', required: true, schema: { type: 'string' } }
] }
},
'/playlists/{id}/reorder': {
put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
}
},
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }
};
res.json(doc);
});