Commit Graph

206 Commits

Author SHA1 Message Date
xRangerDE
f1b4e6c39a cleanup: storage stats table — extract inline element.style.* into CSS
renderStorageStats was building the storage-stats table in
Einstellungen by setting ~10 style props per element with
.style.padding / .style.color / .style.borderBottom / etc. Verbose
in TypeScript and harder to retheme than CSS-class-based markup.

Extracted to .storage-stats-table family in styles.css:
- Header cells become uppercase 10px tracking-wide labels (matches
  the look of the .vod-bulk-count "X selected" label and the
  archive search type-pill chips)
- Body rows pick up a hover-background tint for scannability
- Numbers use font-variant-numeric: tabular-nums so file counts
  and byte sizes don't jitter as values change between scans
- Last row drops the bottom border so the table doesn't end on
  a dangling line

Open-folder button was using .btn-secondary with inline font-size
+ padding overrides — swapped to the .btn-pill class, which is
already the small/compact action button used in vod-bulk-bar and
the archive search results. Visual consistency across the app.

The "Other folders" subheading (.storage-stats-section) gets the
same uppercase-tracking look as the table headers — small
consistency win that ties the section together visually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:38:21 +02:00
xRangerDE
dd08f33dc6 perf: trim live-status batch IPC payload + skip empty broadcasts
The live-status batch poller (60s cadence, every streamer in the
watch list) was sending two things on every tick:
- `changes` — the diff vs. the previous tick, used by the renderer
- `snapshot` — the full Map<login, boolean> serialized as a record

Renderer destructures only `changes` (renderer-streamers.ts line 20).
The snapshot field was wire-noise. For a typical 30-50 streamer
watch list, that snapshot is ~1.5KB of JSON every minute, never
read on the other side. Dropped from the broadcast payload.

Initial-state sync still works: the renderer's
initLiveStatusSubscription calls window.api.getLiveStatusSnapshot()
once at boot to pre-fill its map. The broadcast is only for diffs.

