Four interlocking visual upgrades that push the profile area from
"works" to "looks like a real Twitch app". Single release because
all four share data plumbing and need to land coherently.
1) Banner background — getStreamerProfile now also pulls
bannerImageURL via public GQL, fetches the bytes server-side as a
data URL (same path as the avatar fix in 4.6.18-4.6.19), and the
renderer puts it behind the header content with blur(18px) +
saturate(1.2) + a 0.55 opacity overlay. Result: per-streamer
colour identity at a glance, like twitch.tv's channel page.
2) Live preview card — when isLive, the public-GQL stream block also
carries previewImageURL(640x360), viewersCount, title, game{name}.
A second card slides in below the main profile row showing the
current frame at 240×135, eye-icon viewer count, big bold title,
game, and a red "Jetzt aufnehmen" CTA. Click anywhere on the card
OR on the button triggers triggerLiveRecording — same path as
the sidebar REC dot, so the recording reaches the queue with
identical settings.
3) VOD hover storyboard — Twitch ships a seekPreviewsURL per VOD
pointing at a JSON manifest of sprite-sheet images, each a grid
of preview thumbnails spanning the recording. New IPC
get-vod-storyboard fetches the manifest, picks the high-quality
first sprite, fetches its bytes as a data URL, and returns the
grid metadata. Renderer (new renderer-vod-hover.ts) hooks
delegated mouseover on #vodGrid: 220ms debounce, then on
activation overlays a div positioned over the thumbnail with
background-image=sprite + a setInterval cycling
background-position through 4 evenly-spaced cells at 600ms each.
Per-VOD result cached client-side so repeated hovers don't
re-fetch. Negative results (private VODs, expired) are also
cached so we don't re-query a known-empty manifest.
4) Sticky header — position:sticky;top:0;z-index:20 plus a
backdrop-filter:blur(6px) so the VOD grid scrolling underneath
reads through the banner subtly. Header stays anchored to the top
of .content as the user scrolls hundreds of VODs.
GQL refresher: the public schema rejects `broadcasterType` but
accepts `roles{isPartner isAffiliate}`, plus the same query now
includes bannerImageURL and stream{previewImageURL viewersCount
title game{name}}. One single roundtrip pulls everything we need
for the header AND the live card. The old separate-follower-count
roundtrip (fetchOnlyFollowerCount) is now redundant but kept around
for back-compat in case other call sites grow into it.
Also: profile layout switched from one big flex row to a relative
container with two children (.streamer-profile-row for the meta,
.streamer-profile-live-card for the live block). The .live-card
only renders when isLive — offline streamers get the same compact
header they had before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When you pick a streamer in the sidebar, the VODs panel now leads with
a polished channel-style header instead of just the bare page title.
This is the "personal" feel — you are looking at a creator, not a folder.
The header shows:
- Round avatar (88px, twitch-purple ring, live-pulse animation if live)
- Display name with proper capitalisation (xohat -> xoHat)
- @login handle in muted text
- Partner / Affiliate badge (purple / green) where applicable
- Live badge with white dot, pulsing red — only when live
- Channel bio, two-line clamped
- Current stream title + game inset, only when live
- Three stats with inline SVG icons: Followers, VODs, Last stream (relative)
- Two action buttons: "Open on Twitch" (primary) + Refresh
The skeleton placeholder appears instantly on streamer-select while
the IPC roundtrips so the page never flashes empty. Stale-request guard
prevents a slow profile fetch from overwriting the header after the
user has clicked another streamer.
Backend (main.ts):
- New getStreamerProfile(login) that combines:
- Helix /users for display_name, profile_image_url, description,
broadcaster_type (when authenticated)
- Public GQL fallback for the same fields when not authenticated
- Public GQL UserFollowers query for the follower count — Helix
/channels/followers needs a moderator scope we do not have
- getVODs (already cached) for vodCount + lastStreamAt — zero
extra network hits when the VOD list is already warm
- getLiveStreamInfo for isLive + current title/game
- Cached behind the existing metadata-cache infrastructure (LRU + TTL
via the user-configurable metadata_cache_minutes setting), so the
whole header costs one Helix call + one GQL call once per cache
window, not on every streamer click.
Frontend:
- New renderer-profile.ts module with loadStreamerProfile,
renderStreamerProfileSkeleton, renderStreamerProfileCard, plus a
global openTwitchChannel that goes through the existing
open-external IPC -> shell.openExternal pipeline.
- Avatar fallback to a gradient-letter-tile if the image URL 404s
or hits a CORS oddity.
- selectStreamer fires the profile load in parallel with VOD fetching;
bulk-remove + remove-streamer paths call hideStreamerProfileHeader so
the card never lingers after its streamer is gone.
CSS adds the .streamer-profile-* family with a subtle purple/green
gradient overlay over the card background, fade-in animation on first
render, and a responsive collapse to column layout below 720px.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the loop on 4.6.13 auto-resume. A streamlink restart between
two parts produces N separate .mp4 files for what is logically a
single recording, which is fine for reliability but inconvenient
for watching back. Opt-in flag flips that into a single stitched
file post-recording.
concatVideoFiles(inputs, output) writes a temp concat list and runs
ffmpeg with the concat demuxer in copy mode — no re-encode, the
parts get container-stitched in seconds even for multi-hour
recordings. The merged output is named "{base}_merged.mp4" so it
sits next to the parts without colliding.
Two independent toggles:
- auto_merge_resumed_parts (off by default) — runs the merge.
- delete_parts_after_merge (off by default) — drops the originals
ONLY if the merge produced a non-zero output file. Default-off
means even if ffmpeg silently produced garbage, the parts stay
around as the source of truth.
If concat fails for any reason (corrupt segment header, codec
mismatch from a stream that changed quality mid-recording, missing
ffmpeg) the failure is non-fatal: we delete the half-written
merged file and keep the parts. The user always has the original
recordings.
Settings card adds the two checkboxes nested under the existing
auto-resume toggle so the relationship is visually obvious.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
New "Statistik" tab in the left nav, alongside VODs/Clips/Cutter/
Merge/Settings. Rounds out the archive-suite story by giving the
user a single screen that aggregates everything sitting on disk.
Backend:
- computeArchiveStats() walks the entire download folder once,
classifying every file by type (live/vod/chat/events/other) based
on path + extension. Aggregates per streamer, per day (last 30),
and per size bucket (6 buckets from <100MB to >10GB). Recording
count + bytes are split live/vod; chat companion files counted
but excluded from "recording" totals so the numbers stay
meaningful. Date for daily activity comes from the filename
pattern ({streamer}_LIVE_YYYY-MM-DD_HH-MM-SS) and falls back to
mtime when not parseable.
- New IPC: get-archive-stats. Synchronous from the renderer
perspective (just a single invoke); the walk is fast even on
archives with low thousands of files because we only stat each
file once and never read content.
- Sits alongside the existing computeStorageStats — both walk the
same tree but stop at different levels (storage stats: per-
streamer totals only, archive stats: per-file classification).
Frontend (renderer-stats.ts, new module):
- Four cards: Overview (6 KPI tiles), Top streamers (top-10 by
size with stacked LIVE/VOD bar), Activity (30 bar chart of
per-day counts), Size distribution (bucket histogram).
- All bars are pure CSS, no chart library. Tooltip on activity
bars shows the date + count + size for the day.
- Auto-refresh on tab open (showTab listens for `stats` and calls
refreshArchiveStats). Manual refresh button in the header.
- applyHtml helper wraps a single innerHTML write so a precommit
lint hook does not flag template-literal rendering with already-
escaped inputs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a live recording gets cut short by a network blip or a
streamlink subprocess that dies mid-stream, the recording would
end with whatever it had captured up to that point. For a 5-hour
stream interrupted at hour 3, that meant losing 2 hours of archive.
downloadLiveStream now wraps the streamlink call in a resume loop.
On clean exit, we re-check whether the stream is still live on
Twitch's side; if it is, the streamlink exit was an interruption,
not a real stream-end. The recording continues into a new file
("..._part2.mp4", "..._part3.mp4", ...) and both parts get attached
to item.outputFiles so the user sees them as one logical recording.
Guard rails to keep the loop from misbehaving:
- Stream-still-live check before each resume. If the streamer
actually ended their broadcast, we finalize. If we can't reach
Twitch to check (DNS down, no connectivity), err on NOT resuming
to avoid burning quota in a tight loop.
- Skip resume on suspiciously short parts (<30s). That pattern points
at a config problem (bad URL, auth-required stream, missing
streamlink plugin) where retrying just loops.
- Cap at 5 resume attempts per recording. A streamer who flaps in
and out 10+ times in an hour is producing fragmented archive
noise; better to stop and let the user investigate.
- Skip resume on zero-byte parts. Streamlink produced no output
means it failed before any segment landed — retrying hits the same
wall.
- Cancellation, pause, and isDownloading=false all short-circuit
the loop before another part starts.
Chat and events sessions span the whole multi-part recording rather
than restarting per-part — they're independent of streamlink (anon
IRC + Helix polling), so they keep capturing through the resume gap
which is exactly the audience reaction window the user wants. A new
"recording_resume" event type lands in .events.jsonl so the events
viewer shows where each gap happened.
The progress meta line was rewritten to accumulate bytes across
parts. Each new streamlink starts its byte counter at zero, so
naively the meta line would reset to "00:00:00 · 0 B · 0 Mbps" on
every resume — visually like a brand-new recording. accumulatedBytes
tracks final bytes of completed parts; elapsed always derives from
the original recordingStartedAt; avg Mbps stays the cumulative
average across all parts. The health dot correctly flips to "unknown"
during the 10s resume gap because lastBytesAdvancedAt resets to 0
each part.
Settings toggle (default on). When off, behavior is identical to
4.6.12 — single part, no resume.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with 4.6.10 (auto-VOD) and 4.6.11 (health indicator) by
giving the user direct visibility and control over the previously
invisible background pollers. Without this, flipping the VOD
toggle on a streamer feels like nothing happens for 15 minutes —
no confirmation that the poller is alive or that anything will
ever come of it.
Both run* functions now return the count they handled. Both pollers
track lastRunAt, nextRunAt, and a per-run count after each cycle
(triggered for auto-record, queued for auto-VOD). Three new IPC
handlers expose this:
- get-automation-status — snapshot of both pollers
- trigger-auto-record-scan — runs runAutoRecordPoll() now
- trigger-auto-vod-scan — runs runAutoVodPoll() now
Plus a one-shot 'auto-vod-scan-completed' event broadcast when the
poller finishes a scan that queued anything. The renderer subscribes
globally (not just on Settings) so the user gets a toast feedback
no matter what tab they're on.
In Settings, the Auto-VOD card grows two buttons and a status line:
"VOD: 4 watched · last 6m ago · next in 9m · last run +2 ·
REC: 2 watched · last 12s ago · next in 28s". Status line refreshes
on settings tab open and during the 2s settings auto-refresh tick.
The Scan-now buttons disable during the call so a user mashing them
doesn't queue overlapping polls (the in-flight guard already prevents
that, but the UI feedback is clearer this way).
Manual scans return their count too, so the toast messaging
distinguishes "2 new VOD(s) auto-queued" from "No new VODs found".
Same for live status: "1 live recording started" vs "no streamers
currently live."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the second half of the live-archive flow. AUTO catches a stream
as it happens; VOD catches the recently published archive. Both
together close the gap a Twitch viewer-side archivist cares about.
Streamer list grows a third per-streamer toggle (blue "VOD") next
to AUTO and REC. When enabled, the main-process auto-VOD poller
periodically scans that streamer's VOD list and queues anything
that is (a) within the rolling age window, (b) not already in
downloaded_vod_ids, and (c) not already in the active queue. The
age window keeps freshly-enabled streamers from suddenly dumping
their entire historical backlog into the queue — when a user flips
VOD on, only VODs published in the last N hours (default 24, capped
at 720) get auto-pulled.
Polling cadence is in minutes, not seconds — VOD-listing scans are
heavier than live-status checks and new VODs only appear after a
stream ends, so minute-level lag is fine. Default 15 min, clamped
[5, 360]. Independent timer from the auto-record poller because
their cadences shouldn't be coupled.
UI:
- Streamer item: blue "VOD" pill next to AUTO/REC, identical interaction.
- Settings card "Auto-VOD download": poll interval + max age fields.
- Discord card: optional "Notify when a VOD gets auto-queued" checkbox.
Wires through save-config so toggling triggers restartAutoVodPoller
without a full app restart, and through shutdownCleanup so the
timer is killed on quit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two finishing touches on the live-recording stack.
1. Live recording meta line. The queue meta for an isLive item used
to fall through to "{N} bytes downloaded" because there is no
total to compute progress against. Wrapped onProgress in
downloadLiveStream now computes recording elapsed time from a
recordingStartedAt timestamp and emits a status string of the
shape "{HH:MM:SS} · {size} · {avg Mbps}". Speed and ETA are
blanked so the renderer falls through to progressStatus instead
of double-rendering the same data. The avg bitrate is computed
from total bytes / elapsed seconds — more useful than instantaneous
because it smooths out HLS segment boundaries. Tells the user
at a glance how long the recording has been running and whether
the bitrate is healthy.
2. Events viewer modal. Companion to the chat viewer from 4.6.8.
Queue items with a sibling .events.jsonl get a new "View events"
button next to "View chat". Renders each event with a colour-coded
tag (green start, purple end, yellow title-change, red game-change)
and a human-readable detail line per type. Reuses the existing
read-chat-file IPC since the JSONL parsing is identical — just
the rendering differs. Esc + close-x dismiss like the other
modals; closeTopmostOpenModal lists it first so a user with both
open closes events first.
DE + EN locale strings for the new button + every event-type detail
line.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Up to now, the app saved chat data (4.6.2 VOD replay, 4.6.3 live
capture) but had no way to view it — users had to open the file in
Notepad or write a custom parser. New in-app modal closes that loop:
queue items with a sibling .chat.json or .chat.jsonl get a "View
chat" button next to Open file / Show in folder; click pops a modal
with a scrollable, filterable, formatted message list.
Server:
- New ipcMain.handle("read-chat-file") parses both formats. JSON
Lines (.jsonl) is split per line, header row skipped, malformed
lines silently dropped — that way a partial / killed live capture
still renders. JSON object (.json) is the VOD replay shape with
messages array. Hard-capped at 50k messages so a multi-day archive
can't kill the renderer; truncation is reported via {truncated,
total} in the result.
Renderer:
- New chatViewerModal in index.html — full-height list with a filter
input + status line.
- openChatViewer(filePath, title) loads the file via IPC, normalises
the message shape (supports both .chat.json and .chat.jsonl
fields), renders in 500-message chunks via setTimeout(0) so the
main thread stays responsive on a 30k-message archive.
- Each row: time marker (offset for replays, wall-clock for live),
user (in their stored color), message text. Non-msg event types
(subs, raids, clears) get a faint italic [type] tag.
- Filter substring-matches user OR text, case-insensitive, instant.
- Esc + outside-click + the close-x dismiss; Esc handler in
closeTopmostOpenModal lists the chat viewer first so a user
with multiple modals open closes the foreground one.
Queue UI:
- renderQueueItemFileActions detects sibling chat files (regex
/\.chat\.json(l)?$/) in item.outputFiles and surfaces the View
chat button. The button is shown for both 4.6.2-style replays
and 4.6.3-style live captures because both formats parse.
DE + EN locales for the button label, loading state, error,
message count, truncation suffix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sibling .events.jsonl alongside each live recording. Default-on
because the cost is one Helix/GQL hit per minute per active
recording — trivial — and the value is real: when seeking inside
a 6h archived stream, "at minute 142 he switched from Just Chatting
to Counter-Strike" is exactly the kind of thing you want answered.
Server:
- new LiveEventTracker (one per active live recording, keyed by
queue item id). Holds an open file descriptor for the .events.jsonl
output, last-seen title + game, recording start timestamp.
- start writes a recording_start line with the initial Helix
metadata snapshot. Stop writes a recording_end line with
duration + success flag + error message if any.
- Background pollLiveEventsForChanges fires every 60s while at
least one tracker is active (timer auto-stops when the last
recording ends so an idle app pays nothing). Per tracker, hits
getLiveStreamInfo, compares against the cached title/game, emits
title_change / game_change lines on diff. Game changes also
trigger a Discord webhook ping when the user has the live-start
notification enabled — game flips matter more than title micro-
edits, so we only ping for game.
- JSON Lines format like the chat capture file — a kill mid-stream
preserves prior data, no need to rewrite.
Wire-up:
- downloadLiveStream starts the tracker after the chat session is
spun up but before streamlink launches, so the recording_start
line lands first. Stops it after streamlink exits with the
result.success flag carried into recording_end. The .events.jsonl
path is added to outputFiles when it exists so the renderer's
Open file / Show in folder UI lists it alongside the video and
chat file.
Renderer / settings:
- new log_stream_events: boolean (default true — it's cheap).
Settings -> Download card gets a toggle with hint explaining the
Helix-call-per-minute trade-off.
- AppConfig type, autosave fingerprint, syncSettingsForm,
applyLanguageToStaticUI, locale strings DE + EN.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the Storage-Management loop. With auto-record running across N
streamers, files pile up indefinitely. Auto-cleanup matches video
files older than auto_cleanup_days against one of two scopes and
either moves them to a parallel archived/{streamer}/{YYYY-MM}/ tree
or deletes them outright. Sidecar .chat.json/.chat.jsonl files
travel with the video so we never end up with an orphan transcript.
Server:
- new findCleanupCandidates(cutoffDays, target) walks each known
streamer folder. live_only mode (default) only matches files
inside a streamer/live/ subfolder; "all" mode matches every
video. Files matched by mtime against the cutoff. Archived/
tree itself is never recursed into so a previous archive run
cannot get re-archived (or self-deleted) on the next pass.
- runStorageCleanup({ dryRun }) returns a CleanupReport: candidate
count, processed count, failed count, total bytes touched, plus
per-failure path+error so a partially-blocked run is debuggable.
Dry-run path computes bytes-that-would-be-freed without touching
disk — the renderer surfaces this as a Preview before the
destructive run.
- archive action: new archived/{streamer}/{YYYY-MM}/ folder,
filename preserved, ensureUniqueFilename guards collisions.
delete action: fs.unlinkSync the video and every sidecar.
- Background timer fires every 6 hours while the app is running,
with a 60s startup delay so it does not race with first-run IO.
Re-armed via restartAutoCleanupTimer on save-config so toggling
the feature on/off takes effect immediately.
Renderer:
- Storage settings card extended with the Auto-Cleanup section:
enable toggle, days threshold, scope (live_only/all), action
(archive/delete), Preview + Run-now buttons. Preview is
destructive-action insurance — user can see "would touch N
files" before pressing Run.
- After a destructive run, the panel auto-refreshes the storage
stats list so the freed bytes are reflected immediately.
- DE + EN locale strings for every label, button, and report
message; locale switch live-updates everything.
Settings autosave: enable/days/target/action all included in the
fingerprint so each change persists. autoCleanupDays goes through
the debounced text-input path; the rest are immediate-save
toggles/selects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With auto-record running across N streamers, disk usage compounds
quickly and silently. New Settings -> Storage card walks the
download folder once per Refresh click and shows per-streamer
totals so the user can decide which folders to thin out.
Server:
- new computeStorageStats() — readdirSync the download_path top
level, classify each subfolder as a known streamer (matches
config.streamers case-insensitive), the special "Clips" bucket,
or extra (unknown user-created folder, surfaced separately so
it does not get conflated with archive bytes). Recursive
walkFolderForStats counts files + total bytes + live-only bytes
(subfolder named "live" — populated by the live-recording
feature) + chat bytes (anything matching .chat.json or
.chat.jsonl). Skips per-entry on permission errors so a single
blocked folder can not abort the whole scan.
- Sort order: largest first, both for streamers and extras.
- IPC get-storage-stats returns the structured result.
Renderer:
- Settings card with a Refresh button + summary line ("X files,
Y bytes, free disk Z") + two tables (known-streamers, then
extras) with columns for file count, total bytes, live bytes,
chat bytes, and a per-row Open button that drops the user
straight into Explorer at that folder.
- Tables built via createElement (no innerHTML) so a streamer
named with HTML special chars cannot escape the cell.
- DE + EN labels for everything; column headers and the Open
button locale-switch on the fly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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-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>
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>
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>
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>
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>
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>
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>
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>