Introduces a new `/kuenstler` page, API endpoint `/api/artists`, and enhances meta tags on `index.html` and for crawler requests on `/kuenstler`. Adds `KuenstlerPage.tsx` and updates `routes.ts` and `index.html`. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 401e2ec0-e00d-4f10-9d0e-60f3d479f9a5 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Event-Id: 69ee4fdb-c617-4cdd-b9f6-d3fab268d533 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/60d372ff-2c10-46c7-b01b-10c3435136b0/401e2ec0-e00d-4f10-9d0e-60f3d479f9a5/qFrskyV
2140 lines
78 KiB
TypeScript
2140 lines
78 KiB
TypeScript
import type { Express, Request, Response } from "express";
|
||
import { createServer, type Server } from "http";
|
||
import express from "express";
|
||
import compression from "compression";
|
||
import { storage } from "./storage";
|
||
import { z } from "zod";
|
||
import {
|
||
updateVideoSchema, insertVideoSchema, insertUserSchema,
|
||
insertVideoUploadSchema, insertCategorySchema, insertTagSchema,
|
||
type User, type VideoUpload
|
||
} from "@shared/schema";
|
||
import multer from "multer";
|
||
import { randomUUID } from "crypto";
|
||
import path from "path";
|
||
import fs from "fs";
|
||
import session from "express-session";
|
||
import sharp from "sharp";
|
||
import fetch from "node-fetch";
|
||
import { setupAuth, isAuthenticated, isAdmin } from "./replitAuth";
|
||
import { ObjectStorageService, ObjectNotFoundError } from "./objectStorage";
|
||
import { generateVideoDescription, generateBulkDescriptions } from "./aiService";
|
||
|
||
// Extract unique artist names from video titles
|
||
function extractArtists(videos: any[]): { name: string; videoCount: number; videos: any[] }[] {
|
||
const artistMap = new Map<string, any[]>();
|
||
for (const v of videos) {
|
||
const title = v.title || '';
|
||
let artist = '';
|
||
if (title.includes('–')) {
|
||
artist = title.split('–')[0].trim();
|
||
} else if (title.includes(' - ')) {
|
||
artist = title.split(' - ')[0].trim();
|
||
}
|
||
if (artist && !artist.toUpperCase().startsWith('FOLX')) {
|
||
const normalized = artist.replace(/\s+/g, ' ').trim();
|
||
if (!artistMap.has(normalized)) {
|
||
artistMap.set(normalized, []);
|
||
}
|
||
artistMap.get(normalized)!.push(v);
|
||
}
|
||
}
|
||
return Array.from(artistMap.entries())
|
||
.map(([name, vids]) => ({ name, videoCount: vids.length, videos: vids }))
|
||
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||
}
|
||
|
||
// Find video by short or long ID - moved to top level for export
|
||
export async function findVideoByAnyId(id: string) {
|
||
try {
|
||
// If it's already a full UUID, use it directly
|
||
if (id.length === 36 && id.includes('-')) {
|
||
return await storage.getVideo(id);
|
||
}
|
||
|
||
// If it's an 8-character short ID, find by short ID
|
||
if (id.length === 8) {
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
return allVideos.find(v => v.id.replace(/-/g, '').substring(0, 8) === id);
|
||
}
|
||
|
||
return undefined;
|
||
} catch (error) {
|
||
console.error(`Error finding video by ID ${id}:`, error);
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
// Extend express session
|
||
declare module "express-session" {
|
||
interface SessionData {
|
||
userId: string;
|
||
}
|
||
}
|
||
|
||
// Configure multer for video uploads
|
||
const upload = multer({
|
||
storage: multer.diskStorage({
|
||
destination: './uploads/videos',
|
||
filename: (req, file, cb) => {
|
||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
||
}
|
||
}),
|
||
limits: {
|
||
fileSize: 500 * 1024 * 1024, // 500MB max file size
|
||
},
|
||
fileFilter: (req, file, cb) => {
|
||
// Allow video files only
|
||
if (file.mimetype.startsWith('video/')) {
|
||
cb(null, true);
|
||
} else {
|
||
cb(new Error('Only video files are allowed'));
|
||
}
|
||
}
|
||
});
|
||
|
||
// Simple session-based authentication middleware
|
||
const authenticate = (req: Request, res: Response, next: any) => {
|
||
if (req.session?.userId) {
|
||
next();
|
||
} else {
|
||
res.status(401).json({ message: "Authentication required" });
|
||
}
|
||
};
|
||
|
||
export async function registerRoutes(app: Express): Promise<Server> {
|
||
// Setup Replit Auth first
|
||
await setupAuth(app);
|
||
|
||
// Social media crawler detection middleware for /video/:id routes
|
||
// This serves proper OG meta tags to crawlers while letting regular users get the SPA
|
||
app.get('/video/:id', async (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
|
||
// List of social media crawler user agents
|
||
const crawlers = [
|
||
'facebookexternalhit',
|
||
'facebot',
|
||
'twitterbot',
|
||
'whatsapp',
|
||
'telegrambot',
|
||
'linkedinbot',
|
||
'pinterest',
|
||
'slackbot',
|
||
'viberbot',
|
||
'discordbot',
|
||
'applebot',
|
||
'googlebot',
|
||
'bingbot',
|
||
'yandex',
|
||
'baiduspider',
|
||
'duckduckbot'
|
||
];
|
||
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
|
||
if (!isCrawler) {
|
||
// Not a crawler, let the SPA handle it
|
||
return next();
|
||
}
|
||
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
// Find video from cache
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
let video;
|
||
|
||
if (id.length === 36 && id.includes('-')) {
|
||
video = allVideos.find(v => v.id === id);
|
||
} else if (id.length === 8) {
|
||
video = allVideos.find(v => v.id.replace(/-/g, '').substring(0, 8) === id);
|
||
} else {
|
||
video = allVideos.find(v => v.id.includes(id));
|
||
}
|
||
|
||
if (!video) {
|
||
return next(); // Let SPA handle 404
|
||
}
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const videoUrl = `${baseUrl}/video/${video.id}`;
|
||
|
||
// Get high-quality thumbnail for sharing (1200x630 is ideal for Facebook)
|
||
let thumbnailUrl = `${baseUrl}/api/social-image`;
|
||
if (video.thumbnailUrl) {
|
||
thumbnailUrl = video.thumbnailUrl
|
||
.replace(/width=\d+/gi, 'width=1200')
|
||
.replace(/height=\d+/gi, 'height=630')
|
||
.replace(/format=webp/gi, 'format=jpg');
|
||
}
|
||
|
||
// Extract artist name from title (format: "Artist – Song" or "Artist - Song")
|
||
const titleStr = video.title || '';
|
||
let artistName = '';
|
||
let songName = titleStr;
|
||
if (titleStr.includes('–')) {
|
||
const parts = titleStr.split('–');
|
||
artistName = parts[0].trim();
|
||
songName = parts.slice(1).join('–').trim();
|
||
} else if (titleStr.includes(' - ')) {
|
||
const parts = titleStr.split(' - ');
|
||
artistName = parts[0].trim();
|
||
songName = parts.slice(1).join(' - ').trim();
|
||
}
|
||
|
||
// Clean description for meta tags
|
||
const description = video.description
|
||
? video.description.substring(0, 200).replace(/[<>"']/g, '')
|
||
: artistName
|
||
? `${artistName} – ${songName}. Jetzt ansehen auf Folx TV - Nummer 1 in Europa für Volksmusik und Schlager.`
|
||
: `${titleStr} auf Folx TV ansehen - Nummer 1 in Europa für Volksmusik und Schlager.`;
|
||
|
||
const title = video.title || 'Folx TV - Video';
|
||
const keywords = artistName
|
||
? `${artistName}, ${songName}, Volksmusik, Schlager, Folx TV, Musikvideo`
|
||
: `${titleStr}, Volksmusik, Schlager, Folx TV`;
|
||
|
||
// JSON-LD structured data for video with artist
|
||
const jsonLd: any = {
|
||
"@context": "https://schema.org",
|
||
"@type": "VideoObject",
|
||
"name": title,
|
||
"description": description,
|
||
"thumbnailUrl": thumbnailUrl,
|
||
"uploadDate": video.createdAt ? new Date(video.createdAt).toISOString() : new Date().toISOString(),
|
||
"duration": video.duration ? `PT${Math.floor(video.duration / 60)}M${video.duration % 60}S` : undefined,
|
||
"contentUrl": videoUrl,
|
||
"embedUrl": videoUrl,
|
||
"interactionStatistic": {
|
||
"@type": "InteractionCounter",
|
||
"interactionType": "https://schema.org/WatchAction",
|
||
"userInteractionCount": video.views || 0
|
||
},
|
||
"publisher": {
|
||
"@type": "Organization",
|
||
"name": "FOLX.TV",
|
||
"url": "https://folx.tv"
|
||
},
|
||
"inLanguage": "de"
|
||
};
|
||
|
||
if (artistName) {
|
||
jsonLd["byArtist"] = {
|
||
"@type": "MusicGroup",
|
||
"name": artistName
|
||
};
|
||
jsonLd["@type"] = "MusicVideoObject";
|
||
}
|
||
|
||
// Return HTML page with OG tags for crawlers
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title} | Folx TV - Video</title>
|
||
<meta name="description" content="${description}">
|
||
<meta name="keywords" content="${keywords}">
|
||
|
||
<meta property="og:type" content="video.other">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:image" content="${thumbnailUrl}">
|
||
<meta property="og:image:secure_url" content="${thumbnailUrl}">
|
||
<meta property="og:image:width" content="1200">
|
||
<meta property="og:image:height" content="630">
|
||
<meta property="og:image:type" content="image/jpeg">
|
||
<meta property="og:url" content="${videoUrl}">
|
||
<meta property="og:site_name" content="Folx TV - Video">
|
||
<meta property="og:locale" content="de_DE">
|
||
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<meta name="twitter:image" content="${thumbnailUrl}">
|
||
|
||
<link rel="canonical" href="${videoUrl}">
|
||
<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
|
||
</head>
|
||
<body>
|
||
<h1>${title}</h1>
|
||
${artistName ? `<h2>Interpret: ${artistName}</h2>` : ''}
|
||
<p>${description}</p>
|
||
<p><a href="${videoUrl}">Jetzt ansehen auf Folx TV</a></p>
|
||
</body>
|
||
</html>`;
|
||
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
} catch (error) {
|
||
console.error('Error generating OG tags for crawler:', error);
|
||
next(); // Let SPA handle errors
|
||
}
|
||
});
|
||
|
||
// Add compression middleware for better performance
|
||
app.use(compression({
|
||
level: 6,
|
||
threshold: 1024, // Only compress responses larger than 1KB
|
||
filter: (req: any, res: any) => {
|
||
// Don't compress video files
|
||
if (req.headers['accept']?.includes('video/')) {
|
||
return false;
|
||
}
|
||
return compression.filter(req, res);
|
||
}
|
||
}));
|
||
|
||
// Configure session middleware
|
||
app.use(session({
|
||
secret: process.env.SESSION_SECRET || 'dev-secret-key',
|
||
resave: false,
|
||
saveUninitialized: false,
|
||
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
||
}));
|
||
|
||
// Authentication Routes
|
||
app.post("/api/auth/register", async (req, res) => {
|
||
try {
|
||
const userData = insertUserSchema.parse(req.body);
|
||
|
||
// Check if user already exists
|
||
const existingUser = await storage.getUserByEmail(userData.email);
|
||
if (existingUser) {
|
||
return res.status(400).json({ message: "User already exists with this email" });
|
||
}
|
||
|
||
const user = await storage.createUser(userData);
|
||
|
||
// Remove password from response
|
||
const { passwordHash, ...userResponse } = user;
|
||
res.status(201).json(userResponse);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({ message: "Invalid user data", errors: error.errors });
|
||
}
|
||
res.status(500).json({ message: "Failed to create user" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/auth/login", async (req, res) => {
|
||
try {
|
||
const { email, password } = req.body;
|
||
|
||
if (!email || !password) {
|
||
return res.status(400).json({ message: "Email and password are required" });
|
||
}
|
||
|
||
const user = await storage.validateUserPassword(email, password);
|
||
if (!user) {
|
||
return res.status(401).json({ message: "Invalid credentials" });
|
||
}
|
||
|
||
// Set session
|
||
req.session.userId = user.id;
|
||
|
||
// Remove password from response
|
||
const { passwordHash, ...userResponse } = user;
|
||
res.json(userResponse);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to login" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/auth/logout", (req, res) => {
|
||
req.session?.destroy(() => {
|
||
res.json({ message: "Logged out successfully" });
|
||
});
|
||
});
|
||
|
||
// Server-side meta tags for /player page (crawlers + SEO)
|
||
app.get('/player', (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
|
||
const crawlers = [
|
||
'facebookexternalhit', 'facebot', 'twitterbot', 'whatsapp',
|
||
'telegrambot', 'linkedinbot', 'pinterest', 'slackbot',
|
||
'viberbot', 'discordbot', 'applebot', 'googlebot',
|
||
'bingbot', 'yandex', 'baiduspider', 'duckduckbot'
|
||
];
|
||
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
|
||
if (!isCrawler) {
|
||
return next();
|
||
}
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const pageUrl = `${baseUrl}/player`;
|
||
const title = 'Professional Player - Folx TV - Video';
|
||
const description = 'Professioneller Video Player mit Overlay Graphics und Streaming-Funktionen. Folx TV - Nummer 1 in Europa für Volksmusik und Schlager.';
|
||
const imageUrl = `${baseUrl}/images/logo.svg`;
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title}</title>
|
||
<meta name="description" content="${description}">
|
||
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:image" content="${imageUrl}">
|
||
<meta property="og:url" content="${pageUrl}">
|
||
<meta property="og:site_name" content="video.folx.tv">
|
||
<meta property="og:locale" content="de_DE">
|
||
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<meta name="twitter:image" content="${imageUrl}">
|
||
|
||
<link rel="canonical" href="${pageUrl}">
|
||
</head>
|
||
<body>
|
||
<h1>${title}</h1>
|
||
<p>${description}</p>
|
||
<p><a href="${pageUrl}">Zum Player auf video.folx.tv</a></p>
|
||
</body>
|
||
</html>`;
|
||
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
});
|
||
|
||
// Server-side meta tags for /live page (crawlers + SEO)
|
||
app.get('/live', (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
|
||
const crawlers = [
|
||
'facebookexternalhit', 'facebot', 'twitterbot', 'whatsapp',
|
||
'telegrambot', 'linkedinbot', 'pinterest', 'slackbot',
|
||
'viberbot', 'discordbot', 'applebot', 'googlebot',
|
||
'bingbot', 'yandex', 'baiduspider', 'duckduckbot'
|
||
];
|
||
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
|
||
if (!isCrawler) {
|
||
return next();
|
||
}
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const pageUrl = `${baseUrl}/live`;
|
||
const title = 'LIVE Stream - Folx TV - Video';
|
||
const description = 'Live Stream auf video.folx.tv - Schauen Sie exklusive Inhalte in Echtzeit. Folx TV - Nummer 1 in Europa für Volksmusik und Schlager.';
|
||
const imageUrl = `${baseUrl}/images/logo.svg`;
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title}</title>
|
||
<meta name="description" content="${description}">
|
||
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:image" content="${imageUrl}">
|
||
<meta property="og:url" content="${pageUrl}">
|
||
<meta property="og:site_name" content="video.folx.tv">
|
||
<meta property="og:locale" content="de_DE">
|
||
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<meta name="twitter:image" content="${imageUrl}">
|
||
|
||
<link rel="canonical" href="${pageUrl}">
|
||
</head>
|
||
<body>
|
||
<h1>${title}</h1>
|
||
<p>${description}</p>
|
||
<p><a href="${pageUrl}">Zum Live Stream auf video.folx.tv</a></p>
|
||
</body>
|
||
</html>`;
|
||
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
});
|
||
|
||
// API endpoint for artists list
|
||
app.get('/api/artists', async (req, res) => {
|
||
try {
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
const artists = extractArtists(allVideos);
|
||
res.json({ artists: artists.map(a => ({ name: a.name, videoCount: a.videoCount })), total: artists.length });
|
||
} catch (error) {
|
||
res.status(500).json({ message: 'Error fetching artists' });
|
||
}
|
||
});
|
||
|
||
// Server-side meta tags for /kuenstler page (crawlers + SEO)
|
||
app.get('/kuenstler', async (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
const crawlers = [
|
||
'facebookexternalhit', 'facebot', 'twitterbot', 'whatsapp',
|
||
'telegrambot', 'linkedinbot', 'pinterest', 'slackbot',
|
||
'viberbot', 'discordbot', 'applebot', 'googlebot',
|
||
'bingbot', 'yandex', 'baiduspider', 'duckduckbot'
|
||
];
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
if (!isCrawler) return next();
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const pageUrl = `${baseUrl}/kuenstler`;
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
const artists = extractArtists(allVideos);
|
||
const topArtists = artists.slice(0, 30).map(a => a.name).join(', ');
|
||
const title = `Alle Künstler & Interpreten (${artists.length}) | Folx TV - Video`;
|
||
const description = `Entdecken Sie ${artists.length} Volksmusik- und Schlager-Künstler auf Folx TV: ${topArtists} und viele mehr. Nummer 1 in Europa für Volksmusik und Schlager.`;
|
||
const keywords = artists.map(a => a.name).join(', ') + ', Volksmusik, Schlager, Folx TV';
|
||
|
||
const escapeHtml = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
||
const jsonLd = {
|
||
"@context": "https://schema.org",
|
||
"@type": "CollectionPage",
|
||
"name": title,
|
||
"description": description,
|
||
"url": pageUrl,
|
||
"numberOfItems": artists.length,
|
||
"publisher": { "@type": "Organization", "name": "FOLX.TV", "url": "https://folx.tv" },
|
||
"inLanguage": "de",
|
||
"mainEntity": {
|
||
"@type": "ItemList",
|
||
"numberOfItems": artists.length,
|
||
"itemListElement": artists.map((a, i) => ({
|
||
"@type": "ListItem",
|
||
"position": i + 1,
|
||
"item": {
|
||
"@type": "MusicGroup",
|
||
"name": a.name,
|
||
"url": `${pageUrl}#letter-${a.name.charAt(0).toUpperCase()}`
|
||
}
|
||
}))
|
||
}
|
||
};
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${escapeHtml(title)}</title>
|
||
<meta name="description" content="${escapeHtml(description)}">
|
||
<meta name="keywords" content="${escapeHtml(keywords)}">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:title" content="${escapeHtml(title)}">
|
||
<meta property="og:description" content="${escapeHtml(description)}">
|
||
<meta property="og:url" content="${pageUrl}">
|
||
<meta property="og:site_name" content="Folx TV - Video">
|
||
<meta property="og:locale" content="de_DE">
|
||
<meta name="twitter:card" content="summary">
|
||
<meta name="twitter:title" content="${escapeHtml(title)}">
|
||
<meta name="twitter:description" content="${escapeHtml(description)}">
|
||
<link rel="canonical" href="${pageUrl}">
|
||
<script type="application/ld+json">${JSON.stringify(jsonLd)}</script>
|
||
</head>
|
||
<body>
|
||
<h1>Alle Künstler & Interpreten auf Folx TV</h1>
|
||
<p>${escapeHtml(description)}</p>
|
||
<p>Insgesamt ${artists.length} Künstler mit ${allVideos.length} Musikvideos.</p>
|
||
<ul>${artists.map(a => `<li id="${encodeURIComponent(a.name)}"><strong>${escapeHtml(a.name)}</strong> (${a.videoCount} Video${a.videoCount > 1 ? 's' : ''})</li>`).join('')}</ul>
|
||
</body>
|
||
</html>`;
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
});
|
||
|
||
// Server-side meta tags for /folx-stadl page (crawlers + SEO)
|
||
app.get('/folx-stadl', async (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
const crawlers = [
|
||
'facebookexternalhit', 'facebot', 'twitterbot', 'whatsapp',
|
||
'telegrambot', 'linkedinbot', 'pinterest', 'slackbot',
|
||
'viberbot', 'discordbot', 'applebot', 'googlebot',
|
||
'bingbot', 'yandex', 'baiduspider', 'duckduckbot'
|
||
];
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
if (!isCrawler) return next();
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const pageUrl = `${baseUrl}/folx-stadl`;
|
||
const title = 'FOLX STADL - Die große Volksmusik Show | Folx TV - Video';
|
||
const description = 'FOLX STADL - Die beliebte Volksmusik- und Schlagershow auf Folx TV. Mit Stars wie Angela Wiedl, Oswald Sattler, Die Grubertaler, Kastelruther Spatzen und vielen mehr. Jetzt alle Folgen ansehen!';
|
||
const keywords = 'FOLX STADL, Volksmusik Show, Schlager, Angela Wiedl, Oswald Sattler, Die Grubertaler, Kastelruther Spatzen, Volksmusik TV, Folx TV';
|
||
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
const stadlVideos = allVideos.filter(v => v.title.includes('FOLX STADL') || v.title.includes('FOLXSTADL'));
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title}</title>
|
||
<meta name="description" content="${description}">
|
||
<meta name="keywords" content="${keywords}">
|
||
<meta property="og:type" content="video.tv_show">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:url" content="${pageUrl}">
|
||
<meta property="og:site_name" content="video.folx.tv">
|
||
<meta property="og:locale" content="de_DE">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<link rel="canonical" href="${pageUrl}">
|
||
<script type="application/ld+json">
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "TVSeries",
|
||
"name": "FOLX STADL",
|
||
"description": "${description}",
|
||
"url": "${pageUrl}",
|
||
"numberOfEpisodes": ${stadlVideos.length},
|
||
"genre": ["Volksmusik", "Schlager", "Unterhaltung"],
|
||
"inLanguage": "de",
|
||
"productionCompany": {"@type": "Organization", "name": "FOLX.TV"},
|
||
"publisher": {"@type": "Organization", "name": "FOLX.TV", "url": "https://folx.tv"}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<h1>${title}</h1>
|
||
<p>${description}</p>
|
||
<h2>Alle FOLX STADL Folgen (${stadlVideos.length} Videos)</h2>
|
||
<ul>${stadlVideos.map(v => `<li><a href="${baseUrl}/video/${v.id.replace(/-/g, '').substring(0, 8)}">${v.title}</a></li>`).join('')}</ul>
|
||
</body>
|
||
</html>`;
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
});
|
||
|
||
// Server-side meta tags for /geschichte-lied page (crawlers + SEO)
|
||
app.get('/geschichte-lied', async (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
const crawlers = [
|
||
'facebookexternalhit', 'facebot', 'twitterbot', 'whatsapp',
|
||
'telegrambot', 'linkedinbot', 'pinterest', 'slackbot',
|
||
'viberbot', 'discordbot', 'applebot', 'googlebot',
|
||
'bingbot', 'yandex', 'baiduspider', 'duckduckbot'
|
||
];
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
if (!isCrawler) return next();
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const pageUrl = `${baseUrl}/geschichte-lied`;
|
||
const title = 'Die Geschichte des Liedes - Musikdokumentation | Folx TV - Video';
|
||
const description = 'Die Geschichte des Liedes - Entdecken Sie die Entstehung und Hintergründe der beliebtesten Volksmusik- und Schlagerlieder. Eine einzigartige Musikdokumentation auf Folx TV.';
|
||
const keywords = 'Geschichte des Liedes, Musikdokumentation, Volksmusik Geschichte, Schlager Historie, Liedgeschichte, Folx TV, Musiksendung, Kastelruther Spatzen, Monika Martin, Oswald Sattler, Angela Wiedl, Die Grubertaler';
|
||
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
const geschichteVideos = allVideos.filter(v => v.title.includes('Geschichte des Liedes'));
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title}</title>
|
||
<meta name="description" content="${description}">
|
||
<meta name="keywords" content="${keywords}">
|
||
<meta property="og:type" content="video.tv_show">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:url" content="${pageUrl}">
|
||
<meta property="og:site_name" content="video.folx.tv">
|
||
<meta property="og:locale" content="de_DE">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<link rel="canonical" href="${pageUrl}">
|
||
<script type="application/ld+json">
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "TVSeries",
|
||
"name": "Die Geschichte des Liedes",
|
||
"description": "${description}",
|
||
"url": "${pageUrl}",
|
||
"numberOfEpisodes": ${geschichteVideos.length},
|
||
"genre": ["Musikdokumentation", "Volksmusik", "Schlager"],
|
||
"inLanguage": "de",
|
||
"productionCompany": {"@type": "Organization", "name": "FOLX.TV"},
|
||
"publisher": {"@type": "Organization", "name": "FOLX.TV", "url": "https://folx.tv"}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<h1>${title}</h1>
|
||
<p>${description}</p>
|
||
<h2>Alle Folgen (${geschichteVideos.length} Videos)</h2>
|
||
<ul>${geschichteVideos.map(v => `<li><a href="${baseUrl}/video/${v.id.replace(/-/g, '').substring(0, 8)}">${v.title}</a></li>`).join('')}</ul>
|
||
</body>
|
||
</html>`;
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
});
|
||
|
||
// Server-side meta tags for /gipfelstammtisch page (crawlers + SEO)
|
||
app.get('/gipfelstammtisch', async (req, res, next) => {
|
||
const userAgent = req.headers['user-agent']?.toLowerCase() || '';
|
||
const crawlers = [
|
||
'facebookexternalhit', 'facebot', 'twitterbot', 'whatsapp',
|
||
'telegrambot', 'linkedinbot', 'pinterest', 'slackbot',
|
||
'viberbot', 'discordbot', 'applebot', 'googlebot',
|
||
'bingbot', 'yandex', 'baiduspider', 'duckduckbot'
|
||
];
|
||
const isCrawler = crawlers.some(crawler => userAgent.includes(crawler));
|
||
if (!isCrawler) return next();
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const pageUrl = `${baseUrl}/gipfelstammtisch`;
|
||
const title = 'Gipfelstammtisch - Volksmusik Talkshow | Folx TV - Video';
|
||
const description = 'Gipfelstammtisch - Die gemütliche Volksmusik-Talkshow auf Folx TV. Gespräche mit Stars der Volksmusik- und Schlagerszene in einzigartiger Alpenatmosphäre. Jetzt alle Folgen ansehen!';
|
||
const keywords = 'Gipfelstammtisch, Volksmusik Talkshow, Schlager Talk, Alpen, Volksmusik Stars, Folx TV, Musiksendung, Kastelruther Spatzen, Monika Martin, Oswald Sattler, Angela Wiedl, Michael Hirte';
|
||
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
const gipfelVideos = allVideos.filter(v => v.title.toLowerCase().includes('gipfelstammtisch'));
|
||
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title}</title>
|
||
<meta name="description" content="${description}">
|
||
<meta name="keywords" content="${keywords}">
|
||
<meta property="og:type" content="video.tv_show">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:url" content="${pageUrl}">
|
||
<meta property="og:site_name" content="video.folx.tv">
|
||
<meta property="og:locale" content="de_DE">
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<link rel="canonical" href="${pageUrl}">
|
||
<script type="application/ld+json">
|
||
{
|
||
"@context": "https://schema.org",
|
||
"@type": "TVSeries",
|
||
"name": "Gipfelstammtisch",
|
||
"description": "${description}",
|
||
"url": "${pageUrl}",
|
||
"numberOfEpisodes": ${gipfelVideos.length},
|
||
"genre": ["Talkshow", "Volksmusik", "Unterhaltung"],
|
||
"inLanguage": "de",
|
||
"productionCompany": {"@type": "Organization", "name": "FOLX.TV"},
|
||
"publisher": {"@type": "Organization", "name": "FOLX.TV", "url": "https://folx.tv"}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<h1>${title}</h1>
|
||
<p>${description}</p>
|
||
<h2>Alle Folgen (${gipfelVideos.length} Videos)</h2>
|
||
<ul>${gipfelVideos.map(v => `<li><a href="${baseUrl}/video/${v.id.replace(/-/g, '').substring(0, 8)}">${v.title}</a></li>`).join('')}</ul>
|
||
</body>
|
||
</html>`;
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
});
|
||
|
||
// Sitemap.xml for SEO
|
||
app.get("/sitemap.xml", async (req, res) => {
|
||
try {
|
||
const baseUrl = "https://video.folx.tv";
|
||
const videos = await storage.getVideos(1000, 0);
|
||
|
||
// Helper to escape XML special characters
|
||
const escapeXml = (str: string) => {
|
||
if (!str) return '';
|
||
return str
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
|
||
let xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||
<url>
|
||
<loc>${baseUrl}/</loc>
|
||
<changefreq>daily</changefreq>
|
||
<priority>1.0</priority>
|
||
</url>
|
||
<url>
|
||
<loc>${baseUrl}/folx-stadl</loc>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.7</priority>
|
||
</url>
|
||
<url>
|
||
<loc>${baseUrl}/geschichte-lied</loc>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.7</priority>
|
||
</url>
|
||
<url>
|
||
<loc>${baseUrl}/gipfelstammtisch</loc>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.7</priority>
|
||
</url>
|
||
<url>
|
||
<loc>${baseUrl}/kuenstler</loc>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.8</priority>
|
||
</url>
|
||
`;
|
||
|
||
// Add artist letter-index anchors to sitemap
|
||
const artists = extractArtists(videos);
|
||
const artistLetters = Array.from(new Set(artists.map(a => a.name.charAt(0).toUpperCase()))).sort();
|
||
for (const letter of artistLetters) {
|
||
xml += ` <url>
|
||
<loc>${baseUrl}/kuenstler#letter-${escapeXml(letter)}</loc>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.6</priority>
|
||
</url>
|
||
`;
|
||
}
|
||
|
||
for (const video of videos) {
|
||
const shortId = video.id.replace(/-/g, '').substring(0, 8);
|
||
const lastmod = video.createdAt ? new Date(video.createdAt).toISOString().split('T')[0] : new Date().toISOString().split('T')[0];
|
||
const safeTitle = escapeXml(video.title);
|
||
const safeThumbnail = escapeXml(video.thumbnailUrl || '');
|
||
|
||
// Extract artist for richer description
|
||
const titleStr = video.title || '';
|
||
let artist = '';
|
||
let song = titleStr;
|
||
if (titleStr.includes('–')) {
|
||
artist = titleStr.split('–')[0].trim();
|
||
song = titleStr.split('–').slice(1).join('–').trim();
|
||
} else if (titleStr.includes(' - ')) {
|
||
artist = titleStr.split(' - ')[0].trim();
|
||
song = titleStr.split(' - ').slice(1).join(' - ').trim();
|
||
}
|
||
|
||
const safeDescription = escapeXml(
|
||
video.description ||
|
||
(artist ? `${artist} – ${song}. Volksmusik und Schlager auf Folx TV - Nummer 1 in Europa.` : `${titleStr} auf Folx TV ansehen.`)
|
||
);
|
||
const safeTags = artist ? `<video:tag>${escapeXml(artist)}</video:tag><video:tag>Volksmusik</video:tag><video:tag>Schlager</video:tag><video:tag>Folx TV</video:tag>` : '<video:tag>Volksmusik</video:tag><video:tag>Schlager</video:tag><video:tag>Folx TV</video:tag>';
|
||
|
||
xml += ` <url>
|
||
<loc>${baseUrl}/video/${shortId}</loc>
|
||
<lastmod>${lastmod}</lastmod>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.8</priority>
|
||
<video:video>
|
||
<video:thumbnail_loc>${safeThumbnail}</video:thumbnail_loc>
|
||
<video:title>${safeTitle}</video:title>
|
||
<video:description>${safeDescription}</video:description>
|
||
<video:duration>${video.duration || 0}</video:duration>
|
||
<video:family_friendly>yes</video:family_friendly>
|
||
${safeTags}
|
||
</video:video>
|
||
</url>
|
||
`;
|
||
}
|
||
|
||
xml += `</urlset>`;
|
||
|
||
res.setHeader('Content-Type', 'application/xml');
|
||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||
res.send(xml);
|
||
} catch (error) {
|
||
console.error('Error generating sitemap:', error);
|
||
res.status(500).send('Error generating sitemap');
|
||
}
|
||
});
|
||
|
||
// Robots.txt
|
||
app.get("/robots.txt", (req, res) => {
|
||
const baseUrl = "https://video.folx.tv";
|
||
const robots = `User-agent: *
|
||
Allow: /
|
||
Disallow: /admin
|
||
Disallow: /api/
|
||
|
||
Sitemap: ${baseUrl}/sitemap.xml
|
||
`;
|
||
res.setHeader('Content-Type', 'text/plain');
|
||
res.send(robots);
|
||
});
|
||
|
||
app.get("/api/auth/me", authenticate, async (req, res) => {
|
||
try {
|
||
const user = await storage.getUser(req.session.userId!);
|
||
if (!user) {
|
||
return res.status(404).json({ message: "User not found" });
|
||
}
|
||
|
||
const { passwordHash, ...userResponse } = user;
|
||
res.json(userResponse);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to fetch user" });
|
||
}
|
||
});
|
||
|
||
// Video Routes
|
||
// Bunny.net administration routes
|
||
app.get("/api/bunny/stats", async (req, res) => {
|
||
try {
|
||
const videos = await storage.getVideos(1, 1000);
|
||
const totalViews = videos.length > 0 ? videos.reduce((sum: number, video: any) => sum + video.views, 0) : 0;
|
||
|
||
const stats = {
|
||
totalVideos: videos.length,
|
||
totalViews,
|
||
totalStorage: 0, // Would need separate Bunny API call
|
||
bandwidth: 0 // Would need separate Bunny API call
|
||
};
|
||
|
||
res.json(stats);
|
||
} catch (error) {
|
||
console.error("Error fetching Bunny stats:", error);
|
||
res.status(500).json({ message: "Failed to fetch statistics" });
|
||
}
|
||
});
|
||
|
||
app.delete("/api/bunny/videos/:id", async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
// Note: This would need implementation in BunnyService
|
||
// For now, return success (deletion would happen in Bunny dashboard)
|
||
res.json({ message: "Video deletion initiated" });
|
||
} catch (error) {
|
||
console.error("Error deleting video:", error);
|
||
res.status(500).json({ message: "Failed to delete video" });
|
||
}
|
||
});
|
||
|
||
// Manual sync endpoint to force refresh from Bunny.net
|
||
app.post("/api/bunny/sync", async (req, res) => {
|
||
try {
|
||
console.log('🔄 Manual video sync requested...');
|
||
// Import videoSyncService to trigger manual sync
|
||
const { videoSyncService } = await import('./videoSync');
|
||
await videoSyncService.initialize();
|
||
|
||
const videos = await storage.getVideos(1, 1000);
|
||
res.json({
|
||
message: "Video sync completed successfully",
|
||
totalVideos: videos.length,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
} catch (error) {
|
||
console.error("Error during manual sync:", error);
|
||
res.status(500).json({ message: "Failed to sync videos from Bunny.net" });
|
||
}
|
||
});
|
||
|
||
|
||
|
||
app.get("/api/videos", async (req, res) => {
|
||
try {
|
||
const limit = parseInt(req.query.limit as string) || 20;
|
||
const offset = parseInt(req.query.offset as string) || 0;
|
||
const search = req.query.search as string;
|
||
|
||
// Skip search for queries shorter than 2 characters for performance
|
||
const searchQuery = search && search.length >= 2 ? search : undefined;
|
||
|
||
// Create cache key for ETag
|
||
const cacheKey = `videos-${limit}-${offset}-${searchQuery || 'all'}`;
|
||
const etag = `"${cacheKey}"`;
|
||
|
||
// Check if client has cached version
|
||
if (req.headers['if-none-match'] === etag) {
|
||
return res.status(304).end();
|
||
}
|
||
|
||
// Set optimized cache headers
|
||
res.set({
|
||
'Cache-Control': 'public, max-age=120, stale-while-revalidate=300', // 2 min cache, 5 min stale
|
||
'ETag': etag,
|
||
'X-Content-Type-Options': 'nosniff',
|
||
'Vary': 'Accept-Encoding'
|
||
});
|
||
|
||
console.log(`Fetching videos: limit=${limit}, offset=${offset}, search=${searchQuery}`);
|
||
|
||
const videos = await storage.getVideos(limit, offset, searchQuery);
|
||
const total = await storage.getVideoCount(searchQuery);
|
||
|
||
console.log(`Returning ${videos.length} videos`);
|
||
|
||
res.json({
|
||
videos,
|
||
total,
|
||
hasMore: offset + limit < total
|
||
});
|
||
} catch (error) {
|
||
console.error("Error fetching videos:", error);
|
||
res.status(500).json({ message: "Failed to fetch videos" });
|
||
}
|
||
});
|
||
|
||
// Face detection endpoint for thumbnails
|
||
app.post("/api/videos/:id/analyze-face", async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const video = await storage.getVideo(id);
|
||
|
||
if (!video) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
|
||
if (!video.thumbnailUrl) {
|
||
return res.status(400).json({ message: "Video has no thumbnail" });
|
||
}
|
||
|
||
// Import smart thumbnail service (no external dependencies)
|
||
const { smartThumbnailService } = await import("./smart-thumbnail-service");
|
||
const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl);
|
||
|
||
// Update video with face position data
|
||
await storage.updateVideo(id, {
|
||
faceCenterPosition: result.faceCenterPosition,
|
||
facesDetected: result.facesDetected,
|
||
faceConfidence: result.confidence
|
||
});
|
||
|
||
res.json({
|
||
videoId: id,
|
||
...result
|
||
});
|
||
} catch (error) {
|
||
console.error("Error analyzing face in thumbnail:", error);
|
||
res.status(500).json({ message: "Failed to analyze face" });
|
||
}
|
||
});
|
||
|
||
// Batch face analysis for all videos
|
||
app.post("/api/videos/batch-analyze-faces", async (req, res) => {
|
||
try {
|
||
// Get all videos that don't have face analysis yet
|
||
const allVideos = await storage.getVideos(1000, 0); // Get up to 1000 videos
|
||
const videosToProcess = allVideos.filter(video => !video.faceCenterPosition && video.thumbnailUrl);
|
||
|
||
if (videosToProcess.length === 0) {
|
||
return res.json({
|
||
message: "All videos already processed or no thumbnails available",
|
||
processed: 0,
|
||
total: allVideos.length
|
||
});
|
||
}
|
||
|
||
console.log(`Starting batch face analysis for ${videosToProcess.length} videos...`);
|
||
|
||
// Import smart thumbnail service (no external dependencies)
|
||
const { smartThumbnailService } = await import("./smart-thumbnail-service");
|
||
let processed = 0;
|
||
let failed = 0;
|
||
|
||
// Process videos in batches to avoid overwhelming the system
|
||
const batchSize = 5;
|
||
for (let i = 0; i < videosToProcess.length; i += batchSize) {
|
||
const batch = videosToProcess.slice(i, i + batchSize);
|
||
|
||
await Promise.all(batch.map(async (video) => {
|
||
try {
|
||
console.log(`Processing face detection for video: ${video.title} (${video.id})`);
|
||
const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl);
|
||
|
||
await storage.updateVideo(video.id, {
|
||
faceCenterPosition: result.faceCenterPosition,
|
||
facesDetected: result.facesDetected,
|
||
faceConfidence: result.confidence
|
||
});
|
||
|
||
processed++;
|
||
console.log(`✅ Face analysis completed for ${video.title} - Faces detected: ${result.facesDetected}`);
|
||
} catch (error) {
|
||
console.error(`❌ Face analysis failed for ${video.title}:`, error);
|
||
failed++;
|
||
}
|
||
}));
|
||
|
||
// Small delay between batches to be respectful
|
||
if (i + batchSize < videosToProcess.length) {
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
}
|
||
}
|
||
|
||
console.log(`Batch face analysis completed: ${processed} processed, ${failed} failed`);
|
||
|
||
res.json({
|
||
message: "Batch face analysis completed",
|
||
processed,
|
||
failed,
|
||
total: videosToProcess.length
|
||
});
|
||
} catch (error) {
|
||
console.error("Error in batch face analysis:", error);
|
||
res.status(500).json({ message: "Failed to perform batch face analysis" });
|
||
}
|
||
});
|
||
|
||
// Generate short ID from long UUID
|
||
function generateShortId(longId: string): string {
|
||
// Take first 8 characters and remove dashes for shorter, cleaner URLs
|
||
return longId.replace(/-/g, '').substring(0, 8);
|
||
}
|
||
|
||
|
||
// Get single video by ID (supports both short and long IDs)
|
||
app.get("/api/videos/:id", async (req, res) => {
|
||
try {
|
||
const video = await findVideoByAnyId(req.params.id);
|
||
if (!video) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
res.json(video);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to fetch video" });
|
||
}
|
||
});
|
||
|
||
// Get video ads/spots metadata from Bunny.net
|
||
app.get("/api/videos/:id/ads", async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
// Check if video exists first (supports short and long IDs)
|
||
const video = await findVideoByAnyId(id);
|
||
if (!video) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
|
||
// Get ads from Bunny.net API
|
||
let ads: any[] = [];
|
||
try {
|
||
const { BunnyService } = await import("./bunny");
|
||
const bunnyService = new BunnyService();
|
||
ads = await bunnyService.getVideoAds(video.id); // Use full ID for API calls
|
||
console.log(`Retrieved ${ads.length} ad spots for video ${id}`);
|
||
} catch (error) {
|
||
console.error(`Failed to get ads from Bunny.net for video ${id}:`, error);
|
||
// Return empty array if Bunny service fails
|
||
ads = [];
|
||
}
|
||
|
||
res.json({
|
||
videoId: id,
|
||
ads: ads,
|
||
totalAds: ads.length
|
||
});
|
||
} catch (error) {
|
||
console.error(`Error fetching ads for video ${req.params.id}:`, error);
|
||
res.status(500).json({ message: "Failed to fetch video ads" });
|
||
}
|
||
});
|
||
|
||
// Update video views (supports short and long IDs)
|
||
app.post("/api/videos/:id/view", async (req, res) => {
|
||
try {
|
||
const video = await findVideoByAnyId(req.params.id);
|
||
if (!video) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
|
||
// Use the full video ID for storage operations
|
||
await storage.updateVideoViews(video.id);
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to update views" });
|
||
}
|
||
});
|
||
|
||
// Create new video
|
||
app.post("/api/videos", authenticate, async (req, res) => {
|
||
try {
|
||
const videoData = insertVideoSchema.parse(req.body);
|
||
const video = await storage.createVideo(videoData);
|
||
res.status(201).json(video);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({ message: "Invalid video data", errors: error.errors });
|
||
}
|
||
res.status(500).json({ message: "Failed to create video" });
|
||
}
|
||
});
|
||
|
||
// Update video metadata (title, description, etc.)
|
||
app.patch("/api/videos/:id", authenticate, async (req, res) => {
|
||
try {
|
||
const updates = updateVideoSchema.parse(req.body);
|
||
const video = await storage.updateVideo(req.params.id, updates);
|
||
|
||
if (!video) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
|
||
res.json(video);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({ message: "Invalid request data", errors: error.errors });
|
||
}
|
||
res.status(500).json({ message: "Failed to update video" });
|
||
}
|
||
});
|
||
|
||
// Delete video
|
||
app.delete("/api/videos/:id", authenticate, async (req, res) => {
|
||
try {
|
||
const success = await storage.deleteVideo(req.params.id);
|
||
if (!success) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
res.json({ success: true, message: "Video deleted successfully" });
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to delete video" });
|
||
}
|
||
});
|
||
|
||
// Video Upload Routes
|
||
app.post("/api/uploads/start", authenticate, async (req, res) => {
|
||
try {
|
||
const { originalFileName, fileSize, mimeType } = req.body;
|
||
|
||
if (!originalFileName || !fileSize || !mimeType) {
|
||
return res.status(400).json({
|
||
message: "originalFileName, fileSize, and mimeType are required"
|
||
});
|
||
}
|
||
|
||
const uploadData = {
|
||
userId: req.session.userId!,
|
||
originalFileName,
|
||
fileSize: parseInt(fileSize),
|
||
mimeType,
|
||
uploadStatus: "uploading" as const,
|
||
uploadProgress: 0
|
||
};
|
||
|
||
const upload = await storage.createVideoUpload(uploadData);
|
||
res.status(201).json(upload);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to initialize upload" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/uploads/:id/video", authenticate, upload.single('video'), async (req, res) => {
|
||
try {
|
||
const uploadId = req.params.id;
|
||
const file = req.file;
|
||
|
||
if (!file) {
|
||
return res.status(400).json({ message: "No video file provided" });
|
||
}
|
||
|
||
// Update upload with file information
|
||
await storage.updateVideoUpload(uploadId, {
|
||
uploadStatus: "processing",
|
||
uploadProgress: 1.0
|
||
});
|
||
|
||
// Create video record
|
||
const videoData = {
|
||
title: req.body.title || path.parse(file.originalname).name,
|
||
description: req.body.description || "",
|
||
thumbnailUrl: req.body.thumbnailUrl || "https://via.placeholder.com/800x450",
|
||
videoUrl: `/uploads/videos/${file.filename}`,
|
||
duration: parseInt(req.body.duration) || 0,
|
||
views: 0,
|
||
category: req.body.category || "",
|
||
tags: req.body.tags ? JSON.parse(req.body.tags) : [],
|
||
isPublic: req.body.isPublic !== "false",
|
||
uploadStatus: "completed",
|
||
originalFileName: file.originalname,
|
||
fileSize: file.size,
|
||
format: path.extname(file.originalname).slice(1)
|
||
};
|
||
|
||
const video = await storage.createVideo(videoData);
|
||
|
||
// Link video to upload
|
||
await storage.updateVideoUpload(uploadId, {
|
||
videoId: video.id,
|
||
uploadStatus: "completed"
|
||
});
|
||
|
||
res.json({ video, upload: { id: uploadId, status: "completed" } });
|
||
} catch (error) {
|
||
console.error("Upload error:", error);
|
||
|
||
// Update upload status to failed
|
||
if (req.params.id) {
|
||
await storage.updateVideoUpload(req.params.id, {
|
||
uploadStatus: "failed",
|
||
errorMessage: error instanceof Error ? error.message : "Upload failed"
|
||
});
|
||
}
|
||
|
||
res.status(500).json({ message: "Failed to upload video" });
|
||
}
|
||
});
|
||
|
||
app.get("/api/uploads/:id/status", authenticate, async (req, res) => {
|
||
try {
|
||
const upload = await storage.getVideoUpload(req.params.id);
|
||
if (!upload) {
|
||
return res.status(404).json({ message: "Upload not found" });
|
||
}
|
||
res.json(upload);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to get upload status" });
|
||
}
|
||
});
|
||
|
||
app.get("/api/uploads/user", authenticate, async (req, res) => {
|
||
try {
|
||
const uploads = await storage.getUserVideoUploads(req.session.userId!);
|
||
res.json(uploads);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to fetch user uploads" });
|
||
}
|
||
});
|
||
|
||
// Category Routes
|
||
app.get("/api/categories", async (req, res) => {
|
||
try {
|
||
const categories = await storage.getCategories();
|
||
res.json(categories);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to fetch categories" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/categories", authenticate, async (req, res) => {
|
||
try {
|
||
const categoryData = insertCategorySchema.parse(req.body);
|
||
const category = await storage.createCategory(categoryData);
|
||
res.status(201).json(category);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({ message: "Invalid category data", errors: error.errors });
|
||
}
|
||
res.status(500).json({ message: "Failed to create category" });
|
||
}
|
||
});
|
||
|
||
app.patch("/api/categories/:id", authenticate, async (req, res) => {
|
||
try {
|
||
const updates = insertCategorySchema.partial().parse(req.body);
|
||
const category = await storage.updateCategory(req.params.id, updates);
|
||
|
||
if (!category) {
|
||
return res.status(404).json({ message: "Category not found" });
|
||
}
|
||
|
||
res.json(category);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({ message: "Invalid category data", errors: error.errors });
|
||
}
|
||
res.status(500).json({ message: "Failed to update category" });
|
||
}
|
||
});
|
||
|
||
app.delete("/api/categories/:id", authenticate, async (req, res) => {
|
||
try {
|
||
const success = await storage.deleteCategory(req.params.id);
|
||
if (!success) {
|
||
return res.status(404).json({ message: "Category not found" });
|
||
}
|
||
res.json({ success: true, message: "Category deleted successfully" });
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to delete category" });
|
||
}
|
||
});
|
||
|
||
// Tag Routes
|
||
app.get("/api/tags", async (req, res) => {
|
||
try {
|
||
const limit = parseInt(req.query.limit as string) || 50;
|
||
const popular = req.query.popular === "true";
|
||
|
||
const tags = popular
|
||
? await storage.getPopularTags(limit)
|
||
: await storage.getTags();
|
||
|
||
res.json(tags);
|
||
} catch (error) {
|
||
res.status(500).json({ message: "Failed to fetch tags" });
|
||
}
|
||
});
|
||
|
||
app.post("/api/tags", authenticate, async (req, res) => {
|
||
try {
|
||
const tagData = insertTagSchema.parse(req.body);
|
||
const tag = await storage.createTag(tagData);
|
||
res.status(201).json(tag);
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
return res.status(400).json({ message: "Invalid tag data", errors: error.errors });
|
||
}
|
||
res.status(500).json({ message: "Failed to create tag" });
|
||
}
|
||
});
|
||
|
||
// Serve uploaded videos
|
||
app.use('/uploads', express.static('uploads'));
|
||
|
||
|
||
|
||
|
||
|
||
// Serve ads.txt file specifically
|
||
app.get("/ads.txt", (req, res) => {
|
||
res.type("text/plain");
|
||
res.sendFile(path.join(__dirname, "../client/public/ads.txt"));
|
||
});
|
||
|
||
// Open Graph image generation endpoint
|
||
app.get('/api/og-image', (req, res) => {
|
||
try {
|
||
// Generate SVG-based image for Open Graph
|
||
const svg = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:hsl(250, 50%, 15%);stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:hsl(240, 30%, 25%);stop-opacity:1" />
|
||
</linearGradient>
|
||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#6366f1;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<rect width="1200" height="630" fill="url(#bgGradient)"/>
|
||
|
||
<polygon points="100,100 180,180 70,180" fill="#6366f1" opacity="0.1"/>
|
||
<polygon points="1000,200 1120,280 880,280" fill="#8b5cf6" opacity="0.08"/>
|
||
<polygon points="200,450 260,510 140,510" fill="#6366f1" opacity="0.12"/>
|
||
<polygon points="950,500 1040,590 860,590" fill="#8b5cf6" opacity="0.06"/>
|
||
|
||
<rect x="300" y="250" width="60" height="60" rx="15" fill="url(#logoGradient)"/>
|
||
|
||
<polygon points="321,265 321,295 342,280" fill="white"/>
|
||
|
||
<text x="400" y="330" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="white">FOLX.TV</text>
|
||
|
||
<text x="400" y="380" font-family="Arial, sans-serif" font-size="32" fill="rgba(255,255,255,0.8)">Professional Video Streaming Platform</text>
|
||
|
||
<text x="400" y="430" font-family="Arial, sans-serif" font-size="24" fill="rgba(255,255,255,0.6)">Geschichte des Liedes • FOLX STADL • Premium Content</text>
|
||
</svg>`;
|
||
|
||
res.setHeader('Content-Type', 'image/svg+xml');
|
||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||
res.send(svg);
|
||
} catch (error) {
|
||
console.error('Error generating OG image:', error);
|
||
res.status(500).send('Error generating image');
|
||
}
|
||
});
|
||
|
||
// Custom social image endpoint - directly serve the beautiful triangular image
|
||
app.get('/api/social-image', async (req, res) => {
|
||
try {
|
||
console.log('📸 Generating beautiful social image...');
|
||
|
||
// Create the beautiful triangular design directly
|
||
const width = 1200;
|
||
const height = 630;
|
||
|
||
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#da234d;stop-opacity:1" />
|
||
<stop offset="25%" style="stop-color:#c51d47;stop-opacity:1" />
|
||
<stop offset="50%" style="stop-color:#b01640;stop-opacity:1" />
|
||
<stop offset="75%" style="stop-color:#9b0f39;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#860832;stop-opacity:1" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<!-- Beautiful gradient background -->
|
||
<rect width="${width}" height="${height}" fill="url(#bgGradient)"/>
|
||
|
||
<!-- Large triangular geometric patterns -->
|
||
<polygon points="0,0 400,0 200,200" fill="#ffffff" opacity="0.08"/>
|
||
<polygon points="800,0 1200,0 1000,300" fill="#ffffff" opacity="0.06"/>
|
||
<polygon points="0,400 300,630 0,630" fill="#ffffff" opacity="0.1"/>
|
||
<polygon points="900,330 1200,630 900,630" fill="#ffffff" opacity="0.07"/>
|
||
<polygon points="400,200 600,400 200,400" fill="#ffffff" opacity="0.05"/>
|
||
<polygon points="600,0 1000,0 800,200" fill="#ffffff" opacity="0.04"/>
|
||
|
||
<!-- Central sparkle element -->
|
||
<polygon points="1150,480 1170,500 1150,520 1130,500" fill="#ffffff" opacity="0.9"/>
|
||
|
||
<!-- Main title centered -->
|
||
<text x="600" y="300" font-family="Arial, sans-serif" font-size="88" font-weight="bold" fill="white" text-anchor="middle">FOLX.TV</text>
|
||
<text x="600" y="380" font-family="Arial, sans-serif" font-size="36" fill="rgba(255,255,255,0.9)" text-anchor="middle">video.folx.tv</text>
|
||
<text x="600" y="430" font-family="Arial, sans-serif" font-size="24" fill="rgba(255,255,255,0.7)" text-anchor="middle">Amazing Content • Premium Streaming Platform</text>
|
||
</svg>`;
|
||
|
||
const buffer = await sharp(Buffer.from(svg))
|
||
.png({ quality: 95, compressionLevel: 6, progressive: true })
|
||
.toBuffer();
|
||
|
||
console.log(`📸 Beautiful social image generated: ${buffer.length} bytes`);
|
||
|
||
// Set aggressive cache-busting headers for social media platforms
|
||
res.set({
|
||
'Content-Type': 'image/png',
|
||
'Content-Length': buffer.length.toString(),
|
||
'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
|
||
'Pragma': 'no-cache',
|
||
'Expires': '0',
|
||
'Last-Modified': new Date().toUTCString(),
|
||
'ETag': `W/"${buffer.length}-${Date.now()}"`,
|
||
'Access-Control-Allow-Origin': '*',
|
||
'Vary': 'User-Agent'
|
||
});
|
||
res.send(buffer);
|
||
|
||
} catch (error) {
|
||
console.error('❌ Error generating social image:', error);
|
||
res.status(500).send('Error generating social image');
|
||
}
|
||
});
|
||
|
||
// Favicon generation endpoint
|
||
app.get('/api/favicon', async (req, res) => {
|
||
try {
|
||
const size = req.query.size ? parseInt(req.query.size as string) : 32;
|
||
const format = req.query.format as string || 'svg';
|
||
const padding = Math.max(2, size * 0.1);
|
||
const logoSize = size - (padding * 2);
|
||
const cornerRadius = Math.max(2, size * 0.15);
|
||
|
||
if (format === 'png') {
|
||
// Ustvarimo PNG ikono za iOS PWA
|
||
// Ustvarimo SVG za pretvorbo v PNG
|
||
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#da234d;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#da234d;stop-opacity:1" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<!-- iOS stil ozadje z rounded corners -->
|
||
<rect x="0" y="0" width="${size}" height="${size}" rx="${cornerRadius * 1.5}" fill="url(#logoGradient)"/>
|
||
|
||
<!-- Večji bel play triangle v središču ikone -->
|
||
<polygon points="${size * 0.38},${size * 0.32} ${size * 0.38},${size * 0.68} ${size * 0.68},${size * 0.5}" fill="white" opacity="1.0"/>
|
||
</svg>`;
|
||
|
||
// Pretvorimo SVG v PNG z Sharp
|
||
const pngBuffer = await sharp(Buffer.from(svg))
|
||
.png({ quality: 100, compressionLevel: 0 })
|
||
.toBuffer();
|
||
|
||
res.setHeader('Content-Type', 'image/png');
|
||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||
res.send(pngBuffer);
|
||
} else {
|
||
// Originalni SVG favicon
|
||
const svg = `<svg width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#da234d;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#da234d;stop-opacity:1" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<!-- Logo background -->
|
||
<rect x="${padding}" y="${padding}" width="${logoSize}" height="${logoSize}" rx="${cornerRadius}" fill="url(#logoGradient)"/>
|
||
|
||
<!-- Večji play triangle v središču -->
|
||
<polygon points="${size * 0.38},${size * 0.32} ${size * 0.38},${size * 0.68} ${size * 0.68},${size * 0.5}" fill="white"/>
|
||
</svg>`;
|
||
|
||
res.setHeader('Content-Type', 'image/svg+xml');
|
||
res.setHeader('Cache-Control', 'public, max-age=86400'); // Cache for 24 hours
|
||
res.send(svg);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error generating favicon:', error);
|
||
res.status(500).send('Error generating favicon');
|
||
}
|
||
});
|
||
|
||
// Social media thumbnail generation endpoint
|
||
app.get('/api/video-thumbnail/:videoId', async (req, res) => {
|
||
try {
|
||
const { videoId } = req.params;
|
||
const video = await storage.getVideo(videoId);
|
||
|
||
if (!video) {
|
||
console.log(`❌ Video not found: ${videoId}`);
|
||
return res.status(404).send('Video not found');
|
||
}
|
||
|
||
console.log(`🖼️ Generating thumbnail for: ${video.title}`);
|
||
|
||
// Uporabimo čisti thumbnail iz Bunny.net - prioriteta
|
||
if (video.thumbnailUrl) {
|
||
try {
|
||
console.log(`📥 Fetching thumbnail from Bunny.net: ${video.thumbnailUrl}`);
|
||
const response = await fetch(video.thumbnailUrl);
|
||
if (response.ok) {
|
||
const thumbnailBuffer = await response.arrayBuffer();
|
||
|
||
// Optimiziramo thumbnail za social media brez overlay-a
|
||
const resizedBuffer = await sharp(Buffer.from(thumbnailBuffer))
|
||
.resize(1200, 630, { fit: 'cover', position: 'center' })
|
||
.jpeg({ quality: 85, progressive: true }) // JPEG je bolje za photo thumbnails
|
||
.toBuffer();
|
||
|
||
console.log(`✅ Real thumbnail processed: ${resizedBuffer.length} bytes`);
|
||
|
||
res.setHeader('Content-Type', 'image/jpeg');
|
||
res.setHeader('Cache-Control', 'public, max-age=7200'); // Cache za 2 uri
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
return res.send(resizedBuffer);
|
||
}
|
||
} catch (fetchError) {
|
||
console.log('⚠️ Failed to fetch real thumbnail, falling back to generated:', fetchError);
|
||
}
|
||
}
|
||
|
||
// Fallback: ustvarimo generirani thumbnail z video informacijami
|
||
const width = 1200;
|
||
const height = 630; // Facebook/Twitter optimalne dimenzije
|
||
|
||
const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||
<defs>
|
||
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#2D1B69;stop-opacity:1" />
|
||
<stop offset="50%" style="stop-color:#6366f1;stop-opacity:1" />
|
||
<stop offset="100%" style="stop-color:#8b5cf6;stop-opacity:1" />
|
||
</linearGradient>
|
||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||
<stop offset="0%" style="stop-color:#ffffff;stop-opacity:0.9" />
|
||
<stop offset="100%" style="stop-color:#ffffff;stop-opacity:0.7" />
|
||
</linearGradient>
|
||
</defs>
|
||
|
||
<!-- Ozadje -->
|
||
<rect width="${width}" height="${height}" fill="url(#bgGradient)"/>
|
||
|
||
<!-- Dekorativni elementi -->
|
||
<polygon points="100,100 180,180 70,180" fill="#ffffff" opacity="0.1"/>
|
||
<polygon points="1000,200 1120,280 880,280" fill="#ffffff" opacity="0.08"/>
|
||
<polygon points="200,450 260,510 140,510" fill="#ffffff" opacity="0.12"/>
|
||
<polygon points="950,500 1040,590 860,590" fill="#ffffff" opacity="0.06"/>
|
||
|
||
<!-- Glavni play button v levi strani -->
|
||
<circle cx="300" cy="315" r="80" fill="url(#logoGradient)" opacity="0.9"/>
|
||
<polygon points="270,285 270,345 330,315" fill="#6366f1"/>
|
||
|
||
<!-- Video naslov -->
|
||
<text x="450" y="250" font-family="Arial, sans-serif" font-size="42" font-weight="bold" fill="white" text-anchor="start">${video.title.length > 35 ? video.title.substring(0, 32) + '...' : video.title}</text>
|
||
|
||
<!-- Video opis -->
|
||
<text x="450" y="300" font-family="Arial, sans-serif" font-size="24" fill="rgba(255,255,255,0.8)" text-anchor="start">${video.description && video.description.length > 60 ? video.description.substring(0, 57) + '...' : video.description || 'Watch this video on video.folx.tv'}</text>
|
||
|
||
<!-- go4.video logo -->
|
||
<text x="450" y="400" font-family="Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="start">FOLX.TV</text>
|
||
<text x="450" y="430" font-family="Arial, sans-serif" font-size="18" fill="rgba(255,255,255,0.7)" text-anchor="start">Professional Video Streaming Platform</text>
|
||
|
||
<!-- Video dolžina -->
|
||
<rect x="950" y="500" width="200" height="40" rx="20" fill="rgba(0,0,0,0.7)"/>
|
||
<text x="1050" y="525" font-family="Arial, sans-serif" font-size="18" font-weight="bold" fill="white" text-anchor="middle">${Math.floor(video.duration / 60)}:${(video.duration % 60).toString().padStart(2, '0')}</text>
|
||
</svg>`;
|
||
|
||
// Pretvorimo SVG v PNG
|
||
const pngBuffer = await sharp(Buffer.from(svg))
|
||
.png({ quality: 90, compressionLevel: 6 })
|
||
.toBuffer();
|
||
|
||
res.setHeader('Content-Type', 'image/png');
|
||
res.setHeader('Cache-Control', 'public, max-age=3600'); // Cache za 1 uro
|
||
res.send(pngBuffer);
|
||
|
||
} catch (error) {
|
||
console.error('Error generating video thumbnail:', error);
|
||
res.status(500).send('Error generating thumbnail');
|
||
}
|
||
});
|
||
|
||
// ===== ADMIN ROUTES =====
|
||
|
||
// Auth route to get current user
|
||
app.get('/api/auth/user', isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
// Import the generateDeterministicUUID function from replitAuth
|
||
const { createHash } = await import('crypto');
|
||
const generateDeterministicUUID = (replitId: string): string => {
|
||
const hash = createHash('sha256').update(`replit_${replitId}`).digest('hex');
|
||
return [
|
||
hash.substring(0, 8),
|
||
hash.substring(8, 12),
|
||
'4' + hash.substring(13, 16),
|
||
(parseInt(hash.substring(16, 17), 16) & 0x3 | 0x8).toString(16) + hash.substring(17, 20),
|
||
hash.substring(20, 32)
|
||
].join('-');
|
||
};
|
||
|
||
const userId = generateDeterministicUUID(req.user.claims.sub);
|
||
const user = await storage.getUser(userId);
|
||
if (!user) {
|
||
return res.status(404).json({ message: "User not found" });
|
||
}
|
||
|
||
// Remove sensitive data
|
||
const { passwordHash, ...userResponse } = user;
|
||
res.json(userResponse);
|
||
} catch (error) {
|
||
console.error("Error fetching user:", error);
|
||
res.status(500).json({ message: "Failed to fetch user" });
|
||
}
|
||
});
|
||
|
||
// Admin video management
|
||
app.get('/api/admin/videos', isAdmin, async (req, res) => {
|
||
try {
|
||
const limit = parseInt(req.query.limit as string) || 500; // Increased default limit for admin
|
||
const offset = parseInt(req.query.offset as string) || 0;
|
||
const search = req.query.search as string;
|
||
|
||
const videos = await storage.getVideos(limit, offset, search);
|
||
const total = await storage.getVideoCount(search);
|
||
|
||
res.json({ videos, total, limit, offset });
|
||
} catch (error) {
|
||
console.error("Error fetching admin videos:", error);
|
||
res.status(500).json({ message: "Failed to fetch videos" });
|
||
}
|
||
});
|
||
|
||
app.patch('/api/admin/videos/:id', isAdmin, async (req, res) => {
|
||
try {
|
||
const videoId = req.params.id;
|
||
console.log(`PATCH request for video ${videoId} with body:`, JSON.stringify(req.body, null, 2));
|
||
const updateData = updateVideoSchema.parse(req.body);
|
||
console.log(`Parsed update data:`, JSON.stringify(updateData, null, 2));
|
||
|
||
const updatedVideo = await storage.updateVideo(videoId, updateData);
|
||
if (!updatedVideo) {
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
|
||
res.json(updatedVideo);
|
||
} catch (error) {
|
||
console.error("Error updating video:", error);
|
||
if (error instanceof z.ZodError) {
|
||
res.status(400).json({ message: "Invalid video data", errors: error.errors });
|
||
} else {
|
||
res.status(500).json({ message: "Failed to update video" });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Thumbnail upload
|
||
app.post('/api/admin/thumbnails/upload', isAdmin, async (req, res) => {
|
||
try {
|
||
const objectStorageService = new ObjectStorageService();
|
||
const uploadURL = await objectStorageService.getThumbnailUploadURL();
|
||
res.json({ uploadURL });
|
||
} catch (error) {
|
||
console.error("Error getting thumbnail upload URL:", error);
|
||
res.status(500).json({ message: "Failed to get upload URL" });
|
||
}
|
||
});
|
||
|
||
// Serve uploaded objects
|
||
app.get("/objects/:objectPath(*)", async (req, res) => {
|
||
const objectStorageService = new ObjectStorageService();
|
||
try {
|
||
const objectFile = await objectStorageService.getObjectFile(req.path);
|
||
objectStorageService.downloadObject(objectFile, res);
|
||
} catch (error) {
|
||
console.error("Error serving object:", error);
|
||
if (error instanceof ObjectNotFoundError) {
|
||
return res.sendStatus(404);
|
||
}
|
||
return res.sendStatus(500);
|
||
}
|
||
});
|
||
|
||
// ===== USER MANAGEMENT ROUTES (ADMIN) =====
|
||
|
||
// Get all users (admin only)
|
||
app.get('/api/admin/users', isAdmin, async (req, res) => {
|
||
try {
|
||
const limit = parseInt(req.query.limit as string) || 50;
|
||
const offset = parseInt(req.query.offset as string) || 0;
|
||
const search = req.query.search as string;
|
||
|
||
// For now, get all users - in production you'd want pagination
|
||
const users = await storage.getAllUsers ? await storage.getAllUsers(limit, offset, search) : [];
|
||
|
||
// Remove password hashes from response
|
||
const safeUsers = users.map(({ passwordHash, ...user }) => user);
|
||
|
||
res.json({
|
||
users: safeUsers,
|
||
total: users.length,
|
||
limit,
|
||
offset
|
||
});
|
||
} catch (error) {
|
||
console.error("Error fetching users:", error);
|
||
res.status(500).json({ message: "Failed to fetch users" });
|
||
}
|
||
});
|
||
|
||
// Get user by ID (admin only)
|
||
app.get('/api/admin/users/:id', isAdmin, async (req, res) => {
|
||
try {
|
||
const user = await storage.getUser(req.params.id);
|
||
if (!user) {
|
||
return res.status(404).json({ message: "User not found" });
|
||
}
|
||
|
||
// Remove password hash from response
|
||
const { passwordHash, ...safeUser } = user;
|
||
res.json(safeUser);
|
||
} catch (error) {
|
||
console.error("Error fetching user:", error);
|
||
res.status(500).json({ message: "Failed to fetch user" });
|
||
}
|
||
});
|
||
|
||
// Update user admin status (super admin only)
|
||
app.patch('/api/admin/users/:id/admin', isAuthenticated, async (req: any, res) => {
|
||
try {
|
||
const currentUserId = `replit_${req.user.claims.sub}`;
|
||
const currentUser = await storage.getUser(currentUserId);
|
||
|
||
if (!currentUser?.isSuperAdmin) {
|
||
return res.status(403).json({ message: "Super admin access required" });
|
||
}
|
||
|
||
const userId = req.params.id;
|
||
const { isAdmin } = req.body;
|
||
|
||
const updatedUser = await storage.updateUser(userId, { isAdmin });
|
||
if (!updatedUser) {
|
||
return res.status(404).json({ message: "User not found" });
|
||
}
|
||
|
||
// Remove password hash from response
|
||
const { passwordHash, ...safeUser } = updatedUser;
|
||
res.json(safeUser);
|
||
} catch (error) {
|
||
console.error("Error updating user admin status:", error);
|
||
res.status(500).json({ message: "Failed to update user" });
|
||
}
|
||
});
|
||
|
||
// Update user active status (admin only)
|
||
app.patch('/api/admin/users/:id/status', isAdmin, async (req, res) => {
|
||
try {
|
||
const userId = req.params.id;
|
||
const { isActive } = req.body;
|
||
|
||
if (typeof isActive !== 'boolean') {
|
||
return res.status(400).json({ message: "isActive must be a boolean" });
|
||
}
|
||
|
||
const updatedUser = await storage.updateUser(userId, { isActive });
|
||
if (!updatedUser) {
|
||
return res.status(404).json({ message: "User not found" });
|
||
}
|
||
|
||
// Remove password hash from response
|
||
const { passwordHash, ...safeUser } = updatedUser;
|
||
res.json(safeUser);
|
||
} catch (error) {
|
||
console.error("Error updating user status:", error);
|
||
res.status(500).json({ message: "Failed to update user status" });
|
||
}
|
||
});
|
||
|
||
// Get admin statistics (admin only)
|
||
app.get('/api/admin/stats', isAdmin, async (req, res) => {
|
||
try {
|
||
const videoCount = await storage.getVideoCount();
|
||
const userCount = await storage.getUserCount ? await storage.getUserCount() : 0;
|
||
|
||
// Get recent videos
|
||
const recentVideos = await storage.getVideos(5, 0);
|
||
|
||
// Basic stats - in production you might want more sophisticated analytics
|
||
const stats = {
|
||
videos: {
|
||
total: videoCount,
|
||
recent: recentVideos.length
|
||
},
|
||
users: {
|
||
total: userCount,
|
||
// Could add more user analytics here
|
||
},
|
||
platform: {
|
||
uptime: process.uptime(),
|
||
nodeVersion: process.version
|
||
}
|
||
};
|
||
|
||
res.json(stats);
|
||
} catch (error) {
|
||
console.error("Error fetching admin stats:", error);
|
||
res.status(500).json({ message: "Failed to fetch statistics" });
|
||
}
|
||
});
|
||
|
||
// ===== AI DESCRIPTION ROUTES =====
|
||
|
||
// Generate AI description for single video (admin only)
|
||
app.post('/api/admin/videos/:id/generate-description', isAdmin, async (req, res) => {
|
||
try {
|
||
const videoId = req.params.id;
|
||
const { maxCharacters = 500, includeArtistInfo = true, includeLabelInfo = true, customInstructions } = req.body;
|
||
|
||
console.log("AI description request for video:", videoId); // Debug log
|
||
|
||
// Get video details
|
||
const video = await storage.getVideo(videoId);
|
||
if (!video) {
|
||
console.log("Video not found:", videoId); // Debug log
|
||
return res.status(404).json({ message: "Video not found" });
|
||
}
|
||
|
||
console.log("Generating AI description for title:", video.title); // Debug log
|
||
|
||
// Generate description using AI
|
||
const description = await generateVideoDescription(video.title, {
|
||
maxCharacters,
|
||
language: "german",
|
||
includeArtistInfo,
|
||
includeLabelInfo,
|
||
customInstructions,
|
||
contentType: video.contentType || 'music_video'
|
||
});
|
||
|
||
console.log("Generated description:", description); // Debug log
|
||
|
||
const result = {
|
||
description,
|
||
title: video.title,
|
||
characterCount: description.length,
|
||
maxCharacters
|
||
};
|
||
|
||
console.log("Sending AI response:", result); // Debug log
|
||
res.json(result);
|
||
|
||
} catch (error) {
|
||
console.error("Error generating AI description:", error);
|
||
res.status(500).json({
|
||
message: error instanceof Error ? error.message : "Failed to generate description"
|
||
});
|
||
}
|
||
});
|
||
|
||
// Generate AI descriptions for multiple videos (admin only)
|
||
app.post('/api/admin/videos/generate-descriptions-bulk', isAdmin, async (req, res) => {
|
||
try {
|
||
const { videoIds, maxCharacters = 500, includeArtistInfo = true, includeLabelInfo = true } = req.body;
|
||
|
||
if (!Array.isArray(videoIds) || videoIds.length === 0) {
|
||
return res.status(400).json({ message: "Video IDs array is required" });
|
||
}
|
||
|
||
if (videoIds.length > 50) {
|
||
return res.status(400).json({ message: "Maximum 50 videos can be processed at once" });
|
||
}
|
||
|
||
// Get video details for all requested videos
|
||
const videos = [];
|
||
for (const id of videoIds) {
|
||
const video = await storage.getVideo(id);
|
||
if (video) {
|
||
videos.push({ id: video.id, title: video.title });
|
||
}
|
||
}
|
||
|
||
// Generate descriptions using AI
|
||
const results = await generateBulkDescriptions(videos, {
|
||
maxCharacters,
|
||
language: "slovenian",
|
||
includeArtistInfo,
|
||
includeLabelInfo
|
||
});
|
||
|
||
res.json({
|
||
results,
|
||
processed: results.length,
|
||
successful: results.filter(r => !r.error).length,
|
||
failed: results.filter(r => r.error).length
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error("Error generating bulk AI descriptions:", error);
|
||
res.status(500).json({
|
||
message: error instanceof Error ? error.message : "Failed to generate descriptions"
|
||
});
|
||
}
|
||
});
|
||
|
||
// Facebook/Social Media Share Page - Returns HTML with proper OG tags for video sharing
|
||
app.get('/share/video/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
|
||
// Find video from cache (not direct Bunny API to avoid 404 errors)
|
||
const allVideos = await storage.getVideos(600, 0);
|
||
let video;
|
||
|
||
// Check if it's a full UUID or short ID
|
||
if (id.length === 36 && id.includes('-')) {
|
||
video = allVideos.find(v => v.id === id);
|
||
} else if (id.length === 8) {
|
||
video = allVideos.find(v => v.id.replace(/-/g, '').substring(0, 8) === id);
|
||
} else {
|
||
video = allVideos.find(v => v.id.includes(id));
|
||
}
|
||
|
||
if (!video) {
|
||
return res.status(404).send('Video not found');
|
||
}
|
||
|
||
const baseUrl = 'https://video.folx.tv';
|
||
const videoUrl = `${baseUrl}/video/${video.id}`;
|
||
|
||
// Get high-quality thumbnail for sharing (1200x630 is ideal for Facebook)
|
||
let thumbnailUrl = `${baseUrl}/api/social-image`;
|
||
if (video.thumbnailUrl) {
|
||
thumbnailUrl = video.thumbnailUrl
|
||
.replace(/width=\d+/gi, 'width=1200')
|
||
.replace(/height=\d+/gi, 'height=630')
|
||
.replace(/format=webp/gi, 'format=jpg');
|
||
}
|
||
|
||
// Clean description for meta tags
|
||
const description = video.description
|
||
? video.description.substring(0, 200).replace(/[<>"']/g, '')
|
||
: `Jetzt ${video.title} auf video.folx.tv ansehen - Die beste Volksmusik`;
|
||
|
||
const title = video.title || 'video.folx.tv';
|
||
|
||
// Return HTML page with OG tags that redirects to actual video
|
||
const html = `<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>${title} - video.folx.tv</title>
|
||
|
||
<!-- Open Graph meta tags for Facebook -->
|
||
<meta property="og:type" content="video.other">
|
||
<meta property="og:title" content="${title}">
|
||
<meta property="og:description" content="${description}">
|
||
<meta property="og:image" content="${thumbnailUrl}">
|
||
<meta property="og:image:secure_url" content="${thumbnailUrl}">
|
||
<meta property="og:image:width" content="1200">
|
||
<meta property="og:image:height" content="630">
|
||
<meta property="og:image:type" content="image/jpeg">
|
||
<meta property="og:url" content="${videoUrl}">
|
||
<meta property="og:site_name" content="video.folx.tv">
|
||
<meta property="og:locale" content="de_DE">
|
||
|
||
<!-- Twitter Card meta tags -->
|
||
<meta name="twitter:card" content="summary_large_image">
|
||
<meta name="twitter:title" content="${title}">
|
||
<meta name="twitter:description" content="${description}">
|
||
<meta name="twitter:image" content="${thumbnailUrl}">
|
||
|
||
<!-- Redirect to actual video page for humans -->
|
||
<meta http-equiv="refresh" content="0;url=${videoUrl}">
|
||
<link rel="canonical" href="${videoUrl}">
|
||
|
||
<style>
|
||
body {
|
||
font-family: Arial, sans-serif;
|
||
background: #1a1a2e;
|
||
color: white;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100vh;
|
||
margin: 0;
|
||
}
|
||
.container { text-align: center; }
|
||
a { color: #da234d; text-decoration: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
<h1>Weiterleitung...</h1>
|
||
<p>Falls keine automatische Weiterleitung erfolgt, <a href="${videoUrl}">hier klicken</a>.</p>
|
||
</div>
|
||
<script>window.location.href = "${videoUrl}";</script>
|
||
</body>
|
||
</html>`;
|
||
|
||
res.set('Content-Type', 'text/html');
|
||
res.send(html);
|
||
} catch (error) {
|
||
console.error('Error generating share page:', error);
|
||
res.status(500).send('Error generating share page');
|
||
}
|
||
});
|
||
|
||
// SEO - Sitemap XML with all video pages
|
||
app.get('/sitemap.xml', async (req, res) => {
|
||
try {
|
||
const videos = await storage.getVideos(1, 1000); // Get all videos
|
||
const baseUrl = 'https://video.folx.tv';
|
||
|
||
let sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||
<url>
|
||
<loc>${baseUrl}/</loc>
|
||
<changefreq>daily</changefreq>
|
||
<priority>1.0</priority>
|
||
<lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
|
||
</url>`;
|
||
|
||
// Add all video pages
|
||
videos.forEach(video => {
|
||
sitemap += `
|
||
<url>
|
||
<loc>${baseUrl}/video/${video.id}</loc>
|
||
<changefreq>weekly</changefreq>
|
||
<priority>0.8</priority>
|
||
<lastmod>${video.updatedAt ? video.updatedAt.toISOString().split('T')[0] : new Date().toISOString().split('T')[0]}</lastmod>
|
||
</url>`;
|
||
});
|
||
|
||
sitemap += '\n</urlset>';
|
||
|
||
res.set('Content-Type', 'application/xml');
|
||
res.send(sitemap);
|
||
} catch (error) {
|
||
console.error('Error generating sitemap:', error);
|
||
res.status(500).send('Error generating sitemap');
|
||
}
|
||
});
|
||
|
||
const httpServer = createServer(app);
|
||
return httpServer;
|
||
}
|