feat: local archive search — new Archiv tab
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>
This commit is contained in:
parent
cf141eb9df
commit
8d4b0704db
@ -229,6 +229,10 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
|
||||||
<span id="navStatsText">Statistik</span>
|
<span id="navStatsText">Statistik</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item" data-tab="archive" onclick="showTab('archive')">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||||
|
<span id="navArchiveText">Archiv</span>
|
||||||
|
</div>
|
||||||
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||||
<span id="navSettingsText">Einstellungen</span>
|
<span id="navSettingsText">Einstellungen</span>
|
||||||
@ -465,6 +469,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Archive Search Tab -->
|
||||||
|
<div class="tab-content" id="archiveTab">
|
||||||
|
<div class="settings-card">
|
||||||
|
<h3 id="archiveTitle" style="margin-top:0;">Archiv durchsuchen</h3>
|
||||||
|
<p id="archiveIntro" style="color: var(--text-secondary); font-size:13px; margin-bottom:12px; line-height:1.5;">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
|
||||||
|
<div class="form-row" style="gap:8px; margin-bottom: 8px; flex-wrap: wrap; align-items:center;">
|
||||||
|
<input type="text" id="archiveSearchQuery" placeholder="Suche..." style="flex: 1 1 240px; min-width: 200px; background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text);">
|
||||||
|
<select id="archiveSearchType" style="background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text);">
|
||||||
|
<option value="all">Alle Typen</option>
|
||||||
|
<option value="live">Live-Aufnahmen</option>
|
||||||
|
<option value="vod">VOD-Downloads</option>
|
||||||
|
</select>
|
||||||
|
<select id="archiveSearchStreamer" style="background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text); min-width: 160px;">
|
||||||
|
<option value="">Alle Streamer</option>
|
||||||
|
</select>
|
||||||
|
<select id="archiveSearchSort" style="background: var(--bg-card); border: 1px solid var(--border-soft); border-radius: 4px; padding: 6px 10px; color: var(--text);">
|
||||||
|
<option value="date_desc">Neueste zuerst</option>
|
||||||
|
<option value="date_asc">Aelteste zuerst</option>
|
||||||
|
<option value="size_desc">Groesste zuerst</option>
|
||||||
|
<option value="size_asc">Kleinste zuerst</option>
|
||||||
|
<option value="name_asc">Name (A-Z)</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<div id="archiveSearchSummary" style="font-size: 12px; color: var(--text-secondary);"></div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-card">
|
||||||
|
<div id="archiveSearchResults"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
<div class="tab-content" id="settingsTab">
|
<div class="tab-content" id="settingsTab">
|
||||||
<div class="settings-card">
|
<div class="settings-card">
|
||||||
@ -796,6 +831,7 @@
|
|||||||
<script src="../dist/renderer-queue.js"></script>
|
<script src="../dist/renderer-queue.js"></script>
|
||||||
<script src="../dist/renderer-updates.js"></script>
|
<script src="../dist/renderer-updates.js"></script>
|
||||||
<script src="../dist/renderer-stats.js"></script>
|
<script src="../dist/renderer-stats.js"></script>
|
||||||
|
<script src="../dist/renderer-archive.js"></script>
|
||||||
<script src="../dist/renderer.js"></script>
|
<script src="../dist/renderer.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
188
src/main.ts
188
src/main.ts
@ -3728,6 +3728,177 @@ function walkForArchiveStats(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search a single file matches the live query. Empty query matches all.
|
||||||
|
// streamerFolder is the top-level directory under root (which we equate
|
||||||
|
// with the channel name); relativePath is everything below that.
|
||||||
|
interface ArchiveSearchFilter {
|
||||||
|
query: string;
|
||||||
|
type: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||||
|
streamer: string;
|
||||||
|
sinceMs: number | null;
|
||||||
|
untilMs: number | null;
|
||||||
|
sort: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveSearchHit {
|
||||||
|
fullPath: string;
|
||||||
|
fileName: string;
|
||||||
|
streamer: string;
|
||||||
|
type: ArchiveFileType;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
chatPath: string | null;
|
||||||
|
eventsPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveSearchResult {
|
||||||
|
totalScanned: number;
|
||||||
|
matchCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
hits: ArchiveSearchHit[];
|
||||||
|
scannedAt: string;
|
||||||
|
rootExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchSearchFilter(
|
||||||
|
streamerFolder: string,
|
||||||
|
relativePath: string,
|
||||||
|
fileName: string,
|
||||||
|
fileSize: number,
|
||||||
|
mtimeMs: number,
|
||||||
|
type: ArchiveFileType,
|
||||||
|
filter: ArchiveSearchFilter
|
||||||
|
): boolean {
|
||||||
|
if (filter.type !== 'all' && filter.type !== type) return false;
|
||||||
|
if (filter.streamer && streamerFolder.toLowerCase() !== filter.streamer.toLowerCase()) return false;
|
||||||
|
if (filter.sinceMs !== null && mtimeMs < filter.sinceMs) return false;
|
||||||
|
if (filter.untilMs !== null && mtimeMs > filter.untilMs) return false;
|
||||||
|
if (filter.query) {
|
||||||
|
const q = filter.query.toLowerCase();
|
||||||
|
const hay = `${fileName} ${streamerFolder} ${relativePath}`.toLowerCase();
|
||||||
|
if (!hay.includes(q)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchArchive(filter: ArchiveSearchFilter): ArchiveSearchResult {
|
||||||
|
const root = config.download_path;
|
||||||
|
const result: ArchiveSearchResult = {
|
||||||
|
totalScanned: 0,
|
||||||
|
matchCount: 0,
|
||||||
|
truncated: false,
|
||||||
|
hits: [],
|
||||||
|
scannedAt: new Date().toISOString(),
|
||||||
|
rootExists: false
|
||||||
|
};
|
||||||
|
if (!root || !fs.existsSync(root)) return result;
|
||||||
|
result.rootExists = true;
|
||||||
|
|
||||||
|
const maxHits = Math.max(10, Math.min(2000, Math.floor(filter.limit) || 200));
|
||||||
|
|
||||||
|
let topEntries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
topEntries = fs.readdirSync(root, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// To attach chat/events sibling paths to a recording hit, we collect
|
||||||
|
// every file in a streamer's tree first, then make a second pass to
|
||||||
|
// pair up companions by stripping the .mp4 base.
|
||||||
|
for (const entry of topEntries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const streamerFolder = entry.name;
|
||||||
|
const streamerRoot = path.join(root, streamerFolder);
|
||||||
|
const filesInTree: Array<{ fullPath: string; rel: string; name: string; size: number; mtimeMs: number; type: ArchiveFileType }> = [];
|
||||||
|
const accum: { files: ArchiveFileRecord[] } = { files: [] };
|
||||||
|
// We re-walk here instead of reusing walkForArchiveStats because
|
||||||
|
// we need the full path + rel path on each file, not just the
|
||||||
|
// type/size aggregates. The cost is one redundant tree walk per
|
||||||
|
// search; acceptable for an interactive search.
|
||||||
|
const walkWithPaths = (folderPath: string, relPrefix: string): void => {
|
||||||
|
let entries2: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries2 = fs.readdirSync(folderPath, { withFileTypes: true });
|
||||||
|
} catch { return; }
|
||||||
|
for (const e2 of entries2) {
|
||||||
|
const full = path.join(folderPath, e2.name);
|
||||||
|
const rel = relPrefix ? `${relPrefix}/${e2.name}` : e2.name;
|
||||||
|
try {
|
||||||
|
if (e2.isDirectory()) {
|
||||||
|
walkWithPaths(full, rel);
|
||||||
|
} else if (e2.isFile()) {
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
const type = classifyArchiveFile(rel);
|
||||||
|
filesInTree.push({ fullPath: full, rel, name: e2.name, size: st.size, mtimeMs: st.mtimeMs, type });
|
||||||
|
}
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walkWithPaths(streamerRoot, '');
|
||||||
|
|
||||||
|
if (filesInTree.length === 0) continue;
|
||||||
|
result.totalScanned += filesInTree.length;
|
||||||
|
|
||||||
|
// Build a quick lookup so a recording file can attach its sibling
|
||||||
|
// .chat.* and .events.jsonl by stripping the .mp4/.mkv extension.
|
||||||
|
const companionByBase = new Map<string, { chat: string | null; events: string | null }>();
|
||||||
|
for (const f of filesInTree) {
|
||||||
|
if (f.type !== 'chat' && f.type !== 'events') continue;
|
||||||
|
// Strip companion suffix to get the base name shared with the
|
||||||
|
// recording: foo.mp4 + foo.chat.jsonl + foo.events.jsonl.
|
||||||
|
const base = f.fullPath.replace(/\.chat\.jsonl?$/i, '').replace(/\.events\.jsonl$/i, '');
|
||||||
|
const existing = companionByBase.get(base) || { chat: null, events: null };
|
||||||
|
if (f.type === 'chat') existing.chat = f.fullPath;
|
||||||
|
else if (f.type === 'events') existing.events = f.fullPath;
|
||||||
|
companionByBase.set(base, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of filesInTree) {
|
||||||
|
// We only surface recordings (live/vod) as search hits — chat
|
||||||
|
// and events files attach as companions and don't appear as
|
||||||
|
// standalone rows. Users searching for chat usually want the
|
||||||
|
// recording it belongs to anyway.
|
||||||
|
if (f.type !== 'live' && f.type !== 'vod') continue;
|
||||||
|
if (!matchSearchFilter(streamerFolder, f.rel, f.name, f.size, f.mtimeMs, f.type, filter)) continue;
|
||||||
|
|
||||||
|
const recordingBase = f.fullPath.replace(/\.(mp4|mkv|ts|m4v)$/i, '');
|
||||||
|
const companions = companionByBase.get(recordingBase) || { chat: null, events: null };
|
||||||
|
|
||||||
|
result.hits.push({
|
||||||
|
fullPath: f.fullPath,
|
||||||
|
fileName: f.name,
|
||||||
|
streamer: streamerFolder,
|
||||||
|
type: f.type,
|
||||||
|
size: f.size,
|
||||||
|
mtimeMs: f.mtimeMs,
|
||||||
|
chatPath: companions.chat,
|
||||||
|
eventsPath: companions.events
|
||||||
|
});
|
||||||
|
result.matchCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort then truncate. We sort the FULL match set (not the truncated
|
||||||
|
// one) so the user gets the genuinely largest/newest results, not
|
||||||
|
// arbitrary order.
|
||||||
|
const cmp: Record<typeof filter.sort, (a: ArchiveSearchHit, b: ArchiveSearchHit) => number> = {
|
||||||
|
date_desc: (a, b) => b.mtimeMs - a.mtimeMs,
|
||||||
|
date_asc: (a, b) => a.mtimeMs - b.mtimeMs,
|
||||||
|
size_desc: (a, b) => b.size - a.size,
|
||||||
|
size_asc: (a, b) => a.size - b.size,
|
||||||
|
name_asc: (a, b) => a.fileName.localeCompare(b.fileName)
|
||||||
|
};
|
||||||
|
result.hits.sort(cmp[filter.sort] || cmp.date_desc);
|
||||||
|
if (result.hits.length > maxHits) {
|
||||||
|
result.truncated = true;
|
||||||
|
result.hits = result.hits.slice(0, maxHits);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
function computeArchiveStats(): ArchiveStats {
|
function computeArchiveStats(): ArchiveStats {
|
||||||
const root = config.download_path;
|
const root = config.download_path;
|
||||||
const stats: ArchiveStats = {
|
const stats: ArchiveStats = {
|
||||||
@ -6264,6 +6435,23 @@ ipcMain.handle('get-archive-stats', (): ArchiveStats => {
|
|||||||
return computeArchiveStats();
|
return computeArchiveStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('search-archive', (_, filter: Partial<ArchiveSearchFilter>): ArchiveSearchResult => {
|
||||||
|
const normalized: ArchiveSearchFilter = {
|
||||||
|
query: typeof filter?.query === 'string' ? filter.query.trim() : '',
|
||||||
|
type: (['all', 'live', 'vod', 'chat', 'events'] as const).includes(filter?.type as 'all' | 'live' | 'vod' | 'chat' | 'events')
|
||||||
|
? filter!.type as 'all' | 'live' | 'vod' | 'chat' | 'events'
|
||||||
|
: 'all',
|
||||||
|
streamer: typeof filter?.streamer === 'string' ? filter.streamer.trim() : '',
|
||||||
|
sinceMs: Number.isFinite(filter?.sinceMs as number) ? Number(filter?.sinceMs) : null,
|
||||||
|
untilMs: Number.isFinite(filter?.untilMs as number) ? Number(filter?.untilMs) : null,
|
||||||
|
sort: (['date_desc', 'date_asc', 'size_desc', 'size_asc', 'name_asc'] as const).includes(filter?.sort as 'date_desc')
|
||||||
|
? filter!.sort as 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc'
|
||||||
|
: 'date_desc',
|
||||||
|
limit: Number.isFinite(filter?.limit as number) ? Number(filter?.limit) : 200
|
||||||
|
};
|
||||||
|
return searchArchive(normalized);
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle('get-storage-stats', (): StorageStatsResult => {
|
ipcMain.handle('get-storage-stats', (): StorageStatsResult => {
|
||||||
return computeStorageStats();
|
return computeStorageStats();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -91,6 +91,7 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
||||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||||
|
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||||
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
||||||
|
|||||||
201
src/renderer-archive.ts
Normal file
201
src/renderer-archive.ts
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
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;
|
||||||
28
src/renderer-globals.d.ts
vendored
28
src/renderer-globals.d.ts
vendored
@ -233,6 +233,25 @@ interface StorageStatsResult {
|
|||||||
scannedAt: string;
|
scannedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ArchiveSearchHit {
|
||||||
|
fullPath: string;
|
||||||
|
fileName: string;
|
||||||
|
streamer: string;
|
||||||
|
type: 'live' | 'vod' | 'chat' | 'events' | 'other';
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
chatPath: string | null;
|
||||||
|
eventsPath: string | null;
|
||||||
|
}
|
||||||
|
interface ArchiveSearchResult {
|
||||||
|
totalScanned: number;
|
||||||
|
matchCount: number;
|
||||||
|
truncated: boolean;
|
||||||
|
hits: ArchiveSearchHit[];
|
||||||
|
scannedAt: string;
|
||||||
|
rootExists: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface ArchiveStatsTopStreamer {
|
interface ArchiveStatsTopStreamer {
|
||||||
streamer: string;
|
streamer: string;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
@ -294,6 +313,15 @@ interface ApiBridge {
|
|||||||
checkFolderWritable(path: string): Promise<boolean>;
|
checkFolderWritable(path: string): Promise<boolean>;
|
||||||
getStorageStats(): Promise<StorageStatsResult>;
|
getStorageStats(): Promise<StorageStatsResult>;
|
||||||
getArchiveStats(): Promise<ArchiveStats>;
|
getArchiveStats(): Promise<ArchiveStats>;
|
||||||
|
searchArchive(filter: {
|
||||||
|
query?: string;
|
||||||
|
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||||
|
streamer?: string;
|
||||||
|
sinceMs?: number | null;
|
||||||
|
untilMs?: number | null;
|
||||||
|
sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<ArchiveSearchResult>;
|
||||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||||
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||||
getAutomationStatus(): Promise<{
|
getAutomationStatus(): Promise<{
|
||||||
|
|||||||
@ -118,6 +118,29 @@ const UI_TEXT_DE = {
|
|||||||
statsEmpty: 'Keine Daten.',
|
statsEmpty: 'Keine Daten.',
|
||||||
statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||||
navStats: 'Statistik',
|
navStats: 'Statistik',
|
||||||
|
navArchive: 'Archiv',
|
||||||
|
archiveTitle: 'Archiv durchsuchen',
|
||||||
|
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.',
|
||||||
|
archiveAllTypes: 'Alle Typen',
|
||||||
|
archiveTypeLive: 'Live-Aufnahmen',
|
||||||
|
archiveTypeVod: 'VOD-Downloads',
|
||||||
|
archiveAllStreamers: 'Alle Streamer',
|
||||||
|
archiveSortDateDesc: 'Neueste zuerst',
|
||||||
|
archiveSortDateAsc: 'Aelteste zuerst',
|
||||||
|
archiveSortSizeDesc: 'Groesste zuerst',
|
||||||
|
archiveSortSizeAsc: 'Kleinste zuerst',
|
||||||
|
archiveSortNameAsc: 'Name (A-Z)',
|
||||||
|
archiveSearchBtn: 'Suchen',
|
||||||
|
archiveSearching: 'Scanne...',
|
||||||
|
archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)',
|
||||||
|
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
|
||||||
|
archiveNoMatches: 'Keine Treffer.',
|
||||||
|
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||||
|
archiveSearchPlaceholder: 'Suche...',
|
||||||
|
archiveOpen: 'Oeffnen',
|
||||||
|
archiveShowInFolder: 'Ordner',
|
||||||
|
archiveViewChat: 'Chat',
|
||||||
|
archiveViewEvents: 'Events',
|
||||||
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
||||||
backupCardTitle: 'Sicherung & Wartung',
|
backupCardTitle: 'Sicherung & Wartung',
|
||||||
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
||||||
@ -234,6 +257,7 @@ const UI_TEXT_DE = {
|
|||||||
cutter: 'Video schneiden',
|
cutter: 'Video schneiden',
|
||||||
merge: 'Videos zusammenfugen',
|
merge: 'Videos zusammenfugen',
|
||||||
stats: 'Statistik',
|
stats: 'Statistik',
|
||||||
|
archive: 'Archiv',
|
||||||
settings: 'Einstellungen'
|
settings: 'Einstellungen'
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
|
|||||||
@ -119,6 +119,29 @@ const UI_TEXT_EN = {
|
|||||||
statsEmpty: 'No data.',
|
statsEmpty: 'No data.',
|
||||||
statsNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
statsNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||||
navStats: 'Statistics',
|
navStats: 'Statistics',
|
||||||
|
navArchive: 'Archive',
|
||||||
|
archiveTitle: 'Search archive',
|
||||||
|
archiveIntro: 'Search by filename, streamer, or date string. Hits show recordings (Live + VOD); related chat and events files appear as companion buttons.',
|
||||||
|
archiveAllTypes: 'All types',
|
||||||
|
archiveTypeLive: 'Live recordings',
|
||||||
|
archiveTypeVod: 'VOD downloads',
|
||||||
|
archiveAllStreamers: 'All streamers',
|
||||||
|
archiveSortDateDesc: 'Newest first',
|
||||||
|
archiveSortDateAsc: 'Oldest first',
|
||||||
|
archiveSortSizeDesc: 'Largest first',
|
||||||
|
archiveSortSizeAsc: 'Smallest first',
|
||||||
|
archiveSortNameAsc: 'Name (A-Z)',
|
||||||
|
archiveSearchBtn: 'Search',
|
||||||
|
archiveSearching: 'Scanning...',
|
||||||
|
archiveSummary: '{matchCount} matches (scanned {scanned} files)',
|
||||||
|
archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)',
|
||||||
|
archiveNoMatches: 'No matches.',
|
||||||
|
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||||
|
archiveSearchPlaceholder: 'Search...',
|
||||||
|
archiveOpen: 'Open',
|
||||||
|
archiveShowInFolder: 'Folder',
|
||||||
|
archiveViewChat: 'Chat',
|
||||||
|
archiveViewEvents: 'Events',
|
||||||
backupCardTitle: 'Backup & Maintenance',
|
backupCardTitle: 'Backup & Maintenance',
|
||||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||||
exportConfig: 'Export config',
|
exportConfig: 'Export config',
|
||||||
@ -234,6 +257,7 @@ const UI_TEXT_EN = {
|
|||||||
cutter: 'Video Cutter',
|
cutter: 'Video Cutter',
|
||||||
merge: 'Merge Videos',
|
merge: 'Merge Videos',
|
||||||
stats: 'Statistics',
|
stats: 'Statistics',
|
||||||
|
archive: 'Archive',
|
||||||
settings: 'Settings'
|
settings: 'Settings'
|
||||||
},
|
},
|
||||||
queue: {
|
queue: {
|
||||||
|
|||||||
@ -50,6 +50,28 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setText('navCutterText', UI_TEXT.static.navCutter);
|
setText('navCutterText', UI_TEXT.static.navCutter);
|
||||||
setText('navMergeText', UI_TEXT.static.navMerge);
|
setText('navMergeText', UI_TEXT.static.navMerge);
|
||||||
setText('navStatsText', UI_TEXT.static.navStats);
|
setText('navStatsText', UI_TEXT.static.navStats);
|
||||||
|
setText('navArchiveText', UI_TEXT.static.navArchive);
|
||||||
|
setText('archiveTitle', UI_TEXT.static.archiveTitle);
|
||||||
|
setText('archiveIntro', UI_TEXT.static.archiveIntro);
|
||||||
|
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
|
||||||
|
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||||
|
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
|
||||||
|
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||||
|
if (archiveTypeSelect) {
|
||||||
|
const opts = archiveTypeSelect.options;
|
||||||
|
if (opts[0]) opts[0].text = UI_TEXT.static.archiveAllTypes;
|
||||||
|
if (opts[1]) opts[1].text = UI_TEXT.static.archiveTypeLive;
|
||||||
|
if (opts[2]) opts[2].text = UI_TEXT.static.archiveTypeVod;
|
||||||
|
}
|
||||||
|
const archiveSortSelect = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||||
|
if (archiveSortSelect) {
|
||||||
|
const opts = archiveSortSelect.options;
|
||||||
|
if (opts[0]) opts[0].text = UI_TEXT.static.archiveSortDateDesc;
|
||||||
|
if (opts[1]) opts[1].text = UI_TEXT.static.archiveSortDateAsc;
|
||||||
|
if (opts[2]) opts[2].text = UI_TEXT.static.archiveSortSizeDesc;
|
||||||
|
if (opts[3]) opts[3].text = UI_TEXT.static.archiveSortSizeAsc;
|
||||||
|
if (opts[4]) opts[4].text = UI_TEXT.static.archiveSortNameAsc;
|
||||||
|
}
|
||||||
setText('navSettingsText', UI_TEXT.static.navSettings);
|
setText('navSettingsText', UI_TEXT.static.navSettings);
|
||||||
setText('statsTitle', UI_TEXT.static.statsTitle);
|
setText('statsTitle', UI_TEXT.static.statsTitle);
|
||||||
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
|
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
|
||||||
|
|||||||
@ -811,6 +811,12 @@ function showTab(tab: string): void {
|
|||||||
const fn = (window as unknown as { refreshArchiveStats?: () => Promise<void> }).refreshArchiveStats;
|
const fn = (window as unknown as { refreshArchiveStats?: () => Promise<void> }).refreshArchiveStats;
|
||||||
if (typeof fn === 'function') void fn();
|
if (typeof fn === 'function') void fn();
|
||||||
}
|
}
|
||||||
|
if (tab === 'archive') {
|
||||||
|
const init = (window as unknown as { initArchiveSearchInput?: () => void }).initArchiveSearchInput;
|
||||||
|
const search = (window as unknown as { performArchiveSearch?: () => Promise<void> }).performArchiveSearch;
|
||||||
|
if (typeof init === 'function') init();
|
||||||
|
if (typeof search === 'function') void search();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDurationToSeconds(durStr: string): number {
|
function parseDurationToSeconds(durStr: string): number {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user