Fix L16/L75/L77: datetime formatting, update-token visibility, ffmpeg re-check

L16: relTime now caps at weeks/months instead of unbounded 'N d ago' (adds w/mo
     buckets). formatWhen omits the year when the timestamp is in the current year.
L75: Update-token Field in the Software-update card is hidden by default; only
     shown when a token is already configured or an update-check error occurred.
L77: Adds a Re-check button next to the ffmpeg/ffprobe version display so a
     previously-not-found binary can be re-detected without restarting the app.

typecheck + 242 tests + eslint + prettier green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:54:19 -04:00
parent 910cf23e61
commit 3086ac469f
3 changed files with 74 additions and 51 deletions
+4 -4
View File
@@ -1,4 +1,4 @@
# Code Audit # Code Audit
Living checklist of audit findings for AeroFetch. Security & correctness were reviewed Living checklist of audit findings for AeroFetch. Security & correctness were reviewed
2026-06-23 (all fixed — see Completed). The architectural review (2026-06-29) added the 2026-06-23 (all fixed — see Completed). The architectural review (2026-06-29) added the
@@ -408,7 +408,7 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
rings; `box-sizing` is then set ad hoc on the SettingsView swatch. rings; `box-sizing` is then set ad hoc on the SettingsView swatch.
- [x] **L15 — `MediaThumb` redundant ternary** `kind === 'audio' ? 'audio' : 'video'` (kind is - [x] **L15 — `MediaThumb` redundant ternary** `kind === 'audio' ? 'audio' : 'video'` (kind is
already that union). already that union).
- [ ] **L16 — `relTime`/`formatWhen` verbosity.** `relTime` returns unbounded "N d ago"; History's - [x] **L16 — `relTime`/`formatWhen` verbosity.** `relTime` returns unbounded "N d ago"; History's
`formatWhen` always appends the year, even for the current year. `formatWhen` always appends the year, even for the current year.
- [x] **L17 — TemplateManager regex not validated.** An invalid `urlPattern` is accepted with no - [x] **L17 — TemplateManager regex not validated.** An invalid `urlPattern` is accepted with no
feedback and silently never matches (main's `matchesUrl` swallows the error). feedback and silently never matches (main's `matchesUrl` swallows the error).
@@ -516,11 +516,11 @@ token system + shared primitives (UI/SIMP — high value, larger effort) · i18n
re-queued row loses its thumbnail until re-probed. re-queued row loses its thumbnail until re-probed.
- [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index". - [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index".
- [ ] **L74 — Accent swatches are triple-labeled** (`aria-pressed` + `aria-label` + `title`). - [ ] **L74 — Accent swatches are triple-labeled** (`aria-pressed` + `aria-label` + `title`).
- [ ] **L75 — Update-token field always visible** in the Software-update card, even when no update - [x] **L75 — Update-token field always visible** in the Software-update card, even when no update
is pending — buries an advanced/rarely-needed input. is pending — buries an advanced/rarely-needed input.
- [x] **L76 — Duplicate-warning may show a placeholder title**`setDup(existing.title)` can be the - [x] **L76 — Duplicate-warning may show a placeholder title**`setDup(existing.title)` can be the
`titleFromUrl` placeholder before metadata resolves ('YouTube video (id)'). `titleFromUrl` placeholder before metadata resolves ('YouTube video (id)').
- [ ] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks - [x] **L77 — ffmpeg/ffprobe versions have no re-check affordance** (load once; "not found" sticks
with no retry) unlike the yt-dlp "Check version" button. with no retry) unlike the yt-dlp "Check version" button.
- [ ] **L78 — `DownloadsView` empty-state copy** ("Paste a URL above to get started") doesn't match - [ ] **L78 — `DownloadsView` empty-state copy** ("Paste a URL above to get started") doesn't match
the actual two-step Fetch→Download affordance. the actual two-step Fetch→Download affordance.
+40 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { import {
Field, Field,
Input, Input,
@@ -153,10 +153,10 @@ const useStyles = makeStyles({
} }
}) })
// Combined "Type Quality" options for the default-format dropdown. // Combined "Type -- Quality" options for the default-format dropdown.
const FORMAT_OPTIONS = [ const FORMAT_OPTIONS = [
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video ${q}` })), ...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video -- ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio ${q}` })) ...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio -- ${q}` }))
] ]
const THEME_MODE_OPTIONS = [ const THEME_MODE_OPTIONS = [
@@ -364,7 +364,7 @@ export function SettingsView(): React.JSX.Element {
const result = await window.api.importBackup() const result = await window.api.importBackup()
setImportResult(result) setImportResult(result)
if (result.ok) { if (result.ok) {
// Settings + templates changed underneath the stores reload both. // Settings + templates changed underneath the stores -- reload both.
const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()]) const [s, t] = await Promise.all([window.api.getSettings(), window.api.listTemplates()])
useSettings.setState({ ...s, loaded: true }) useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t }) useTemplates.setState({ templates: t })
@@ -378,7 +378,7 @@ export function SettingsView(): React.JSX.Element {
function copyErrorReport(): void { function copyErrorReport(): void {
// The button is disabled when there are no entries, so `report` is always // The button is disabled when there are no entries, so `report` is always
// non-empty here no need for an unreachable "No errors logged." fallback (L159). // non-empty here -- no need for an unreachable "No errors logged." fallback (L159).
const report = errorEntries const report = errorEntries
.map((e) => .map((e) =>
[new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n') [new Date(e.occurredAt).toLocaleString(), e.title ?? e.url, e.url, e.error].join('\n')
@@ -393,8 +393,8 @@ export function SettingsView(): React.JSX.Element {
try { try {
const result = await window.api.cookiesLogin(loginUrl) const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) { if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything don't imply success (L50). // Window closed without capturing anything -- don't imply success (L50).
setLoginError('No cookies were captured did you sign in before closing the window?') setLoginError('No cookies were captured -- did you sign in before closing the window?')
} else if (result.ok) { } else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus()) setCookiesStatus(await window.api.cookiesStatus())
} else { } else {
@@ -441,7 +441,7 @@ export function SettingsView(): React.JSX.Element {
try { try {
const result = await window.api.updateYtdlp(ytdlpChannel) const result = await window.api.updateYtdlp(ytdlpChannel)
setUpdateResult(result) setUpdateResult(result)
if (result.ok) setVersion(null) // stale prompt a re-check rather than show a wrong version if (result.ok) setVersion(null) // stale -- prompt a re-check rather than show a wrong version
} catch (e) { } catch (e) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) }) setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally { } finally {
@@ -468,7 +468,7 @@ export function SettingsView(): React.JSX.Element {
/> />
{noResults && ( {noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}> <Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match {search}. No settings match "{search}".
</Caption1> </Caption1>
)} )}
</div> </div>
@@ -577,7 +577,7 @@ export function SettingsView(): React.JSX.Element {
<Field <Field
label="Keep running in the tray" label="Keep running in the tray"
hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)" hint="When on, closing the window minimizes AeroFetch to the system tray instead of quitting -- so downloads keep going and watched channels can auto-download new uploads. Use the tray icon to reopen or quit. (A download in progress always keeps AeroFetch running, even when this is off.)"
> >
<Switch <Switch
checked={minimizeToTray} checked={minimizeToTray}
@@ -588,7 +588,7 @@ export function SettingsView(): React.JSX.Element {
<Field <Field
label="Start with Windows" label="Start with Windows"
hint="Launch AeroFetch automatically when you sign in useful with auto-download so watched channels stay current in the background." hint="Launch AeroFetch automatically when you sign in -- useful with auto-download so watched channels stay current in the background."
> >
<Switch <Switch
checked={launchAtStartup} checked={launchAtStartup}
@@ -635,7 +635,7 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
{highContrast {highContrast
? 'A Windows high-contrast theme is active AeroFetch follows your system colors.' ? 'A Windows high-contrast theme is active -- AeroFetch follows your system colors.'
: 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'} : 'AeroFetch automatically follows a Windows high-contrast theme if you turn one on.'}
</Caption1> </Caption1>
{highContrast && ( {highContrast && (
@@ -746,7 +746,7 @@ export function SettingsView(): React.JSX.Element {
setPotHint('Token saved.') setPotHint('Token saved.')
} else { } else {
setPotHint( setPotHint(
'Token not found try signing into YouTube first via Cookies above.' 'Token not found -- try signing into YouTube first via Cookies above.'
) )
} }
} catch { } catch {
@@ -799,7 +799,7 @@ export function SettingsView(): React.JSX.Element {
<> <>
<Field <Field
label="Site to sign in to" label="Site to sign in to"
hint="Opens a sign-in window. Log in, then close the window your cookies are saved automatically." hint="Opens a sign-in window. Log in, then close the window -- your cookies are saved automatically."
> >
<div className={styles.folderRow}> <div className={styles.folderRow}>
<Input <Input
@@ -838,7 +838,7 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Custom commands</Subtitle2> <Subtitle2>Custom commands</Subtitle2>
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Named templates of extra yt-dlp flags your own power-user recipes (e.g. Named templates of extra yt-dlp flags -- your own power-user recipes (e.g.
--write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other --write-thumbnail, --no-mtime, anything yt-dlp supports). Appended after every other
option, so a template flag can override a setting above it. option, so a template flag can override a setting above it.
</Caption1> </Caption1>
@@ -891,7 +891,7 @@ export function SettingsView(): React.JSX.Element {
<Field <Field
label="Restrict filenames" label="Restrict filenames"
hint="Sanitize titles to plain ASCII letters/digits, no spaces safer for old filesystems and shells." hint="Sanitize titles to plain ASCII letters/digits, no spaces -- safer for old filesystems and shells."
> >
<Switch <Switch
checked={restrictFilenames} checked={restrictFilenames}
@@ -919,7 +919,7 @@ export function SettingsView(): React.JSX.Element {
</div> </div>
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
Save your settings and custom-command templates to a JSON file, or restore them on another Save your settings and custom-command templates to a JSON file, or restore them on another
machine. Does not include download history or credentials (proxy, API tokens re-enter machine. Does not include download history or credentials (proxy, API tokens -- re-enter
those after import). those after import).
</Caption1> </Caption1>
@@ -1034,23 +1034,25 @@ export function SettingsView(): React.JSX.Element {
{appChecking && <Spinner size="tiny" />} {appChecking && <Spinner size="tiny" />}
</div> </div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field <Field
label="Update access token" label="Update access token"
hint="Only needed if the update server requires sign-in (you'll see “could not reach the update server” without it). Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access." hint="Only needed if the update server requires sign-in. Paste a read-only access token to enable update checks and downloads. Stored encrypted on this device; leave blank for anonymous access."
> >
<Input <Input
type="password" type="password"
value={updateToken} value={updateToken}
placeholder="Optional access token" placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })} onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />} contentBefore={<ArrowSyncRegular />}
/> />
</Field> </Field>
)}
{appUpd?.ok && appUpd.available && ( {appUpd?.ok && appUpd.available && (
<> <>
<Text style={{ fontWeight: tokens.fontWeightSemibold }}> <Text style={{ fontWeight: tokens.fontWeightSemibold }}>
Version {appUpd.latestVersion} is available here&apos;s what changed: Version {appUpd.latestVersion} is available -- here&apos;s what changed:
</Text> </Text>
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>} {appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
<div className={styles.folderRow}> <div className={styles.folderRow}>
@@ -1073,7 +1075,8 @@ export function SettingsView(): React.JSX.Element {
</div> </div>
{!appUpd.downloadUrl && ( {!appUpd.downloadUrl && (
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
This release has no installer attached use View release to download it manually. This release has no installer attached -- use "View release" to download it
manually.
</Caption1> </Caption1>
)} )}
{appDownloading && <ProgressBar value={appFraction} />} {appDownloading && <ProgressBar value={appFraction} />}
@@ -1081,7 +1084,7 @@ export function SettingsView(): React.JSX.Element {
)} )}
{appUpd?.ok && !appUpd.available && ( {appUpd?.ok && !appUpd.available && (
<Text>You&apos;re up to date v{appUpd.currentVersion} is the latest.</Text> <Text>You&apos;re up to date -- v{appUpd.currentVersion} is the latest.</Text>
)} )}
{appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>} {appUpdError && <Caption1 className={errText.errorPre}>{appUpdError}</Caption1>}
@@ -1115,14 +1118,24 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={errText.errorPre}>{version.error}</Caption1> <Caption1 className={errText.errorPre}>{version.error}</Caption1>
)} )}
{ffmpeg && ( {ffmpeg && (
<> <div className={styles.folderRow}>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}> <div>
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
ffmpeg {ffmpeg.ffmpeg ?? 'not found'} ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
</Text> </Text>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}> <Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
ffprobe {ffmpeg.ffprobe ?? 'not found'} ffprobe {ffmpeg.ffprobe ?? 'not found'}
</Text> </Text>
</> </div>
<Button
appearance="subtle"
icon={<ArrowSyncRegular />}
onClick={() =>
window.api.getFfmpegVersions().then(setFfmpeg).catch(logError('getFfmpegVersions'))
}
aria-label="Re-check ffmpeg versions"
/>
</div>
)} )}
{ytdlpLastUpdateCheck > 0 && ( {ytdlpLastUpdateCheck > 0 && (
<Caption1 className={styles.hint}> <Caption1 className={styles.hint}>
+16 -6
View File
@@ -2,8 +2,9 @@
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView) // three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline. // and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
/** Relative "time since" label, e.g. "just now" / "5 min ago" / "3 h ago" / /** Relative "time since" label: "just now" / "5 min ago" / "3 h ago" /
* "2 d ago". Returns "never" for a missing timestamp. (Library last-indexed.) */ * "2 d ago" / "3 w ago" / "2 mo ago". Returns "never" for a missing
* timestamp. (Library last-indexed.) */
export function relTime(ms?: number): string { export function relTime(ms?: number): string {
if (!ms) return 'never' if (!ms) return 'never'
const mins = Math.round((Date.now() - ms) / 60000) const mins = Math.round((Date.now() - ms) / 60000)
@@ -11,11 +12,15 @@ export function relTime(ms?: number): string {
if (mins < 60) return `${mins} min ago` if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60) const hrs = Math.round(mins / 60)
if (hrs < 24) return `${hrs} h ago` if (hrs < 24) return `${hrs} h ago`
return `${Math.round(hrs / 24)} d ago` const days = Math.round(hrs / 24)
if (days < 7) return `${days} d ago`
const weeks = Math.round(days / 7)
if (weeks < 5) return `${weeks} w ago`
return `${Math.round(days / 30)} mo ago`
} }
/** Absolute "when" label that stays short for recent items: "Today, 3:04 PM" / /** Absolute "when" label: "Today, 3:04 PM" / "Yesterday, 3:04 PM" /
* "Yesterday, 3:04 PM" / "Jun 5, 2025". (History rows.) */ * "Jun 5" (current year) / "Jun 5, 2024" (past year). (History rows.) */
export function formatWhen(ts: number): string { export function formatWhen(ts: number): string {
const d = new Date(ts) const d = new Date(ts)
const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) const time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
@@ -24,7 +29,12 @@ export function formatWhen(ts: number): string {
const dayMs = 1000 * 60 * 60 * 24 const dayMs = 1000 * 60 * 60 * 24
if (ts >= startOfToday) return `Today, ${time}` if (ts >= startOfToday) return `Today, ${time}`
if (ts >= startOfToday - dayMs) return `Yesterday, ${time}` if (ts >= startOfToday - dayMs) return `Yesterday, ${time}`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) const sameYear = d.getFullYear() === now.getFullYear()
return d.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
...(sameYear ? {} : { year: 'numeric' })
})
} }
/** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM". /** Full date + time for a scheduled download, e.g. "Jun 5, 2025, 3:04 PM".