/** * 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', 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', 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', /** 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', 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 `). * * 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') */ 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 /** 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 } /** 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 /** * 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 */ 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 /** 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 */ sidebarCollapsed: 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. videoDir: '', audioDir: '', 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', clipboardWatch: true, downloadOptions: DEFAULT_DOWNLOAD_OPTIONS, proxy: '', rateLimit: '', useAria2c: false, cookieSource: 'none', cookiesBrowser: 'chrome', youtubePlayerClient: '', youtubePoToken: '', restrictFilenames: false, downloadArchive: 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, customCommandEnabled: 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, hasCompletedOnboarding: false, minimizeToTray: false, launchAtStartup: false, sidebarCollapsed: 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 /** 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 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 } /** * 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 }