feat: fix m3u8 native playback in LivePlayer & add webhooks API
This commit is contained in:
parent
8993ab3293
commit
1f718afec9
@ -23,14 +23,14 @@
|
|||||||
"@tiptap/extension-youtube": "^3.21.0",
|
"@tiptap/extension-youtube": "^3.21.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/video.js": "^7.3.58",
|
"@types/video.js": "^7.3.58",
|
||||||
"appwrite": "^23.0.0",
|
"appwrite": "^1.7.0",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"isomorphic-dompurify": "^2.20.0",
|
"isomorphic-dompurify": "^2.20.0",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"node-appwrite": "^22.1.3",
|
"node-appwrite": "^1.7.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
|
|||||||
86
src/app/api/webhooks/publications/route.ts
Normal file
86
src/app/api/webhooks/publications/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Tv, PlayCircle, RadioTower, WifiOff, PauseCircle, Volume2 } from 'lucide-react';
|
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 { usePlayerStore } from '@/store/usePlayerStore';
|
||||||
|
import videojs from 'video.js';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
|
||||||
interface LivePlayerProps {
|
interface LivePlayerProps {
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -52,6 +54,40 @@ export function LivePlayer({ url, type, title }: LivePlayerProps) {
|
|||||||
}
|
}
|
||||||
}, [url, type, setAudioUrl, setVideoUrl, setVideoType]);
|
}, [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) {
|
if (!processedUrl) {
|
||||||
return (
|
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'}`}>
|
<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,6 +114,7 @@ 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'}`}>
|
<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' ? (
|
{type === 'tv' ? (
|
||||||
<div className="w-full h-full relative group">
|
<div className="w-full h-full relative group">
|
||||||
|
{isYouTube ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={processedUrl}
|
src={processedUrl}
|
||||||
className="w-full h-full border-0"
|
className="w-full h-full border-0"
|
||||||
@ -85,11 +122,18 @@ export function LivePlayer({ url, type, title }: LivePlayerProps) {
|
|||||||
allowFullScreen
|
allowFullScreen
|
||||||
title={title || "Live TV Stream"}
|
title={title || "Live TV Stream"}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<div ref={innerVideoRef} className="w-full h-full pointer-events-none" />
|
||||||
|
)}
|
||||||
{/* Overlay to catch interaction and sync state */}
|
{/* Overlay to catch interaction and sync state */}
|
||||||
<div
|
<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)}
|
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>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center relative p-8">
|
<div className="w-full h-full flex flex-col items-center justify-center relative p-8">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user