134c6a167c
CC1: customCommandEnabled→enableCustomCommands, downloadArchive→ useDownloadArchive, clipboardWatch→watchClipboard, sidebarCollapsed→ isSidebarCollapsed, hardwareAcceleration→useHardwareAcceleration, videoDir/audioDir→videoFolder/audioFolder (+ chooseDir/clearDir store actions → chooseFolder/clearFolder). Rename map in settingsMigration.ts (pure, unit-tested incl. pre-rename backup fixture), applied as an idempotent on-disk shim at store open and on backup import so old settings.json and old backups both keep working. CC11: update host/owner/repo + release API moved to src/main/config.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
940 lines
37 KiB
TypeScript
940 lines
37 KiB
TypeScript
/**
|
||
* 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',
|
||
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',
|
||
/** 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]
|
||
|
||
/** 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
|
||
}
|
||
|
||
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
|
||
/** 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
|
||
/** use bundled aria2c (resources/bin/aria2c.exe) as the external downloader */
|
||
useAria2c: boolean
|
||
/** aria2c parallel connections per download (-x/-s), 1–16; 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',
|
||
// 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: '',
|
||
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
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|
||
|
||
/** 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
|
||
}
|