diff --git a/docs/IMPROVEMENT_LOG.md b/docs/IMPROVEMENT_LOG.md index 47160e3..19bc6c9 100644 --- a/docs/IMPROVEMENT_LOG.md +++ b/docs/IMPROVEMENT_LOG.md @@ -2,6 +2,37 @@ Dated entries from improvement cycles. Newest at top. +## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix + +Three independent improvements landed this cycle. + +### 1. `download-clip` IPC: integrity, cancellation, sanitization (server defensive) + +- **File**: `src/main.ts` — `download-clip` IPC handler, new `activeClipProcesses` map. +- **Problem**: The handler reported `success: true` on streamlink exit code 0 even when the resulting file was empty / a few hundred bytes (Twitch occasionally returns a manifest with no segments). The path passed `clipInfo.broadcaster_name` straight to `path.join` — Twitch returns the broadcaster's *display* name, which can carry unicode, spaces, or punctuation that produced surprising directory layouts on Windows. The spawned streamlink process was tracked nowhere, so `window-all-closed` left it orphaned. +- **Fix**: `safeBroadcaster` runs through `sanitizeFilenamePart`. `safeTitle` falls back to `clip` when the title sanitises to empty. The output filename now goes through `ensureUniqueFilename(path, clipId)` so retrying a clip with the same title doesn't overwrite the previous download. After streamlink exits, the file is rejected if smaller than 16 KiB or if `validateDownloadedFileIntegrity` fails (no video stream / unreadable). The proc is tracked in a new `activeClipProcesses` map and killed by `window-all-closed`. + +### 2. VOD list filter / search (client feature: VM/state + UI + persistence + keyboard) + +- **Files**: `src/renderer-streamers.ts`, `src/renderer.ts`, `src/renderer-texts.ts`, `src/index.html`, `src/renderer-locale-de.ts`, `src/renderer-locale-en.ts`. +- **Problem**: A streamer can have hundreds of VODs (the test fixture alone has 37 cards). There was no way to find a specific VOD by title — only scroll. With a long archive this is genuinely painful. +- **Fix**: Filter row above the VOD grid (`vodFilterInput`, clear button, match counter). State (`vodFilterQuery`) is persisted to `localStorage` via `loadPersistedVodFilter` / `persistVodFilter`, so the search bar survives an app restart. The render path was split: `renderVODs` now stores `lastLoadedVods` + `lastLoadedStreamer` and delegates to `renderVodGridFromCurrentState`, which applies `filterVodsByQuery` on every input event without re-fetching. Empty-state DOM is built via `setVodGridEmptyState` using `createElement` + `textContent` (no `innerHTML` for locale strings — defense-in-depth even though the strings are trusted). Keyboard: `Ctrl+F` / `Cmd+F` focuses the filter (only when the VODs tab is active and Electron's no-op default is suppressed); `Esc` clears the filter when the input has focus and content; `Esc` still closes modals first if any are open. + +### 3. Decouple `currentProcess` from queue downloads (server cleanup + race fix) + +- **File**: `src/main.ts` — global rename and assignment removal. +- **Problem**: A single `currentProcess: ChildProcess | null` was shared by `cutVideo`, `mergeVideos`, `splitMergedFile`, AND `downloadVODPart`. With parallel downloads the global was constantly overwritten between siblings, but the cross-talk that mattered was different: if a queue download was running and the user kicked off a video cut, the cutter ffmpeg ran into the same global. Pressing the queue's *cancel-download* button then iterated `activeDownloads` (correct) AND called `currentProcess.kill()` (incorrect — that was the cutter ffmpeg by then), killing the unrelated cut. +- **Fix**: `currentProcess` renamed to `currentEditorProcess` and confined to the editor pipeline (cutter / merger / splitter). `downloadVODPart` no longer assigns to it — `activeDownloads` is the sole source of truth for queue children. The fallback `if (currentProcess) currentProcess.kill()` was removed from `remove-from-queue`, `pause-download`, and `cancel-download`. `window-all-closed` still kills it (so a cutter ffmpeg gets cleaned up on app exit) and now also kills `activeClipProcesses` introduced by Pick 1. + +### Regression + +- `npm run build` — clean (TypeScript strict, 0 errors). +- `npm run test:e2e:update-logic` — passed. +- `npm run test:e2e` — passed (`issues: []`). +- `npm run test:e2e:guide` — passed (`failures: []`). +- `npm run test:merge-split` — passed. +- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue add, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check). + ## 2026-05-03 — Cycle 2: release pipeline + defensive parsing Three independent improvements landed this cycle. diff --git a/src/index.html b/src/index.html index 56668cb..e502863 100644 --- a/src/index.html +++ b/src/index.html @@ -239,6 +239,11 @@
+
+ + + +
diff --git a/src/renderer-locale-de.ts b/src/renderer-locale-de.ts index b7d22e7..fb03b6c 100644 --- a/src/renderer-locale-de.ts +++ b/src/renderer-locale-de.ts @@ -162,7 +162,12 @@ const UI_TEXT_DE = { noResultsText: 'Dieser Streamer hat keine VODs.', untitled: 'Unbenanntes VOD', views: 'Aufrufe', - addQueue: '+ Warteschlange' + addQueue: '+ Warteschlange', + filterPlaceholder: 'Nach Titel filtern... (Strg+F)', + filterClearTitle: 'Filter loeschen (Esc)', + filterNoMatchTitle: 'Keine Treffer', + filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.', + filterMatchCount: '{shown} von {total} VODs' }, clips: { dialogTitle: 'Clip zuschneiden', diff --git a/src/renderer-locale-en.ts b/src/renderer-locale-en.ts index a51c2c6..9b47d5b 100644 --- a/src/renderer-locale-en.ts +++ b/src/renderer-locale-en.ts @@ -162,7 +162,12 @@ const UI_TEXT_EN = { noResultsText: 'This streamer has no VODs.', untitled: 'Untitled VOD', views: 'views', - addQueue: '+ Queue' + addQueue: '+ Queue', + filterPlaceholder: 'Filter by title... (Ctrl+F)', + filterClearTitle: 'Clear filter (Esc)', + filterNoMatchTitle: 'No matches', + filterNoMatchText: 'No VODs match the current filter.', + filterMatchCount: '{shown} of {total} VODs' }, clips: { dialogTitle: 'Trim clip', diff --git a/src/renderer-streamers.ts b/src/renderer-streamers.ts index 52d75cf..1aac7d1 100644 --- a/src/renderer-streamers.ts +++ b/src/renderer-streamers.ts @@ -2,6 +2,79 @@ let selectStreamerRequestId = 0; let vodRenderTaskId = 0; const VOD_RENDER_CHUNK_SIZE = 64; +// VOD filter state — persists across renderer reloads via localStorage so the +// user's search query survives an app restart. Cleared explicitly via Esc / +// the clear button. Shared across streamers (acts like a search bar). +let lastLoadedVods: VOD[] = []; +let lastLoadedStreamer: string | null = null; +let vodFilterQuery = ''; +const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter'; + +function loadPersistedVodFilter(): string { + try { + return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? ''; + } catch { + return ''; + } +} + +function persistVodFilter(query: string): void { + try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ } +} + +function filterVodsByQuery(vods: VOD[], query: string): VOD[] { + const q = query.trim().toLowerCase(); + if (!q) return vods; + return vods.filter((vod) => (vod.title || '').toLowerCase().includes(q)); +} + +function updateVodFilterCount(filteredCount: number, totalCount: number): void { + const node = document.getElementById('vodFilterCount'); + if (!node) return; + if (!totalCount || !vodFilterQuery.trim()) { + node.textContent = ''; + return; + } + node.textContent = UI_TEXT.vods.filterMatchCount + .replace('{shown}', String(filteredCount)) + .replace('{total}', String(totalCount)); +} + +function syncVodFilterClearButton(): void { + const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null; + if (!btn) return; + btn.style.display = vodFilterQuery.trim() ? '' : 'none'; +} + +function onVodFilterInput(): void { + const input = byId('vodFilterInput'); + vodFilterQuery = input.value; + persistVodFilter(vodFilterQuery); + syncVodFilterClearButton(); + if (lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } +} + +function clearVodFilter(): void { + vodFilterQuery = ''; + const input = byId('vodFilterInput'); + if (input) input.value = ''; + persistVodFilter(''); + syncVodFilterClearButton(); + if (lastLoadedStreamer) { + renderVodGridFromCurrentState(); + } +} + +function focusVodFilter(): void { + const input = document.getElementById('vodFilterInput') as HTMLInputElement | null; + if (input) { + input.focus(); + input.select(); + } +} + function buildVodCardHtml(vod: VOD, streamer: string): string { const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180'); const date = formatUiDate(vod.created_at); @@ -117,9 +190,48 @@ async function selectStreamer(name: string, forceRefresh = false): Promise renderVODs(vods, name); } +function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void { + // Build via DOM API so the (locale-only) strings can never escape into HTML. + const wrap = document.createElement('div'); + wrap.className = 'empty-state'; + const h3 = document.createElement('h3'); + h3.textContent = title; + const p = document.createElement('p'); + p.textContent = text; + wrap.appendChild(h3); + wrap.appendChild(p); + grid.replaceChildren(wrap); +} + function renderVODs(vods: VOD[] | null | undefined, streamer: string): void { + lastLoadedVods = Array.isArray(vods) ? vods : []; + lastLoadedStreamer = streamer; + renderVodGridFromCurrentState(); +} + +function renderVodGridFromCurrentState(): void { + if (!lastLoadedStreamer) return; + const grid = byId('vodGrid'); const renderTaskId = ++vodRenderTaskId; + const total = lastLoadedVods.length; + + if (total === 0) { + setVodGridEmptyState(grid, UI_TEXT.vods.noResultsTitle, UI_TEXT.vods.noResultsText); + updateVodFilterCount(0, 0); + return; + } + + const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery); + + if (filtered.length === 0 && vodFilterQuery.trim()) { + setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText); + updateVodFilterCount(0, total); + return; + } + + grid.replaceChildren(); + updateVodFilterCount(filtered.length, total); const scheduleNextChunk = (nextStartIndex: number): void => { const delayMs = document.hidden ? 16 : 0; @@ -128,26 +240,19 @@ function renderVODs(vods: VOD[] | null | undefined, streamer: string): void { }, delayMs); }; - if (!vods || vods.length === 0) { - grid.innerHTML = `

${UI_TEXT.vods.noResultsTitle}

${UI_TEXT.vods.noResultsText}

`; - return; - } - - grid.innerHTML = ''; - const renderChunk = (startIndex: number): void => { if (renderTaskId !== vodRenderTaskId) { return; } - const chunk = vods.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE); + const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE); if (!chunk.length) { return; } - grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, streamer)).join('')); + grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join('')); - if (startIndex + chunk.length < vods.length) { + if (startIndex + chunk.length < filtered.length) { scheduleNextChunk(startIndex + chunk.length); } }; diff --git a/src/renderer-texts.ts b/src/renderer-texts.ts index be171db..ceb7b04 100644 --- a/src/renderer-texts.ts +++ b/src/renderer-texts.ts @@ -143,6 +143,8 @@ function applyLanguageToStaticUI(): void { setText('updateChangelogToggle', UI_TEXT.updates.showChangelog); setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog); setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder); + setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder); + setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle); const status = document.getElementById('statusText')?.textContent?.trim() || ''; if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) { diff --git a/src/renderer.ts b/src/renderer.ts index 76707d5..434a392 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -45,6 +45,13 @@ async function init(): Promise { initQueueDragDrop(); updateDownloadButtonState(); + // Restore persisted VOD filter into the input — the filter itself only + // takes effect once VODs load (renderVODs reads vodFilterQuery). + vodFilterQuery = loadPersistedVodFilter(); + const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null; + if (vodFilterInput) vodFilterInput.value = vodFilterQuery; + syncVodFilterClearButton(); + // Restore last active tab from previous session (default 'vods') showTab(loadPersistedActiveTab()); @@ -136,6 +143,28 @@ async function init(): Promise { e.preventDefault(); return; } + + // No modal open: if the VOD filter has focus or content, clear it. + // Otherwise let Esc bubble (e.g. blur). + if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') { + if (vodFilterQuery) { + clearVodFilter(); + e.preventDefault(); + } + return; + } + } + + // Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab. + // Browser's default Ctrl+F is suppressed because Electron's renderer + // doesn't have a native find bar anyway. + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) { + const onVodsTab = document.getElementById('vodsTab')?.classList.contains('active'); + if (onVodsTab) { + e.preventDefault(); + focusVodFilter(); + return; + } } // Skip rest if user is typing in an input field