chore: update TypeScript build info cache

This commit is contained in:
Bruno Charest 2025-09-18 22:20:52 -04:00
parent bcfb46ef7a
commit 41255a8126
22 changed files with 529 additions and 665 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,61 +1,61 @@
{
"hash": "3cecccc1",
"hash": "70aeb477",
"configHash": "d859ec53",
"lockfileHash": "80653b50",
"browserHash": "cc6248f8",
"lockfileHash": "38d89503",
"browserHash": "a9625742",
"optimized": {
"@angular/common": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/common.mjs",
"file": "@angular_common.js",
"fileHash": "c6e29582",
"fileHash": "0c3a1cb9",
"needsInterop": false
},
"@angular/common/http": {
"src": "../../../../../../node_modules/@angular/common/fesm2022/http.mjs",
"file": "@angular_common_http.js",
"fileHash": "3a2fb905",
"fileHash": "56f4d3d3",
"needsInterop": false
},
"@angular/core": {
"src": "../../../../../../node_modules/@angular/core/fesm2022/core.mjs",
"file": "@angular_core.js",
"fileHash": "236bf4f0",
"fileHash": "d890be7e",
"needsInterop": false
},
"@angular/forms": {
"src": "../../../../../../node_modules/@angular/forms/fesm2022/forms.mjs",
"file": "@angular_forms.js",
"fileHash": "910649fe",
"fileHash": "905d0ee2",
"needsInterop": false
},
"@angular/platform-browser": {
"src": "../../../../../../node_modules/@angular/platform-browser/fesm2022/platform-browser.mjs",
"file": "@angular_platform-browser.js",
"fileHash": "669d4d15",
"fileHash": "44676ec1",
"needsInterop": false
},
"@angular/router": {
"src": "../../../../../../node_modules/@angular/router/fesm2022/router.mjs",
"file": "@angular_router.js",
"fileHash": "697a6c94",
"fileHash": "c691e369",
"needsInterop": false
},
"@google/genai": {
"src": "../../../../../../node_modules/@google/genai/dist/web/index.mjs",
"file": "@google_genai.js",
"fileHash": "3ef22c22",
"fileHash": "cd430349",
"needsInterop": false
},
"rxjs": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/index.js",
"file": "rxjs.js",
"fileHash": "9c55814f",
"fileHash": "467e2c35",
"needsInterop": false
},
"rxjs/operators": {
"src": "../../../../../../node_modules/rxjs/dist/esm5/operators/index.js",
"file": "rxjs_operators.js",
"fileHash": "4e738648",
"fileHash": "9aa95ff5",
"needsInterop": false
}
},

View File

@ -1,8 +1,8 @@
{
"hash": "75f2f65a",
"hash": "be6806eb",
"configHash": "3d00a7fd",
"lockfileHash": "80653b50",
"browserHash": "f89b65f9",
"lockfileHash": "38d89503",
"browserHash": "f3292f98",
"optimized": {},
"chunks": {}
}

Binary file not shown.

View File

