Treat this file as a living cache. Whenever you discover something non-obvious that a future session would otherwise have to re-derive (an engine API shape, a gotcha, a settled design choice), append a concise boilerplate note here. The goal is steadily fewer tokens per session: each future run should read the answer here instead of re-grepping the codebase or the sibling ../Downloader engine. Keep additions short and factual — a few lines, not essays. Prune notes that become wrong. This is an explicit standing instruction from the author.
Skip the discovery grep; jump straight to the file. src/Downloader.Desktop/:
-
Plan-part completion must NOT gate on DownloadPart.ExpectedSize (DownloadManager.Plans.cs): for extracted streams (progressive/video/audio via yt-dlp) ExpectedSize is filesize_approx — an estimate (e.g. x.com reported 5.36 MB, real file 3.66 MB). An exact len==ExpectedSize gate made a finished part look unfinished → "Part 1/1 did not finish downloading" and an infinite re-download. IsPartComplete/PartDownloadedOk/MarkPartDone now rely on the .done marker + non-empty file (the engine already validates the full download and reports errors via the completion event). ExpectedSize is kept only for progress display. x.com/YouTube page-URL downloads work directly — the HLS plugin (com.bezzad.hls, now in-repo at src/Downloader.Desktop.Plugins/Downloader.Desktop.Plugins.Hls, an optional/catalog-tier plugin — was the separate ../Downloader.Plugins repo, consolidated in; see the "Plugin consolidation" note near the end) runs yt-dlp, no browser-extension .m3u8 hunting needed. yt-dlp runs bare (no cookies) so public content works but login-gated/age-restricted media would need --cookies-from-browser (future work). Two plugin bugs fixed in v1.1.1 (root-relative segments becoming file:// on Unix; codecless progressive MP4s skipped — see Downloader.Plugins issue #2). YouTube (plugin v1.1.2): needs BOTH browser cookies (bot check — plugin retries --cookies-from-browser per installed browser) AND a deno JS runtime (--js-runtimes deno:<path>, auto-provisioned like yt-dlp/ffmpeg) — without deno yt-dlp can't solve the "n challenge" and returns ONLY storyboard images (no formats). Node ≤20 is "unsupported" by yt-dlp's EJS solver — don't bother with it; deno is the supported default (see Downloader.Plugins issue #3).
-
HttpClientTimeout is the WHOLE-request timeout (HttpClient.Timeout), incl. reading a chunk's body — keep it large (default 100 s). Setting it small (e.g. 10 s) makes longer chunks fail with "Operation Cancelled" after retries (~1 min). Per-block stalls are handled by BlockTimeout, not this.
-
Cancellation vs failure status: the engine raises DownloadFileCompleted with Cancelled=true for BOTH a user pause/stop and an internal abort (e.g. timeout). Disambiguate by the status we set before calling the engine: if it's already Paused/Stopped it was the user; a cancel while still Running = real failure → mark Failed.
-
"File already exists" is NOT a failure (FileExistPolicy=IgnoreDownload, the app default): when the target already exists the engine skips the download — it SendDownloadCompletionSignal(Stopped) (so DownloadFileCompleted arrives Cancelled=true, Error=null) and never fires DownloadStarted. That used to be misread as a timeout failure. The manager now calls TryMarkAlreadyExists in the cancelled branch: if DownloadManager.LooksAlreadyDownloaded(policy, path) (policy==IgnoreDownload && the resolved file exists on disk), it backfills name/folder/size from that file and marks the row Completed with DownloadItemViewModel.AlreadyExisted=true (StatusText → State_Exists "Already downloaded"; still IsCompleted, green bar). The final path comes from (e.UserState as DownloadPackage)?.FileName (the event's userState IS the DownloadPackage) or vm.Download.Package.FileName. AlreadyExisted is reset in Start. No new enum state was added (would ripple through filters/converters) — it's a Completed row with a display flag.
-
Queued-item file names: the engine only resolves the name once a download starts, so queue-capped items show no name. UrlResolver.ResolveFileNameAsync (Content-Disposition → URL path) fills a VM-only PreviewName in the background; don't write it to DownloadItem.FileName or it gets forced on the engine.
-
Integration test pattern: spin up a loopback HttpListener with Range/206 support and download through a real DownloadService — no external network, CI-safe (see IntegrationTests).
-
UI progress coalescing (perf — main-thread budget): do NOT marshal each engine DownloadProgressChanged to the UI (with N downloads × M connections that floods the dispatcher and makes the grid lag). Handlers call vm.StageProgress(...) (plain fields, any thread, no UI touch); a single DispatcherTimer in DownloadManager (EnsureUiPump, 250 ms) flushes all rows via vm.FlushProgress() and fires StatsChanged once per tick. The pump self-stops when no row is Running. FlushProgress drops staged values unless Status==Running, so a paused row keeps its last fill. This bounds main-thread work regardless of download count — keep it; don't re-add per-event Dispatcher.UIThread.Post.
-
Queue concurrency cap — single choke point: Start(vm) is the uncapped primitive and must only be reached via PumpQueue. Every user-facing start path (Resume, Retry, StartAll, bulk StartSelected → Resume, Add(autoStart), completion's TryStartNextInQueue) must re-queue the item (set Status=Created) and call PumpQueue, which starts/resumes only while running < MaxConcurrent. PumpQueue handles both Paused (resume in place) and Created/None (start fresh), paused first. Regression to watch: making Resume/StartAll call Start directly bypasses the cap (e.g. select 10 with cap 2 → all 10 ran). Start sets Status=Running synchronously before its first await, so PumpQueue's running recount is correct mid-loop.
-
Cap value lives on DownloadQueue.MaxConcurrent, but the user sets it via Settings (DownloadSettings.MaxConcurrentDownloads). These are TWO fields — MaxConcurrentDownloads historically only seeded new queues, so changing it never limited anything (the real bug behind "I set max 2 but 10 ran"). They're now kept in lockstep for the primary/default queue (Config.DefaultQueue = Queues[0]): SettingViewModel.MaxConcurrentDownloads setter writes it through to DefaultQueue.MaxConcurrent + PumpQueue; DownloadManager.Initialize re-syncs the default queue from the setting on load (fixes stale saved configs); and the Queues page (QueueRowViewModel.MaxConcurrent) mirrors edits of the default queue back into the setting. Extra (non-default) queues keep their own caps. So enforcement reads queue.MaxConcurrent, but the default queue's value always equals the Settings number.
-
State transitions must be guarded in the manager, not just the buttons: per-row buttons gate via IsVisible/Can*, but bulk actions (StopSelected→Cancel, StartSelected→Resume) apply to every selected row regardless of state. So the guards live in DownloadManager (the single choke point that all callers — buttons, bulk, scheduler, pump — go through):
Pause no-ops unless Running.
Cancel (= "Stop") acts on Running/Paused and queued (Created/None) → all become Stopped; it no-ops only for terminal/idle states (Completed/Failed/already-Stopped). Stopping the queued rows too is essential: otherwise stopping the running rows fires DownloadFileCompleted→TryStartNextInQueue and the pump immediately starts the next queued rows ("select all → Stop: 3 stop, 3 start"). The StopSelected loop runs synchronously before any completion callback is posted, so by the time the pump runs there are no queued rows left to start. (Do not restrict Cancel to Running/Paused only — that reintroduces the bug.)
Resume/Start no-op if Running/Completed; Retry only acts on Failed/Stopped. Prevents re-running a completed download from 0% and stray double-Start (a second engine reporting from 0% → "100% then begins again from 0").
Keep state-machine rules in the manager methods, not scattered across VMs/views.
-
StartQueue must re-queue Stopped/Failed: PumpQueue only picks up Paused/Created/None, so StartQueue/StartAll must first flip Stopped (and Failed, for StartQueue) rows to Created — otherwise "Start queue → " after a Stop/Stop-all does nothing (rows are Stopped). StartQueue does this in a RunBatch then pumps.
-
Completed rows always show 100%: don't compute a completed row's bar from Downloaded/Size — a file that already existed on disk is Completed with Downloaded=0, and that read 0% (esp. after restart). The DownloadItemViewModel ctor forces _progress=100 when Status==Completed, the Status setter sets Progress=100 on transition to Completed, and the already-exists handler persists Downloaded=fileLength.
-
Status badge colors live in Converters/StatusToBrushConverter: Running teal, Completed green, Failed red, Paused amber, Stopped neutral-gray, Queued steel-blue (#4F6D9C — deliberately distinct from Stopped's gray so a waiting vs stopped row is tellable apart). Badge shows for every state except Running (which shows live %); DownloadItemViewModel.ShowStatusBadge = Status != Running.
-
Queues page = a real queue manager (Views/QueuesView.axaml, ViewModels/QueuesViewModel.cs): per-queue card shows live aggregate stats (RunningCount/WaitingCount/DoneCount/FailedCount, TotalSpeedText, SummaryText) + a combined OverallProgress bar (average of item Progress), a run/pause ToggleSwitch, the concurrency cap, and the queue's downloads with per-item progress + pause/resume/retry/cancel/remove + reorder (ChevronUp/Down → manager.MovePriority(vm, ±1)) + move-between-queues (a MenuFlyout of QueueMoveTargets → manager.MoveToQueue(vm, queueId), hidden when only one queue). Rows are wrapped in QueueItemViewModel (holds the real DownloadItemViewModel as Item + the reorder/move commands); the card's Items is an ObservableCollection<QueueItemViewModel> rebuilt on ListChanged (order = master Items order = pump priority), and aggregates refresh on both ListChanged and StatsChanged (live). QueueRowViewModel.Detach() unsubscribes both events. Pump order follows master-list order, so MovePriority just Items.Moves past the same-queue neighbour. Initialize now backfills QueueId=DefaultQueue.Id for items saved without one (older configs) so they always appear on a queue. DownloadItemViewModel.FormatBytes is now public static for reuse.
Then verify the PNGs by viewing them. Capture uses the real App with .UseSkia().UseHeadless(UseHeadlessDrawing=false).