import os
import sys
import time
import json
import threading
import ctypes
from ctypes import wintypes
try:
    from dotenv import load_dotenv
    load_dotenv()
except ImportError:
    pass

try:
    import pymysql
except ImportError:
    pymysql = None
import requests
from flask import Flask, render_template, request, jsonify, session, redirect, url_for, Response
from flask_sock import Sock
from flask_cors import CORS
import obsws_python as obs
import urllib.parse
import websocket
import json

# Monkey patch obsws_python to support URL hosts and custom path/secure websockets
_original_obs_client_init = obs.baseclient.ObsClient.__init__

def _custom_obs_client_init(self, **kwargs):
    self.logger = obs.baseclient.logger.getChild(self.__class__.__name__)
    defaultkwargs = {
        "host": "localhost",
        "port": 4455,
        "password": "",
        "subs": 0,
        "timeout": None,
    }
    if not any(key in kwargs for key in ("host", "port", "password")):
        kwargs |= self._conn_from_toml()
    kwargs = defaultkwargs | kwargs
    for attr, val in kwargs.items():
        setattr(self, attr, val)

    host_str = str(self.host).strip()
    if "://" not in host_str:
        test_url = "ws://" + host_str
    else:
        test_url = host_str

    parsed = urllib.parse.urlparse(test_url)
    scheme = "ws"
    if "https://" in host_str.lower() or "wss://" in host_str.lower():
        scheme = "wss"

    netloc = parsed.netloc or host_str
    path = parsed.path or ""

    hostname = netloc
    port_in_url = None
    if ":" in netloc:
        parts = netloc.rsplit(":", 1)
        hostname = parts[0]
        try:
            port_in_url = int(parts[1])
        except ValueError:
            pass

    if port_in_url is not None:
        resolved_port = port_in_url
    else:
        resolved_port = self.port
        if scheme == "wss" and resolved_port == 4455:
            resolved_port = 443

    ws_url = f"{scheme}://{hostname}:{resolved_port}{path}"

    self.logger.info(f"Connecting OBS WebSocket using URL: {ws_url} (timeout={self.timeout})")
    print(f"[*] Connecting OBS WebSocket using URL: {ws_url}")

    try:
        self.ws = websocket.WebSocket()
        self.ws.connect(ws_url, timeout=self.timeout)
        self.server_hello = json.loads(self.ws.recv())
    except ValueError as e:
        self.logger.error(f"{type(e).__name__}: {e}")
        raise
    except (ConnectionRefusedError, TimeoutError, websocket.WebSocketTimeoutException) as e:
        self.logger.exception(f"{type(e).__name__}: {e}")
        raise

obs.baseclient.ObsClient.__init__ = _custom_obs_client_init

import queue

# Redirect stdout and stderr to files in scratch folder for debugging
class LoggerWriter:
    def __init__(self, filename):
        self.terminal = sys.stdout if filename == "app_stdout.log" else sys.stderr
        log_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'scratch')
        os.makedirs(log_dir, exist_ok=True)
        self.log = open(os.path.join(log_dir, filename), "a", encoding="utf-8")

    def write(self, message):
        if self.terminal:
            self.terminal.write(message)
        self.log.write(message)
        self.log.flush()

    def flush(self):
        if self.terminal:
            self.terminal.flush()
        self.log.flush()

sys.stdout = LoggerWriter("app_stdout.log")
sys.stderr = LoggerWriter("app_stderr.log")

app = Flask(__name__)
app.secret_key = os.environ.get('FLASK_SECRET_KEY', 'obs-stream-secret-key-change-me')
# Enable CORS for convenience
CORS(app)
# Initialize Websockets
sock = Sock(app)

# Konfigurasi Kredensial Login
ADMIN_USERNAME = os.environ.get('OBS_ADMIN_USER', 'admin')
ADMIN_PASSWORD = os.environ.get('OBS_ADMIN_PASSWORD', 'admin123')
STREAMER_USERNAME = os.environ.get('OBS_STREAMER_USER', 'streamer')
STREAMER_PASSWORD = os.environ.get('OBS_STREAMER_PASSWORD', 'streamer123')

# Konfigurasi folder upload untuk gambar
UPLOAD_FOLDER = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'static', 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

def get_active_windows():
    """
    Mengembalikan daftar window yang aktif/terlihat di sistem operasi Windows.
    Setiap elemen berupa dict: {"title": ..., "class_name": ..., "process_name": ...}
    """
    WNDENUMPROC = ctypes.WINFUNCTYPE(ctypes.wintypes.BOOL, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM)
    user32 = ctypes.windll.user32
    kernel32 = ctypes.windll.kernel32

    user32.EnumWindows.argtypes = [WNDENUMPROC, ctypes.wintypes.LPARAM]
    user32.GetWindowTextW.argtypes = [ctypes.wintypes.HWND, ctypes.wintypes.LPWSTR, ctypes.c_int]
    user32.GetWindowTextLengthW.argtypes = [ctypes.wintypes.HWND]
    user32.IsWindowVisible.argtypes = [ctypes.wintypes.HWND]
    user32.GetClassNameW.argtypes = [ctypes.wintypes.HWND, ctypes.wintypes.LPWSTR, ctypes.c_int]
    user32.GetWindowThreadProcessId.argtypes = [ctypes.wintypes.HWND, ctypes.POINTER(ctypes.wintypes.DWORD)]

    PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
    kernel32.OpenProcess.argtypes = [ctypes.wintypes.DWORD, ctypes.wintypes.BOOL, ctypes.wintypes.DWORD]
    kernel32.OpenProcess.restype = ctypes.wintypes.HANDLE
    kernel32.CloseHandle.argtypes = [ctypes.wintypes.HANDLE]
    kernel32.CloseHandle.restype = ctypes.wintypes.BOOL
    kernel32.QueryFullProcessImageNameW.argtypes = [ctypes.wintypes.HANDLE, ctypes.wintypes.DWORD, ctypes.wintypes.LPWSTR, ctypes.POINTER(ctypes.wintypes.DWORD)]
    kernel32.QueryFullProcessImageNameW.restype = ctypes.wintypes.BOOL

    windows = []

    def enum_windows_callback(hwnd, lParam):
        if not user32.IsWindowVisible(hwnd):
            return True
        length = user32.GetWindowTextLengthW(hwnd)
        if length == 0:
            return True
        title_buf = ctypes.create_unicode_buffer(length + 1)
        user32.GetWindowTextW(hwnd, title_buf, length + 1)
        title = title_buf.value
        if not title or title in ["Program Manager", "Settings", "Microsoft Text Input Application", "Windows Input Experience"]:
            return True
            
        class_buf = ctypes.create_unicode_buffer(256)
        user32.GetClassNameW(hwnd, class_buf, 256)
        class_name = class_buf.value
        
        pid = ctypes.wintypes.DWORD()
        user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
        
        proc_name = "unknown.exe"
        h_process = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid.value)
        if h_process:
            path_buf = ctypes.create_unicode_buffer(1024)
            path_len = ctypes.wintypes.DWORD(1024)
            if kernel32.QueryFullProcessImageNameW(h_process, 0, path_buf, ctypes.byref(path_len)):
                proc_name = os.path.basename(path_buf.value)
            kernel32.CloseHandle(h_process)
            
        windows.append({
            "title": title,
            "class_name": class_name,
            "process_name": proc_name
        })
        return True

    cb = WNDENUMPROC(enum_windows_callback)
    user32.EnumWindows(cb, 0)
    return windows

# Audio streaming queues and WebM headers for streamers
audio_queues = {}
audio_headers = {}

def get_audio_queue(obs_id):
    if obs_id not in audio_queues:
        audio_queues[obs_id] = queue.Queue(maxsize=100)
    return audio_queues[obs_id]

# Global clients connection tracking
ws_lock = threading.Lock()
active_websockets = set()

def broadcast_msg(obs_id, msg):
    """
    Kirim data ke seluruh client web yang terhubung via WebSocket secara real-time.
    """
    msg["obs_id"] = obs_id
    msg_str = json.dumps(msg)
    with ws_lock:
        for ws in list(active_websockets):
            try:
                ws.send(msg_str)
            except Exception:
                if ws in active_websockets:
                    active_websockets.remove(ws)


class ThreadSafeReqClientProxy:
    def __init__(self, client, lock):
        self._client = client
        self._lock = lock

    def __getattr__(self, name):
        attr = getattr(self._client, name)
        if callable(attr):
            def wrapped(*args, **kwargs):
                with self._lock:
                    return attr(*args, **kwargs)
            return wrapped
        return attr

    def __setattr__(self, name, value):
        if name in ("_client", "_lock"):
            super().__setattr__(name, value)
        else:
            setattr(self._client, name, value)


