Files
wizarr/app/activity/monitoring/session_manager.py
Matthieu B 73c29458fe fix: resolve all test failures, linting errors, and type errors
- Fix 55 test failures caused by missing request contexts and incorrect
  session_transaction() usage across 8 test files
- Fix ruff import sorting errors and unused imports
- Fix 122 type errors: rename method override parameters to match base
  classes, add None guards for fetchone()/datetime, widen dict type
  annotations, add type: ignore for SQLAlchemy stub limitations
- Add [tool.ty.rules] config to suppress unsupported-base warnings
- Fix _ variable shadowing gettext in wizard routes
- Add noqa: ARG002 for unused method arguments required by base class
2026-03-29 16:20:23 +02:00

883 lines
34 KiB
Python

"""
Session lifecycle management with state tracking and automatic cleanup.
"""
from dataclasses import dataclass
from datetime import UTC, datetime
from enum import Enum
from threading import Lock
from typing import Any
import structlog
from ..domain.models import ActivityEvent
class SessionState(Enum):
"""Possible session states."""
PLAYING = "playing"
PAUSED = "paused"
STOPPED = "stopped"
BUFFERING = "buffering"
ERROR = "error"
UNKNOWN = "unknown"
@dataclass
class SessionTransition:
"""Represents a state transition for a session."""
session_key: str
from_state: SessionState | None
to_state: SessionState
timestamp: datetime
view_offset: int | None = None
metadata: dict[str, Any] | None = None
class SessionManager:
"""Manages session lifecycle with state transitions and automatic cleanup."""
def __init__(self, event_callback=None):
self.logger = structlog.get_logger(__name__)
self.event_callback = event_callback # Callback to emit events properly
self.active_sessions: dict[str, dict[str, Any]] = {}
self.session_timers: dict[str, Any] = {}
self._lock = Lock() # Protect shared state from race conditions
self.cleanup_interval = 300 # 5 minutes stale session cleanup
def process_alert(self, alert_data: dict[str, Any], server_id: int) -> bool:
"""Process a Plex alert and update session state."""
try:
alert_type = alert_data.get("type")
if alert_type == "playing":
return self._process_playing_alert(alert_data, server_id)
if alert_type == "transcodeSession.start":
return self._process_transcode_start(alert_data, server_id)
if alert_type == "transcodeSession.end":
return self._process_transcode_end(alert_data, server_id)
if alert_type in [
"activity",
"status",
"timeline",
"transcodeSession.update",
"update.statechange",
]:
# Filter out noisy alerts completely
return True
self.logger.debug(f"Ignoring alert type: {alert_type}")
return True
except Exception as e:
self.logger.error(f"Error processing alert: {e}", exc_info=True)
return False
def _process_playing_alert(
self, alert_data: dict[str, Any], server_id: int
) -> bool:
"""Process playing state notifications with state machine logic."""
notifications = alert_data.get("PlaySessionStateNotification", [])
if not isinstance(notifications, list):
notifications = [notifications]
for notification in notifications:
session_key = notification.get("sessionKey")
if not session_key:
continue
try:
session_key = str(session_key)
new_state = self._map_plex_state(notification.get("state", "unknown"))
view_offset = notification.get("viewOffset", 0)
rating_key = notification.get("ratingKey")
# Get current session state
with self._lock:
current_session = self.active_sessions.get(session_key)
last_state = None
if current_session:
last_state = SessionState(
current_session.get("state", "unknown")
)
# Create state transition
transition = SessionTransition(
session_key=session_key,
from_state=last_state,
to_state=new_state,
timestamp=datetime.now(UTC),
view_offset=view_offset,
metadata={
"rating_key": rating_key,
"server_id": server_id,
"raw_alert": notification,
},
)
# Process the state transition
self._handle_state_transition(transition)
# Reset cleanup timer
self._schedule_cleanup(session_key)
except Exception as e:
self.logger.error(
f"Error processing playing alert for session {session_key}: {e}"
)
continue
return True
def _handle_state_transition(self, transition: SessionTransition):
"""Handle state transitions and update session tracking."""
session_key = transition.session_key
from_state = transition.from_state
to_state = transition.to_state
self.logger.debug(
f"Session {session_key} transition: {from_state} -> {to_state}"
)
# Check if this is truly a new session or a duplicate alert BEFORE updating
with self._lock:
session_exists = session_key in self.active_sessions
if session_exists:
session_already_started = (
self.active_sessions[session_key].get("started_at") is not None
)
else:
session_already_started = False
# Update session tracking
with self._lock:
if not session_exists:
# New session - create minimal tracking (started_at will be set by _on_session_start)
self.active_sessions[session_key] = {
"session_key": session_key,
"started_at": None, # Will be set by _on_session_start to avoid duplicates
"state": to_state.value,
"view_offset": transition.view_offset,
"server_id": transition.metadata.get("server_id")
if transition.metadata
else None,
"rating_key": transition.metadata.get("rating_key")
if transition.metadata
else None,
"last_updated": transition.timestamp,
}
else:
# Existing session - just update state and offset
self.active_sessions[session_key].update(
{
"state": to_state.value,
"view_offset": transition.view_offset,
"last_updated": transition.timestamp,
}
)
# Handle specific state transitions
if from_state is None and to_state == SessionState.PLAYING:
if session_already_started:
# Duplicate start alert - skip
self.logger.debug(
f"Duplicate start alert for session {session_key}, skipping"
)
else:
# Double-check right before starting to handle race conditions
with self._lock:
# Check if another thread just started this session
currently_started = (
session_key in self.active_sessions
and self.active_sessions[session_key].get("started_at")
is not None
)
if currently_started:
self.logger.debug(
f"Session {session_key} already started by another thread"
)
else:
self._on_session_start(transition)
elif from_state == SessionState.PLAYING and to_state == SessionState.PAUSED:
self._on_session_pause(transition)
elif from_state == SessionState.PAUSED and to_state == SessionState.PLAYING:
self._on_session_resume(transition)
elif to_state == SessionState.STOPPED:
self._on_session_stop(transition)
elif to_state == SessionState.BUFFERING:
self._on_session_buffer(transition)
elif to_state == SessionState.ERROR:
self._on_session_error(transition)
# Always record progress for playing/paused states
if to_state in (SessionState.PLAYING, SessionState.PAUSED):
self._record_progress(transition)
def _on_session_start(self, transition: SessionTransition):
"""Handle session start and fetch complete data immediately."""
session_key = transition.session_key
server_id_raw = (
transition.metadata.get("server_id") if transition.metadata else None
)
server_id = int(server_id_raw) if server_id_raw is not None else 0
self.logger.info(f"Session {session_key} started, fetching data")
session_data = self._get_session_from_current_activity(session_key, server_id)
if not session_data or session_data.get("username") == "Unknown":
self.logger.warning(
f"Could not enrich session {session_key}, using minimal data"
)
rating_key = (
transition.metadata.get("rating_key") if transition.metadata else None
)
session_data = {
"username": "Unknown",
"full_title": "Unknown",
"device": "Unknown",
"player": "Unknown",
"platform": "Unknown",
"media_type": "unknown",
"rating_key": rating_key,
"session_key": session_key,
"user_id": None,
"needs_enrichment": True,
}
else:
self.logger.info(
f"Session {session_key} enriched: "
f"user={session_data['username']}, title={session_data['full_title']}"
)
session_data["needs_enrichment"] = False
with self._lock:
self.active_sessions[session_key] = {
**session_data,
"started_at": transition.timestamp,
"last_update": transition.timestamp,
"state": "playing",
"paused_counter": 0,
"paused_at": None,
}
# Create activity event with enriched data
user_id_raw = session_data.get("user_id")
media_type_raw = session_data.get("media_type", "unknown")
media_id_raw = session_data.get("rating_key")
device_name_raw = session_data.get("device", "Unknown")
client_name_raw = session_data.get("player", "Unknown")
platform_raw = session_data.get("platform", "Unknown")
event = ActivityEvent(
event_type="session_start",
server_id=server_id,
session_id=session_key,
user_name=str(session_data.get("username", "Unknown")),
user_id=str(user_id_raw) if user_id_raw is not None else None,
media_title=str(session_data.get("full_title", "Unknown")),
media_type=str(media_type_raw) if media_type_raw is not None else None,
media_id=str(media_id_raw) if media_id_raw is not None else None,
device_name=str(device_name_raw) if device_name_raw is not None else None,
client_name=str(client_name_raw) if client_name_raw is not None else None,
platform=str(platform_raw) if platform_raw is not None else None,
timestamp=transition.timestamp,
position_ms=transition.view_offset,
metadata=transition.metadata,
)
# Record the event
if self.event_callback:
self.event_callback(event)
def _on_session_pause(self, transition: SessionTransition):
"""Handle session pause."""
session_key = transition.session_key
self.logger.info(f"Session {session_key} paused at {transition.view_offset}ms")
with self._lock:
if session_key in self.active_sessions:
self.active_sessions[session_key]["paused_at"] = transition.timestamp
# Get complete session data for pause event
server_id = (
int(transition.metadata.get("server_id", 0)) if transition.metadata else 0
)
session_data = self._get_session_from_current_activity(session_key, server_id)
event = ActivityEvent(
event_type="session_pause",
server_id=server_id,
session_id=session_key,
user_name=str(session_data.get("username", "Unknown")),
media_title=str(session_data.get("full_title", "Unknown")),
timestamp=transition.timestamp,
position_ms=transition.view_offset,
metadata=transition.metadata,
)
if self.event_callback:
self.event_callback(event)
def _on_session_resume(self, transition: SessionTransition):
"""Handle session resume and track pause duration."""
session_key = transition.session_key
self.logger.info(
f"Session {session_key} resumed from {transition.view_offset}ms"
)
pause_duration = None
with self._lock:
if session_key in self.active_sessions:
paused_at = self.active_sessions[session_key].get("paused_at")
if paused_at:
pause_duration = (transition.timestamp - paused_at).total_seconds()
current_paused_counter = self.active_sessions[session_key].get(
"paused_counter", 0
)
self.active_sessions[session_key]["paused_counter"] = (
current_paused_counter + pause_duration
)
self.active_sessions[session_key]["paused_at"] = None
self.logger.debug(
f"Session {session_key} paused {pause_duration:.1f}s, "
f"total: {self.active_sessions[session_key]['paused_counter']:.1f}s"
)
# Get complete session data for resume event
server_id = (
int(transition.metadata.get("server_id", 0)) if transition.metadata else 0
)
session_data = self._get_session_from_current_activity(session_key, server_id)
metadata_dict = (
{**transition.metadata, "pause_duration_seconds": pause_duration}
if transition.metadata
else {"pause_duration_seconds": pause_duration}
)
event = ActivityEvent(
event_type="session_resume",
server_id=server_id,
session_id=session_key,
user_name=str(session_data.get("username", "Unknown")),
media_title=str(session_data.get("full_title", "Unknown")),
timestamp=transition.timestamp,
position_ms=transition.view_offset,
metadata=metadata_dict,
)
if self.event_callback:
self.event_callback(event)
def _on_session_stop(self, transition: SessionTransition):
"""Handle session stop and record final duration."""
session_key = transition.session_key
server_id = (
int(transition.metadata.get("server_id", 0)) if transition.metadata else 0
)
self.logger.info(f"Session {session_key} stopped at {transition.view_offset}ms")
with self._lock:
if session_key not in self.active_sessions:
self.logger.warning(
f"Session {session_key} stop received but not found (race condition)"
)
self._cancel_cleanup_timer(session_key)
return
session_data = self.active_sessions.pop(session_key, {})
self._cancel_cleanup_timer(session_key)
current_session_data = session_data
if (
not current_session_data
or current_session_data.get("username") == "Unknown"
):
lookup_data = self._get_session_from_current_activity(
session_key, server_id
)
if lookup_data and lookup_data.get("username") != "Unknown":
current_session_data = lookup_data
user_name = str(current_session_data.get("username", "Unknown"))
media_title = str(current_session_data.get("full_title", "Unknown"))
started_at = session_data.get("started_at")
paused_counter = session_data.get("paused_counter", 0)
paused_at = session_data.get("paused_at")
if paused_at:
current_pause_duration = (transition.timestamp - paused_at).total_seconds()
paused_counter += current_pause_duration
real_play_time = None
duration_ms = None
if started_at:
elapsed_time = (transition.timestamp - started_at).total_seconds()
real_play_time = max(elapsed_time - paused_counter, 0)
# Ignore very short sessions (< 10 seconds)
MIN_SESSION_DURATION = 10
if real_play_time < MIN_SESSION_DURATION:
self.logger.info(
f"Session {session_key} too short ({real_play_time:.1f}s), ignoring"
)
return
if real_play_time is not None:
try:
duration_ms = max(int(real_play_time * 1000), 0)
except (TypeError, ValueError):
duration_ms = None
self.logger.info(
f"Session {session_key} duration: "
f"elapsed={elapsed_time:.1f}s, paused={paused_counter:.1f}s, "
f"play_time={real_play_time:.1f}s"
)
media_type_raw = current_session_data.get("media_type", "unknown")
media_id_raw = current_session_data.get("rating_key")
device_name_raw = current_session_data.get("device", "Unknown")
client_name_raw = current_session_data.get("player", "Unknown")
platform_raw = current_session_data.get("platform", "Unknown")
metadata_base = transition.metadata if transition.metadata else {}
event = ActivityEvent(
event_type="session_end",
server_id=server_id,
session_id=session_key,
user_name=user_name,
media_title=media_title,
media_type=str(media_type_raw) if media_type_raw is not None else None,
media_id=str(media_id_raw) if media_id_raw is not None else None,
device_name=str(device_name_raw) if device_name_raw is not None else None,
client_name=str(client_name_raw) if client_name_raw is not None else None,
platform=str(platform_raw) if platform_raw is not None else None,
timestamp=transition.timestamp,
position_ms=transition.view_offset,
duration_ms=duration_ms,
metadata={
**metadata_base,
"real_play_time_seconds": real_play_time,
"paused_counter_seconds": paused_counter,
"elapsed_time_seconds": (
(transition.timestamp - started_at).total_seconds()
if started_at
else None
),
},
)
if self.event_callback:
self.event_callback(event)
def _on_session_buffer(self, transition: SessionTransition):
"""Handle buffering events."""
session_key = transition.session_key
with self._lock:
if session_key in self.active_sessions:
buffer_count = (
self.active_sessions[session_key].get("buffer_count", 0) + 1
)
self.active_sessions[session_key]["buffer_count"] = buffer_count
if buffer_count >= 3:
self.logger.warning(
f"Session {session_key} buffering ({buffer_count} times)"
)
# Get complete session data for buffer event
server_id = (
int(transition.metadata.get("server_id", 0)) if transition.metadata else 0
)
session_data = self._get_session_from_current_activity(session_key, server_id)
event = ActivityEvent(
event_type="session_buffer",
server_id=server_id,
session_id=session_key,
user_name=str(session_data.get("username", "Unknown")),
media_title=str(session_data.get("full_title", "Unknown")),
timestamp=transition.timestamp,
position_ms=transition.view_offset,
metadata=transition.metadata,
)
if self.event_callback:
self.event_callback(event)
def _on_session_error(self, transition: SessionTransition):
"""Handle session errors."""
session_key = transition.session_key
self.logger.warning(f"Session {session_key} encountered error")
event = ActivityEvent(
event_type="session_error",
server_id=int(transition.metadata.get("server_id", 0))
if transition.metadata
else 0,
session_id=session_key,
user_name="Unknown",
media_title="Unknown",
timestamp=transition.timestamp,
position_ms=transition.view_offset,
metadata=transition.metadata,
)
if self.event_callback:
self.event_callback(event)
def _record_progress(self, transition: SessionTransition):
"""Record periodic progress snapshots with enrichment retry."""
session_key = transition.session_key
with self._lock:
cached_session = self.active_sessions.get(session_key, {})
last_progress = cached_session.get(
"last_progress_recorded", datetime.min.replace(tzinfo=UTC)
)
needs_enrichment = cached_session.get("needs_enrichment", False)
retry_interval = 10 if needs_enrichment else 30
time_since_progress = (transition.timestamp - last_progress).total_seconds()
if time_since_progress >= retry_interval:
server_id = (
int(transition.metadata.get("server_id", 0))
if transition.metadata
else 0
)
session_data = cached_session
if needs_enrichment or time_since_progress >= 30:
fresh_session_data = self._get_session_from_current_activity(
session_key, server_id
)
if fresh_session_data:
has_improvements = (
fresh_session_data.get("username", "Unknown") != "Unknown"
or fresh_session_data.get("full_title", "Unknown") != "Unknown"
or fresh_session_data.get("device", "Unknown") != "Unknown"
)
if has_improvements:
if needs_enrichment:
self.logger.info(
f"Enriched session {session_key}: "
f"user={fresh_session_data.get('username')}, "
f"title={fresh_session_data.get('full_title')}"
)
with self._lock:
if session_key in self.active_sessions:
self.active_sessions[session_key].update(
fresh_session_data
)
self.active_sessions[session_key][
"needs_enrichment"
] = False
self.active_sessions[session_key]["enriched_at"] = (
transition.timestamp
)
session_data = fresh_session_data
else:
session_data = cached_session
else:
if needs_enrichment:
self.logger.debug(
f"Unable to enrich session {session_key}, will retry"
)
session_data = cached_session
# Create progress event with current data
user_id_raw = session_data.get("user_id")
media_type_raw = session_data.get("media_type", "unknown")
media_id_raw = session_data.get("rating_key")
device_name_raw = session_data.get("device", "Unknown")
client_name_raw = session_data.get("player", "Unknown")
platform_raw = session_data.get("platform", "Unknown")
event = ActivityEvent(
event_type="session_progress",
server_id=server_id,
session_id=session_key,
user_name=str(session_data.get("username", "Unknown")),
user_id=str(user_id_raw) if user_id_raw is not None else None,
media_title=str(session_data.get("full_title", "Unknown")),
media_type=str(media_type_raw) if media_type_raw is not None else None,
media_id=str(media_id_raw) if media_id_raw is not None else None,
device_name=str(device_name_raw)
if device_name_raw is not None
else None,
client_name=str(client_name_raw)
if client_name_raw is not None
else None,
platform=str(platform_raw) if platform_raw is not None else None,
timestamp=transition.timestamp,
position_ms=transition.view_offset,
state=transition.to_state.value,
metadata=transition.metadata,
)
if self.event_callback:
self.event_callback(event)
with self._lock:
if session_key in self.active_sessions:
self.active_sessions[session_key]["last_progress_recorded"] = (
transition.timestamp
)
def _schedule_cleanup(self, session_key: str, timeout_minutes: int = 5):
"""Schedule automatic cleanup for stale sessions."""
# Cancel existing timer
self._cancel_cleanup_timer(session_key)
# Schedule new cleanup
from threading import Timer
timer = Timer(
timeout_minutes * 60, self._force_stop_session, args=[session_key]
)
timer.start()
with self._lock:
self.session_timers[session_key] = timer
def _cancel_cleanup_timer(self, session_key: str):
"""Cancel cleanup timer for a session."""
with self._lock:
timer = self.session_timers.pop(session_key, None)
if timer:
timer.cancel()
def _force_stop_session(self, session_key: str):
"""Force stop a stale session."""
with self._lock:
if session_key not in self.active_sessions:
return
session_data = self.active_sessions[session_key].copy()
self.logger.warning(f"Force stopping stale session {session_key}")
# Create a synthetic stop transition
transition = SessionTransition(
session_key=session_key,
from_state=SessionState(session_data.get("state", "unknown")),
to_state=SessionState.STOPPED,
timestamp=datetime.now(UTC),
view_offset=session_data.get("view_offset", 0),
metadata={
"server_id": session_data.get("server_id"),
"rating_key": session_data.get("rating_key"),
"force_stopped": True,
},
)
self._handle_state_transition(transition)
def _map_plex_state(self, plex_state: str) -> SessionState:
"""Map Plex state strings to our SessionState enum."""
state_mapping = {
"playing": SessionState.PLAYING,
"paused": SessionState.PAUSED,
"stopped": SessionState.STOPPED,
"buffering": SessionState.BUFFERING,
"error": SessionState.ERROR,
"unknown": SessionState.UNKNOWN,
}
return state_mapping.get(plex_state.lower(), SessionState.UNKNOWN)
def _process_timeline_alert(
self, alert_data: dict[str, Any], _server_id: int
) -> bool:
"""Process timeline alerts (library changes, etc.)."""
# For now, just log these - could be enhanced for library monitoring
self.logger.debug(
f"Timeline alert received: {alert_data.get('type', 'unknown')}"
)
return True
def _process_transcode_start(
self, alert_data: dict[str, Any], _server_id: int
) -> bool:
"""Process transcoding session start."""
transcode_sessions = alert_data.get("TranscodeSession", [])
if not isinstance(transcode_sessions, list):
transcode_sessions = [transcode_sessions]
for session in transcode_sessions:
self.logger.debug(f"Transcode started: {session.get('key', 'unknown')}")
return True
def _process_transcode_end(
self, alert_data: dict[str, Any], _server_id: int
) -> bool:
"""Process transcoding session end."""
transcode_sessions = alert_data.get("TranscodeSession", [])
if not isinstance(transcode_sessions, list):
transcode_sessions = [transcode_sessions]
for session in transcode_sessions:
self.logger.debug(f"Transcode ended: {session.get('key', 'unknown')}")
return True
def get_active_sessions(self) -> dict[str, dict[str, Any]]:
"""Get all currently active sessions."""
with self._lock:
return self.active_sessions.copy()
def cleanup_all_sessions(self):
"""Clean up all active sessions (for shutdown)."""
self.logger.info("Cleaning up all active sessions")
with self._lock:
session_keys = list(self.active_sessions.keys())
for session_key in session_keys:
self._cancel_cleanup_timer(session_key)
with self._lock:
self.active_sessions.clear()
self.session_timers.clear()
def _extract_session_data_from_plex(
self, plex_session, server_id: int
) -> dict[str, Any]:
"""Extract session data from a PlexAPI session object."""
from app.extensions import db
from app.models import User
session_data = {}
session_key = str(getattr(plex_session, "sessionKey", ""))
# User information
plex_username = None
usernames = getattr(plex_session, "usernames", None)
users = getattr(plex_session, "users", None)
if usernames:
plex_username = usernames[0]
elif users:
plex_username = users[0].title
# Map to local user
if plex_username:
local_user = (
User.query.filter_by(server_id=server_id)
.filter(
db.or_(
User.username == plex_username,
User.email == plex_username,
)
)
.first()
)
if local_user:
session_data["username"] = local_user.username
session_data["user_id"] = local_user.id
else:
session_data["username"] = plex_username
session_data["user_id"] = None
else:
session_data["username"] = "Unknown"
session_data["user_id"] = None
# Media information
title = getattr(plex_session, "title", "Unknown")
grandparent_title = getattr(plex_session, "grandparentTitle", None)
parent_title = getattr(plex_session, "parentTitle", None)
if grandparent_title and parent_title:
session_data["full_title"] = (
f"{grandparent_title} - {parent_title} - {title}"
)
elif grandparent_title:
session_data["full_title"] = f"{grandparent_title} - {title}"
else:
session_data["full_title"] = title
session_data["media_type"] = getattr(plex_session, "type", "unknown")
session_data["rating_key"] = getattr(plex_session, "ratingKey", "")
session_data["session_key"] = session_key
player_obj = getattr(plex_session, "player", None)
if player_obj:
session_data["player"] = getattr(player_obj, "product", "Unknown")
session_data["device"] = getattr(player_obj, "title", "Unknown")
session_data["platform"] = getattr(player_obj, "platform", "Unknown")
else:
session_data["player"] = "Unknown"
session_data["device"] = "Unknown"
session_data["platform"] = "Unknown"
return session_data
def _get_session_from_current_activity(
self, session_key: str, server_id: int
) -> dict[str, Any]:
"""Get session data from Plex /status/sessions endpoint."""
try:
# Import Flask dependencies
from flask import has_app_context
def _do_session_lookup():
"""Helper function to perform the actual session lookup."""
from app.models import MediaServer
from app.services.media.service import get_client_for_media_server
# Get the server
server = MediaServer.query.get(server_id)
if not server:
self.logger.warning(f"Server {server_id} not found")
return {}
# Get the media client
client = get_client_for_media_server(server)
if not client or not hasattr(client, "server"):
self.logger.warning(f"No valid client for server {server_id}")
return {}
sessions = client.server.sessions() # type: ignore
target_session = None
for session in sessions:
session_key_attr = str(getattr(session, "sessionKey", ""))
if session_key_attr == str(session_key):
target_session = session
break
if not target_session:
return {}
return self._extract_session_data_from_plex(target_session, server_id)
if has_app_context():
return _do_session_lookup()
from .monitor import _app_instance
app = _app_instance
if app:
with app.app_context():
return _do_session_lookup()
return {}
except Exception as e:
self.logger.warning(
f"Failed to get session from current activity: {e}", exc_info=True
)
return {}