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:
+4
-4
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
<Field
|
{(updateToken.trim() !== '' || !!appUpdError) && (
|
||||||
label="Update access token"
|
<Field
|
||||||
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."
|
label="Update access token"
|
||||||
>
|
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"
|
<Input
|
||||||
value={updateToken}
|
type="password"
|
||||||
placeholder="Optional — access token"
|
value={updateToken}
|
||||||
onChange={(_, d) => update({ updateToken: d.value })}
|
placeholder="Optional -- access token"
|
||||||
contentBefore={<ArrowSyncRegular />}
|
onChange={(_, d) => update({ updateToken: d.value })}
|
||||||
/>
|
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's what changed:
|
Version {appUpd.latestVersion} is available -- here'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're up to date — v{appUpd.currentVersion} is the latest.</Text>
|
<Text>You'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>
|
||||||
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
|
||||||
</Text>
|
ffmpeg {ffmpeg.ffmpeg ?? 'not found'}
|
||||||
<Text style={{ fontFamily: tokens.fontFamilyMonospace }}>
|
</Text>
|
||||||
ffprobe {ffmpeg.ffprobe ?? 'not found'}
|
<Text style={{ fontFamily: tokens.fontFamilyMonospace, display: 'block' }}>
|
||||||
</Text>
|
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 && (
|
{ytdlpLastUpdateCheck > 0 && (
|
||||||
<Caption1 className={styles.hint}>
|
<Caption1 className={styles.hint}>
|
||||||
|
|||||||
@@ -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".
|
||||||
|
|||||||
Reference in New Issue
Block a user