441 lines
14 KiB
JavaScript
441 lines
14 KiB
JavaScript
/**
|
|
* API Client - Safe fetch wrapper (never throws)
|
|
*/
|
|
|
|
import { SETTINGS_KEY } from "../app/settingsStore.js";
|
|
import { 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,
|
|
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 };
|
|
|
|
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, 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) {
|
|
const value = String(raw ?? "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return value || "";
|
|
}
|
|
|
|
function _dedupeTagList(tags) {
|
|
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[CLIENT_GLOBAL_KEY]) {
|
|
w[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,
|
|
readAuthToken: _readAuthToken,
|
|
ensureWriteAuthToken,
|
|
normalizeWriteAuthFailure: _normalizeWriteAuthFailure,
|
|
});
|
|
const fetchAPI = _apiFetchClient.fetchAPI;
|
|
|
|
export async function get(url, options = {}) {
|
|
return _apiFetchClient.get(url, options);
|
|
}
|
|
|
|
export async function post(url, body, options = {}) {
|
|
return _apiFetchClient.post(url, body, options);
|
|
}
|
|
|
|
/**
|
|
* Update asset rating (0-5 stars)
|
|
*/
|
|
export async function updateAssetRating(assetId, rating, options = {}) {
|
|
const enabled = _readRatingTagsSyncEnabled();
|
|
const asset = assetId && typeof assetId === "object" ? assetId : null;
|
|
const resolvedId = asset ? asset.id : assetId;
|
|
const normalizedId = normalizeAssetId(resolvedId);
|
|
const payload = {
|
|
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, tags, options = {}) {
|
|
const enabled = _readRatingTagsSyncEnabled();
|
|
const asset = assetId && typeof assetId === "object" ? assetId : null;
|
|
const resolvedId = asset ? asset.id : assetId;
|
|
const normalizedId = normalizeAssetId(resolvedId);
|
|
const payload = {
|
|
tags: Array.isArray(tags) ? tags : [],
|
|
};
|
|
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 result = await fetchAPI("/mjr/am/asset/tags", {
|
|
...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, options = {}) {
|
|
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<ApiResult<ViewerInfo>>} */
|
|
export async function getViewerInfo(assetId, options = {}) {
|
|
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, options = {}) {
|
|
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 hydrateAssetRatingTags(assetId) {
|
|
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 = "" } = {},
|
|
options = {},
|
|
) {
|
|
try {
|
|
if (globalThis.__mjrFolderInfoSupported === false) {
|
|
return {
|
|
ok: false,
|
|
data: null,
|
|
error: "Folder info endpoint unavailable",
|
|
code: "UNAVAILABLE",
|
|
};
|
|
}
|
|
if (globalThis.__mjrFolderInfoSupported == null) {
|
|
const rr = await get("/mjr/am/routes");
|
|
if (rr?.ok && Array.isArray(rr.data)) {
|
|
const hasRoute = rr.data.some(
|
|
(r) => String(r?.path || "").trim() === "/mjr/am/folder-info",
|
|
);
|
|
globalThis.__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.__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.__mjrFolderInfoSupported = false;
|
|
}
|
|
} catch (e) {
|
|
console.debug?.(e);
|
|
}
|
|
return res;
|
|
}
|
|
|
|
|
|
export * from "./clientOps.js";
|