folx-tv/server/routes.ts
sebastjanartic 3d672766b8 Add pagination to article categories and video pages, and enhance home page with more content and ads
Implement paginated API endpoints for articles and videos, add pagination UI to category and video pages, and inject additional ads and content widgets into the home page.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: c201f10b-bbd3-4402-9212-e0b79bfa670f
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/EtK2Sno
Replit-Helium-Checkpoint-Created: true
2026-03-05 09:48:56 +00:00

552 lines
20 KiB
TypeScript

import type { Express } from "express";
import { createServer, type Server } from "http";
import { storage } from "./storage";
import { insertArticleSchema } from "@shared/schema";
import { seedDatabase } from "./seed";
import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator";
import { analyzeAllArticleImages, getCachedFocalPoints } from "./focal-point";
import { optimizeImage } from "./image-optimizer";
import { getAuthUrl, exchangeCodeForTokens, isConnected, fetchGalleryFromDropbox, getValidAccessToken } from "./dropbox";
import multer from "multer";
import path from "path";
import fs from "fs";
import https from "https";
const apiCache = new Map<string, { data: any; timestamp: number }>();
function getCached<T>(key: string, ttlMs: number): T | null {
const entry = apiCache.get(key);
if (entry && Date.now() - entry.timestamp < ttlMs) return entry.data as T;
return null;
}
function setCache(key: string, data: any) {
apiCache.set(key, { data, timestamp: Date.now() });
}
function getStale<T>(key: string): T | null {
const entry = apiCache.get(key);
return entry ? (entry.data as T) : null;
}
const uploadDir = path.join(process.cwd(), "client/public/uploads");
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
const upload = multer({
storage: multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadDir),
filename: (_req, file, cb) => {
const uniqueSuffix = Date.now() + "-" + Math.round(Math.random() * 1e9);
const ext = path.extname(file.originalname);
cb(null, uniqueSuffix + ext);
},
}),
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = /jpeg|jpg|png|gif|webp/;
const ext = allowed.test(path.extname(file.originalname).toLowerCase());
const mime = allowed.test(file.mimetype);
cb(null, ext && mime);
},
});
export async function registerRoutes(
httpServer: Server,
app: Express
): Promise<Server> {
await seedDatabase();
generateDailyHoroscopes().catch((err) =>
console.error("Background horoscope generation failed:", err.message)
);
storage.getArticles().then((articles) => {
analyzeAllArticleImages(articles).catch(err =>
console.log("[focal-point] Analysis skipped:", err.message)
);
});
app.get("/api/focal-points", (_req, res) => {
res.json(getCachedFocalPoints());
});
app.get("/api/articles", async (_req, res) => {
const articles = await storage.getArticles();
res.json(articles);
});
app.get("/api/articles/featured", async (_req, res) => {
const articles = await storage.getFeaturedArticles();
res.json(articles);
});
app.get("/api/articles/popular", async (req, res) => {
const limit = parseInt(req.query.limit as string) || 6;
const articles = await storage.getPopularArticles(limit);
res.json(articles);
});
app.get("/api/articles/category/:category", async (req, res) => {
const page = parseInt(req.query.page as string);
const limit = parseInt(req.query.limit as string) || 12;
if (page && page > 0) {
const result = await storage.getArticlesByCategoryPaginated(req.params.category, page, limit);
const totalPages = Math.ceil(result.total / limit);
return res.json({
articles: result.articles,
total: result.total,
page,
totalPages,
hasMore: page < totalPages,
});
}
const articles = await storage.getArticlesByCategory(req.params.category);
res.json(articles);
});
app.get("/api/search", async (req, res) => {
const q = (req.query.q as string || "").trim().toLowerCase();
if (!q || q.length < 2) return res.json({ articles: [], videos: [] });
try {
const articles = await storage.getArticles();
const matchedArticles = articles.filter(a =>
a.title.toLowerCase().includes(q) ||
a.excerpt.toLowerCase().includes(q) ||
a.content.replace(/<[^>]+>/g, "").toLowerCase().includes(q) ||
a.category.toLowerCase().includes(q)
).slice(0, 10);
let matchedVideos: any[] = [];
try {
const data = await bunnyFetch(`/library/${LIBRARY_ID}/videos?page=1&itemsPerPage=20&search=${encodeURIComponent(q)}`);
matchedVideos = (data.items || []).map((v: any) => {
const descTag = (v.metaTags || []).find((t: any) => t.property === "description");
return {
guid: v.guid,
title: (v.title || "").replace(/\.mp4$/i, ""),
description: descTag?.value || v.description || "",
thumbnail: `https://${CDN_HOST}/${v.guid}/${v.thumbnailFileName || "thumbnail.jpg"}`,
embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${v.guid}`,
};
}).slice(0, 10);
} catch {}
res.json({ articles: matchedArticles, videos: matchedVideos });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.get("/api/articles/:slug", async (req, res) => {
const article = await storage.getArticleBySlug(req.params.slug);
if (!article) {
return res.status(404).json({ message: "Article not found" });
}
await storage.incrementViews(article.id);
res.json({ ...article, views: article.views + 1 });
});
app.post("/api/articles", async (req, res) => {
const parsed = insertArticleSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ message: "Invalid article data", errors: parsed.error.flatten() });
}
const existing = await storage.getArticleBySlug(parsed.data.slug);
if (existing) {
return res.status(409).json({ message: `Artikel "${existing.title}" existiert bereits (ID: ${existing.id})` });
}
const article = await storage.createArticle(parsed.data);
res.status(201).json(article);
});
app.patch("/api/articles/:id", async (req, res) => {
const id = parseInt(req.params.id);
const partial = insertArticleSchema.partial().safeParse(req.body);
if (!partial.success) {
return res.status(400).json({ message: "Invalid article data", errors: partial.error.flatten() });
}
const article = await storage.updateArticle(id, partial.data);
if (!article) {
return res.status(404).json({ message: "Article not found" });
}
res.json(article);
});
app.delete("/api/articles/:id", async (req, res) => {
const id = parseInt(req.params.id);
await storage.deleteArticle(id);
res.status(204).send();
});
app.post("/api/upload", upload.single("image"), async (req, res) => {
if (!req.file) {
return res.status(400).json({ message: "No file uploaded" });
}
try {
const result = await optimizeImage(req.file.path);
res.json({ url: result.webp, thumb: result.thumb });
} catch (err) {
const url = `/uploads/${req.file.filename}`;
console.log("[image-optimizer] Optimization failed, using original:", (err as Error).message);
res.json({ url });
}
});
function bunnyFetch(path: string): Promise<any> {
return new Promise((resolve, reject) => {
const options = {
hostname: "video.bunnycdn.com",
path,
method: "GET",
headers: { "AccessKey": process.env.BUNNY_API_KEY || "", "Accept": "application/json" },
};
const req = https.request(options, (res) => {
let body = "";
res.on("data", (c: string) => (body += c));
res.on("end", () => {
try { resolve(JSON.parse(body)); } catch { reject(new Error(body)); }
});
});
req.on("error", reject);
req.end();
});
}
const LIBRARY_ID = process.env.BUNNY_LIBRARY_ID || "476412";
const CDN_HOST = process.env.BUNNY_CDN_HOST || "vz-7982dfc4-cc8.b-cdn.net";
app.get("/api/videos", async (req, res) => {
try {
const page = parseInt(req.query.page as string) || 1;
const perPage = parseInt(req.query.perPage as string) || 20;
const search = req.query.search as string || "";
const cacheKey = `videos_${page}_${perPage}_${search}`;
const cached = getCached<any>(cacheKey, 30 * 60 * 1000);
if (cached) return res.json(cached);
let path = `/library/${LIBRARY_ID}/videos?page=${page}&itemsPerPage=${perPage}&orderBy=date`;
if (search) path += `&search=${encodeURIComponent(search)}`;
const data = await bunnyFetch(path);
const videos = (data.items || []).map((v: any) => {
const descTag = (v.metaTags || []).find((t: any) => t.property === "description");
return {
guid: v.guid,
title: (v.title || "").replace(/\.mp4$/i, ""),
description: descTag?.value || v.description || "",
length: v.length,
dateUploaded: v.dateUploaded,
thumbnail: `https://${CDN_HOST}/${v.guid}/${v.thumbnailFileName || "thumbnail.jpg"}`,
embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${v.guid}`,
hlsUrl: `https://${CDN_HOST}/${v.guid}/playlist.m3u8`,
status: v.status,
category: v.category || "",
};
});
const result = { items: videos, totalItems: data.totalItems, currentPage: data.currentPage };
setCache(cacheKey, result);
res.json(result);
} catch (err: any) {
const stale = getStale<any>(`videos_${req.query.page || 1}_${req.query.perPage || 20}_${req.query.search || ""}`);
if (stale) return res.json(stale);
res.status(500).json({ message: err.message });
}
});
app.get("/api/videos/:guid", async (req, res) => {
try {
const data = await bunnyFetch(`/library/${LIBRARY_ID}/videos/${req.params.guid}`);
const descTag = (data.metaTags || []).find((t: any) => t.property === "description");
res.json({
guid: data.guid,
title: (data.title || "").replace(/\.mp4$/i, ""),
description: descTag?.value || data.description || "",
length: data.length,
dateUploaded: data.dateUploaded,
thumbnail: `https://${CDN_HOST}/${data.guid}/${data.thumbnailFileName || "thumbnail.jpg"}`,
embedUrl: `https://player.mediadelivery.net/embed/${LIBRARY_ID}/${data.guid}`,
hlsUrl: `https://${CDN_HOST}/${data.guid}/playlist.m3u8`,
status: data.status,
category: data.category || "",
});
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
// Dropbox OAuth
app.get("/api/dropbox/auth", (_req, res) => {
const host = _req.headers.host || "news-folx-tv.replit.app";
const protocol = _req.headers["x-forwarded-proto"] || "https";
const redirectUri = `${protocol}://${host}/api/dropbox/callback`;
const url = getAuthUrl(redirectUri);
res.redirect(url);
});
app.get("/api/dropbox/callback", async (req, res) => {
const code = req.query.code as string;
if (!code) {
return res.status(400).send("Manjka avtorizacijska koda.");
}
try {
const host = req.headers.host || "news-folx-tv.replit.app";
const protocol = req.headers["x-forwarded-proto"] || "https";
const redirectUri = `${protocol}://${host}/api/dropbox/callback`;
await exchangeCodeForTokens(code, redirectUri);
res.send("<html><body style='background:#111;color:#fff;font-family:sans-serif;text-align:center;padding:60px'><h1>Dropbox povezan!</h1><p>Lahko zaprete to stran.</p></body></html>");
} catch (err: any) {
res.status(500).send(`Napaka: ${err.message}`);
}
});
app.get("/api/dropbox/status", (_req, res) => {
res.json({ connected: isConnected() });
});
const thumbCache = new Map<string, { data: Buffer; timestamp: number }>();
const THUMB_CACHE_TTL = 30 * 60 * 1000;
app.get("/api/gallery/thumb", async (req, res) => {
const src = req.query.src as string;
if (!src) return res.status(400).send("Missing src");
const cached = thumbCache.get(src);
if (cached && Date.now() - cached.timestamp < THUMB_CACHE_TTL) {
res.set("Content-Type", "image/jpeg");
res.set("Cache-Control", "public, max-age=1800");
return res.send(cached.data);
}
try {
const resp = await fetch(src);
if (!resp.ok) return res.status(502).send("Upstream error");
const arrayBuf = await resp.arrayBuffer();
const fullBuf = Buffer.from(arrayBuf);
const sharp = (await import("sharp")).default;
const thumbBuf = await sharp(fullBuf)
.resize(400, 400, { fit: "cover", position: "attention" })
.jpeg({ quality: 70 })
.toBuffer();
thumbCache.set(src, { data: thumbBuf, timestamp: Date.now() });
if (thumbCache.size > 300) {
const oldest = [...thumbCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
for (let i = 0; i < 50; i++) thumbCache.delete(oldest[i][0]);
}
res.set("Content-Type", "image/jpeg");
res.set("Cache-Control", "public, max-age=1800");
res.send(thumbBuf);
} catch (err: any) {
res.status(500).send(err.message);
}
});
app.post("/api/dropbox/refresh-gallery", async (_req, res) => {
try {
const images = await fetchGalleryFromDropbox();
res.json({ count: images.length });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
// Gallery API - serves shuffled photos from Dropbox
const folderArtists: Record<string, string> = {
"2": "Folx Stadl",
"3": "Folx Stadl",
"4": "Folx Stadl",
"5": "Folx Stadl",
"6": "Folx Stadl",
};
app.get("/api/gallery", async (_req, res) => {
try {
let data: any[] = [];
if (isConnected()) {
data = await fetchGalleryFromDropbox();
}
if (data.length === 0) {
const galleryPath = path.join(process.cwd(), "server/gallery-data.json");
data = JSON.parse(fs.readFileSync(galleryPath, "utf-8"));
}
data = data.map((img: any) => {
if (!img.artist && folderArtists[img.folder]) {
return { ...img, artist: folderArtists[img.folder] };
}
return img;
});
const shuffled = [...data];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
res.json(shuffled);
} catch (err: any) {
res.json([]);
}
});
// Horoscope API
app.get("/api/horoscopes/today", async (_req, res) => {
try {
const horoscopes = await getHoroscopesForToday();
res.json(horoscopes);
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.get("/api/horoscopes/sign/:index", async (req, res) => {
try {
const signIndex = parseInt(req.params.index);
if (isNaN(signIndex) || signIndex < 0 || signIndex > 11) {
return res.status(400).json({ message: "Invalid sign index" });
}
const horoscope = await getOrGenerateHoroscope(signIndex);
res.json(horoscope);
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.post("/api/horoscopes/generate", async (_req, res) => {
try {
await generateDailyHoroscopes();
const horoscopes = await getHoroscopesForToday();
res.json({ generated: horoscopes.length, horoscopes });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
function parseRssItems(xml: string, maxAgeDays?: number): { title: string; link: string; source: string; pubDate: string }[] {
const channelTitle = xml.match(/<channel>[\s\S]*?<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || "";
const items: { title: string; link: string; source: string; pubDate: string }[] = [];
const itemRegex = /<item>([\s\S]*?)<\/item>/g;
let match;
while ((match = itemRegex.exec(xml)) !== null && items.length < 15) {
const block = match[1];
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || "";
const link = block.match(/<link>(.*?)<\/link>/)?.[1]?.trim() || "";
let source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || "";
if (!source && block.includes("<dc:creator>")) {
source = block.match(/<dc:creator>(.*?)<\/dc:creator>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "").trim() || "";
}
if (!source) {
try { source = new URL(link).hostname.replace("www.", "").split(".")[0]; source = source.charAt(0).toUpperCase() + source.slice(1); } catch {}
}
if (!source) source = channelTitle;
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || block.match(/<dc:date>(.*?)<\/dc:date>/)?.[1] || "";
let pubDate = "";
try {
const d = new Date(pubDateRaw);
if (isNaN(d.getTime())) continue;
if (maxAgeDays !== undefined) {
const ageMs = Date.now() - d.getTime();
if (ageMs > maxAgeDays * 24 * 3600000) continue;
}
const diffH = Math.floor((Date.now() - d.getTime()) / 3600000);
if (diffH < 1) pubDate = "Gerade eben";
else if (diffH < 24) pubDate = `vor ${diffH} Std.`;
else pubDate = `vor ${Math.floor(diffH / 24)} T.`;
} catch { pubDate = ""; }
if (title && link) items.push({ title, link, source, pubDate });
}
return items;
}
async function fetchRssFeed(rssUrl: string): Promise<string> {
const resp = await fetch(rssUrl, {
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "de-DE,de;q=0.9,en;q=0.5",
"Cache-Control": "no-cache",
},
redirect: "follow",
});
if (!resp.ok) throw new Error(`RSS fetch failed: ${resp.status}`);
return await resp.text();
}
app.get("/api/news-feed", async (_req, res) => {
const cacheKey = "news-feed";
const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000);
if (cached) return res.json(cached);
try {
const musicFeeds = [
"https://www.merkur.de/kultur/musik/rssfeed.rdf",
"https://www.stern.de/feed/standard/kultur/",
"https://www.spiegel.de/kultur/index.rss",
"https://rss.orf.at/oesterreich.xml",
];
let allItems: { title: string; link: string; source: string; pubDate: string }[] = [];
const results = await Promise.allSettled(
musicFeeds.map(async (url) => {
try {
const xml = await fetchRssFeed(url);
return parseRssItems(xml, 14);
} catch { return []; }
})
);
for (const r of results) {
if (r.status === "fulfilled") allItems.push(...r.value);
}
const musikKeywords = /musik|schlager|volksmusik|oberkrainer|konzert|sänger|sängerin|lied|album|hit|chor|festival|bühne|oper|melodie|band|pop|rock|charts|song|tour|tournee|rapper|hip.?hop|grammy|award|karneval|fasching|wiesn|oktoberfest|helene.fischer|andrea.berg|florian.silbereisen|kulturnacht|open.air/i;
let filtered = allItems.filter(i => musikKeywords.test(i.title));
if (filtered.length < 3) filtered = allItems.slice(0, 10);
filtered = filtered.slice(0, 10);
if (filtered.length > 0) setCache(cacheKey, filtered);
res.json(filtered);
} catch (err: any) {
console.log("[news-feed] RSS fetch error:", err.message);
const stale = getStale<any[]>(cacheKey);
res.json(stale || []);
}
});
app.get("/api/breaking-news", async (_req, res) => {
const cacheKey = "breaking-news";
const cached = getCached<any[]>(cacheKey, 15 * 60 * 1000);
if (cached) return res.json(cached);
try {
const newsFeeds = [
"https://www.tagesschau.de/xml/rss2_https/",
"https://www.n-tv.de/rss",
"https://rss.orf.at/oesterreich.xml",
"https://www.welt.de/feeds/latest.rss",
];
let allItems: { title: string; link: string; source: string; pubDate: string }[] = [];
const results = await Promise.allSettled(
newsFeeds.map(async (url) => {
try {
const xml = await fetchRssFeed(url);
return parseRssItems(xml, 1);
} catch { return []; }
})
);
for (const r of results) {
if (r.status === "fulfilled") allItems.push(...r.value);
}
allItems = allItems.slice(0, 10);
if (allItems.length > 0) setCache(cacheKey, allItems);
res.json(allItems);
} catch (err: any) {
console.log("[breaking-news] RSS fetch error:", err.message);
const stale = getStale<any[]>(cacheKey);
res.json(stale || []);
}
});
return httpServer;
}