For users who run the app on a dedicated archive box and aren't
watching the queue panel directly. Three optional event types post
to a Discord webhook:
- Live recording started: red embed with streamer + URL + output
filename. Fires inside downloadLiveStream after chat-capture
init, before streamlink launches, so a hung streamlink doesn't
silently delay the alert.
- Live recording ended: green (ok) or purple (failed) embed with
duration, file size, captured-chat-message count, output filename.
Fires after streamlink exits — picks up cancellation, integrity
failure, and clean stream-ended exits the same way.
- VOD download complete: green embed with file count + total bytes.
Skipped for live items (those have their own end-of-recording
embed; double-firing would be noisy).
Server:
- New isAcceptableDiscordWebhook(url) regex sanity-check —
refuses URLs that aren't discord.com/api/webhooks/* so a
pasted-by-mistake other URL doesn't leak data anywhere.
- sendDiscordWebhook(payload) is fire-and-forget: 8s timeout,
errors logged via appendDebugLog but never surface to the user.
Should NOT block the recording flow.
- DiscordEmbedColor enum maps live/success/info to known palette
values (red / green / Twitch purple).
- Embed body slices fields to Discord's documented length limits
(title 256, description 4096, field name 256, field value 1024,
max 25 fields per embed) so a runaway long stream title can't
produce a rejected webhook.
Renderer / settings:
- New Settings card "Discord-Webhook" between Backup and Updates.
URL input + 3 toggles (live-start / live-end / vod-complete).
All three default off, URL empty — totally inert until the user
configures it.
- AppConfig type, autosave fingerprint, syncSettingsForm,
applyLanguageToStaticUI, debounced-save IDs all updated. Webhook
URL is debounced like other text inputs so each keystroke
doesn't trigger a save.
- DE + EN locales for every label.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Companion to 4.6.2 (VOD chat replay): when capturing a live stream,
also open an anonymous IRC connection to Twitch chat and append every
message to a sibling .chat.jsonl file. Closes the symmetry — VOD
downloads get .chat.json, live recordings get .chat.jsonl. Both
formats are deliberate: VOD pulls finite, JSON-array friendly; live
streams are open-ended, JSON Lines friendly so a kill mid-stream
preserves prior data.
Server:
- new LiveChatSession + startLiveChatCapture / stopLiveChatCapture.
Opens a TLS connection to irc.chat.twitch.tv:6697, anonymous
Twitch auth (NICK justinfan{rand}, no PASS), JOINs the channel,
enables CAP twitch.tv/tags + commands so we get badges, color,
display-name, etc.
- IRC line parser: minimal — split tags / prefix / command / params,
handle PRIVMSG (chat), USERNOTICE (subs/raids), CLEARCHAT,
CLEARMSG. Each parsed message is one JSON object on its own line:
{ t, type, u, login, color, msg, badges, bits, msgId, systemMsg }.
Per-line write keeps memory flat — a 12-hour stream's chat could
be hundreds of MB; we never hold more than one batch in RAM.
- File handle is opened up-front (so a write failure surfaces early),
always closed on the close event.
- PING/PONG handling so Twitch doesn't ratelimit the connection out.
- Header line written at session start so an empty-chat capture
still produces a valid file with metadata.
Wire-up:
- downloadLiveStream starts the session BEFORE streamlink (so the
first JOIN messages aren't lost) and stops it AFTER streamlink
exits (so trailing reactions still get logged). Failures inside
the chat session do NOT mark the recording as failed — the video
is still fine. The chat file path is added to outputFiles when it
exists so the existing Open file / Show in folder UI lists both.
Renderer / settings:
- new capture_live_chat: boolean (default off). Settings -> Download
card gets the toggle with hint.
- AppConfig type, autosave fingerprint, syncSettingsForm, locale
strings (DE + EN).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Twitch retains chat replay on the same VOD-lifetime clock — when the
VOD vanishes after 7-60 days, the chat goes with it. Anyone archiving
the video usually wants the chat too. Added an opt-in setting that
saves a paginated GQL pull of the chat as a JSON file next to the
.mp4 download.
Server:
- new fetchVodChatReplay(videoId, onProgress, cancelCheck) — uses
the existing fetchPublicTwitchGql helper (so the retry-on-transient
logic from cycle 4 applies here too) with the standard
video.comments(contentOffsetSeconds, cursor) query, paginated via
edge cursors. Each message is normalised to a small flat shape:
id, offset (seconds-into-VOD), createdAt, user (display name),
login, color, text (assembled from fragments). Hard-capped at 500
pages (~50k messages) so a single runaway stream can't fill memory;
hitting the cap sets truncated:true in the result. Honours a
cancelCheck() callback so removing the queue item also cancels the
in-flight chat fetch.
- new chatReplayPathFor() helper produces sibling .chat.json path.
- processOneQueueItem fires the chat fetch after a successful, non-
live, non-merge VOD download whose URL parses to a VOD id.
Progress shows up in the queue item via existing download-progress
IPC: "Fetching chat replay..." then "Chat messages fetched: N".
Output file is added to item.outputFiles so the existing
Open file / Show in folder UI lists the chat right next to the
video. A failed chat fetch is logged but does NOT mark the queue
item as failed — the video itself is fine, the chat is a bonus.
- Atomic write via writeFileAtomicSync so a crash mid-fetch can't
leave a half-written .chat.json next to the video.
Renderer:
- new download_chat_replay: boolean in Config (default false because
long streams can take a few minutes of chat-page pulls and we
don't want to surprise users on upgrade). Settings -> Download
card gets the toggle with hint tooltip explaining the trade-off.
- AppConfig type, settings autosave fingerprint, syncSettingsForm,
applyLanguageToStaticUI all updated.
- DE + EN labels and the two new backend status strings
(statusFetchingChatReplay, statusChatMessagesFetched).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Building on the manual REC button from 4.6.0: each streamer now also
has an AUTO toggle. When enabled, a background poller in the main
process checks the streamers live status every 90s (configurable
30-1800s via config.auto_record_poll_seconds). On an offline -> live
transition, a live recording is queued automatically without the
user having to be at the keyboard.
Server:
- config.auto_record_streamers: string[] holds the watched logins
(deduped + normalized via normalizeAutoRecordList). Empty list
stops the poller entirely so users who don't use the feature pay
zero CPU.
- runAutoRecordPoll iterates the list, hits getLiveStreamInfo
(existing helper from 4.6.0 — Helix when authed, public GQL
otherwise), tracks per-streamer last-known live state in
autoRecordLastLiveState, and only triggers on the offline->live
edge. If a live item already exists for that streamer (manual
REC click + auto-poll racing), the auto-trigger backs off.
- restartAutoRecordPoller is wired into save-config so toggling AUTO
on/off or changing the interval takes effect without a restart;
state for de-watched streamers is dropped so re-enabling them
later doesn't suppress an immediate first-poll trigger.
- Wired into app.whenReady (start) and shutdownCleanup (stop).
- Initial poll fires ~1.5s after restart so a streamer that's
already live when the user enables AUTO gets picked up
immediately instead of after a full interval.
Renderer:
- AUTO pill next to REC. Off = grey outline, on = green outline +
green text + faint green background. Click toggles via saveConfig
with the updated auto_record_streamers array; toast confirms.
- Per-streamer state survives reload (it's in the config file).
DE + EN locale strings for the toggle title + on/off toasts.
Why this matters: VODs vanish from Twitch within 7-60 days. Manual
REC requires the user to be present when the stream starts. AUTO
closes that gap — the app watches in the background and captures
without supervision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Major: each streamer now has a "REC" button. When the channel is
live, click captures into the queue with an open-ended streamlink
recording until the stream ends. Output goes to
{download_path}/{streamer}/live/{streamer}_LIVE_{date}_{time}.mp4.
VODs vanish from Twitch within weeks; this closes the gap.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
VODs disappear from Twitch after 7-60 days depending on the channel
partnership tier. Anyone serious about archiving needs to capture
streams while they are still live, not after. The downloader is now
a recorder too.
End-user surface:
- Each streamer in the sidebar has a small red "REC" pill next to
the remove-x. Click it -> server checks Helix (or public GQL when
no client_id is configured) for live status. If the channel is
online a new queue item is added with isLive: true, status:
pending; the existing queue scheduler picks it up. Toast feedback
for offline / already-recording / generic-failure cases.
- Live items render with a pulsing red REC badge in the queue title
row and skip the bulk-select checkbox + the merge-group selector
(they don't make sense for an open-ended capture).
- Output goes to {download_path}/{streamer}/live/
{streamer}_LIVE_{YYYY-MM-DD}_{HH-mm-ss}.mp4 — timestamped so back-
to-back recordings of the same channel never collide.
- Streamlink runs without --hls-start-offset / --hls-duration so it
records until the stream actually ends or the user hits cancel /
remove. The existing per-item filename claim, integrity check on
close, and downloaded_vod_ids tracking apply unchanged (live
recordings are not added to downloaded_vod_ids since they have
no Twitch VOD ID).
Server plumbing:
- New getLiveStreamInfo(login) helper. Helix /streams when an app
token is available (better metadata: title + game), public GQL
fallback otherwise so users in public-mode still get live status.
- New IPC start-live-recording(streamerName) does the live check,
refuses with ALREADY_RECORDING if a live item for the same
channel is already pending or downloading.
- downloadVOD branches into a small downloadLiveStream helper when
item.isLive — computes the timestamped filename, ensures the
per-streamer/live folder exists, hands off to downloadVODPart
with null start/end times.
- sanitizeQueueItem preserves the isLive flag across queue file
reload so a recording in progress survives an app restart in
state (though streamlink itself dies on app exit and the user
has to re-trigger).
DE + EN locale strings for every toast + tooltip + the queue badge.
CSS animation for the pulsing badge so it visually distinguishes
live recordings from regular VOD downloads at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-13 wins.
1. Stats bar polling pauses while document.hidden. Previously
setInterval(updateStatsBar, 5000) ran forever, including while
the user had a different tab focused or the window minimised.
Now wraps start/stopStatsBarPolling and listens to
visibilitychange. When the page becomes visible the interval
restarts; while hidden it sleeps. Saves an IPC round-trip every
5s when nobody's looking.
2. Bulk mark / unmark "as downloaded" on the VOD bulk-bar. Companion
to the per-card right-click context menu's mark/unmark items —
when the user has 5 VODs selected they now get one click to
toggle the green check on all of them instead of right-clicking
each. Uses the existing markVodDownloaded IPC, refreshes the
local config copy + re-renders the grid so badges update live.
3. VOD card title tooltip. The card title is text-overflow:ellipsis
so longer titles get cut off. Adding title="${full title}"
surfaces the full text on hover via the native browser tooltip
— no custom UI needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-12 wins.
1. Streamlink --twitch-disable-ads is now a setting (default on, since
most users hit this — Twitch mid-roll ads otherwise get embedded
into the VOD output as black-screen audio gaps). Off only when the
user explicitly opts out via the new checkbox in Download Settings.
Applied in downloadVODPart args; clip downloads are unaffected
(Twitch clips do not carry mid-roll ads).
2. Right-click context menu on queue items. Items vary by status:
pending/paused -> Move to top, Move to bottom; failed -> Retry;
completed -> Open file (when 1 output) / Show in folder; always
-> Copy URL, Open on Twitch, Remove from queue. Move-to-top/
bottom calls existing reorderQueue IPC. Menu auto-dismisses on
outside-click / Escape / scroll, repositions to stay inside the
viewport.
3. Removed the global currentDownloadCancelled flag. It was a
leftover from before per-item tracking — every site that set it
(pause-download / cancel-download / remove-from-queue) already
added every active item to cancelledItemIds via the activeDownloads
loop. The four read sites (downloadVODPart close handler,
processOneQueueItem retry-loop guard, processDownloadMergeGroup
phase 1 and phase 3 guards, splitMergedFile loop) now check
cancelledItemIds.has(itemId) directly. splitMergedFile reads
from its itemId parameter (added in cycle 1) so the per-item
intent threads through correctly. Net: -8 lines, one less
global flag to reason about, no behaviour change for the
intended cases (per-item cancel via remove + bulk cancel via
pause/cancel both still work because they each populate the
per-item set).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-11 wins.
1. Streamlink stream quality is now configurable. config.streamlink_quality
defaults to "best" (preserves prior behaviour) but can be set to source,
1080p60, 720p60, 720p, 480p, or audio_only via a new dropdown in
Settings -> Download. The chosen quality is passed as STREAMS to
streamlink with ",best" appended as a fallback so an old VOD lacking
the chosen rendition still completes. Used by both the queue
downloadVODPart and the standalone download-clip IPC. The whitelist is
enforced via normalizeStreamlinkQuality so an arbitrary string in the
config file falls back to "best".
2. Per-item completion notifications. Default off because long queues
would spam the OS notifications panel. When enabled (Settings ->
Queue zwischen App-Starts checkbox area), every successful download
pops a "{title}" notification whose click brings the window forward
AND opens shell.showItemInFolder on the produced file (or the
download folder if the file is gone). The end-of-queue summary
notification still fires regardless.
3. Download-path writability check on selectFolder. The renderer now
asks the new check-folder-writable IPC after the user picks a
folder; if isDownloadPathWritable returns false, a warning toast
surfaces immediately instead of the next download failing with a
cryptic "datei zu klein" / "ENOENT" error. Save proceeds anyway —
the user might be picking a USB-stick path that is offline at the
moment.
Plus DE + EN locale strings for every label/option/hint, all wired
through applyLanguageToStaticUI for live language switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-10 wins.
1. Streamer-list filter + bulk-remove. Above 6 streamers (the magic
number where the list starts to feel cluttered) a search input
appears below the section title and a small bulk-remove "x"
button next to it. Filter is title-substring, case-insensitive.
Bulk-remove honours the active filter — when the input is empty
it confirms removing the entire list, when filled it confirms
removing only the matching subset. Used a confirm() dialog with
the matching count interpolated into the locale string.
2. Cutter drag-and-drop. Dragging a video file from Explorer onto
the cutter tab now loads it directly — no separate Browse click.
Uses Electron's File.path extension on the dropped File object
(works through contextIsolation:true). selectCutterVideo was
refactored into loadCutterFromPath + a thin wrapper so the drop
handler reuses the same loading logic. dragenter/dragleave count
adds visual outline on #cutterPreview while a Files drag is over
the tab. Falls back gracefully if the dropped file lacks .path.
3. Per-streamer VOD scroll position. Switching streamers used to
reset scroll-to-top, painful when cycling between archives.
vodScrollPositions Record<streamer, scrollY> persisted to
localStorage, capped to 32 entries to bound storage. Save fires
on a 250ms scroll-debounce timer + on every selectStreamer
transition. Restore happens 80ms after renderVODs paints (lets
the first chunk settle) so scrollTop has somewhere to land.
Plus: bounded the persistence table at 32 entries, locale strings
DE/EN, all wired through applyLanguageToStaticUI for live language
switch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four wins from a deep-audit pass.
1. Windows taskbar progress bar. While downloads run, mainWindow.
setProgressBar(0..1) shows aggregate progress on the taskbar icon
(visible while minimised). New activeDownloadProgress map tracks
per-item fractions because main's downloadQueue.progress field
is not updated mid-download (only renderer streams progress).
Cleared via clearDownloadProgress in processOneQueueItem.finally
so the bar resets when the queue idles.
2. VOD card data-* refactor. The previous inline-onclick template
strings did escapedTitle = title.replace(/'/, "\\'").replace(/"/,
""") and then interpolated that into onclick="addToQueue('...')".
Edge cases (titles with backslash, ', etc.) could break the
JS parser. All identity now lives on data-vod-id / -url / -title /
-date / -streamer / -duration on .vod-card. A delegated click
listener on #vodGrid reads the dataset at click time and
dispatches to openClipDialog / addToQueue / openExternal. Plus:
clicking the thumbnail / title / meta now opens the VOD on Twitch
in the OS default browser.
3. Right-click context menu on VOD cards. Items: "Open on Twitch",
"Copy VOD URL" (uses navigator.clipboard, toast confirmation),
"Trim VOD", "+ Queue", and toggle "Mark as downloaded" /
"Unmark downloaded". The mark toggle hits a new
ipcMain.handle("mark-vod-downloaded", id, mark) so a user can
add or remove entries in config.downloaded_vod_ids manually
without re-downloading. Menu auto-closes on outside-click /
Escape / scroll. Repositioned to stay inside the viewport.
4. userIdLoginCache now bounded (insertion-order eviction at 4096).
Was Map<string, string> with no cap; setUserIdLogin helper
centralises insertion + eviction. Long-running sessions with
thousands of unique streamer lookups no longer accumulate the
reverse-lookup table forever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three companion features around the 4.5.22 already-downloaded badge.
1. "Hide downloaded" toggle in the VOD filter row. Persisted to
localStorage so power users who keep it on across sessions don't
re-flip it on every launch. Filter applies before the title-search
filter so the match counter stays consistent.
2. "Reset downloaded list" button in a new Backup & Maintenance
settings card. Confirm-dialog before clearing, IPC returns the
removed count for a "cleared N entries" toast. Renderer refreshes
its config copy + re-renders the VOD grid so badges disappear
immediately. No files are touched.
3. Config export / import via dialog.show*Dialog. Export strips
client_secret (should never travel as plain text via cloud sync),
tags the file with __exportVersion + __exportedAt. Import runs
the JSON through normalizeConfigTemplates so out-of-range fields
fall back to defaults; if the imported file lacks client_secret,
the existing value is preserved. After import the renderer reloads
config + relocalizes if language changed + re-renders streamers /
settings form / VOD grid.
DE + EN locale strings for every label, button, toast, and confirm
dialog. New backupCardTitle / backupCardIntro section header in
Settings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two real UX wins.
1. Auto-resume queue on startup. New checkbox in Settings -> Download
("Queue beim Start automatisch fortsetzen"). When enabled and the
persisted queue has pending items, processQueue() fires ~5 seconds
after did-finish-load — long enough for the user to see the queue
and pause if they did not actually want this. Default off so the
existing behaviour (explicit Start click) is preserved on upgrade.
The Settings auto-save fingerprint includes the new flag and
syncSettingsFormFromConfig restores it. Tooltip explains the
timing on hover.
2. Already-downloaded indicator on VOD cards. Config gains
downloaded_vod_ids: string[] (bounded to 4096 latest entries).
Every successful queue-item download appends its parsed VOD ID
(or every component ID for merge groups). On the VOD grid each
card whose vod.id is in the set gets a small green checkmark
badge in the top-right plus a slightly dimmed thumbnail, with a
localized "Already downloaded" / "Bereits heruntergeladen"
tooltip. The lookup builds a Set once per render so it stays
O(1) per card. The renderer refreshes its local config copy on
every "newly completed" queue update so the badge appears live
without waiting for a settings save.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Phase-6 wins.
1. Cutter & Merge tab labels were the same i18n gap as the trim-VOD
dialog before 4.5.20: Dauer / Aufloesung / FPS / Auswahl / Start: /
Ende: / Schneiden / Zusammenfuegen were hardcoded German in
index.html. Each got an id + setText wiring + DE/EN locale strings
(cutter.infoDuration / .infoResolution / .infoFps / .infoSelection
/ .startLabel / .endLabel; cutter.cut + merge.merge already existed
for dynamic state, now also used as initial text on btnCut /
btnMerge).
2. Per-item retry button on failed queue entries. The existing
"retry failed" queue-action retried ALL failed items at once;
when only one specific item should be retried (e.g. transient
network blip on one URL), the user had to remove every other
failed item first. New ipcMain.handle("retry-queue-item", id)
resets that single item to status: pending and triggers
processQueue if idle. A small ↻ icon now sits next to the
remove (x) button on items in the error state.
3. Status bar queue summary. The footer previously showed only the
connection status + version. With longer queues the user had to
scroll the queue panel to see how many downloads were active
versus pending. New span between the status indicator and the
version reads "{downloading} dl, {pending} queued" (locale-aware,
hidden when queue is empty). Updated on onQueueUpdated and
onDownloadProgress so it stays live.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UX wins.
1. Trim-VOD dialog: every inner label was hardcoded German in
index.html (Start:, Ende:, Startzeit (HH:MM:SS):, Dauer:, Start
Part-Nummer..., Leer lassen = Teil 1, Dateinamen-Format:, Zur
Queue hinzufuegen). EN-mode users had a German dialog. Each
element now has an id + setText wiring + DE/EN locale strings.
2. Settings -> Twitch API card now opens with a help line + link
to dev.twitch.tv/console/apps. Uses window.api.openExternal so
the link opens in the user's default browser instead of the
Electron renderer (which has nodeIntegration off / no native
navigation). Fixes the "no idea how to set this up" first-run
friction.
3. Settings -> Live Debug Log gets an "Open log file" button next
to Refresh. Uses a new ipcMain handle (open-debug-log-file ->
shell.showItemInFolder on DEBUG_LOG_FILE) so users no longer
have to navigate manually to ProgramData. As a small defensive
bundle:
- get-debug-log: lines parameter capped at [1, 5000] so a
misbehaving renderer (or future feature) cannot ask main to
slice millions of lines.
- export-runtime-metrics: now uses writeFileAtomicSync (the
fsync+rename helper from cycle 1) instead of plain
writeFileSync so a power loss mid-export cannot leave a
half-written metrics file at the user-chosen path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>