Files
script.service.ultimate/lib/streaming_providers/base/ui/kodi_notification_adapter.py
T
2026-01-16 12:24:41 +01:00

636 lines
20 KiB
Python

"""
Kodi notification adapter with threaded polling support
Architecture:
1. Starts polling in background thread
2. Shows QR code dialog (blocking)
3. Dialog monitors thread status and auto-closes on success
4. User can cancel by closing dialog
"""
import os
import tempfile
import threading
import time
from typing import Callable, Optional
from ..utils.logger import logger
from .notification_interface import NotificationInterface, NotificationResult
# Import QR generator
try:
from .qr_generator import generate_qr_code_png
QR_GENERATOR_AVAILABLE = True
except ImportError:
logger.warning("qr_generator not available")
QR_GENERATOR_AVAILABLE = False
class PollingThread(threading.Thread):
"""
Background thread for polling authentication status
"""
def __init__(self, poll_callback: Callable, expires_in: int, interval: int):
"""
Initialize polling thread
Args:
poll_callback: Function to call for polling (returns token_data or None)
expires_in: Total time before expiration
interval: Polling interval in seconds
"""
super().__init__(daemon=True)
self.poll_callback = poll_callback
self.expires_in = expires_in
self.interval = interval
self.auth_completed = False
self.token_data = None
self.error = None
self.stop_event = threading.Event()
self.start_time = None
def run(self):
"""Run polling loop"""
self.start_time = time.time()
logger.info("Polling thread started")
try:
# Call the polling callback (blocking)
self.token_data = self.poll_callback()
if self.token_data:
self.auth_completed = True
logger.info("Polling thread: Authentication successful")
else:
logger.warning("Polling thread: Authentication failed/timed out")
except Exception as e:
logger.error(f"Polling thread error: {e}", exc_info=True)
self.error = str(e)
def stop(self):
"""Signal thread to stop"""
self.stop_event.set()
def get_remaining_time(self) -> int:
"""Get remaining time in seconds"""
if not self.start_time:
return self.expires_in
elapsed = time.time() - self.start_time
return max(0, int(self.expires_in - elapsed))
class QRCodeDialog:
"""
WindowDialog for displaying QR code with status monitoring
"""
def __init__(
self,
xbmcgui,
xbmc,
qr_image_path: str,
login_code: str,
expires_in: int,
polling_thread: Optional[PollingThread] = None,
):
"""
Initialize QR code dialog
Args:
xbmcgui: xbmcgui module
xbmc: xbmc module
qr_image_path: Path to QR code PNG file
login_code: Login code for manual entry
expires_in: Expiration time in seconds
polling_thread: Optional polling thread to monitor
"""
self.xbmcgui = xbmcgui
self.xbmc = xbmc
self.qr_image_path = qr_image_path
self.login_code = login_code
self.expires_in = expires_in
self.polling_thread = polling_thread
self.dialog = None
self.user_closed = False
self.time_label = None
self.status_label = None
# Background monitoring thread
self.monitor_thread = None
self.monitor_stop = threading.Event()
def show(self):
"""Show the QR code dialog"""
try:
# Create WindowDialog
self.dialog = self.xbmcgui.WindowDialog()
screen_width = self.dialog.getWidth()
screen_height = self.dialog.getHeight()
# Calculate layout
dialog_width = int(screen_width * 0.8)
dialog_height = int(screen_height * 0.8)
dialog_x = (screen_width - dialog_width) // 2
dialog_y = (screen_height - dialog_height) // 2
# Background - Use a solid color label instead of ControlImage
bg = self.xbmcgui.ControlLabel(
dialog_x, dialog_y, dialog_width, dialog_height, label=""
)
# Set semi-transparent black background
try:
bg.setColorDiffuse("0xE0000000")
except:
pass # Ignore if method not available
self.dialog.addControl(bg)
# Title
title_y = dialog_y + 30
title = self.xbmcgui.ControlLabel(
x=dialog_x + 50,
y=title_y,
width=dialog_width - 100,
height=50,
label="[B]MagentaTV Remote Login[/B]",
font="font30",
textColor="0xFFFFFFFF",
alignment=0x00000002, # Center
)
self.dialog.addControl(title)
# QR Code
qr_size = min(dialog_width // 2, dialog_height // 2, 400)
qr_x = (screen_width - qr_size) // 2
qr_y = title_y + 70
if os.path.exists(self.qr_image_path):
qr_image = self.xbmcgui.ControlImage(
qr_x, qr_y, qr_size, qr_size, self.qr_image_path
)
self.dialog.addControl(qr_image)
else:
logger.error(f"QR image not found: {self.qr_image_path}")
# Instructions
instructions_y = qr_y + qr_size + 40
inst1 = self.xbmcgui.ControlLabel(
x=dialog_x + 50,
y=instructions_y,
width=dialog_width - 100,
height=30,
label="[B]Scan QR code with your MagentaTV app[/B]",
font="font13",
textColor="0xFFFFFFFF",
alignment=0x00000002,
)
self.dialog.addControl(inst1)
inst2_y = instructions_y + 35
inst2 = self.xbmcgui.ControlLabel(
x=dialog_x + 50,
y=inst2_y,
width=dialog_width - 100,
height=30,
label=f"Or enter code: [COLOR yellow]{self.login_code}[/COLOR]",
font="font13",
textColor="0xFFCCCCCC",
alignment=0x00000002,
)
self.dialog.addControl(inst2)
# Time remaining label
time_y = inst2_y + 45
self.time_label = self.xbmcgui.ControlLabel(
x=dialog_x + 50,
y=time_y,
width=dialog_width - 100,
height=30,
label=f"Time remaining: {self._format_time(self.expires_in)}",
font="font12",
textColor="0xFFFF8800",
alignment=0x00000002,
)
self.dialog.addControl(self.time_label)
# Status label
status_y = time_y + 35
self.status_label = self.xbmcgui.ControlLabel(
x=dialog_x + 50,
y=status_y,
width=dialog_width - 100,
height=30,
label="Waiting for authentication...",
font="font12",
textColor="0xFFAAAAAA",
alignment=0x00000002,
)
self.dialog.addControl(self.status_label)
# Cancel hint
cancel_y = status_y + 35
cancel = self.xbmcgui.ControlLabel(
x=dialog_x + 50,
y=cancel_y,
width=dialog_width - 100,
height=25,
label="(Press any key to cancel)",
font="font10",
textColor="0xFF888888",
alignment=0x00000002,
)
self.dialog.addControl(cancel)
# Start background monitor if we have polling thread
if self.polling_thread:
self._start_monitor()
# Show dialog (blocking)
logger.info("Showing QR code dialog")
self.dialog.doModal()
# Dialog closed
self.user_closed = True
self._stop_monitor()
logger.info("QR code dialog closed by user")
except Exception as e:
logger.error(f"Failed to show QR code dialog: {e}", exc_info=True)
finally:
self._cleanup()
def _start_monitor(self):
"""Start background monitor thread"""
self.monitor_stop.clear()
self.monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
self.monitor_thread.start()
logger.debug("Started dialog monitor thread")
def _stop_monitor(self):
"""Stop background monitor thread"""
self.monitor_stop.set()
if self.monitor_thread:
self.monitor_thread.join(timeout=1.0)
logger.debug("Stopped dialog monitor thread")
def _monitor_loop(self):
"""Monitor polling thread status and update UI"""
while not self.monitor_stop.is_set():
try:
# Update countdown
if self.polling_thread:
remaining = self.polling_thread.get_remaining_time()
if self.time_label:
self.time_label.setLabel(f"Time remaining: {self._format_time(remaining)}")
# Check if auth completed
if self.polling_thread.auth_completed:
logger.info("Monitor: Authentication completed, closing dialog")
if self.status_label:
self.status_label.setLabel(
"[COLOR green]Authentication successful![/COLOR]"
)
time.sleep(1) # Show success message briefly
self.close_dialog()
break
# Check for errors
if self.polling_thread.error:
logger.error(f"Monitor: Polling error: {self.polling_thread.error}")
if self.status_label:
self.status_label.setLabel("[COLOR red]Authentication failed[/COLOR]")
time.sleep(2)
self.close_dialog()
break
# Sleep briefly
time.sleep(0.5)
except Exception as e:
logger.error(f"Monitor loop error: {e}")
break
def close_dialog(self):
"""Close dialog programmatically"""
if self.dialog:
try:
self.dialog.close()
except:
pass
def _cleanup(self):
"""Cleanup dialog resources"""
if self.dialog:
try:
del self.dialog
except:
pass
self.dialog = None
@staticmethod
def _format_time(seconds: int) -> str:
"""Format seconds as MM:SS"""
if seconds <= 0:
return "Expired"
minutes = seconds // 60
secs = seconds % 60
return f"{minutes:02d}:{secs:02d}"
class KodiNotificationAdapter(NotificationInterface):
"""
Kodi notification adapter with fast QR generation and threading
Architecture:
1. Generate QR code directly from target URL (fast)
2. Start polling in background thread
3. Show dialog that monitors thread and auto-closes on success
"""
def __init__(self, http_manager=None):
"""
Initialize Kodi notification adapter
Args:
http_manager: Optional HTTPManager instance
"""
super().__init__()
# Import Kodi modules
try:
import xbmc
import xbmcgui
import xbmcvfs
self.xbmcgui = xbmcgui
self.xbmc = xbmc
self.xbmcvfs = xbmcvfs
self._kodi_available = True
except ImportError as e:
logger.error(f"Kodi modules not available: {e}")
self._kodi_available = False
raise RuntimeError("Kodi modules not available")
self._qr_dialog = None
self._qr_image_path = None
self._http_manager = http_manager
self._polling_thread = None
@property
def supports_qr_display(self) -> bool:
"""Kodi can display QR codes"""
return True
@property
def supports_countdown(self) -> bool:
"""Kodi supports countdown via monitor thread"""
return True
@property
def is_blocking(self) -> bool:
"""Dialog is blocking but polling happens in thread"""
return True # From caller's perspective, it blocks
def show_remote_login_with_polling(
self,
login_code: str,
qr_target_url: str,
expires_in: int,
interval: int,
poll_callback: Callable,
) -> NotificationResult:
"""
Show remote login with integrated polling
This is the main method that coordinates everything:
1. Generate QR code from target URL (fast!)
2. Start polling thread
3. Show dialog (blocking, but thread runs)
4. Return result based on outcome
Args:
login_code: Short login code
qr_target_url: The URL to encode in QR code (NOT the SVG URL!)
expires_in: Expiration time in seconds
interval: Polling interval
poll_callback: Function to call for polling
Returns:
NotificationResult
"""
if not self._kodi_available:
return NotificationResult.ERROR
self._is_active = True
self._is_cancelled = False
try:
# Step 1: Generate QR code (fast!)
qr_image_path = self._generate_qr_code(qr_target_url)
if not qr_image_path:
logger.error("Failed to generate QR code")
return NotificationResult.ERROR
self._qr_image_path = qr_image_path
# Step 2: Start polling thread
self._polling_thread = PollingThread(poll_callback, expires_in, interval)
self._polling_thread.start()
logger.info("Started polling thread")
# Step 3: Show dialog (blocking, but thread runs)
self._qr_dialog = QRCodeDialog(
self.xbmcgui,
self.xbmc,
qr_image_path,
login_code,
expires_in,
self._polling_thread,
)
self._qr_dialog.show()
# Step 4: Determine outcome
if self._polling_thread.auth_completed:
logger.info("Authentication successful")
return NotificationResult.CONTINUE
elif self._qr_dialog.user_closed:
logger.info("User cancelled")
self._is_cancelled = True
return NotificationResult.CANCELLED
else:
logger.warning("Authentication timed out")
return NotificationResult.TIMEOUT
except Exception as e:
logger.error(f"Failed to show remote login: {e}", exc_info=True)
return NotificationResult.ERROR
finally:
self._is_active = False
self._cleanup_qr_image()
def show_remote_login(
self, login_code: str, qr_target_url: str, expires_in: int, interval: int = 10
) -> NotificationResult:
"""
Simplified version without polling (for backward compatibility)
Just shows the dialog, no polling
Args:
login_code: Short login code
qr_target_url: The URL to encode in QR code
expires_in: Expiration time
interval: Update interval (unused in this version)
"""
if not self._kodi_available:
return NotificationResult.ERROR
self._is_active = True
self._is_cancelled = False
try:
qr_image_path = self._generate_qr_code(qr_target_url)
if not qr_image_path:
return NotificationResult.ERROR
self._qr_image_path = qr_image_path
self._qr_dialog = QRCodeDialog(
self.xbmcgui,
self.xbmc,
qr_image_path,
login_code,
expires_in,
None, # No polling thread
)
self._qr_dialog.show()
if self._qr_dialog.user_closed:
self._is_cancelled = True
return NotificationResult.CANCELLED
return NotificationResult.CONTINUE
except Exception as e:
logger.error(f"Failed to show QR dialog: {e}", exc_info=True)
return NotificationResult.ERROR
finally:
self._is_active = False
self._cleanup_qr_image()
def update_countdown(self, remaining_seconds: int) -> bool:
"""Check if user cancelled"""
if self._qr_dialog and self._qr_dialog.user_closed:
self._is_cancelled = True
return False
return not self._is_cancelled
def close(self, success: bool = False, message: Optional[str] = None):
"""Close and show notification"""
if not self._is_active:
return
self._is_active = False
try:
if self._qr_dialog:
self._qr_dialog.close_dialog()
self._qr_dialog = None
# Show result
if success:
self.xbmcgui.Dialog().notification(
"MagentaTV",
"Remote login successful!",
self.xbmcgui.NOTIFICATION_INFO,
3000,
)
elif message and not self._is_cancelled:
self.xbmcgui.Dialog().notification(
"MagentaTV",
f"Remote login failed: {message}",
self.xbmcgui.NOTIFICATION_ERROR,
5000,
)
self._cleanup_qr_image()
except Exception as e:
logger.error(f"Failed to close dialog: {e}")
def is_cancelled(self) -> bool:
"""Check if cancelled"""
return self._is_cancelled
def get_token_data(self) -> Optional[dict]:
"""Get token data from polling thread"""
if self._polling_thread:
return self._polling_thread.token_data
return None
@staticmethod
def _generate_qr_code(target_url: str) -> Optional[str]:
"""
Generate QR code PNG file from target URL
This is GENERIC - works for any provider!
No provider-specific logic here.
Args:
target_url: The URL to encode in the QR code
Returns:
Path to PNG file
"""
try:
if not QR_GENERATOR_AVAILABLE:
logger.error("QR generator not available")
return None
logger.info(f"Generating QR code for: {target_url}")
start_time = time.time()
# Generate QR code directly from target URL
png_data = generate_qr_code_png(target_url, size=512)
if not png_data:
logger.error("Failed to generate QR code PNG")
return None
elapsed = time.time() - start_time
logger.info(f"Generated QR code in {elapsed:.2f}s: {len(png_data)} bytes")
# Save to temp file
temp_dir = tempfile.gettempdir()
png_filename = f"magentatv_qr_{int(time.time())}.png"
png_path = os.path.join(temp_dir, png_filename)
with open(png_path, "wb") as f:
f.write(png_data)
logger.info(f"QR code saved to: {png_path}")
return png_path
except Exception as e:
logger.error(f"Failed to generate QR code: {e}", exc_info=True)
return None
def _cleanup_qr_image(self):
"""Cleanup temporary QR image"""
if self._qr_image_path and os.path.exists(self._qr_image_path):
try:
os.remove(self._qr_image_path)
logger.debug(f"Cleaned up QR image: {self._qr_image_path}")
except Exception as e:
logger.warning(f"Failed to cleanup QR image: {e}")
finally:
self._qr_image_path = None