diff --git a/plugins/AIOverhaul/AIButton.js b/plugins/AIOverhaul/AIButton.js new file mode 100644 index 00000000..9a1e614e --- /dev/null +++ b/plugins/AIOverhaul/AIButton.js @@ -0,0 +1,422 @@ +(function(){ +// AIButton (MinimalAIButton) +// Contract: +// - Provides a single floating/contextual button that lists available AI actions for current page context. +// - No polling: actions fetched on open + context change; task progress via shared websocket + global cache. +// - Supports multiple concurrent parent/controller tasks; shows aggregate count or single progress ring. +// - Exposes global aliases: window.AIButton & window.MinimalAIButton for integrations to mount. +// - Debug logging gated by window.AIDebug = true. +// - Assumes backend REST under /api/v1 and websocket under /api/v1/ws/tasks (with legacy fallback /ws/tasks). +// - Only parent/controller task IDs are tracked in activeTasks; child task events still drive progress inference. +// ---- Small internal helpers (pure / non-visual) ---- +const sanitizeBackendBase = (value) => { + if (typeof value !== 'string') + return ''; + const trimmed = value.trim(); + if (!trimmed) + return ''; + const cleaned = trimmed.replace(/\/$/, ''); + try { + if (typeof location !== 'undefined' && location.origin) { + const origin = location.origin.replace(/\/$/, ''); + if (cleaned === origin) + return ''; + } + } + catch { } + return cleaned; +}; +const getBackendBase = () => { + const fn = window.AIDefaultBackendBase; + if (typeof fn !== 'function') + throw new Error('AIDefaultBackendBase not initialized. Ensure backendBase is loaded first.'); + return sanitizeBackendBase(fn()); +}; +const debugEnabled = () => !!window.AIDebug; +const dlog = (...a) => { if (debugEnabled()) + console.log('[AIButton]', ...a); }; +const parseActionsChanged = (prev, next) => { + if (!prev || prev.length !== next.length) + return true; + for (let i = 0; i < next.length; i++) { + const p = prev[i]; + const n = next[i]; + if (p.id !== n.id || p.label !== n.label || p.result_kind !== n.result_kind) + return true; + } + return false; +}; +const computeSingleProgress = (activeIds) => { + if (activeIds.length !== 1) + return null; + try { + const g = window; + const tid = activeIds[0]; + const cache = g.__AI_TASK_CACHE__ || {}; + const tasks = Object.values(cache); + const children = tasks.filter(t => t.group_id === tid); + if (!children.length) + return 0; // show ring at 0%, matches previous UX + let done = 0, running = 0, queued = 0, failed = 0, cancelled = 0; // cancelled intentionally excluded from denominator + for (const c of children) { + switch (c.status) { + case 'completed': + done++; + break; + case 'running': + running++; + break; + case 'queued': + queued++; + break; + case 'failed': + failed++; + break; + case 'cancelled': + cancelled++; + break; + } + } + const effectiveTotal = done + running + queued + failed; + if (!effectiveTotal) + return 0; + const weighted = done + failed + running * 0.5; + return Math.min(1, weighted / effectiveTotal); + } + catch { + return null; + } +}; +const ensureTaskWebSocket = (backendBase) => { + const g = window; + dlog('ensureWS invoked'); + if (g.__AI_TASK_WS__ && g.__AI_TASK_WS__.readyState === 1) + return g.__AI_TASK_WS__; + if (g.__AI_TASK_WS_INIT__) + return g.__AI_TASK_WS__; + g.__AI_TASK_WS_INIT__ = true; + const base = backendBase.replace(/^http/, 'ws'); + const paths = [`${base}/api/v1/ws/tasks`, `${base}/ws/tasks`]; + for (const url of paths) { + try { + dlog('Attempt WS connect', url); + const sock = new WebSocket(url); + g.__AI_TASK_WS__ = sock; + wireSocket(sock); + return sock; + } + catch (e) { + if (debugEnabled()) + console.warn('[AIButton] WS connect failed candidate', url, e); + } + } + g.__AI_TASK_WS_INIT__ = false; + return null; +}; +function wireSocket(sock) { + const g = window; + if (!g.__AI_TASK_WS_LISTENERS__) + g.__AI_TASK_WS_LISTENERS__ = {}; + if (!g.__AI_TASK_ANY_LISTENERS__) + g.__AI_TASK_ANY_LISTENERS__ = []; + if (!g.__AI_TASK_CACHE__) + g.__AI_TASK_CACHE__ = {}; + sock.onopen = () => { dlog('WS open', sock.url); }; + sock.onmessage = (evt) => { + var _a; + dlog('WS raw message', evt.data); + try { + const m = JSON.parse(evt.data); + const task = m.task || ((_a = m.data) === null || _a === void 0 ? void 0 : _a.task) || m.data || m; + if (!(task === null || task === void 0 ? void 0 : task.id)) { + dlog('Message without task id ignored', m); + return; + } + g.__AI_TASK_CACHE__[task.id] = task; + const ls = g.__AI_TASK_WS_LISTENERS__[task.id]; + if (ls) + ls.forEach((fn) => fn(task)); + const anyLs = g.__AI_TASK_ANY_LISTENERS__; + if (anyLs && anyLs.length) + anyLs.forEach((fn) => { try { + fn(task); + } + catch { } }); + } + catch (err) { + if (debugEnabled()) + console.error('[AIButton] Failed parse WS message', err); + } + }; + const cleanup = (ev) => { if (debugEnabled()) + console.warn('[AIButton] WS closed/error', ev === null || ev === void 0 ? void 0 : ev.code, ev === null || ev === void 0 ? void 0 : ev.reason); if (window.__AI_TASK_WS__ === sock) + window.__AI_TASK_WS__ = null; window.__AI_TASK_WS_INIT__ = false; }; + sock.onclose = cleanup; + sock.onerror = cleanup; +} +const MinimalAIButton = () => { + var _a, _b; + const React = ((_a = window.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || window.React; + if (!React) { + console.error('[AIButton] React not found on window.PluginApi.React'); + return null; + } + const pageAPI = window.AIPageContext; + if (!pageAPI) { + console.error('[AIButton] AIPageContext missing on window'); + return null; + } + const [context, setContext] = React.useState(pageAPI.get()); + const [showTooltip, setShowTooltip] = React.useState(false); + const [openMenu, setOpenMenu] = React.useState(false); + const [loadingActions, setLoadingActions] = React.useState(false); + const [actions, setActions] = React.useState([]); + const [activeTasks, setActiveTasks] = React.useState([]); + const [recentlyFinished, setRecentlyFinished] = React.useState([]); // retained for potential future UX + const [backendBase, setBackendBase] = React.useState(() => getBackendBase()); + React.useEffect(() => { + const updateBase = (event) => { + const customEvent = event; + const detail = customEvent === null || customEvent === void 0 ? void 0 : customEvent.detail; + if (typeof detail === 'string') { + setBackendBase(sanitizeBackendBase(detail)); + } + else { + setBackendBase(getBackendBase()); + } + }; + updateBase(); + window.addEventListener('AIBackendBaseUpdated', updateBase); + return () => window.removeEventListener('AIBackendBaseUpdated', updateBase); + }, []); + const actionsRef = React.useRef(null); + React.useEffect(() => pageAPI.subscribe((ctx) => setContext(ctx)), []); + const refetchActions = React.useCallback(async (ctx, opts = {}) => { + if (!backendBase) { + if (!opts.silent) + setLoadingActions(false); + setActions([]); + return; + } + if (!opts.silent) + setLoadingActions(true); + try { + const res = await fetch(`${backendBase}/api/v1/actions/available`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + context: { + page: ctx.page, + entityId: ctx.entityId, + isDetailView: ctx.isDetailView, + selectedIds: ctx.selectedIds || [], + visibleIds: ctx.visibleIds || [] + } + }) + }); + if (!res.ok) + throw new Error('Failed to load actions'); + const data = await res.json(); + if (parseActionsChanged(actionsRef.current, data)) { + actionsRef.current = data; + setActions(data); + } + } + catch { + if (!opts.silent) + setActions([]); + } + finally { + if (!opts.silent) + setLoadingActions(false); + } + }, [backendBase]); + React.useEffect(() => { refetchActions(context); }, [context, refetchActions]); + // Websocket ensure + React.useEffect(() => { + if (!backendBase) + return; + ensureTaskWebSocket(backendBase); + }, [backendBase]); + const executeAction = async (actionId) => { + var _a, _b, _c; + if (!backendBase) { + alert('AI backend URL is not configured. Update it under AI Overhaul settings.'); + return; + } + dlog('Execute action', actionId, 'context', context); + ensureTaskWebSocket(backendBase); + try { + const g = window; + let liveContext = context; + try { + if (pageAPI.forceRefresh) + pageAPI.forceRefresh(); + if (pageAPI.get) { + liveContext = pageAPI.get(); + setContext(liveContext); + } + } + catch { /* fall back to current state */ } + const actionMeta = (_a = actionsRef.current) === null || _a === void 0 ? void 0 : _a.find((a) => a.id === actionId); + const resultKind = (actionMeta === null || actionMeta === void 0 ? void 0 : actionMeta.result_kind) || 'none'; + const res = await fetch(`${backendBase}/api/v1/actions/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action_id: actionId, + context: { + page: liveContext.page, + entityId: liveContext.entityId, + isDetailView: liveContext.isDetailView, + selectedIds: liveContext.selectedIds || [], + visibleIds: liveContext.visibleIds || [] + }, + params: {} + }) + }); + if (!res.ok) { + let message = 'Submit failed'; + try { + const err = await res.json(); + if (err === null || err === void 0 ? void 0 : err.detail) { + if (typeof err.detail === 'string') { + message = err.detail; + } + else if (typeof ((_b = err.detail) === null || _b === void 0 ? void 0 : _b.message) === 'string') { + message = err.detail.message; + } + } + } + catch { } + throw new Error(message); + } + const { task_id: taskId } = await res.json(); + if (!g.__AI_TASK_WS_LISTENERS__) + g.__AI_TASK_WS_LISTENERS__ = {}; + if (!g.__AI_TASK_WS_LISTENERS__[taskId]) + g.__AI_TASK_WS_LISTENERS__[taskId] = []; + setActiveTasks((prev) => prev.includes(taskId) ? prev : [...prev, taskId]); + const finalize = (t) => { + if (t.status === 'completed') { + if (resultKind === 'dialog' || resultKind === 'notification') { + alert(`Action ${actionId} result:\n` + JSON.stringify(t.result, null, 2)); + } + } + else if (t.status === 'failed') { + alert(`Action ${actionId} failed: ${t.error || 'unknown error'}`); + } + setActiveTasks((prev) => prev.filter((id) => id !== t.id)); + setRecentlyFinished((prev) => [t.id, ...prev].slice(0, 20)); + }; + const listener = (t) => { if (t.id !== taskId) + return; if (["completed", "failed", "cancelled"].includes(t.status)) { + finalize(t); + g.__AI_TASK_WS_LISTENERS__[taskId] = (g.__AI_TASK_WS_LISTENERS__[taskId] || []).filter((fn) => fn !== listener); + } }; + g.__AI_TASK_WS_LISTENERS__[taskId].push(listener); + if ((_c = g.__AI_TASK_CACHE__) === null || _c === void 0 ? void 0 : _c[taskId]) + listener(g.__AI_TASK_CACHE__[taskId]); + } + catch (e) { + alert(`Action ${actionId} failed: ${e.message}`); + } + }; + // Any-task listener for progress updates + React.useEffect(() => { + const g = window; + if (!g.__AI_TASK_ANY_LISTENERS__) + g.__AI_TASK_ANY_LISTENERS__ = []; + const listener = (t) => { if (!activeTasks.length) + return; if (activeTasks.includes(t.id) || activeTasks.includes(t.group_id)) + setProgressVersion((v) => v + 1); }; + g.__AI_TASK_ANY_LISTENERS__.push(listener); + return () => { g.__AI_TASK_ANY_LISTENERS__ = (g.__AI_TASK_ANY_LISTENERS__ || []).filter((fn) => fn !== listener); }; + }, [activeTasks]); + const [progressVersion, setProgressVersion] = React.useState(0); // triggers re-render on child task activity + const singleProgress = computeSingleProgress(activeTasks); + const progressPct = singleProgress != null ? Math.round(singleProgress * 100) : null; + const toggleMenu = () => { + if (!openMenu) { + let liveContext = context; + try { + if (pageAPI.forceRefresh) + pageAPI.forceRefresh(); + if (pageAPI.get) { + liveContext = pageAPI.get(); + setContext(liveContext); + } + } + catch { /* best effort */ } + refetchActions(liveContext, { silent: true }); + } + setOpenMenu((o) => !o); + }; + const getButtonIcon = () => { switch (context.page) { + case 'scenes': return '🎬'; + case 'galleries': + case 'images': return '🖼️'; + case 'performers': return '👤'; + case 'studios': return '🏢'; + case 'tags': return '🔖'; + case 'markers': return '⏱️'; + case 'home': return '🏠'; + case 'settings': return '⚙️'; + default: return '🤖'; + } }; + // Map page keys to more compact labels where necessary (e.g. 'performers' -> 'Actors') + const getButtonLabel = () => { + if (!context || !context.page) + return 'AI'; + switch (context.page) { + case 'performers': + return 'Actors'; + default: + return context.page; + } + }; + const colorClass = context.isDetailView ? 'ai-btn--detail' : `ai-btn--${context.page}`; + // Build children (unchanged structure / classes) + const elems = []; + const activeCount = activeTasks.length; + const progressRing = (singleProgress != null && activeCount === 1) ? React.createElement('div', { key: 'ring', className: 'ai-btn__progress-ring', style: { ['--ai-progress']: `${progressPct}%` } }) : null; + elems.push(React.createElement('button', { key: 'ai-btn', className: `ai-btn ${colorClass}` + (singleProgress != null ? ' ai-btn--progress' : ''), onClick: toggleMenu, onMouseEnter: () => setShowTooltip(true), onMouseLeave: () => setShowTooltip(false), disabled: loadingActions }, [ + progressRing, + React.createElement('div', { key: 'icon', className: 'ai-btn__icon' }, activeCount === 0 ? getButtonIcon() : (activeCount === 1 && progressPct != null ? `${progressPct}%` : '⏳')), + React.createElement('div', { key: 'lbl', className: 'ai-btn__label' }, String(getButtonLabel() || 'AI').toUpperCase()), + activeCount > 1 && React.createElement('span', { key: 'badge', className: 'ai-btn__badge' }, String(activeCount)) + ])); + if (showTooltip && !openMenu) { + elems.push(React.createElement('div', { key: 'tip', className: 'ai-btn__tooltip' }, [ + React.createElement('div', { key: 'main', className: 'ai-btn__tooltip-main' }, context.contextLabel), + React.createElement('div', { key: 'detail', className: 'ai-btn__tooltip-detail' }, context.detailLabel || ''), + context.entityId && React.createElement('div', { key: 'id', className: 'ai-btn__tooltip-id' }, `ID: ${context.entityId}`), + ((_b = context.selectedIds) === null || _b === void 0 ? void 0 : _b.length) && React.createElement('div', { key: 'sel', className: 'ai-btn__tooltip-sel' }, `Selected: ${context.selectedIds.length}`) + ])); + } + if (openMenu) { + elems.push(React.createElement('div', { key: 'menu', className: 'ai-actions-menu' }, [ + loadingActions && React.createElement('div', { key: 'loading', className: 'ai-actions-menu__status' }, 'Loading actions...'), + !loadingActions && actions.length === 0 && React.createElement('div', { key: 'none', className: 'ai-actions-menu__status' }, 'No actions'), + !loadingActions && actions.map((a) => { + var _a, _b; + return React.createElement('button', { key: a.id, onClick: () => executeAction(a.id), className: 'ai-actions-menu__item' }, [ + React.createElement('span', { key: 'svc', className: 'ai-actions-menu__svc' }, ((_b = (_a = a.service) === null || _a === void 0 ? void 0 : _a.toUpperCase) === null || _b === void 0 ? void 0 : _b.call(_a)) || a.service), + React.createElement('span', { key: 'albl', style: { flexGrow: 1 } }, a.label), + a.result_kind === 'dialog' && React.createElement('span', { key: 'rk', className: 'ai-actions-menu__rk' }, '↗') + ]); + }) + ])); + } + return React.createElement('div', { className: 'minimal-ai-button', style: { position: 'relative', display: 'inline-block' } }, elems); +}; +window.MinimalAIButton = MinimalAIButton; +window.AIButton = MinimalAIButton; // alias for integrations expecting AIButton +if (!window.__AI_BUTTON_LOADED__) { + window.__AI_BUTTON_LOADED__ = true; + if (window.AIDebug) + console.log('[AIButton] Component loaded and globals registered'); +} +MinimalAIButton; +})(); + diff --git a/plugins/AIOverhaul/AIButtonIntegration.js b/plugins/AIOverhaul/AIButtonIntegration.js new file mode 100644 index 00000000..29aacdf2 --- /dev/null +++ b/plugins/AIOverhaul/AIButtonIntegration.js @@ -0,0 +1,110 @@ +(function(){ +// ============================================================================= +// Unified Integration for AI Button + Task Dashboard +// - Injects MinimalAIButton into MainNavBar.UtilityItems +// - Registers /plugins/ai-tasks route mounting TaskDashboard +// - Adds SettingsToolsSection entry linking to the dashboard +// - Adds simple "AI" nav utility link (in case button not visible) +// - All logging gated by window.AIDebug +// ============================================================================= +(function () { + var _a, _b, _c; + const g = window; + const PluginApi = g.PluginApi; + if (!PluginApi) { + console.warn('[AIIntegration] PluginApi not ready'); + return; + } + const React = PluginApi.React; + const debug = !!g.AIDebug; + const dlog = (...a) => { if (debug) + console.log('[AIIntegration]', ...a); }; + // Helper to safely get components + const Button = ((_b = (_a = PluginApi.libraries) === null || _a === void 0 ? void 0 : _a.Bootstrap) === null || _b === void 0 ? void 0 : _b.Button) || ((p) => React.createElement('button', p, p.children)); + const { Link, NavLink } = ((_c = PluginApi.libraries) === null || _c === void 0 ? void 0 : _c.ReactRouterDOM) || {}; + function getMinimalButton() { return g.MinimalAIButton || g.AIButton; } + function getTaskDashboard() { return g.TaskDashboard || g.AITaskDashboard; } + function getPluginSettings() { return g.AIPluginSettings; } + // Main nav utility items: inject AI button + nav link + try { + PluginApi.patch.before('MainNavBar.UtilityItems', function (props) { + const MinimalAIButton = getMinimalButton(); + const children = [props.children]; + if (MinimalAIButton) { + children.push(React.createElement('div', { key: 'ai-btn-wrap', style: { marginRight: 8, display: 'flex', alignItems: 'center' } }, React.createElement(MinimalAIButton))); + } + return [{ children }]; + }); + dlog('Patched MainNavBar.UtilityItems'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] main nav patch failed', e); + } + // Register dashboard route + try { + PluginApi.register.route('/plugins/ai-tasks', () => { + const Dash = getTaskDashboard(); + return Dash ? React.createElement(Dash, {}) : React.createElement('div', { style: { padding: 16 } }, 'Loading AI Tasks...'); + }); + dlog('Registered /plugins/ai-tasks route'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] route register failed', e); + } + // Register settings route (event-driven, no polling) + try { + const SettingsWrapper = () => { + const [Comp, setComp] = React.useState(() => getPluginSettings()); + React.useEffect(() => { + if (Comp) + return; // already there + const handler = () => { + const found = getPluginSettings(); + if (found) { + if (debug) + console.debug('[AIIntegration] AIPluginSettingsReady event captured'); + setComp(() => found); + } + }; + window.addEventListener('AIPluginSettingsReady', handler); + // one immediate async attempt (in case script loaded right after) + setTimeout(handler, 0); + return () => window.removeEventListener('AIPluginSettingsReady', handler); + }, [Comp]); + const C = Comp; + return C ? React.createElement(C, {}) : React.createElement('div', { style: { padding: 16 } }, 'Loading AI Overhaul Settings...'); + }; + PluginApi.register.route('/plugins/ai-settings', () => React.createElement(SettingsWrapper)); + dlog('Registered /plugins/ai-settings route (event)'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] settings route register failed', e); + } + // Settings tools entry + try { + PluginApi.patch.before('SettingsToolsSection', function (props) { + var _a; + const Setting = (_a = PluginApi.components) === null || _a === void 0 ? void 0 : _a.Setting; + if (!Setting) + return props; + return [{ children: (React.createElement(React.Fragment, null, + props.children, + React.createElement(Setting, { heading: Link ? React.createElement(Link, { to: "/plugins/ai-tasks" }, + React.createElement(Button, null, "AI Tasks")) : React.createElement(Button, { onClick: () => (location.href = '/plugins/ai-tasks') }, 'AI Tasks') }), + React.createElement(Setting, { heading: Link ? React.createElement(Link, { to: "/plugins/ai-settings" }, + React.createElement(Button, null, "AI Overhaul Settings")) : React.createElement(Button, { onClick: () => (location.href = '/plugins/ai-settings') }, 'AI Overhaul Settings') }))) }]; + }); + dlog('Patched SettingsToolsSection'); + } + catch (e) { + if (debug) + console.warn('[AIIntegration] settings tools patch failed', e); + } + if (debug) + console.log('[AIIntegration] Unified integration loaded'); +})(); +})(); + diff --git a/plugins/AIOverhaul/AIOverhaul.yml b/plugins/AIOverhaul/AIOverhaul.yml new file mode 100644 index 00000000..4be3573c --- /dev/null +++ b/plugins/AIOverhaul/AIOverhaul.yml @@ -0,0 +1,42 @@ +name: AIOverhaul +description: AI Overhaul for Stash +version: 0.5.0 +ui: + javascript: + - BackendBase.js + - BackendHealth.js + - PageContext.js + - RecommendationUtils.js + - AIButton.js + - TaskDashboard.js + - PluginSettings.js # ensure settings component registers before integration + - RecommendedScenes.js + - SimilarScenes.js + - SimilarTabIntegration.js + - InteractionTracker.js + - AIButtonIntegration.js # integration last after components + css: + - css/AIOverhaul.css + - css/recommendedscenes.css + - css/SimilarScenes.css + csp: + connect-src: + - http://localhost:4153 + - ws://localhost:4153 + - https://localhost:4153 +interface: raw +exec: + - python + - "{pluginDir}/plugin_setup.py" +tasks: + - name: Setup AI Overhaul Plugin settings + description: Use to set automatically set AI Overhaul Plugin settings + defaultArgs: + mode: plugin_setup +settings: + backend_base_url: + displayName: Backend Base URL Override + type: STRING + capture_events: + displayName: Capture Interaction Events + type: BOOLEAN \ No newline at end of file diff --git a/plugins/AIOverhaul/BackendBase.js b/plugins/AIOverhaul/BackendBase.js new file mode 100644 index 00000000..bc90e349 --- /dev/null +++ b/plugins/AIOverhaul/BackendBase.js @@ -0,0 +1,139 @@ +(function(){ +// Shared helper to determine the backend base URL used by the frontend. +// Exposes a default export and also attaches to window.AIDefaultBackendBase for +// non-module consumers in the minimal build. +defaultBackendBase; +const PLUGIN_NAME = 'AIOverhaul'; +// Local default to keep the UI functional before plugin config loads. +const DEFAULT_BACKEND_BASE = 'http://localhost:4153'; +const CONFIG_QUERY = `query AIOverhaulPluginConfig($ids: [ID!]) { + configuration { + plugins(include: $ids) + } +}`; +let configLoaded = false; +let configLoading = false; +function getOrigin() { + try { + if (typeof location !== 'undefined' && location.origin) { + return location.origin.replace(/\/$/, ''); + } + } + catch { } + return ''; +} +function normalizeBase(raw) { + if (typeof raw !== 'string') + return null; + const trimmed = raw.trim(); + if (!trimmed) + return ''; + const cleaned = trimmed.replace(/\/$/, ''); + const origin = getOrigin(); + if (origin && cleaned === origin) { + return ''; + } + return cleaned; +} +function interpretBool(raw) { + if (typeof raw === 'boolean') + return raw; + if (typeof raw === 'number') + return raw !== 0; + if (typeof raw === 'string') { + const lowered = raw.trim().toLowerCase(); + if (!lowered) + return false; + if (['1', 'true', 'yes', 'on'].includes(lowered)) + return true; + if (['0', 'false', 'no', 'off'].includes(lowered)) + return false; + } + return null; +} +function applyPluginConfig(base, captureEvents) { + if (base !== undefined) { + const normalized = normalizeBase(base); + if (normalized !== null) { + const value = normalized || ''; + try { + window.AI_BACKEND_URL = value; + window.dispatchEvent(new CustomEvent('AIBackendBaseUpdated', { detail: value })); + } + catch { } + } + } + if (captureEvents !== undefined && captureEvents !== null) { + const normalized = !!captureEvents; + try { + window.__AI_INTERACTIONS_ENABLED__ = normalized; + } + catch { } + try { + const tracker = window.stashAIInteractionTracker; + if (tracker) { + if (typeof tracker.setEnabled === 'function') + tracker.setEnabled(normalized); + else if (typeof tracker.configure === 'function') + tracker.configure({ enabled: normalized }); + } + } + catch { } + } +} +async function loadPluginConfig() { + var _a, _b, _c, _d, _e, _f; + if (configLoaded || configLoading) + return; + configLoading = true; + try { + const resp = await fetch('/graphql', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ query: CONFIG_QUERY, variables: { ids: [PLUGIN_NAME] } }), + }); + if (!resp.ok) + return; + const payload = await resp.json().catch(() => null); + const plugins = (_b = (_a = payload === null || payload === void 0 ? void 0 : payload.data) === null || _a === void 0 ? void 0 : _a.configuration) === null || _b === void 0 ? void 0 : _b.plugins; + if (plugins && typeof plugins === 'object') { + const entry = plugins[PLUGIN_NAME]; + if (entry && typeof entry === 'object') { + const backendBase = (_d = (_c = entry.backend_base_url) !== null && _c !== void 0 ? _c : entry.backendBaseUrl) !== null && _d !== void 0 ? _d : entry.backendBaseURL; + const captureEvents = (_f = (_e = entry.capture_events) !== null && _e !== void 0 ? _e : entry.captureEvents) !== null && _f !== void 0 ? _f : entry.captureEventsEnabled; + applyPluginConfig(backendBase, interpretBool(captureEvents)); + } + } + } + catch { } + finally { + configLoaded = true; + configLoading = false; + } +} +function defaultBackendBase() { + try { + if (!configLoaded) + loadPluginConfig(); + } + catch { } + if (typeof window.AI_BACKEND_URL === 'string') { + const explicit = normalizeBase(window.AI_BACKEND_URL); + if (explicit !== null && explicit !== undefined) { + return explicit; + } + return ''; + } + return DEFAULT_BACKEND_BASE; +} +// Also attach as a global so files that are executed before this module can still +// use the shared function when available. +try { + window.AIDefaultBackendBase = defaultBackendBase; + defaultBackendBase.loadPluginConfig = loadPluginConfig; + defaultBackendBase.applyPluginConfig = applyPluginConfig; +} +catch { } +})(); + diff --git a/plugins/AIOverhaul/BackendHealth.js b/plugins/AIOverhaul/BackendHealth.js new file mode 100644 index 00000000..b2663965 --- /dev/null +++ b/plugins/AIOverhaul/BackendHealth.js @@ -0,0 +1,200 @@ +(function(){ +// Shared backend connectivity tracking & notice helpers for the AI Overhaul frontend. +// Each bundle is built as an isolated IIFE, so we expose a small global helper +// (`window.AIBackendHealth`) that provides three core pieces: +// • reportOk / reportError for callers performing fetches +// • useBackendHealth hook for React components to subscribe to status changes +// • buildNotice helper to render a consistent user-facing outage banner +// The goal is to provide a single, user-friendly experience whenever the +// backend cannot be reached instead of bespoke inline error badges. +(function initBackendHealth() { + const w = window; + const listeners = new Set(); + const EVENT_NAME = 'AIBackendHealthChange'; + function now() { return Date.now ? Date.now() : new Date().getTime(); } + function getOrigin() { + try { + if (typeof location !== 'undefined' && location.origin) { + return location.origin.replace(/\/$/, ''); + } + } + catch (_) { } + return ''; + } + function normalizeBase(base) { + if (base === undefined || base === null) + return current.backendBase || ''; + try { + const str = String(base || '').trim(); + if (!str) + return ''; + const cleaned = str.replace(/\/$/, ''); + const origin = getOrigin(); + return origin && cleaned === origin ? '' : cleaned; + } + catch (_) { + return ''; + } + } + function fallbackBase() { + try { + const fn = (w.AIDefaultBackendBase || w.defaultBackendBase); + if (typeof fn === 'function') { + const base = fn(); + if (typeof base === 'string') { + const normalized = normalizeBase(base); + if (normalized) + return normalized; + } + } + } + catch (_) { } + return ''; + } + function emit(state) { + listeners.forEach((fn) => { + try { + fn(state); + } + catch (err) { + if (w.AIDebug) + console.warn('[BackendHealth] listener error', err); + } + }); + try { + w.dispatchEvent(new CustomEvent(EVENT_NAME, { detail: state })); + } + catch (_) { } + } + let current = { + status: 'idle', + backendBase: fallbackBase(), + lastUpdated: now(), + message: undefined, + lastError: undefined + }; + function update(partial) { + var _a, _b; + const next = { + ...current, + ...partial, + backendBase: normalizeBase((_b = (_a = partial.backendBase) !== null && _a !== void 0 ? _a : current.backendBase) !== null && _b !== void 0 ? _b : fallbackBase()), + lastUpdated: now() + }; + const changed = next.status !== current.status || + next.backendBase !== current.backendBase || + next.message !== current.message || + next.lastError !== current.lastError; + current = next; + if (changed) + emit(current); + } + function describeErrorMessage(message, baseHint) { + const baseLabel = baseHint ? baseHint : (current.backendBase || fallbackBase()); + const prefix = "Can't reach the AI Overhaul backend"; + const suffix = baseLabel ? ` at ${baseLabel}.` : '.'; + const detail = message ? (message.endsWith('.') ? message : `${message}.`) : ''; + const instruction = ' Check that the AI server is running and update the URL under Settings → Tools → AI Overhaul Settings.'; + return `${prefix}${suffix}${detail ? ` ${detail}` : ''}${instruction}`; + } + function reportOk(base) { + const baseUrl = normalizeBase(base); + update({ status: 'ok', backendBase: baseUrl, message: undefined, lastError: undefined, details: undefined }); + } + function reportChecking(base) { + const baseUrl = normalizeBase(base); + update({ status: 'checking', backendBase: baseUrl }); + } + function reportError(base, message, details) { + const baseUrl = normalizeBase(base); + const friendly = describeErrorMessage(message, baseUrl || undefined); + const lastError = typeof details === 'string' ? details : (details && details.message) ? details.message : message; + update({ status: 'error', backendBase: baseUrl, message: friendly, lastError, details }); + } + function subscribe(fn) { + listeners.add(fn); + fn(current); + return () => listeners.delete(fn); + } + function getReact() { + var _a; + return ((_a = w.PluginApi) === null || _a === void 0 ? void 0 : _a.React) || w.React; + } + function useBackendHealth() { + const React = getReact(); + if (!React || !React.useState || !React.useEffect) { + // React may not be ready yet; return the latest state directly + return current; + } + const { useEffect, useState } = React; + const [state, setState] = useState(current); + useEffect(() => subscribe(setState), []); + return state; + } + function buildNotice(state, options = {}) { + const React = getReact(); + if (!React || !React.createElement) + return null; + const snapshot = state || current; + if (!snapshot || snapshot.status !== 'error') + return null; + const retryHandler = options.onRetry; + const message = options.messageOverride || snapshot.message || describeErrorMessage(snapshot.lastError, snapshot.backendBase); + const containerStyle = options.dense ? { + padding: '8px 12px', + borderRadius: 6, + marginBottom: 12, + background: 'rgba(120,0,0,0.35)', + border: '1px solid rgba(255,80,80,0.4)', + color: '#ffd7d7', + fontSize: '13px' + } : { + padding: '12px 16px', + borderRadius: 8, + margin: '12px 0', + background: 'rgba(120,0,0,0.35)', + border: '1px solid rgba(255,80,80,0.4)', + color: '#ffd7d7', + fontSize: '14px', + lineHeight: 1.5, + boxShadow: '0 0 0 1px rgba(0,0,0,0.2) inset' + }; + const children = [ + React.createElement('div', { key: 'title', style: { fontWeight: 600, marginBottom: 6 } }, "Can't reach AI Overhaul backend"), + React.createElement('div', { key: 'body', style: { whiteSpace: 'pre-wrap' } }, message) + ]; + if (retryHandler) { + children.push(React.createElement('div', { key: 'actions', style: { marginTop: options.dense ? 8 : 12 } }, React.createElement('button', { + type: 'button', + onClick: retryHandler, + style: { + background: '#c33', + color: '#fff', + border: '1px solid rgba(255,255,255,0.25)', + borderRadius: 4, + padding: options.dense ? '4px 10px' : '6px 14px', + cursor: 'pointer', + fontSize: options.dense ? '12px' : '13px' + } + }, options.retryLabel || 'Retry now'))); + } + return React.createElement('div', { + key: options.key || 'ai-backend-offline', + className: options.className || 'ai-backend-offline-alert', + style: containerStyle + }, children); + } + const api = { + reportOk, + reportChecking, + reportError, + useBackendHealth, + buildNotice, + getState: () => current, + subscribe, + EVENT_NAME + }; + w.AIBackendHealth = api; +})(); +})(); + diff --git a/plugins/AIOverhaul/InteractionTracker.js b/plugins/AIOverhaul/InteractionTracker.js new file mode 100644 index 00000000..bbde2b42 --- /dev/null +++ b/plugins/AIOverhaul/InteractionTracker.js @@ -0,0 +1,1855 @@ +(function(){ +// ============================================================================= +// InteractionTracker - Core user interaction & consumption analytics +// ============================================================================= +// Purpose: Collect ONLY events useful for recommendation systems while keeping +// implementation lightweight and decoupled from UI components. +// +// Design Goals: +// * Minimal public API; internal batching + robustness. +// * Data model optimized for downstream recommendation pipelines. +// * Focus on scenes (video consumption), images, galleries. Extendable. +// * Session-scoped (tab/sessionStorage) with soft continuation if same tab. +// * Graceful offline: localStorage queue + retry. sendBeacon on unload. +// * Avoid over-emitting: aggregate watch segments; throttle progress. +// * No dependency on legacy messy trackers; selectively inspired only. +// +// NOTE: This file intentionally avoids React imports so it can be built as a +// standalone IIFE like other integration utilities. +// ============================================================================= +void 0; +const NUMERIC_ENTITY_TYPES = new Set(['scene', 'image', 'gallery']); +function hashToUint32(value) { + let hash = 0x811c9dc5; // FNV-1a 32-bit offset basis + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + const out = hash >>> 0; + return out === 0 ? 1 : out; // avoid zero to keep sentinel-free +} +function normalizeEntityIdForEvent(entityType, entityId) { + if (entityId === null || entityId === undefined) + throw new Error(`missing entity id for ${entityType}`); + if (typeof entityId === 'number' && Number.isFinite(entityId)) { + return Math.trunc(entityId); + } + const raw = String(entityId).trim(); + if (!raw) + throw new Error(`missing entity id for ${entityType}`); + if (NUMERIC_ENTITY_TYPES.has(entityType)) { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) + throw new Error(`expected numeric entity id for ${entityType}, received ${entityId}`); + return Math.trunc(parsed); + } + return hashToUint32(`${entityType}:${raw}`); +} +// Resolve backend base using the shared helper when available. +function _resolveBackendBase() { + const globalFn = window.AIDefaultBackendBase; + if (typeof globalFn !== 'function') + throw new Error('AIDefaultBackendBase not initialized. Ensure backendBase is loaded first.'); + return globalFn(); +} +// ------------------------------ Tracker Class ------------------------------ +class InteractionTracker { + static get instance() { return this._instance || (this._instance = new InteractionTracker()); } + constructor() { + this.queue = []; + this.flushTimer = null; + this.pageVisibilityHandler = null; + this.beforeUnloadHandler = null; + this.lastEntityView = null; + this.initialized = false; + this.lastScenePageEntered = null; // track current scene page for leave events + this.lastLibrarySearchSignature = null; // dedupe library_search emissions + this.lastSceneViewSceneId = null; // dedupe rapid successive scene_view emissions + this.lastSceneViewAt = null; // epoch ms of last accepted scene_view + this.lastDetailKey = null; // prevent duplicate view events + this.videoJsRetryTimers = new Map(); + this.playerReinstrumentTimers = new WeakMap(); + this.videoJsHooksInstalled = false; + this.pendingVideoJsPlayers = new Set(); + this.trackedVideoJsPlayers = new WeakSet(); + this.videoJsDomObserver = null; + this.videoJsFallbackActiveFor = null; + this.videoJsFallbackTimer = null; + this.videoJsPrimaryIds = ['VideoJsPlayer']; + this.flushInFlight = false; + this.cfg = this.buildConfig({}); + this.sessionId = this.ensureSession(); + this.clientId = this.ensureClientId(); + this.restoreQueue(); + this.bootstrap(); + } + configure(partial) { + this.cfg = this.buildConfig(partial); + } + buildConfig(partial) { + var _a; + const resolved = ((_a = partial.endpoint) !== null && _a !== void 0 ? _a : _resolveBackendBase()).replace(/\/$/, ''); + let storedEnabled = false; + try { + const flag = window.__AI_INTERACTIONS_ENABLED__; + if (typeof flag === 'boolean') + storedEnabled = flag; + } + catch { } + const base = { + endpoint: resolved, + batchPath: '/api/v1/interactions/sync', + sendIntervalMs: 5000, + maxBatchSize: 40, + progressThrottleMs: 5000, + immediateTypes: ['session_start', 'scene_watch_complete'], + localStorageKey: 'ai_overhaul_event_queue', + maxQueueLength: 1000, + debug: false, // default off; can be toggled via enableInteractionDebug() + autoDetect: true, + integratePageContext: true, + videoAutoInstrument: true, + enabled: storedEnabled + }; + const merged = { ...base, ...partial }; + if (partial.enabled !== undefined) + merged.enabled = !!partial.enabled; + try { + window.__AI_INTERACTIONS_ENABLED__ = merged.enabled; + } + catch { } + return merged; + } + ensureSession() { + let id = sessionStorage.getItem('ai_overhaul_session_id'); + if (!id) { + id = 'sess_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + sessionStorage.setItem('ai_overhaul_session_id', id); + } + return id; + } + ensureClientId() { + try { + let id = localStorage.getItem('ai_overhaul_client_id'); + if (!id) { + id = 'client_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); + localStorage.setItem('ai_overhaul_client_id', id); + } + return id; + } + catch (e) { + return 'client_unknown'; + } + } + bootstrap() { + if (this.initialized) + return; + this.initialized = true; + this.trackInternal('session_start', 'session', 'session', { started_at: Date.now() }); + this.startFlushTimer(); + this.installLifecycleHandlers(); + if (this.cfg.autoDetect) + this.tryAutoDetect(); + if (this.cfg.integratePageContext) + this.tryIntegratePageContext(); + this.installVideoJsHooks(); + // Capture library search from URL on init (e.g., /scenes?search=...) + try { + this.scanForLibrarySearch(); + } + catch (e) { /* ignore */ } + try { + this.installLibraryListeners(); + } + catch (e) { /* ignore */ } + } + // Lightweight debounce helper + debounce(fn, wait = 300) { + let t = null; + return (...args) => { if (t) + clearTimeout(t); t = setTimeout(() => fn(...args), wait); }; + } + // Install listeners to detect library search inputs and filter changes + installLibraryListeners() { + try { + // remove previous listeners if any by storing on window (best-effort cleanup) + if (window.__ai_lib_listeners_installed) + return; + window.__ai_lib_listeners_installed = true; + const collectFilters = (target) => { + const out = {}; + try { + // If we have a target, prefer scanning its nearest filter-related ancestor + const findFilterContainer = (el) => { + let node = el; + while (node) { + const cls = (node.className || '').toString().toLowerCase(); + if (cls && /filter|filters|filter-panel|facets|facets-panel|sidebar|search-controls/.test(cls)) + return node; + node = node.parentElement; + } + return null; + }; + let scope = document; + if (target) { + const container = findFilterContainer(target); + if (container) + scope = container; + } + // Collect inputs/selects within the chosen scope + const nodes = Array.from(scope.querySelectorAll('input,select')); + for (const n of nodes) { + const name = (n.name || n.getAttribute('data-filter') || n.id || '').toString(); + const cls = (n.className || '').toString().toLowerCase(); + // Accept anything that looks like a filter control or has an explicit data-filter + const likely = name || cls || n.getAttribute('data-filter'); + if (!likely) + continue; + if (!(name.toLowerCase().includes('filter') || cls.includes('filter') || cls.includes('tag') || cls.includes('performer') || name.toLowerCase().includes('tag') || n.hasAttribute('data-filter'))) { + // If we're scoped to a container, accept any control inside it + if (scope === document) + continue; // global scan should still be conservative + } + const key = name || n.id || (n.getAttribute('data-filter') || cls || 'filter'); + if (n.type === 'checkbox') { + out[key] = n.checked; + } + else if (n.type === 'radio') { + if (n.checked) + out[key] = n.value; + } + else { + out[key] = n.value; + } + } + } + catch (e) { /* ignore */ } + return out; + }; + // Input handler for text search boxes + const onInput = this.debounce((ev) => { + try { + const t = ev.target; + if (!t) + return; + const val = (t.value || '').trim(); + if (val.length < 2) + return; + // Heuristic: only treat as library search if on a library page + const p = location.pathname || ''; + if (p.match(/\/scenes(\/|$)/i)) { + this.trackLibrarySearch('scenes', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/images(\/|$)/i)) { + this.trackLibrarySearch('images', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/galleries(\/|$)/i)) { + this.trackLibrarySearch('galleries', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/performers(\/|$)/i)) { + this.trackLibrarySearch('performers', val, { source: 'input', page_url: location.href }); + } + else if (p.match(/\/tags(\/|$)/i)) { + this.trackLibrarySearch('tags', val, { source: 'input', page_url: location.href }); + } + } + catch (e) { /* ignore */ } + }, 600); + document.addEventListener('input', (ev) => { + try { + const target = ev.target; + if (!target) + return; + // Only consider text inputs likely to be search boxes + const isText = target.tagName === 'INPUT' && (target.type === 'text' || target.type === 'search'); + const placeholder = (target.placeholder || '').toLowerCase(); + const name = (target.name || '').toLowerCase(); + if (isText && (placeholder.includes('search') || name.includes('search') || target.className.toLowerCase().includes('search'))) { + onInput(ev); + } + } + catch (e) { } + }, true); + // Change handler for selects/checkboxes used as filters + const onChange = this.debounce((ev) => { + var _a, _b; + try { + const p = location.pathname || ''; + let lib = null; + if (p.match(/\/scenes(\/|$)/i)) + lib = 'scenes'; + else if (p.match(/\/images(\/|$)/i)) + lib = 'images'; + else if (p.match(/\/galleries(\/|$)/i)) + lib = 'galleries'; + else if (p.match(/\/performers(\/|$)/i)) + lib = 'performers'; + else if (p.match(/\/tags(\/|$)/i)) + lib = 'tags'; + if (!lib) + return; + const target = (ev && ev.target) || null; + let filters = collectFilters(target); + // If no filters found, try to derive a single-control filter from the changed element. + // This helps cases where the performers page uses controls without explicit "filter" names/classes. + if (Object.keys(filters).length === 0 && target) { + try { + const el = target; + if (el) { + let key = (el.getAttribute('name') || el.getAttribute('data-filter') || el.id || el.className || '').toString(); + key = key.trim() || (el.getAttribute('data-filter') || el.id || el.className || 'filter'); + let value = null; + if (el.tagName && el.tagName.toLowerCase() === 'input') { + const inp = el; + if (inp.type === 'checkbox') + value = inp.checked; + else if (inp.type === 'radio') { + if (inp.checked) + value = inp.value; + } + else + value = inp.value; + } + else if (el.tagName && el.tagName.toLowerCase() === 'select') { + value = el.value; + } + else { + // fallback: try dataset or text + value = (_b = (_a = el.value) !== null && _a !== void 0 ? _a : el.dataset) !== null && _b !== void 0 ? _b : null; + } + if (value !== null && value !== undefined && !(typeof value === 'string' && String(value).trim() === '')) { + filters = { [String(key)]: value }; + } + } + } + catch (e) { /* ignore */ } + } + if (Object.keys(filters).length === 0) + return; + this.trackLibrarySearch(lib, undefined, { source: 'filters', filters, page_url: location.href }); + } + catch (e) { /* ignore */ } + }, 400); + document.addEventListener('change', (ev) => { + try { + const target = ev.target; + if (!target) + return; + const tag = target.tagName.toLowerCase(); + if (tag === 'select' || (tag === 'input' && (target.type === 'checkbox' || target.type === 'radio'))) { + onChange(ev); + } + } + catch (e) { } + }, true); + // Re-scan on navigation via history API + const hookNav = (orig) => { + return function (...args) { + const res = orig.apply(this, args); + try { + setTimeout(() => { var _a, _b; (_b = (_a = window.stashAIInteractionTracker) === null || _a === void 0 ? void 0 : _a.scanForLibrarySearch) === null || _b === void 0 ? void 0 : _b.call(_a); }, 100); + } + catch { } + return res; + }; + }; + const origPush = history.pushState; + const origReplace = history.replaceState; + history.pushState = hookNav(origPush); + history.replaceState = hookNav(origReplace); + window.addEventListener('popstate', () => { try { + this.scanForLibrarySearch(); + } + catch { } }); + } + catch (e) { + // swallow errors; this is non-essential + } + } + tryAutoDetect() { + const run = () => { + try { + const url = window.location.href; + let sceneId = null; + const sceneMatch = url.match(/scenes\/(\d+)/i); + if (sceneMatch) + sceneId = sceneMatch[1]; + const params = new URLSearchParams(window.location.search); + if (!sceneId && params.get('sceneId')) + sceneId = params.get('sceneId'); + if (sceneId) { + this.log('auto-detect scene id', sceneId); + this.trackSceneView(sceneId); + if (this.cfg.videoAutoInstrument) + this.ensureVideoInstrumentation(sceneId); + } + else { + this.log('auto-detect: no scene id pattern matched'); + } + } + catch (e) { + this.log('auto-detect failed', e); + } + }; + if (document.readyState === 'loading') + document.addEventListener('DOMContentLoaded', run); + else + run(); + } + installVideoJsHooks() { + var _a; + if (this.videoJsHooksInstalled) + return; + const attachIfReady = (candidate) => { + if (!candidate || typeof candidate.hook !== 'function') + return false; + if (this.videoJsHooksInstalled) + return true; + this.videoJsHooksInstalled = true; + try { + candidate.hook('setup', (player) => { + try { + this.handleVideoJsPlayerRegistration(player); + } + catch (err) { + if (this.cfg.debug) + this.log('videojs setup hook error', err); + } + }); + } + catch (err) { + if (this.cfg.debug) + this.log('videojs hook registration failed', err); + } + this.instrumentExistingVideoJsPlayers(); + return true; + }; + if (attachIfReady(window.videojs)) + return; + const descriptor = Object.getOwnPropertyDescriptor(window, 'videojs'); + if (descriptor && !descriptor.configurable) { + window.addEventListener('videojsready', (event) => { + var _a; + const value = (_a = event === null || event === void 0 ? void 0 : event.detail) !== null && _a !== void 0 ? _a : window.videojs; + attachIfReady(value); + }); + } + else { + let stored = descriptor === null || descriptor === void 0 ? void 0 : descriptor.value; + const originalGetter = descriptor === null || descriptor === void 0 ? void 0 : descriptor.get; + const originalSetter = descriptor === null || descriptor === void 0 ? void 0 : descriptor.set; + Object.defineProperty(window, 'videojs', { + configurable: true, + enumerable: (_a = descriptor === null || descriptor === void 0 ? void 0 : descriptor.enumerable) !== null && _a !== void 0 ? _a : true, + get() { + if (originalGetter) + return originalGetter.call(window); + return stored; + }, + set(value) { + if (originalSetter) { + originalSetter.call(window, value); + } + else { + stored = value; + } + const current = originalGetter ? originalGetter.call(window) : value; + attachIfReady(current); + }, + }); + const initial = originalGetter ? originalGetter.call(window) : stored; + attachIfReady(initial); + } + if (!this.videoJsHooksInstalled) { + let tries = 0; + const maxTries = 40; + const retry = () => { + if (attachIfReady(window.videojs)) + return; + if (tries >= maxTries) + return; + tries += 1; + setTimeout(retry, 250); + }; + retry(); + } + } + activateVideoJsFallbackMonitor(sceneId) { + const requested = sceneId !== null && sceneId !== void 0 ? sceneId : null; + const scope = () => document.body || document.documentElement; + const observerHandler = (mutations) => { + for (const mut of mutations) { + mut.addedNodes.forEach(node => this.scanNodeForVideoJsPlayers(node)); + } + }; + if (this.videoJsDomObserver) { + this.videoJsFallbackActiveFor = requested; + const target = scope(); + if (target) + this.scanNodeForVideoJsPlayers(target); + this.refreshVideoJsFallbackTimer(); + return; + } + try { + const target = scope(); + if (!target) + return; + this.videoJsDomObserver = new MutationObserver(observerHandler); + this.videoJsDomObserver.observe(target, { childList: true, subtree: true }); + this.videoJsFallbackActiveFor = requested; + this.scanNodeForVideoJsPlayers(target); + this.refreshVideoJsFallbackTimer(); + } + catch (err) { + if (this.cfg.debug) + this.log('videoJs fallback observer attach failed', err); + } + } + deactivateVideoJsFallbackMonitor(sceneId) { + if (!this.videoJsDomObserver) + return; + if (sceneId && this.videoJsFallbackActiveFor && this.videoJsFallbackActiveFor !== sceneId) + return; + try { + this.videoJsDomObserver.disconnect(); + } + catch { } + this.videoJsDomObserver = null; + this.videoJsFallbackActiveFor = null; + if (this.videoJsFallbackTimer !== null) { + window.clearTimeout(this.videoJsFallbackTimer); + this.videoJsFallbackTimer = null; + } + } + refreshVideoJsFallbackTimer() { + if (this.videoJsFallbackTimer !== null) { + window.clearTimeout(this.videoJsFallbackTimer); + } + this.videoJsFallbackTimer = window.setTimeout(() => { + this.videoJsFallbackTimer = null; + if (this.pendingVideoJsPlayers.size > 0) { + this.refreshVideoJsFallbackTimer(); + return; + } + this.deactivateVideoJsFallbackMonitor(); + }, 30000); + } + scanNodeForVideoJsPlayers(node) { + var _a; + if (!(node instanceof HTMLElement)) + return; + const roots = []; + if (this.isVideoJsRoot(node)) + roots.push(node); + (_a = node.querySelectorAll) === null || _a === void 0 ? void 0 : _a.call(node, '.video-js, video-js').forEach(el => { + if (el instanceof HTMLElement && this.isVideoJsRoot(el)) + roots.push(el); + }); + for (const root of roots) { + const player = this.extractVideoJsPlayer(root); + if (player && this.shouldInstrumentVideoJsPlayer(player, { root })) { + this.handleVideoJsPlayerRegistration(player, { root }); + } + } + } + isVideoJsRoot(el) { + var _a; + if (!el) + return false; + if ((_a = el.classList) === null || _a === void 0 ? void 0 : _a.contains('video-js')) + return true; + if (el.tagName && el.tagName.toUpperCase() === 'VIDEO-JS') + return true; + if (el.hasAttribute && el.hasAttribute('data-vjs-player')) + return true; + return false; + } + extractVideoJsPlayer(el) { + if (!el) + return null; + const root = this.resolveVideoJsRootElement(el); + if (!root) + return null; + const candidate = root.player || root.player_ || root.Player || null; + if (candidate && typeof candidate.ready === 'function') + return candidate; + const vjs = window.videojs; + if (vjs) { + try { + if (typeof vjs.getPlayer === 'function') { + const fromId = root.id ? vjs.getPlayer(root.id) : null; + if (fromId) + return fromId; + const viaRoot = vjs.getPlayer(root); + if (viaRoot) + return viaRoot; + } + } + catch { } + } + // schedule a short retry in case player is attached asynchronously + setTimeout(() => { + const later = root.player || root.player_; + if (later && typeof later.ready === 'function') + this.handleVideoJsPlayerRegistration(later); + }, 0); + return null; + } + resolveVideoJsRootElement(el) { + var _a; + if (this.isVideoJsRoot(el)) + return el; + const found = (_a = el.querySelector) === null || _a === void 0 ? void 0 : _a.call(el, '.video-js, video-js'); + return found instanceof HTMLElement ? found : null; + } + getVideoJsRootFromPlayer(player) { + if (!player) + return null; + try { + const el = typeof player.el === 'function' ? player.el() : player.el; + if (el instanceof HTMLElement) + return el; + } + catch { } + try { + const alt = player.el_; + if (alt instanceof HTMLElement) + return alt; + } + catch { } + return null; + } + getVideoJsPlayerId(player) { + if (!player) + return null; + try { + if (typeof player.id === 'function') { + const id = player.id(); + if (id) + return String(id); + } + } + catch { } + try { + const id = player.id_; + if (id) + return String(id); + } + catch { } + return null; + } + isPrimaryVideoJsRoot(root) { + var _a, _b; + if (!root) + return false; + const lowerId = (root.id || '').toLowerCase(); + for (const id of this.videoJsPrimaryIds) { + if (lowerId === id.toLowerCase()) + return true; + if (root.closest && root.closest(`#${id}`)) + return true; + } + const marker = ((_a = root.getAttribute) === null || _a === void 0 ? void 0 : _a.call(root, 'data-player-id')) || ((_b = root.getAttribute) === null || _b === void 0 ? void 0 : _b.call(root, 'data-vjs-player-id')); + if (marker) { + const lower = marker.toLowerCase(); + for (const id of this.videoJsPrimaryIds) { + if (lower.includes(id.toLowerCase())) + return true; + } + } + return false; + } + shouldInstrumentVideoJsPlayer(player, ctx) { + var _a, _b, _c; + if (!player) + return false; + const primaryIds = this.videoJsPrimaryIds; + const playerId = (_b = (_a = this.getVideoJsPlayerId(player)) === null || _a === void 0 ? void 0 : _a.toLowerCase()) !== null && _b !== void 0 ? _b : null; + if (playerId && primaryIds.some(id => playerId === id.toLowerCase() || playerId.includes(id.toLowerCase()))) + return true; + const root = (_c = ctx === null || ctx === void 0 ? void 0 : ctx.root) !== null && _c !== void 0 ? _c : this.getVideoJsRootFromPlayer(player); + if (root && this.isPrimaryVideoJsRoot(root)) + return true; + const tech = this.getVideoJsTechElement(player); + if (tech === null || tech === void 0 ? void 0 : tech.id) { + const techId = tech.id.toLowerCase(); + if (primaryIds.some(id => techId === id.toLowerCase() || techId.includes(id.toLowerCase()))) + return true; + } + return false; + } + instrumentExistingVideoJsPlayers() { + try { + const players = this.collectVideoJsPlayers(); + for (const player of players) { + const root = this.getVideoJsRootFromPlayer(player); + if (!this.shouldInstrumentVideoJsPlayer(player, { root })) + continue; + this.handleVideoJsPlayerRegistration(player); + } + } + catch (err) { + if (this.cfg.debug) + this.log('instrumentExistingVideoJsPlayers failed', err); + } + } + handleVideoJsPlayerRegistration(player, ctx) { + var _a; + if (!player) + return; + const initialRoot = (_a = ctx === null || ctx === void 0 ? void 0 : ctx.root) !== null && _a !== void 0 ? _a : this.getVideoJsRootFromPlayer(player); + if (!this.shouldInstrumentVideoJsPlayer(player, { root: initialRoot })) { + if (this.cfg.debug) + this.log('skipping non-primary videojs player', { playerId: this.getVideoJsPlayerId(player) }); + return; + } + if (this.trackedVideoJsPlayers.has(player)) + return; + this.trackedVideoJsPlayers.add(player); + try { + player.on('dispose', () => { + this.pendingVideoJsPlayers.delete(player); + }); + } + catch { } + player.ready(() => { + var _a; + const readyRoot = (_a = this.getVideoJsRootFromPlayer(player)) !== null && _a !== void 0 ? _a : initialRoot; + if (!this.shouldInstrumentVideoJsPlayer(player, { root: readyRoot })) { + if (this.cfg.debug) + this.log('skipping non-primary videojs player (ready)', { playerId: this.getVideoJsPlayerId(player) }); + return; + } + const sceneId = this.resolveSceneIdFromContext(); + if (!sceneId) { + this.pendingVideoJsPlayers.add(player); + return; + } + const success = this.instrumentSceneWithVideoJs(sceneId, { player }); + if (!success) { + this.pendingVideoJsPlayers.add(player); + this.queuePlayerReinstrument(sceneId, player); + } + else { + this.pendingVideoJsPlayers.delete(player); + } + }); + } + resolveSceneIdFromContext() { + var _a; + if ((_a = this.currentScene) === null || _a === void 0 ? void 0 : _a.sceneId) + return this.currentScene.sceneId; + if (this.lastScenePageEntered) + return this.lastScenePageEntered; + return this.extractSceneIdFromLocation(); + } + extractSceneIdFromLocation() { + try { + const url = window.location.href; + const match = url.match(/scenes\/(\d+)/i); + if (match) + return match[1]; + const params = new URLSearchParams(window.location.search); + const fromParam = params.get('sceneId') || params.get('id'); + return fromParam || null; + } + catch { + return null; + } + } + flushPendingVideoJsPlayers(sceneId) { + if (!this.pendingVideoJsPlayers.size) + return; + for (const player of Array.from(this.pendingVideoJsPlayers)) { + const success = this.instrumentSceneWithVideoJs(sceneId, { player }); + if (success) { + this.pendingVideoJsPlayers.delete(player); + } + else { + this.queuePlayerReinstrument(sceneId, player); + } + } + } + tryIntegratePageContext() { + const attach = () => { + const api = window.AIPageContext; + if (!api || typeof api.subscribe !== 'function') { + this.log('PageContext not ready, retrying...'); + setTimeout(attach, 1000); + return; + } + api.subscribe((ctx) => this.handlePageContext(ctx)); + this.log('subscribed to AIPageContext'); + try { + this.handlePageContext(api.get()); + } + catch { } + }; + attach(); + } + handlePageContext(ctx) { + if (!ctx) + return; + if (!ctx.isDetailView || !ctx.entityId) + return; + const key = ctx.page + ':' + ctx.entityId; + if (key === this.lastDetailKey) + return; + this.lastDetailKey = key; + switch (ctx.page) { + case 'scenes': + this.trackSceneView(ctx.entityId, { from: 'PageContext' }); + if (this.cfg.videoAutoInstrument) + this.ensureVideoInstrumentation(ctx.entityId); + break; + case 'images': + this.trackImageView(ctx.entityId, { title: ctx.detailLabel }); + break; + case 'galleries': + this.trackGalleryView(ctx.entityId, { title: ctx.detailLabel }); + break; + default: + break; + } + } + ensureVideoInstrumentation(sceneId) { + this.flushPendingVideoJsPlayers(sceneId); + const existing = this.currentScene; + if (existing && existing.sceneId === sceneId && existing.video && existing.player) + return; + if (this.instrumentSceneWithVideoJs(sceneId)) { + this.deactivateVideoJsFallbackMonitor(sceneId); + return; + } + this.activateVideoJsFallbackMonitor(sceneId); + this.scheduleSceneVideoRetry(sceneId, 1); + } + scheduleSceneVideoRetry(sceneId, attempt) { + this.cancelSceneVideoRetry(sceneId); + const maxAttempts = 12; + if (attempt > maxAttempts) { + this.log('videojs instrumentation failed after retries', { sceneId, attempt }); + if (typeof console !== 'undefined' && console.error) { + console.error('[InteractionTracker] videojs instrumentation failed', { sceneId, attempt }); + } + this.deactivateVideoJsFallbackMonitor(sceneId); + return; + } + this.activateVideoJsFallbackMonitor(sceneId); + const delay = Math.min(1200, 150 * attempt); + const handle = window.setTimeout(() => { + this.videoJsRetryTimers.delete(sceneId); + const success = this.instrumentSceneWithVideoJs(sceneId, { attempt }); + if (!success) + this.scheduleSceneVideoRetry(sceneId, attempt + 1); + }, delay); + this.videoJsRetryTimers.set(sceneId, handle); + } + cancelSceneVideoRetry(sceneId) { + const handle = this.videoJsRetryTimers.get(sceneId); + if (handle !== undefined) { + window.clearTimeout(handle); + this.videoJsRetryTimers.delete(sceneId); + } + } + instrumentSceneWithVideoJs(sceneId, opts) { + var _a, _b, _c; + const attempt = (_a = opts === null || opts === void 0 ? void 0 : opts.attempt) !== null && _a !== void 0 ? _a : 0; + const player = (_c = (_b = opts === null || opts === void 0 ? void 0 : opts.player) !== null && _b !== void 0 ? _b : this.getDefaultVideoJsPlayer()) !== null && _c !== void 0 ? _c : this.resolveActiveVideoJsPlayer(); + if (!player) { + if (attempt > 0) + this.log('videojs player unavailable', { sceneId, attempt }); + return false; + } + if (typeof player.isDisposed === 'function' && player.isDisposed()) { + this.log('videojs player disposed', { sceneId }); + return false; + } + const tech = this.getVideoJsTechElement(player); + if (!tech) { + this.log('videojs tech element missing', { sceneId, attempt }); + return false; + } + this.cancelSceneVideoRetry(sceneId); + this.instrumentSceneVideo(sceneId, tech, player); + this.deactivateVideoJsFallbackMonitor(sceneId); + return true; + } + getDefaultVideoJsPlayer() { + try { + const vjs = window.videojs; + if (!vjs || typeof vjs.getPlayer !== 'function') + return null; + const player = vjs.getPlayer('VideoJsPlayer'); + if (player) + return player; + } + catch { } + return null; + } + collectVideoJsPlayers() { + const vjs = window.videojs; + if (!vjs) + return []; + const seen = new Set(); + const out = []; + if (typeof vjs.getPlayers === 'function') { + try { + const players = vjs.getPlayers(); + if (players && typeof players === 'object') { + for (const key of Object.keys(players)) { + const player = players[key]; + if (player && !seen.has(player)) { + seen.add(player); + out.push(player); + } + } + } + } + catch { } + } + try { + const wrappers = Array.from(document.querySelectorAll('.video-js')); + for (const wrapper of wrappers) { + let player = null; + if (typeof vjs.getPlayer === 'function') { + try { + player = vjs.getPlayer(wrapper); + } + catch { } + } + if (!player) { + try { + player = typeof vjs === 'function' ? vjs(wrapper) : null; + } + catch { } + } + if (player && !seen.has(player)) { + seen.add(player); + out.push(player); + } + } + } + catch { } + return out; + } + resolveActiveVideoJsPlayer() { + var _a, _b; + const primary = this.getDefaultVideoJsPlayer(); + if (primary && (!(typeof primary.isDisposed === 'function') || !primary.isDisposed())) + return primary; + const candidates = this.collectVideoJsPlayers().filter(p => { + try { + return !(typeof p.isDisposed === 'function' && p.isDisposed()); + } + catch { + return true; + } + }).filter(p => this.shouldInstrumentVideoJsPlayer(p)); + if (!candidates.length) + return null; + const scored = candidates.map(player => ({ player, score: this.scoreVideoJsPlayer(player) })); + scored.sort((a, b) => b.score - a.score); + return (_b = (_a = scored[0]) === null || _a === void 0 ? void 0 : _a.player) !== null && _b !== void 0 ? _b : null; + } + scoreVideoJsPlayer(player) { + let score = 0; + try { + const el = typeof player.el === 'function' ? player.el() : player.el; + score += this.isElementLikelyVisible(el) ? 40 : -30; + } + catch { } + try { + if (typeof player.paused === 'function' && !player.paused()) + score += 25; + } + catch { } + try { + const ready = typeof player.readyState === 'function' ? player.readyState() : player.readyState; + if (typeof ready === 'number' && ready >= 2) + score += 10; + } + catch { } + try { + const source = typeof player.currentSource === 'function' ? player.currentSource() : null; + const src = (source === null || source === void 0 ? void 0 : source.src) || (typeof player.currentSrc === 'function' ? player.currentSrc() : ''); + if (src) { + score += 5; + if (/transcode|stream|m3u8/i.test(src)) + score += 5; + } + } + catch { } + try { + if (typeof player.hasStarted === 'function' && player.hasStarted()) + score += 10; + } + catch { } + return score; + } + isElementLikelyVisible(el) { + var _a; + try { + if (!el) + return false; + if (!(el instanceof HTMLElement)) + return true; + if (!el.isConnected) + return false; + const style = window.getComputedStyle(el); + if (style) { + if (style.display === 'none' || style.visibility === 'hidden') + return false; + if (style.opacity !== undefined && parseFloat(style.opacity) === 0) + return false; + } + const rect = (_a = el.getBoundingClientRect) === null || _a === void 0 ? void 0 : _a.call(el); + if (rect && (rect.width < 2 || rect.height < 2)) + return false; + return true; + } + catch { + return true; + } + } + getVideoJsTechElement(player) { + if (!player) + return null; + const extract = (candidate) => { + if (!candidate) + return null; + if (candidate instanceof HTMLVideoElement) + return candidate; + try { + const el = typeof candidate.el === 'function' ? candidate.el() : candidate.el; + if (el instanceof HTMLVideoElement) + return el; + if (el && el.querySelector) { + const nested = el.querySelector('video'); + if (nested instanceof HTMLVideoElement) + return nested; + } + } + catch { } + try { + const elAlt = candidate.el_; + if (elAlt instanceof HTMLVideoElement) + return elAlt; + if (elAlt && elAlt.querySelector) { + const nested = elAlt.querySelector('video'); + if (nested instanceof HTMLVideoElement) + return nested; + } + } + catch { } + return null; + }; + let tech = null; + try { + tech = typeof player.tech === 'function' ? player.tech(true) : player.tech_; + } + catch { } + const viaTech = extract(tech); + if (viaTech) + return viaTech; + const viaPlayer = extract(player); + if (viaPlayer) + return viaPlayer; + try { + const root = typeof player.el === 'function' ? player.el() : player.el; + if (root && root.querySelector) { + const candidate = root.querySelector('video'); + if (candidate instanceof HTMLVideoElement) + return candidate; + } + } + catch { } + return null; + } + readCurrentTime(player, fallbackVideo) { + try { + if (player && typeof player.currentTime === 'function') { + const val = player.currentTime(); + if (typeof val === 'number' && Number.isFinite(val)) + return val; + } + } + catch { } + try { + if (fallbackVideo && typeof fallbackVideo.currentTime === 'number' && Number.isFinite(fallbackVideo.currentTime)) { + return fallbackVideo.currentTime; + } + } + catch { } + return null; + } + readDuration(player, fallbackVideo) { + try { + if (player && typeof player.duration === 'function') { + const val = player.duration(); + if (typeof val === 'number' && Number.isFinite(val) && val >= 0) + return val; + } + } + catch { } + try { + if (fallbackVideo && typeof fallbackVideo.duration === 'number' && Number.isFinite(fallbackVideo.duration) && fallbackVideo.duration >= 0) { + return fallbackVideo.duration; + } + } + catch { } + return null; + } + getPlaybackSnapshot(state) { + var _a, _b; + const position = this.readCurrentTime(state.player, (_a = state.video) !== null && _a !== void 0 ? _a : null); + let duration = this.readDuration(state.player, (_b = state.video) !== null && _b !== void 0 ? _b : null); + if (!duration && state.duration && Number.isFinite(state.duration)) { + duration = state.duration; + } + if (duration && (!state.duration || Math.abs(state.duration - duration) > 0.5)) { + state.duration = duration; + } + const percent = duration && position !== null && position !== undefined && duration > 0 + ? (position / duration) * 100 + : undefined; + return { + position: position === null ? undefined : position, + duration: duration === null ? undefined : duration, + percent, + }; + } + isPlayerPaused(state) { + try { + if (state.player && typeof state.player.paused === 'function') { + return !!state.player.paused(); + } + } + catch { } + if (state.video) { + try { + return state.video.paused; + } + catch { } + } + return false; + } + isPlayerSeeking(state) { + try { + if (state.player && typeof state.player.seeking === 'function') { + return !!state.player.seeking(); + } + } + catch { } + if (state.video) { + try { + return state.video.seeking; + } + catch { } + } + return false; + } + attachVideoJsWatcher(state, sceneId, player) { + if (!player || typeof player.on !== 'function') + return; + if (state.player === player && state.playerDispose) + return; + if (state.playerDispose) + this.detachVideoJsWatcher(state); + const events = ['sourceset', 'loadstart', 'techloadstart', 'playerreset']; + const handler = () => { this.queuePlayerReinstrument(sceneId, player); }; + for (const evt of events) { + try { + player.on(evt, handler); + } + catch { } + } + try { + player.on('ready', handler); + } + catch { } + state.player = player; + state.playerDispose = () => { + for (const evt of events) { + try { + player.off(evt, handler); + } + catch { } + } + try { + player.off('ready', handler); + } + catch { } + this.cancelPlayerReinstrument(player); + state.playerDispose = null; + state.player = null; + }; + } + detachVideoJsWatcher(state) { + if (!state) + return; + const dispose = state.playerDispose; + if (dispose) { + try { + dispose(); + } + catch { } + } + if (state.player) + this.cancelPlayerReinstrument(state.player); + state.playerDispose = null; + state.player = null; + } + queuePlayerReinstrument(sceneId, player, attempt = 0) { + var _a; + if (!player) + return; + this.cancelPlayerReinstrument(player); + this.activateVideoJsFallbackMonitor(sceneId); + try { + if (this.currentScene && this.currentScene.sceneId === sceneId) { + // Close out any active segment before the tech swap replaces the