Also added a short-circuit on the main side: if changes.length === 0
(every streamer's live status matched the cached value this tick),
don't broadcast at all. The renderer would just iterate an empty
array and trigger a no-op render; saves the wakeup entirely.

Type signature updates ride through preload.ts +
renderer-globals.d.ts so the API contract stays accurate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:33:09 +02:00
xRangerDE
e09efd4a33 cleanup: status bar — drop stale v4.1.13 hardcode + extract inline styles
The bottom status-bar version span had "v4.1.13" hardcoded as initial
content from a release several versions ago. init() overwrites it via
window.api.getVersion() on app boot, so the user only ever sees this
for the millisecond between paint and IPC return — but during that
window we were lying about the version, and if init() ever crashed
the user would be stuck looking at v4.1.13 in the corner forever.

Cleared the initial content to empty so the span just doesn't render
text until the real version arrives.

Side effect: extracted the inline `style="color:var(--text-secondary);
font-size:12px; margin-left:auto; padding-right:12px"` from
statusBarQueueSummary to a .status-bar-queue-summary class, plus a
new .status-bar-version class for the version pill. Both consistent
with the rest of the status-bar styling. Bonus: queue summary picks
up font-variant-numeric: tabular-nums so the "X B | Y avg | Z done"
numbers don't jitter horizontally as values update during a
download.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:28:28 +02:00
xRangerDE
6fdfa08ecb feat: sidebar empty-state when no streamers added yet
First-launch (or after-clearing-everything) opens the app with an
empty sidebar streamer list — just the "Streamer" section heading
and a blank area below. New users had no in-app indication of where
to add their first streamer. The "Add streamer..." input lives in
the TOP bar, which is non-obvious from the sidebar context.

renderStreamers now short-circuits on empty streamers[] and stamps
a small dashed-border hint card into the list with locale-driven
copy pointing the user at the top-right input ("No streamers yet.
Add one via the input at the top right." / "Noch keine Streamer.
Fuege oben rechts einen hinzu.").

The empty state styling (.streamer-list-empty) is intentionally
subtler than the full-page .empty-state used for the VOD grid —
dashed border + tinted background + small padding so it fits the
narrow sidebar rail without dominating it.

Also clears the streamer-section-counter on this branch and hides
the bulk-remove X button, since both would otherwise have stale
state from a previous non-empty render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:24:50 +02:00
xRangerDE
5f514b1700 i18n: localize 3 empty-state strings (VOD grid + Merge tab)
Three empty-state texts hardcoded German in the HTML and never wired
through the locale system:

- VOD grid empty state: "Keine VODs" + "Wahle einen Streamer aus
  der Liste oder fuge einen neuen hinzu." Shown when no streamer is
  selected. English users were reading German strings here despite
  the rest of the app rendering in English.

- Merge tab empty state: "Keine Videos ausgewahlt." Shown in the
  Videos zusammenfugen tab before any files are added.

Existing locale tables already had `vods.noneTitle` /
`vods.noneText` / `merge.empty` in both EN and DE — they just
weren't being applied. Added IDs to the three elements
(vodGridEmptyTitle / vodGridEmptyText / mergeEmptyText) and
wired three setText calls in renderer-texts.applyText.

Zero new locale keys; pure plumbing fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:20:29 +02:00
xRangerDE
9afff4b8b0 feat: events viewer rows — class-based + data-type colour pills
renderEventsList was painting every timeline row with ~7 inline
style props per element (padding, border-bottom, font-size on the
row; margin-right, color on each span; padding-top on detail) AND
keeping a JS-side colour map per event type. For a chatty recording
with 100+ events the inline-style noise added up, and adding a new
event type meant editing the renderer to extend the map.

Extracted:
- .event-viewer-row picks up the padding + bottom border + font-size
- .event-viewer-time gets the secondary colour + monospace stack
- .event-viewer-tag becomes an actual pill (uppercase, letter-
  spacing, rounded background tint, bordered) — visually consistent
  with the chat viewer's [type] chip tag
- .event-viewer-detail handles the row-detail line spacing

Per-type colour is now driven by CSS [data-type="..."] attribute
selectors (recording_start = green, recording_end = purple,
recording_resume = blue, title_change = amber, game_change = red).
Each variant overrides background + border + text colour to give
each tag a contained "pill" look. The renderer just stamps
ev.type onto data-type and the CSS handles the rest.

Adding a new event type now means one new selector here, not a JS
map edit. Lint, focus, future polish all stay near the styling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:14:33 +02:00
xRangerDE
5da4cc9e64 cleanup: unify filter inputs + monospace template inputs into shared classes
Five filter-style text inputs (vodFilterInput, streamerListFilter,
archiveSearchQuery, chatViewerFilter) plus three monospace template
inputs (vod / parts / clip filename templates) were each carrying
their own ~80 chars of inline style declaring near-identical
background / border / radius / padding combinations.

Consolidated into three new utility classes:
- .filter-input — base flex-1 minWidth-180 filter look, used by
  vodFilterInput
- .filter-input.compact — small variant for the sidebar streamer
  filter (smaller padding, smaller font, no flex, percent-width
  with margin)
- .filter-input.flex-1-1-240 — larger variant for the archive
  search box (240px basis, 200px min, smaller radius/padding to
  fit the multi-control form-row it sits in)
- .input-monospace — applies the same monospace stack (Consolas /
  Segoe UI Mono / monospace) used by .chat-viewer-time and
  .viewer-modal-list-chat to text inputs that hold code-shaped
  values

Side effect: vodHideDownloadedToggle had a hardcoded
`accent-color: var(--accent); cursor:pointer;` inline style, which
was redundant after the global custom-checkbox styling landed in
4.6.26 (the checkbox is now ::after-driven, accent-color does
nothing). Removed.

Zero visual change. The inputs render identically because the
class CSS values match what was inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:09:45 +02:00
xRangerDE
ee8f9425fc cleanup: extract events/chat viewer inline styles + drop dead var
The events-viewer and chat-viewer modals were each carrying ~5 inline
styled elements (modal sizing, status text, list container, filter
row + filter input) duplicated between the two modals. Edits to one
viewer left the other drifting visually.

Extracted to a shared .viewer-modal* family in styles.css:
- .viewer-modal sets the column flex layout
- .viewer-modal-events / .viewer-modal-chat set their own sizing
- .viewer-modal-title / .viewer-modal-status / .viewer-modal-list +
  inline + chat list variants for the data area
- .viewer-modal-filter-row + .viewer-modal-filter-input for the
  chat viewer's filter

Zero visual change; just stops the two viewers from drifting and
unblocks future polish (skeleton states inside the list, sticky
filter row, etc.) without an inline-edit-by-inline-edit grind.

Side: removed lastArchiveStatsScannedAt module variable in
renderer-stats.ts. It was assigned in refreshArchiveStats but never
read anywhere — leftover from an early plan to compare against a
previous timestamp before refreshing. The renderer-rendered "Last
scan" line reads stats.scannedAt directly. Dead, removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:05:37 +02:00
xRangerDE
b37244cccf a11y+i18n: localize modal close aria-labels + strip dead modal title fallbacks
Two related artifacts from the 4.6.31 a11y pass.

aria-label="Close" was hardcoded English on all five modal-close X
buttons — anyone running the German locale would still hear "Close
button" from their screen reader. Added a shared
.modal-close-localizable class on each X, plus a streamers.modalCloseAria
locale string ("Close dialog" / "Dialog schliessen"), plus a small
setAriaLabelAll helper in renderer-texts that resolves the class via
querySelectorAll and applies the localized label in one shot. Now all
five modals announce in the active language.

While editing the modal headers, also removed the dead "Stream events"
and "Chat replay" English fallback text from eventsViewerTitle and
chatViewerTitle. Both h2s get their textContent overwritten the
instant openEventsViewer / openChatViewer is called (with the
streamers name or a UI_TEXT fallback), so the inline English text was
never user-visible past first-paint and only mattered to a screen
reader if a user managed to focus an unopened modal. Empty <h2/> is
cheaper and removes the i18n drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:00:28 +02:00
xRangerDE
0df8bf357d feat: clip-cutter modal themed + global radio button styling
Two interrelated changes shipped together.

Clip-cutter modal cleanup. The "VOD zuschneiden" modal was the last
big surface still painted in the apps PRE-purple colour palette:
hardcoded #2b2b2b modal bg, #E5A00D orange title, #1a1a1a slider
tracks (already overridden by the global rule but inline-styles still
sat there), #333 input bgs, #444 borders, plain "white" text, #888
labels, #aaa radio labels. All of it inline. The result: opening the
clip dialog was visually jumping back two themes.

Extracted everything to class-based styles using var() colours:
- .clip-modal* family of classes for layout
- Title now uses var(--text), no orange
- Inputs use var(--bg-elevated) + var(--border-soft) and pick up
  the global focus ring automatically
- The duration display ("Dauer: 00:01:00") now sits in a small
  green-tinted card to make it visually distinct from the input
  rows around it
- Radio labels go through a unified .clip-radio-row with a hover
  background tint, and the :has(input:checked) selector swaps the
  label text colour + weight when a radio is selected

Global radio button styling. The clip modal had four radio buttons
that were the only non-OS-themed control left in the app. Custom
.appearance:none + ::after-driven dot styling matching the new
checkbox visual: 16px circle, 1.5px border, hover purple tint,
checked fills the inner circle with the accent colour + a soft
purple shadow, focus-visible has the same 3px purple ring as every
other form control. Cascades globally so any future radio gets the
treatment for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:56:07 +02:00
xRangerDE
afef213b45 a11y: dialog roles + aria-labelledby + aria-label on modal closes
All five modal-overlay containers (update, clip-cutter, events-viewer,
chat-viewer, template-guide) were rendering as plain divs from an
accessibility perspective. Screen readers would announce nothing
distinguishing when one of them opened, and the close-X buttons would
read as "x button" with no semantic meaning.

Added on each .modal-overlay:
- role="dialog" — tells assistive tech this is a modal region
- aria-modal="true" — instructs the reader to ignore content outside
  the dialog while it is open (matches the keyboard escape + click-
  outside-to-dismiss behavior the renderer already implements)
- aria-labelledby="<existingTitleId>" — every modal already had a
  uniquely-IDd h2; pointed each dialog at its own title so the reader
  announces e.g. "Stream events dialog" on open

Added on each .modal-close button:
- aria-label="Close" — gives the X button a real semantic label
  independent of the visual character

Zero visual change, zero behavior change. Just makes the app actually
usable for someone running NVDA/JAWS/Orca/VoiceOver. WCAG 4.1.2 +
2.1.1 + 1.3.1 alignment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:50:26 +02:00
xRangerDE
f93b07c87a cleanup: remove dead fetchOnlyFollowerCount + clarify profile inferred type
Two related artifacts left over from the avatar/banner GQL refactor
in 4.6.20:

- fetchOnlyFollowerCount was an early standalone helper from the
  iteration where Helix supplied core profile fields and a separate
  public-GQL roundtrip pulled just the follower count. The 4.6.19
  rewrite folded all of that into a single public-GQL query, so the
  helper has no callers. Removed.

- streamFromPublic was typed via
  `Awaited<ReturnType<typeof fetchPublicStreamerProfile>> extends ...`
  conditional inference because the inline stream shape was anonymous.
  That worked but read like a riddle. Hoisted the inline shapes to
  two named interfaces (PublicStreamInfo + PublicStreamerProfileResult)
  so the function signature is explicit and the local var is just
  `PublicStreamInfo | null`. Same type, an order of magnitude more
  obvious to anyone reading.

Both changes are zero-runtime-behavior; tests confirm.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:46:13 +02:00
xRangerDE
9115819bb0 feat: VOD bulk-action bar — slide-in animation + style extracted from inline
The bulk-action bar (the purple-tinted row that appears between the
VOD filter and the grid when 1+ VOD checkbox is ticked) was 100%
inline-styled in HTML, which meant:
- No animation when it appears — it just popped into existence
- No reusable styling for similar action surfaces later
- Layout debugging meant editing HTML, not CSS

Extracted to a proper .vod-bulk-bar class, plus .vod-bulk-count for
the "N selected" label and a .vod-bulk-spacer for the flex push.
The CSS rule also picks up a 4px soft purple shadow + a slightly
richer gradient background that matches the rest of the purple
surfaces in the app.

Animation: vod-bulk-bar-slide @keyframes fires every time the JS
flips display:none -> display:flex, because @keyframes restart on
each display change. 220ms, cubic-bezier(0.16, 1, 0.3, 1) for a
quick spring landing, 10px translateY-from + opacity 0->1. The
appear feels intentional now instead of jarring.

Disappear (display:flex -> none) still snaps because CSS cannot
transition through display:none — adding that would require a
class-toggle refactor and an explicit timer to defer the actual
removal. Not worth the complexity for the polish-grade improvement
this is going for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:41:08 +02:00
xRangerDE
b9f2b68596 feat+cleanup: active streamer highlight polish + dead scrollbar rule removed
Active streamer state in the sidebar was a flat purple-tinted
background with a 3px left border. Felt slightly weak as the primary
"this is what you're looking at" affordance, especially with the
similarly-tinted hover state immediately next to it.

Bumped to:
- Gradient background fading purple-strong to purple-faint across
  the row, so the active item has a directional emphasis matching
  the rest of the Twitch-purple language.
- 1px inset purple ring on top so the active state reads as a
  clearly-bordered card, not just a tinted background.
- A small purple right-edge marker (3px wide, 60% tall, centered)
  drawn via ::after — mirrors the existing left border and makes
  the selected row feel "framed".
- Streamer name in the active row goes 600 weight so the identity
  pops over the meta toggles next to it.

Cleanup side: the old generic ::-webkit-scrollbar rule block from
the early days of the app was still in the file at line 1497, even
though the newer purple-themed *::-webkit-scrollbar block further
down has been overriding it for several releases (later wins on
identical specificity). Replaced the old block with a one-line
comment explaining where the live rule lives, so the next person
greping for "scrollbar" doesn't get a misleading hit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:36:32 +02:00
xRangerDE
227c4bdf82 feat: range slider repaint + number input spinner cleanup
Two leftover form-control oddities from the audit.

Range sliders (used in the clip-cutter modal and any future
slider-group settings) had a stale orange thumb colour (#E5A00D) from
when the app was a different shade of Twitch. Reskinned to the
current purple family: track gets a subtle purple-to-dark gradient
that visually echoes the queue progress bar, thumb is a 16px purple
circle with a 2px white border and a soft shadow, hover scales the
thumb 1.15x and turns the shadow into a purple halo for the "I can
grab this" affordance. Focus-visible adds the same 3px purple ring
the rest of the form controls use, so keyboard tabbing through a
modal lands on a clearly-focused slider. Mirrored ::-moz-range-thumb
+ ::-webkit-slider-thumb so Firefox and Chromium-Electron look
identical.

Number inputs got the OS spinner stack hidden globally
(::-webkit-inner-spin-button / outer + -moz-appearance: textfield).
The default Webkit spinners are a tiny gray arrow pair that always
reads as "unfinished" and clutter the look. Users still get keyboard
arrow keys + wheel scroll. If a custom spinner pattern is needed
later it can come back as a chrome around the input — for now the
inputs read clean as text fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:31:29 +02:00
xRangerDE
12fd2b7217 feat: custom-styled checkboxes + select dropdowns
Audit turned up 20 raw `<input type="checkbox">` in Settings still
rendering with OS-default gray-square look — and most `<select>`
elements showing the OS-default dropdown arrow. With the rest of the
UI now Twitch-themed (purple inputs, modal pops, animated everything),
those felt jarringly out of place.

Checkbox: 16px rounded square, dark base with 1.5px border, hovers to
a purple-tinted border, fills purple + draws a CSS-only white check
on the diagonal when checked, scales down briefly on click, focus
shows the same 3px purple ring as the text inputs. No JS, just
:checked + ::after.

Select: appearance:none everywhere to kill the OS chevron, then an
inline-SVG chevron in background-image at right:8px (gray default,
purple on hover). padding-right boosted to 28px so option text never
overlaps the arrow. The dropdown menu itself still uses the OS list,
but the closed control matches the rest of the input family now.

Cascades globally via input[type="checkbox"] / select selectors — no
markup edits needed. The few selects/checkboxes that previously had
inline accent-color overrides keep working because accent-color
applies to native widgets, which we have now replaced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:27:04 +02:00
xRangerDE
f7a54a2007 feat: sidebar streamer counter + VOD duration badge + queue shimmer + chat polish
Round-4 polish.

- Streamer section counter. Tiny line next to the "Streamer" sidebar
  heading: "12" when nobody is live, "12 · 3 live" with the live
  count highlighted red when broadcasters from the watch list are
  on air. Re-rendered on every renderStreamers call so it stays in
  sync with add/remove and the 60s live-status poll.

- VOD duration badge. Twitch-style bottom-right pill on every VOD
  thumbnail showing the recordings duration ("32h37m9s"). 11px,
  white-on-near-black, 2px backdrop-blur, hover deepens the
  background, fades out when the storyboard preview activates so
  the preview frame reads cleanly. Pairs with the existing
  downloaded checkmark badge (top-left) and live-recording badge
  to give each thumbnail a complete at-a-glance status row.

- Queue progress bar shimmer. The fill bar now uses a purple-to-
  light-purple gradient and rides a moving white-translucent
  highlight strip that sweeps L->R every 1.8s. Same translateX-100%
  to 100% trick used everywhere else, but only visible because
  the underlying bar has colour. Makes "currently downloading"
  obvious without needing a separate spinner.

- Chat viewer polish. Replaced the inline per-message styling with
  proper .chat-viewer-* classes: hoverable row background, system
  events (subs/raids/deletions) get a left-purple-border + tinted
  background to set them apart from normal chat lines, the type
  tag (e.g. [sub], [raid]) renders as a real chip with a border,
  timestamps are mono-fonted and faded. Per-user IRC colour from
  Twitch metadata is still respected as an inline override on the
  username.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:23:18 +02:00
xRangerDE
17b715ab24 fix+feat: input focus ring + queue item polish + toast + button class collision
Polish round 3, plus a class-collision fix.

Fix first: the new X-close button class introduced in 4.6.21 was
called .btn-icon, which collided with an EXISTING .btn-icon class
already used by the top-bar Refresh button (and elsewhere). The
second declaration partially overrode the first, leaving Refresh
with the wrong hover state (red instead of purple-tinted). Renamed
the close-button class to .btn-close and updated the two call sites
(btnStreamerBulkRemove + vodFilterClearBtn). Refresh now hovers
correctly with a purple tint + a 180deg SVG icon spin on hover.

Polish bundle:

- Input focus ring globally: every text/search/number/password
  input + textarea + select picks up a 3px rgba purple ring on
  focus, with a smooth 180ms transition on border-color, box-shadow,
  and background. Focus state finally reads as intentional instead
  of the OS default blue glow.

- Queue items: 3px left border that color-codes by status (purple
  while downloading, green when complete, red on error), faint
  purple-tinted background when downloading, soft glow on the
  status dot. The queue list now reads as a status timeline at a
  glance.

- Top-bar Refresh button picks up a 1px border, purple-tint hover
  background, and the SVG arrow spins on hover for the "refresh"
  feel.

- Header search box (Add streamer): consistent border-radius (6px
  vs 4px) and the + button gets a hover shadow + active-press
  micro-bounce.

- App toast: gradient background, accent-color left border (purple
  for info, amber for warn, red for error), animated slide-in from
  the right instead of vertical, backdrop blur for content
  legibility over busy backgrounds, and an extra .error variant
  class. Feels modern instead of like a notice strip from 2010.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:18:11 +02:00
xRangerDE
cc23f1e272 feat: skeleton cards + tab fade + modal pop animation + scrollbar polish
Visual-polish round 2.

- VOD skeleton loader: replaces the "Loading..." placeholder with
  six shimmering skeleton cards that share the real cards
  dimensions. The grid no longer collapses+expands as VODs arrive,
  and the shimmer telegraphs that work is happening rather than
  the app sitting silent. CSS @keyframes skel-shimmer drives a
  smooth 1.5s gradient pan.

- Tab switch animation: 180ms ease-out fade-in + 4px lift on
  every .tab-content.active. Switching between VODs / Statistik /
  Archiv / Einstellungen no longer feels like an instant
  paint-swap.

- Modal overhaul: backdrop-filter blur(8px) on the overlay so the
  app behind softly blurs out, animated pop on the modal itself
  (scale 0.96 -> 1 + translateY 8px -> 0 with a clean spring
  curve), proper bordered + glow-cornered card, and the close X
  swapped from a flat 24px text button to a real 30x30 rounded
  pill with hover-red highlight.

- Scrollbar: thin 10px purple-tinted webkit scrollbar across the
  entire app, matching the accent color. Hover deepens to full
  purple. Track is near-transparent. Looks intentional instead of
  the default OS gray.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:14:15 +02:00
xRangerDE
11883889de feat: sidebar live indicators + polished hover + empty-state animation
The killer-feature of this pass is the live indicator: red pulsing
dot next to every streamer in the sidebar that is currently
broadcasting on Twitch. Suddenly the sidebar conveys real-time
state at a glance — you know who to click before clicking.

How it works:
- New live-status batch poller (main.ts) fires every 60s, packs
  every streamer in the user's watch list into a single GQL query
  using aliased user lookups (`u0:user(login:$l0){stream{type}} ...`),
  chunked at 50 logins per request. One roundtrip for the whole
  list — far cheaper than per-streamer polling.
- Updates a liveStatusByLogin Map on the main side, emits an IPC
  `live-status-batch-update` event with only the entries that
  flipped (plus a full snapshot for the renderer to keep in sync).
- Renderer subscribes once at boot via initLiveStatusSubscription,
  keeps a parallel Map, and re-renders the streamer list on
  change. Stamps a .streamer-live-dot before the name. Bold name
  for live streamers so they pop in scannability.
- Restart triggers: app boot, streamer-list change (added/removed
  via save-config) so a freshly added streamer gets their dot in
  seconds without waiting for the next 60s tick.

Polish bundled in the same release:

- VOD card hover gets a more substantial lift: 12px shadow + faint
  purple border-glow on hover. Subtle but enough to feel
  "tactile". Border-color transitions alongside the shadow.

- Empty states get a floating animation and a bigger SVG icon
  with accent-colored tint. "No VODs / select a streamer" now
  feels intentional instead of an oversight.

- Streamer-name span dedicated class (.streamer-name +
  .streamer-name.is-live) so a live streamer's name itself bolds,
  not just gets a dot beside it.

Locale strings: liveNowTooltip ("Currently live on Twitch" / "Aktuell
live auf Twitch") for the dot's tooltip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:11:26 +02:00
xRangerDE
30776c02b9 fix: sticky header opaque + banner visible + missing button styles
Three things from screenshot feedback against 4.6.20:

1) VODs visible through/above the sticky profile header. Root cause
   was a stack: the 0.10/0.04 alpha gradient over var(--bg-card)
   pushed the resulting background just barely under "opaque" in
   some renderers AND .content has padding-top: 25px which let
   VODs scroll through the area above the sticky element when
   top: 0 was used. Fix: drop the gradient (banner-bg + ::before
   pseudo handle the visual interest now), use straight
   var(--bg-card), set top: -25px to negate .contents padding so
   the header pins flush with the visible top edge, bump z-index
   to 100, add isolation:isolate to force a new stacking context
   so VODs cannot escape upward through the header.

2) Banner not visible. Was being suppressed by a 0.78-0.92 alpha
   dimming gradient applied via background-image alongside the
   banner URL — readable for text but visually killed the banner.
   Moved the gradient into a ::before pseudo at z-index 1 with
   gentler 0.55-0.78 alpha, dropped banner-bg blur from 18px to
   10px, took opacity from 0.55 back up to 1.0. Banner now
   actually shows behind the content the way twitch.tv does.

3) Stray un-styled buttons. Scan turned up a handful of action
   buttons rolling their own inline styles (.vodBulkAddBtn /
   MarkBtn / UnmarkBtn / ClearBtn, .vodFilterClearBtn,
   .btnStreamerBulkRemove, .clipDialogConfirmBtn) plus a missing
   .queue-detail-btn rule that was leaving every "View chat",
   "View events", "Open file", "Show in folder" button defaulting
   to the browsers gray fallback. Added three reusable classes
   (.btn-pill default/primary/success/danger, .btn-icon, plus the
   missing .queue-detail-btn) and swapped the inline styles for
   the classes. Visual consistency across queue bulk-bar, archive
   search results, and queue item detail rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 01:04:22 +02:00
xRangerDE
3c73efbad7 feat: banner background + live preview card + VOD hover storyboard + sticky header
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>
2026-05-11 00:55:17 +02:00
xRangerDE
ec48592503 fix: public-mode profile shows letter X — GQL query referenced authed-only field
Root cause of the X-fallback in the new profile header when the app
runs without Twitch credentials ("public mode"): the GQL query in
fetchPublicStreamerProfile asked for `broadcasterType`, which exists
on the AUTHENTICATED Twitch GQL schema but NOT on the public one. The
public endpoint returned `errors[]` with "Cannot query field
broadcasterType on type User", which fetchPublicTwitchGql correctly
treats as a complete failure and returns null. That cascaded:
- avatarUrl stayed empty
- displayName fell back to the lowercase login
- description stayed empty
- partner/affiliate badge never rendered
- the renderer hit the letter-tile fallback path

Reproduced live against gql.twitch.tv with a curl-equivalent: the
exact query worked when broadcasterType was swapped for the public-
schema field roles{isPartner isAffiliate}. xrohat correctly comes
back as Partner, with the full 150x150 avatar URL, real displayName
"xRohat", and 1.25M follower count.

The 4.6.18 data-URL fetch fix is still right (Electron renderer img
loading against the Twitch CDN was its own minor headache) — it just
never got exercised because we never had a URL to fetch in the first
place. With this fix the data-URL path now activates on every
public-mode profile load, and avatars actually render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:43:53 +02:00
xRangerDE
b1fd73cbe6 fix: profile avatar shows fallback X instead of real picture
Reproduction: open the new profile header (4.6.17) for any streamer
with a real Twitch avatar. The fallback gradient letter tile renders
instead of the actual profile picture. Image works fine pasted into
the browser; only the renderer img tag fails.

Cause is Electron renderer image loading against the Twitch CDN
(static-cdn.jtvnw.net) — undocumented but reproducible: the same
HTTPS URL that loads fine in DevTools fails silently from the live
page, firing the img.onerror handler which (by design) swaps to the
letter-tile fallback.

Fix: fetch the avatar bytes in the main process via axios (Node http
client, no renderer / CSP / referrer-policy / CORS shenanigans),
convert to base64 data URL, and put THAT in the profile.avatarUrl
field. The renderer just renders the data URL via img src — same
code path, but the URL is now data:image/png;base64,... so no
external fetch is involved.

Bytes cached by source URL in a small FIFO map (256 entries) so the
same avatar across cache misses only downloads once. Profile cache
itself is unchanged, just stores the data URL now instead of the
remote URL. On a clean restart the user sees the fix on first
streamer click; mid-session a click on Refresh (top-right of the
header) forces a re-fetch through the new path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:41:03 +02:00
xRangerDE
9239eebf34 feat: streamer profile header — modern channel-page card above VOD grid
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>
2026-05-11 00:38:38 +02:00
xRangerDE
073c1863fe feat: auto-merge resumed live-recording parts via ffmpeg concat
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>
2026-05-11 00:29:54 +02:00
xRangerDE
8d4b0704db feat: local archive search — new Archiv tab
Pairs with 4.6.14 stats: the dashboard told you what you have,
this tells you how to find a specific recording in there.

New Archiv tab between Statistik and Einstellungen. Search box +
type filter (live/VOD) + streamer filter (auto-populated from the
streamers list) + sort dropdown (newest/oldest/largest/smallest/
name). Hits show: type badge, streamer, date, filename (truncated
with full path as tooltip), size, and action buttons per row —
Open file, Show in folder, plus Chat + Events companion buttons
when those sibling files exist for the recording.

Backend (searchArchive in main.ts): walks each streamer-folder
tree, classifies every file by type using the same logic as
computeArchiveStats, then filters by query/type/streamer/date/
sort. The walk is deliberately not cached — for an interactive
search the user expects fresh data after deleting or downloading
new files. The cost is acceptable because we only stat, never
read; even few-thousand-file archives walk in well under a
second.

Companion attachment: each recording fullPath strips its .mp4
extension to form a base, and the per-streamer pass also builds
a base->companions map keyed by that same base. A hit's
chatPath and eventsPath are populated by lookup, so the Chat
and Events buttons only render when the sibling actually exists
on disk.

Frontend (renderer-archive.ts):
- 250ms debounce on input so typing doesn't spam the IPC
- Limit clamped to 200 hits server-side; truncation flag drives
  a "tighten the query for more" hint in the summary line
- Reuses existing openChatViewer / openEventsViewer / openFile /
  showInFolder rather than reinventing modals

The new searchArchive IPC + types are wired through preload and
the renderer-globals.d.ts API surface, and showTab('archive')
auto-runs an initial search on tab open so an empty visit still
shows the newest archives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:26:42 +02:00
xRangerDE
4adeffe7dc feat: archive statistics dashboard
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>
2026-05-11 00:20:14 +02:00
xRangerDE
7d82f70ca3 feat: auto-resume live recording across streamlink crashes
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>
2026-05-11 00:10:44 +02:00
xRangerDE
28692b2e54 feat: manual scan-now buttons + automation status line
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>
2026-05-10 22:09:59 +02:00
xRangerDE
2c40bbf66e feat: live recording health indicator (green/amber dot per item)
In-flight live recordings now show a small coloured dot before the
title indicating whether bytes are still flowing.

The health state is derived from byte-progress liveness: each time
the byte counter advances, we stamp lastBytesAdvancedAt; if more
than 30s pass without an advance we flip the badge to amber to tell
the user the streamlink subprocess has gone quiet (dropped segments,
network blip, or the stream just ended). Until the first segment
arrives we report "unknown" so we don't claim health prematurely on
a streamlink that's still negotiating playlists.

Critical wrinkle: streamlink emits progress events on byte boundaries,
so a hung process emits NO events at all. A pure event-driven badge
would never update from "ok" to "stale" — it'd stay frozen at the
last known good state. To avoid that, downloadLiveStream now runs a
10s health-tick interval that re-emits the most recent progress
event with a fresh health computation. The interval is killed in a
finally block so process termination doesn't leak it.

DownloadProgress + QueueItem in both src/types.ts and the renderer
declaration shadow get the new optional recordingHealth field. The
renderer queue handler copies it onto the item; the queue render
function shows a coloured dot before the title for in-flight live
items only (status === 'downloading' && isLive). Three states:
green pulsing (ok), amber flashing (stale), grey static (unknown).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 22:04:53 +02:00
xRangerDE
1ab6f01e07 feat: auto-vod-download — per-streamer VOD toggle + background poller
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>
2026-05-10 21:59:05 +02:00
xRangerDE
fab263ae4c feat: live recording meta + events viewer modal
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>
2026-05-10 21:50:13 +02:00
xRangerDE
3129c9b5be feat: in-app chat replay viewer — read .chat.json/.chat.jsonl without leaving the app
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>
2026-05-10 21:42:41 +02:00
xRangerDE
55434f499d feat: stream-events log — track title/game changes during live recording
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>
2026-05-10 21:38:40 +02:00
xRangerDE
8634834d16 feat: auto-cleanup — archive or delete old recordings, keep disk under control
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>
2026-05-10 21:34:18 +02:00
xRangerDE
b7c7b9eb7c feat: per-streamer storage stats panel — see what eats the disk
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>
2026-05-10 20:54:19 +02:00
xRangerDE
47862e7fbf feat: Discord webhook notifications for live + VOD events
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>
2026-05-10 20:50:58 +02:00
xRangerDE
ddee248f6b feat: live-chat capture during recording — anonymous IRC -> .chat.jsonl
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>
2026-05-10 20:46:50 +02:00
xRangerDE
45456650d4 feat: VOD chat-replay download — keep the chat alongside the video
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>
2026-05-10 20:40:16 +02:00
xRangerDE
029b2bd407 feat: auto-record polling — set-and-forget live archival
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>
2026-05-10 20:35:19 +02:00
xRangerDE
56261216a9 feat: live stream recording — record streamers as they go live
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>
2026-05-10 20:30:08 +02:00
xRangerDE
e098708398 feat: stats-bar pause-on-hidden + bulk-mark downloaded + title tooltip
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>
2026-05-10 20:20:42 +02:00
xRangerDE
092932d8d5 release: 4.5.27 disable-ads + queue context menu + cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 20:11:41 +02:00
xRangerDE
1f2b5e583c feat: --twitch-disable-ads + queue context menu + remove redundant flag
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>
2026-05-10 20:09:33 +02:00
xRangerDE
fdb096fa96 feat: streamlink quality preference + per-item notifications + path validation
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>
2026-05-10 20:00:36 +02:00
xRangerDE
b959a930af feat: streamer search/bulk-remove + cutter drag-drop + per-streamer scroll
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>
2026-05-10 16:03:47 +02:00
xRangerDE
6379723248 feat: taskbar progress + VOD card delegation + context menu + LRU bound
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(/"/,
   "&quot;") and then interpolated that into onclick="addToQueue('...')".
   Edge cases (titles with backslash, &apos;, 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>
2026-05-10 15:56:33 +02:00
xRangerDE
e2c0e3a2bf feat: hide-downloaded filter + reset list + config export/import
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>
2026-05-10 15:46:21 +02:00
xRangerDE
3f04b42b02 feat: auto-resume queue toggle + already-downloaded VOD indicator
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>
2026-05-10 15:16:21 +02:00
xRangerDE
3e1d4e188c feat: cutter/merge i18n + per-item retry + status-bar queue summary
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>
2026-05-10 14:02:42 +02:00
xRangerDE
16d2456770 feat: trim-VOD dialog i18n + Twitch API help link + log file shortcut
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>
2026-05-10 13:33:10 +02:00
xRangerDE
44c9173f10 feat: backend i18n for user-visible errors + light-theme color vars
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>
2026-05-10 12:33:18 +02:00
xRangerDE
386998deaf feat: streamer drag-reorder + bulk-queue checkboxes on VOD cards
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>
2026-05-10 12:24:29 +02:00
xRangerDE
933af6a6da feat: queue "Open file" / "Show in folder" + clickable finish notification
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>
2026-05-10 12:19:29 +02:00
xRangerDE
d6e513d70d feat: skip-version + addStreamer validation + smart-scheduler tooltip
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>
2026-05-10 12:14:13 +02:00
xRangerDE
13d208c30f ui: trim button now reads "Trim VOD" / "VOD zuschneiden"
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>
2026-05-10 11:59:23 +02:00
xRangerDE
138c81eb8c ui: rename VOD card "Clip" button to Trim/Zuschneiden + live re-render
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>
2026-05-10 11:55:59 +02:00
xRangerDE
ddb3845263 ui: rename "Trim clip" dialog to "Trim VOD" / "VOD zuschneiden"
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>
2026-05-10 11:49:18 +02:00
xRangerDE
013e8be1f0 feat(clip): add Parts-format preset to Trim-Clip dialog
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>
2026-05-10 11:46:20 +02:00
xRangerDE
832b606701 ui: VOD sort dropdown with persisted key + locale labels
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>
2026-05-03 15:54:53 +02:00
xRangerDE
020f3dacf1 harden: GQL retry on transient errors + consolidate shutdown cleanup
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>
2026-05-03 15:54:40 +02:00
xRangerDE
23d0dd5829 ui: VOD list filter with persistence + Ctrl+F focus + Esc clear
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>
2026-05-03 15:43:16 +02:00
xRangerDE
31e6671e65 harden: download-clip integrity + cancel tracking + decouple editor procs
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>
2026-05-03 15:43:01 +02:00
xRangerDE
379048f191 harden: defensive parsing for config + queue, normalize stale downloading
- 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>
2026-05-03 15:29:28 +02:00
xRangerDE
feebfc86a1 ui: data-id queue lookup + persisted active tab + Esc/Ctrl+N shortcuts
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>
2026-05-03 15:10:28 +02:00
xRangerDE
8d0cb4cefd harden: atomic fsync writes + per-item filename claims
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>
2026-05-03 15:10:15 +02:00
xRangerDE
37d75fac24 fix: Windows notification shows 'Twitch VOD Manager' instead of 'electron.app.Electron'
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>
2026-03-29 04:18:34 +02:00
xRangerDE
da1d14d458 fix: guard formatDuration against NaN/Infinity input
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 15:23:21 +01:00
xRangerDE
18940d0640 chore: add ESLint with security plugin, fix code quality warnings
- Install eslint, typescript-eslint, eslint-plugin-security
- Add eslint.config.mjs with project-tuned rules
- Fix redundant catch assignment in cutVideo
- Fix let→const for promise dedup patterns
- No security bugs found — all regex warnings are false positives (anchored patterns)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 14:55:35 +01:00
xRangerDE
d8f0836165 fix: ETA calculation was using video duration instead of download progress
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>
2026-03-21 15:47:19 +01:00
xRangerDE
5f2e85e455 fix: clip time validation, cutter 0-byte check, pagination guard, atomic config write
H1: Add NaN/negative/zero-duration validation to clip dialog before IPC call
H2: Reject cut video output <= 256 bytes as effectively empty
H3: Add paginated VOD fetching with MAX_VOD_PAGES=50 safety guard
H4: Atomic write (tmp+rename) for config and queue persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:33:31 +01:00
xRangerDE
2b379e5e6a fix: correct dragstart cancel method and release claimed filenames after download
- 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>
2026-03-21 15:19:30 +01:00
xRangerDE
cf9d7b8334 fix: selector overflow for 10+ items, drag-drop status guard, filename claim set for parallel safety
- 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>
2026-03-21 15:15:25 +01:00
xRangerDE
6c47c63fa8 fix: clamp ETA bounds, store stats interval, add activeDownloads finally-cleanup, prevent progress backward jump
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:04:59 +01:00
xRangerDE
4607ba9cc6 fix: persist expanded details across re-renders, guard drag-drop init against duplicates
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:03:20 +01:00
xRangerDE
00d35f1b1c fix(ui): reduce queue action button height for compact layout
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:10:02 +01:00
xRangerDE
0133569104 fix: include selection state in queue render fingerprint so numbered selectors update visually
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 10:06:42 +01:00
xRangerDE
66afaba0ea refactor: extract tool discovery functions to src/tools.ts
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>
2026-03-20 10:00:51 +01:00
xRangerDE
54d04d4f73 feat: support parallel downloads (up to 2 simultaneous)
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>
2026-03-20 09:54:20 +01:00
xRangerDE
63aafae85d feat: add light theme with toggle in settings
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:46:26 +01:00
xRangerDE
424b312551 refactor: extract shared interfaces to src/types.ts
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:44:37 +01:00
xRangerDE
fbcf3935d0 feat: add drag & drop queue reordering and expandable queue item details
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:42:35 +01:00
xRangerDE
2481230983 feat: add keyboard shortcuts (Del/S) and download statistics bar
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:40:39 +01:00
xRangerDE
c96fd13aff feat: add ETA calculation for download progress display
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:39:27 +01:00
xRangerDE
a4ca410641 feat: filename collision detection, queue JSON validation, download-complete notification
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:27:19 +01:00
xRangerDE
76ecbc652d perf: parallel init with Promise.all, targeted DOM updates for download progress
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:26:19 +01:00
xRangerDE
00cf7781d3 fix(ui): prevent queue buttons from being pushed off-screen by long queue
Queue section now uses flex layout with flex-shrink:0 on action buttons,
so they stay visible regardless of queue list length.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 09:06:10 +01:00
xRangerDE
0e6b219455 feat(merge-split): numbered selection instead of checkboxes, user-defined merge order
Replace checkboxes with numbered selectors (1, 2, 3...) that show the
merge order. Click order determines VOD sequence in the merged result.
Chronological auto-sort removed — user controls the order.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 08:55:35 +01:00
xRangerDE
86c80acf28 fix: flush config on quit, verify temp cleanup, add missing spawn error handlers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:42:35 +01:00
xRangerDE
33730fd372 fix: guard processQueue against concurrent invocations, fix stale process kill reference
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:41:11 +01:00
xRangerDE
6c082a87ab fix(merge-split): fix premature 100% progress, add ffmpeg preflight, clean partial splits, use locale metaLabel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:12:50 +01:00
xRangerDE
ad4e540952 feat(merge-split): add queue checkboxes and merge-group selection UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:55:33 +01:00
xRangerDE
c1a72ebd66 feat(merge-split): add Merge & Split button and queue checkbox/merge-group styles
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:55:14 +01:00
xRangerDE
409c976df0 feat(merge-split): integrate merge-group pipeline into processQueue and cleanup handlers
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:53:55 +01:00
xRangerDE
8501bd17f7 feat(merge-split): add processDownloadMergeGroup() 4-phase pipeline
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:52:41 +01:00
xRangerDE
03f47a7240 feat(merge-split): add splitMergedFile() function using FFmpeg stream-copy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:49:15 +01:00
xRangerDE
5a20c1c6a4 fix(merge): fix progress formula for long videos, add optional totalDurationSec param, normalize Windows paths
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:48:02 +01:00
xRangerDE
645d2f147b feat(merge-split): add createMergeGroup IPC handler
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:47:36 +01:00
xRangerDE
4750af2f97 feat(merge-split): add MergeGroup type definitions to all interface locations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:46:20 +01:00
xRangerDE
03c6e68da0 feat(merge-split): add EN and DE localization strings for merge group
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:46:20 +01:00
xRangerDE
47df9664a4 release: 4.2.4 improve updater and queue persistence 2026-03-06 02:48:07 +01:00
xRangerDE
b7cd8fbec2 release: 4.2.3 improve updates and startup UX 2026-03-06 02:34:16 +01:00
xRangerDE
1005b583bd release: 4.2.2 add full settings autosave 2026-03-06 02:05:23 +01:00
xRangerDE
935125a83e chore: switch updater and release flow to gitea 2026-03-05 00:49:30 +01:00
xRangerDE
2631924ef5 chore: migrate repository to Codeberg, bump version to 4.2.0, update update logic 2026-03-01 20:23:21 +01:00