/** * 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', /** 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', defaultFolder: 'download:default-folder', chooseFolder: 'download:choose-folder', openPath: 'shell:open-path', 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', 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', /** 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: 'external-url', // --- Sources & media-manager indexing (Pinchflat-style, see ROADMAP-PINCHFLAT.md) --- sourcesList: 'sources:list', sourceIndex: 'sources:index', sourceReindex: 'sources:reindex', 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' } 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] /** 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 /** 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'], 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 `). * * 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) } 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 * `/// - .<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 (MP3)') */ 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 /** 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 (customCommandEnabled + 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 /** human-readable, e.g. '4.7 MB/s' */ speed?: string /** human-readable, e.g. '1:12' */ eta?: string /** human-readable total size, e.g. '512 MB' */ sizeLabel?: string } /** 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 } /** Persisted user settings (electron-store). */ export interface Settings { /** where video downloads are saved; empty string = the default Documents\Video folder */ videoDir: string /** where audio downloads are saved; empty string = the default Documents\Audio folder */ audioDir: 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 */ clipboardWatch: 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 /** 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 /** 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 */ downloadArchive: 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 */ customCommandEnabled: boolean /** id of the CommandTemplate applied when customCommandEnabled 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 /** false until the first-run welcome screen has been dismissed */ hasCompletedOnboarding: boolean } /** * 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: string filePath?: string sizeLabel?: string thumbnail?: string /** epoch milliseconds */ completedAt: number } /** * 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; the live download queue then pulls from // that list a batch at a time, so "download an entire channel" never has to hold // the whole collection in the queue at once. /** 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 customCommandEnabled 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 }