from __future__ import annotations import asyncio from datetime import datetime, timedelta, timezone from html import escape import json import logging from typing import Any, Dict, Optional from ..config import settings as env_settings from ..db import ( add_portal_item_activity, get_portal_item, get_user_by_username, list_portal_item_activity, list_portal_items, update_portal_item, ) from ..runtime import get_runtime_settings from .invite_email import resolve_user_delivery_email, send_generic_email from .snapshot import build_snapshot from .media_repair import evaluate_media_repair logger = logging.getLogger(__name__) _SYSTEM_USER = "Magent" _MEDIA_REPAIR_STARTED_EVENTS = {"replacement_started", "missing_search_started"} def _now() -> datetime: return datetime.now(timezone.utc) def _parse_datetime(value: Any) -> Optional[datetime]: if not isinstance(value, str) or not value.strip(): return None try: parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00")) except ValueError: return None return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc) def _metadata(item: Dict[str, Any]) -> Dict[str, Any]: raw = item.get("metadata_json") if not isinstance(raw, str) or not raw.strip(): return {} try: parsed = json.loads(raw) except (TypeError, ValueError): return {} return parsed if isinstance(parsed, dict) else {} def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]: state = _metadata(item).get("resolutionConfirmation") return dict(state) if isinstance(state, dict) else {} def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str: metadata = _metadata(item) metadata["resolutionConfirmation"] = state return json.dumps(metadata, separators=(",", ":"), sort_keys=True) def _interval_delta(value: int, unit: str) -> timedelta: safe_value = max(1, min(int(value), 365)) normalized_unit = str(unit or "days").strip().lower() if normalized_unit == "weeks": return timedelta(weeks=safe_value) if normalized_unit == "months": return timedelta(days=30 * safe_value) return timedelta(days=safe_value) def _workflow_settings() -> tuple[int, int, str]: runtime = get_runtime_settings() attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10)) interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365)) interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower() if interval_unit not in {"days", "weeks", "months"}: interval_unit = "days" return attempts, interval_value, interval_unit def _app_url() -> str: runtime = get_runtime_settings() for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin): candidate = str(value or "").strip() if candidate: return candidate.rstrip("/") return f"http://localhost:{int(runtime.magent_application_port or 3000)}" def _issue_url(item_id: int) -> str: return f"{_app_url()}/portal/issues?item={item_id}" def _activity( item_id: int, event_type: str, message: str, *, actor_username: str = _SYSTEM_USER, actor_role: str = "system", metadata: Optional[Dict[str, Any]] = None, ) -> None: add_portal_item_activity( item_id, event_type=event_type, actor_username=actor_username, actor_role=actor_role, message=message, metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None, ) def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]: raw = entry.get("metadata_json") if not isinstance(raw, str) or not raw.strip(): return {} try: parsed = json.loads(raw) except (TypeError, ValueError): return {} return parsed if isinstance(parsed, dict) else {} def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]: activity = list_portal_item_activity(item_id, limit=500) for entry in reversed(activity): # A rejected repair must not be proposed again simply because the same # replacement file is still present. Wait for a NEW repair attempt. if str(entry.get("event_type") or "") == "resolution_rejected": return {}, activity if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS: continue tracking = _activity_metadata(entry).get("repairTracking") if isinstance(tracking, dict): return dict(tracking), activity return {}, activity async def _media_repair_evidence(tracking: Dict[str, Any]) -> Dict[str, Any]: snapshot = await build_snapshot(str(tracking.get("requestId") or "")) raw = snapshot.raw if isinstance(snapshot.raw, dict) else {} jellyfin = dict(raw.get("jellyfin") or {}) jellyfin["found"] = jellyfin.get("catalogFound", jellyfin.get("found")) return await evaluate_media_repair( tracking, (raw.get("arr") or {}).get("item"), jellyfin, episodes=(raw.get("arr") or {}).get("episodes"), ) def _close_issue( item: Dict[str, Any], *, reason: str, confirmed: bool, actor_username: str = _SYSTEM_USER, actor_role: str = "system", ) -> Dict[str, Any]: now = _now().isoformat() state = issue_resolution_state(item) state.update( { "status": "confirmed" if confirmed else "auto_closed", "confirmedAt": now if confirmed else state.get("confirmedAt"), "closedAt": now, "nextContactAt": None, "closedReason": reason, } ) updated = update_portal_item( int(item["id"]), status="closed", issue_resolved_at=now, metadata_json=_metadata_with_resolution(item, state), ) if not updated: raise RuntimeError("Issue could not be closed") _activity( int(item["id"]), "resolution_confirmed" if confirmed else "issue_auto_closed", reason, actor_username=actor_username, actor_role=actor_role, ) return updated async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]: maximum, interval_value, interval_unit = _workflow_settings() state = issue_resolution_state(item) attempts = max(0, int(state.get("attemptsSent") or 0)) if maximum <= 0: return _close_issue( item, reason="Issue closed automatically because reporter confirmation emails are disabled.", confirmed=False, ) if attempts >= maximum: return _close_issue( item, reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.", confirmed=False, ) attempt_number = attempts + 1 reporter = get_user_by_username(str(item.get("created_by_username") or "")) recipient = resolve_user_delivery_email(reporter) issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}" sent = False delivery_error: Optional[str] = None if recipient: subject = f"Ready to try again? Grizzlyflix issue #{item['id']}" body_text = ( "Your repair looks ready to test.\n\n" f"{item.get('title') or 'Your reported issue'}\n\n" "Please try the affected content in Grizzlyflix. Is it fixed?\n\n" f"YES — it works: {issue_url}#yes\n" f"NO — still broken: {issue_url}#no\n\n" "Confirm your answer in Magent. You may need to sign in first.\n" "Yes closes the report. No keeps it open for another look.\n\n" f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically." ) body_html = ( '
| '
' GRIZZLYFLIX · MAGENT ' 'Ready to try again?' 'Your repair looks ready to test. Give the affected content a try, then let us know: ' f'{escape(str(item.get("title") or "Your reported issue"))} ' 'Is it fixed?' f'YES — it works' f'NO — still broken' 'Confirm your answer in Magent. You may need to sign in first. Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])} |