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
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.
+16
View File
@@ -115,6 +115,9 @@ CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
thumbnail TEXT,
is_private INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, name)
@@ -126,12 +129,25 @@ CREATE TABLE IF NOT EXISTS playlist_items (
provider TEXT NOT NULL,
video_id TEXT NOT NULL,
title TEXT,
thumbnail TEXT,
added_at TEXT NOT NULL,
position INTEGER NOT NULL,
UNIQUE(playlist_id, provider, video_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_items_order ON playlist_items(playlist_id, position);
-- Playlist metrics -----------------------------------------------------------
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, -- create|update|delete|add_video|remove_video|reorder
meta_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_playlist_metrics_user_time ON playlist_metrics(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_playlist_metrics_playlist_time ON playlist_metrics(playlist_id, created_at DESC);
-- Tags (optional) -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
+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);
});
+5 -1
View File
@@ -1,4 +1,3 @@
import { Routes } from '@angular/router';
export const APP_ROUTES: Routes = [
@@ -45,6 +44,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => import('./components/library/playlists/playlists.component').then(m => m.PlaylistsComponent),
title: 'NewTube - Playlists'
},
{
path: 'library/playlists/:id',
loadComponent: () => import('./components/library/playlists/playlist-detail.component').then(m => m.PlaylistDetailComponent),
title: 'NewTube - Playlist'
},
{
path: 'library/liked',
loadComponent: () => import('./components/library/liked/liked.component').then(m => m.LikedComponent),
@@ -0,0 +1,158 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/playlists.service';
@Component({
selector: 'app-playlist-detail',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="container mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between gap-3 mb-4">
<a routerLink="/library/playlists" class="text-slate-300 hover:text-slate-100">← Retour</a>
<div class="flex items-center gap-2" *ngIf="dirty()">
<button (click)="saveOrder()" [disabled]="saving()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold">
{{ saving() ? 'Enregistrement...' : 'Enregistrer l\'ordre' }}
</button>
</div>
</div>
<ng-container *ngIf="loading(); else bodyTpl">
<div class="animate-pulse">
<div class="h-8 bg-slate-700 w-1/2 rounded mb-4"></div>
<div class="h-4 bg-slate-700 w-1/3 rounded mb-6"></div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="h-24 bg-slate-800 rounded"></div>
<div class="h-24 bg-slate-800 rounded"></div>
<div class="h-24 bg-slate-800 rounded"></div>
<div class="h-24 bg-slate-800 rounded"></div>
</div>
</div>
</ng-container>
<ng-template #bodyTpl>
<ng-container *ngIf="error(); else contentTpl">
<div class="bg-red-900/40 border border-red-700 text-red-300 p-4 rounded">{{ error() }}</div>
</ng-container>
</ng-template>
<ng-template #contentTpl>
<div class="flex flex-col sm:flex-row gap-4 mb-6">
<div class="w-full sm:w-64">
<div class="rounded overflow-hidden border border-slate-700 bg-slate-800">
<img *ngIf="playlist()?.thumbnail; else noThumb" [src]="playlist()!.thumbnail!" alt="" class="w-full h-40 object-cover"/>
<ng-template #noThumb>
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800"></div>
</ng-template>
</div>
</div>
<div class="flex-1">
<h1 class="text-2xl font-bold text-slate-100">{{ playlist()?.title }}</h1>
<div class="text-slate-400 mt-1 text-sm">
{{ playlist()?.itemsCount || 0 }} vidéo(s) • {{ (playlist()?.updatedAt || '').slice(0,10) }}
<span class="ml-2 text-xs px-2 py-1 rounded border" [ngClass]="playlist()?.isPrivate ? 'border-slate-600 text-slate-300' : 'border-emerald-700 text-emerald-300'">{{ playlist()?.isPrivate ? 'Privée' : 'Publique' }}</span>
</div>
<p class="mt-2 text-slate-300 whitespace-pre-wrap">{{ playlist()?.description }}</p>
</div>
</div>
<div class="space-y-3">
<div *ngFor="let it of items(); let i = index" class="flex items-center gap-3 p-2 rounded border border-slate-700 bg-slate-800/60">
<div class="w-28 flex-shrink-0">
<img *ngIf="it.thumbnail; else noThumb2" [src]="it.thumbnail!" alt="" class="w-28 h-16 object-cover rounded">
<ng-template #noThumb2>
<div class="w-28 h-16 bg-slate-700 rounded"></div>
</ng-template>
</div>
<div class="flex-1">
<a [routerLink]="['/watch', it.videoId]" [queryParams]="{ p: it.provider }" class="font-semibold text-slate-100 hover:text-red-400 line-clamp-2">{{ it.title || it.videoId }}</a>
<div class="text-xs text-slate-400 mt-1">{{ it.provider }} • Pos. {{ i + 1 }}</div>
</div>
<div class="flex items-center gap-2">
<button (click)="moveUp(i)" [disabled]="i===0" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↑</button>
<button (click)="moveDown(i)" [disabled]="i===items().length-1" class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">↓</button>
<button (click)="removeItem(it)" class="px-2 py-1 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Retirer</button>
</div>
</div>
</div>
</ng-template>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistDetailComponent {
private route = inject(ActivatedRoute);
private api = inject(PlaylistsService);
loading = signal<boolean>(true);
error = signal<string | null>(null);
playlist = signal<(Playlist & { items?: PlaylistItem[] }) | null>(null);
items = signal<PlaylistItem[]>([]);
dirty = signal<boolean>(false);
saving = signal<boolean>(false);
private id = signal<string>('');
constructor() {
const pid = this.route.snapshot.paramMap.get('id') || '';
this.id.set(pid);
this.load(pid);
}
load(id: string) {
this.loading.set(true);
this.error.set(null);
this.api.get(id, 1000, 0).subscribe({
next: (pl) => {
this.playlist.set(pl);
this.items.set((pl.items || []).slice().sort((a, b) => a.position - b.position));
this.loading.set(false);
this.dirty.set(false);
},
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
});
}
moveUp(i: number) {
if (i <= 0) return;
const arr = this.items().slice();
const tmp = arr[i-1];
arr[i-1] = arr[i];
arr[i] = tmp;
this.items.set(arr);
this.dirty.set(true);
}
moveDown(i: number) {
const arr = this.items().slice();
if (i >= arr.length - 1) return;
const tmp = arr[i+1];
arr[i+1] = arr[i];
arr[i] = tmp;
this.items.set(arr);
this.dirty.set(true);
}
saveOrder() {
if (!this.dirty() || !this.id()) return;
this.saving.set(true);
const order = this.items().map(it => it.id);
this.api.reorder(this.id(), order).subscribe({
next: () => { this.saving.set(false); this.dirty.set(false); },
error: (err) => { this.saving.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'enregistrement'); }
});
}
removeItem(it: PlaylistItem) {
const ok = confirm('Retirer cette vidéo de la playlist ?');
if (!ok) return;
this.api.removeVideo(this.id(), it.provider, it.videoId).subscribe({
next: () => {
this.items.set(this.items().filter(x => !(x.provider === it.provider && x.videoId === it.videoId)));
this.dirty.set(true);
},
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec du retrait')
});
}
}
@@ -1,6 +1,107 @@
<div class="container mx-auto p-4 sm:p-6">
<h2 class="text-3xl font-bold mb-6 text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2>
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Bientôt disponible: vos listes de lecture centralisées, tous providers confondus.</p>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<h2 class="text-3xl font-bold text-slate-100 border-l-4 border-red-500 pl-4">Listes de lecture</h2>
<div class="flex items-center gap-3 w-full sm:w-auto">
<input type="text" class="flex-1 sm:w-64 bg-slate-900 text-slate-100 border border-slate-700 rounded px-3 py-2"
placeholder="Rechercher..."
[value]="searchQuery()"
(input)="onSearchChange($event)"
/>
<button (click)="openCreate()" class="bg-red-600 hover:bg-red-500 text-white font-semibold px-4 py-2 rounded">
Nouvelle playlist
</button>
</div>
</div>
@if (loading()) {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (i of [1,2,3,4,5,6,7,8]; track i) {
<div class="animate-pulse bg-slate-800 rounded-lg overflow-hidden border border-slate-700">
<div class="w-full h-40 bg-slate-700"></div>
<div class="p-4 space-y-3">
<div class="h-5 bg-slate-700 rounded w-3/4"></div>
<div class="h-4 bg-slate-700 rounded w-1/2"></div>
</div>
</div>
}
</div>
} @else if (error()) {
<div class="bg-red-900/40 border border-red-700 text-red-300 p-4 rounded">{{ error() }}</div>
} @else if (playlists().length === 0) {
<div class="bg-slate-800/50 rounded-lg p-6 border border-slate-700">
<p class="text-slate-300">Aucune playlist pour le moment. Créez-en une pour organiser vos vidéos.</p>
</div>
} @else {
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
@for (pl of playlists(); track pl.id) {
<div class="bg-slate-800/70 rounded-lg overflow-hidden border border-slate-700 flex flex-col">
<a [routerLink]="['/library/playlists', pl.id]" class="block group">
<div class="relative">
@if (pl.thumbnail) {
<img [src]="pl.thumbnail!" alt="" class="w-full h-40 object-cover group-hover:opacity-90 transition" />
} @else {
<div class="w-full h-40 bg-gradient-to-br from-slate-700 to-slate-800 flex items-center justify-center text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10" viewBox="0 0 24 24" fill="currentColor"><path d="M4 5a2 2 0 00-2 2v7.5A2.5 2.5 0 004.5 17H20a2 2 0 002-2V7a2 2 0 00-2-2H4zm0 2h16v8H4.5a.5.5 0 01-.5-.5V7z"/><path d="M12 8.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z"/></svg>
</div>
}
</div>
</a>
<div class="p-4 flex-1 flex flex-col">
<div class="flex items-start justify-between gap-3">
<a [routerLink]="['/library/playlists', pl.id]" class="font-semibold text-slate-100 hover:text-red-400 transition line-clamp-2">{{ pl.title }}</a>
<span class="text-xs px-2 py-1 rounded border" [ngClass]="pl.isPrivate ? 'border-slate-600 text-slate-300' : 'border-emerald-700 text-emerald-300'">
{{ pl.isPrivate ? 'Privée' : 'Publique' }}
</span>
</div>
<div class="mt-2 text-sm text-slate-400 flex items-center justify-between">
<span>{{ pl.itemsCount || 0 }} vidéo(s)</span>
<span>MAJ: {{ (pl.updatedAt || '').slice(0,10) }}</span>
</div>
<div class="mt-3 flex items-center gap-2">
<button (click)="openEdit(pl)" class="px-3 py-1.5 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-sm">Modifier</button>
<button (click)="deletePlaylist(pl)" class="px-3 py-1.5 rounded bg-red-700 hover:bg-red-600 text-white text-sm">Supprimer</button>
</div>
</div>
</div>
}
</div>
}
@if (modalOpen()) {
<div class="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true">
<div class="w-full max-w-lg bg-slate-900 border border-slate-700 rounded-lg shadow-xl">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-100">{{ editId() ? 'Modifier la playlist' : 'Nouvelle playlist' }}</h3>
<button (click)="closeModal()" class="text-slate-400 hover:text-slate-200">✕</button>
</div>
<div class="p-4 space-y-4">
<div>
<label class="block text-sm text-slate-400 mb-1">Titre</label>
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
[value]="formTitle()" (input)="onTitleChange($event)" />
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">Description</label>
<textarea rows="3" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
[value]="formDescription()" (input)="onDescriptionChange($event)"></textarea>
</div>
<div>
<label class="block text-sm text-slate-400 mb-1">URL de l'image</label>
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-3 py-2"
[value]="formThumbnail()" (input)="onThumbnailChange($event)" />
</div>
<div class="flex items-center gap-2">
<input id="priv" type="checkbox" class="accent-red-500" [checked]="formPrivate()" (change)="onPrivateChange($event)" />
<label for="priv" class="text-sm text-slate-300">Privée</label>
</div>
</div>
<div class="p-4 border-t border-slate-700 flex items-center justify-end gap-3">
<button (click)="closeModal()" class="px-4 py-2 rounded bg-slate-700 hover:bg-slate-600 text-slate-100" [disabled]="busySave()">Annuler</button>
<button (click)="savePlaylist()" class="px-4 py-2 rounded bg-red-600 hover:bg-red-500 text-white font-semibold disabled:opacity-60" [disabled]="busySave() || !formTitle().trim()">
{{ busySave() ? 'Enregistrement...' : 'Enregistrer' }}
</button>
</div>
</div>
</div>
}
</div>
@@ -1,11 +1,132 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-library-playlists',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './playlists.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistsComponent { }
export class PlaylistsComponent {
private http = inject(HttpClient);
// UI state
loading = signal<boolean>(true);
error = signal<string | null>(null);
searchQuery = signal<string>('');
playlists = signal<Array<{ id: string; title: string; description?: string | null; thumbnail?: string | null; isPrivate: number | boolean; createdAt: string; updatedAt: string; itemsCount: number }>>([]);
// Modal state
modalOpen = signal<boolean>(false);
editId = signal<string | null>(null);
formTitle = signal<string>('');
formDescription = signal<string>('');
formThumbnail = signal<string>('');
formPrivate = signal<boolean>(true);
busySave = signal<boolean>(false);
constructor() {
this.reload();
}
private apiBase(): string {
try {
const port = window?.location?.port || '';
return port && port !== '4000' ? '/proxy/api' : '/api';
} catch { return '/api'; }
}
reload(q?: string): void {
this.loading.set(true);
this.error.set(null);
const params: any = { limit: 200 };
if (q && q.trim()) params.q = q.trim();
this.http.get<any[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true }).subscribe({
next: (rows) => { this.playlists.set(rows || []); this.loading.set(false); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
});
}
onSearchChange(event: Event) {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
const q = input.value.trim();
this.reload(q || undefined);
}
onTitleChange(event: Event) {
const input = event.target as HTMLInputElement;
this.formTitle.set(input.value);
}
onDescriptionChange(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
this.formDescription.set(textarea.value);
}
onThumbnailChange(event: Event) {
const input = event.target as HTMLInputElement;
this.formThumbnail.set(input.value);
}
onPrivateChange(event: Event) {
const input = event.target as HTMLInputElement;
this.formPrivate.set(input.checked);
}
openCreate() {
this.editId.set(null);
this.formTitle.set('');
this.formDescription.set('');
this.formThumbnail.set('');
this.formPrivate.set(true);
this.modalOpen.set(true);
}
openEdit(pl: any) {
this.editId.set(pl.id);
this.formTitle.set(pl.title || '');
this.formDescription.set(pl.description || '');
this.formThumbnail.set(pl.thumbnail || '');
this.formPrivate.set(!!pl.isPrivate);
this.modalOpen.set(true);
}
closeModal() {
if (this.busySave()) return;
this.modalOpen.set(false);
}
savePlaylist() {
if (!this.formTitle().trim()) return;
this.busySave.set(true);
const body = {
title: this.formTitle().trim(),
description: this.formDescription().trim() || undefined,
thumbnail: this.formThumbnail().trim() || undefined,
isPrivate: !!this.formPrivate()
};
const id = this.editId();
const req = id
? this.http.put(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, body, { withCredentials: true })
: this.http.post(`${this.apiBase()}/playlists`, body, { withCredentials: true });
req.subscribe({
next: () => { this.busySave.set(false); this.modalOpen.set(false); this.reload(this.searchQuery().trim() || undefined); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Échec de l’enregistrement'); this.busySave.set(false); }
});
}
deletePlaylist(pl: any) {
if (!pl?.id) return;
const ok = confirm(`Supprimer la playlist "${pl.title}" ?`);
if (!ok) return;
this.http.delete(`${this.apiBase()}/playlists/${encodeURIComponent(pl.id)}`, { withCredentials: true }).subscribe({
next: () => this.reload(this.searchQuery().trim() || undefined),
error: (err) => this.error.set(err?.error?.error || err?.message || 'Échec de la suppression')
});
}
}
@@ -56,6 +56,7 @@
</div>
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
@@ -92,6 +93,7 @@
</div>
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="'twitch'" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="'twitch'" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
@@ -128,6 +130,7 @@
</div>
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="video.videoId" [provider]="selectedProviderForView()" [title]="video.title" [thumbnail]="video.thumbnail"></app-like-button>
<app-add-to-playlist class="ml-2" [provider]="selectedProviderForView()" [videoId]="video.videoId" [title]="video.title" [thumbnail]="video.thumbnail"></app-add-to-playlist>
</div>
</div>
<div class="p-4 flex-grow flex flex-col">
+2 -1
View File
@@ -10,13 +10,14 @@ import { HistoryService } from '../../services/history.service';
import { Title } from '@angular/platform-browser';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
@Component({
selector: 'app-search',
standalone: true,
templateUrl: './search.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent]
imports: [CommonModule, RouterLink, InfiniteAnchorComponent, TranslatePipe, LikeButtonComponent, AddToPlaylistComponent]
})
export class SearchComponent {
private route = inject(ActivatedRoute);
@@ -0,0 +1,158 @@
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, SimpleChanges, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HttpClientModule } from '@angular/common/http';
import { PlaylistsService, Playlist } from '../../../../services/playlists.service';
import { AuthService } from '../../../../services/auth.service';
@Component({
selector: 'app-add-to-playlist',
standalone: true,
imports: [CommonModule, HttpClientModule],
template: `
<div class="relative inline-block text-left" (click)="$event.stopPropagation();">
<button (click)="toggleOpen($event)"
[disabled]="!isAuthenticated()"
class="transition-colors duration-200 p-1 rounded-full hover:bg-slate-700/50 flex items-center justify-center text-slate-300 disabled:opacity-60"
[attr.aria-expanded]="open()"
[attr.aria-haspopup]="true"
[attr.title]="!isAuthenticated() ? 'Connectez-vous pour gérer vos playlists' : 'Ajouter à une playlist'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 6a2 2 0 012-2h12a1 1 0 010 2H6v10a1 1 0 11-2 0V6z" />
<path d="M14 11a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM14 15a1 1 0 011-1h5a1 1 0 110 2h-5a1 1 0 01-1-1zM10 11a1 1 0 112 0v1h1a1 1 0 110 2h-1v1a1 1 0 11-2 0v-1H9a1 1 0 110-2h1v-1z" />
</svg>
<span class="sr-only">Ajouter à une playlist</span>
</button>
@if (open()) {
<div class="absolute right-0 mt-2 w-72 origin-top-right rounded-md bg-slate-900 border border-slate-700 shadow-lg focus:outline-none z-50">
<div class="p-3 space-y-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-slate-200">Vos playlists</div>
<button class="text-xs text-slate-400 hover:text-slate-200" (click)="reloadPlaylists()">Rafraîchir</button>
</div>
@if (loading()) {
<div class="text-sm text-slate-400">Chargement...</div>
} @else if (error()) {
<div class="text-sm text-red-300">{{ error() }}</div>
} @else if (playlists().length === 0) {
<div class="text-sm text-slate-400">Aucune playlist. Créez-en une ci-dessous.</div>
} @else {
<div class="max-h-48 overflow-auto space-y-2">
@for (pl of playlists(); track pl.id) {
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="truncate text-sm text-slate-200">{{ pl.title }}</div>
<div class="text-xs text-slate-400">{{ pl.itemsCount || 0 }} vidéo(s)</div>
</div>
<button class="px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-100 text-xs"
[disabled]="busyAdd()"
(click)="onAddTo(pl)">Ajouter</button>
</div>
}
</div>
}
<div class="border-t border-slate-700 pt-2">
<label class="block text-xs text-slate-400 mb-1">Nouvelle playlist</label>
<input type="text" class="w-full bg-slate-950 text-slate-100 border border-slate-700 rounded px-2 py-1 text-sm"
[value]="newTitle()" (input)="onTitleChange($event)" placeholder="Titre de la playlist" />
<div class="mt-2 flex items-center justify-end gap-2">
<label class="flex items-center gap-2 text-xs text-slate-300">
<input type="checkbox" class="accent-red-500" [checked]="newPrivate()" (change)="onPrivateChange($event)" />
Privée
</label>
<button class="px-3 py-1.5 rounded bg-red-600 hover:bg-red-500 text-white text-xs font-semibold disabled:opacity-60"
[disabled]="!newTitle().trim() || busyAdd()"
(click)="onQuickCreate()">Créer + Ajouter</button>
</div>
</div>
</div>
</div>
}
</div>
`,
styles: [
`
.sr-only { position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0; }
`
],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AddToPlaylistComponent implements OnInit, OnDestroy {
private api = inject(PlaylistsService);
private auth = inject(AuthService);
@Input() provider: string = 'youtube';
@Input() videoId: string = '';
@Input() title?: string;
@Input() thumbnail?: string;
open = signal<boolean>(false);
loading = signal<boolean>(false);
error = signal<string | null>(null);
playlists = signal<Playlist[]>([]);
busyAdd = signal<boolean>(false);
newTitle = signal<string>('');
newPrivate = signal<boolean>(true);
private destroyed = false;
ngOnInit(): void {}
ngOnDestroy(): void { this.destroyed = true; }
isAuthenticated(): boolean { return !!this.auth.currentUser(); }
toggleOpen(ev: Event): void {
try { ev.preventDefault(); ev.stopPropagation(); } catch {}
if (!this.isAuthenticated()) return;
const next = !this.open();
this.open.set(next);
if (next && this.playlists().length === 0 && !this.loading()) {
this.reloadPlaylists();
}
}
reloadPlaylists(): void {
this.loading.set(true);
this.error.set(null);
this.api.list(200, 0).subscribe({
next: (rows) => { if (!this.destroyed) { this.playlists.set(rows || []); this.loading.set(false); } },
error: (err) => { if (!this.destroyed) { this.error.set(err?.error?.error || err?.message || 'Erreur'); this.loading.set(false); } }
});
}
onAddTo(pl: Playlist): void {
if (this.busyAdd() || !pl?.id) return;
this.busyAdd.set(true);
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({
next: () => { this.busyAdd.set(false); this.open.set(false); },
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); }
});
}
onQuickCreate(): void {
const t = this.newTitle().trim();
if (!t) return;
if (this.busyAdd()) return;
this.busyAdd.set(true);
this.api.create(t, undefined, undefined, this.newPrivate()).subscribe({
next: (pl) => {
this.api.addVideo(pl.id, this.provider, this.videoId, this.title, this.thumbnail).subscribe({
next: () => { this.busyAdd.set(false); this.open.set(false); this.newTitle.set(''); },
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de l\'ajout'); }
});
},
error: (err) => { this.busyAdd.set(false); this.error.set(err?.error?.error || err?.message || 'Échec de la création'); }
});
}
onTitleChange(event: Event) {
const input = event.target as HTMLInputElement;
this.newTitle.set(input.value);
}
onPrivateChange(event: Event) {
const input = event.target as HTMLInputElement;
this.newPrivate.set(input.checked);
}
}
@@ -94,6 +94,9 @@
<span>{{ liked() ? 'Liked' : 'Like' }}</span>
</button>
}
@if (isLoggedIn()) {
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
}
@if (isLoggedIn()) {
<button (click)="openDownloadPanel()"
class="flex items-center space-x-2 bg-red-600 hover:bg-red-500 px-4 py-2 rounded-full transition font-semibold">
+3 -2
View File
@@ -5,6 +5,7 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { YoutubeApiService } from '../../services/youtube-api.service';
import { Video, VideoDetail } from '../../models/video.model';
import { VideoPlayerComponent } from '../video-player/video-player.component';
import { AddToPlaylistComponent } from '../shared/components/add-to-playlist/add-to-playlist.component';
import { Subscription } from 'rxjs';
import { GeminiService } from '../../services/gemini.service';
import { LikesService } from '../../services/likes.service';
@@ -21,7 +22,7 @@ import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
templateUrl: './watch.component.html',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, VideoPlayerComponent, RouterLink]
imports: [CommonModule, VideoPlayerComponent, RouterLink, AddToPlaylistComponent]
})
export class WatchComponent implements OnDestroy, AfterViewInit {
private route = inject(ActivatedRoute);
@@ -165,7 +166,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
if (directUrl) return directUrl;
if (id) return `https://rumble.com/${encodeURIComponent(id)}`;
}
return directUrl;
return directUrl || null;
} catch {
return directUrl || null;
}
+80
View File
@@ -0,0 +1,80 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface Playlist {
id: string;
userId?: string;
title: string;
description?: string | null;
thumbnail?: string | null;
isPrivate: boolean | number;
createdAt: string;
updatedAt: string;
itemsCount?: number;
}
export interface PlaylistItem {
id: string;
playlistId: string;
provider: string;
videoId: string;
title?: string | null;
thumbnail?: string | null;
addedAt: string;
position: number;
}
@Injectable({ providedIn: 'root' })
export class PlaylistsService {
private http = inject(HttpClient);
private apiBase(): string {
try {
const port = window?.location?.port || '';
return port && port !== '4000' ? '/proxy/api' : '/api';
} catch {
return '/api';
}
}
list(limit = 100, offset = 0, q?: string): Observable<Playlist[]> {
let params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
if (q && q.trim().length) params = params.set('q', q.trim());
return this.http.get<Playlist[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
}
get(id: string, limit = 200, offset = 0): Observable<Playlist & { items: PlaylistItem[] }> {
const params = new HttpParams().set('limit', String(limit)).set('offset', String(offset));
return this.http.get<Playlist & { items: PlaylistItem[] }>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, { params, withCredentials: true });
}
create(title: string, description?: string, thumbnail?: string, isPrivate: boolean = true): Observable<Playlist> {
return this.http.post<Playlist>(`${this.apiBase()}/playlists`, { title, description, thumbnail, isPrivate }, { withCredentials: true });
}
update(id: string, patch: Partial<{ title: string; description: string; thumbnail: string; isPrivate: boolean }>): Observable<Playlist> {
return this.http.put<Playlist>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, patch, { withCredentials: true });
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiBase()}/playlists/${encodeURIComponent(id)}`, { withCredentials: true });
}
addVideo(playlistId: string, provider: string, videoId: string, title?: string, thumbnail?: string, opts?: { slug?: string; instance?: string; sourceUrl?: string }): Observable<PlaylistItem> {
const body: any = { provider, videoId, title, thumbnail };
if (opts?.slug) body.slug = opts.slug;
if (opts?.instance) body.instance = opts.instance;
if (opts?.sourceUrl) body.sourceUrl = opts.sourceUrl;
return this.http.post<PlaylistItem>(`${this.apiBase()}/playlists/${encodeURIComponent(playlistId)}/videos`, body, { withCredentials: true });
}
removeVideo(playlistId: string, provider: string, videoId: string): Observable<{ removed: boolean }> {
const params = new HttpParams().set('provider', provider);
return this.http.delete<{ removed: boolean }>(`${this.apiBase()}/playlists/${encodeURIComponent(playlistId)}/videos/${encodeURIComponent(videoId)}`, { params, withCredentials: true });
}
reorder(playlistId: string, order: Array<string | { id?: string; provider?: string; videoId?: string }>): Observable<{ changed: number }> {
return this.http.put<{ changed: number }>(`${this.apiBase()}/playlists/${encodeURIComponent(playlistId)}/reorder`, { order }, { withCredentials: true });
}
}