feat(audit): Batch 15 live-verify — download-engine lifecycle (R4, L65, L139, B2, B6, L143)
The watched live session for the six deferred lifecycle items, each verified against real yt-dlp/Electron behavior on Windows: - R4: cancel now deletes the download's orphaned partials. The engine captures the final output path via a new `--print before_dl:dest|%(filename)s` (empirically %(filepath)s is still 'NA' that early) and the close handler sweeps only unambiguous intermediates (.part / .part-Frag* / .ytdl / <stem>.fNNN.<ext>) via the pure, unit-tested lib/partials.ts — never a bare <stem>.<ext> or another download's files. Live-verified with decoys. - L65: verified-acceptable, no code change — a taskkill /F pause leaves the .part byte-intact and yt-dlp --continue resumes at the exact byte offset. - L139: spawn↔self-update interlock (new ytdlpLock.ts). The updater refuses while any yt-dlp spawn is live; download/terminal IPC handlers hold (await) behind an in-progress exe rewrite instead of failing. - B2: source indexing is cancellable — AbortSignal through the probe walk (kills the in-flight child, live-verified via tasklist), sources:index-cancel IPC, and a Cancel button in the Library add-source row. - B6: app-update download is cancellable — app:update-cancel routes through the existing finish() teardown (abort + destroy + unlink, live-verified on Electron net.request); Cancel button under the update progress bar. The checksum phase is cancelable too. - L143: closing the window mid-download (non-tray mode) now offers a native tray-independent "Quit anyway / Keep downloading" dialog, replacing the passive "use the tray to quit" notification. Peer-review pass caught and fixed: a non-reentrant update lock (concurrent begin clobbered the settle promise), a dead Cancel window during the B6 checksum fetch + a canceller slot-ownership bug (L140 shape), and a persist-after-cancel hole in the index walk. typecheck + 304 tests + eslint + production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+53
-9
@@ -46,11 +46,13 @@ interface FlatInfo {
|
||||
* flat upload list can be a few MB of JSON; the timeout is generous for the same
|
||||
* reason. `--` terminates option parsing so the URL can't be read as a flag.
|
||||
*/
|
||||
async function probeFlat(url: string): Promise<FlatInfo> {
|
||||
async function probeFlat(url: string, signal?: AbortSignal): Promise<FlatInfo> {
|
||||
const r = await execFileAsync(
|
||||
getYtdlpPath(),
|
||||
['-J', '--flat-playlist', '--no-warnings', '--', url],
|
||||
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS }
|
||||
// `signal` lets a Cancel abort the in-flight probe child mid-walk (B2); Node
|
||||
// kills the process and the call rejects, which the caller treats as canceled.
|
||||
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS, signal }
|
||||
)
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
@@ -67,8 +69,36 @@ async function probeFlat(url: string): Promise<FlatInfo> {
|
||||
}
|
||||
|
||||
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
|
||||
function probeTab(url: string): Promise<FlatInfo | null> {
|
||||
return probeFlat(url).catch(() => null)
|
||||
function probeTab(url: string, signal?: AbortSignal): Promise<FlatInfo | null> {
|
||||
return probeFlat(url, signal).catch(() => null)
|
||||
}
|
||||
|
||||
// Active user-initiated index walks. `cancelIndexing()` aborts them so an
|
||||
// `index:cancel` IPC can stop the walk and kill the in-flight probe child (B2).
|
||||
// Background sync (sync.ts) calls indexSource without a signal, so it isn't
|
||||
// user-cancelable — it's throttled and unattended, not a blocking UI action.
|
||||
const activeIndexAborts = new Set<AbortController>()
|
||||
|
||||
/** Abort every in-flight user index/reindex walk (the add-source "Cancel" button). */
|
||||
export function cancelIndexing(): void {
|
||||
for (const c of activeIndexAborts) c.abort()
|
||||
}
|
||||
|
||||
/**
|
||||
* indexSource wrapped with a fresh AbortController registered for cancelIndexing().
|
||||
* The IPC layer calls this; sync.ts calls the raw indexSource (not cancelable).
|
||||
*/
|
||||
export async function indexSourceCancelable(
|
||||
url: string,
|
||||
onProgress: (p: IndexProgress) => void
|
||||
): Promise<IndexSourceResult> {
|
||||
const controller = new AbortController()
|
||||
activeIndexAborts.add(controller)
|
||||
try {
|
||||
return await indexSource(url, onProgress, controller.signal)
|
||||
} finally {
|
||||
activeIndexAborts.delete(controller)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,8 +108,14 @@ function probeTab(url: string): Promise<FlatInfo | null> {
|
||||
*/
|
||||
export async function indexSource(
|
||||
url: string,
|
||||
onProgress: (p: IndexProgress) => void
|
||||
onProgress: (p: IndexProgress) => void,
|
||||
signal?: AbortSignal
|
||||
): Promise<IndexSourceResult> {
|
||||
// Returned whenever a Cancel arrives mid-walk. It deliberately does NOT push a
|
||||
// progress event: the renderer drives the cancel and resets its own indexing UI,
|
||||
// so a late 'error' event can't re-surface as a failure (B2).
|
||||
const canceled: IndexSourceResult = { ok: false, error: 'Indexing canceled.' }
|
||||
|
||||
let normalizedUrl: string
|
||||
try {
|
||||
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
|
||||
@@ -106,11 +142,13 @@ export async function indexSource(
|
||||
|
||||
// 1. Enumerate the channel's playlists, then each playlist's videos.
|
||||
onProgress({ url, phase: 'playlists', message: 'Finding playlists…' })
|
||||
const playlistTab = await probeTab(`${cls.base}/playlists`)
|
||||
const playlistTab = await probeTab(`${cls.base}/playlists`, signal)
|
||||
if (signal?.aborted) return canceled
|
||||
const playlistEntries = playlistTab?.entries ?? []
|
||||
const total = playlistEntries.length
|
||||
let done = 0
|
||||
for (const pe of playlistEntries) {
|
||||
if (signal?.aborted) return canceled // stop before the next probe
|
||||
done++
|
||||
const purl = entryUrl(pe)
|
||||
if (!purl) continue
|
||||
@@ -121,7 +159,7 @@ export async function indexSource(
|
||||
current: done,
|
||||
total
|
||||
})
|
||||
const pdata = await probeTab(purl)
|
||||
const pdata = await probeTab(purl, signal)
|
||||
if (pdata?.entries?.length) {
|
||||
playlists.push({ title: pe.title || 'Playlist', entries: pdata.entries })
|
||||
}
|
||||
@@ -129,7 +167,8 @@ export async function indexSource(
|
||||
|
||||
// 2. The full uploads feed — the catch-all for videos in no playlist.
|
||||
onProgress({ url, phase: 'uploads', message: 'Indexing channel uploads…' })
|
||||
const videoTab = await probeTab(`${cls.base}/videos`)
|
||||
const videoTab = await probeTab(`${cls.base}/videos`, signal)
|
||||
if (signal?.aborted) return canceled
|
||||
uploads = videoTab?.entries ?? []
|
||||
|
||||
title =
|
||||
@@ -144,7 +183,8 @@ export async function indexSource(
|
||||
// resolve to a playlist when probed. A lone video has no entries → error.
|
||||
kind = cls?.kind ?? 'playlist'
|
||||
onProgress({ url, phase: 'uploads', message: 'Indexing playlist…' })
|
||||
const data = await probeFlat(cls?.base ?? normalizedUrl)
|
||||
const data = await probeFlat(cls?.base ?? normalizedUrl, signal)
|
||||
if (signal?.aborted) return canceled
|
||||
const entries = data.entries ?? []
|
||||
if (entries.length === 0) {
|
||||
return {
|
||||
@@ -166,6 +206,9 @@ export async function indexSource(
|
||||
if (fresh.length === 0) {
|
||||
return { ok: false, error: 'No downloadable videos found for this source.' }
|
||||
}
|
||||
// A cancel that landed after the final probe must not persist the source —
|
||||
// otherwise a "canceled" add still pops into the library later (B2).
|
||||
if (signal?.aborted) return canceled
|
||||
|
||||
// Incremental merge: preserve the downloaded state of anything already on
|
||||
// disk, and report how many videos are new since the last index.
|
||||
@@ -196,6 +239,7 @@ export async function indexSource(
|
||||
})
|
||||
return { ok: true, source, itemCount: items.length, newCount }
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return canceled // the throw was our own abort — not an error
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
onProgress({ url, phase: 'error', message: msg })
|
||||
return { ok: false, error: msg }
|
||||
|
||||
Reference in New Issue
Block a user