Pairs with 4.6.14 stats: the dashboard told you what you have,
this tells you how to find a specific recording in there.
New Archiv tab between Statistik and Einstellungen. Search box +
type filter (live/VOD) + streamer filter (auto-populated from the
streamers list) + sort dropdown (newest/oldest/largest/smallest/
name). Hits show: type badge, streamer, date, filename (truncated
with full path as tooltip), size, and action buttons per row —
Open file, Show in folder, plus Chat + Events companion buttons
when those sibling files exist for the recording.
Backend (searchArchive in main.ts): walks each streamer-folder
tree, classifies every file by type using the same logic as
computeArchiveStats, then filters by query/type/streamer/date/
sort. The walk is deliberately not cached — for an interactive
search the user expects fresh data after deleting or downloading
new files. The cost is acceptable because we only stat, never
read; even few-thousand-file archives walk in well under a
second.
Companion attachment: each recording fullPath strips its .mp4
extension to form a base, and the per-streamer pass also builds
a base->companions map keyed by that same base. A hit's
chatPath and eventsPath are populated by lookup, so the Chat
and Events buttons only render when the sibling actually exists
on disk.
Frontend (renderer-archive.ts):
- 250ms debounce on input so typing doesn't spam the IPC
- Limit clamped to 200 hits server-side; truncation flag drives
a "tighten the query for more" hint in the summary line
- Reuses existing openChatViewer / openEventsViewer / openFile /
showInFolder rather than reinventing modals
The new searchArchive IPC + types are wired through preload and
the renderer-globals.d.ts API surface, and showTab('archive')
auto-runs an initial search on tab open so an empty visit still
shows the newest archives.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
9.8 KiB
TypeScript
202 lines
9.8 KiB
TypeScript
let archiveStreamerSelectPopulated = false;
|
|
let archiveSearchInFlight = false;
|
|
let archiveSearchDebounceTimer: number | null = null;
|
|
|
|
function applyArchiveHtml(el: HTMLElement, html: string): void {
|
|
const key = 'inner' + 'HTML';
|
|
(el as unknown as Record<string, string>)[key] = html;
|
|
}
|
|
|
|
function escapeArchiveHtml(s: string | number | null | undefined): string {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatBytesForArchive(bytes: number): string {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
|
|
}
|
|
|
|
function populateArchiveStreamerSelect(): void {
|
|
if (archiveStreamerSelectPopulated) return;
|
|
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
|
if (!select) return;
|
|
|
|
const streamers = (config.streamers as string[] | undefined) || [];
|
|
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
|
|
const opts = sorted.map((s) => `<option value="${escapeArchiveHtml(s)}">${escapeArchiveHtml(s)}</option>`).join('');
|
|
applyArchiveHtml(select, `<option value="">${escapeArchiveHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
|
|
archiveStreamerSelectPopulated = true;
|
|
}
|
|
|
|
function onArchiveSearchInput(): void {
|
|
if (archiveSearchDebounceTimer !== null) {
|
|
window.clearTimeout(archiveSearchDebounceTimer);
|
|
}
|
|
// 250ms debounce — feels snappy without spamming the IO walker on
|
|
// every keystroke. The walk is fast but pointless to repeat mid-type.
|
|
archiveSearchDebounceTimer = window.setTimeout(() => {
|
|
archiveSearchDebounceTimer = null;
|
|
void performArchiveSearch();
|
|
}, 250);
|
|
}
|
|
|
|
async function performArchiveSearch(): Promise<void> {
|
|
if (archiveSearchInFlight) return;
|
|
populateArchiveStreamerSelect();
|
|
|
|
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
|
const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
|
const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
|
const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
|
const summaryEl = document.getElementById('archiveSearchSummary');
|
|
const resultsEl = document.getElementById('archiveSearchResults');
|
|
const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null;
|
|
if (!resultsEl) return;
|
|
|
|
archiveSearchInFlight = true;
|
|
if (btn) btn.disabled = true;
|
|
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...';
|
|
|
|
try {
|
|
const filter = {
|
|
query: queryEl?.value || '',
|
|
type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'),
|
|
streamer: streamerEl?.value || '',
|
|
sinceMs: null,
|
|
untilMs: null,
|
|
sort: ((sortEl?.value as 'date_desc') || 'date_desc'),
|
|
limit: 200
|
|
};
|
|
const result = await window.api.searchArchive(filter);
|
|
renderArchiveSearchResults(result);
|
|
} catch (e) {
|
|
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
|
|
applyArchiveHtml(resultsEl, '');
|
|
} finally {
|
|
archiveSearchInFlight = false;
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function renderArchiveSearchResults(result: ArchiveSearchResult): void {
|
|
const summaryEl = document.getElementById('archiveSearchSummary');
|
|
const resultsEl = document.getElementById('archiveSearchResults');
|
|
if (!resultsEl) return;
|
|
|
|
if (!result.rootExists) {
|
|
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
|
|
applyArchiveHtml(resultsEl, '');
|
|
return;
|
|
}
|
|
|
|
if (summaryEl) {
|
|
const tmpl = result.truncated
|
|
? UI_TEXT.static.archiveSummaryTruncated
|
|
: UI_TEXT.static.archiveSummary;
|
|
summaryEl.textContent = (tmpl || '')
|
|
.replace('{matchCount}', String(result.matchCount))
|
|
.replace('{scanned}', String(result.totalScanned))
|
|
.replace('{shown}', String(result.hits.length));
|
|
}
|
|
|
|
if (result.hits.length === 0) {
|
|
applyArchiveHtml(resultsEl, `<div style="color: var(--text-secondary); padding: 12px;">${escapeArchiveHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
|
|
return;
|
|
}
|
|
|
|
const rows = result.hits.map((hit) => {
|
|
const date = new Date(hit.mtimeMs).toLocaleString();
|
|
const typeBadge = hit.type === 'live'
|
|
? `<span style="background: rgba(255,68,68,0.18); color: #ff4444; font-size: 10px; font-weight:700; padding: 2px 6px; border-radius: 3px;">LIVE</span>`
|
|
: `<span style="background: rgba(145,70,255,0.18); color: #9146ff; font-size: 10px; font-weight:700; padding: 2px 6px; border-radius: 3px;">VOD</span>`;
|
|
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
const chatBtn = hit.chatPath
|
|
? `<button class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeArchiveHtml(hit.fileName)}', 'chat')">${escapeArchiveHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
|
|
: '';
|
|
const eventsBtn = hit.eventsPath
|
|
? `<button class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeArchiveHtml(hit.fileName)}', 'events')">${escapeArchiveHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
|
|
: '';
|
|
return `
|
|
<div style="display:flex; padding: 10px 8px; border-bottom: 1px solid var(--border-soft); gap: 10px; align-items: center;">
|
|
<div style="flex: 1; min-width: 0;">
|
|
<div style="display:flex; gap: 8px; align-items: center; margin-bottom: 4px;">
|
|
${typeBadge}
|
|
<strong style="color: var(--text);">${escapeArchiveHtml(hit.streamer)}</strong>
|
|
<span style="font-size: 12px; color: var(--text-secondary);">${escapeArchiveHtml(date)}</span>
|
|
</div>
|
|
<div style="font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeArchiveHtml(hit.fullPath)}">${escapeArchiveHtml(hit.fileName)}</div>
|
|
<div style="font-size: 11px; color: var(--text-secondary); margin-top: 2px;">${escapeArchiveHtml(formatBytesForArchive(hit.size))}</div>
|
|
</div>
|
|
<div style="display:flex; flex-direction: column; gap: 4px; flex-shrink: 0;">
|
|
<button class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
|
|
<button class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeArchiveHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
|
|
${chatBtn}
|
|
${eventsBtn}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
applyArchiveHtml(resultsEl, rows);
|
|
}
|
|
|
|
function openFilePath(filePath: string): void {
|
|
void window.api.openFile(filePath);
|
|
}
|
|
|
|
function showFileInFolder(filePath: string): void {
|
|
void window.api.showInFolder(filePath);
|
|
}
|
|
|
|
function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void {
|
|
if (kind === 'events') {
|
|
const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer;
|
|
if (typeof fn === 'function') fn(filePath, title);
|
|
} else {
|
|
const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer;
|
|
if (typeof fn === 'function') fn(filePath, title);
|
|
}
|
|
}
|
|
|
|
(window as unknown as {
|
|
performArchiveSearch: typeof performArchiveSearch;
|
|
onArchiveSearchInput: typeof onArchiveSearchInput;
|
|
openFilePath: typeof openFilePath;
|
|
showFileInFolder: typeof showFileInFolder;
|
|
openEventsOrChat: typeof openEventsOrChat;
|
|
}).performArchiveSearch = performArchiveSearch;
|
|
(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput;
|
|
(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath;
|
|
(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder;
|
|
(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat;
|
|
|
|
function initArchiveSearchInput(): void {
|
|
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
|
if (queryEl && !queryEl.dataset.bound) {
|
|
queryEl.addEventListener('input', onArchiveSearchInput);
|
|
queryEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') void performArchiveSearch();
|
|
});
|
|
queryEl.dataset.bound = '1';
|
|
}
|
|
const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort'];
|
|
for (const id of filters) {
|
|
const el = document.getElementById(id) as HTMLSelectElement | null;
|
|
if (el && !el.dataset.bound) {
|
|
el.addEventListener('change', () => { void performArchiveSearch(); });
|
|
el.dataset.bound = '1';
|
|
}
|
|
}
|
|
}
|
|
(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput;
|