Files
AeroFetch/src/shared/ipc.ts
T
debont80 5c88d9c8e0 feat(network): request-throttle controls to avoid YouTube rate-limiting
Re-implements the sleepRequests/sleepInterval/maxSleepInterval feature
(--sleep-requests / --sleep-interval / --max-sleep-interval) from the
stale feat/binary-management-and-library-scale branch, ported onto
current main's structure (zod settingsSchema, decomposed NetworkCard).
All default to 0/off, preserving current behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:34:34 -04:00

1024 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared contract between main and renderer.
* IPC channel names + payload types live here so both sides stay in sync.
*/
export const IpcChannels = {
/** the AeroFetch app version (package.json / app.getVersion) */
appVersion: 'app:version',
/** check the configured Gitea repo for a newer AeroFetch release */
appUpdateCheck: 'app:update-check',
/** download the latest release's installer to a temp file */
appUpdateDownload: 'app:update-download',
/** abort the in-flight installer download (B6) */
appUpdateCancel: 'app:update-cancel',
/** launch a downloaded installer and quit so it can replace the app */
appUpdateRun: 'app:update-run',
/** main → renderer push channel for installer download progress */
appUpdateProgress: 'app:update-progress',
ytdlpVersion: 'ytdlp:version',
ffmpegVersion: 'ffmpeg:version',
probe: 'media:probe',
downloadStart: 'download:start',
downloadCancel: 'download:cancel',
downloadPause: 'download:pause',
chooseFolder: 'download:choose-folder',
openPath: 'shell:open-path',
openUrl: 'shell:open-url',
showInFolder: 'shell:show-in-folder',
clipboardRead: 'clipboard:read',
settingsGet: 'settings:get',
settingsSet: 'settings:set',
historyList: 'history:list',
historyAdd: 'history:add',
historyRemove: 'history:remove',
historyRemoveMany: 'history:remove-many',
historyClear: 'history:clear',
/** main → renderer push channel for live download events */
downloadEvent: 'download:event',
cookiesLogin: 'cookies:login',
cookiesStatus: 'cookies:status',
cookiesClear: 'cookies:clear',
templatesList: 'templates:list',
templatesSave: 'templates:save',
templatesRemove: 'templates:remove',
/** Media profiles: named download presets a Source can point at (PINCHFLAT) */
profilesList: 'profiles:list',
profilesSave: 'profiles:save',
profilesRemove: 'profiles:remove',
commandPreview: 'command:preview',
ytdlpUpdate: 'ytdlp:update',
/** main → renderer push channel for background yt-dlp auto-update status */
ytdlpAutoUpdateStatus: 'ytdlp:auto-update-status',
errorLogList: 'errorlog:list',
errorLogClear: 'errorlog:clear',
/** Persisted download queue (M4): saved/scheduled/pending items survive a quit. */
queueList: 'queue:list',
queueSave: 'queue:save',
backupExport: 'backup:export',
backupImport: 'backup:import',
systemThemeGet: 'system-theme:get',
/** main → renderer push channel for OS theme/contrast changes (nativeTheme 'updated') */
systemThemeUpdate: 'system-theme:update',
openHighContrastSettings: 'shell:open-high-contrast-settings',
/** preload → main: the contextBridge failed to expose the API; main shows a
* hard error instead of letting the renderer silently fall back to mock mode (M30) */
preloadBridgeFailure: 'preload:bridge-failure',
/** renderer → main fire-and-forget: forward a renderer-side failure to the main
* diagnostic log so it survives into a packaged build (CC8; the M29 sink half). */
logWrite: 'log:write',
/** main → renderer push channel: a URL handed to AeroFetch from outside the app
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
externalUrl: 'app:external-url',
// --- Sources & media-manager indexing (Pinchflat-style, see ROADMAP-PINCHFLAT.md) ---
sourcesList: 'sources:list',
sourceIndex: 'sources:index',
sourceReindex: 'sources:reindex',
/** renderer → main: abort the in-flight index/reindex walk (B2) */
sourceIndexCancel: 'sources:index-cancel',
sourceRemove: 'sources:remove',
sourceItems: 'sources:items',
/** mark a media item downloaded once its collection download completes */
sourceItemDownloaded: 'sources:item-downloaded',
/** toggle whether a source is watched for new uploads (Phase J) */
sourceSetWatched: 'sources:set-watched',
/** point a source at a MediaProfile (or clear it) */
sourceSetProfile: 'sources:set-profile',
/** prune (delete files of) the given media items for a source (retention) */
sourcePruneItems: 'sources:prune-items',
/** re-index all watched sources and return the videos that are new */
sourcesSync: 'sources:sync',
/** get / set the Windows scheduled-sync task (Task Scheduler) */
scheduledSyncGet: 'sources:scheduled-sync-get',
scheduledSyncSet: 'sources:scheduled-sync-set',
/** main → renderer push channel for live indexing progress */
indexProgress: 'sources:index-progress',
// --- Built-in yt-dlp terminal (Phase N) ---
terminalRun: 'terminal:run',
terminalCancel: 'terminal:cancel',
/** main → renderer push channel for live terminal output lines */
terminalOutput: 'terminal:output',
/** renderer → main: reflect overall queue progress on the Windows taskbar (Phase O) */
taskbarProgress: 'taskbar:progress',
/** renderer → main: open a YouTube WebView and extract a PO token (Phase P) */
youtubePoTokenMint: 'youtube:po-token-mint'
} as const
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
export const BEST_FORMAT_ID = '__best__'
export type MediaKind = 'video' | 'audio'
// --- Post-processing / format options ---------------------------------------
/** Audio extraction target. 'best' keeps the source codec without re-encoding. */
export type AudioFormat = 'best' | 'mp3' | 'm4a' | 'opus' | 'flac' | 'wav' | 'aac'
export const AUDIO_FORMATS: AudioFormat[] = ['best', 'mp3', 'm4a', 'opus', 'flac', 'wav', 'aac']
/** Container the merged video is muxed into (`--merge-output-format`). */
export type VideoContainer = 'mp4' | 'mkv' | 'webm'
export const VIDEO_CONTAINERS: VideoContainer[] = ['mp4', 'mkv', 'webm']
/** Preferred video codec, applied as a `-S vcodec:…` sort tiebreaker. */
export type VideoCodecPref = 'any' | 'h264' | 'vp9' | 'av1'
export const VIDEO_CODECS: VideoCodecPref[] = ['any', 'h264', 'vp9', 'av1']
/** Whether SponsorBlock cuts the segments out or just marks them as chapters. */
export type SponsorBlockMode = 'remove' | 'mark'
/**
* Where yt-dlp's auth cookies come from. 'browser' reads a logged-in browser's
* cookie store directly; 'login' uses a cookie file exported from AeroFetch's
* own built-in sign-in window (see src/main/cookies.ts).
*/
export type CookieSource = 'none' | 'browser' | 'login'
/** Browsers yt-dlp's --cookies-from-browser supports that ship on Windows. */
export const COOKIE_BROWSERS = ['chrome', 'edge', 'firefox', 'brave', 'opera', 'vivaldi'] as const
export type CookieBrowser = (typeof COOKIE_BROWSERS)[number]
/** Accent color presets for the brand ramp (see src/renderer/src/theme.ts). */
export const ACCENT_COLORS = ['rose', 'coral', 'amber', 'teal'] as const
export type AccentColor = (typeof ACCENT_COLORS)[number]
/** Quality/format labels for the video download selector. */
export const VIDEO_QUALITY_OPTIONS = ['Best available', '1080p', '720p', '480p', '360p'] as const
export type VideoQuality = (typeof VIDEO_QUALITY_OPTIONS)[number]
/** Quality/format labels for the audio download selector. */
// Format-agnostic: the audio container/codec is chosen separately (downloadOptions
// .audioFormat); these are just the target quality. Bitrate values are ignored for
// lossless formats (flac/wav) -- see buildArgs (M18/M21).
export const AUDIO_QUALITY_OPTIONS = ['Best', '320 kbps', '192 kbps', '128 kbps'] as const
export type AudioQuality = (typeof AUDIO_QUALITY_OPTIONS)[number]
/**
* Rank a quality label so two can be compared for the retention quality-upgrade
* (higher = better). Works across the video and audio ladders: "Best*" tops each,
* then the numeric resolution/bitrate descends. An unknown label ranks 0 (worst),
* so it never blocks an upgrade to a known-better target. Shared so the selector
* (main) and any UI use one ordering.
*/
export function qualityRank(label: string): number {
const l = label.trim().toLowerCase()
if (l.startsWith('best')) return 100_000
// First number in the label is the resolution (1080) or bitrate (320) — both
// sort correctly as "bigger is better" within their own ladder.
const n = parseInt(l.replace(/[^\d]/g, ''), 10)
return Number.isFinite(n) ? n : 0
}
/** UI color theme: an explicit mode, or 'system' to follow the OS preference. */
export type ThemeMode = 'light' | 'dark' | 'system'
/** OS-level theme signal, read from Electron's nativeTheme in the main process. */
export interface SystemThemeInfo {
/** whether Windows is currently in dark mode */
shouldUseDarkColors: boolean
/** whether a Windows high-contrast theme is active */
shouldUseHighContrastColors: boolean
}
/** SponsorBlock segment categories (subset of yt-dlp's, the ones Seal exposes). */
export const SPONSORBLOCK_CATEGORIES = [
'sponsor',
'intro',
'outro',
'selfpromo',
'preview',
'filler',
'interaction',
'music_offtopic'
] as const
export type SponsorBlockCategory = (typeof SPONSORBLOCK_CATEGORIES)[number]
/**
* Post-processing / format choices applied to a download. The persisted
* defaults live in `Settings.downloadOptions`; an individual download may
* override them via `StartDownloadOptions.options`.
*/
export interface DownloadOptions {
/** audio extraction target (audio downloads only) */
audioFormat: AudioFormat
/** container for merged video downloads */
videoContainer: VideoContainer
/** preferred video codec (sort tiebreaker, not a hard filter) */
preferredVideoCodec: VideoCodecPref
/**
* Advanced: a raw yt-dlp `-S` format-sort string (e.g. 'res:1080,vcodec:av01,size').
* When non-empty it replaces the codec-derived sort, letting power users rank
* resolution / codec / container / size by weighted priority. Empty = use the
* preferredVideoCodec tiebreaker.
*/
formatSort: string
/** download + embed subtitles into video */
embedSubtitles: boolean
/** comma-separated yt-dlp sub-lang selectors, e.g. 'en' or 'en.*,es' */
subtitleLanguages: string
/** also pull auto-generated (ASR) captions */
autoSubtitles: boolean
/** enable SponsorBlock */
sponsorBlock: boolean
sponsorBlockMode: SponsorBlockMode
sponsorBlockCategories: SponsorBlockCategory[]
/** embed chapter markers */
embedChapters: boolean
/** split the download into one file per chapter (--split-chapters) */
splitChapters: boolean
/** embed metadata (title/artist/etc.) */
embedMetadata: boolean
/** override the title tag before embedding; blank = keep the extracted value */
metadataTitle?: string
/** override the artist tag before embedding (audio downloads) */
metadataArtist?: string
/** override the album tag before embedding (audio downloads) */
metadataAlbum?: string
/** embed the thumbnail (cover art for audio, poster for mp4/mkv video) */
embedThumbnail: boolean
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
cropThumbnail: boolean
/** write a .info.json metadata sidecar (Jellyfin/Plex/Kodi ingest — Phase K) */
writeInfoJson: boolean
/** write the thumbnail as a separate image file alongside the video */
writeThumbnailFile: boolean
/** write the video description to a .description sidecar file */
writeDescription: boolean
}
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
audioFormat: 'mp3',
videoContainer: 'mp4',
preferredVideoCodec: 'any',
formatSort: '',
embedSubtitles: false,
subtitleLanguages: 'en',
autoSubtitles: false,
sponsorBlock: false,
sponsorBlockMode: 'remove',
sponsorBlockCategories: ['sponsor', 'selfpromo', 'interaction'],
embedChapters: false,
splitChapters: false,
embedMetadata: true,
embedThumbnail: true,
cropThumbnail: false,
writeInfoJson: false,
writeThumbnailFile: false,
writeDescription: false
}
export interface YtdlpVersionResult {
ok: boolean
/** yt-dlp version string, present when ok is true */
version?: string
/** human-readable error, present when ok is false */
error?: string
}
/**
* Versions of the bundled ffmpeg + ffprobe, for display in Settings. Each is the
* parsed version token, or null when that binary is missing or unreadable. These
* ship with AeroFetch and aren't auto-updated (ffmpeg has no self-update and,
* unlike yt-dlp, doesn't break as sites change), so there's no ok/error here —
* just "what's installed".
*/
export interface FfmpegVersionResult {
ffmpeg: string | null
ffprobe: string | null
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
export interface AppUpdateInfo {
ok: boolean
/** true when latestVersion is newer than the running app */
available: boolean
/** the running app's version, e.g. '0.4.0' */
currentVersion: string
/** the latest release's version (tag without a leading 'v'), when ok */
latestVersion?: string
/** the release notes / changelog body (Markdown), shown before updating */
notes?: string
/** the release's web page on Gitea (for "View release") */
htmlUrl?: string
/** direct download URL of the Windows installer asset, when the release has one */
downloadUrl?: string
/** the installer asset's file name */
assetName?: string
/** human-readable error, when ok is false */
error?: string
}
/** Result of downloading the update installer to a temp file. */
export interface AppUpdateDownload {
ok: boolean
/** absolute path to the downloaded installer, when ok */
filePath?: string
error?: string
}
/** Live progress of the installer download (main → renderer). */
export interface AppUpdateProgress {
/** bytes downloaded so far */
received: number
/** total bytes, when the server reports Content-Length */
total?: number
/** 0..1 fraction, when total is known */
fraction?: number
}
/**
* yt-dlp's self-update release channels (`--update-to <channel>`).
*
* SECURITY (audit F1): `--update-to` also accepts an `OWNER/REPO@TAG` spec, which
* makes yt-dlp download a release binary from an ARBITRARY GitHub repo and
* overwrite the running yt-dlp.exe — i.e. arbitrary, persistent code execution.
* The TypeScript type is erased at runtime and is no defence over IPC, so the
* value must be checked against this allowlist before it ever reaches the flag
* (see isYtdlpUpdateChannel; enforced in src/main/ytdlp.ts).
*/
export const YTDLP_UPDATE_CHANNELS = ['stable', 'nightly'] as const
export type YtdlpUpdateChannel = (typeof YTDLP_UPDATE_CHANNELS)[number]
/** Runtime guard for an untrusted update-channel value crossing the IPC boundary. */
export function isYtdlpUpdateChannel(v: unknown): v is YtdlpUpdateChannel {
return typeof v === 'string' && (YTDLP_UPDATE_CHANNELS as readonly string[]).includes(v)
}
/**
* Runtime guard for a 24h 'HH:MM' sync time crossing the IPC boundary (L51) —
* the value reaches `schtasks /ST`, so anything malformed must never be forwarded.
* Shared so main's settings validation and schedule.ts use one definition.
*/
export function isValidSyncTime(v: unknown): v is string {
return typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v)
}
export interface YtdlpUpdateResult {
ok: boolean
/** yt-dlp's own update output, e.g. "Updated to ... " or "yt-dlp is up to date" */
output?: string
error?: string
}
/**
* Status of a background (startup) yt-dlp auto-update, pushed main → renderer on
* IpcChannels.ytdlpAutoUpdateStatus so the Settings UI can reflect a check that
* ran on its own — no button click needed.
*/
export interface YtdlpAutoUpdateStatus {
/** 'checking' when the update starts; the rest are terminal */
phase: 'checking' | 'updated' | 'current' | 'error'
/** which channel was checked */
channel: YtdlpUpdateChannel
/** yt-dlp version after the run, when known (phases 'updated' | 'current') */
version?: string
/** epoch ms the check finished (terminal phases) — feeds the "last checked" line */
checkedAt?: number
/** error text when phase === 'error' */
error?: string
}
/** A single selectable output format, derived from `yt-dlp -J`. */
export interface FormatOption {
/** yt-dlp format_id, or BEST_FORMAT_ID for the auto-best option */
id: string
/** human label, e.g. '1080p60 · mp4 · 124 MB' */
label: string
height?: number
ext?: string
filesizeLabel?: string
/** true when this format already carries an audio track */
hasAudio: boolean
}
/** Metadata + available formats returned by a probe. */
export interface MediaInfo {
title: string
channel?: string
durationLabel?: string
thumbnail?: string
/** video format options, best-first (audio is transcoded, so not listed) */
formats: FormatOption[]
}
/** One entry of a probed playlist (flat — no per-entry format list). */
export interface PlaylistEntry {
/** 1-based position in the playlist */
index: number
id: string
title: string
/** canonical URL for the entry, enqueued as its own single-video download */
url: string
durationLabel?: string
uploader?: string
}
/** A probed playlist: lightweight metadata for each entry. */
export interface PlaylistInfo {
title: string
uploader?: string
count: number
entries: PlaylistEntry[]
}
/**
* A single probe can resolve to either one video (with formats) or a playlist
* (with entries). `kind` discriminates which field is populated.
*/
export interface ProbeResult {
ok: boolean
kind?: 'video' | 'playlist'
info?: MediaInfo
playlist?: PlaylistInfo
error?: string
}
/**
* Media-manager folder context for a collection download (see ROADMAP-PINCHFLAT.md
* Phase G). When present on a StartDownloadOptions, the file is filed into
* `<outputDir>/<channel>/<playlist>/<NNN> - <title>.<ext>` instead of the flat
* filenameTemplate. The directory segments are sanitized for Windows at argv-build
* time, and `index` (the 1-based playlist position from the persisted MediaItem)
* drives the NNN prefix — not yt-dlp's %(playlist_index)s, which is empty under
* the per-video `--no-playlist` path these downloads still take.
*/
export interface CollectionContext {
/** channel / uploader name — the top folder level */
channel: string
/** playlist title — the second folder level ('Uploads' for the catch-all) */
playlist: string
/** 1-based position within the playlist, for the NNN filename prefix */
index: number
}
/** Sent renderer → main to kick off a download. The renderer owns the id. */
export interface StartDownloadOptions {
id: string
url: string
kind: MediaKind
/** one of the UI quality labels (e.g. '1080p', 'Best') */
quality: string
/** absolute output directory; main falls back to the OS Downloads folder */
outputDir?: string
/** exact yt-dlp format_id chosen from a probe; omit for the preset path */
formatId?: string
/** whether the chosen format already includes audio (skips +bestaudio) */
formatHasAudio?: boolean
/**
* Incognito ("private") download. Main enforces the promise the UI makes ("no
* logging, no history, no cookies"): it writes no errorlog entry, reveals no
* title/URL in an OS notification, and attaches no saved login / browser
* cookies — not just the renderer's history skip (L136 / M6).
*/
incognito?: boolean
/** per-download post-processing override; main falls back to settings defaults */
options?: DownloadOptions
/**
* Per-download custom-command override: raw extra yt-dlp flags appended to
* the generated argv (see CommandTemplate). Omit to fall back to the
* persisted settings default (enableCustomCommands + defaultTemplateId);
* pass an empty string to explicitly run with no extra args for just this
* download even when the settings default is enabled.
*/
extraArgs?: string
/**
* Optional trim: keep only these time ranges. Raw user text — ranges like
* '1:30-2:00', comma- or newline-separated — normalised to yt-dlp
* `--download-sections` specs in buildArgs (parseTrimSections). Empty or
* undefined downloads the whole media.
*/
trim?: string
/**
* Metadata the renderer already fetched when probing the URL (title/channel/
* duration). When present, main uses it directly instead of spawning a second
* yt-dlp `--skip-download` probe — avoiding doubled network traffic and the
* race where the late probe overwrites a good title the renderer supplied.
*/
meta?: DownloadMeta
/**
* When set, this is a media-manager (collection) download — file it into
* `<channel>/<playlist>/<NNN> - <title>` folders instead of the flat
* filenameTemplate. See CollectionContext.
*/
collection?: CollectionContext
/**
* Per-download collection folder-layout override (a Media Profile's
* `outputTemplate`). When set, it overrides `Settings.collectionOutputTemplate`
* for this collection download; omit to fall back to the global setting.
*/
collectionOutputTemplate?: string
}
export interface StartDownloadResult {
ok: boolean
error?: string
}
export interface DownloadMeta {
title?: string
channel?: string
durationLabel?: string
}
export interface DownloadProgress {
/** yt-dlp progress status: 'downloading' | 'finished' | … */
status: string
/** 0..1 (0 when total size is unknown) */
progress: number
/** raw transfer rate in bytes/sec; the renderer formats + aggregates it (H4) */
speedBytesPerSec?: number
/** raw time remaining in seconds; the renderer formats + aggregates it (H4) */
etaSeconds?: number
/** human-readable total size, e.g. '512 MB' */
sizeLabel?: string
/** true when yt-dlp reports no total/estimated size (livestreams, some sites),
* so `progress` stays 0 the whole time — the bar should go indeterminate
* instead of reading a frozen 0% (L137) */
sizeUnknown?: boolean
/** latched true once the first download stream has finished, so the remaining
* stream/merge/post-processing shows as an indeterminate "Finishing…" state
* rather than a second 0→100% fill of the bar (SR7) */
finishing?: boolean
}
/** Discriminated union pushed on IpcChannels.downloadEvent. */
export type DownloadEvent =
| { type: 'meta'; id: string; meta: DownloadMeta }
| { type: 'progress'; id: string; progress: DownloadProgress }
| { type: 'done'; id: string; filePath?: string }
| { type: 'error'; id: string; error: string }
/** The subset of a persisted queue item's status worth surviving a quit. */
export type PersistedQueueStatus = 'queued' | 'downloading' | 'paused' | 'saved' | 'error'
/**
* A queue item persisted across restarts (M4) so saved/scheduled and other pending
* downloads return after a quit. This is the *durable* subset of the renderer's
* `DownloadItem`: runtime-only fields (progress/speed/eta/sizeLabel/finishing) are
* dropped since a restored item re-derives them. Transient results ('completed' /
* 'canceled') are never persisted; on load a 'downloading' item is normalized back
* to 'queued' because its process died with the previous session.
*/
export interface PersistedQueueItem {
id: string
url: string
title: string
channel?: string
durationLabel?: string
thumbnail?: string
kind: MediaKind
quality: string
status: PersistedQueueStatus
filePath?: string
error?: string
formatId?: string
formatHasAudio?: boolean
options?: DownloadOptions
extraArgs?: string
trim?: string
scheduledFor?: number
incognito?: boolean
probedMeta?: DownloadMeta
collection?: CollectionContext
mediaItemId?: string
}
/** Persisted user settings (electron-store). */
export interface Settings {
/** where video downloads are saved; empty string = the default Documents\Video folder */
videoFolder: string
/** where audio downloads are saved; empty string = the default Documents\Audio folder */
audioFolder: string
defaultKind: MediaKind
defaultVideoQuality: string
defaultAudioQuality: string
/** how many downloads run at once */
maxConcurrent: number
/** yt-dlp output template, e.g. '%(title)s.%(ext)s' */
filenameTemplate: string
/**
* Raw yt-dlp `-o` template for COLLECTION downloads (a channel/playlist from
* the Library), overriding the default `Channel/Playlist/NNN - Title` folder
* layout. Empty = the built-in default. A relative template (no absolute path,
* no `..`); yt-dlp expands its own fields (`%(uploader)s`, `%(playlist)s`,
* `%(playlist_index)s`, `%(title)s`, `%(ext)s`, …). PINCHFLAT output-path setting.
*/
collectionOutputTemplate: string
/** UI color theme: explicit light/dark, or 'system' to follow the OS */
theme: ThemeMode
/** brand accent preset (buttons, links, selected nav item) */
accentColor: AccentColor
/** auto-detect video links from the clipboard on window focus */
watchClipboard: boolean
/** default post-processing / format options for new downloads */
downloadOptions: DownloadOptions
/** yt-dlp --proxy value, e.g. 'socks5://127.0.0.1:1080'. Empty disables it. */
proxy: string
/** yt-dlp --limit-rate value, e.g. '2M' or '500K'. Empty means unlimited. */
rateLimit: string
/**
* Request throttle (seconds), to avoid YouTube's "rate-limited for up to an
* hour" / HTTP 403 wall when pulling a whole channel. All default to 0 = off
* (yt-dlp's as-fast-as-possible behaviour):
* - sleepRequests: `--sleep-requests`, a pause between the metadata/extraction
* HTTP requests — the single most effective knob for large channel pulls.
* - sleepInterval / maxSleepInterval: `--sleep-interval` (min) and
* `--max-sleep-interval` (max) bracket a random pause before each download.
* max only applies when it's set above a non-zero min (yt-dlp requires the
* min to be present, and a max below it is meaningless).
*/
sleepRequests: number
sleepInterval: number
maxSleepInterval: number
/** use bundled aria2c (resources/bin/aria2c.exe) as the external downloader */
useAria2c: boolean
/** aria2c parallel connections per download (-x/-s), 116; only used when useAria2c is on (L64) */
aria2cConnections: number
/** where auth cookies come from: none, a browser's store, or the sign-in window */
cookieSource: CookieSource
/** which browser to read cookies from when cookieSource is 'browser' */
cookiesBrowser: CookieBrowser
/**
* YouTube reliability (Phase P). When set, emitted as
* `--extractor-args "youtube:player_client=…;po_token=…"`:
* - playerClient: an alternate extraction client (e.g. 'web_safari', 'tv',
* 'mweb') — a common workaround for YouTube throttling/extraction breakage.
* - poToken: a manually-supplied Proof-of-Origin token for the bot check.
* Both empty = yt-dlp's defaults. (Automatic WebView token minting is deferred;
* this is the argv plumbing it would feed.)
*/
youtubePlayerClient: string
youtubePoToken: string
/** sanitize output filenames to a portable ASCII-only subset (--restrict-filenames) */
restrictFilenames: boolean
/** record completed downloads in a yt-dlp --download-archive file to skip repeats */
useDownloadArchive: boolean
/** keep the managed yt-dlp.exe auto-updated in the background on launch */
autoUpdateYtdlp: boolean
/** channel the auto-updater (and the manual "Update yt-dlp" button) updates to */
ytdlpChannel: YtdlpUpdateChannel
/** epoch ms of the last automatic yt-dlp update check; 0 = never run */
ytdlpLastUpdateCheck: number
/** apply a named custom-command template's extra args to every new download */
enableCustomCommands: boolean
/** id of the CommandTemplate applied when enableCustomCommands is on; null = none picked yet */
defaultTemplateId: string | null
/** show a native OS notification when a download finishes or fails */
notifyOnComplete: boolean
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
autoDownloadNew: boolean
/** daily watched-source sync start time, 24h 'HH:MM' (Task Scheduler /ST) (L51) */
syncTime: string
/** false until the first-run welcome screen has been dismissed */
hasCompletedOnboarding: boolean
/** keep AeroFetch running in the system tray when its window is closed (Phase O) */
minimizeToTray: boolean
/** launch AeroFetch automatically when the user signs in to Windows */
launchAtStartup: boolean
/** whether the navigation sidebar is collapsed to icon-only mode */
isSidebarCollapsed: boolean
/**
* Opt in to GPU compositing (W15; takes effect on next launch). Off by
* default: software rendering is the safe choice for this app's target
* hardware — on some old/locked-down GPUs (the documented GeForce GT 625
* case) Chromium's GPU compositor paints the whole window blank. Users on
* modern GPUs can turn this on for smoother high-DPI/large-window rendering.
*/
useHardwareAcceleration: boolean
/**
* Optional Gitea access token for the in-app updater. Empty = anonymous (works
* only where the release repo allows anonymous access). When the release repo
* is private / the instance requires sign-in, paste a read-only token so the
* updater can check + download. Stored locally in settings, never shipped.
*/
updateToken: string
}
/**
* The canonical default Settings — the single source of truth shared by the main
* electron-store defaults (main/settings.ts), the renderer's pre-load FALLBACK
* (store/settings.ts), and the browser-preview mock (mockApi.ts), so the full
* Settings object is no longer hand-maintained in three places (C1). Preview-only
* tweaks (placeholder folders, skip onboarding, demo channel) are layered on top
* of this at each call site.
*/
export const DEFAULT_SETTINGS: Settings = {
// Both blank by default → downloads land in Documents\Video / Documents\Audio
// (see getDefaultMediaDir). A non-empty value is an explicit per-kind override.
videoFolder: '',
audioFolder: '',
defaultKind: 'video',
defaultVideoQuality: 'Best available',
defaultAudioQuality: 'Best',
maxConcurrent: 2,
filenameTemplate: '%(title)s.%(ext)s',
collectionOutputTemplate: '',
// Follow the OS theme on first launch (SR1) so a dark-mode Windows user isn't
// greeted by a bright white window despite full system-theme support.
theme: 'system',
accentColor: 'teal',
// Off on first launch (SR4): reading the clipboard on every window focus is a
// privacy surprise for a brand-new user. It's a one-toggle opt-in in Settings
// (the onboarding tip points there). Existing installs keep their saved value.
watchClipboard: false,
downloadOptions: DEFAULT_DOWNLOAD_OPTIONS,
proxy: '',
rateLimit: '',
sleepRequests: 0,
sleepInterval: 0,
maxSleepInterval: 0,
useAria2c: false,
aria2cConnections: 16,
cookieSource: 'none',
cookiesBrowser: 'chrome',
youtubePlayerClient: '',
youtubePoToken: '',
restrictFilenames: false,
useDownloadArchive: false,
// yt-dlp is self-managed: a writable copy under userData, auto-updated on the
// configured channel so a stale binary can't silently cause YouTube 403s.
autoUpdateYtdlp: true,
// Default to stable so a nightly regression doesn't break downloads for
// everyone out of the box (SR2). Users who want the latest YouTube fixes
// can switch to nightly in Settings → Software.
ytdlpChannel: 'stable',
ytdlpLastUpdateCheck: 0,
enableCustomCommands: false,
defaultTemplateId: null,
notifyOnComplete: true,
// Default off so adding a watched channel doesn't silently fill the user's
// disk on first use (SR3). Enable explicitly once they know what it does.
autoDownloadNew: false,
syncTime: '09:00',
hasCompletedOnboarding: false,
minimizeToTray: false,
launchAtStartup: false,
isSidebarCollapsed: false,
useHardwareAcceleration: false,
updateToken: ''
}
/**
* A named, reusable set of extra yt-dlp CLI flags (Phase C "custom commands"),
* e.g. name: 'Write thumbnail + no mtime', args: '--write-thumbnail --no-mtime'.
* Shell-split (see parseExtraArgs) and appended to the generated argv just
* before the `--` URL terminator, so they can override earlier flags.
*/
export interface CommandTemplate {
id: string
name: string
args: string
/**
* Optional regex (matched case-insensitively against the download URL). When
* set and custom commands are enabled, this template auto-applies to any URL it
* matches — taking precedence over the global defaultTemplateId. Empty/undefined
* means the template is only applied when explicitly chosen as the default.
*/
urlPattern?: string
}
/** Result of building the exact yt-dlp command line for the current form state, without running it. */
export interface CommandPreviewResult {
ok: boolean
/** full command line (binary + quoted args), for display/copy only */
command?: string
error?: string
}
/** Whether the built-in sign-in window has ever saved a cookie file, and when. */
export interface CookiesStatus {
exists: boolean
/** epoch milliseconds the cookie file was last written */
savedAt?: number
}
/** Result of a built-in sign-in window session, resolved once the window closes. */
export interface CookiesLoginResult {
ok: boolean
/** number of cookies captured into the exported file */
cookieCount?: number
error?: string
}
/** A completed download, persisted to history.json (newest-first). */
export interface HistoryEntry {
id: string
title: string
channel?: string
url: string
kind: MediaKind
/** Quality preset ('720p', 'Best available', 'Best', …) or the probed
* format's display label. Used for display and, when no formatId is present,
* as the fallback quality selector on re-download (H5). */
quality: string
/** Exact yt-dlp format id from the probe picker, carried so re-downloading
* always uses the same format rather than falling back to a preset (H5). */
formatId?: string
/** Whether the probed format already carries its own audio track (H5). */
formatHasAudio?: boolean
filePath?: string
sizeLabel?: string
thumbnail?: string
/** The per-download post-processing options used originally, carried so a
* re-download reproduces the same output (subtitles, SponsorBlock, container,
* codec, …) instead of silently falling back to the current global defaults (L87). */
options?: DownloadOptions
/** epoch milliseconds */
completedAt: number
}
/**
* Cap on persisted history rows. Single-sourced here so the main store (history.ts)
* and the renderer's optimistic in-memory list (store/history.ts, L141) share one
* value instead of two hand-synced copies.
*/
export const HISTORY_MAX_ENTRIES = 500
/**
* A failed download or download-start attempt, persisted to errorlog.json
* (newest-first) so the report survives the queue item being cleared —
* Seal's "debug report" equivalent.
*/
export interface ErrorLogEntry {
id: string
/** resolved video title when known; falls back to the URL in the UI */
title?: string
url: string
kind: MediaKind
error: string
/** epoch milliseconds */
occurredAt: number
}
/** Result of exporting settings + custom-command templates to a JSON file. */
export interface BackupExportResult {
ok: boolean
/** absolute path written to, present when ok is true */
path?: string
error?: string
}
/** Result of restoring settings + custom-command templates from a JSON file. */
export interface BackupImportResult {
ok: boolean
error?: string
}
// --- Sources & media-manager indexing (Pinchflat-style) ---------------------
// See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a
// persisted list of MediaItem records. When the user downloads pending items,
// all selected entries are enqueued at once — add a batch cap here if large
// channel queues (thousands of items) cause performance issues (M33).
/** Whether a Source is a whole channel or a single playlist. */
export type SourceKind = 'channel' | 'playlist'
/**
* A monitored channel or playlist that AeroFetch has indexed. Its full video
* list lives as MediaItem records (one JSON store), keyed back by `id`.
*/
export interface Source {
id: string
/** the URL the user added (used for re-indexing) */
url: string
kind: SourceKind
title: string
/** channel / uploader name, when known */
channel?: string
/** epoch ms the source was first added */
addedAt: number
/** epoch ms of the last successful (re)index; undefined if never indexed */
lastIndexedAt?: number
/** number of MediaItems at the last index (cached for list display) */
itemCount: number
/** watched for new uploads — included in scheduled / startup sync (Phase J) */
watched?: boolean
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
feedUrl?: string
/** id of the MediaProfile applied to this source's downloads; undefined = global defaults */
profileId?: string
}
/**
* A named download preset a Library Source can point at (PINCHFLAT "Media
* Profile"): a reusable bundle of the per-download choices that would otherwise
* come from the global defaults — kind, quality, post-processing options, the
* collection folder layout, and extra yt-dlp args. A Source with a `profileId`
* resolves those fields from the profile first, then falls back to the global
* settings for any field the profile leaves unset (see resolveProfile).
*/
export interface MediaProfile {
id: string
name: string
/** override the download kind (video/audio); unset = global default */
kind?: MediaKind
/** override the quality label ('1080p', 'Best', …); unset = global default */
quality?: string
/** override post-processing options; unset = global default */
options?: DownloadOptions
/** override the collection folder layout (raw yt-dlp -o template); unset = global default */
outputTemplate?: string
/** extra yt-dlp args appended to the argv (like a CommandTemplate's args); unset = none */
extraArgs?: string
}
/**
* One video discovered while indexing a Source. `playlistTitle`/`playlistIndex`
* drive the on-disk folder layout (<Channel>/<Playlist>/<NNN> - <Title>); the
* `downloaded` flag + `filePath` let the library view show per-item state and
* let re-syncs skip what's already on disk.
*/
export interface MediaItem {
/** globally unique, formed as `${sourceId}:${videoId}` */
id: string
sourceId: string
/** the yt-dlp video id — the dedup key within a source */
videoId: string
title: string
url: string
/** the playlist this item is filed under; 'Uploads' for videos in no playlist */
playlistTitle: string
/** 1-based position within its playlist, for NNN numbering */
playlistIndex: number
durationLabel?: string
downloaded: boolean
downloadedAt?: number
filePath?: string
/** the quality label this item was last downloaded at, for the retention
* quality-upgrade check (undefined for items downloaded before this was tracked) */
downloadedQuality?: string
}
/** Live progress pushed while a Source is being indexed (main → renderer). */
export interface IndexProgress {
/** the Source URL being indexed (correlates events back to the request) */
url: string
phase: 'start' | 'playlists' | 'playlist' | 'uploads' | 'done' | 'error'
/** human-readable status line */
message: string
/** for the per-playlist phase: playlists processed so far / total */
current?: number
total?: number
}
/** Result of indexing (or re-indexing) a Source. */
export interface IndexSourceResult {
ok: boolean
source?: Source
itemCount?: number
/** how many videos were new since the previous index (all of them on first index) */
newCount?: number
error?: string
}
/** Result of syncing all watched sources: the videos found new across them. */
export interface SyncResult {
ok: boolean
/** newly-discovered media items across all watched sources (empty if nothing new) */
newItems: MediaItem[]
error?: string
}
/** Whether the Windows scheduled-sync task is currently registered. */
export interface ScheduledSyncStatus {
enabled: boolean
error?: string
}
// --- Built-in yt-dlp terminal (Phase N) -------------------------------------
// A power-user console that runs the bundled yt-dlp with raw, user-typed args
// and streams its output. The binary is fixed to yt-dlp (never arbitrary exes),
// and the feature is gated on the same enableCustomCommands consent flag as the
// per-download extra args (extra args can run code via --exec — audit F2).
/** Live output pushed on IpcChannels.terminalOutput while a terminal command runs. */
export type TerminalEvent =
| { type: 'output'; id: string; line: string; stream: 'stdout' | 'stderr' }
| { type: 'done'; id: string; code: number | null }
| { type: 'error'; id: string; error: string }
/** Result of starting a terminal command (pre-spawn validation only). */
export interface TerminalRunResult {
ok: boolean
error?: string
}
/**
* Extract the `URL=` value from a Windows Internet Shortcut (.url) file's content.
* Returns the raw URL string (untrimmed), or null if not found.
* Callers validate the result with their own http/https check.
*/
export function parseUrlShortcutContent(text: string): string | null {
const m = /^\s*URL\s*=\s*(\S+)/im.exec(text)
return m?.[1]?.trim() ?? null
}
/** Overall queue state for the Windows taskbar progress bar (Phase O). */
export interface TaskbarProgress {
/** 0..1 overall fraction; ignored when mode is 'none' */
fraction: number
/** taskbar bar mode: hidden, normal, or red (some failed) */
mode: 'none' | 'normal' | 'error'
/** active download count for the overlay badge; absent or 0 = clear badge */
badgeCount?: number
}