folx-tv/server/routes.ts
sebastjanartic 4b5b3e5d97 Improve photo gallery with artist names and infinite scrolling
Refactor photo gallery to include artist names extracted from filenames, implement pagination with infinite scrolling, and optimize image loading.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 79e0a082-1752-4a36-8483-997b7269c4f3
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/nFw7xof
Replit-Helium-Checkpoint-Created: true
2026-03-02 16:39:45 +00:00

441 lines
16 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 articles = await storage.getArticlesByCategory(req.params.category);
res.json(articles);
});
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
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"));
}
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): { title: string; link: string; source: string; pubDate: string }[] {
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 < 10) {
const block = match[1];
const title = block.match(/<title>(.*?)<\/title>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const link = block.match(/<link>(.*?)<\/link>/)?.[1] || "";
const source = block.match(/<source[^>]*>(.*?)<\/source>/)?.[1]?.replace(/<!\[CDATA\[|\]\]>/g, "") || "";
const pubDateRaw = block.match(/<pubDate>(.*?)<\/pubDate>/)?.[1] || "";
let pubDate = "";
try {
const d = new Date(pubDateRaw);
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 (compatible; FolxTV/1.0)" },
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 topics = ["Volksmusik", "Schlager+Musik", "Oberkrainer"];
const topic = topics[Math.floor(Date.now() / 3600000) % topics.length];
const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`;
const xml = await fetchRssFeed(rssUrl);
const items = parseRssItems(xml);
if (items.length > 0) setCache(cacheKey, items);
res.json(items);
} 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 topics = ["Nachrichten+Deutschland", "Nachrichten+Oesterreich", "Nachrichten+Europa", "Wirtschaft+aktuell", "Sport+aktuell"];
const topic = topics[Math.floor(Date.now() / 3600000) % topics.length];
const rssUrl = `https://news.google.com/rss/search?q=${topic}&hl=de&gl=DE&ceid=DE:de`;
const xml = await fetchRssFeed(rssUrl);
const items = parseRssItems(xml);
if (items.length > 0) setCache(cacheKey, items);
res.json(items);
} catch (err: any) {
console.log("[breaking-news] RSS fetch error:", err.message);
const stale = getStale<any[]>(cacheKey);
res.json(stale || []);
}
});
return httpServer;
}