@ -8,7 +8,8 @@
"build": "ng build",
"preview": "ng serve --configuration=production",
"api": "node --env-file=.env.local ./server/index.mjs",
"api:watch": "node --watch --env-file=.env.local ./server/index.mjs"
"api:watch": "node --watch --env-file=.env.local ./server/index.mjs",
"test:playlists": "node ./server/tests/playlist_visibility.test.mjs"
},
"dependencies": {
"@angular/build": "^20.1.0",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 1003 KiB

After

Width:  |  Height:  |  Size: 1003 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -4,9 +4,12 @@ import { randomBytes, randomUUID } from 'node:crypto';
import Database from 'better-sqlite3';
const root = process.cwd();
const dbDir = path.join(root, 'db');
const dbFile = path.join(dbDir, 'newtube.db');
const schemaFile = path.join(dbDir, 'schema.sql');
const overrideDbFile = process.env.NEWTUBE_DB_FILE && String(process.env.NEWTUBE_DB_FILE).trim().length
? path.resolve(String(process.env.NEWTUBE_DB_FILE))
: null;
const dbDir = overrideDbFile ? path.dirname(overrideDbFile) : path.join(root, 'db');
const dbFile = overrideDbFile || path.join(dbDir, 'newtube.db');
const schemaFile = path.join(root, 'db', 'schema.sql');
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
@ -460,11 +463,39 @@ export function listPlaylists({ userId, limit = 50, offset = 0, q }) {
: db.prepare(sql).all(userId, limit, offset);
}
// List public playlists (visible to everyone)
export function listPublicPlaylists({ 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 is_private = 0`;
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(like, like, limit, offset)
: db.prepare(sql).all(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);
}
// Get playlist with items if allowed for a given viewer (owner or public)
export function getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit = 200, offset = 0 }) {
const pl = 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);
if (!pl) return null;
const isOwner = viewerUserId && pl.userId === viewerUserId;
if (!isOwner && Number(pl.isPrivate) === 1) return 'forbidden';
const items = 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(id, limit, offset);
return { ...pl, items };
}
export function updatePlaylist({ userId, id, patch }) {
const cur = db.prepare(`SELECT * FROM playlists WHERE id = ?`).get(id);
if (!cur) return null;

View File

@ -44,7 +44,9 @@ import {
// Playlists
createPlaylist,
listPlaylists,
listPublicPlaylists,
getPlaylistRaw,
getPlaylistWithItemsIfAllowed,
updatePlaylist,
deletePlaylist,
listPlaylistItems,
@ -60,6 +62,46 @@ const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15);
const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2);
const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30);
// Create router
const r = express.Router();
// Public: list public playlists (no auth required)
r.get('/playlists/public', (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 = listPublicPlaylists({ limit, offset, q });
return res.json(rows);
} catch (e) {
return res.status(500).json({ error: 'list_public_failed', details: String(e?.message || e) });
}
});
// Public: view a playlist if allowed (owner or public). Authorization header is optional.
r.get('/playlists/:id/view', (req, res) => {
try {
const id = String(req.params.id || '');
let viewerUserId = undefined;
try {
const auth = req.headers['authorization'] || '';
const [, token] = String(auth).split(' ');
if (token) {
const payload = jwt.verify(token, JWT_SECRET);
viewerUserId = payload?.sub;
}
} catch {}
const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500)));
const offset = Math.max(0, Number(req.query.offset || 0));
const result = getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit, offset });
if (result === 'forbidden') return res.status(404).json({ error: 'not_found' });
if (!result) return res.status(404).json({ error: 'not_found' });
return res.json(result);
} catch (e) {
return res.status(500).json({ error: 'view_failed', details: String(e?.message || e) });
}
});
// Servir les fichiers statiques du dossier dist
app.use(express.static(path.join(process.cwd(), 'dist')));
app.use('/assets', express.static(path.join(process.cwd(), 'assets')));
@ -528,7 +570,6 @@ function formatListFromMeta(meta) {
}
// Routes under /api
const r = express.Router();
// -------------------- YouTube simple cache (GET) --------------------
// Cache TTL defaults to 15 minutes, configurable via env YT_CACHE_TTL_MS

View File

@ -0,0 +1,94 @@
// Simple integration test for playlist public/private visibility
// Run with: npm run test:playlists
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
// Create isolated temp DB file
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'newtube-test-'));
const dbPath = path.join(tmpDir, 'test.db');
process.env.NEWTUBE_DB_FILE = dbPath;
// Dynamically import DB after setting env
const dbMod = await import('../db.mjs');
const {
insertUser,
cryptoRandomUUID,
createPlaylist,
listPublicPlaylists,
listPlaylists,
getPlaylistWithItemsIfAllowed,
} = dbMod;
function expect(cond, msg) {
if (!cond) {
throw new Error(`Assertion failed: ${msg}`);
}
}
function logOk(msg) {
console.log(`${msg}`);
}
// Seed users
const ownerId = cryptoRandomUUID();
const otherId = cryptoRandomUUID();
insertUser({ id: ownerId, username: 'owner', email: 'owner@example.com', passwordHash: 'x' });
insertUser({ id: otherId, username: 'other', email: 'other@example.com', passwordHash: 'y' });
// Seed playlists
const pub1 = createPlaylist({ userId: ownerId, title: 'Pub A', description: 'public', thumbnail: null, isPrivate: false });
const pub2 = createPlaylist({ userId: ownerId, title: 'Pub B', description: 'public', thumbnail: null, isPrivate: 0 });
const priv1 = createPlaylist({ userId: ownerId, title: 'Priv A', description: 'private', thumbnail: null, isPrivate: true });
// 1) Anonymous lists public
{
const pubs = listPublicPlaylists({ limit: 50, offset: 0 });
expect(Array.isArray(pubs), 'listPublicPlaylists returns array');
const ids = new Set(pubs.map(p => p.id));
expect(ids.has(pub1.id) && ids.has(pub2.id), 'public playlists visible');
expect(!ids.has(priv1.id), 'private playlist not visible');
logOk('Anonymous sees public playlists only');
}
// 2) Other user lists own (none) + sees public via listPublicPlaylists
{
const ownOther = listPlaylists({ userId: otherId, limit: 50, offset: 0 });
expect(ownOther.length === 0, 'other user has no own playlists');
const pubs = listPublicPlaylists({ limit: 50, offset: 0 });
const ids = new Set(pubs.map(p => p.id));
expect(ids.has(pub1.id) && ids.has(pub2.id), 'other user sees public playlists');
logOk('Other user sees public playlists');
}
// 3) Owner lists own (public + private)
{
const ownOwner = listPlaylists({ userId: ownerId, limit: 50, offset: 0 });
const ids = new Set(ownOwner.map(p => p.id));
expect(ids.has(pub1.id) && ids.has(pub2.id) && ids.has(priv1.id), 'owner sees all own playlists, including private');
logOk('Owner sees own public + private playlists');
}
// 4) Public view endpoint logic: getPlaylistWithItemsIfAllowed
{
// Anonymous can view public
const viewPub = getPlaylistWithItemsIfAllowed({ viewerUserId: undefined, id: pub1.id, limit: 10, offset: 0 });
expect(viewPub && viewPub.id === pub1.id, 'anonymous can view public playlist details');
// Anonymous cannot view private
const viewPriv = getPlaylistWithItemsIfAllowed({ viewerUserId: undefined, id: priv1.id, limit: 10, offset: 0 });
expect(viewPriv === 'forbidden', 'anonymous cannot view private playlist');
// Other user can view public
const viewPubOther = getPlaylistWithItemsIfAllowed({ viewerUserId: otherId, id: pub2.id, limit: 10, offset: 0 });
expect(viewPubOther && viewPubOther.id === pub2.id, 'other user can view public playlist');
// Owner can view private
const viewPrivOwner = getPlaylistWithItemsIfAllowed({ viewerUserId: ownerId, id: priv1.id, limit: 10, offset: 0 });
expect(viewPrivOwner && viewPrivOwner.id === priv1.id, 'owner can view own private playlist');
logOk('Public view behavior correct for anonymous, other, and owner');
}
console.log('\nAll playlist visibility tests passed.');

View File

@ -1,7 +1,9 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/playlists.service';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-playlist-detail',
@ -11,7 +13,7 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
<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()">
<div class="flex items-center gap-2" *ngIf="dirty() && isOwner()">
<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>
@ -41,10 +43,7 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
<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>
<img [src]="playlist()?.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="playlist()?.title || 'Playlist'" class="w-full h-40 object-cover"/>
</div>
</div>
<div class="flex-1">
@ -60,16 +59,13 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
<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>
<img [src]="it.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="it.title || 'Video thumbnail'" class="w-28 h-16 object-cover rounded">
</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">
<div class="flex items-center gap-2" *ngIf="isOwner()">
<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>
@ -82,8 +78,16 @@ import { PlaylistsService, Playlist, PlaylistItem } from '../../../services/play
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistDetailComponent {
// Default image path
readonly DEFAULT_PLAYLIST_IMAGE = '/images/default_playlist.png';
private route = inject(ActivatedRoute);
private router = inject(Router);
private http = inject(HttpClient);
private api = inject(PlaylistsService);
private auth = inject(AuthService);
// Expose a bound accessor to avoid losing 'this' context
currentUser = () => this.auth.currentUser();
loading = signal<boolean>(true);
error = signal<string | null>(null);
@ -91,6 +95,7 @@ export class PlaylistDetailComponent {
items = signal<PlaylistItem[]>([]);
dirty = signal<boolean>(false);
saving = signal<boolean>(false);
isOwner = signal<boolean>(false);
private id = signal<string>('');
@ -103,14 +108,24 @@ export class PlaylistDetailComponent {
load(id: string) {
this.loading.set(true);
this.error.set(null);
const onLoad = (pl: any) => {
this.playlist.set(pl);
this.items.set((pl.items || []).slice().sort((a: PlaylistItem, b: PlaylistItem) => a.position - b.position));
const user = this.currentUser();
const uid = user?.id;
this.isOwner.set(Boolean(uid && pl && pl.userId === uid));
this.loading.set(false);
this.dirty.set(false);
};
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); }
next: onLoad,
error: (_err) => {
// Fallback to public view
this.api.view(id, 1000, 0).subscribe({
next: onLoad,
error: (err2) => { this.error.set(err2?.error?.error || err2?.message || 'Erreur de chargement'); this.loading.set(false); }
});
}
});
}
@ -135,7 +150,7 @@ export class PlaylistDetailComponent {
}
saveOrder() {
if (!this.dirty() || !this.id()) return;
if (!this.isOwner() || !this.dirty() || !this.id()) return;
this.saving.set(true);
const order = this.items().map(it => it.id);
this.api.reorder(this.id(), order).subscribe({
@ -145,6 +160,7 @@ export class PlaylistDetailComponent {
}
removeItem(it: PlaylistItem) {
if (!this.isOwner()) return;
const ok = confirm('Retirer cette vidéo de la playlist ?');
if (!ok) return;
this.api.removeVideo(this.id(), it.provider, it.videoId).subscribe({

View File

@ -37,13 +37,7 @@
<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>
}
<img [src]="pl.thumbnail || DEFAULT_PLAYLIST_IMAGE" [alt]="pl.title" class="w-full h-40 object-cover group-hover:opacity-90 transition" />
</div>
</a>
<div class="p-4 flex-1 flex flex-col">
@ -58,8 +52,10 @@
<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>
@if (currentUser() && pl.userId === currentUser()?.id) {
<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>

View File

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { AuthService } from '../../../services/auth.service';
@Component({
selector: 'app-library-playlists',
@ -12,13 +13,20 @@ import { RouterLink } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlaylistsComponent {
// Default image path
readonly DEFAULT_PLAYLIST_IMAGE = '/images/default_playlist.png';
private http = inject(HttpClient);
private auth = inject(AuthService);
// Expose a bound accessor to avoid losing 'this' context when used in template
currentUser = () => this.auth.currentUser();
// 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 }>>([]);
playlists = signal<Array<{ id: string; userId?: string; title: string; description?: string | null; thumbnail?: string | null; isPrivate: number | boolean; createdAt: string; updatedAt: string; itemsCount: number }>>([]);
// Modal state
modalOpen = signal<boolean>(false);
@ -45,10 +53,32 @@ export class PlaylistsComponent {
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); },
const own$ = this.http.get<any[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
const pub$ = this.http.get<any[]>(`${this.apiBase()}/playlists/public`, { params, withCredentials: true });
let own: any[] = [];
let pub: any[] = [];
let ownErr: any = null;
// Fetch own (may 401 for anonymous)
own$.subscribe({
next: (rows) => { own = rows || []; tryMerge(); },
error: () => { own = []; ownErr = true; tryMerge(); }
});
// Fetch public
pub$.subscribe({
next: (rows) => { pub = rows || []; tryMerge(); },
error: (err) => { this.error.set(err?.error?.error || err?.message || 'Erreur de chargement'); this.loading.set(false); }
});
const tryMerge = () => {
// Wait until we got public at least (own may be empty or errored)
if (pub === null) return;
const map = new Map<string, any>();
for (const p of pub) if (p && p.id) map.set(p.id, p);
for (const o of own) if (o && o.id) map.set(o.id, o);
const merged = Array.from(map.values());
this.playlists.set(merged);
this.loading.set(false);
};
}
onSearchChange(event: Event) {
@ -88,6 +118,9 @@ export class PlaylistsComponent {
}
openEdit(pl: any) {
// Only owners can edit
const user = this.currentUser();
if (!user?.id || pl.userId !== user.id) return;
this.editId.set(pl.id);
this.formTitle.set(pl.title || '');
this.formDescription.set(pl.description || '');
@ -121,7 +154,8 @@ export class PlaylistsComponent {
}
deletePlaylist(pl: any) {
if (!pl?.id) return;
const user = this.currentUser();
if (!pl?.id || !user?.id || pl.userId !== user.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({

View File

@ -21,6 +21,12 @@
<span [class.hidden]="collapsed">{{ 'nav.shorts' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
</a>
</li>
</ul>
</div>
@ -47,12 +53,6 @@
<span [class.hidden]="collapsed">{{ 'nav.liked' | t }}</span>
</a>
</li>
<li>
<a routerLink="/library/playlists" routerLinkActive="bg-slate-800" class="flex items-center px-3 py-2 rounded hover:bg-slate-800 transition" [ngClass]="{ 'gap-0 justify-center': collapsed, 'gap-3': !collapsed }" [attr.title]="collapsed ? ('nav.playlists' | t) : null">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 24 24" fill="currentColor"><path d="M4 6h12v2H4V6zm0 4h12v2H4v-2zm0 4h8v2H4v-2zm14-6h2v8h-2v-8z"/></svg>
<span [class.hidden]="collapsed">{{ 'nav.playlists' | t }}</span>
</a>
</li>
</ul>
</div>
}

View File

@ -1,178 +1,140 @@
<div class="container mx-auto p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2 text-slate-200">
<a [routerLink]="['/t', theme()]" class="px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 border border-slate-700">← {{ 'themes.backToThemes' | t }}</a>
<h2 class="text-2xl font-bold">
{{ themes.bySlug(theme())?.emoji }} {{ themes.bySlug(theme())?.label }}
<span class="text-slate-400 text-base">— {{ provider() | titlecase }}</span>
</h2>
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3 text-slate-200">
<a [routerLink]="['/t', theme()]" class="px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</a>
<h1 class="text-3xl font-bold">
<span class="text-4xl mr-2">{{ themes.bySlug(theme())?.emoji }}</span>
{{ themes.bySlug(theme())?.label }}
</h1>
</div>
<div class="flex items-center gap-2">
<label class="text-sm text-slate-400">{{ 'themes.changeProvider' | t }}</label>
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="bg-gray-800 text-white rounded px-2 py-1">
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
</select>
<label class="text-sm font-medium text-slate-400">{{ 'themes.changeProvider' | t }}</label>
<div class="relative">
<select [ngModel]="provider()" (ngModelChange)="changeProvider($event)" class="appearance-none bg-slate-800 text-white rounded-md px-3 py-2 pr-8 border border-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all">
<option *ngFor="let p of providersList()" [value]="p.id">{{ p.label }}</option>
</select>
<svg class="w-5 h-5 absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
</div>
<!-- Filters -->
<div class="grid sm:grid-cols-4 gap-3 mb-6 bg-slate-800/50 p-3 rounded-lg border border-slate-700">
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.sort' | t }}</label>
<select [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.duration' | t }}</label>
<select [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">{{ 'filter.duration.any' | t }}</option>
<option value="short">{{ 'filter.duration.short' | t }}</option>
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
<option value="long">{{ 'filter.duration.long' | t }}</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.language' | t }}</label>
<select [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">Any</option>
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">{{ 'filter.date' | t }}</label>
<select [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-gray-800 text-white rounded px-2 py-1">
<option value="any">Any</option>
<option value="day">Last 24h</option>
<option value="week">Last week</option>
<option value="month">Last month</option>
<option value="year">Last year</option>
</select>
<div class="bg-slate-800/50 p-4 rounded-xl border border-slate-700 mb-8">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-5 gap-4">
<div>
<label for="sort-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.sort' | t }}</label>
<select id="sort-filter" [ngModel]="sort()" (ngModelChange)="onSortChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="recent">{{ 'filter.sort.recent' | t }}</option>
<option value="viewed">{{ 'filter.sort.viewed' | t }}</option>
<option value="longest">{{ 'filter.sort.longest' | t }}</option>
</select>
</div>
<div>
<label for="duration-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.duration' | t }}</label>
<select id="duration-filter" [ngModel]="duration()" (ngModelChange)="onDurationChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">{{ 'filter.duration.any' | t }}</option>
<option value="short">{{ 'filter.duration.short' | t }}</option>
<option value="medium">{{ 'filter.duration.medium' | t }}</option>
<option value="long">{{ 'filter.duration.long' | t }}</option>
</select>
</div>
<div>
<label for="type-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.type' | t }}</label>
<select id="type-filter" [ngModel]="type()" (ngModelChange)="onTypeChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">Any</option>
<option value="video">Video</option>
<option value="live">Live</option>
<option value="recorded">Recorded</option>
<option value="short">Shorts</option>
</select>
</div>
<div>
<label for="language-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.language' | t }}</label>
<select id="language-filter" [ngModel]="language()" (ngModelChange)="onLanguageChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">Any</option>
<option value="en">English</option>
<option value="fr">Français</option>
</select>
</div>
<div>
<label for="date-filter" class="block text-sm font-medium text-slate-300 mb-1">{{ 'filter.date' | t }}</label>
<select id="date-filter" [ngModel]="date()" (ngModelChange)="onDateChange($event)" class="w-full bg-slate-700 text-white rounded-md px-3 py-2 border border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="any">Any</option>
<option value="day">Last 24h</option>
<option value="week">Last week</option>
<option value="month">Last month</option>
<option value="year">Last year</option>
</select>
</div>
</div>
</div>
<!-- Sections Tabs mimic (simple headings) -->
<div class="space-y-10">
<!-- Video Grid -->
<div *ngIf="loading() && videos().length === 0" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
<div *ngFor="let i of [1,2,3,4,5,6,7,8,9,10]" class="animate-pulse">
<div class="w-full aspect-video bg-slate-800 rounded-lg"></div>
<div class="mt-2 space-y-2">
<div class="h-4 bg-slate-800 rounded w-3/4"></div>
<div class="h-4 bg-slate-800 rounded w-1/2"></div>
</div>
</div>
</div>
<!-- Videos -->
<section>
<h2 class="text-xl font-semibold mb-3">{{ 'themes.videos' | t }}</h2>
<div *ngIf="videos.loading && !nonShorts.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
<div class="w-full h-32 bg-slate-800"></div>
<div class="p-2">
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
<div *ngIf="!loading() && videos().length === 0" class="text-center py-16">
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-12 w-12 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-2 text-lg font-medium text-slate-300">{{ 'empty.noItems' | t }}</h3>
<p class="mt-1 text-sm text-slate-400">Try adjusting your filters or changing the provider.</p>
</div>
<div *ngIf="error()" class="mt-4 p-4 bg-red-900/30 border border-red-700 text-red-200 rounded-lg text-center">
<p>{{ error() }}</p>
<button class="mt-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md transition-colors" (click)="retry()">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-x-6 gap-y-8">
<div *ngFor="let v of videos(); trackBy: trackByVideo" class="group">
<a [routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }" class="block bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50 transition-all duration-300 transform hover:-translate-y-1 shadow-lg hover:shadow-xl">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full aspect-video object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute bottom-2 right-2 bg-black/75 text-white text-xs px-2 py-1 rounded-md font-mono">
{{ v.duration | number:'1.0-0' }}:{{ (v.duration % 60) | number:'2.0-0' }}
</div>
<div class="absolute top-2 left-2">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
</div>
<div *ngIf="!videos.loading && nonShorts.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="videos.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
{{ videos.error }}
<button class="ml-2 underline hover:text-red-100" (click)="retry(videos)">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="nonShorts.length">
<a *ngFor="let v of (nonShorts | slice:0:20); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
<div class="p-3">
<h3 class="font-semibold text-slate-100 group-hover:text-red-400 transition-colors duration-200 text-sm line-clamp-2 pr-2">{{ v.title }}</h3>
<div class="mt-2 flex items-center space-x-2 text-xs text-slate-400">
<img [src]="v.uploaderAvatar" [alt]="v.uploaderName" class="w-6 h-6 rounded-full">
<span>{{ v.uploaderName }}</span>
</div>
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<div class="mt-3" *ngIf="nonShorts.length > 20">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
(click)="showMore(videos)"
aria-label="Afficher plus de vidéos">
{{ 'loading.more' | t }} ({{nonShorts.length - 20}})
</button>
</div>
</section>
<!-- Shorts: show only if any are detected -->
<section *ngIf="shorts.length > 0">
<h2 class="text-xl font-semibold mb-3">Shorts</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<a *ngFor="let v of (shorts | slice:0:shortsVisibleCount); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-64 object-cover">
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<!-- Bouton Voir plus pour les Shorts -->
<div class="mt-3" *ngIf="shorts.length > shortsVisibleCount">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
(click)="showMoreShorts()"
aria-label="Afficher plus de shorts">
{{ 'loading.more' | t }} ({{shorts.length - shortsVisibleCount}})
</button>
</div>
</section>
<!-- Recorded Streams -->
<section>
<h2 class="text-xl font-semibold mb-3">{{ 'themes.recorded' | t }}</h2>
<div *ngIf="recorded.loading && !recorded.items.length" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let i of [1,2,3,4,5,6,7,8]" class="animate-pulse bg-slate-800/50 rounded-lg overflow-hidden border border-slate-800">
<div class="w-full h-32 bg-slate-800"></div>
<div class="p-2">
<div class="h-3 bg-slate-700 rounded w-11/12 mb-2"></div>
<div class="h-3 bg-slate-700 rounded w-8/12"></div>
<div class="mt-1.5 text-xs text-slate-400">
<span>{{ v.views | number }} visionnements</span> &bull; <span>{{ v.uploadedDate | date:'shortDate' }}</span>
</div>
</div>
</div>
<div *ngIf="!recorded.loading && recordedNonShorts.length === 0" class="text-slate-400">{{ 'empty.noItems' | t }}</div>
<div *ngIf="recorded.error" class="mt-2 p-3 bg-red-900/30 border border-red-700 text-red-200 rounded">
{{ recorded.error }}
<button class="ml-2 underline hover:text-red-100" (click)="retry(recorded)">{{ 'action.retry' | t }}</button>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4" *ngIf="recordedNonShorts.length">
<a *ngFor="let v of (recordedNonShorts | slice:0:20); trackBy: trackByVideo"
[routerLink]="['/watch', v.videoId]" [queryParams]="watchQueryParams(v)" [state]="{ video: v }"
class="group bg-slate-800/50 rounded-lg overflow-hidden hover:bg-slate-700/50">
<div class="relative">
<img [src]="v.thumbnail" [alt]="v.title" loading="lazy" decoding="async" class="w-full h-32 object-cover">
<div class="absolute bottom-2 left-2">
<app-like-button [videoId]="v.videoId" [provider]="provider()" [title]="v.title" [thumbnail]="v.thumbnail"></app-like-button>
</div>
</div>
<div class="p-2 text-sm line-clamp-2">{{ v.title }}</div>
</a>
</div>
<div class="mt-3" *ngIf="recordedNonShorts.length > 20">
<button class="px-4 py-2 bg-slate-800 hover:bg-slate-700 rounded border border-slate-700 text-slate-200"
(click)="showMore(recorded)"
aria-label="Afficher plus de vidéos enregistrées">
{{ 'loading.more' | t }} ({{recordedNonShorts.length - 20}})
</button>
</div>
</section>
</a>
</div>
</div>
<div *ngIf="loading() && videos().length > 0" class="text-center py-8">
<svg class="animate-spin h-8 w-8 text-slate-400 mx-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Categories (static recommended) -->
<section>
<h2 class="text-xl font-semibold mb-3">{{ 'themes.categories' | t }}</h2>
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div *ngFor="let c of categories()" class="bg-slate-800/60 rounded-xl p-4 border border-slate-700">
<div class="text-4xl">{{ c.emoji }}</div>
<div class="mt-2 font-semibold">{{ c.label }}</div>
</div>
</div>
</section>
<div *ngIf="nextCursor()" class="text-center mt-8">
<button (click)="loadMore()" [disabled]="loading()" class="px-6 py-3 bg-slate-800 hover:bg-slate-700 rounded-lg border border-slate-700 text-slate-200 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all">
{{ 'loading.more' | t }}
</button>
</div>
</div>

View File

@ -9,16 +9,6 @@ import { Video } from '../../models/video.model';
import { TranslatePipe } from '../../pipes/translate.pipe';
import { LikeButtonComponent } from '../shared/components/like-button/like-button.component';
interface SectionState {
key: 'recorded' | 'videos' | 'categories';
titleKey: string;
items: Video[];
nextCursor: string | null;
loading: boolean;
error: string | null;
visibleCount: number; // how many items are currently shown
}
@Component({
selector: 'app-provider-theme',
standalone: true,
@ -37,27 +27,25 @@ export class ProviderThemePageComponent implements OnDestroy {
provider = signal<Provider>('youtube');
theme = signal<string>('trending');
// Guard to ignore stale async responses after route/theme/provider changes
// Guard to ignore stale async responses
private version = 0;
// Filters state
sort = signal<'recent' | 'viewed' | 'longest'>('recent');
duration = signal<'any' | 'short' | 'medium' | 'long'>('any');
type = signal<'any' | 'video' | 'live' | 'recorded' | 'short'>('any');
language = signal<'any' | 'en' | 'fr'>('any');
date = signal<'any' | 'day' | 'week' | 'month' | 'year'>('any');
// UI
// Data state
allVideos = signal<Video[]>([]);
nextCursor = signal<string | null>(null);
loading = signal(false);
error = signal<string | null>(null);
// Derived state for the UI
providersList = computed(() => this.instances.providers());
categories = computed(() => this.themes.categoriesFor(this.theme()));
// Sections (Live removed on provider pages)
recorded: SectionState = { key: 'recorded', titleKey: 'themes.recorded', items: [], nextCursor: null, loading: false, error: null, visibleCount: 20 };
videos: SectionState = { key: 'videos', titleKey: 'themes.videos', items: [], nextCursor: null, loading: false, error: null, visibleCount: 12 };
// Derived lists for UI separation
shorts: Video[] = []; // aggregated from both sections
nonShorts: Video[] = []; // from videos section only
recordedNonShorts: Video[] = []; // from recorded section
videos = computed(() => this.applyFilters(this.allVideos()));
constructor() {
this.route.paramMap.subscribe(pm => {
@ -65,424 +53,150 @@ export class ProviderThemePageComponent implements OnDestroy {
const theme = pm.get('theme') || 'trending';
this.provider.set(provider);
this.theme.set(theme);
// Keep global provider in sync so header search uses the current context
try { this.instances.setSelectedProvider(provider); } catch {}
this.resetAll();
// Increment version so in-flight requests from previous state are ignored
this.version++;
this.loadInitial();
this.cdr.markForCheck();
this.instances.setSelectedProvider(provider);
this.resetAndLoad();
});
// Prefetch recalculation on resize (affects columns/rows thresholds)
this.handleResize = () => {
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
};
try { window.addEventListener('resize', this.handleResize); } catch {}
// Re-apply filters on change (Live removed) with a tiny debounce to avoid thrashing
// Re-apply filters on change
effect(() => {
const _s = this.sort();
const _d = this.duration();
const _l = this.language();
const _dt = this.date();
const apply = () => {
// Reset visible window to ensure top results reflect new sort/filter instantly
this.recorded.visibleCount = Math.max(20, this.recorded.visibleCount);
// Keep videos at 12-increment pagination
this.videos.visibleCount = Math.max(12, this.videos.visibleCount);
this.recorded.items = this.applyFilters(this.recorded.items);
this.videos.items = this.applyFilters(this.videos.items);
// Recompute derived partitions
this.partitionVideos();
// After filtering, re-evaluate if we need to prefetch more
this.checkPrefetch(this.recorded);
this.checkPrefetch(this.videos);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
try {
clearTimeout((this as any).__fltTimer);
} catch {}
(this as any).__fltTimer = setTimeout(apply, 150);
}, { allowSignalWrites: true });
this.sort();
this.duration();
this.type();
this.language();
this.date();
this.cdr.markForCheck();
});
}
private resetAll() {
for (const s of [this.recorded, this.videos]) {
s.items = []; s.nextCursor = null; s.loading = false; s.error = null;
s.visibleCount = s.key === 'videos' ? 12 : 20;
}
this.shorts = [];
this.nonShorts = [];
this.recordedNonShorts = [];
this.cdr.markForCheck();
private resetAndLoad() {
this.version++;
this.allVideos.set([]);
this.nextCursor.set(null);
this.error.set(null);
this.loadMore();
}
private baseTokens(): string[] {
const t = this.theme();
if (t === 'trending') return [];
if (t === 'live') return ['live'];
return this.themes.tokensFor(t);
}
private buildQueryFor(section: SectionState): string {
const tokens = [...this.baseTokens()];
if (section.key === 'recorded') {
const p = this.provider();
// Only providers that benefit from a 'replay' search hint
if (p === 'youtube' || p === 'twitch') tokens.push('replay');
// For other providers, prefer trending (empty query) to avoid bad/no results
}
private buildQuery(): string {
const tokens = this.themes.tokensFor(this.theme());
return tokens.join(' ').trim();
}
loadMore() {
if (this.loading()) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
this.error.set(readiness.reason || 'Provider not ready');
return;
}
this.loading.set(true);
this.error.set(null);
const snapshotVersion = this.version;
const query = this.buildQuery();
const provider = this.provider();
const apiCall = query
? this.api.searchVideosPage(query, this.nextCursor(), provider)
: this.api.getTrendingPage(this.nextCursor(), provider);
apiCall.subscribe({
next: (res) => {
if (snapshotVersion !== this.version) return;
const existingIds = new Set(this.allVideos().map(v => v.videoId));
const newVideos = (res.items || []).filter(v => !existingIds.has(v.videoId));
this.allVideos.update(current => [...current, ...newVideos]);
this.nextCursor.set(res.nextCursor || null);
this.loading.set(false);
this.cdr.markForCheck();
},
error: (e) => {
if (snapshotVersion !== this.version) return;
this.error.set(e.message || 'Failed to load videos');
this.loading.set(false);
this.cdr.markForCheck();
}
});
}
private applyFilters(list: Video[]): Video[] {
let arr = list.slice();
const d = this.duration();
if (d !== 'any') {
let arr = [...list];
const durationFilter = this.duration();
if (durationFilter !== 'any') {
arr = arr.filter(v => {
const dur = v.duration || 0;
if (d === 'short') return dur > 0 && dur < 600;
if (d === 'medium') return dur >= 600 && dur < 1800;
if (d === 'long') return dur >= 1800;
if (durationFilter === 'short') return dur > 0 && dur < 240; // < 4 min
if (durationFilter === 'medium') return dur >= 240 && dur < 1200; // 4-20 min
if (durationFilter === 'long') return dur >= 1200; // > 20 min
return true;
});
}
// Language heuristic (very lightweight)
const lang = this.language();
if (lang !== 'any') {
const isFrWord = (s: string) => /\b(le|la|les|des|un|une|et|ou|pour|avec|sans|sur|dans|est|cette|vidéo)\b/i.test(s);
const isEnWord = (s: string) => /\b(the|a|an|and|or|with|without|for|this|video|live)\b/i.test(s);
const typeFilter = this.type();
if (typeFilter !== 'any') {
arr = arr.filter(v => {
const text = `${v.title} ${v.uploaderName}`;
const fr = isFrWord(text) || /[éèêàùçîïô]/i.test(text);
const en = isEnWord(text);
return lang === 'fr' ? fr && !en : en && !fr;
if (typeFilter === 'video') return v.type === 'video';
if (typeFilter === 'live') return v.type === 'live' || v.type === 'channel';
if (typeFilter === 'recorded') return v.type === 'recorded';
if (typeFilter === 'short') return v.type === 'short' || v.duration < 60;
return true;
});
}
// Date filter based on uploaded timestamp if available
const dateOpt = this.date();
if (dateOpt !== 'any') {
const langFilter = this.language();
if (langFilter !== 'any') {
// Simple heuristic, can be improved
const isFr = (s: string) => /[éèêàùçîïô]/.test(s) || /\b(le|la|les|un|une)\b/i.test(s);
const isEn = (s: string) => /\b(the|a|an|and|for)\b/i.test(s);
arr = arr.filter(v => {
const text = `${v.title} ${v.uploaderName}`.toLowerCase();
if (langFilter === 'fr') return isFr(text);
if (langFilter === 'en') return isEn(text) && !isFr(text);
return true;
});
}
const dateFilter = this.date();
if (dateFilter !== 'any') {
const now = Date.now();
const cutoff = (() => {
if (dateOpt === 'day') return now - 24 * 3600_000;
if (dateOpt === 'week') return now - 7 * 24 * 3600_000;
if (dateOpt === 'month') return now - 30 * 24 * 3600_000;
if (dateOpt === 'year') return now - 365 * 24 * 3600_000;
return 0;
})();
const cutoff = {
day: now - 24 * 3600 * 1000,
week: now - 7 * 24 * 3600 * 1000,
month: now - 30 * 24 * 3600 * 1000,
year: now - 365 * 24 * 3600 * 1000,
}[dateFilter] || 0;
if (cutoff > 0) {
arr = arr.filter(v => (v.uploaded || 0) >= cutoff);
}
}
const s = this.sort();
if (s === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
if (s === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
if (s === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
const sortOrder = this.sort();
if (sortOrder === 'recent') arr.sort((a, b) => (b.uploaded || 0) - (a.uploaded || 0));
if (sortOrder === 'viewed') arr.sort((a, b) => (b.views || 0) - (a.views || 0));
if (sortOrder === 'longest') arr.sort((a, b) => (b.duration || 0) - (a.duration || 0));
return arr;
}
// Pagination des shorts
shortsVisibleCount = 20;
// Charge plus de shorts
showMoreShorts() {
this.shortsVisibleCount += 20;
// Vérifier si nous avons assez de vidéos pour afficher
if (this.shorts.length <= this.shortsVisibleCount) {
// Si nous n'avons pas assez de vidéos, essayer d'en charger plus
this.checkAndLoadMoreShorts();
}
this.cdr.markForCheck();
}
// Vérifie et charge plus de shorts si nécessaire
private checkAndLoadMoreShorts() {
// Vérifier si nous avons déjà chargé toutes les vidéos disponibles
const totalShorts = this.videos.items.filter(v => this.isShortItem(v)).length +
this.recorded.items.filter(v => this.isShortItem(v)).length;
// Si nous avons moins de vidéos que ce que nous voulons afficher, essayer d'en charger plus
if (totalShorts <= this.shortsVisibleCount) {
// Essayer de charger plus de vidéos de la section videos d'abord
if (this.videos.nextCursor && !this.videos.loading) {
this.loadMore(this.videos);
}
// Puis essayer de charger plus de vidéos de la section recorded
else if (this.recorded.nextCursor && !this.recorded.loading) {
this.loadMore(this.recorded);
}
}
}
// Classification per spec
private isShortItem(x: any): boolean {
try {
if (!x) return false;
if (x.type === 'short') return true;
if ((x as any).isShort === true) return true;
// Platform URL hints (best-effort)
const u = String((x as any).url || '');
if (u) {
// YouTube shorts URLs: /shorts/VIDEOID
if (/\/shorts\//i.test(u)) return true;
// General reels/clips hints used by some providers
if (/\b(reel|reels|clip|clips)\b/i.test(u)) return true;
}
const dur = Number((x as any).durationSeconds ?? x.duration ?? 0);
if (dur > 0 && dur <= 60) return true;
const ar = Number((x as any).aspectRatio ?? 0);
if (ar > 1.2) return true; // treat vertical as shorts if hinted
return false;
} catch {
return false;
}
}
private partitionVideos() {
const vids = this.videos.items || [];
const recs = this.recorded.items || [];
const shortsAgg: Video[] = [];
const vidsLongs: Video[] = [];
const recsLongs: Video[] = [];
const seenShort = new Set<string>();
const pushShort = (x: Video) => {
const k = x.videoId || x.url || '';
if (k && !seenShort.has(k)) {
seenShort.add(k);
shortsAgg.push(x);
}
};
// Filtrer les shorts et les vidéos longues pour les vidéos
for (const v of vids) {
if (this.isShortItem(v)) {
pushShort(v);
} else {
vidsLongs.push(v);
}
}
// Filtrer les shorts et les vidéos longues pour les enregistrements
for (const r of recs) {
if (this.isShortItem(r)) {
pushShort(r);
} else {
recsLongs.push(r);
}
}
// Mettre à jour les tableaux de vidéos
this.shorts = shortsAgg;
// Conserver les vidéos déjà affichées si elles existent encore
const currentNonShorts = new Set(this.nonShorts.map(v => v.videoId || v.url || ''));
const currentRecordedNonShorts = new Set(this.recordedNonShorts.map(v => v.videoId || v.url || ''));
// Mettre à jour les vidéos non-courtes en conservant l'ordre et les éléments existants
this.nonShorts = [
...this.nonShorts.filter(v => vidsLongs.some(v2 => (v2.videoId || v2.url) === (v.videoId || v.url))),
...vidsLongs.filter(v => !currentNonShorts.has(v.videoId || v.url || ''))
].slice(0, this.videos.visibleCount);
// Mettre à jour les enregistrements non-courts en conservant l'ordre et les éléments existants
this.recordedNonShorts = [
...this.recordedNonShorts.filter(v => recsLongs.some(v2 => (v2.videoId || v2.url) === (v.videoId || v.url))),
...recsLongs.filter(v => !currentRecordedNonShorts.has(v.videoId || v.url || ''))
].slice(0, this.recorded.visibleCount);
// Mettre à jour le compteur de shorts visibles si nécessaire
if (this.shortsVisibleCount > this.shorts.length) {
this.shortsVisibleCount = this.shorts.length;
}
// Forcer la détection des changements
this.cdr.markForCheck();
}
changeProvider(p: any) {
const provider = String(p) as Provider;
// Sync the global provider so header search reflects the current context immediately
try { this.instances.setSelectedProvider(provider); } catch {}
this.instances.setSelectedProvider(provider);
this.router.navigate(['/p', provider, 't', this.theme()]);
}
onSortChange(val: any) { this.sort.set(val as any); }
onDurationChange(val: any) { this.duration.set(val as any); }
onTypeChange(val: any) { this.type.set(val as any); }
onLanguageChange(val: any) { this.language.set(val as any); }
onDateChange(val: any) { this.date.set(val as any); }
loadInitial() {
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
const msg = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Provider not ready', { provider: this.provider(), reason: msg, theme: this.theme() });
this.recorded.error = msg; this.recorded.loading = false;
this.videos.error = msg; this.videos.loading = false;
return;
}
this.loadMore(this.recorded);
this.loadMore(this.videos);
retry() {
this.error.set(null);
this.loadMore();
}
loadMore(section: SectionState) {
if (section.loading) return;
const readiness = this.instances.getProviderReadiness(this.provider());
if (!readiness.ready) {
section.error = readiness.reason || 'Provider not ready';
console.warn('[ProviderTheme] Skip load: provider not ready', { provider: this.provider(), section: section.key, reason: section.error, theme: this.theme() });
section.loading = false;
return;
}
section.loading = true;
this.cdr.markForCheck();
const provider = this.provider();
const query = this.buildQueryFor(section);
const snapshotVersion = this.version;
// If YouTube quota has already been flagged exceeded, surface it and avoid requests
if (provider === 'youtube' && this.api.quotaExceeded$.getValue()) {
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
section.loading = false;
console.warn('[ProviderTheme] Skip load: YouTube quota exceeded', { section: section.key, theme: this.theme() });
return;
}
const next = (res: { items: Video[]; nextCursor?: string | null }) => {
// Ignore responses from a previous state
if (snapshotVersion !== this.version) return;
// Deduplicate by videoId or URL before applying filters
const existing = new Set(section.items.map(v => v.videoId || v.url || ''));
const beforeLen = section.items.length;
const merged: Video[] = [...section.items];
const incoming = res.items || [];
const beforeKeys = existing.size;
for (const v of res.items || []) {
const key = v.videoId || v.url || '';
if (!existing.has(key)) { existing.add(key); merged.push(v); }
}
const afterKeys = existing.size;
const added = Math.max(0, afterKeys - beforeKeys);
const duplicates = Math.max(0, (incoming.length || 0) - added);
// Mettre à jour les éléments de la section
section.items = this.applyFilters(merged);
section.nextCursor = res.nextCursor || null;
section.loading = false;
// Mettre à jour les tableaux de vidéos visibles
if (section.key === 'videos') {
this.nonShorts = section.items.filter(v => !this.isShortItem(v)).slice(0, section.visibleCount);
} else if (section.key === 'recorded') {
this.recordedNonShorts = section.items.filter(v => !this.isShortItem(v)).slice(0, section.visibleCount);
}
// Mettre à jour les shorts si nécessaire
this.partitionVideos();
if (incoming.length === 0) {
console.info('[ProviderTheme] Empty page', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor });
}
if (!res.nextCursor) {
console.info('[ProviderTheme] Reached end of pagination', { provider, section: section.key, theme: this.theme(), total: section.items.length });
}
if (duplicates > 0) {
console.debug('[ProviderTheme] Deduplicated incoming items', { provider, section: section.key, duplicates, added, before: beforeLen, after: section.items.length });
}
// Post-process for YouTube: fetch durations for items lacking them to make duration filters/sorts work
if (provider === 'youtube') {
try {
const wantIds = merged.filter(v => (v.duration || 0) === 0 && !!v.videoId).slice(0, 50).map(v => v.videoId as string);
const unique = Array.from(new Set(wantIds));
if (unique.length) {
this.api.getYouTubeDurations(unique).subscribe({
next: (mapDur) => {
if (snapshotVersion !== this.version) return;
if (!mapDur) return;
// Update durations in-place
for (const v of section.items) {
const id = v.videoId || '';
if (id && mapDur[id] != null) v.duration = Number(mapDur[id]) || 0;
}
// Re-apply filters/sorts that depend on duration
section.items = this.applyFilters(section.items);
this.partitionVideos();
this.checkPrefetch(section);
this.cdr.markForCheck();
},
error: () => {}
});
}
} catch {}
}
this.partitionVideos();
// Logic-based prefetch: if we are within ~2 rows of the end, prefetch next page
this.checkPrefetch(section);
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
const onErr = (e?: any) => {
// Specific message for quota exceeded on YouTube
if (provider === 'youtube' && e?.status === 403 && (e?.error?.error?.errors?.[0]?.reason === 'quotaExceeded' || /quota/i.test(String(e?.error?.error?.message || e?.message || '')))) {
this.api.quotaExceeded$.next(true);
section.error = 'YouTube quota exceeded. Try again later or switch provider.';
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
const status = e?.status;
if (status === 404) section.error = `PeerTube (${inst}) introuvable. Essayez une autre instance.`;
else if (status === 429) section.error = `PeerTube (${inst}) limite atteinte. Réessayez plus tard.`;
else section.error = `PeerTube (${inst}) indisponible. Réessayez plus tard.`;
} else {
section.error = 'Erreur de chargement';
}
section.loading = false;
console.error('[ProviderTheme] Load error', { provider, section: section.key, theme: this.theme(), query, cursor: section.nextCursor, error: String(e?.message || e) });
this.cdr.markForCheck();
try { this.cdr.detectChanges(); } catch {}
};
if (query) {
this.api.searchVideosPage(query, section.nextCursor, provider).subscribe({ next, error: onErr });
} else {
this.api.getTrendingPage(section.nextCursor, provider).subscribe({ next, error: onErr });
}
}
// Increase visible items by 20; fetch the next page if we need more data
showMore(section: SectionState) {
const step = section.key === 'videos' ? 12 : 20;
const target = section.visibleCount + step;
section.visibleCount = target;
// Mettre à jour les vidéos visibles immédiatement
if (section.key === 'videos') {
this.nonShorts = this.videos.items.slice(0, section.visibleCount);
} else if (section.key === 'recorded') {
this.recordedNonShorts = this.recorded.items.slice(0, section.visibleCount);
}
// Si nous n'avons pas assez d'éléments et qu'il y a une page suivante, la charger
const poolLength = this.poolLengthFor(section);
if (poolLength < target && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] showMore triggers fetch', { section: section.key, target, have: section.items.length, nextCursor: section.nextCursor });
this.loadMore(section);
}
// Vérifier également si nous devons charger plus de contenu
this.checkPrefetch(section);
this.cdr.markForCheck();
// Forcer la détection des changements pour mettre à jour l'interface utilisateur
try { this.cdr.detectChanges(); } catch {}
}
// Helpers for watch params
watchQueryParams(v: Video): Record<string, any> | null {
const p = this.provider();
const qp: any = { p };
@ -495,47 +209,9 @@ export class ProviderThemePageComponent implements OnDestroy {
return qp;
}
// trackBy to reduce DOM churn
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url || _idx; }
// Manual retry helper
retry(section: SectionState) {
section.error = null;
this.loadMore(section);
}
// --- Prefetch helpers ---
private handleResize: (() => void) | null = null;
private currentCols(): number {
try {
// Tailwind breakpoints: md>=768px -> 4 cols, lg>=1024px -> 6 cols, else 2 cols
if (window.matchMedia && window.matchMedia('(min-width: 1024px)').matches) return 6;
if (window.matchMedia && window.matchMedia('(min-width: 768px)').matches) return 4;
return 2;
} catch {
return 4; // safe default
}
}
private checkPrefetch(section: SectionState) {
const cols = this.currentCols();
const threshold = cols * 2; // ~2 rows
const pool = this.poolLengthFor(section);
const remaining = pool - section.visibleCount;
if (remaining <= threshold && section.nextCursor && !section.loading) {
console.debug('[ProviderTheme] Logic prefetch triggered', { section: section.key, remaining, threshold, nextCursor: section.nextCursor });
this.loadMore(section);
}
}
private poolLengthFor(section: SectionState): number {
if (section.key === 'videos') return this.nonShorts.length || 0;
if (section.key === 'recorded') return this.recordedNonShorts.length || 0;
return section.items.length;
}
trackByVideo(_idx: number, v: Video) { return v.videoId || v.url; }
ngOnDestroy(): void {
try { if (this.handleResize) window.removeEventListener('resize', this.handleResize); } catch {}
this.version++; // Invalidate any pending requests
}
}

View File

@ -44,11 +44,24 @@ export class PlaylistsService {
return this.http.get<Playlist[]>(`${this.apiBase()}/playlists`, { params, withCredentials: true });
}
// Public listing (no auth required)
listPublic(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/public`, { 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 });
}
// Public view (owner or public)
view(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)}/view`, { 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 });
}