class OBSWebManager:
    """
    Manager kelas untuk mengelola koneksi ReqClient (request-response)
    dan EventClient (event listener) dari OBS Studio.
    """
    def __init__(self, obs_id, broadcast_callback):
        self.obs_id = obs_id
        self.req_client = None
        self.event_client = None
        self.broadcast = lambda msg: broadcast_callback(self.obs_id, msg)
        self.host = "127.0.0.1"
        self.port = 4455
        self.password = "123456"
        self.stream_name = ""
        self.streamer = ""
        self.name = ""
        self.lock = threading.RLock()
        self.event_thread = None
        self.event_loop_running = False
        self.connecting = False

    def is_connected(self):
        if self.req_client is None:
            return False
        try:
            # Check if underlying websocket is still connected and socket exists
            return (self.req_client.base_client is not None and 
                    self.req_client.base_client.ws is not None and 
                    self.req_client.base_client.ws.connected and 
                    self.req_client.base_client.ws.sock is not None)
        except Exception:
            return False

    def is_host_local(self):
        host_lower = str(self.host).lower().strip()
        # Remove scheme if present
        if "://" in host_lower:
            host_lower = host_lower.split("://", 1)[1]
        # Remove path if present
        if "/" in host_lower:
            host_lower = host_lower.split("/", 1)[0]
        # Remove port if present
        if ":" in host_lower:
            host_lower = host_lower.split(":", 1)[0]
            
        if host_lower in ("localhost", "127.0.0.1", "::1"):
            return True
        # Local networks
        if host_lower.startswith("192.168.") or host_lower.startswith("10."):
            return True
        if host_lower.startswith("172."):
            try:
                parts = host_lower.split(".")
                if len(parts) >= 2:
                    second_octet = int(parts[1])
                    if 16 <= second_octet <= 31:
                        return True
            except ValueError:
                pass
        if host_lower.endswith(".local"):
            return True
        return False

    def connect(self, host, port, password):
        with self.lock:
            if getattr(self, 'connecting', False):
                print(f"[*] OBS {self.obs_id} connection already in progress. Skipping.")
                return False
            if self.is_connected() and self.host == host and self.port == int(port) and self.password == password:
                print(f"[*] OBS {self.obs_id} is already connected to {host}:{port}. Skipping reconnect.")
                return True
            self.connecting = True

        try:
            with self.lock:
                self.disconnect_unlocked()
                self.host = host
                self.port = int(port)
                self.password = password
            
            try:
                print(f"[*] Menghubungkan ke OBS Studio di {self.host}:{self.port}...")
                raw_client = obs.ReqClient(host=self.host, port=self.port, password=self.password, timeout=3)
                self.req_client = ThreadSafeReqClientProxy(raw_client, self.lock)
                
                # Fetch initial studio mode state
                try:
                    sm_resp = self.req_client.get_studio_mode_enabled()
                    self.studio_mode_enabled = getattr(sm_resp, "studio_mode_enabled", False)
                except Exception:
                    self.studio_mode_enabled = False
                
                # Fetch program scene
                try:
                    resp = self.req_client.get_scene_list()
                    self.current_program_scene = getattr(resp, "current_program_scene_name", getattr(resp, "currentProgramSceneName", ""))
                    self.current_scene = self.current_program_scene
                except Exception:
                    self.current_program_scene = ""
                    self.current_scene = ""

                # Fetch preview scene if studio mode is active
                self.current_preview_scene = ""
                if self.studio_mode_enabled:
                    try:
                        prev_resp = self.req_client.get_current_preview_scene()
                        self.current_preview_scene = getattr(prev_resp, "current_preview_scene_name", getattr(prev_resp, "currentPreviewSceneName", ""))
                        self.current_scene = self.current_preview_scene
                    except Exception:
                        pass
                
                # Start event listener thread
                self.event_loop_running = True
                self.event_thread = threading.Thread(target=self._run_event_listener, daemon=True)
                self.event_thread.start()
                
                print("[+] Berhasil terhubung ke OBS WebSocket!")
                
                # Sinkronkan stream key secara otomatis saat terhubung
                try:
                    self.sync_stream_key()
                except Exception as sync_err:
                    print(f"[-] Gagal sinkronisasi stream key saat terhubung: {sync_err}")
                
                # Kirim status koneksi dan muatan awal ke seluruh client web secara real-time
                try:
                    status_data = {
                        "connected": True,
                        "host": self.host,
                        "port": self.port,
                        "video_settings": self.get_video_settings()
                    }
                    self.broadcast({"event": "connection_status", "data": status_data})
                    
                    scenes, current = self.get_scenes()
                    self.broadcast({"event": "scene_list", "data": {
                        "scenes": scenes,
                        "current": current,
                        "studio_mode": self.studio_mode_enabled,
                        "preview_scene": self.current_preview_scene
                    }})
                    
                    active_edit_scene = self.current_preview_scene if (self.studio_mode_enabled and self.current_preview_scene) else current
                    if active_edit_scene:
                        items = self.get_scene_items(active_edit_scene)
                        self.broadcast({"event": "scene_items", "data": {"scene": active_edit_scene, "items": items}})
                    
                    audio = self.get_audio_sources()
                    self.broadcast({"event": "audio_sources", "data": audio})
                    
                    status = self.get_obs_status()
                    self.broadcast({"event": "obs_status", "data": status})
                except Exception as broadcast_err:
                    print(f"[-] Gagal mengirim broadcast inisiasi setelah terhubung: {broadcast_err}")
                
                return True
            except Exception as e:
                print(f"[-] Gagal terhubung ke OBS WebSocket: {e}")
                self.req_client = None
                return False
        finally:
            with self.lock:
                self.connecting = False

    def disconnect(self):
        with self.lock:
            self.disconnect_unlocked()

    def disconnect_unlocked(self):
        if self.req_client is None and self.event_client is None and not self.event_loop_running:
            return
        was_connected = self.is_connected()
        self.event_loop_running = False
        if self.event_client:
            self.event_client = None
        self.req_client = None
        print("[*] Koneksi diputuskan.")
        if was_connected:
            try:
                self.broadcast({"event": "connection_status", "data": {
                    "connected": False,
                    "host": "",
                    "port": "",
                    "video_settings": None
                }})
            except Exception:
                pass

    def sync_stream_key(self):
        """
        Sinkronkan stream key di OBS dengan stream_name (Stream Path / Match) yang dikonfigurasi.
        """
        if not self.req_client or not getattr(self, 'stream_name', None):
            return
        
        try:
            resp = self.req_client.get_stream_service_settings()
            ss_type = getattr(resp, "stream_service_type", None)
            ss_settings = getattr(resp, "stream_service_settings", None)
            
            if ss_type and ss_settings:
                current_key = ss_settings.get("key")
                if current_key != self.stream_name:
                    print(f"[*] Menyelaraskan Stream Key OBS ({self.obs_id}) dari '{current_key}' menjadi '{self.stream_name}'...")
                    ss_settings["key"] = self.stream_name
                    self.req_client.set_stream_service_settings(ss_type, ss_settings)
                    print(f"[+] Berhasil menyelaraskan Stream Key OBS ({self.obs_id}) ke '{self.stream_name}'!")
        except Exception as e:
            print(f"[-] Gagal menyelaraskan Stream Key OBS ({self.obs_id}): {e}")

    def _run_event_listener(self):
        """
        Thread background untuk mendengarkan event langsung dari OBS Studio
        dan menyebarkannya (broadcast) ke seluruh browser klien.
        """
        try:
            if self.is_host_local():
                subs = obs.Subs.LOW_VOLUME | obs.Subs.INPUTVOLUMEMETERS | obs.Subs.SCENEITEMTRANSFORMCHANGED
            else:
                subs = obs.Subs.LOW_VOLUME | obs.Subs.SCENEITEMTRANSFORMCHANGED
            self.event_client = obs.EventClient(host=self.host, port=self.port, password=self.password, subs=subs)
            
            def dispatch(event_name, data):
                if self.event_loop_running:
                    self.broadcast({"event": event_name, "data": data})

            # Override/Definisikan callbacks
            def on_current_program_scene_changed(data):
                scene_name = getattr(data, "scene_name", "")
                self.current_program_scene = scene_name
                if not getattr(self, 'studio_mode_enabled', False):
                    self.current_scene = scene_name
                dispatch("current_scene_changed", {"sceneName": scene_name})

            def on_current_preview_scene_changed(data):
                scene_name = getattr(data, "scene_name", "")
                self.current_preview_scene = scene_name
                if getattr(self, 'studio_mode_enabled', False):
                    self.current_scene = scene_name
                dispatch("current_preview_scene_changed", {"sceneName": scene_name})

            def on_studio_mode_state_changed(data):
                enabled = getattr(data, "studio_mode_active", False)
                self.studio_mode_enabled = enabled
                if enabled:
                    try:
                        prev_resp = self.req_client.get_current_preview_scene()
                        self.current_preview_scene = getattr(prev_resp, "current_preview_scene_name", getattr(prev_resp, "currentPreviewSceneName", ""))
                        self.current_scene = self.current_preview_scene
                    except Exception:
                        pass
                else:
                    self.current_scene = self.current_program_scene
                dispatch("studio_mode_state_changed", {
                    "enabled": enabled,
                    "previewScene": getattr(self, 'current_preview_scene', ''),
                    "programScene": getattr(self, 'current_program_scene', '')
                })

            def on_scene_item_transform_changed(data):
                trans_obj = getattr(data, "scene_item_transform", {})
                transform = {}
                for key in ["positionX", "positionY", "scaleX", "scaleY", "rotation", "width", "height", "sourceWidth", "sourceHeight", "alignment"]:
                    # Cek baik snake_case (obsws-python) maupun camelCase (API asli)
                    snake_key = key
                    if key == "positionX": snake_key = "position_x"
                    elif key == "positionY": snake_key = "position_y"
                    elif key == "scaleX": snake_key = "scale_x"
                    elif key == "scaleY": snake_key = "scale_y"
                    elif key == "sourceWidth": snake_key = "source_width"
                    elif key == "sourceHeight": snake_key = "source_height"
                    
                    val = None
                    if hasattr(trans_obj, snake_key):
                        val = getattr(trans_obj, snake_key)
                    elif hasattr(trans_obj, key):
                        val = getattr(trans_obj, key)
                    elif isinstance(trans_obj, dict):
                        val = trans_obj.get(snake_key) or trans_obj.get(key)
                    transform[key] = val

                dispatch("scene_item_transform_changed", {
                    "sceneName": getattr(data, "scene_name", ""),
                    "sceneItemId": getattr(data, "scene_item_id", 0),
                    "sceneItemTransform": transform
                })

            def on_scene_item_created(data):
                dispatch("scene_item_created", {
                    "sceneName": getattr(data, "scene_name", ""),
                    "sourceName": getattr(data, "source_name", ""),
                    "sceneItemId": getattr(data, "scene_item_id", 0)
                })

            def on_scene_item_removed(data):
                dispatch("scene_item_removed", {
                    "sceneName": getattr(data, "scene_name", ""),
                    "sourceName": getattr(data, "source_name", ""),
                    "sceneItemId": getattr(data, "scene_item_id", 0)
                })

            def on_scene_item_enable_state_changed(data):
                dispatch("scene_item_enable_state_changed", {
                    "sceneName": getattr(data, "scene_name", ""),
                    "sceneItemId": getattr(data, "scene_item_id", 0),
                    "sceneItemEnabled": getattr(data, "scene_item_enabled", False)
                })

            def on_scene_item_list_reindexed(data):
                dispatch("scene_item_list_reindexed", {"sceneName": getattr(data, "scene_name", "")})

            def on_input_mute_state_changed(data):
                audio_sources = self.get_audio_sources()
                dispatch("audio_sources", audio_sources)

            def on_input_volume_changed(data):
                audio_sources = self.get_audio_sources()
                dispatch("audio_sources", audio_sources)

            # Track last broadcast time for audio meters to throttle updates to ~15 FPS
            last_meter_broadcast = [0.0]
            meter_broadcast_interval = 0.066  # ~15 FPS (every 66ms)

            def on_input_volume_meters(data):
                import math
                try:
                    now = time.time()
                    if now - last_meter_broadcast[0] >= meter_broadcast_interval:
                        last_meter_broadcast[0] = now
                        
                        meters_data = {}
                        inputs_list = getattr(data, "inputs", [])
                        for inp in inputs_list:
                            if isinstance(inp, dict):
                                name = inp.get("inputName")
                                levels_mul = inp.get("inputLevelsMul", [])
                            else:
                                name = getattr(inp, "inputName", getattr(inp, "input_name", None))
                                levels_mul = getattr(inp, "inputLevelsMul", getattr(inp, "input_levels_mul", []))
                                
                            if not name:
                                continue
                            
                            max_val = 0.0
                            for ch in levels_mul:
                                if isinstance(ch, list) and len(ch) > 0:
                                    try:
                                        max_val = max(max_val, float(ch[0]))
                                    except (ValueError, TypeError):
                                        pass
                                elif isinstance(ch, (int, float)):
                                    max_val = max(max_val, float(ch))
                                    
                            if max_val <= 0.00001:
                                db = -60.0
                            else:
                                db = 20.0 * math.log10(max_val)
                                
                            # Map dB [-60, 0] to percentage [0, 100]
                            percentage = max(0.0, min(100.0, ((db + 60.0) / 60.0) * 100.0))
                            meters_data[name] = round(percentage, 1)
                            
                        if meters_data:
                            dispatch("volume_meters", meters_data)
                except Exception as ex:
                    print(f"Error in on_input_volume_meters: {ex}", flush=True)

            # Daftarkan callback ke EventClient
            self.event_client.callback.register(on_current_program_scene_changed)
            self.event_client.callback.register(on_current_preview_scene_changed)
            self.event_client.callback.register(on_studio_mode_state_changed)
            self.event_client.callback.register(on_scene_item_transform_changed)
            self.event_client.callback.register(on_scene_item_created)
            self.event_client.callback.register(on_scene_item_removed)
            self.event_client.callback.register(on_scene_item_enable_state_changed)
            self.event_client.callback.register(on_scene_item_list_reindexed)
            self.event_client.callback.register(on_input_mute_state_changed)
            self.event_client.callback.register(on_input_volume_changed)
            self.event_client.callback.register(on_input_volume_meters)

            # Loop penahan agar EventClient tetap berjalan
            while self.event_loop_running:
                time.sleep(0.5)

        except Exception as e:
            print(f"[-] EventClient error: {e}")
            self.event_client = None

    def start_status_poller(self):
        """
        Thread background untuk secara berkala memancarkan
        status streaming / rekaman ke semua client web setiap 1 detik.
        """
        def poller():
            while True:
                if self.req_client is not None:
                    if self.is_connected():
                        try:
                            status = self.get_obs_status()
                            self.broadcast({"event": "obs_status", "data": status})
                        except Exception:
                            pass
                    else:
                        print(f"[*] Connection lost to OBS ({self.obs_id}), disconnecting status poller...")
                        self.disconnect()
                time.sleep(1)
        t = threading.Thread(target=poller, daemon=True)
        t.start()

    def start_screenshot_poller(self):
        """
        Thread background untuk berkala mengambil screenshot scene aktif
        dan memancarkannya ke klien untuk live preview.
        """
        def poller():
            while True:
                if self.req_client is not None:
                    if self.is_connected():
                        # Jika Studio Mode aktif, ambil gambar Program dan Preview
                        if getattr(self, 'studio_mode_enabled', False):
                            try:
                                # 1. Capture Preview scene (the one edited on the canvas)
                                preview_scene = getattr(self, 'current_preview_scene', '')
                                if preview_scene:
                                    preview_img = self.get_scene_screenshot(preview_scene)
                                    if preview_img:
                                        if not preview_img.startswith("data:"):
                                            preview_img = f"data:image/jpeg;base64,{preview_img}"
                                        self.broadcast({"event": "preview_frame", "data": {"image": preview_img}})
                                        
                                # 2. Capture Program scene (the live stream output)
                                program_scene = getattr(self, 'current_program_scene', '')
                                if program_scene:
                                    program_img = self.get_scene_screenshot(program_scene)
                                    if program_img:
                                        if not program_img.startswith("data:"):
                                            program_img = f"data:image/jpeg;base64,{program_img}"
                                        self.broadcast({"event": "program_frame", "data": {"image": program_img}})
                            except Exception as e:
                                print(f"Error in studio screenshot poller: {e}")
                        else:
                            # Single mode: capture Program scene only
                            try:
                                program_scene = getattr(self, 'current_program_scene', '') or getattr(self, 'current_scene', '')
                                if program_scene:
                                    img_data = self.get_scene_screenshot(program_scene)
                                    if img_data:
                                        if not img_data.startswith("data:"):
                                            img_data = f"data:image/jpeg;base64,{img_data}"
                                        self.broadcast({"event": "preview_frame", "data": {"image": img_data}})
                            except Exception:
                                pass
                    else:
                        print(f"[*] Connection lost to OBS ({self.obs_id}), disconnecting screenshot poller...")
                        self.disconnect()
                # Ambil screenshot secara berkala: 0.25s jika lokal, 3.0s jika remote untuk menghemat bandwidth
                sleep_interval = 0.25 if self.is_host_local() else 3.0
                time.sleep(sleep_interval)
        t = threading.Thread(target=poller, daemon=True)
        t.start()

    def get_scene_screenshot(self, scene_name):
        if not self.req_client: return None
        try:
            # Panggil secara posisional sesuai signature: (name, img_format, width, height, quality)
            resp = self.req_client.get_source_screenshot(
                scene_name,
                "jpeg",
                960,
                540,
                65
            )
            img_data = getattr(resp, "image_data", None) or getattr(resp, "image_uri", None)
            return img_data
        except Exception as e:
            if self.is_connected():
                print(f"Error getting screenshot: {e}")
            else:
                print(f"[-] Debug: get_scene_screenshot failed, is_connected=False. Exception: {e}")
                self.disconnect()
            return None

    # === METODE KONTROL OBS ===
    
    def get_video_settings(self):
        if not self.req_client: return None
        try:
            resp = self.req_client.get_video_settings()
            return {
                "baseWidth": getattr(resp, "base_width", 1920),
                "baseHeight": getattr(resp, "base_height", 1080)
            }
        except Exception:
            return {"baseWidth": 1920, "baseHeight": 1080}

    def get_scenes(self):
        if not self.req_client: return [], ""
        try:
            resp = self.req_client.get_scene_list()
            raw_scenes = getattr(resp, "scenes", [])
            scenes = []
            for s in raw_scenes:
                if isinstance(s, dict):
                    name = s.get("sceneName", s.get("scene_name", ""))
                else:
                    name = getattr(s, "sceneName", getattr(s, "scene_name", ""))
                if name:
                    scenes.append(name)
            
            if isinstance(resp, dict):
                current = resp.get("currentProgramSceneName", resp.get("current_program_scene_name", ""))
            else:
                current = getattr(resp, "current_program_scene_name", getattr(resp, "currentProgramSceneName", ""))
                
            return scenes, current
        except Exception as e:
            if not self.is_connected():
                print(f"[-] Debug: get_scenes failed, is_connected=False. Exception: {e}")
                self.disconnect()
            else:
                print(f"Error getting scenes: {e}")
            return [], ""

    def get_scene_items(self, scene_name):
        if not self.req_client: return []
        try:
            resp = self.req_client.get_scene_item_list(scene_name)
            items_list = []
            raw_items = getattr(resp, "scene_items", [])
            
            if isinstance(raw_items, str):
                raw_items = json.loads(raw_items)
                
            for item in raw_items:
                # Helper to read keys from dict or attributes from object
                def read_field(obj, camel_key, snake_key, default=None):
                    if isinstance(obj, dict):
                        return obj.get(camel_key, obj.get(snake_key, default))
                    return getattr(obj, camel_key, getattr(obj, snake_key, default))

                trans_obj = read_field(item, "sceneItemTransform", "scene_item_transform", {})
                transform = {}
                for key in ["positionX", "positionY", "scaleX", "scaleY", "rotation", "width", "height", "sourceWidth", "sourceHeight", "alignment"]:
                    snake_key = key
                    if key == "positionX": snake_key = "position_x"
                    elif key == "positionY": snake_key = "position_y"
                    elif key == "scaleX": snake_key = "scale_x"
                    elif key == "scaleY": snake_key = "scale_y"
                    elif key == "sourceWidth": snake_key = "source_width"
                    elif key == "sourceHeight": snake_key = "source_height"
                    
                    transform[key] = read_field(trans_obj, key, snake_key, 0.0)

                items_list.append({
                    "sceneItemId": read_field(item, "sceneItemId", "scene_item_id", 0),
                    "sourceName": read_field(item, "sourceName", "source_name", ""),
                    "sourceType": read_field(item, "sourceType", "source_type", ""),
                    "inputKind": read_field(item, "inputKind", "input_kind", ""),
                    "sceneItemIndex": read_field(item, "sceneItemIndex", "scene_item_index", 0),
                    "sceneItemEnabled": read_field(item, "sceneItemEnabled", "scene_item_enabled", False),
                    "sceneItemLocked": read_field(item, "sceneItemLocked", "scene_item_locked", False),
                    "sceneItemTransform": transform
                })
            return items_list
        except Exception as e:
            print(f"Error getting scene items: {e}")
            return []

    def get_audio_sources(self):
        audio_sources = []
        if not self.req_client: return []
        try:
            # Ambil audio channel khusus (Mic/Aux, Desktop Audio)
            special = self.req_client.get_special_inputs()
            special_names = []
            for attr in ["desktop1", "desktop2", "mic1", "mic2", "mic3", "mic4", "desktop_1", "desktop_2", "mic_1", "mic_2", "mic_3", "mic_4"]:
                name = getattr(special, attr, None)
                if name and name != "disabled" and name != "":
                    special_names.append(name)
            
            # Ambil semua input di OBS dan cari yang berjenis audio
            inputs = self.req_client.get_input_list()
            for inp in getattr(inputs, "inputs", []):
                if isinstance(inp, dict):
                    kind = inp.get("inputKind", inp.get("input_kind", ""))
                    name = inp.get("inputName", inp.get("input_name", ""))
                else:
                    kind = getattr(inp, "inputKind", getattr(inp, "input_kind", ""))
                    name = getattr(inp, "inputName", getattr(inp, "input_name", ""))
                if kind in ["wasapi_input_capture", "wasapi_output_capture", "wasapi_process_output_capture", "ffmpeg_source", "browser_source", "audio_line_in", "vlc_source"]:
                    special_names.append(name)
                    
            def get_source_sort_key(name):
                name_lower = name.lower()
                is_global = "desktop" in name_lower or "mic" in name_lower or "aux" in name_lower
                return (0 if is_global else 1, name_lower)

            special_names = sorted(list(set(special_names)), key=get_source_sort_key)
            
            for name in special_names:
                try:
                    mute_resp = self.req_client.get_input_mute(name)
                    vol_resp = self.req_client.get_input_volume(name)
                    
                    # Ambil informasi monitoring audio tambahan
                    try:
                        mon_resp = self.req_client.get_input_audio_monitor_type(name)
                        monitor_type = getattr(mon_resp, "monitor_type", "OBS_MONITORING_TYPE_NONE")
                    except Exception:
                        monitor_type = "OBS_MONITORING_TYPE_NONE"
                        
                    try:
                        sync_resp = self.req_client.get_input_audio_sync_offset(name)
                        sync_offset = getattr(sync_resp, "input_audio_sync_offset", 0)
                    except Exception:
                        sync_offset = 0
                        
                    try:
                        bal_resp = self.req_client.get_input_audio_balance(name)
                        balance = getattr(bal_resp, "input_audio_balance", 0.5)
                    except Exception:
                        balance = 0.5
                        
                    audio_sources.append({
                        "name": name,
                        "muted": getattr(mute_resp, "input_muted", False),
                        "volume": getattr(vol_resp, "input_volume_mul", 1.0),
                        "monitor_type": monitor_type,
                        "sync_offset": sync_offset,
                        "balance": balance
                    })
                except Exception:
                    continue
        except Exception as e:
            if not self.is_connected():
                print(f"[-] Debug: get_audio_sources failed, is_connected=False. Exception: {e}")
                self.disconnect()
            else:
                print(f"Error getting audio inputs: {e}")
        return audio_sources

    def _get_active_profile_dir(self):
        if not self.req_client:
            return None
        try:
            resp = self.req_client.get_profile_list()
            current_profile = getattr(resp, "current_profile_name", "Untitled")
        except Exception:
            current_profile = "Untitled"
        
        appdata = os.getenv("APPDATA")
        if appdata:
            profile_path = os.path.join(appdata, "obs-studio", "basic", "profiles", current_profile)
            if os.path.exists(profile_path):
                return profile_path
        return None

    def get_obs_settings(self):
        """
        Mengambil setelan stream, video, dan folder rekaman dari OBS Studio.
        """
        if not self.req_client: return None
        try:
            # 1. Stream Service Settings
            stream_service_resp = self.req_client.get_stream_service_settings()
            ss_type = getattr(stream_service_resp, "stream_service_type", "rtmp_custom")
            ss_settings = getattr(stream_service_resp, "stream_service_settings", {})
            
            # 2. Video Settings
            video_resp = self.req_client.get_video_settings()
            video_settings = {
                "base_width": getattr(video_resp, "base_width", 1920),
                "base_height": getattr(video_resp, "base_height", 1080),
                "output_width": getattr(video_resp, "output_width", 1920),
                "output_height": getattr(video_resp, "output_height", 1080),
                "fps_numerator": getattr(video_resp, "fps_numerator", 60),
                "fps_denominator": getattr(video_resp, "fps_denominator", 1)
            }
            
            # 3. Record Directory
            try:
                rec_dir_resp = self.req_client.get_record_directory()
                record_directory = getattr(rec_dir_resp, "record_directory", "")
            except Exception:
                record_directory = ""

            # 4. Output Mode and Advanced settings (basic.ini)
            output_mode = "Simple"
            try:
                mode_resp = self.req_client.get_profile_parameter("Output", "Mode")
                output_mode = getattr(mode_resp, "parameter_value", "Simple")
            except Exception:
                pass
                
            adv_output = {}
            for param in [
                "Encoder", "AudioEncoder", "TrackIndex", "RecType", 
                "RecFilePath", "RecFormat2", "RecEncoder", "RecTracks", 
                "RecRB", "RecRBTime", "RecRBSize", "UseRescale", "RescaleRes",
                "Track1Bitrate", "Track2Bitrate", "Track3Bitrate", 
                "Track4Bitrate", "Track5Bitrate", "Track6Bitrate"
            ]:
                try:
                    resp = self.req_client.get_profile_parameter("AdvOut", param)
                    val = getattr(resp, "parameter_value", None)
                    if val is not None:
                        adv_output[param] = val
                except Exception:
                    pass

            # 5. Advanced x264 Encoder settings (streamEncoder.json)
            stream_encoder = {}
            profile_dir = self._get_active_profile_dir()
            if profile_dir:
                encoder_json_path = os.path.join(profile_dir, "streamEncoder.json")
                if os.path.exists(encoder_json_path):
                    try:
                        with open(encoder_json_path, "r", encoding="utf-8") as f:
                            stream_encoder = json.load(f)
                    except Exception as e:
                        print(f"Error reading streamEncoder.json: {e}")
                
            return {
                "stream_service": {
                    "type": ss_type,
                    "settings": ss_settings
                },
                "video": video_settings,
                "record_directory": record_directory,
                "output_mode": output_mode,
                "adv_output": adv_output,
                "stream_encoder": stream_encoder
            }
        except Exception as e:
            print(f"Error getting OBS settings: {e}")
            return None

    def set_obs_settings(self, settings_data):
        """
        Mengupdate setelan stream, video, dan direktori rekaman ke OBS Studio.
        """
        if not self.req_client: return False
        try:
            # 1. Update Stream Service Settings
            stream_service = settings_data.get("stream_service")
            if stream_service:
                ss_type = stream_service.get("type")
                ss_settings = stream_service.get("settings")
                if ss_type and ss_settings:
                    self.req_client.set_stream_service_settings(ss_type, ss_settings)
                    
                    # Update local stream key/stream name to keep them in sync
                    new_key = ss_settings.get("key")
                    if new_key:
                        self.stream_name = new_key
                        update_connection_stream_name(self.obs_id, new_key)
                    
            # 2. Update Video Settings
            video = settings_data.get("video")
            if video:
                numerator = int(video.get("fps_numerator", 60))
                denominator = int(video.get("fps_denominator", 1))
                base_width = int(video.get("base_width", 1920))
                base_height = int(video.get("base_height", 1080))
                output_width = int(video.get("output_width", 1920))
                output_height = int(video.get("output_height", 1080))
                self.req_client.set_video_settings(numerator, denominator, base_width, base_height, output_width, output_height)
                
            # 3. Update Record Directory
            record_directory = settings_data.get("record_directory")
            if record_directory is not None:
                self.req_client.set_record_directory(record_directory)

            # 4. Update Output Mode
            output_mode = settings_data.get("output_mode")
            if output_mode:
                try:
                    self.req_client.set_profile_parameter("Output", "Mode", str(output_mode))
                except Exception as e:
                    print(f"Error setting profile output mode: {e}")
                    
            # 5. Update Advanced Settings (basic.ini [AdvOut] section)
            adv_output = settings_data.get("adv_output")
            if adv_output:
                for param, val in adv_output.items():
                    if val is not None:
                        try:
                            self.req_client.set_profile_parameter("AdvOut", param, str(val))
                        except Exception as e:
                            print(f"Error setting profile parameter AdvOut.{param}: {e}")
                            
            # 6. Update Stream Encoder JSON settings (streamEncoder.json)
            stream_encoder = settings_data.get("stream_encoder")
            if stream_encoder:
                profile_dir = self._get_active_profile_dir()
                if profile_dir:
                    encoder_json_path = os.path.join(profile_dir, "streamEncoder.json")
                    existing_encoder_data = {}
                    if os.path.exists(encoder_json_path):
                        try:
                            with open(encoder_json_path, "r", encoding="utf-8") as f:
                                existing_encoder_data = json.load(f)
                        except Exception:
                            pass
                    
                    # Merge keys and enforce correct types
                    for k, v in stream_encoder.items():
                        if v is not None:
                            if k in ["bitrate", "keyint_sec"]:
                                try:
                                    existing_encoder_data[k] = int(v)
                                except (ValueError, TypeError):
                                    pass
                            elif k == "use_bufsize":
                                existing_encoder_data[k] = bool(v)
                            else:
                                existing_encoder_data[k] = str(v)
                                
                    try:
                        with open(encoder_json_path, "w", encoding="utf-8") as f:
                            json.dump(existing_encoder_data, f)
                    except Exception as e:
                        print(f"Error writing streamEncoder.json: {e}")
                        
            # 7. Trigger reload by setting current profile to itself
            try:
                resp = self.req_client.get_profile_list()
                current_profile = getattr(resp, "current_profile_name", "Untitled")
                self.req_client.set_current_profile(current_profile)
            except Exception as e:
                print(f"Error reloading profile: {e}")
                
            return True
        except Exception as e:
            print(f"Error setting OBS settings: {e}")
            return False


    def get_obs_status(self):
        if not self.req_client: return {}
        try:
            stream_resp = self.req_client.get_stream_status()
            record_resp = self.req_client.get_record_status()
            return {
                "streaming": getattr(stream_resp, "output_active", False),
                "stream_timecode": getattr(stream_resp, "output_timecode", "00:00:00"),
                "recording": getattr(record_resp, "output_active", False),
                "record_paused": getattr(record_resp, "output_paused", False),
                "record_timecode": getattr(record_resp, "output_timecode", "00:00:00")
            }
        except Exception as e:
            if not self.is_connected():
                print(f"[-] Debug: get_obs_status failed, is_connected=False. Exception: {e}")
                self.disconnect()
            else:
                print(f"Error getting OBS status: {e}")
            return {"streaming": False, "recording": False}

    def switch_scene(self, scene_name):
        if not self.req_client: return False
        try:
            if getattr(self, 'studio_mode_enabled', False):
                self.req_client.set_current_preview_scene(scene_name)
                self.current_preview_scene = scene_name
                self.current_scene = scene_name
            else:
                self.req_client.set_current_program_scene(scene_name)
                self.current_scene = scene_name
            return True
        except Exception:
            return False

    def toggle_stream(self):
        if not self.req_client: return False
        try:
            self.req_client.toggle_stream()
            return True
        except Exception:
            return False

    def toggle_record(self):
        if not self.req_client: return False
        try:
            self.req_client.toggle_record()
            return True
        except Exception:
            return False

    def toggle_mute(self, source_name):
        if not self.req_client: return False
        try:
            mute_resp = self.req_client.get_input_mute(source_name)
            is_muted = getattr(mute_resp, "input_muted", False)
            self.req_client.set_input_mute(source_name, not is_muted)
            return True
        except Exception:
            return False

    def set_volume(self, source_name, volume):
        if not self.req_client: return False
        try:
            self.req_client.set_input_volume(source_name, volume)
            return True
        except Exception as e:
            print(f"Error setting volume: {e}")
            return False

    def update_item_transform(self, scene_name, item_id, transform):
        if not self.req_client: return False
        try:
            self.req_client.set_scene_item_transform(scene_name, item_id, transform)
            return True
        except Exception as e:
            print(f"Error setting transform: {e}")
            return False

    def set_scene_item_index(self, scene_name, item_id, index):
        if not self.req_client: return False
        try:
            self.req_client.set_scene_item_index(scene_name, item_id, index)
            return True
        except Exception as e:
            print(f"Error setting scene item index: {e}")
            return False

    def toggle_source_visibility(self, scene_name, item_id, enabled):
        if not self.req_client: return False
        try:
            self.req_client.set_scene_item_enabled(scene_name, item_id, enabled)
            return True
        except Exception:
            return False

    def toggle_source_lock(self, scene_name, item_id, locked):
        if not self.req_client: return False
        try:
            self.req_client.set_scene_item_locked(scene_name, item_id, locked)
            return True
        except Exception:
            return False

    def delete_source(self, scene_name, item_id):
        if not self.req_client: return False
        try:
            self.req_client.remove_scene_item(scene_name, item_id)
            return True
        except Exception:
            return False

    def get_input_settings(self, input_name):
        if not self.req_client: return None, None
        try:
            resp = self.req_client.get_input_settings(input_name)
            if isinstance(resp, dict):
                settings = resp.get("inputSettings", resp.get("input_settings", {}))
                kind = resp.get("inputKind", resp.get("input_kind", ""))
            else:
                settings = getattr(resp, "input_settings", getattr(resp, "inputSettings", {}))
                kind = getattr(resp, "input_kind", getattr(resp, "inputKind", ""))
            return settings, kind
        except Exception as e:
            print(f"Error getting input settings for {input_name}: {e}")
            return None, None

    def set_input_settings(self, input_name, settings, overlay=True):
        if not self.req_client: return False
        try:
            self.req_client.set_input_settings(input_name, settings, overlay)
            return True
        except Exception as e:
            print(f"Error setting input settings for {input_name}: {e}")
            return False

    def get_source_filters(self, source_name):
        if not self.req_client: return []
        try:
            resp = self.req_client.get_source_filter_list(source_name)
            raw_filters = getattr(resp, "filters", [])
            if isinstance(raw_filters, str):
                raw_filters = json.loads(raw_filters)
            
            filters_list = []
            for f in raw_filters:
                def read_field(obj, camel_key, snake_key, default=None):
                    if isinstance(obj, dict):
                        return obj.get(camel_key, obj.get(snake_key, default))
                    return getattr(obj, camel_key, getattr(obj, snake_key, default))

                filters_list.append({
                    "filterName": read_field(f, "filterName", "filter_name", ""),
                    "filterKind": read_field(f, "filterKind", "filter_kind", ""),
                    "filterEnabled": read_field(f, "filterEnabled", "filter_enabled", False),
                    "filterIndex": read_field(f, "filterIndex", "filter_index", 0),
                    "filterSettings": read_field(f, "filterSettings", "filter_settings", {})
                })
            return filters_list
        except Exception as e:
            print(f"Error getting filters for {source_name}: {e}")
            return []

    def set_source_filter_settings(self, source_name, filter_name, settings, overlay=True):
        if not self.req_client: return False
        try:
            self.req_client.set_source_filter_settings(source_name, filter_name, settings, overlay)
            return True
        except Exception as e:
            print(f"Error setting filter settings for {source_name}/{filter_name}: {e}")
            return False

    def create_source_filter(self, source_name, filter_name, filter_kind, settings=None):
        if not self.req_client: return False
        try:
            self.req_client.create_source_filter(source_name, filter_name, filter_kind, settings)
            return True
        except Exception as e:
            print(f"Error creating filter for {source_name}/{filter_name}: {e}")
            return False

    def remove_source_filter(self, source_name, filter_name):
        if not self.req_client: return False
        try:
            self.req_client.remove_source_filter(source_name, filter_name)
            return True
        except Exception as e:
            print(f"Error removing filter from {source_name}/{filter_name}: {e}")
            return False

    def set_source_filter_enabled(self, source_name, filter_name, enabled):
        if not self.req_client: return False
        try:
            self.req_client.set_source_filter_enabled(source_name, filter_name, enabled)
            return True
        except Exception as e:
            print(f"Error setting filter enabled for {source_name}/{filter_name}: {e}")
            return False

    def add_image_source(self, scene_name, source_name, file_path):
        if not self.req_client: return False
        try:
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind="image_source",
                inputSettings={
                    "file": file_path
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating image input: {e}")
            return False

    def add_text_source(self, scene_name, source_name, text_value):
        if not self.req_client: return False
        text_kind = "text_gdiplus_v3"  # default fallback
        try:
            # Deteksi versi GDI+ Text yang aktif secara dinamis (misal v3 di OBS 30.1+)
            try:
                kinds_resp = self.req_client.get_input_kind_list(unversioned=False)
                kinds = getattr(kinds_resp, "input_kinds", [])
                for k in kinds:
                    if k.startswith("text_gdiplus"):
                        text_kind = k
                        break
            except Exception:
                pass
                
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind=text_kind,
                inputSettings={
                    "text": text_value,
                    "font": {
                        "face": "Arial",
                        "size": 120,
                        "style": "Regular"
                    }
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating text input of kind {text_kind}: {e}")
            return False

    def add_browser_source(self, scene_name, source_name, url, width=800, height=600):
        if not self.req_client: return False
        try:
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind="browser_source",
                inputSettings={
                    "url": url,
                    "width": int(width),
                    "height": int(height)
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating browser source: {e}")
            return False

    def add_media_source(self, scene_name, source_name, file_path):
        if not self.req_client: return False
        try:
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind="ffmpeg_source",
                inputSettings={
                    "local_file": file_path
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating media source: {e}")
            return False

    def add_audio_stream(self, scene_name, source_name, url):
        if not self.req_client: return False
        try:
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind="ffmpeg_source",
                inputSettings={
                    "is_local_file": False,
                    "input": url,
                    "restart_on_activate": True
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating audio stream source: {e}")
            return False

    def find_streamer_mic_source(self, username):
        if not self.req_client: return None
        try:
            inputs = self.req_client.get_input_list()
            for inp in getattr(inputs, "inputs", []):
                if isinstance(inp, dict):
                    kind = inp.get("inputKind", inp.get("input_kind", ""))
                    name = inp.get("inputName", inp.get("input_name", ""))
                else:
                    kind = getattr(inp, "inputKind", getattr(inp, "input_kind", ""))
                    name = getattr(inp, "inputName", getattr(inp, "input_name", ""))
                
                if kind == "ffmpeg_source":
                    try:
                        settings_resp = self.req_client.get_input_settings(name)
                        settings = getattr(settings_resp, "input_settings", {})
                        url = settings.get("input", "")
                        if f"/audio_stream/{username}" in url:
                            return name
                    except Exception:
                        pass
            return None
        except Exception as e:
            print(f"Error finding streamer mic source for {username}: {e}")
            return None

    def add_window_capture(self, scene_name, source_name, window_id):
        if not self.req_client: return False
        try:
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind="window_capture",
                inputSettings={
                    "window": window_id
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating window capture: {e}")
            return False

    def add_application_audio_capture(self, scene_name, source_name, window_id):
        if not self.req_client: return False
        try:
            self.req_client.create_input(
                sceneName=scene_name,
                inputName=source_name,
                inputKind="wasapi_process_output_capture",
                inputSettings={
                    "window": window_id
                },
                sceneItemEnabled=True
            )
            return True
        except Exception as e:
            print(f"Error creating application audio capture: {e}")
            return False

# Database MySQL credentials (loaded from env)
MYSQL_HOST = os.environ.get('DB_HOST', 'localhost')
MYSQL_USER = os.environ.get('DB_USER', 'root')
MYSQL_PASSWORD = os.environ.get('DB_PASSWORD', '')
MYSQL_DB = os.environ.get('DB_DATABASE', 'obs_stream')
MYSQL_PORT = int(os.environ.get('DB_PORT', 3306))

# API Base URL for external services (loaded from env)
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://192.168.18.195:8080')

# Stream Base URL for HLS video mirror (loaded from env)
STREAM_BASE_URL = os.environ.get('STREAM_BASE_URL', 'https://larapy8.ngrok.io')

def get_db_connection(select_db=True):
    if pymysql is None:
        raise Exception("pymysql is not installed. Run 'pip install pymysql'")
    
    return pymysql.connect(
        host=MYSQL_HOST,
        user=MYSQL_USER,
        password=MYSQL_PASSWORD,
        database=MYSQL_DB if select_db else None,
        port=MYSQL_PORT,
        cursorclass=pymysql.cursors.DictCursor
    )

def init_db():
    if pymysql is None:
        print("[-] Warning: pymysql is not installed. Cannot initialize MySQL DB.")
        return
        
    try:
        # 1. Hubungkan ke MySQL Server (tanpa memilih database) untuk membuat DB jika belum ada
        conn = pymysql.connect(
            host=MYSQL_HOST,
            user=MYSQL_USER,
            password=MYSQL_PASSWORD,
            port=MYSQL_PORT
        )
        cursor = conn.cursor()
        cursor.execute(f"CREATE DATABASE IF NOT EXISTS {MYSQL_DB}")
        conn.commit()
        conn.close()
        
        # 2. Hubungkan ke database terpilih dan buat tabel
        conn = get_db_connection(select_db=True)
        cursor = conn.cursor()
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS obs_connections (
                id VARCHAR(50) PRIMARY KEY,
                name VARCHAR(100) NOT NULL,
                host VARCHAR(255) NOT NULL,
                port INT NOT NULL,
                password VARCHAR(255),
                stream_name VARCHAR(100),
                streamer VARCHAR(100)
            )
        """)
        conn.commit()

        # Alter table host column in case it already exists with shorter length
        try:
            cursor.execute("ALTER TABLE obs_connections MODIFY COLUMN host VARCHAR(255) NOT NULL")
            conn.commit()
            print("[+] Berhasil mengubah panjang kolom host pada obs_connections")
        except Exception as alter_err:
            print(f"[*] Info: Alter column host: {alter_err}")
        
        # Check if the table is empty, if so do JSON migration or default seed
        cursor.execute("SELECT count(*) as cnt FROM obs_connections")
        count = cursor.fetchone()["cnt"]
        if count == 0:
            json_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'obs_connections.json')
            json_backup_path = json_path + ".backup"
            actual_json_path = None
            if os.path.exists(json_path):
                actual_json_path = json_path
            elif os.path.exists(json_backup_path):
                actual_json_path = json_backup_path
                
            if actual_json_path:
                print(f"[*] Mengisi DB MySQL dari file JSON '{actual_json_path}'...")
                try:
                    with open(actual_json_path, 'r', encoding='utf-8') as f:
                        conns = json.load(f)
                    for c in conns:
                        cursor.execute("""
                            INSERT INTO obs_connections (id, name, host, port, password, stream_name, streamer)
                            VALUES (%s, %s, %s, %s, %s, %s, %s)
                        """, (
                            c["id"],
                            c["name"],
                            c["host"],
                            int(c["port"]),
                            c.get("password"),
                            c.get("stream_name"),
                            c.get("streamer")
                        ))
                    conn.commit()
                    # Backup JSON
                    if actual_json_path == json_path:
                        os.rename(json_path, json_backup_path)
                except Exception as json_err:
                    print(f"Error self-migrating JSON to MySQL inside app: {json_err}")
            else:
                # Default seed
                print("[*] Mengisi DB MySQL dengan data default...")
                cursor.execute("""
                    INSERT INTO obs_connections (id, name, host, port, password, stream_name, streamer)
                    VALUES (%s, %s, %s, %s, %s, %s, %s)
                """, ("obs_default", "OBS Default", "127.0.0.1", 4455, "123456", "worldcup26", "streamer"))
                conn.commit()
        conn.close()
    except Exception as e:
        print(f"Error initializing MySQL DB on startup: {e}")

# Inisialisasi DB di startup
init_db()

def load_connections():
    try:
        conn = get_db_connection(select_db=True)
        cursor = conn.cursor()
        cursor.execute("SELECT id, name, host, port, password, stream_name, streamer FROM obs_connections")
        rows = cursor.fetchall()
        conn.close()
        
        conns = []
        for row in rows:
            conns.append({
                "id": row["id"],
                "name": row["name"],
                "host": row["host"],
                "port": row["port"],
                "password": row["password"],
                "stream_name": row["stream_name"],
                "streamer": row["streamer"]
            })
        return conns
    except Exception as e:
        print(f"Error loading connections: {e}")
        return []

def save_connections(conns):
    try:
        conn = get_db_connection(select_db=True)
        cursor = conn.cursor()
        cursor.execute("DELETE FROM obs_connections")
        for c in conns:
            cursor.execute("""
                INSERT INTO obs_connections (id, name, host, port, password, stream_name, streamer)
                VALUES (%s, %s, %s, %s, %s, %s, %s)
            """, (
                c["id"],
                c["name"],
                c["host"],
                int(c["port"]),
                c.get("password"),
                c.get("stream_name"),
                c.get("streamer")
            ))
        conn.commit()
        conn.close()
    except Exception as e:
        print(f"Error saving connections: {e}")

def update_connection_stream_name(obs_id, stream_name):
    try:
        conn = get_db_connection(select_db=True)
        cursor = conn.cursor()
        cursor.execute("UPDATE obs_connections SET stream_name = %s WHERE id = %s", (stream_name, obs_id))
        conn.commit()
        conn.close()
    except Exception as e:
        print(f"Error updating connection stream name: {e}")

def save_single_connection(c):
    conn = get_db_connection(select_db=True)
    cursor = conn.cursor()
    cursor.execute("""
        INSERT INTO obs_connections (id, name, host, port, password, stream_name, streamer)
        VALUES (%s, %s, %s, %s, %s, %s, %s)
        ON DUPLICATE KEY UPDATE
            name = VALUES(name),
            host = VALUES(host),
            port = VALUES(port),
            password = VALUES(password),
            stream_name = VALUES(stream_name),
            streamer = VALUES(streamer)
    """, (
        c["id"],
        c["name"],
        c["host"],
        int(c["port"]),
        c.get("password"),
        c.get("stream_name"),
        c.get("streamer")
    ))
    conn.commit()
    conn.close()

def delete_connection_from_db(obs_id):
    conn = get_db_connection(select_db=True)
    cursor = conn.cursor()
    cursor.execute("DELETE FROM obs_connections WHERE id = %s", (obs_id,))
    conn.commit()
    conn.close()

# Dict global untuk melacak instansi OBSWebManager
obs_managers = {}

def add_or_update_manager(conn, connect_auto=True):
    obs_id = conn["id"]
    if obs_id not in obs_managers:
        manager = OBSWebManager(obs_id, broadcast_msg)
        obs_managers[obs_id] = manager
        manager.start_status_poller()
        manager.start_screenshot_poller()
    else:
        manager = obs_managers[obs_id]
    
    manager.name = conn.get("name", "OBS Stream")
    old_stream_name = getattr(manager, "stream_name", "")
    manager.stream_name = conn.get("stream_name", "")
    manager.streamer = conn.get("streamer", "")
    
    if connect_auto:
        # Hubungkan jika belum terhubung atau parameter berubah
        if (not manager.is_connected() or 
                manager.host != conn["host"] or 
                manager.port != int(conn["port"]) or 
                manager.password != conn["password"]):
            print(f"[*] Menghubungkan {conn['name']} ({obs_id}) ke {conn['host']}:{conn['port']}...")
            t = threading.Thread(target=manager.connect, args=(conn["host"], int(conn["port"]), conn["password"]), daemon=True)
            t.start()
        else:
            # Jika parameter koneksi sama tetapi stream_name berubah, sinkronkan stream key secara asinkron
            if old_stream_name != manager.stream_name:
                t = threading.Thread(target=manager.sync_stream_key, daemon=True)
                t.start()
    else:
        # Simpan saja parameternya tanpa mencoba connect
        manager.host = conn["host"]
        manager.port = int(conn["port"])
        manager.password = conn["password"]

def delete_manager(obs_id):
    if obs_id in obs_managers:
        manager = obs_managers[obs_id]
        manager.disconnect()
        del obs_managers[obs_id]

def init_obs_managers():
    conns = load_connections()
    for conn in conns:
        add_or_update_manager(conn, connect_auto=False)

# Inisialisasi semua koneksi OBS
init_obs_managers()

# === FLASK ROUTES ===

@app.before_request
def require_login():
    if request.endpoint in ['login', 'static', 'api_get_connections', 'audio_stream'] or request.path.startswith('/static/'):
        return
    
    if not session.get('logged_in'):
        if request.path.startswith('/api/'):
            return jsonify({"error": "Unauthorized"}), 401
        return redirect(url_for('login'))
    
    if session.get('role') != 'admin':
        is_control_route = request.path == '/' or request.path.startswith('/api/')
        if is_control_route:
            if request.path.startswith('/api/'):
                return jsonify({"error": "Forbidden"}), 403
            return redirect(url_for('streamer'))

def find_obs_id_by_identifier(identifier):
    conns = load_connections()
    for c in conns:
        if c.get("streamer") == identifier:
            return c["id"]
    for c in conns:
        if c["id"] == identifier:
            return c["id"]
    return identifier

@app.route('/audio_stream/<identifier>')
def audio_stream(identifier):
    # Endpoint untuk OBS menarik streaming audio mikrofon streamer
    obs_id = find_obs_id_by_identifier(identifier)
    q = get_audio_queue(obs_id)
    header = audio_headers.get(obs_id)
    
    def generate():
        if header:
            yield header
        while True:
            try:
                chunk = q.get(timeout=15)
                yield chunk
            except queue.Empty:
                break
            except GeneratorExit:
                break
                
    return Response(generate(), mimetype='audio/webm')

@app.route('/login', methods=['GET', 'POST'])
def login():
    if session.get('logged_in'):
        if session.get('role') == 'admin':
            return redirect(url_for('index'))
        else:
            return redirect(url_for('streamer'))
            
    error = None
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        try:
            api_url = f"{API_BASE_URL.rstrip('/')}/api/login"
            resp = requests.post(api_url, json={"username": username, "password": password}, timeout=5)
            
            if resp.status_code == 200:
                resp_data = resp.json()
                if resp_data.get("status"):
                    data = resp_data.get("data", {})
                    user_info = data.get("user", {})
                    roles = user_info.get("roles", [])
                    
                    session['logged_in'] = True
                    session['username'] = user_info.get("username", username)
                    session['access_token'] = data.get("access_token")
                    
                    if "admin" in roles:
                        session['role'] = 'admin'
                        return redirect(url_for('index'))
                    elif "host" in roles:
                        session['role'] = 'streamer'
                        return redirect(url_for('streamer'))
                    else:
                        session.clear()
                        error = 'Akses ditolak: Akun Anda tidak memiliki peran admin atau host!'
                else:
                    error = resp_data.get("message", "Username atau password salah!")
            else:
                try:
                    resp_data = resp.json()
                    error = resp_data.get("message", "Username atau password salah!")
                except Exception:
                    error = f"Gagal login (Server Error: {resp.status_code})"
        except Exception as e:
            print(f"Error authenticating user: {e}")
            error = "Gagal menghubungi server autentikasi eksternal!"
            
    return render_template('login.html', error=error)

@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('login'))

@app.route('/streamer')
def streamer():
    username = session.get('username')
    role = session.get('role')
    
    conns = load_connections()
    mapped_conn = None
    
    if role == 'admin':
        if conns:
            mapped_conn = conns[0]
    else:
        for c in conns:
            if c.get("streamer") == username:
                mapped_conn = c
                break
                
    if mapped_conn:
        stream_name = mapped_conn.get("stream_name", "")
        hls_stream_url = f"{STREAM_BASE_URL.rstrip('/')}/live/{stream_name}/index.m3u8" if stream_name else None
        return render_template('streamer_host.html', 
                               username=username, 
                               role=role, 
                               stream_name=stream_name,
                               obs_name=mapped_conn.get("name", "OBS Stream"),
                               obs_id=mapped_conn.get("id", "obs_default"),
                               hls_stream_url=hls_stream_url,
                               stream_base_url=STREAM_BASE_URL)
    else:
        return render_template('streamer_host.html', 
                               username=username, 
                               role=role, 
                               stream_name=None,
                               obs_name=None,
                               obs_id=None,
                               hls_stream_url=None,
                               stream_base_url=STREAM_BASE_URL)

@app.route('/')
def index():
    return render_template('index.html', username=session.get('username'), role=session.get('role'))

@app.route('/api/active_windows')
def api_active_windows():
    try:
        # Panggil fungsi ctypes Windows untuk membaca window berjalan
        wins = get_active_windows()
        return jsonify(wins)
    except Exception as e:
        return jsonify({"error": str(e)}), 500

@app.route('/api/hosts', methods=['GET'])
def api_get_hosts():
    if not session.get('logged_in') or session.get('role') != 'admin':
        return jsonify({"error": "Unauthorized"}), 401
        
    exclude_id = request.args.get("exclude_id", "")
    
    # 1. Ambil streamer yang sudah terdaftar pada koneksi lain
    try:
        conns = load_connections()
        registered_streamers = {c.get("streamer") for c in conns if c.get("streamer")}
        
        # Kecualikan streamer milik koneksi yang sedang diedit agar tetap muncul di dropdown
        if exclude_id:
            for c in conns:
                if c.get("id") == exclude_id:
                    registered_streamers.discard(c.get("streamer"))
    except Exception as e:
        print(f"Error loading connections for host check: {e}")
        registered_streamers = set()
        
    # 2. Ambil list dari API eksternal
    try:
        url = f"{API_BASE_URL.rstrip('/')}/api/users/hosts"
        resp = requests.get(url, timeout=3)
        if resp.status_code == 200:
            api_data = resp.json()
            if api_data.get("status") and "data" in api_data:
                hosts = api_data["data"]
                available_hosts = []
                for h in hosts:
                    username = h.get("username")
                    if username and username not in registered_streamers:
                        available_hosts.append({
                            "username": username,
                            "name": h.get("name", username)
                        })
                return jsonify(available_hosts)
    except Exception as e:
        print(f"Error fetching hosts from external API: {e}")
        
    return jsonify([])

@app.route('/api/connections', methods=['GET'])
def api_get_connections():
    if not session.get('logged_in') or session.get('role') != 'admin':
        return jsonify({"error": "Unauthorized"}), 401
    
    conns = load_connections()
    result = []
    for c in conns:
        manager = obs_managers.get(c["id"])
        connected = manager.is_connected() if manager else False
        result.append({
            **c,
            "connected": connected
        })
    return jsonify(result)

@app.route('/api/connections/save', methods=['POST'])
def api_save_connection():
    if not session.get('logged_in') or session.get('role') != 'admin':
        return jsonify({"error": "Unauthorized"}), 401
        
    data = request.json
    if not data or not data.get("name") or not data.get("host"):
        return jsonify({"error": "Parameter tidak lengkap"}), 400
        
    obs_id = data.get("id")
    if not obs_id:
        obs_id = f"obs_{int(time.time())}"
        data["id"] = obs_id
        
    port = data.get("port")
    if port is None or port == "":
        port = 4455
    try:
        data["port"] = int(port)
    except ValueError:
        return jsonify({"error": "Port harus berupa angka"}), 400
        
    try:
        save_single_connection(data)
        add_or_update_manager(data)
        return jsonify({"status": "success", "id": obs_id})
    except Exception as e:
        print(f"Error saving connection to database: {e}")
        return jsonify({"error": f"Gagal menyimpan koneksi ke database: {str(e)}"}), 500

@app.route('/api/connections/delete', methods=['POST'])
def api_delete_connection():
    if not session.get('logged_in') or session.get('role') != 'admin':
        return jsonify({"error": "Unauthorized"}), 401
        
    data = request.json
    obs_id = data.get("id")
    if not obs_id:
        return jsonify({"error": "ID diperlukan"}), 400
        
    try:
        delete_connection_from_db(obs_id)
        delete_manager(obs_id)
        return jsonify({"status": "success"})
    except Exception as e:
        print(f"Error deleting connection from database: {e}")
        return jsonify({"error": f"Gagal menghapus koneksi dari database: {str(e)}"}), 500

@app.route('/api/connections/toggle_connect', methods=['POST'])
def api_toggle_connect():
    if not session.get('logged_in') or session.get('role') != 'admin':
        return jsonify({"error": "Unauthorized"}), 401
        
    data = request.json
    obs_id = data.get("id")
    if not obs_id:
        return jsonify({"error": "ID diperlukan"}), 400
        
    manager = obs_managers.get(obs_id)
    if not manager:
        return jsonify({"error": "OBS Manager tidak ditemukan"}), 404
        
    if manager.is_connected():
        manager.disconnect()
        connected = False
    else:
        conns = load_connections()
        conn_config = next((c for c in conns if c["id"] == obs_id), None)
        if not conn_config:
            return jsonify({"error": "Konfigurasi tidak ditemukan"}), 404
        
        manager.name = conn_config.get("name", "OBS Stream")
        manager.stream_name = conn_config.get("stream_name", "")
        manager.streamer = conn_config.get("streamer", "")
        
        success = manager.connect(conn_config["host"], int(conn_config["port"]), conn_config["password"])
        connected = manager.is_connected()
        if not success:
            return jsonify({"status": "error", "error": "Gagal menghubungkan ke OBS"}), 500
            
    return jsonify({"status": "success", "connected": connected})

@app.route('/api/add_image_upload', methods=['POST'])
def add_image_upload():
    if 'file' not in request.files:
        return jsonify({"error": "File tidak ditemukan dalam request"}), 400
    
    file = request.files['file']
    source_name = request.form.get('source_name')
    scene_name = request.form.get('scene_name')
    obs_id = request.form.get('obs_id', 'obs_default')
    
    if not file or not source_name or not scene_name:
        return jsonify({"error": "Parameter tidak lengkap (file, source_name, scene_name diperlukan)"}), 400
    
    if file.filename == '':
        return jsonify({"error": "Nama file tidak valid"}), 400
    
    try:
        from werkzeug.utils import secure_filename
        filename = secure_filename(file.filename)
        name_parts = os.path.splitext(filename)
        unique_filename = f"{name_parts[0]}_{int(time.time())}{name_parts[1]}"
        
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
        file.save(filepath)
        
        abs_path = os.path.abspath(filepath)
        abs_path = abs_path.replace('\\', '/')
        
        manager = obs_managers.get(obs_id)
        if manager and manager.is_connected():
            success = manager.add_image_source(scene_name, source_name, abs_path)
            if success:
                time.sleep(0.3)
                items = manager.get_scene_items(scene_name)
                broadcast_msg(obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                return jsonify({"status": "success", "filepath": abs_path})
            else:
                return jsonify({"error": "Gagal menambahkan image input ke OBS Studio"}), 500
        else:
            return jsonify({"error": "Koneksi ke OBS Studio terputus"}), 500
            
    except Exception as e:
        print(f"Error in add_image_upload API: {e}")
        return jsonify({"error": str(e)}), 500

@app.route('/api/update_source_image', methods=['POST'])
def update_source_image():
    if 'file' not in request.files:
        return jsonify({"error": "File tidak ditemukan dalam request"}), 400
    
    file = request.files['file']
    source_name = request.form.get('source_name')
    scene_name = request.form.get('scene_name')
    obs_id = request.form.get('obs_id', 'obs_default')
    
    if not file or not source_name or not scene_name:
        return jsonify({"error": "Parameter tidak lengkap (file, source_name, scene_name diperlukan)"}), 400
    
    if file.filename == '':
        return jsonify({"error": "Nama file tidak valid"}), 400
    
    try:
        from werkzeug.utils import secure_filename
        filename = secure_filename(file.filename)
        name_parts = os.path.splitext(filename)
        unique_filename = f"{name_parts[0]}_{int(time.time())}{name_parts[1]}"
        
        filepath = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
        file.save(filepath)
        
        abs_path = os.path.abspath(filepath)
        abs_path = abs_path.replace('\\', '/')
        
        manager = obs_managers.get(obs_id)
        if manager and manager.is_connected():
            success = manager.set_input_settings(source_name, {"file": abs_path})
            if success:
                time.sleep(0.3)
                items = manager.get_scene_items(scene_name)
                broadcast_msg(obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                return jsonify({"status": "success", "filepath": abs_path})
            else:
                return jsonify({"error": "Gagal mengubah properti gambar di OBS Studio"}), 500
        else:
            return jsonify({"error": "Koneksi ke OBS Studio terputus"}), 500
            
    except Exception as e:
        print(f"Error in update_source_image API: {e}")
        return jsonify({"error": str(e)}), 500

# === WEBSOCKET CONTROL ===

# === WEBSOCKET CONTROL ===

@sock.route('/ws')
def ws_endpoint(ws):
    if not session.get('logged_in') or session.get('role') not in ['admin', 'streamer']:
        ws.close()
        return
    
    with ws_lock:
        active_websockets.add(ws)
        
    user_role = session.get('role')
    username = session.get('username')
    active_obs_id = "obs_default"
    
    if user_role == 'streamer':
        conns = load_connections()
        mapped_conn = next((c for c in conns if c.get("streamer") == username), None)
        if mapped_conn:
            active_obs_id = mapped_conn.get("id", "obs_default")
        else:
            ws.send(json.dumps({"event": "error", "message": "Akun Anda tidak dipetakan ke OBS mana pun."}))
            ws.close()
            return
            
    try:
        # Kirim status koneksi awal ke client
        manager = obs_managers.get(active_obs_id)
        if manager:
            initial_conn_status = {
                "obs_id": active_obs_id,
                "connected": manager.is_connected(),
                "host": manager.host,
                "port": manager.port,
                "video_settings": manager.get_video_settings() if manager.is_connected() else None
            }
            ws.send(json.dumps({"event": "connection_status", "obs_id": active_obs_id, "data": initial_conn_status}))
            
            if manager.is_connected():
                if user_role == 'admin':
                    scenes, current = manager.get_scenes()
                    studio_mode = getattr(manager, 'studio_mode_enabled', False)
                    preview_scene = getattr(manager, 'current_preview_scene', '')
                    ws.send(json.dumps({"event": "scene_list", "obs_id": active_obs_id, "data": {
                        "scenes": scenes,
                        "current": current,
                        "studio_mode": studio_mode,
                        "preview_scene": preview_scene
                    }}))
                    active_edit_scene = preview_scene if (studio_mode and preview_scene) else current
                    if active_edit_scene:
                        items = manager.get_scene_items(active_edit_scene)
                        ws.send(json.dumps({"event": "scene_items", "obs_id": active_obs_id, "data": {"scene": active_edit_scene, "items": items}}))
                
                audio = manager.get_audio_sources()
                ws.send(json.dumps({"event": "audio_sources", "obs_id": active_obs_id, "data": audio}))
                
                if user_role == 'admin':
                    status = manager.get_obs_status()
                    ws.send(json.dumps({"event": "obs_status", "obs_id": active_obs_id, "data": status}))
            
        while True:
            msg_str = ws.receive()
            if msg_str is None:
                break
            
            # Intercept binary audio data (microphone stream)
            if isinstance(msg_str, bytes):
                if user_role == 'streamer':
                    # Simpan block audio WebM pertama sebagai EBML header
                    if not audio_headers.get(active_obs_id):
                        audio_headers[active_obs_id] = msg_str
                        print(f"[+] Header WebM disimpan untuk OBS '{active_obs_id}'")
                    
                    q = get_audio_queue(active_obs_id)
                    try:
                        q.put_nowait(msg_str)
                    except queue.Full:
                        try:
                            q.get_nowait()
                            q.put_nowait(msg_str)
                        except Exception:
                            pass
                continue
            
            msg = json.loads(msg_str)
            event = msg.get("event")
            
            if user_role == 'streamer':
                msg_obs_id = active_obs_id
                # Streamers are allowed to adjust volume, trigger transition, toggle mic, and toggle mute
                if event not in ["set_volume", "studio_transition", "start_mic", "stop_mic", "toggle_mute"]:
                    continue
            else:
                msg_obs_id = msg.get("obs_id") or active_obs_id
                
            data = msg.get("data", {})
            
            if event == "start_mic":
                if user_role == 'streamer':
                    audio_headers[active_obs_id] = None
                    q = get_audio_queue(active_obs_id)
                    while not q.empty():
                        try:
                            q.get_nowait()
                        except queue.Empty:
                            break
                    print(f"[*] Streamer '{username}' memulai mic untuk OBS '{active_obs_id}'")
                    
                    manager = obs_managers.get(active_obs_id)
                    if manager and manager.is_connected():
                        mic_source = manager.find_streamer_mic_source(username)
                        if mic_source:
                            last_vol = getattr(manager, 'streamer_mic_volumes', {}).get(username, 0.8)
                            manager.set_volume(mic_source, last_vol)
                            # Kirim update audio sources agar UI ter-refresh
                            audio = manager.get_audio_sources()
                            broadcast_msg(active_obs_id, {"event": "audio_sources", "data": audio})
                            print(f"[+] Mic source '{mic_source}' volume dikembalikan ke {last_vol}")
                continue
                
            elif event == "stop_mic":
                if user_role == 'streamer':
                    print(f"[*] Streamer '{username}' mematikan mic untuk OBS '{active_obs_id}'")
                    manager = obs_managers.get(active_obs_id)
                    if manager and manager.is_connected():
                        mic_source = manager.find_streamer_mic_source(username)
                        if mic_source:
                            try:
                                vol_resp = manager.req_client.get_input_volume(mic_source)
                                current_vol = getattr(vol_resp, "input_volume_mul", 0.8)
                                if current_vol > 0.01:
                                    if not hasattr(manager, 'streamer_mic_volumes'):
                                        manager.streamer_mic_volumes = {}
                                    manager.streamer_mic_volumes[username] = current_vol
                                    print(f"[+] Menyimpan volume mic streamer '{username}': {current_vol}")
                            except Exception as e:
                                print(f"Error getting volume before stop_mic: {e}")
                            
                            manager.set_volume(mic_source, 0.0)
                            # Kirim update audio sources agar UI ter-refresh
                            audio = manager.get_audio_sources()
                            broadcast_msg(active_obs_id, {"event": "audio_sources", "data": audio})
                            print(f"[+] Mic source '{mic_source}' volume diturunkan ke 0.0")
                continue
            
            if event == "select_obs":
                if user_role == 'streamer':
                    continue  # Streamers cannot select/change OBS instances
                active_obs_id = data.get("obs_id")
                manager = obs_managers.get(active_obs_id)
                if manager:
                    status_data = {
                        "obs_id": active_obs_id,
                        "connected": manager.is_connected(),
                        "host": manager.host,
                        "port": manager.port,
                        "video_settings": manager.get_video_settings() if manager.is_connected() else None
                    }
                    ws.send(json.dumps({"event": "connection_status", "obs_id": active_obs_id, "data": status_data}))
                    if manager.is_connected():
                        scenes, current = manager.get_scenes()
                        studio_mode = getattr(manager, 'studio_mode_enabled', False)
                        preview_scene = getattr(manager, 'current_preview_scene', '')
                        ws.send(json.dumps({"event": "scene_list", "obs_id": active_obs_id, "data": {
                            "scenes": scenes,
                            "current": current,
                            "studio_mode": studio_mode,
                            "preview_scene": preview_scene
                        }}))
                        active_edit_scene = preview_scene if (studio_mode and preview_scene) else current
                        if active_edit_scene:
                            items = manager.get_scene_items(active_edit_scene)
                            ws.send(json.dumps({"event": "scene_items", "obs_id": active_obs_id, "data": {"scene": active_edit_scene, "items": items}}))
                        audio = manager.get_audio_sources()
                        ws.send(json.dumps({"event": "audio_sources", "obs_id": active_obs_id, "data": audio}))
                        status = manager.get_obs_status()
                        ws.send(json.dumps({"event": "obs_status", "obs_id": active_obs_id, "data": status}))
                continue
                
            # Dapatkan manager spesifik untuk obs_id yang dikirim
            manager = obs_managers.get(msg_obs_id)
            if not manager:
                continue
                
            if event == "connect":
                host = data.get("host", "127.0.0.1")
                port = int(data.get("port", 4455))
                password = data.get("password", "123456")
                
                connected = manager.connect(host, port, password)
                if not connected:
                    status_data = {
                        "connected": False,
                        "host": host,
                        "port": port,
                        "video_settings": None
                    }
                    broadcast_msg(msg_obs_id, {"event": "connection_status", "data": status_data})
                    
            elif event == "disconnect":
                manager.disconnect()
                
            elif event == "get_scene_items":
                scene_name = data.get("scene_name")
                if scene_name:
                    items = manager.get_scene_items(scene_name)
                    ws.send(json.dumps({"event": "scene_items", "obs_id": msg_obs_id, "data": {"scene": scene_name, "items": items}}))
                    
            elif event == "switch_scene":
                scene_name = data.get("scene_name")
                if scene_name:
                    manager.switch_scene(scene_name)
                    if getattr(manager, 'studio_mode_enabled', False):
                        broadcast_msg(msg_obs_id, {"event": "current_preview_scene_changed", "data": {"sceneName": scene_name}})
                    else:
                        broadcast_msg(msg_obs_id, {"event": "current_scene_changed", "data": {"sceneName": scene_name}})
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                    
            elif event == "toggle_studio_mode":
                enabled = data.get("enabled", False)
                try:
                    manager.req_client.set_studio_mode_enabled(enabled)
                    manager.studio_mode_enabled = enabled
                    
                    # Update local state
                    if enabled:
                        try:
                            prev_resp = manager.req_client.get_current_preview_scene()
                            manager.current_preview_scene = getattr(prev_resp, "current_preview_scene_name", getattr(prev_resp, "currentPreviewSceneName", ""))
                            manager.current_scene = manager.current_preview_scene
                        except Exception:
                            pass
                    else:
                        manager.current_scene = manager.current_program_scene

                    broadcast_msg(msg_obs_id, {
                        "event": "studio_mode_state_changed", 
                        "data": {
                            "enabled": enabled,
                            "previewScene": getattr(manager, 'current_preview_scene', ''),
                            "programScene": getattr(manager, 'current_program_scene', '')
                        }
                    })
                    
                    # Re-send current scene list and items
                    scenes, current = manager.get_scenes()
                    broadcast_msg(msg_obs_id, {"event": "scene_list", "data": {
                        "scenes": scenes,
                        "current": current,
                        "studio_mode": enabled,
                        "preview_scene": getattr(manager, 'current_preview_scene', '')
                    }})
                    
                    active_edit_scene = manager.current_preview_scene if (enabled and manager.current_preview_scene) else current
                    if active_edit_scene:
                        items = manager.get_scene_items(active_edit_scene)
                        broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": active_edit_scene, "items": items}})
                except Exception as e:
                    print(f"Error toggling studio mode: {e}")
                    
            elif event == "studio_transition":
                trans_type = data.get("transition_type")
                try:
                    if trans_type:
                        manager.req_client.set_current_scene_transition(trans_type)
                    manager.req_client.trigger_studio_mode_transition()
                except Exception as e:
                    print(f"Error triggering transition: {e}")
                    
            elif event == "update_item_transform":
                scene_name = data.get("scene_name")
                item_id = data.get("item_id")
                transform = data.get("transform")
                if scene_name and item_id is not None and transform:
                    manager.update_item_transform(scene_name, int(item_id), transform)
                    
            elif event == "set_scene_item_index":
                scene_name = data.get("scene_name")
                item_id = data.get("item_id")
                index = data.get("index")
                if scene_name and item_id is not None and index is not None:
                    manager.set_scene_item_index(scene_name, int(item_id), int(index))
                    
            elif event == "toggle_source_visibility":
                scene_name = data.get("scene_name")
                item_id = data.get("item_id")
                enabled = data.get("enabled")
                if scene_name and item_id is not None and enabled is not None:
                    manager.toggle_source_visibility(scene_name, int(item_id), enabled)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                    
            elif event == "toggle_source_lock":
                scene_name = data.get("scene_name")
                item_id = data.get("item_id")
                locked = data.get("locked")
                if scene_name and item_id is not None and locked is not None:
                    manager.toggle_source_lock(scene_name, int(item_id), locked)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                    
            elif event == "delete_source":
                scene_name = data.get("scene_name")
                item_id = data.get("item_id")
                if scene_name and item_id is not None:
                    manager.delete_source(scene_name, int(item_id))
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                    
            elif event == "toggle_stream":
                manager.toggle_stream()
                status = manager.get_obs_status()
                broadcast_msg(msg_obs_id, {"event": "obs_status", "data": status})
 
            elif event == "toggle_record":
                manager.toggle_record()
                status = manager.get_obs_status()
                broadcast_msg(msg_obs_id, {"event": "obs_status", "data": status})
 
            elif event == "toggle_mute":
                source_name = data.get("source_name")
                if source_name:
                    manager.toggle_mute(source_name)
                    audio = manager.get_audio_sources()
                    broadcast_msg(msg_obs_id, {"event": "audio_sources", "data": audio})
                    
            elif event == "set_volume":
                source_name = data.get("source_name")
                volume = data.get("volume")
                if source_name and volume is not None:
                    manager.set_volume(source_name, volume)
                    audio = manager.get_audio_sources()
                    broadcast_msg(msg_obs_id, {"event": "audio_sources", "data": audio})
                    
            elif event == "set_monitor_type":
                source_name = data.get("source_name")
                mon_type = data.get("monitor_type")
                if source_name and mon_type:
                    try:
                        manager.req_client.set_input_audio_monitor_type(source_name, mon_type)
                        audio = manager.get_audio_sources()
                        broadcast_msg(msg_obs_id, {"event": "audio_sources", "data": audio})
                    except Exception as e:
                        print(f"Error setting monitor type: {e}")
                        
            elif event == "set_sync_offset":
                source_name = data.get("source_name")
                offset = data.get("offset")
                if source_name and offset is not None:
                    try:
                        manager.req_client.set_input_audio_sync_offset(source_name, int(offset))
                        audio = manager.get_audio_sources()
                        broadcast_msg(msg_obs_id, {"event": "audio_sources", "data": audio})
                    except Exception as e:
                        print(f"Error setting sync offset: {e}")
                        
            elif event == "set_balance":
                source_name = data.get("source_name")
                balance = data.get("balance")
                if source_name and balance is not None:
                    try:
                        manager.req_client.set_input_audio_balance(source_name, float(balance))
                        audio = manager.get_audio_sources()
                        broadcast_msg(msg_obs_id, {"event": "audio_sources", "data": audio})
                    except Exception as e:
                        print(f"Error setting balance: {e}")
                        
            elif event == "get_obs_settings":
                settings = manager.get_obs_settings()
                if settings:
                    ws.send(json.dumps({"event": "obs_settings", "obs_id": msg_obs_id, "data": settings}))
                    
            elif event == "set_obs_settings":
                success = manager.set_obs_settings(data)
                if success:
                    settings = manager.get_obs_settings()
                    broadcast_msg(msg_obs_id, {"event": "obs_settings", "data": settings})
                    broadcast_msg(msg_obs_id, {"event": "connection_status", "data": {
                        "connected": manager.is_connected(),
                        "host": manager.host,
                        "port": manager.port,
                        "video_settings": manager.get_video_settings()
                    }})
                    
            elif event == "add_image_source":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                file_path = data.get("file_path")
                if scene_name and source_name and file_path:
                    manager.add_image_source(scene_name, source_name, file_path)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                    
            elif event == "add_text_source":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                text_value = data.get("text_value")
                if scene_name and source_name and text_value:
                    manager.add_text_source(scene_name, source_name, text_value)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
 
            elif event == "add_browser_source":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                url = data.get("url")
                width = int(data.get("width", 800))
                height = int(data.get("height", 600))
                if scene_name and source_name and url:
                    manager.add_browser_source(scene_name, source_name, url, width, height)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
 
            elif event == "add_media_source":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                file_path = data.get("file_path")
                if scene_name and source_name and file_path:
                    manager.add_media_source(scene_name, source_name, file_path)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
 
            elif event == "add_audio_stream":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                url = data.get("url")
                if scene_name and source_name and url:
                    manager.add_audio_stream(scene_name, source_name, url)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
 
            elif event == "add_window_capture":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                window_id = data.get("window_id")
                if scene_name and source_name and window_id:
                    manager.add_window_capture(scene_name, source_name, window_id)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
 
            elif event == "add_application_audio_capture":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                window_id = data.get("window_id")
                if scene_name and source_name and window_id:
                    manager.add_application_audio_capture(scene_name, source_name, window_id)
                    time.sleep(0.2)
                    items = manager.get_scene_items(scene_name)
                    broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
                    audio = manager.get_audio_sources()
                    broadcast_msg(msg_obs_id, {"event": "audio_sources", "data": audio})
 
            elif event == "get_source_properties":
                source_name = data.get("source_name")
                if source_name:
                    settings, kind = manager.get_input_settings(source_name)
                    filters = manager.get_source_filters(source_name)
                    ws.send(json.dumps({
                        "event": "source_properties",
                        "obs_id": msg_obs_id,
                        "data": {
                            "source_name": source_name,
                            "input_kind": kind,
                            "settings": settings,
                            "filters": filters
                        }
                    }))
 
            elif event == "update_source_properties":
                scene_name = data.get("scene_name")
                source_name = data.get("source_name")
                input_kind = data.get("input_kind")
                settings = data.get("settings", {})
                
                print(f"[*] Received update_source_properties for '{source_name}' on OBS '{msg_obs_id}': settings={settings}")
                
                if "width" in settings:
                    settings["width"] = int(settings["width"])
                if "height" in settings:
                    settings["height"] = int(settings["height"])
                
                scroll_filter_data = data.get("scroll_filter")
                
                if source_name:
                    manager.set_input_settings(source_name, settings)
                    
                    if scroll_filter_data:
                        enabled = scroll_filter_data.get("enabled", False)
                        speed_x = int(scroll_filter_data.get("speed_x", 0))
                        speed_y = int(scroll_filter_data.get("speed_y", 0))
                        
                        existing_filters = manager.get_source_filters(source_name)
                        scroll_filter_name = "Scroll_Filter"
                        
                        has_scroll = False
                        for f in existing_filters:
                            if f.get("filterName") == scroll_filter_name or f.get("filterKind") == "scroll_filter":
                                scroll_filter_name = f.get("filterName")
                                has_scroll = True
                                break
                        
                        if enabled:
                            filter_settings = {"speed_x": speed_x, "speed_y": speed_y}
                            if not has_scroll:
                                manager.create_source_filter(source_name, scroll_filter_name, "scroll_filter", filter_settings)
                            else:
                                manager.set_source_filter_settings(source_name, scroll_filter_name, filter_settings)
                                manager.set_source_filter_enabled(source_name, scroll_filter_name, True)
                        else:
                            if has_scroll:
                                manager.set_source_filter_enabled(source_name, scroll_filter_name, False)
                                manager.set_source_filter_settings(source_name, scroll_filter_name, {"speed_x": 0, "speed_y": 0})
                    
                    time.sleep(0.2)
                    if scene_name:
                        items = manager.get_scene_items(scene_name)
                        broadcast_msg(msg_obs_id, {"event": "scene_items", "data": {"scene": scene_name, "items": items}})
 
    except Exception as e:
        print(f"[-] WebSocket loop exception: {e}")
    finally:
        with ws_lock:
            if ws in active_websockets:
                active_websockets.remove(ws)

if __name__ == '__main__':
    print("[*] Memulai Flask web server pada http://127.0.0.1:5000/")
    app.run(host='0.0.0.0', port=5000, debug=False)
