diff --git a/configuration/homeScreenAdvancedEditor.css b/configuration/homeScreenAdvancedEditor.css
index ac21481..a8e69b3 100644
--- a/configuration/homeScreenAdvancedEditor.css
+++ b/configuration/homeScreenAdvancedEditor.css
@@ -142,6 +142,52 @@
white-space: nowrap;
}
+.hsae-troubleshoot-summary-wrap {
+ margin-bottom: 0.85em;
+}
+
+.hsae-troubleshoot-summary {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.hsae-troubleshoot-summary th,
+.hsae-troubleshoot-summary td {
+ padding: 0.45em 0.65em;
+ text-align: left;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.hsae-troubleshoot-summary thead th {
+ font-weight: 600;
+ opacity: 0.9;
+ background: rgba(255, 255, 255, 0.04);
+ border-top: none;
+}
+
+.hsae-troubleshoot-panels {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85em;
+}
+
+.hsae-troubleshoot-panel {
+ min-width: 0;
+}
+
+.hsae-troubleshoot-textarea {
+ width: 100%;
+ min-height: 180px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.9em;
+ line-height: 1.5;
+ box-sizing: border-box;
+}
+
:is([data-modal-id="kefin-homescreen-section-editor"], [data-modal-id="kefin-homescreen-section-create-flow"]) .hsae-group {
margin-bottom: 1.25em;
}
diff --git a/configuration/homeScreenAdvancedEditor.js b/configuration/homeScreenAdvancedEditor.js
index b5b4304..a926d80 100644
--- a/configuration/homeScreenAdvancedEditor.js
+++ b/configuration/homeScreenAdvancedEditor.js
@@ -3350,6 +3350,7 @@
body.scrollTop = scrollTop;
});
updateSectionNameBadge(modalInstance);
+ syncHeaderActions(modalInstance);
}
function handleHsaeRootClick(e, modalInstance) {
@@ -3648,6 +3649,7 @@
const HSAE_SECTION_EXPORT_MODAL_ID = 'hsae-section-export';
const HSAE_SECTION_IMPORT_MODAL_ID = 'hsae-section-import-paste';
const HSAE_RESTORE_DEFAULTS_MODAL_ID = 'hsae-section-restore-defaults';
+ const HSAE_TROUBLESHOOT_MODAL_ID = 'hsae-section-troubleshoot';
const HSAE_SECTION_DIFF_KEYS = [
'name', 'caption', 'description', 'enabled', 'order', 'cardFormat', 'renderMode', 'ttl',
'queries', 'items', 'externalListUrls', 'spotlightConfig', 'hideName', 'userConfigurable',
@@ -4145,7 +4147,7 @@
const content = document.createElement('div');
content.innerHTML = `
- Restoring defaults will override the current editor values for this section with the canonical Kefin defaults.
+ Restoring defaults will override the current editor values for this section with the KefinTweaks defaults.
Enabled status and order are kept. You still need to Save to persist.
${rowsHtml}
@@ -4185,6 +4187,387 @@
});
}
+ function getEligibleTroubleshootQueries(section) {
+ const queries = Array.isArray(section?.queries) ? section.queries : [];
+ return queries
+ .map((query, index) => ({ query, index, sourceType: inferQuerySourceType(query) }))
+ .filter((entry) => entry.sourceType === 'jellyfin' || entry.sourceType === 'static');
+ }
+
+ function formatTroubleshootRequestUrl(method, url) {
+ const methodLabel = method || 'GET';
+ if (!url || typeof url !== 'string') {
+ return `${methodLabel} (no URL)`;
+ }
+ try {
+ const parsed = new URL(url, window.location?.origin || undefined);
+ const lines = [`${methodLabel} ${parsed.origin}${parsed.pathname}`];
+ parsed.searchParams.forEach((value, key) => {
+ let decoded = value;
+ try {
+ decoded = decodeURIComponent(value);
+ } catch (_) { /* keep raw */ }
+ lines.push(`${key}=${decoded}`);
+ });
+ return lines.join('\n');
+ } catch (_) {
+ const qIndex = url.indexOf('?');
+ if (qIndex < 0) return `${methodLabel} ${url}`;
+ const base = url.slice(0, qIndex);
+ const query = url.slice(qIndex + 1);
+ const lines = [`${methodLabel} ${base}`];
+ query.split('&').forEach((pair) => {
+ if (!pair) return;
+ const eq = pair.indexOf('=');
+ const key = eq >= 0 ? pair.slice(0, eq) : pair;
+ const rawVal = eq >= 0 ? pair.slice(eq + 1) : '';
+ let decodedKey = key;
+ let decodedVal = rawVal;
+ try { decodedKey = decodeURIComponent(key); } catch (_) { /* keep */ }
+ try { decodedVal = decodeURIComponent(rawVal); } catch (_) { /* keep */ }
+ lines.push(`${decodedKey}=${decodedVal}`);
+ });
+ return lines.join('\n');
+ }
+ }
+
+ function troubleshootQueryLabel(entry) {
+ const name = entry?.name != null ? String(entry.name).trim() : '';
+ if (name) return name;
+ return `Query ${entry?.index ?? '?'}`;
+ }
+
+ function formatTroubleshootSectionConfigText(section) {
+ const eligible = getEligibleTroubleshootQueries(section);
+ if (!eligible.length) return '(no eligible queries)';
+ return eligible.map(({ query, index }) => {
+ const label = troubleshootQueryLabel({
+ index,
+ name: query?.name != null ? String(query.name).trim() : ''
+ });
+ const clone = deepCloneJson(query) || {};
+ // Drop ephemeral editor-only keys from the displayed config object
+ Object.keys(clone).forEach((key) => {
+ if (key.startsWith('_') && key !== '_sourceType') delete clone[key];
+ });
+ return `=== ${label} ===\n${JSON.stringify(clone, null, 2)}`;
+ }).join('\n\n');
+ }
+
+ function formatTroubleshootRequestText(payload) {
+ const entries = Array.isArray(payload?.queries) ? payload.queries : [];
+ if (!entries.length) return '(no requests)';
+ return entries.map((entry) => {
+ const header = `=== ${troubleshootQueryLabel(entry)} ===`;
+ const req = entry.request;
+ if (!req) return `${header}\n(no request)`;
+ if (req.error) return `${header}\nERROR: ${req.error}`;
+ if (typeof req.url === 'string') {
+ return `${header}\n${formatTroubleshootRequestUrl(req.method, req.url)}`;
+ }
+ // Non-URL request object (e.g. cache marker)
+ return `${header}\n${JSON.stringify(req, null, 2)}`;
+ }).join('\n\n');
+ }
+
+ function formatTroubleshootResponseText(payload) {
+ const entries = Array.isArray(payload?.queries) ? payload.queries : [];
+ if (!entries.length) return '(no responses)';
+ return entries.map((entry) => {
+ const header = `=== ${troubleshootQueryLabel(entry)} ===`;
+ return `${header}\n${JSON.stringify(entry.response ?? null, null, 2)}`;
+ }).join('\n\n');
+ }
+
+ function countTroubleshootItems(response) {
+ if (!response || typeof response !== 'object') return null;
+ if (response.error || response.skipped) return null;
+ if (Array.isArray(response.Items)) return response.Items.length;
+ if (Array.isArray(response.items)) return response.items.length;
+ if (Array.isArray(response)) return response.length;
+ if (typeof response.TotalRecordCount === 'number') return response.TotalRecordCount;
+ return null;
+ }
+
+ function measureTroubleshootResponseSize(response) {
+ if (response == null) return null;
+ try {
+ const json = typeof response === 'string' ? response : JSON.stringify(response);
+ if (typeof TextEncoder !== 'undefined') {
+ return new TextEncoder().encode(json).length;
+ }
+ return json.length;
+ } catch (_) {
+ return null;
+ }
+ }
+
+ function formatTroubleshootDuration(ms) {
+ if (ms == null || !Number.isFinite(ms)) return '—';
+ if (ms < 1000) return `${Math.round(ms)} ms`;
+ return `${(ms / 1000).toFixed(2)} s`;
+ }
+
+ function formatTroubleshootSize(bytes) {
+ if (bytes == null || !Number.isFinite(bytes)) return '—';
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+ }
+
+ function buildTroubleshootSummaryHtml(payload) {
+ const entries = Array.isArray(payload?.queries) ? payload.queries : [];
+ if (!entries.length) {
+ return 'No query results.
';
+ }
+ const rows = entries.map((entry) => {
+ const stats = entry.stats || {};
+ const itemCount = stats.itemCount != null ? String(stats.itemCount) : '—';
+ const duration = formatTroubleshootDuration(stats.durationMs);
+ const size = formatTroubleshootSize(stats.responseBytes);
+ return `
+
+ ${escapeHtml(troubleshootQueryLabel(entry))}
+ ${escapeHtml(itemCount)}
+ ${escapeHtml(duration)}
+ ${escapeHtml(size)}
+
+ `;
+ }).join('');
+ return `
+
+
+
+ Query
+ Item Count
+ Response Time
+ Response Size
+
+
+ ${rows}
+
+ `;
+ }
+
+ function attachTroubleshootStats(entry, response, durationMs) {
+ entry.stats = {
+ itemCount: countTroubleshootItems(response),
+ durationMs: durationMs != null && Number.isFinite(durationMs) ? durationMs : null,
+ responseBytes: measureTroubleshootResponseSize(response)
+ };
+ return entry;
+ }
+
+ async function runSectionTroubleshoot(section) {
+ const apiHelper = window.apiHelper;
+ const userId = window.ApiClient?.getCurrentUserId?.();
+ const serverUrl = window.ApiClient?.serverAddress?.();
+ if (!apiHelper?.buildQueryFromSection || !apiHelper?.getQuery) {
+ return {
+ queries: [attachTroubleshootStats(
+ { index: -1, request: null, response: { error: 'apiHelper unavailable' } },
+ { error: 'apiHelper unavailable' },
+ null
+ )]
+ };
+ }
+ if (!userId || !serverUrl) {
+ return {
+ queries: [attachTroubleshootStats(
+ { index: -1, request: null, response: { error: 'Unable to get user ID or server URL' } },
+ { error: 'Unable to get user ID or server URL' },
+ null
+ )]
+ };
+ }
+
+ const isSpotlight = section?.renderMode === 'Spotlight' || section?.spotlight === true;
+ const sectionType = section?.type || deriveSectionSurfaceType(section);
+ const eligible = getEligibleTroubleshootQueries(section);
+ const results = [];
+
+ for (const { query, index } of eligible) {
+ const queryName = query?.name != null ? String(query.name).trim() : '';
+ let built;
+ try {
+ built = apiHelper.buildQueryFromSection(query, userId, serverUrl, isSpotlight, { sectionType });
+ } catch (err) {
+ const errBody = { error: err?.message || String(err) };
+ results.push(attachTroubleshootStats({
+ index,
+ name: queryName || null,
+ request: { method: 'GET', url: null, error: errBody.error },
+ response: errBody
+ }, errBody, null));
+ continue;
+ }
+
+ if (typeof built !== 'string') {
+ const skipped = { skipped: true, reason: 'Non-URL request (e.g. cache dataSource)' };
+ results.push(attachTroubleshootStats({
+ index,
+ name: queryName || null,
+ request: built,
+ response: skipped
+ }, skipped, null));
+ continue;
+ }
+
+ const entry = {
+ index,
+ name: queryName || null,
+ request: { method: 'GET', url: built },
+ response: null
+ };
+ const started = (typeof performance !== 'undefined' && performance.now)
+ ? performance.now()
+ : Date.now();
+ try {
+ entry.response = await apiHelper.getQuery(built, { useCache: false, forceRefresh: true });
+ } catch (err) {
+ entry.response = { error: err?.message || String(err) };
+ }
+ const ended = (typeof performance !== 'undefined' && performance.now)
+ ? performance.now()
+ : Date.now();
+ attachTroubleshootStats(entry, entry.response, ended - started);
+ results.push(entry);
+ }
+
+ return { queries: results };
+ }
+
+ function showSectionTroubleshootModal(modalInstance) {
+ if (!window.ModalSystem) {
+ showHsaeToast('Unable to open troubleshoot modal');
+ return;
+ }
+ if (window.ModalSystem.isOpen?.(HSAE_TROUBLESHOOT_MODAL_ID)) {
+ window.ModalSystem.close(HSAE_TROUBLESHOOT_MODAL_ID);
+ }
+
+ const dialog = modalInstance?.dialogContainer || modalInstance?.dialogContent;
+ const state = modalInstance?._hsaeState;
+ if (!dialog || !state) return;
+
+ const original = state.context?.originalSection || state.section || {};
+ const section = collectData(dialog, original);
+ if (!section) return;
+ if (state.section?.id) section.id = state.section.id;
+
+ // Quiet no-op if source/queries flipped mid-click (button should already be hidden)
+ if (!getEligibleTroubleshootQueries(section).length) return;
+
+ const canCopy = canUseClipboardWrite();
+ const sectionConfigText = formatTroubleshootSectionConfigText(section);
+ window.ModalSystem.create({
+ id: HSAE_TROUBLESHOOT_MODAL_ID,
+ title: 'Troubleshoot',
+ content: `
+
+
+ `,
+ footer: `
+ ${canCopy ? `
+
+ Copy config
+
+
+ Copy request
+
+
+ Copy response
+ ` : ''}
+
+ Close
+
+ `,
+ closeOnBackdrop: true,
+ closeOnEscape: true,
+ dialogStyle: {
+ width: 'min(48rem, calc(100vw - 2rem))',
+ maxWidth: '48rem'
+ },
+ onOpen: (modal) => {
+ const summaryEl = modal.dialogContent?.querySelector('#hsae-troubleshoot-summary');
+ const configTa = modal.dialogContent?.querySelector('#hsae-troubleshoot-section-config');
+ const requestTa = modal.dialogContent?.querySelector('#hsae-troubleshoot-request');
+ const responseTa = modal.dialogContent?.querySelector('#hsae-troubleshoot-response');
+ const copyConfigBtn = modal.dialogFooter?.querySelector('#hsae-troubleshoot-copy-config-btn');
+ const copyRequestBtn = modal.dialogFooter?.querySelector('#hsae-troubleshoot-copy-request-btn');
+ const copyResponseBtn = modal.dialogFooter?.querySelector('#hsae-troubleshoot-copy-response-btn');
+ if (configTa) configTa.value = sectionConfigText;
+ if (requestTa) requestTa.value = 'Loading…';
+ if (responseTa) responseTa.value = 'Loading…';
+
+ modal.dialogFooter?.querySelector('#hsae-troubleshoot-close-btn')?.addEventListener('click', () => {
+ window.ModalSystem.close(HSAE_TROUBLESHOOT_MODAL_ID);
+ });
+
+ const copyFrom = async (textarea, label) => {
+ const text = textarea?.value || '';
+ if (!text) return;
+ try {
+ await navigator.clipboard.writeText(text);
+ showHsaeToast(`${label} copied to clipboard`);
+ } catch (_) {
+ textarea?.focus();
+ textarea?.select();
+ showHsaeToast('Select and copy the text manually');
+ }
+ };
+ copyConfigBtn?.addEventListener('click', () => copyFrom(configTa, 'Section config'));
+ copyRequestBtn?.addEventListener('click', () => copyFrom(requestTa, 'Request'));
+ copyResponseBtn?.addEventListener('click', () => copyFrom(responseTa, 'Response'));
+
+ const applyPayload = (payload) => {
+ if (summaryEl) summaryEl.innerHTML = buildTroubleshootSummaryHtml(payload);
+ if (requestTa) requestTa.value = formatTroubleshootRequestText(payload);
+ if (responseTa) responseTa.value = formatTroubleshootResponseText(payload);
+ if (copyRequestBtn) copyRequestBtn.disabled = false;
+ if (copyResponseBtn) copyResponseBtn.disabled = false;
+ };
+
+ runSectionTroubleshoot(section).then(applyPayload).catch((err) => {
+ const errBody = { error: err?.message || String(err) };
+ applyPayload({
+ queries: [attachTroubleshootStats(
+ { index: -1, request: null, response: errBody },
+ errBody,
+ null
+ )]
+ });
+ });
+ }
+ });
+ }
+
+ function handleHeaderTroubleshoot(modalInstance) {
+ showSectionTroubleshootModal(modalInstance);
+ }
+
async function handleHeaderExport(modalInstance) {
const json = exportCurrentSectionJson(modalInstance);
if (!json) {
@@ -4255,6 +4638,45 @@
});
}
+ function getEditorItemSource(modalInstance) {
+ const state = modalInstance?._hsaeState;
+ const root = modalInstance?.dialogContent;
+ const fromDom = root?.querySelector?.('.hsae-source-card.hsae-active[data-item-source]')?.dataset?.itemSource;
+ if (fromDom) return fromDom;
+ const hsaeRoot = root?.classList?.contains('hsae-root') ? root : root?.querySelector?.('.hsae-root');
+ if (hsaeRoot?.dataset?.itemSource) return hsaeRoot.dataset.itemSource;
+ return state?.itemSource || inferItemSource(state?.section) || 'jellyfin';
+ }
+
+ function getEditorQueriesForTroubleshoot(modalInstance) {
+ const state = modalInstance?._hsaeState;
+ const root = modalInstance?.dialogContent;
+ const editors = root?.querySelectorAll?.('.query-editor');
+ if (editors?.length) {
+ return Array.from(editors).map((editor) => {
+ const index = parseInt(editor.dataset.queryIndex, 10);
+ const sourceType = editor.querySelector(`#query-${index}-sourceType`)?.value
+ || inferQuerySourceType(state?.queries?.[index]);
+ if (sourceType === 'static') return { _sourceType: 'static' };
+ if (sourceType === 'cache') return { dataSource: 'cache' };
+ return {};
+ });
+ }
+ return Array.isArray(state?.queries) ? state.queries : [];
+ }
+
+ function sectionHasTroubleshootEligibleQueries(queries) {
+ return (queries || []).some((q) => {
+ const type = inferQuerySourceType(q);
+ return type === 'jellyfin' || type === 'static';
+ });
+ }
+
+ function canShowTroubleshoot(modalInstance) {
+ if (getEditorItemSource(modalInstance) !== 'jellyfin') return false;
+ return sectionHasTroubleshootEligibleQueries(getEditorQueriesForTroubleshoot(modalInstance));
+ }
+
function syncHeaderActions(modalInstance) {
const actions = modalInstance?.dialogHeader?.querySelector('.hsae-header-actions');
if (!actions) return;
@@ -4265,6 +4687,7 @@
const importBtn = actions.querySelector('[data-hsae-header-action="import"]');
const exportBtn = actions.querySelector('[data-hsae-header-action="export"]');
const restoreBtn = actions.querySelector('[data-hsae-header-action="restore"]');
+ const troubleshootBtn = actions.querySelector('[data-hsae-header-action="troubleshoot"]');
if (importBtn) importBtn.hidden = isDefault;
if (exportBtn) exportBtn.hidden = isDefault;
if (restoreBtn) {
@@ -4273,6 +4696,9 @@
restoreBtn.hidden = true;
}
}
+ if (troubleshootBtn) {
+ troubleshootBtn.hidden = !canShowTroubleshoot(modalInstance);
+ }
}
function createHeaderActionButton(action, title, icon) {
@@ -4327,9 +4753,11 @@
const importBtn = createHeaderActionButton('import', 'Import', 'content_paste');
const exportBtn = createHeaderActionButton('export', 'Export', 'content_copy');
const restoreBtn = createHeaderActionButton('restore', 'Restore Defaults', 'settings_backup_restore');
+ const troubleshootBtn = createHeaderActionButton('troubleshoot', 'Troubleshoot', 'troubleshoot');
actions.appendChild(importBtn);
actions.appendChild(exportBtn);
actions.appendChild(restoreBtn);
+ actions.appendChild(troubleshootBtn);
headerMain.appendChild(actions);
actions.addEventListener('click', (e) => {
@@ -4341,6 +4769,7 @@
if (action === 'export') handleHeaderExport(modalInstance);
else if (action === 'import') handleHeaderImport(modalInstance);
else if (action === 'restore') handleHeaderRestoreDefaults(modalInstance);
+ else if (action === 'troubleshoot') handleHeaderTroubleshoot(modalInstance);
});
if (closeBtn) {