diff --git a/.gitignore b/.gitignore index b74afbf..97c76ca 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ temp/ # Claude Code # ====================================================== .claude/ +nul diff --git a/backend/src/controllers/AdminController.cpp b/backend/src/controllers/AdminController.cpp index 9c4fb95..6895b68 100644 --- a/backend/src/controllers/AdminController.cpp +++ b/backend/src/controllers/AdminController.cpp @@ -2536,7 +2536,7 @@ void AdminController::uploadDefaultAvatars(const HttpRequestPtr &req, } // Create avatars directory if it doesn't exist - std::string avatarsDir = "/data/uploads/avatars"; + std::string avatarsDir = "/app/uploads/avatars"; std::filesystem::create_directories(avatarsDir); Json::Value uploaded(Json::arrayValue); @@ -2667,7 +2667,7 @@ void AdminController::deleteDefaultAvatar(const HttpRequestPtr &req, << id >> [callback, filePath](const Result&) { // Delete file from filesystem - std::string fullPath = "/data" + filePath; + std::string fullPath = "/app" + filePath; std::filesystem::remove(fullPath); Json::Value resp; diff --git a/backend/src/controllers/WatchController.cpp b/backend/src/controllers/WatchController.cpp index 107afc3..4e5f5fb 100644 --- a/backend/src/controllers/WatchController.cpp +++ b/backend/src/controllers/WatchController.cpp @@ -562,20 +562,87 @@ void WatchController::removeFromPlaylist(const HttpRequestPtr &req, << iId << rId >> [callback, dbClient, rId, isDeletingCurrentVideo](const Result&) { if (isDeletingCurrentVideo) { - // Clear the current video state - *dbClient << "UPDATE watch_room_state SET current_video_id = NULL, " - "playback_state = 'paused', current_time_seconds = 0, " - "last_sync_at = CURRENT_TIMESTAMP WHERE realm_id = $1" + // FIX: Instead of just clearing the state, try to advance to next video + // Get next queued video (prioritize locked, then by created_at) + *dbClient << "SELECT id, youtube_video_id, title, duration_seconds, thumbnail_url, is_locked " + "FROM watch_playlist " + "WHERE realm_id = $1 AND status = 'queued' " + "ORDER BY is_locked DESC NULLS LAST, created_at ASC LIMIT 1" << rId - >> [callback](const Result&) { - Json::Value resp; - resp["success"] = true; - resp["clearedCurrentVideo"] = true; - callback(jsonResp(resp)); + >> [callback, dbClient, rId](const Result& nextResult) { + if (nextResult.empty()) { + // No more videos - clear the state + *dbClient << "UPDATE watch_room_state SET current_video_id = NULL, " + "playback_state = 'paused', current_time_seconds = 0, " + "last_sync_at = CURRENT_TIMESTAMP WHERE realm_id = $1" + << rId + >> [callback](const Result&) { + Json::Value resp; + resp["success"] = true; + resp["clearedCurrentVideo"] = true; + resp["currentVideo"] = Json::nullValue; + resp["playbackState"] = "paused"; + callback(jsonResp(resp)); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to clear room state: " << e.base().what(); + Json::Value resp; + resp["success"] = true; + callback(jsonResp(resp)); + }; + } else { + // Advance to next video + int64_t nextVideoId = nextResult[0]["id"].as(); + std::string youtubeVideoId = nextResult[0]["youtube_video_id"].as(); + std::string title = nextResult[0]["title"].as(); + int durationSeconds = nextResult[0]["duration_seconds"].as(); + std::string thumbnailUrl = nextResult[0]["thumbnail_url"].isNull() ? "" : nextResult[0]["thumbnail_url"].as(); + bool isLocked = nextResult[0]["is_locked"].isNull() ? false : nextResult[0]["is_locked"].as(); + + // Update next video to playing + *dbClient << "UPDATE watch_playlist SET status = 'playing', " + "started_at = CURRENT_TIMESTAMP WHERE id = $1" + << nextVideoId + >> [callback, dbClient, rId, nextVideoId, youtubeVideoId, title, durationSeconds, thumbnailUrl, isLocked](const Result&) { + // Update room state to point to new video + *dbClient << "UPDATE watch_room_state SET current_video_id = $1, " + "playback_state = 'playing', current_time_seconds = 0, " + "last_sync_at = CURRENT_TIMESTAMP WHERE realm_id = $2" + << nextVideoId << rId + >> [callback, nextVideoId, youtubeVideoId, title, durationSeconds, thumbnailUrl, isLocked](const Result&) { + Json::Value resp; + resp["success"] = true; + resp["advancedToNext"] = true; + resp["playbackState"] = "playing"; + resp["currentTime"] = 0.0; + + Json::Value video; + video["id"] = static_cast(nextVideoId); + video["youtubeVideoId"] = youtubeVideoId; + video["title"] = title; + video["durationSeconds"] = durationSeconds; + video["thumbnailUrl"] = thumbnailUrl; + video["isLocked"] = isLocked; + resp["currentVideo"] = video; + callback(jsonResp(resp)); + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to update room state: " << e.base().what(); + Json::Value resp; + resp["success"] = true; + callback(jsonResp(resp)); + }; + } + >> [callback](const DrogonDbException& e) { + LOG_ERROR << "Failed to update next video: " << e.base().what(); + Json::Value resp; + resp["success"] = true; + callback(jsonResp(resp)); + }; + } } >> [callback](const DrogonDbException& e) { - LOG_ERROR << "Failed to clear room state: " << e.base().what(); - // Still return success since video was deleted + LOG_ERROR << "Failed to get next video: " << e.base().what(); Json::Value resp; resp["success"] = true; callback(jsonResp(resp)); diff --git a/chat-service/src/controllers/WatchSyncController.h b/chat-service/src/controllers/WatchSyncController.h index 172f91d..dd101ca 100644 --- a/chat-service/src/controllers/WatchSyncController.h +++ b/chat-service/src/controllers/WatchSyncController.h @@ -148,5 +148,6 @@ private: static constexpr int64_t SKIP_DEBOUNCE_MS = 1000; // Database sync freshness threshold (refresh from DB if older than this) - static constexpr int64_t DB_SYNC_STALE_MS = 5000; + // Reduced to 2 seconds for faster response to state changes (video deletion, etc.) + static constexpr int64_t DB_SYNC_STALE_MS = 2000; }; diff --git a/frontend/src/lib/components/chat/ChatMessage.svelte b/frontend/src/lib/components/chat/ChatMessage.svelte index 4db8395..8e5f978 100644 --- a/frontend/src/lib/components/chat/ChatMessage.svelte +++ b/frontend/src/lib/components/chat/ChatMessage.svelte @@ -330,12 +330,6 @@ {/if} {#if message.usedRoll} R diff --git a/frontend/src/lib/components/chat/ChatPanel.svelte b/frontend/src/lib/components/chat/ChatPanel.svelte index 4318b77..e98bee5 100644 --- a/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/frontend/src/lib/components/chat/ChatPanel.svelte @@ -16,6 +16,7 @@ } from '$lib/chat/chatStore'; import { chatWebSocket } from '$lib/chat/chatWebSocket'; import { chatLayout } from '$lib/stores/chatLayout'; + import { auth } from '$lib/stores/auth'; import { ttsEnabled, ttsSettings, @@ -238,7 +239,19 @@ }); function handleSendMessage(event) { - const { message, selfDestructSeconds } = event.detail; + let { message, selfDestructSeconds } = event.detail; + + // Handle /graffiti command + if (message.trim().toLowerCase() === '/graffiti') { + const graffitiUrl = $auth.user?.graffitiUrl; + if (graffitiUrl) { + message = `[graffiti]${graffitiUrl}[/graffiti]`; + } else { + alert('You don\'t have a graffiti yet. Create one in Settings > Appearance.'); + return; + } + } + chatWebSocket.sendMessage(message, userColor, selfDestructSeconds || 0); } diff --git a/frontend/src/lib/components/watch/WatchPlaylist.svelte b/frontend/src/lib/components/watch/WatchPlaylist.svelte index 7fba0e2..1f716f1 100644 --- a/frontend/src/lib/components/watch/WatchPlaylist.svelte +++ b/frontend/src/lib/components/watch/WatchPlaylist.svelte @@ -266,7 +266,14 @@ }); if (response.ok) { - dispatch('videoRemoved', { itemId }); + const data = await response.json(); + // Pass the full response data so the parent can update player state + dispatch('videoRemoved', { + itemId, + advancedToNext: data.advancedToNext || false, + currentVideo: data.currentVideo || null, + playbackState: data.playbackState || null + }); } } catch (e) { console.error('Failed to remove video:', e); diff --git a/frontend/src/routes/[realm]/watch/+page.svelte b/frontend/src/routes/[realm]/watch/+page.svelte index 3301d7b..9cbd4e3 100644 --- a/frontend/src/routes/[realm]/watch/+page.svelte +++ b/frontend/src/routes/[realm]/watch/+page.svelte @@ -24,11 +24,25 @@ $: realmName = $page.params.realm; - // Re-check ownership when auth state changes (login/logout) + // Re-check ownership and reconnect WebSocket when auth state changes (login/logout) + let lastAuthUserId = undefined; // undefined = not yet initialized $: { - $auth; // Track auth store changes - if (realm) { - checkOwnership(); + const currentUserId = $auth.user?.id || null; + if (realm && !loading) { + // Initialize on first run after loading completes + if (lastAuthUserId === undefined) { + lastAuthUserId = currentUserId; + checkOwnership(); + } else if (currentUserId !== lastAuthUserId) { + // Auth changed - reconnect WebSocket to get updated permissions + lastAuthUserId = currentUserId; + checkOwnership(); + const token = browser ? localStorage.getItem('token') : null; + watchSync.disconnect(); + setTimeout(() => { + watchSync.connect(realm.id, token); + }, 100); + } } } @@ -108,6 +122,29 @@ isOwner = (user?.id === realm.ownerId) || user?.isAdmin; } + // Detect permission mismatch: if user is owner but WebSocket doesn't have control + // This can happen if the WebSocket connected before auth was ready + let permissionCheckDone = false; + $: { + // Only run this check once after WebSocket is connected and we know we're the owner + if (!permissionCheckDone && isOwner && !loading && $auth.user) { + // Give the WebSocket a moment to receive welcome message + setTimeout(() => { + if (isOwner && !$canControl && realm) { + console.log('Permission mismatch detected: owner but no control. Reconnecting...'); + const token = browser ? localStorage.getItem('token') : null; + if (token) { + watchSync.disconnect(); + setTimeout(() => { + watchSync.connect(realm.id, token); + }, 100); + } + } + permissionCheckDone = true; + }, 1500); + } + } + function handleVideoAdded(event) { loadPlaylist(); // Request sync to get updated current video state (video may have auto-started) @@ -118,10 +155,16 @@ function handleVideoRemoved(event) { loadPlaylist(); - // Request sync to get updated state (current video may have been cleared) - setTimeout(() => { + // If the backend advanced to next video, request sync immediately to update player + if (event.detail?.advancedToNext || event.detail?.currentVideo) { + // Request sync immediately to get the new video playing watchSync.requestSync(); - }, 500); + } else { + // Request sync after a short delay for other cases + setTimeout(() => { + watchSync.requestSync(); + }, 500); + } } function handlePlayerReady() { diff --git a/nakama/modules/package.json b/nakama/modules/package.json index 48279e6..f70693c 100644 --- a/nakama/modules/package.json +++ b/nakama/modules/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "Nakama server modules for realms.india", "scripts": { - "build": "npx esbuild src/main.ts --bundle --outfile=build/index.js --format=cjs --target=es2020 --platform=neutral --main-fields=main,module" + "build": "npx esbuild src/main.ts --bundle --outfile=build/index.js --format=cjs --target=es2020 --platform=node --main-fields=main,module" }, "devDependencies": { "esbuild": "^0.19.0", diff --git a/openresty/lua/redis_helper.lua b/openresty/lua/redis_helper.lua index db7fec8..8371e48 100644 --- a/openresty/lua/redis_helper.lua +++ b/openresty/lua/redis_helper.lua @@ -18,6 +18,14 @@ local function get_redis_connection() return nil end + -- Select the correct Redis database (db 1, matching backend config) + local db = tonumber(os.getenv("REDIS_DB")) or 1 + local res, err = red:select(db) + if not res then + ngx.log(ngx.ERR, "Failed to select Redis database: ", err) + return nil + end + -- Authenticate if password is set if REDIS_PASSWORD and REDIS_PASSWORD ~= "" then local res, err = red:auth(REDIS_PASSWORD)