Commit Graph

39 Commits

Author SHA1 Message Date
xRangerDE
84f576d131 a11y: aria-label on the add-streamer input — reuses the existing locale key
The newStreamer text input (where the user types a Twitch username to add to the streamer list) had only a placeholder ("Streamer hinzufuegen..." / "Add streamer...") as its accessible-name source. Same problem as the 3 filter/search inputs fixed in 4.6.151: placeholder text is not a reliable screen-reader name and disappears once typing starts.

Wired up setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel) — reusing the locale key already in use for the adjacent "+" button (4.6.91), which means screen reader users hear "Streamer hinzufuegen" / "Add streamer" for both the input and the button that submits it. Zero new translation work, immediate a11y win.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:01:12 +02:00
xRangerDE
7b0e511479 a11y: localized aria-label on the 3 filter/search inputs
The three filter / search inputs in the always-visible UI surface (VOD filter, sidebar streamer-list filter, Archive search) had only placeholder text as their accessible-name source. Placeholder text is unreliable as a screen-reader name — some implementations announce it, some skip it, and it disappears as soon as the user types so a re-focus during typing leaves the input unannounced.

Added three locale keys (DE+EN):
- vods.filterAria — "VOD-Titel filtern" / "Filter VOD titles"
- static.archiveSearchAria — "Archiv durchsuchen" / "Search archive"
- static.streamerListFilterAria — "Streamer-Liste filtern" / "Filter streamer list"

Wired through renderer-texts via the existing setAriaLabel helper (added in 4.6.91), so each input now has a proper aria-label that survives the placeholder vanishing and reads cleanly in screen-reader navigation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:57:11 +02:00
xRangerDE
564d123431 i18n: wire statsIntro into renderer-texts + drop unused statsScannedAtNever
Two related locale-table fixes:

1. statsIntro was already defined in both DE+EN but never applied — the HTML's German static text stayed visible when the user picked English. Wired it through renderer-texts: the locale strings now include the <code>{streamer}/live/</code> / <code>{streamer}/</code> markup that the HTML carried inline, and the setText pass uses applyHtml to render them so the inline <code> styling survives. The locale strings are static developer-authored content (no untrusted input) so the inline <code> tags are safe.

2. statsScannedAtNever was defined in both DE+EN but had zero callsites — leftover from an earlier stats-card iteration. Removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 11:19:20 +02:00
xRangerDE
7a6654097f i18n: localize 3 placeholders that stayed in German under English
Three input placeholders in the HTML had hardcoded German strings that were never routed through the locale system:

- clipStartPart (Clip cutter modal — "Start Part-Nummer") said "z.B. 42" regardless of language
- clipUrl (Clips tab) read "...oder https://www.twitch.tv/..." with a mid-string German conjunction
- cutterFilePath (Video cutter "Datei ausgewahlt" field) read "Keine Datei ausgewahlt..." even under EN

Added three locale keys (clips.urlPlaceholder, clips.startPartPlaceholder, cutter.filePathPlaceholder) with DE+EN translations, plus the three setPlaceholder() wire-up calls in renderer-texts.ts. The HTML defaults stay German (consistent with the other placeholders) but the JS now overrides them when the user picks English (or re-renders when language changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:54:08 +02:00
xRangerDE
773addb279 a11y + i18n: chat viewer filter placeholder + aria-label, status dot aria-hidden
Two unrelated small fixes bundled:

1. The chat viewer modal's filter input (chatViewerFilter) had a hardcoded "Filter..." placeholder that was never localized — every other filter input in the app routes its placeholder through UI_TEXT. Added queue.chatViewerFilterPlaceholder + queue.chatViewerFilterAria locale keys (DE: "Chat filtern..." / "Chatnachrichten filtern"; EN: "Filter chat..." / "Filter chat messages") and wired them through renderer-texts so the placeholder now matches the active language and screen readers get a proper accessible name on the input.

2. The status-bar's coloured dot (statusDot) had no aria-hidden — screen readers would read it as a generic element with no meaning. The status text right next to it already carries the same information ("Verbunden" / "Nicht verbunden"), so the dot is purely decorative. Added aria-hidden="true".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 08:49:17 +02:00
xRangerDE
3ec88a7800 a11y: aria-label on the 3 icon-only buttons in the main shell
Three icon-only buttons in the always-visible UI had no programmatic accessible name:
- the "+" add-streamer button (no id, no title, no aria-label — entire button was unnamed)
- the bulk-remove X button next to the Streamer section title (had a localized title, but title is not a reliable accessible-name source — many screen readers don't expose it)
- the VOD-filter clear X button above the VOD grid (same — title-only)

For all three the visible text is just a glyph ("+", "x"), so the accessible name has to come from somewhere else. Added:
- new locale key static.streamerAddAriaLabel ("Streamer hinzufuegen" / "Add streamer") and an id on the "+" button so renderer-texts can localize it
- new setAriaLabel(id, value) helper in renderer-texts mirroring the existing setTitle/setPlaceholder pattern
- aria-label calls for all three buttons, in addition to the existing title (so the tooltip stays for sighted users)

The two existing X buttons reuse their existing title strings as aria-label — no new translation work, just exposing the already-present text via the right ARIA attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 06:37:26 +02:00
xRangerDE
c393457492 a11y: update-banner progress bar role=progressbar + aria-valuenow
Third progress bar in this a11y pass — the download-progress bar
shown in the update banner during an auto-update download. Same
pattern as 4.6.64 (queue) + 4.6.65 (cut/merge): bare div with
JS-driven width, no semantic role.

Promoted the .update-banner-progress-track to role="progressbar"
with aria-valuemin / max / now + a localized aria-label
(updateProgressAria: "Update download progress" / "Update-Download-
Fortschritt").

Three call sites in renderer-updates.ts that drive bar.style.width
now also stamp aria-valuenow on the gauge:
- onUpdateProgress event handler (per-tick percent)
- setDownloadPendingUi (initial 30% indeterminate placeholder)
- setDownloadReadyUi (100% on finish)

renderer-texts.applyText sets the localized aria-label at boot +
on language switch.

That's all three application-level progress bars now AT-friendly.
The same pattern would extend to any future progress UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:36:42 +02:00
xRangerDE
fa440951d2 a11y: cut + merge progress bars role=progressbar + aria-valuenow
Following 4.6.64 for the queue progress bars, the cut + merge
progress containers in their respective tabs had the same gap:
a plain <div class="progress-bar"> wrapping a <div class="progress-
bar-fill"> with no semantic role. JS poked the bar's style.width
on every percent update; AT had no way to read out the running
value.

Promoted both .progress-bar wrappers to role="progressbar" with
aria-valuemin / max / now, plus aria-label sourced from new
locale strings (cutProgressAria / mergeProgressAria) so EN/DE
both work.

The progress event handlers in renderer.ts now also stamp
aria-valuenow on each tick, so AT live regions pick up the
percentage as the cut / merge advances. setAttribute is cheap
relative to the FFmpeg progress event rate (~1/s), no perf
concern.

renderer-texts.applyText sets the localized aria-label on both
gauges at boot + language switch — text contents already get
re-applied through the same path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 04:31:41 +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
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
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
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
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
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
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
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
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
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
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
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
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
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
2631924ef5 chore: migrate repository to Codeberg, bump version to 4.2.0, update update logic 2026-03-01 20:23:21 +01:00