/** * API Client - Safe fetch wrapper (never throws) */ import { SETTINGS_KEY } from "../app/settingsStore.js"; import { buildWorkflowContentURL, buildWorkflowDiffURL, buildWorkflowValidateURL, buildWorkflowVersionsURL, ENDPOINTS, } from "./endpoints.js"; import { createApiFetchClient } from "./fetchUtils.js"; import { normalizeAssetId, pickRootId } from "../utils/ids.js"; import { createTTLCache } from "../utils/ttlCache.js"; import { readAuthToken as _readAuthToken, ensureWriteAuthToken, normalizeWriteAuthFailure as _normalizeWriteAuthFailure, invalidateAuthTokenCache, hasRuntimeSecurityToken, setRuntimeSecurityToken, } from "./clientAuth.js"; /** * @template T * @typedef {{ * ok: boolean, * data: (T|null), * error?: (string|null), * code?: string, * meta?: any, * status?: number * }} ApiResult */ /** * Compact viewer-oriented media info returned by `/mjr/am/viewer/info`. * * @typedef {{ * kind?: ('image'|'video'|'audio'|'model3d'|'unknown'), * mime?: (string|null), * width?: (number|null), * height?: (number|null), * fps?: (number|string|null), * fps_raw?: (string|null), * frame_count?: (number|null), * duration_s?: (number|null), * loader?: (string|null), * previewable?: (boolean|null), * interactive?: (boolean|null), * resource_endpoint?: (string|null) * }} ViewerInfo */ export { setRuntimeSecurityToken, ensureWriteAuthToken, hasRuntimeSecurityToken }; const TAGS_CACHE_TTL_MS = 30_000; const DEFAULT_TAGS_CACHE_TTL_MS = TAGS_CACHE_TTL_MS; const CLIENT_GLOBAL_KEY = "__MJR_API_CLIENT__"; const SETTINGS_FAST_CACHE_TTL_MS = 2000; const MAX_BATCH_ASSET_IDS = 200; export const VECTOR_BACKFILL_DEFAULT_POLL_INTERVAL_MS = 1000; export const VECTOR_BACKFILL_DEFAULT_POLL_TIMEOUT_MS = 30 * 60_000; export const VECTOR_BACKFILL_MAX_POLL_TIMEOUT_MS = 12 * 60 * 60_000; const SETTINGS_CACHE_KEY = "settings"; const TAGS_CACHE_KEY = "available-tags"; const _obsCache = createTTLCache({ ttlMs: SETTINGS_FAST_CACHE_TTL_MS, maxSize: 1 }); const _rtSyncCache = createTTLCache({ ttlMs: SETTINGS_FAST_CACHE_TTL_MS, maxSize: 1 }); const _tagsCache = createTTLCache({ ttlMs: () => _getTagsCacheTTL(), maxSize: 1 }); const TRUE_VALUES = new Set(["1", "true", "yes", "on"]); const FALSE_VALUES = new Set(["0", "false", "no", "off"]); function _coerceBool(value: any, fallback = false) { if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") { const s = value.trim().toLowerCase(); if (TRUE_VALUES.has(s)) return true; if (FALSE_VALUES.has(s)) return false; } return Boolean(fallback); } function _getTagsCacheTTL() { try { const raw = localStorage?.getItem?.(SETTINGS_KEY) || "{}"; const parsed = JSON.parse(raw); const ttl = parsed?.cache?.tagsTTLms ?? parsed?.cache?.tagsTTL ?? parsed?.cache?.tags_ttl_ms ?? null; const n = Number(ttl); if (!Number.isFinite(n)) return DEFAULT_TAGS_CACHE_TTL_MS; return Math.max(1_000, Math.min(10 * 60_000, Math.floor(n))); } catch { return DEFAULT_TAGS_CACHE_TTL_MS; } } function invalidateObsCache() { _obsCache.clear(); } function invalidateRatingTagsSyncCache() { _rtSyncCache.clear(); } function invalidateTagsCache() { _tagsCache.clear(); } function _normalizeTagCacheKey(raw: any) { const value = String(raw ?? "") .trim() .toLowerCase(); return value || ""; } function _dedupeTagList(tags: any) { const next = []; const seen = new Set(); for (const raw of Array.isArray(tags) ? tags : []) { const value = String(raw ?? "").trim(); if (!value) continue; const key = _normalizeTagCacheKey(value); if (!key || seen.has(key)) continue; seen.add(key); next.push(value); } return next; } // Best-effort cache invalidation when settings change (ComfyUI settings, dev tools, etc.). try { const w = typeof window !== "undefined" ? window : null; if (w && !(w as any)[CLIENT_GLOBAL_KEY]) { (w as any)[CLIENT_GLOBAL_KEY] = { initialized: true }; w.addEventListener?.("storage", (event) => { try { if (event?.key === SETTINGS_KEY) { invalidateObsCache(); invalidateRatingTagsSyncCache(); invalidateTagsCache(); invalidateAuthTokenCache(); } } catch (e) { console.debug?.(e); } }); w.addEventListener?.("mjr-settings-changed", () => { invalidateObsCache(); invalidateRatingTagsSyncCache(); invalidateTagsCache(); invalidateAuthTokenCache(); }); } } catch (e) { console.debug?.(e); } const _readObsEnabled = () => { const cached = _obsCache.get(SETTINGS_CACHE_KEY); if (cached !== undefined) { return cached; } const now = Date.now(); try { const raw = localStorage?.getItem?.(SETTINGS_KEY); if (!raw) { _obsCache.set(SETTINGS_CACHE_KEY, false, { at: now }); return false; } const parsed = JSON.parse(raw); const value = !!parsed?.observability?.enabled; _obsCache.set(SETTINGS_CACHE_KEY, value, { at: now }); return value; } catch { _obsCache.set(SETTINGS_CACHE_KEY, false, { at: now }); return false; } }; const _readRatingTagsSyncEnabled = () => { const cached = _rtSyncCache.get(SETTINGS_CACHE_KEY); if (cached !== undefined) { return cached; } const now = Date.now(); try { const raw = localStorage?.getItem?.(SETTINGS_KEY); if (!raw) { _rtSyncCache.set(SETTINGS_CACHE_KEY, true, { at: now }); return true; } const parsed = JSON.parse(raw); const configured = parsed?.ratingTagsSync?.enabled; const value = configured === undefined || configured === null ? true : _coerceBool(configured, true); _rtSyncCache.set(SETTINGS_CACHE_KEY, value, { at: now }); return value; } catch { _rtSyncCache.set(SETTINGS_CACHE_KEY, true, { at: now }); return true; } }; const _apiFetchClient = createApiFetchClient({ readObsEnabled: _readObsEnabled as () => boolean, readAuthToken: _readAuthToken as () => string, ensureWriteAuthToken: ensureWriteAuthToken as () => Promise, normalizeWriteAuthFailure: _normalizeWriteAuthFailure, }); const fetchAPI = _apiFetchClient.fetchAPI; export async function get(url: string, options: Record = {}) { return _apiFetchClient.get(url, options); } export async function post(url: string, body: unknown, options: Record = {}) { return _apiFetchClient.post(url, body, options); } /** * Update asset rating (0-5 stars) */ export async function updateAssetRating(assetId: any, rating: any, options: Record = {}) { const enabled = _readRatingTagsSyncEnabled(); const asset = assetId && typeof assetId === "object" ? assetId : null; const resolvedId = asset ? asset.id : assetId; const normalizedId = normalizeAssetId(resolvedId); const payload: Record = { rating: Math.max(0, Math.min(5, Number(rating) || 0)), }; if (normalizedId) { payload.asset_id = normalizedId; } else if (asset) { payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || ""; payload.type = asset.type || "output"; payload.root_id = pickRootId(asset); } return fetchAPI("/mjr/am/asset/rating", { ...options, method: "POST", headers: { "Content-Type": "application/json", ...(enabled ? { "X-MJR-RTSYNC": "on" } : {}), }, body: JSON.stringify(payload), }); } /** * Update asset tags */ export async function updateAssetTags(assetId: any, tags: any, options: Record = {}) { const enabled = _readRatingTagsSyncEnabled(); const asset = assetId && typeof assetId === "object" ? assetId : null; const resolvedId = asset ? asset.id : assetId; const normalizedId = normalizeAssetId(resolvedId); const isWorkflowAsset = String(asset?.kind || asset?.type || "").trim().toLowerCase() === "workflow"; const workflowFilepath = String(asset?.filepath || asset?.path || asset?.file_info?.filepath || "").trim(); const payload: Record = { tags: Array.isArray(tags) ? tags : [], }; if (isWorkflowAsset && workflowFilepath) { payload.filepath = workflowFilepath; } else if (normalizedId) { payload.asset_id = normalizedId; } else if (asset) { payload.filepath = asset.filepath || asset.path || asset?.file_info?.filepath || ""; payload.type = asset.type || "output"; payload.root_id = pickRootId(asset); } const endpoint = isWorkflowAsset && workflowFilepath ? ENDPOINTS.WORKFLOWS_TAGS : "/mjr/am/asset/tags"; const result = await fetchAPI(endpoint, { ...options, method: "POST", headers: { "Content-Type": "application/json", ...(enabled ? { "X-MJR-RTSYNC": "on" } : {}), }, body: JSON.stringify(payload), }); if (result?.ok) { invalidateTagsCache(); } return result; } /** * Get all available tags from the database */ export async function getAvailableTags() { const cachedTags = _tagsCache.get(TAGS_CACHE_KEY); if (Array.isArray(cachedTags)) { return { ok: true, data: cachedTags, error: null, code: "OK", meta: { cached: true } }; } const result = await get("/mjr/am/tags"); if (result?.ok && Array.isArray(result.data)) { const deduped = _dedupeTagList(result.data); _tagsCache.set(TAGS_CACHE_KEY, deduped); return { ...result, data: deduped }; } return result; } /** * Get full asset metadata by ID */ export async function getAssetMetadata(assetId: any, options: Record = {}) { const id = encodeURIComponent(normalizeAssetId(assetId)); return get(`/mjr/am/asset/${id}`, { ...options, dedupeKey: options?.dedupeKey || `meta:${id}`, }); } /** * Get compact viewer media info by asset ID (fps/frame count/dimensions/etc). */ /** @returns {Promise>} */ export async function getViewerInfo(assetId: any, options: Record = {}) { const id = normalizeAssetId(assetId); if (!id) return { ok: false, data: null, error: "Missing assetId", code: "INVALID_INPUT" }; let url = `/mjr/am/viewer/info?asset_id=${encodeURIComponent(id)}`; if (options.refresh) url += "&refresh=1"; const { refresh: _ignored, ...fetchOpts } = options; return get(url, fetchOpts); } /** * Batch fetch assets by ID (no per-asset tool invocations / self-heal). */ export async function getAssetsBatch(assetIds: any, options: Record = {}) { const ids = Array.isArray(assetIds) ? assetIds : []; const cleaned = []; for (const id of ids) { const n = Number(id); if (!Number.isFinite(n)) continue; cleaned.push(Math.trunc(n)); if (cleaned.length >= MAX_BATCH_ASSET_IDS) break; } if (!cleaned.length) return { ok: true, data: [], error: null, code: "OK" }; return post("/mjr/am/assets/batch", { asset_ids: cleaned }, options); } export async function getWorkflowContent(filepath: any, options: Record = {}) { const url = buildWorkflowContentURL(filepath); if (!url) { return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" }; } return get(url, options); } export async function validateWorkflow(filepath: any, options: Record = {}) { const url = buildWorkflowValidateURL(filepath); if (!url) { return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" }; } return get(url, options); } export async function listWorkflowVersions(filepath: any, options: Record = {}) { const url = buildWorkflowVersionsURL(filepath); if (!url) { return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" }; } return get(url, options); } export async function diffWorkflow(filepath: any, version_filepath: any = "", options: Record = {}) { const url = buildWorkflowDiffURL(filepath, version_filepath); if (!url) { return { ok: false, data: null, error: "Missing workflow filepath", code: "INVALID_INPUT" }; } return get(url, options); } export async function saveWorkflow( { workflow = null, name = "", category = "", overwrite = false, filepath = "", task = "", model_family = "", provider = "", runs_on = "", notes = "", }: Record = {}, options: Record = {}, ) { return post( ENDPOINTS.WORKFLOWS_SAVE, { workflow, name, category, overwrite, filepath, task, model_family, provider, runs_on, notes }, options, ); } export async function duplicateWorkflow( { filepath = "", name = "" }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_DUPLICATE, { filepath, name }, options); } export async function moveWorkflow( { filepath = "", name = "", category = "" }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_MOVE, { filepath, name, category }, options); } export async function deleteWorkflow( { filepath = "" }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_DELETE, { filepath }, options); } export async function markWorkflowLoaded( { filepath = "" }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_MARK_LOADED, { filepath }, options); } export async function setWorkflowFavorite( { filepath = "", favorite = false }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_FAVORITE, { filepath, favorite: !!favorite }, options); } export async function setWorkflowInfo( { filepath = "", task = "", model_family = "", provider = "", runs_on = "", notes = "", }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_INFO, { filepath, task, model_family, provider, runs_on, notes }, options); } export async function listWorkflowThumbnailCandidates( { filepath = "", limit = 12 }: Record = {}, options: Record = {}, ) { const safeLimit = Math.max(1, Math.min(50, Number(limit) || 12)); const url = `${ENDPOINTS.WORKFLOWS_THUMBNAIL_CANDIDATES}?filepath=${encodeURIComponent( String(filepath || "").trim(), )}&limit=${encodeURIComponent(String(safeLimit))}`; return get(url, options); } export async function listWorkflowModelFamilies(options: Record = {}) { return get(ENDPOINTS.WORKFLOWS_MODEL_FAMILIES, options); } export async function listWorkflows( { q = "*", limit = 100, offset = 0, sort = "mtime" }: Record = {}, options: Record = {}, ) { const safeLimit = Math.max(1, Math.min(500, Number(limit) || 100)); const safeOffset = Math.max(0, Number(offset) || 0); const url = `${ENDPOINTS.LIST}?scope=workflow&q=${encodeURIComponent( String(q || "*"), )}&limit=${encodeURIComponent(String(safeLimit))}&offset=${encodeURIComponent( String(safeOffset), )}&sort=${encodeURIComponent(String(sort || "mtime"))}`; return get(url, options); } export async function setWorkflowThumbnail( { filepath = "", source_filepath = "" }: Record = {}, options: Record = {}, ) { return post(ENDPOINTS.WORKFLOWS_THUMBNAIL_SET, { filepath, source_filepath }, options); } export async function hydrateAssetRatingTags(assetId: any) { const id = normalizeAssetId(assetId); if (!id) return { ok: false, error: "Missing assetId" }; return get(`/mjr/am/asset/${encodeURIComponent(id)}?hydrate=rating_tags`); } /** * Get metadata for a file reference (preferred over absolute paths). * Works on /view URLs where ComfyUI provides type/filename/subfolder. */ export async function getFileMetadataScoped( { type = "output", filename = "", subfolder = "", root_id = "", rootId = "", filepath = "", } = {}, options = {}, ) { const t = String(type || "output") .trim() .toLowerCase() || "output"; const fn = String(filename || "").trim(); const sub = String(subfolder || "").trim(); const rid = String(root_id || rootId || "").trim(); const fp = String(filepath || "").trim(); if (!fn) return { ok: false, data: null, error: "Missing filename", code: "INVALID_INPUT" }; let url = `/mjr/am/metadata?type=${encodeURIComponent(t)}&filename=${encodeURIComponent(fn)}`; if (fp) url += `&filepath=${encodeURIComponent(fp)}`; if (sub) url += `&subfolder=${encodeURIComponent(sub)}`; if (rid) url += `&root_id=${encodeURIComponent(rid)}`; return get(url, options); } export async function getFolderInfo( { filepath = "", root_id = "", subfolder = "" }: Record = {}, options: Record = {}, ) { try { if ((globalThis as any).__mjrFolderInfoSupported === false) { return { ok: false, data: null, error: "Folder info endpoint unavailable", code: "UNAVAILABLE", }; } if ((globalThis as any).__mjrFolderInfoSupported == null) { const rr = await get("/mjr/am/routes"); if (rr?.ok && Array.isArray(rr.data)) { const hasRoute = rr.data.some( (r: any) => String(r?.path || "").trim() === "/mjr/am/folder-info", ); (globalThis as any).__mjrFolderInfoSupported = !!hasRoute; if (!hasRoute) { return { ok: false, data: null, error: "Folder info endpoint unavailable", code: "UNAVAILABLE", }; } } else { // Soft-fail: keep null so future calls can retry route discovery. (globalThis as any).__mjrFolderInfoSupported = null; } } } catch (e) { console.debug?.(e); } const fp = String(filepath || "").trim(); const rid = String(root_id || "").trim(); const sub = String(subfolder || "").trim(); let url = ENDPOINTS.FOLDER_INFO; const params = []; if (fp) { params.push(`filepath=${encodeURIComponent(fp)}`); params.push("browser_mode=1"); } else { if (rid) params.push(`root_id=${encodeURIComponent(rid)}`); if (sub) params.push(`subfolder=${encodeURIComponent(sub)}`); } if (params.length) url += `?${params.join("&")}`; const res = await get(url, options); try { if (!res?.ok && Number(res?.status || 0) === 404) { (globalThis as any).__mjrFolderInfoSupported = false; } } catch (e) { console.debug?.(e); } return res; } export * from "./clientOps.js";