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
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.
- [x] **L15 — `MediaThumb` redundant ternary** `kind === 'audio' ? 'audio' : 'video'` (kind is
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.
- [x] **L17 — TemplateManager regex not validated.** An invalid `urlPattern` is accepted with no
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.
- [ ] **L73 — Two verbs for the same probe action** — DownloadBar "Fetch" vs LibraryView "Index".
- [ ] **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.
- [x] **L76 — Duplicate-warning may show a placeholder title**`setDup(existing.title)` can be the
`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.
- [ ] **L78 — `DownloadsView` empty-state copy** ("Paste a URL above to get started") doesn't match
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 {
Field,
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 = [
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio ${q}` }))
...QUALITY_OPTIONS.video.map((q) => ({ value: `video|${q}`, label: `Video -- ${q}` })),
...QUALITY_OPTIONS.audio.map((q) => ({ value: `audio|${q}`, label: `Audio -- ${q}` }))
]
const THEME_MODE_OPTIONS = [
@@ -364,7 +364,7 @@ export function SettingsView(): React.JSX.Element {
const result = await window.api.importBackup()
setImportResult(result)
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()])
useSettings.setState({ ...s, loaded: true })
useTemplates.setState({ templates: t })
@@ -378,7 +378,7 @@ export function SettingsView(): React.JSX.Element {
function copyErrorReport(): void {
// 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
.map((e) =>
[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 {
const result = await window.api.cookiesLogin(loginUrl)
if (result.ok && result.cookieCount === 0) {
// Window closed without capturing anything don't imply success (L50).
setLoginError('No cookies were captured did you sign in before closing the window?')
// Window closed without capturing anything -- don't imply success (L50).
setLoginError('No cookies were captured -- did you sign in before closing the window?')
} else if (result.ok) {
setCookiesStatus(await window.api.cookiesStatus())
} else {
@@ -441,7 +441,7 @@ export function SettingsView(): React.JSX.Element {
try {
const result = await window.api.updateYtdlp(ytdlpChannel)
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) {
setUpdateResult({ ok: false, error: e instanceof Error ? e.message : String(e) })
} finally {
@@ -468,7 +468,7 @@ export function SettingsView(): React.JSX.Element {
/>
{noResults && (
<Caption1 style={{ color: tokens.colorNeutralForeground3, marginTop: '8px' }}>
No settings match {search}.
No settings match "{search}".
</Caption1>
)}
</div>
@@ -577,7 +577,7 @@ export function SettingsView(): React.JSX.Element {
<Field
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
checked={minimizeToTray}
@@ -588,7 +588,7 @@ export function SettingsView(): React.JSX.Element {
<Field
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
checked={launchAtStartup}
@@ -635,7 +635,7 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={styles.hint}>
{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.'}
</Caption1>
{highContrast && (
@@ -746,7 +746,7 @@ export function SettingsView(): React.JSX.Element {
setPotHint('Token saved.')
} else {
setPotHint(
'Token not found try signing into YouTube first via Cookies above.'
'Token not found -- try signing into YouTube first via Cookies above.'
)
}
} catch {
@@ -799,7 +799,7 @@ export function SettingsView(): React.JSX.Element {
<>
<Field
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}>
<Input
@@ -838,7 +838,7 @@ export function SettingsView(): React.JSX.Element {
<Subtitle2>Custom commands</Subtitle2>
</div>
<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
option, so a template flag can override a setting above it.
</Caption1>
@@ -891,7 +891,7 @@ export function SettingsView(): React.JSX.Element {
<Field
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
checked={restrictFilenames}
@@ -919,7 +919,7 @@ export function SettingsView(): React.JSX.Element {
</div>
<Caption1 className={styles.hint}>
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).
</Caption1>
@@ -1034,23 +1034,25 @@ export function SettingsView(): React.JSX.Element {
{appChecking && <Spinner size="tiny" />}
</div>
{(updateToken.trim() !== '' || !!appUpdError) && (
<Field
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
type="password"
value={updateToken}
placeholder="Optional access token"
placeholder="Optional -- access token"
onChange={(_, d) => update({ updateToken: d.value })}
contentBefore={<ArrowSyncRegular />}
/>
</Field>
)}
{appUpd?.ok && appUpd.available && (
<>
<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>
{appUpd.notes && <div className={styles.notesBox}>{appUpd.notes}</div>}
<div className={styles.folderRow}>
@@ -1073,7 +1075,8 @@ export function SettingsView(): React.JSX.Element {
</div>
{!appUpd.downloadUrl && (
<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>
)}
{appDownloading && <ProgressBar value={appFraction} />}
@@ -1081,7 +1084,7 @@ export function SettingsView(): React.JSX.Element {
)}
{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>}
@@ -1115,14 +1118,24 @@ export function SettingsView(): React.JSX.Element {
<Caption1 className={errText.errorPre}>{version.error}</Caption1>
)}
{ffmpeg && (
<>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
<div className={styles.folderRow}>
<div>
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
</Text>
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
ffprobe {ffmpeg.ffprobe ?? 'not found'}
</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 && (
<Caption1 className={styles.hint}>
+16 -6
View File
@@ -2,8 +2,9 @@
// three private functions — `relTime` (LibraryView), `formatWhen` (HistoryView)
// and `fmtSchedule` (QueueItem) — each reimplementing date math inline.
/** Relative "time since" label, e.g. "just now" / "5 min ago" / "3 h ago" /
* "2 d ago". Returns "never" for a missing timestamp. (Library last-indexed.) */
/** Relative "time since" label: "just now" / "5 min ago" / "3 h ago" /
* "2 d ago" / "3 w ago" / "2 mo ago". Returns "never" for a missing
* timestamp. (Library last-indexed.) */
export function relTime(ms?: number): string {
if (!ms) return 'never'
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`
const hrs = Math.round(mins / 60)
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" /
* "Yesterday, 3:04 PM" / "Jun 5, 2025". (History rows.) */
/** Absolute "when" label: "Today, 3:04 PM" / "Yesterday, 3:04 PM" /
* "Jun 5" (current year) / "Jun 5, 2024" (past year). (History rows.) */
export function formatWhen(ts: number): string {
const d = new Date(ts)
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
if (ts >= startOfToday) return `Today, ${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".