Two related Phase-4 changes.
1. main.ts: tBackend(key, params) helper with DE/EN tables for every
user-visible error / status string produced server-side. Previously
every backend message was hardcoded German, so EN-mode users saw
German errors in the queue (last_error), in download progress
status, in clip-download responses, and in the preflight panel.
~30 keys covered: invalidVodUrl, streamlinkMissing, fileTooSmall,
integrity*, downloadCancelled / downloadPaused, attemptFailed,
retryingIn, statusBytesDownloaded, mergeGroupFileMissing,
notAllPartsDownloaded / notAllClipPartsDownloaded, ffmpegMerge/
SplitFailed, diskSpaceShortFor, all preflight* messages, etc.
classifyDownloadError extended to recognize EN equivalents
(streamlink not found, no video stream, folder) so the retry
classification still works correctly when the language is EN.
The hand-rolled translation table in renderer.ts:downloadClip is
gone — backend strings are already locale-correct.
2. styles.css: --border-soft CSS var added to :root and the
theme-light override. Inline styles in index.html for the VOD
filter input / sort select / bulk bar were referencing
--bg-secondary / --text-primary / --border-color (which don't
exist) and falling through to dark hex fallbacks (#222 / #fff /
#444), producing a dark patch in light theme. Now uses
var(--bg-card) / var(--text) / var(--border-soft) which both
themes define.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two complete UX features.
1. Streamer list is now drag-and-drop reorderable. The order is
persisted via the existing config.streamers save path, so it
survives a restart. The dragstart-then-click race that would
normally fire selectStreamer when the drag is released is
suppressed via a 50ms post-dragend window.
2. VOD cards each get a top-left checkbox. Selecting >=1 card opens
a sticky action bar above the grid with "+ Queue" and "Clear"
buttons. Bulk-add iterates the selected URLs and calls addToQueue
for each, with a single per-batch toast summarizing the outcome.
Selection is cleared on streamer switch (per-streamer mental
model) but not persisted across reloads (stale selection across
restarts is more confusing than helpful).
Implementation notes:
- Click-on-checkbox is handled by a single delegated listener on
vodGrid (initVodGridSelectionDelegation), not per-card inline
handlers. The card .selected class is toggled in place to avoid
re-rendering the entire grid on every check.
- Streamer items are rebuilt from createElement so the existing
`event.stopPropagation(); removeStreamer(...)` inline pattern
is replaced with a real listener; defends against unusual
characters in streamer names even though Cycle 4 added the
4-25-char alphanumeric regex.
- styles.css: position: relative on .vod-card for the absolute-
positioned checkbox; .selected ring highlight; .dragging
opacity for streamer drag.
- DE / EN locale strings for the bulk-bar; setText / updateBar
hook into applyLanguageToStaticUI so the bar count updates on
language switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After a download completes there was no way to jump to the result
without manually navigating the download folder.
Server-side:
- DownloadResult and QueueItem gain optional outputFiles: string[]
(single entry for VOD/clip, multi for parts/merge-group splits).
Threaded through every downloadVOD / processDownloadMergeGroup
branch into processOneQueueItem which attaches it to the queue
item on success. Persisted via sanitizeQueueItem so the actions
survive a queue file reload.
- New IPC handlers open-file (shell.openPath) and show-in-folder
(shell.showItemInFolder), both with existence + type checks.
- The "downloads finished" Notification gets a click handler that
brings the window to the foreground and opens the download folder.
Renderer-side:
- Expanded queue-item details now render an action row when
status === completed and outputFiles is non-empty.
- "Open file" only shown when there is exactly one file (so multi-
part downloads do not surprise the user by opening just part 1).
"Show in folder" always shown.
- DE / EN locale strings + a graceful toast if the file was moved
or deleted between completion and click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UX wins.
1. Auto-update: "Skip this version" button on the update modal.
Stores the dismissed version in localStorage; subsequent automatic
update-available events for the same version are silenced (banner
hidden, modal not opened). Manual "Check for updates" overrides the
skip so the user can change their mind. The flag is cleared once
the version is actually downloaded so a stale entry never masks a
future update. Skip button is hidden in the "ready to install"
state where it would not make sense.
2. addStreamer now validates against Twitch username rules
(4-25 chars, [a-zA-Z0-9_]). Previously bad input fell through to
the API and the user saw a silent "streamer not found" message
instead of being told the input was invalid.
3. Smart Queue Scheduler checkbox got a hover tooltip that explains
what enabling it actually does ("prefers shorter VODs and older
queue entries first"). Users were disabling it without knowing
what they were turning off.
DE + EN locale strings added for all three.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VOD-card button now reads "Trim VOD" / "VOD zuschneiden" to mirror
the dialog title exactly (was "Trim" / "Zuschneiden" in 4.5.14).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Match the dialog title exactly. Fits on the button width and removes
the last "Trim" / "Trim VOD" inconsistency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 4.5.13: the button on each VOD card that opens the
"Trim VOD" dialog now reads "Trim" (EN) / "Zuschneiden" (DE) instead
of "Clip". Language switch now re-renders the VOD grid so labels
update live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow-up to 4.5.13. The dialog title was renamed but the VOD-card
button that opens it still read "Clip", which kept the same
overloaded-with-Twitch-Clips ambiguity it was meant to fix.
- DE: "Zuschneiden", EN: "Trim" (kept short for the small card button;
the dialog itself still reads "Trim VOD" / "VOD zuschneiden")
- buildVodCardHtml now uses UI_TEXT.vods.trimButton instead of a
hardcoded "Clip"
- changeLanguage now also calls renderVodGridFromCurrentState +
refreshVodSortSelectLabels so the button label and sort-select
options update live on language switch (the existing addQueue label
was suffering the same staleness)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible: the modal opened by the per-VOD "Clip" button now reads
"Trim VOD" (EN) / "VOD zuschneiden" (DE) instead of "Trim clip" /
"Clip zuschneiden" — disambiguates from the separate Twitch Clips
feature.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dialog cuts a custom time-range out of a VOD; calling its result a
"clip" was overloaded with the separate Twitch Clips feature (which
this project handles in the dedicated Clips tab). Renaming the modal
title disambiguates without touching the per-VOD-card "Clip" button
(still the right verb for the action that opens it).
- EN: "Trim VOD"
- DE: "VOD zuschneiden"
- index.html static fallback updated to match the DE locale
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User-visible: Trim-Clip dialog now exposes the {date}_Part{part_padded}
naming pattern (e.g. 08.05.2026_Part07.mp4) as a radio preset, matching
the global VOD-parts template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Trim-Clip filename-format radio group only offered three presets
(simple, timestamp, custom template). Users who organise their archive
with the global filename_template_parts pattern (e.g.
08.05.2026_Part07.mp4) had to switch to "custom template" and retype
{date}_Part{part_padded}.mp4 every time.
New "parts" preset:
- index.html: 4th radio option, span#formatParts for the live preview
- types.ts + renderer-globals.d.ts: filenameFormat union extended
- main.ts: makeClipFilename branch produces ${dateStr}_Part${padded}.mp4;
sanitizeCustomClip whitelists "parts" so persisted queue items with
the new format survive a restart
- renderer.ts: getSelectedFilenameFormat returns "parts"; live preview
via partNum.padStart(2, "0")
- DE/EN locales: clips.formatParts label
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- public-API fallback retries on transient HTTP/network errors so a
single TCP RST does not produce an empty VOD list
- new VOD list sort dropdown with persisted key (newest, oldest,
most viewed, longest, shortest)
- shutdownCleanup() consolidates window-all-closed and before-quit
into one idempotent helper so future exit-time cleanup lands once
See docs/IMPROVEMENT_LOG.md (Cycle 4, 2026-05-03) for the dated
rationale and regression run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a sort selector next to the existing filter input. Five modes:
newest first (default), oldest first, most viewed, longest first,
shortest first. Concrete user pain — long archives previously had no
way to find the longest stream, the most-watched, or to scroll back
to the start chronologically.
- vodSortKey state persisted to localStorage as
twitch-vod-manager:vod-sort and validated against an enum on load,
so an unknown stored value falls back to date_desc
- renderVodGridFromCurrentState now applies sortVods before
filterVodsByQuery so the filter sees the sort and the match counter
is consistent
- sortVods uses created_at timestamps for date sorts, view_count for
views, and a tiny vodDurationToSeconds parser (XhYmZs) for duration
- DE + EN labels for both the "Sort:" prefix and the five option
texts; refreshVodSortSelectLabels re-runs on language switch
- syncVodSortSelect on init preselects the persisted value before
any VOD load so the dropdown reflects state immediately
Browser-default keyboard nav (arrows, type-ahead) covers keyboard
access for the select.
docs/IMPROVEMENT_LOG.md: Cycle 4 dated section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two server-side changes touching different paths.
1. fetchPublicTwitchGql now retries on transient HTTP (408/429/5xx) and
network-layer failures (no response). Up to 3 attempts with
exponential backoff + jitter (400ms * 2^(n-1)). The previous
catch (e) { return null; } swallowed network blips on the public
fallback path, which is what every user without a client_id hits
on each VOD list load — a single TCP RST produced an empty list
and the user had to click refresh. GraphQL errors[] are still
returned without retry (application-level query rejections).
Recovery is logged via appendDebugLog so we can later see whether
the retries actually pay off in production.
2. shutdownCleanup() consolidates window-all-closed and before-quit.
The two handlers ran nearly identical cleanup blocks but had
drifted: only window-all-closed killed children and was
platform-aware. The helper kills activeDownloads + activeClipProcesses
+ currentEditorProcess with try/catch, persists config + queue,
then stops timers (debug-log flush moved AFTER persistence so any
save error reaches the log before the timer is gone). An idempotent
shutdownCleanupDone flag makes a follow-on event a no-op.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- download-clip: sanitize broadcaster name + title, ensure unique
filename, post-download size + integrity check, track in
activeClipProcesses so window-close cleans up
- VOD list: persistent filter input with Ctrl+F focus, Esc clear,
match counter (DE + EN strings)
- currentProcess split into currentEditorProcess (cutter/merger/
splitter only) so cancel-download no longer accidentally kills a
separate video cut
See docs/IMPROVEMENT_LOG.md (Cycle 3, 2026-05-03) for the dated
rationale and regression run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Filter row above the VOD grid lets the user search the loaded archive
by title. Concrete user pain: streamers commonly have hundreds of VODs
and the current UI only supported scrolling.
- vodFilterInput / vodFilterClearBtn / vodFilterCount in index.html
- localized placeholder + clear-button title (DE + EN)
- vodFilterQuery state persisted to localStorage as
twitch-vod-manager:vod-filter so the search bar survives reloads
- renderVODs split: it now caches lastLoadedVods + lastLoadedStreamer
and delegates to renderVodGridFromCurrentState which applies
filterVodsByQuery on every input event (no re-fetch)
- empty-state DOM is now built with createElement + textContent (via
setVodGridEmptyState) instead of an innerHTML template, even for
locale-only strings — defence in depth
- keyboard: Ctrl/Cmd+F focuses the filter when the VODs tab is active
(Electron has no native find bar, so the default is suppressed). Esc
clears the filter when the input has focus and content. Esc still
closes modals first if any are open.
docs/IMPROVEMENT_LOG.md: Cycle 3 dated section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two server-side fixes for separate clip/queue/editor crosstalk paths.
1. download-clip IPC was unsafe in three ways:
- reported success: true on exit code 0 even with empty files
(Twitch sometimes returns a manifest with no segments)
- passed clipInfo.broadcaster_name straight to path.join, so unicode
/ spaces / punctuation in display names produced odd directory
layouts on Windows
- the spawned streamlink process was tracked nowhere, so window
close orphaned it
Now: sanitize broadcaster_name + title, ensureUniqueFilename so
re-downloads do not overwrite, post-download size + integrity check
(16 KiB floor + ffprobe via validateDownloadedFileIntegrity), proc
tracked in activeClipProcesses and killed on window-all-closed.
2. currentProcess (a single ChildProcess global) was shared between
cutter/merger/splitter and downloadVODPart. The real bug: while a
queue download was running and the user kicked off a video cut,
pressing the queue's "Stop" button iterated activeDownloads (fine)
AND called currentProcess.kill() — which by then pointed at the
cutter ffmpeg, killing an unrelated cut.
Renamed to currentEditorProcess, confined to the editor pipeline.
downloadVODPart no longer touches it. The fallback kill calls in
remove-from-queue / pause-download / cancel-download are gone — the
activeDownloads loop above each was already authoritative.
window-all-closed now also kills activeClipProcesses.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- scripts/release_gitea.mjs: --skip-build flag and auto-skip when
artifacts for the version already exist on disk
- playwright in devDependencies; test:e2e* scripts call node directly
- loadConfig isPlainObject shape check + loadQueue sanitizes every
entry incl. customClip/mergeGroup; stale status="downloading" is
demoted to "pending" so the queue can actually be resumed
See docs/IMPROVEMENT_LOG.md (Cycle 2, 2026-05-03) for the dated
rationale and regression run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- loadConfig now checks isPlainObject(parsed) before spreading over
defaults. Non-object JSON (array, primitive, null) is logged and the
app falls back to defaults instead of silently polluting the config
with array indices or dropping values.
- loadQueue runs every entry through sanitizeQueueItem which validates
the status enum, clamps progress to [0, 100], validates customClip
and mergeGroup shapes (with sanitizeCustomClip / sanitizeMergeGroup
helpers), and demotes stale status="downloading" entries to "pending"
with progress=0 on cold start. The previous filter only checked
typeof id/url/status === "string" and let through whatever shape
customClip / mergeGroup happened to have.
- The stale-downloading normalisation fixes a real user trap: after a
hard kill mid-download, the queue persisted status="downloading", but
no download was running on next launch and start-download only resumed
paused items, leaving "downloading" entries stuck.
- Bonus: CustomClip and MergeGroupItem imports now have call sites
(previously unused-import warnings).
docs/IMPROVEMENT_LOG.md gains a Cycle 2 dated section.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two release-pipeline fixes that previously forced manual workarounds.
- scripts/release_gitea.mjs no longer unconditionally runs npm run dist:win.
New --skip-build flag, plus auto-skip when all 3 required artifacts
(Setup-<v>.exe, Setup-<v>.exe.blockmap, latest.yml) already exist for
the requested version. The previous behaviour re-ran the entire test
suite + electron-builder on every release attempt — unusable when the
test path was broken.
- playwright ^1.59.1 added to devDependencies. test:e2e / test:e2e:guide
/ test:e2e:full now invoke node scripts/smoke-test*.js directly instead
of "npm exec --yes --package=playwright -- node ...", which failed with
MODULE_NOT_FOUND when npm exec could not resolve playwright on the fly.
No browser binaries needed — the smoke tests drive Electron via
_electron, not a browser.
All test paths verified after the change: test:e2e, test:e2e:guide,
test:e2e:full, test:merge-split, test:e2e:update-logic — all pass with
the simplified scripts.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stability + UX cycle.
- saveConfig and writeQueueToDisk now use openSync+writeSync+fsyncSync+
closeSync+renameSync via writeFileAtomicSync. Survives power loss
between write and rename (used to leave the renamed file empty and
silently reset config / queue on next launch).
- Per-item claimedFilenames map fixes the parallel-download race where
one item finishing wiped sibling claims and let a third item collide
on the same output path.
- Renderer queue lookup by [data-id] (no more index drift), active tab
persisted in localStorage, Escape closes the topmost open modal,
Ctrl/Cmd+1..5 jumps tabs.
See docs/IMPROVEMENT_LOG.md for the dated rationale and regression run.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renderer-side polish bundle.
- updateQueueItemProgress now looks up items by [data-id] selector instead
of array index. Resilient against queue/DOM divergence between renders.
Determinate vs indeterminate progress logic tightened.
- Active tab persisted to localStorage on every showTab; restored on init
via loadPersistedActiveTab (whitelisted to known tab IDs so a future
rename cannot strand the user on a missing tab). Page title now only
shows the streamer name on the VODs tab — it no longer leaks into
Settings / Cutter / Merge.
- Escape closes the topmost open modal regardless of focus (clip dialog,
template guide, update modal — in that priority order).
- Ctrl+1..5 (Cmd+1..5 on macOS) jumps directly to a tab. The existing Del
(delete selected) and S (start/pause) shortcuts still work and remain
blocked while typing in inputs.
Adds docs/IMPROVEMENT_LOG.md (new, single dated section for this cycle).
Build: tsc clean. Full smoke suite green (failures: [], runtimeIssues: []).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two server-side correctness fixes for parallel downloads and crash recovery.
1. Atomic file writes survive power loss / crash mid-write.
saveConfig and writeQueueToDisk used writeFileSync + renameSync. Node's
writeFileSync does NOT fsync — a power loss between write and rename can
leave the renamed file empty or truncated, and the next launch silently
falls back to defaults / empty queue.
New writeFileAtomicSync helper: openSync + writeSync + fsyncSync +
closeSync + renameSync (with the existing Windows copy fallback). fsync
failure is non-fatal (some FS reject it) but file ordering is preserved.
2. Per-item claimed filenames fix the parallel-download race.
With max 2 parallel downloads, processOneQueueItem.finally was calling
claimedFilenames.clear() — wiping every parallel item's claims when any
one finished. In the window between an active item claiming a filename
and streamlink actually writing the first bytes, a third item could
compute the same filename and both downloads would race the same path.
New Map<itemId, Set<filename>> tracks claims per active download.
ensureUniqueFilename(path, itemId) registers per-item;
releaseClaimedFilenamesForItem(itemId) removes only that item's claims.
splitMergedFile gained an itemId parameter for the same reason. The
dead releaseClaimedFilename(path) function was removed.
Build: tsc clean. Tests: smoke + smoke-template-guide + smoke-full + merge-split
+ update-version-logic all pass. No new ESLint warnings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set app.setAppUserModelId('com.twitch.vodmanager') on startup so Windows
notifications display the correct app name.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The old formula (avgSpeed * expectedDurationSeconds) simplified to just
(videoDuration - elapsedTime), showing 59min ETA for a 60min part after
1min of downloading. Now uses streamlink's actual progress percentage:
ETA = (elapsed / percent) * (100 - percent), which reflects real download
speed rather than video length.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Use dataTransfer.effectAllowed='none' instead of preventDefault() for dragstart
(preventDefault does not cancel dragstart events per HTML spec)
- Clear claimedFilenames Set in processOneQueueItem finally block to prevent
stale claims from blocking re-downloads of same VODs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Queue selector uses min-width instead of fixed width for double-digit numbers
- Drag-start handler validates item is still pending before allowing drag
- ensureUniqueFilename uses in-memory claim set to prevent TOCTOU race
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move streamlink/ffmpeg path discovery, bundled tool management,
auto-install logic, and related caches (~430 lines) into a
dedicated tools module. main.ts uses dependency injection for
debug logging and directory paths to keep the module decoupled.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add parallel_downloads config option (1 or 2) with Settings UI dropdown.
Refactor processQueue to run concurrent download slots using Promise.race,
extracting per-item logic into processOneQueueItem. Add per-item process
tracking via activeDownloads Map and cancelledItemIds Set so cancel/pause
correctly terminates all active downloads.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>