feat: fix m3u8 native playback in LivePlayer & add webhooks API

This commit is contained in:
Carlos (Almeira) 2026-04-14 11:53:09 -05:00
parent 8993ab3293
commit 1f718afec9
3 changed files with 142 additions and 12 deletions

View File

@ -23,14 +23,14 @@
"@tiptap/extension-youtube": "^3.21.0",
"@types/dompurify": "^3.0.5",
"@types/video.js": "^7.3.58",
"appwrite": "^23.0.0",
"appwrite": "^1.7.0",
"axios": "^1.13.6",
"clsx": "^2.1.1",
"dompurify": "^3.3.3",
"isomorphic-dompurify": "^2.20.0",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"node-appwrite": "^22.1.3",
"node-appwrite": "^1.7.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",

View File

@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server';
import { ID } from 'node-appwrite';
import { createAdminClient } from '@/lib/appwrite.server';
import { APPWRITE_ALMEIRA_CONFIG } from '@/lib/appwrite';
import { PostSchema, generateSlug } from '@/lib/validators/publications';
import type { ApiResponse } from '@/types/publications';
const DB = APPWRITE_ALMEIRA_CONFIG.databaseId;
const COLLECTION = 'posts';
/**
* @file app/api/webhooks/publications/route.ts
* @description Secure Bridge API for n8n/Automation.
*
* Features:
* 1. X-Webhook-Token Authentication.
* 2. Automatic slug generation from title.
* 3. Default values for automated posts.
* 4. Full Zod validation.
*/
export async function POST(request: NextRequest) {
// 1. Authentication (Shared Secret)
const authHeader = request.headers.get('X-Webhook-Token');
const WEBHOOK_TOKEN = process.env.WEBHOOK_TOKEN || 'almeira_n8n_secret_2024'; // FALLBACK FOR DEV
if (!authHeader || authHeader !== WEBHOOK_TOKEN) {
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Unauthorized. Invalid Webhook Token.' },
{ status: 401 }
);
}
try {
// 2. Parse Body
const body = await request.json();
// 3. Pre-process Data (Slug & Defaults)
const processedData = {
...body,
// Default to "Borrador" if not specified
estado: body.estado || 'Borrador',
// Auto-generate slug if missing
slug: body.slug || (body.titulo ? generateSlug(body.titulo) : undefined),
// Current ISO date for publication
fecha_publicacion: body.fecha_publicacion || new Date().toISOString(),
};
// 4. Validate with Zod
const parseResult = PostSchema.safeParse(processedData);
if (!parseResult.success) {
const validationErrors: Record<string, string[]> = {};
parseResult.error.issues.forEach((err) => {
const key = String(err.path[0]);
if (!validationErrors[key]) validationErrors[key] = [];
validationErrors[key].push(err.message);
});
return NextResponse.json<ApiResponse>(
{ success: false, error: 'Invalid publication data.', validationErrors },
{ status: 422 }
);
}
// 5. Create Document in Appwrite
const admin = await createAdminClient();
const document = await admin.databases.createDocument(
DB,
COLLECTION,
ID.unique(),
parseResult.data
);
return NextResponse.json<ApiResponse>(
{ success: true, data: document },
{ status: 201 }
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Error processing webhook.';
console.error('[WEBHOOK_ERROR]', error);
return NextResponse.json<ApiResponse>(
{ success: false, error: message },
{ status: 500 }
);
}
}

View File

@ -1,8 +1,10 @@
'use client';
import { Tv, PlayCircle, RadioTower, WifiOff, PauseCircle, Volume2 } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { usePlayerStore } from '@/store/usePlayerStore';
import videojs from 'video.js';
import 'video.js/dist/video-js.css';
interface LivePlayerProps {
url?: string;
@ -52,6 +54,40 @@ export function LivePlayer({ url, type, title }: LivePlayerProps) {
}
}, [url, type, setAudioUrl, setVideoUrl, setVideoType]);
// Handle inner Video Player Initialization
const innerVideoRef = useRef<HTMLDivElement>(null);
const innerPlayerRef = useRef<any>(null);
useEffect(() => {
if (type === 'tv' && !isYouTube && processedUrl && innerVideoRef.current) {
if (innerPlayerRef.current) {
innerPlayerRef.current.dispose();
innerPlayerRef.current = null;
}
const videoElement = document.createElement('video-js');
videoElement.classList.add('vjs-fill', 'vjs-almeira');
innerVideoRef.current.innerHTML = '';
innerVideoRef.current.appendChild(videoElement);
innerPlayerRef.current = videojs(videoElement, {
autoplay: 'muted',
controls: false,
fluid: true,
responsive: true,
sources: [{
src: processedUrl,
type: 'application/x-mpegURL'
}]
});
}
return () => {
if (innerPlayerRef.current) {
innerPlayerRef.current.dispose();
innerPlayerRef.current = null;
}
};
}, [type, isYouTube, processedUrl]);
if (!processedUrl) {
return (
<div className={`relative w-full rounded-3xl overflow-hidden border border-slate-800 shadow-2xl bg-slate-900 flex flex-col items-center justify-center p-12 transition-all ${type === 'tv' ? 'aspect-video' : 'h-64'}`}>
@ -78,18 +114,26 @@ export function LivePlayer({ url, type, title }: LivePlayerProps) {
<div className={`relative w-full rounded-3xl overflow-hidden border border-slate-800 shadow-2xl bg-black transition-all ${type === 'tv' ? 'aspect-video' : 'h-48'}`}>
{type === 'tv' ? (
<div className="w-full h-full relative group">
<iframe
src={processedUrl}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={title || "Live TV Stream"}
/>
{isYouTube ? (
<iframe
src={processedUrl}
className="w-full h-full border-0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
title={title || "Live TV Stream"}
/>
) : (
<div ref={innerVideoRef} className="w-full h-full pointer-events-none" />
)}
{/* Overlay to catch interaction and sync state */}
<div
className="absolute inset-0 bg-transparent cursor-pointer"
className="absolute inset-0 bg-transparent cursor-pointer flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity bg-black/40"
onClick={() => setVideoActive(true)}
></div>
>
<div className="w-20 h-20 bg-pink-600 rounded-full flex items-center justify-center shadow-[0_0_30px_rgba(219,39,119,0.5)] animate-pulse">
<PlayCircle size={40} className="text-white translate-x-1" />
</div>
</div>
</div>
) : (
<div className="w-full h-full flex flex-col items-center justify-center relative p-8">