Files
AeroFetch/src/main/indexer.ts
T
debont80 e19f988c72 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>
2026-07-01 21:36:22 -04:00

248 lines
9.2 KiB
TypeScript

/**
* Source indexing orchestration (Pinchflat-style media manager; see
* ROADMAP-PINCHFLAT.md). Walks a channel (its /playlists + /videos tabs) or a
* single playlist with `yt-dlp --flat-playlist`, merges the result into a deduped
* MediaItem list, and persists it as a Source. The download step (Phase G+) then
* pulls from that persisted list rather than the live queue holding it all.
*
* The pure URL-classification + merge logic lives in indexerCore.ts (unit-tested);
* this module is the impure shell that spawns yt-dlp and writes to disk.
*/
import { existsSync } from 'fs'
import { getYtdlpPath, YTDLP_MISSING_MSG } from './binaries'
import { execFileAsync } from './lib/exec'
import { INDEX_MAX_BUFFER, INDEX_TIMEOUT_MS } from './constants'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import {
classifySource,
buildMediaItems,
buildFeedUrl,
entryUrl,
stripTabSuffix,
stableSourceId,
type RawEntry,
type NamedPlaylist
} from './indexerCore'
import { getSource, upsertSource, mergeMediaItems } from './sources'
import type { IndexProgress, IndexSourceResult, Source, SourceKind } from '@shared/ipc'
/** The slice of `yt-dlp -J --flat-playlist` output the indexer reads. */
interface FlatInfo {
_type?: string
id?: string
title?: string
uploader?: string
channel?: string
/** the UC… channel id, used to build the RSS feed URL */
channel_id?: string
entries?: RawEntry[]
}
/**
* Run `yt-dlp -J --flat-playlist` on a URL and parse the JSON. Rejects on a
* non-zero exit or unparseable output. maxBuffer is large because a big channel's
* 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, signal?: AbortSignal): Promise<FlatInfo> {
const r = await execFileAsync(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
// `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(
r.timedOut
? 'Timed out indexing source. Check the link or your connection.'
: cleanError(r.stderr) || r.error?.message || ''
)
}
try {
return JSON.parse(r.stdout) as FlatInfo
} catch {
throw new Error('Could not parse source info from yt-dlp.')
}
}
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns 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)
}
}
/**
* Index (or re-index) a Source. Resolves to the persisted Source + item count, or
* an error. `onProgress` is invoked throughout so the renderer can show a live
* status line — it's best-effort and never affects the result.
*/
export async function indexSource(
url: string,
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)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
if (!existsSync(getYtdlpPath())) {
return { ok: false, error: YTDLP_MISSING_MSG }
}
const cls = classifySource(url)
onProgress({ url, phase: 'start', message: 'Reading source…' })
try {
let kind: SourceKind
let title: string
let channel: string | undefined
let feedId: string | undefined
const playlists: NamedPlaylist[] = []
let uploads: RawEntry[] = []
if (cls?.kind === 'channel') {
kind = 'channel'
// 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`, 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
onProgress({
url,
phase: 'playlist',
message: `Indexing playlist ${done}/${total}: ${pe.title ?? ''}`.trim(),
current: done,
total
})
const pdata = await probeTab(purl, signal)
if (pdata?.entries?.length) {
playlists.push({ title: pe.title || 'Playlist', entries: pdata.entries })
}
}
// 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`, signal)
if (signal?.aborted) return canceled
uploads = videoTab?.entries ?? []
title =
stripTabSuffix(videoTab?.channel) ||
stripTabSuffix(videoTab?.title) ||
stripTabSuffix(playlistTab?.title) ||
cls.base
channel = stripTabSuffix(videoTab?.channel || videoTab?.uploader) || title
feedId = videoTab?.channel_id || playlistTab?.channel_id
} else {
// A single playlist (classified) — or an unclassified URL that might still
// 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, signal)
if (signal?.aborted) return canceled
const entries = data.entries ?? []
if (entries.length === 0) {
return {
ok: false,
error:
'That link is a single video, not a channel or playlist. Use the download bar for one video.'
}
}
title = data.title || 'Playlist'
channel = data.uploader || data.channel
// A playlist feed keys off the playlist id (PL…), carried as `id` here.
feedId = data.id
// File everything under the playlist's own name (not the 'Uploads' fallback).
playlists.push({ title, entries })
}
const sourceId = stableSourceId(cls?.base ?? url.trim())
const fresh = buildMediaItems(sourceId, playlists, uploads)
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.
const { items, newCount } = mergeMediaItems(sourceId, fresh)
const prev = getSource(sourceId)
const source: Source = {
id: sourceId,
url: url.trim(),
kind,
title,
channel,
addedAt: prev?.addedAt ?? Date.now(),
lastIndexedAt: Date.now(),
itemCount: items.length,
// Preserve the watched flag across a re-index; refresh the RSS feed URL.
watched: prev?.watched,
feedUrl: buildFeedUrl(kind, feedId) ?? prev?.feedUrl
}
upsertSource(source)
const newNote = newCount === items.length ? '' : ` (${newCount} new)`
onProgress({
url,
phase: 'done',
message: `Indexed ${items.length} videos${newNote}.`,
total: items.length
})
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 }
}
}