videofolxtv/server/routes.ts
sebastjanartic 590584b893 Add an artist listing page and improve SEO for various pages
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
2026-02-13 17:53:37 +00:00

2140 lines
78 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
};
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;
}