This commit is contained in:
parent
24d9a945b3
commit
c2bcc86527
12 changed files with 252 additions and 83 deletions
|
|
@ -818,7 +818,8 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
// SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking
|
// SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking
|
||||||
*dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, u.username, u.avatar_url "
|
*dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, r.offline_image_url, r.title_color, "
|
||||||
|
"u.username, u.avatar_url "
|
||||||
"FROM realms r JOIN users u ON r.user_id = u.id "
|
"FROM realms r JOIN users u ON r.user_id = u.id "
|
||||||
"WHERE r.is_live = true AND r.is_active = true "
|
"WHERE r.is_live = true AND r.is_active = true "
|
||||||
"ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC"
|
"ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC"
|
||||||
|
|
@ -842,6 +843,8 @@ void RealmController::getLiveRealms(const HttpRequestPtr &,
|
||||||
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
||||||
realm["username"] = row["username"].as<std::string>();
|
realm["username"] = row["username"].as<std::string>();
|
||||||
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||||
|
realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>();
|
||||||
|
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
||||||
resp.append(realm);
|
resp.append(realm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1436,3 +1439,24 @@ void RealmController::updateTitleColor(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
>> DB_ERROR(callback, "check realm ownership");
|
>> DB_ERROR(callback, "check realm ownership");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Internal endpoint for Lua thumbnail generator to lookup stream key by realm name
|
||||||
|
void RealmController::getStreamKeyByRealmName(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &realmName) {
|
||||||
|
auto dbClient = app().getDbClient();
|
||||||
|
|
||||||
|
*dbClient << "SELECT stream_key FROM realms WHERE name = $1 AND is_live = true AND is_active = true"
|
||||||
|
<< realmName
|
||||||
|
>> [callback](const Result& r) {
|
||||||
|
if (r.empty()) {
|
||||||
|
callback(jsonError("Realm not found or not live", k404NotFound));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json::Value resp;
|
||||||
|
resp["streamKey"] = r[0]["stream_key"].as<std::string>();
|
||||||
|
callback(jsonResp(resp));
|
||||||
|
}
|
||||||
|
>> DB_ERROR(callback, "get stream key by realm name");
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ public:
|
||||||
ADD_METHOD_TO(RealmController::addRealmModerator, "/api/realms/{1}/moderators", Post);
|
ADD_METHOD_TO(RealmController::addRealmModerator, "/api/realms/{1}/moderators", Post);
|
||||||
ADD_METHOD_TO(RealmController::removeRealmModerator, "/api/realms/{1}/moderators/{2}", Delete);
|
ADD_METHOD_TO(RealmController::removeRealmModerator, "/api/realms/{1}/moderators/{2}", Delete);
|
||||||
ADD_METHOD_TO(RealmController::updateTitleColor, "/api/realms/{1}/title-color", Put);
|
ADD_METHOD_TO(RealmController::updateTitleColor, "/api/realms/{1}/title-color", Put);
|
||||||
|
ADD_METHOD_TO(RealmController::getStreamKeyByRealmName, "/internal/realm-stream-key/{1}", Get);
|
||||||
METHOD_LIST_END
|
METHOD_LIST_END
|
||||||
|
|
||||||
void getUserRealms(const HttpRequestPtr &req,
|
void getUserRealms(const HttpRequestPtr &req,
|
||||||
|
|
@ -105,4 +106,8 @@ public:
|
||||||
void updateTitleColor(const HttpRequestPtr &req,
|
void updateTitleColor(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
const std::string &realmId);
|
const std::string &realmId);
|
||||||
|
|
||||||
|
void getStreamKeyByRealmName(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||||
|
const std::string &realmName);
|
||||||
};
|
};
|
||||||
|
|
@ -83,7 +83,8 @@ export const filteredMessages = derived(
|
||||||
const prev = filtered[i - 1];
|
const prev = filtered[i - 1];
|
||||||
const sameUser = String(prev.userId) === String(msg.userId);
|
const sameUser = String(prev.userId) === String(msg.userId);
|
||||||
const sameRealm = String(prev.realmId) === String(msg.realmId);
|
const sameRealm = String(prev.realmId) === String(msg.realmId);
|
||||||
const showHeader = !sameUser || !sameRealm || msg.usedRoll || msg.usedRtd;
|
const hasSelfDestruct = msg.selfDestructAt && msg.selfDestructAt > 0;
|
||||||
|
const showHeader = !sameUser || !sameRealm || msg.usedRoll || msg.usedRtd || hasSelfDestruct;
|
||||||
return { ...msg, showHeader };
|
return { ...msg, showHeader };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,7 +127,7 @@
|
||||||
const sources = [
|
const sources = [
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${viewerToken}`,
|
file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`,
|
||||||
label: 'LLHLS'
|
label: 'LLHLS'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -151,7 +151,7 @@
|
||||||
// Only add token if not already present (segments don't have it)
|
// Only add token if not already present (segments don't have it)
|
||||||
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
|
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
xhr.open('GET', url + separator + 'token=' + viewerToken, true);
|
xhr.open('GET', url + separator + 'token=' + encodeURIComponent(viewerToken), true);
|
||||||
}
|
}
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@
|
||||||
const sources = [
|
const sources = [
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `${proto}://${host}:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${token}`,
|
file: `${proto}://${host}:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${encodeURIComponent(token)}`,
|
||||||
label: 'LLHLS'
|
label: 'LLHLS'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
@ -163,9 +163,9 @@
|
||||||
lowLatencyMode: true,
|
lowLatencyMode: true,
|
||||||
backBufferLength: 30,
|
backBufferLength: 30,
|
||||||
xhrSetup: function(xhr, url) {
|
xhrSetup: function(xhr, url) {
|
||||||
if (token && url.includes('/app/')) {
|
if (token && url.includes('/app/') && !url.includes('token=')) {
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
xhr.open('GET', url + separator + 'token=' + token, true);
|
xhr.open('GET', url + separator + 'token=' + encodeURIComponent(token), true);
|
||||||
}
|
}
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
} from '$lib/chat/chatStore';
|
} from '$lib/chat/chatStore';
|
||||||
import { chatWebSocket } from '$lib/chat/chatWebSocket';
|
import { chatWebSocket } from '$lib/chat/chatWebSocket';
|
||||||
import { chatLayout } from '$lib/stores/chatLayout';
|
import { chatLayout } from '$lib/stores/chatLayout';
|
||||||
import { auth } from '$lib/stores/auth';
|
import { auth, isAuthenticated } from '$lib/stores/auth';
|
||||||
import {
|
import {
|
||||||
ttsEnabled,
|
ttsEnabled,
|
||||||
ttsSettings,
|
ttsSettings,
|
||||||
|
|
@ -55,6 +55,7 @@
|
||||||
let honkAudio = null;
|
let honkAudio = null;
|
||||||
let honkSoundUrl = null;
|
let honkSoundUrl = null;
|
||||||
let mentionedMessageIds = new Set(); // Track which messages have already played honk
|
let mentionedMessageIds = new Set(); // Track which messages have already played honk
|
||||||
|
let wasAuthenticated = false; // Track previous auth state for reconnect detection
|
||||||
|
|
||||||
$: isConnected = $connectionStatus === 'connected';
|
$: isConnected = $connectionStatus === 'connected';
|
||||||
|
|
||||||
|
|
@ -63,6 +64,16 @@
|
||||||
chatWebSocket.getParticipants();
|
chatWebSocket.getParticipants();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reconnect WebSocket when user logs in or registers while already connected as guest
|
||||||
|
$: {
|
||||||
|
const nowAuthenticated = $isAuthenticated;
|
||||||
|
if (nowAuthenticated && !wasAuthenticated && isConnected) {
|
||||||
|
console.log('[ChatPanel] Auth state changed to authenticated, reconnecting...');
|
||||||
|
chatWebSocket.manualReconnect();
|
||||||
|
}
|
||||||
|
wasAuthenticated = nowAuthenticated;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
showMenu = !showMenu;
|
showMenu = !showMenu;
|
||||||
if (showMenu) {
|
if (showMenu) {
|
||||||
|
|
@ -192,11 +203,17 @@
|
||||||
console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)');
|
console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)');
|
||||||
chatWebSocket.connect(realmId, token);
|
chatWebSocket.connect(realmId, token);
|
||||||
|
|
||||||
// Function to scroll to bottom
|
// Function to scroll to newest messages (bottom for UP flow, top for DOWN flow)
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = () => {
|
||||||
if (autoScroll && messagesContainer) {
|
if (autoScroll && messagesContainer) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if ($chatLayout.messagesFromTop) {
|
||||||
|
// column-reverse: newest at top, scroll to top
|
||||||
|
messagesContainer.scrollTop = 0;
|
||||||
|
} else {
|
||||||
|
// normal: newest at bottom, scroll to bottom
|
||||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -257,8 +274,14 @@
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
|
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
|
||||||
|
if ($chatLayout.messagesFromTop) {
|
||||||
|
// column-reverse: user at "newest" means scrollTop near 0
|
||||||
|
autoScroll = scrollTop <= 50;
|
||||||
|
} else {
|
||||||
|
// normal: user at bottom means scrollTop + clientHeight near scrollHeight
|
||||||
autoScroll = scrollTop + clientHeight >= scrollHeight - 50;
|
autoScroll = scrollTop + clientHeight >= scrollHeight - 50;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDeleteMessage(messageId) {
|
function handleDeleteMessage(messageId) {
|
||||||
chatWebSocket.deleteMessage(messageId);
|
chatWebSocket.deleteMessage(messageId);
|
||||||
|
|
@ -640,7 +663,6 @@
|
||||||
<ChatMessage
|
<ChatMessage
|
||||||
{message}
|
{message}
|
||||||
showHeader={message.showHeader ?? false}
|
showHeader={message.showHeader ?? false}
|
||||||
headerBelow={$chatLayout.messagesFromTop}
|
|
||||||
currentUserId={$chatUserInfo.userId}
|
currentUserId={$chatUserInfo.userId}
|
||||||
currentUsername={$chatUserInfo.username}
|
currentUsername={$chatUserInfo.username}
|
||||||
currentRealmId={realmId}
|
currentRealmId={realmId}
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-thumbnail .placeholder-container {
|
.stream-thumbnail .placeholder-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -276,6 +281,16 @@
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stream-thumbnail .offline-placeholder-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.stream-thumbnail .placeholder-initial {
|
.stream-thumbnail .placeholder-initial {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|
@ -288,6 +303,7 @@
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: white;
|
color: white;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stream-thumbnail .live-pulse {
|
.stream-thumbnail .live-pulse {
|
||||||
|
|
@ -984,13 +1000,15 @@
|
||||||
<a href={`/${stream.name}/live`} class="stream-card">
|
<a href={`/${stream.name}/live`} class="stream-card">
|
||||||
<div class="stream-thumbnail">
|
<div class="stream-thumbnail">
|
||||||
<div class="live-badge">LIVE</div>
|
<div class="live-badge">LIVE</div>
|
||||||
{#if stream.streamKey}
|
|
||||||
<img
|
<img
|
||||||
src={`/thumb/${stream.streamKey}.webp`}
|
src={`/thumb/${encodeURIComponent(stream.name)}.webp`}
|
||||||
alt={stream.name}
|
alt={stream.name}
|
||||||
|
on:error={(e) => e.target.style.display = 'none'}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
<div class="placeholder-container">
|
<div class="placeholder-container">
|
||||||
|
{#if stream.offlineImageUrl}
|
||||||
|
<img src={stream.offlineImageUrl} alt="" class="offline-placeholder-img" />
|
||||||
|
{/if}
|
||||||
<div class="placeholder-initial">
|
<div class="placeholder-initial">
|
||||||
<div class="live-pulse"></div>
|
<div class="live-pulse"></div>
|
||||||
{stream.name.charAt(0).toUpperCase()}
|
{stream.name.charAt(0).toUpperCase()}
|
||||||
|
|
|
||||||
|
|
@ -322,12 +322,12 @@
|
||||||
sources.push(
|
sources.push(
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${viewerToken}`,
|
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`,
|
||||||
label: 'LLHLS (Low Latency)'
|
label: 'LLHLS (Low Latency)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'hls',
|
type: 'hls',
|
||||||
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${viewerToken}`,
|
file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${encodeURIComponent(viewerToken)}`,
|
||||||
label: 'HLS (Standard)'
|
label: 'HLS (Standard)'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -361,7 +361,7 @@
|
||||||
// Only add if token is not already present in the URL
|
// Only add if token is not already present in the URL
|
||||||
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
|
if (viewerToken && url.includes('/app/') && !url.includes('token=')) {
|
||||||
const separator = url.includes('?') ? '&' : '?';
|
const separator = url.includes('?') ? '&' : '?';
|
||||||
xhr.open('GET', url + separator + 'token=' + viewerToken, true);
|
xhr.open('GET', url + separator + 'token=' + encodeURIComponent(viewerToken), true);
|
||||||
}
|
}
|
||||||
xhr.withCredentials = true;
|
xhr.withCredentials = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,10 @@
|
||||||
// Helper to get auth token for WebSocket connections
|
// Helper to get auth token for WebSocket connections
|
||||||
async function getAuthToken() {
|
async function getAuthToken() {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
|
// Guests don't have auth tokens - skip the API call to avoid 401 error
|
||||||
|
if (!$auth.user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/token', { credentials: 'include' });
|
const response = await fetch('/api/user/token', { credentials: 'include' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@
|
||||||
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
|
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
|
||||||
|
|
||||||
let realmId = null;
|
let realmId = null;
|
||||||
|
let ready = false;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Parse realm ID from URL parameter
|
// Parse realm ID from URL parameter
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
realmId = params.get('realm');
|
realmId = params.get('realm') || ''; // Empty string for global chat
|
||||||
|
ready = true;
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -18,7 +20,11 @@
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="popout-container">
|
<div class="popout-container">
|
||||||
|
{#if ready}
|
||||||
<ChatPanel {realmId} chatEnabled={true} chatGuestsAllowed={true} hideTheaterMode={true} />
|
<ChatPanel {realmId} chatEnabled={true} chatGuestsAllowed={true} hideTheaterMode={true} />
|
||||||
|
{:else}
|
||||||
|
<div class="loading-chat">Connecting to chat...</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -64,4 +70,13 @@
|
||||||
.popout-container :global(.chat-input) {
|
.popout-container :global(.chat-input) {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-chat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
local _M = {}
|
local _M = {}
|
||||||
|
|
||||||
local THUMB_DIR = "/tmp/thumbs"
|
local THUMB_DIR = "/tmp/thumbs"
|
||||||
local CACHE_TTL = 5 -- seconds before regenerating
|
local GENERATION_INTERVAL = 600 -- 10 minutes in seconds
|
||||||
local FFMPEG_TIMEOUT = 15 -- seconds (needs more time for animated capture)
|
local FFMPEG_TIMEOUT = 15 -- seconds (needs more time for animated capture)
|
||||||
local ANIMATION_DURATION = 3 -- seconds of video to capture
|
local ANIMATION_DURATION = 3 -- seconds of video to capture
|
||||||
local ANIMATION_FPS = 8 -- frames per second in output
|
local ANIMATION_FPS = 8 -- frames per second in output
|
||||||
|
|
@ -31,31 +31,72 @@ local function file_size(path)
|
||||||
return 0
|
return 0
|
||||||
end
|
end
|
||||||
|
|
||||||
local function file_age(path)
|
-- Lookup stream_key from realm name via internal API
|
||||||
-- Get file modification time using stat
|
local function get_stream_key_for_realm(realm_name)
|
||||||
local handle = io.popen("stat -c %Y " .. path .. " 2>/dev/null")
|
local http = require "resty.http"
|
||||||
if not handle then
|
local httpc = http.new()
|
||||||
return nil
|
|
||||||
end
|
|
||||||
local mtime = handle:read("*a")
|
|
||||||
handle:close()
|
|
||||||
|
|
||||||
if not mtime or mtime == "" then
|
local res, err = httpc:request_uri("http://backend:3000/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), {
|
||||||
|
method = "GET",
|
||||||
|
headers = { ["Content-Type"] = "application/json" }
|
||||||
|
})
|
||||||
|
|
||||||
|
if not res then
|
||||||
|
ngx.log(ngx.ERR, "Failed to lookup stream key for realm ", realm_name, ": ", err)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local now = os.time()
|
if res.status ~= 200 then
|
||||||
return now - tonumber(mtime)
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local cjson = require "cjson"
|
||||||
|
local ok, data = pcall(cjson.decode, res.body)
|
||||||
|
if not ok or not data.streamKey then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return data.streamKey
|
||||||
end
|
end
|
||||||
|
|
||||||
local function generate_thumbnail(stream_key, thumb_path)
|
-- Get all live realms from API
|
||||||
|
local function get_live_realms()
|
||||||
|
local http = require "resty.http"
|
||||||
|
local httpc = http.new()
|
||||||
|
|
||||||
|
local res, err = httpc:request_uri("http://backend:3000/api/realms/live", {
|
||||||
|
method = "GET",
|
||||||
|
headers = { ["Content-Type"] = "application/json" }
|
||||||
|
})
|
||||||
|
|
||||||
|
if not res then
|
||||||
|
ngx.log(ngx.ERR, "Failed to get live realms: ", err)
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
if res.status ~= 200 then
|
||||||
|
ngx.log(ngx.ERR, "Failed to get live realms: status ", res.status)
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
local cjson = require "cjson"
|
||||||
|
local ok, realms = pcall(cjson.decode, res.body)
|
||||||
|
if not ok then
|
||||||
|
ngx.log(ngx.ERR, "Failed to parse live realms response")
|
||||||
|
return {}
|
||||||
|
end
|
||||||
|
|
||||||
|
return realms
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Generate thumbnail for a single stream
|
||||||
|
local function generate_thumbnail(stream_key)
|
||||||
-- Build LLHLS URL - internal docker network
|
-- Build LLHLS URL - internal docker network
|
||||||
local llhls_url = "http://ovenmediaengine:8080/app/" .. stream_key .. "/llhls.m3u8"
|
local llhls_url = "http://ovenmediaengine:8080/app/" .. stream_key .. "/llhls.m3u8"
|
||||||
|
local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp"
|
||||||
local log_file = THUMB_DIR .. "/" .. stream_key .. ".log"
|
local log_file = THUMB_DIR .. "/" .. stream_key .. ".log"
|
||||||
|
|
||||||
-- First, try to generate animated WebP
|
-- First, try to generate animated WebP
|
||||||
-- Using os.execute which blocks properly until complete
|
|
||||||
-- SECURITY FIX: Use shell_escape to prevent command injection
|
|
||||||
local cmd = string.format(
|
local cmd = string.format(
|
||||||
"timeout %d ffmpeg -y -i %s -t %d -vf 'fps=%d,scale=320:-1:flags=lanczos' -c:v libwebp -lossless 0 -compression_level 3 -q:v 70 -loop 0 -preset default -an %s > %s 2>&1",
|
"timeout %d ffmpeg -y -i %s -t %d -vf 'fps=%d,scale=320:-1:flags=lanczos' -c:v libwebp -lossless 0 -compression_level 3 -q:v 70 -loop 0 -preset default -an %s > %s 2>&1",
|
||||||
FFMPEG_TIMEOUT,
|
FFMPEG_TIMEOUT,
|
||||||
|
|
@ -80,7 +121,6 @@ local function generate_thumbnail(stream_key, thumb_path)
|
||||||
-- If animated webp failed, try static webp (single frame)
|
-- If animated webp failed, try static webp (single frame)
|
||||||
ngx.log(ngx.WARN, "Animated webp failed (exit: ", tostring(exit_code), "), trying static")
|
ngx.log(ngx.WARN, "Animated webp failed (exit: ", tostring(exit_code), "), trying static")
|
||||||
|
|
||||||
-- SECURITY FIX: Use shell_escape to prevent command injection
|
|
||||||
local static_cmd = string.format(
|
local static_cmd = string.format(
|
||||||
"timeout %d ffmpeg -y -i %s -vframes 1 -vf 'scale=320:-1:flags=lanczos' -c:v libwebp -q:v 75 %s > %s 2>&1",
|
"timeout %d ffmpeg -y -i %s -vframes 1 -vf 'scale=320:-1:flags=lanczos' -c:v libwebp -q:v 75 %s > %s 2>&1",
|
||||||
FFMPEG_TIMEOUT,
|
FFMPEG_TIMEOUT,
|
||||||
|
|
@ -97,70 +137,103 @@ local function generate_thumbnail(stream_key, thumb_path)
|
||||||
return true
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Read log file for error info
|
ngx.log(ngx.ERR, "Thumbnail generation failed for stream: ", stream_key)
|
||||||
local log_content = ""
|
|
||||||
local log_f = io.open(log_file, "r")
|
|
||||||
if log_f then
|
|
||||||
log_content = log_f:read("*a") or ""
|
|
||||||
log_f:close()
|
|
||||||
end
|
|
||||||
|
|
||||||
ngx.log(ngx.ERR, "Thumbnail generation failed (exit: ", tostring(exit_code), "): ", log_content:sub(-500))
|
|
||||||
return false
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Background job: Generate thumbnails for all live streams
|
||||||
|
local function generate_all_thumbnails(premature)
|
||||||
|
if premature then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.log(ngx.INFO, "Starting thumbnail generation for all live streams")
|
||||||
|
|
||||||
|
-- Ensure thumb directory exists
|
||||||
|
os.execute("mkdir -p " .. THUMB_DIR)
|
||||||
|
|
||||||
|
local realms = get_live_realms()
|
||||||
|
local count = 0
|
||||||
|
|
||||||
|
for _, realm in ipairs(realms) do
|
||||||
|
if realm.name then
|
||||||
|
local stream_key = get_stream_key_for_realm(realm.name)
|
||||||
|
if stream_key then
|
||||||
|
generate_thumbnail(stream_key)
|
||||||
|
count = count + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.log(ngx.INFO, "Thumbnail generation complete. Generated ", count, " thumbnails")
|
||||||
|
|
||||||
|
-- Schedule next run
|
||||||
|
local ok, err = ngx.timer.at(GENERATION_INTERVAL, generate_all_thumbnails)
|
||||||
|
if not ok then
|
||||||
|
ngx.log(ngx.ERR, "Failed to schedule next thumbnail generation: ", err)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Initialize background thumbnail generation (call from init_worker_by_lua_block)
|
||||||
|
function _M.init_worker()
|
||||||
|
-- Only run on worker 0 to avoid duplicate work
|
||||||
|
if ngx.worker.id() ~= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.log(ngx.INFO, "Initializing thumbnail generator on worker 0")
|
||||||
|
|
||||||
|
-- Ensure thumb directory exists
|
||||||
|
os.execute("mkdir -p " .. THUMB_DIR)
|
||||||
|
|
||||||
|
-- Start first generation after 10 seconds (let services start up)
|
||||||
|
local ok, err = ngx.timer.at(10, generate_all_thumbnails)
|
||||||
|
if not ok then
|
||||||
|
ngx.log(ngx.ERR, "Failed to start thumbnail generator: ", err)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Serve existing thumbnail (no generation on request)
|
||||||
function _M.serve()
|
function _M.serve()
|
||||||
-- Get stream key from nginx variable
|
-- Get realm name from nginx variable
|
||||||
local stream_key = ngx.var.stream_key
|
local realm_name = ngx.var.realm_name
|
||||||
if not stream_key or stream_key == "" then
|
if not realm_name or realm_name == "" then
|
||||||
ngx.status = 400
|
ngx.status = 400
|
||||||
ngx.header["Content-Type"] = "text/plain"
|
ngx.header["Content-Type"] = "text/plain"
|
||||||
ngx.say("Missing stream key")
|
ngx.say("Missing realm name")
|
||||||
return ngx.exit(400)
|
return ngx.exit(400)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Sanitize stream key (alphanumeric, dash, underscore only)
|
-- Sanitize realm name (alphanumeric, dash only)
|
||||||
if not stream_key:match("^[%w%-_]+$") then
|
if not realm_name:match("^[%w%-]+$") then
|
||||||
ngx.status = 400
|
ngx.status = 400
|
||||||
ngx.header["Content-Type"] = "text/plain"
|
ngx.header["Content-Type"] = "text/plain"
|
||||||
ngx.say("Invalid stream key")
|
ngx.say("Invalid realm name")
|
||||||
return ngx.exit(400)
|
return ngx.exit(400)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Lookup stream key from realm name
|
||||||
|
local stream_key = get_stream_key_for_realm(realm_name)
|
||||||
|
if not stream_key then
|
||||||
|
ngx.status = 404
|
||||||
|
ngx.header["Content-Type"] = "text/plain"
|
||||||
|
ngx.say("Realm not found or not live")
|
||||||
|
return ngx.exit(404)
|
||||||
|
end
|
||||||
|
|
||||||
local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp"
|
local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp"
|
||||||
|
|
||||||
-- Check if cached thumbnail exists and is fresh
|
-- Check if thumbnail exists
|
||||||
local age = file_age(thumb_path)
|
|
||||||
local needs_refresh = not age or age > CACHE_TTL
|
|
||||||
|
|
||||||
if needs_refresh then
|
|
||||||
-- Generate new thumbnail
|
|
||||||
local ok = generate_thumbnail(stream_key, thumb_path)
|
|
||||||
if not ok then
|
|
||||||
-- If generation failed, check if we have a stale one to serve
|
|
||||||
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
|
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
|
||||||
ngx.status = 503
|
ngx.status = 404
|
||||||
ngx.header["Content-Type"] = "text/plain"
|
ngx.header["Content-Type"] = "text/plain"
|
||||||
ngx.header["Retry-After"] = "5"
|
ngx.say("Thumbnail not available")
|
||||||
ngx.say("Thumbnail generation in progress")
|
return ngx.exit(404)
|
||||||
return ngx.exit(503)
|
|
||||||
end
|
|
||||||
-- Serve stale thumbnail
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Verify file exists and has content
|
|
||||||
if not file_exists(thumb_path) or file_size(thumb_path) < 100 then
|
|
||||||
ngx.status = 503
|
|
||||||
ngx.header["Content-Type"] = "text/plain"
|
|
||||||
ngx.header["Retry-After"] = "5"
|
|
||||||
ngx.say("Thumbnail not ready")
|
|
||||||
return ngx.exit(503)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Serve the thumbnail file
|
-- Serve the thumbnail file
|
||||||
ngx.header["Content-Type"] = "image/webp"
|
ngx.header["Content-Type"] = "image/webp"
|
||||||
ngx.header["Cache-Control"] = "public, max-age=" .. CACHE_TTL
|
ngx.header["Cache-Control"] = "public, max-age=60" -- Cache for 1 minute on client
|
||||||
ngx.header["Access-Control-Allow-Origin"] = "*"
|
ngx.header["Access-Control-Allow-Origin"] = "*"
|
||||||
|
|
||||||
local f = io.open(thumb_path, "rb")
|
local f = io.open(thumb_path, "rb")
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,12 @@ http {
|
||||||
lua_shared_dict fingerprints 10m; # Server-side fingerprint cache
|
lua_shared_dict fingerprints 10m; # Server-side fingerprint cache
|
||||||
lua_shared_dict uberban_cache 1m; # Uberban status cache (5 second TTL)
|
lua_shared_dict uberban_cache 1m; # Uberban status cache (5 second TTL)
|
||||||
|
|
||||||
|
# Initialize background thumbnail generator (runs every 10 minutes)
|
||||||
|
init_worker_by_lua_block {
|
||||||
|
local thumbnail = require "thumbnail"
|
||||||
|
thumbnail.init_worker()
|
||||||
|
}
|
||||||
|
|
||||||
# Enable compression
|
# Enable compression
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_vary on;
|
gzip_vary on;
|
||||||
|
|
@ -827,8 +833,9 @@ http {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Stream thumbnails - 3 second animated WebP generated on-demand via FFmpeg
|
# Stream thumbnails - 3 second animated WebP generated on-demand via FFmpeg
|
||||||
|
# URL uses realm name for security, Lua looks up stream_key internally
|
||||||
location ~ ^/thumb/([^/]+)\.webp$ {
|
location ~ ^/thumb/([^/]+)\.webp$ {
|
||||||
set $stream_key $1;
|
set $realm_name $1;
|
||||||
|
|
||||||
# CORS headers for preflight
|
# CORS headers for preflight
|
||||||
if ($request_method = 'OPTIONS') {
|
if ($request_method = 'OPTIONS') {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue