feat(audit): Batch 25 — project reorg (CC12), leading DI args (CC7), CC10 by-design

CC12: renderer views/ (5 screens + Onboarding + settings cards) +
lib/ (theme/thumb/datetime/id/qualityOptions/useClipboardLink/queueStats);
main core/ (buildArgs/validation/indexerCore/ytdlpPolicy). git mv preserved
history; all src+test imports updated. Build emits view chunks from views/,
344 tests + typecheck green, live probe rendered all screens.
CC7: wc/onProgress is the leading arg everywhere — downloadAppUpdate(wc,url),
indexSource(onProgress,url,signal), indexSourceCancelable(onProgress,url).
CC10: closed by-design — jsonStore backs all records; settings stay in
electron-store for DPAPI secret encryption (a deliberate two-store split).
Also closes the UI24/W4 context-menu cross-reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:37:28 -04:00
parent f45acb645b
commit 9134e7d216
70 changed files with 160 additions and 151 deletions
+249
View File
@@ -0,0 +1,249 @@
/**
* Pure, dependency-free core for Source indexing (Pinchflat-style media manager;
* see ROADMAP-PINCHFLAT.md). Like buildArgs.ts / validation.ts this module imports
* nothing from electron or the node runtime, so its URL-classification and
* item-merge logic can be unit-tested without spinning up Electron.
*
* The impure orchestration (spawning yt-dlp, persisting to disk) lives in
* indexer.ts, which composes these helpers.
*/
import type { MediaItem, SourceKind } from '@shared/ipc'
// --- yt-dlp --flat-playlist entry shape (shared with probe.ts) --------------
/** The slice of a flat-playlist entry we read (a video, or a nested playlist). */
export interface RawEntry {
id?: string
title?: string
url?: string
webpage_url?: string
duration?: number
uploader?: string
channel?: string
}
/**
* Resolve the canonical URL for a flat-playlist entry. Prefer an explicit http(s)
* URL; otherwise build a YouTube watch URL from the id (the common case where flat
* entries carry only an id). Returns null when nothing usable is present.
*
* Note: the returned value is always either an http(s) URL or null, so it can
* never begin with '-' and be mis-read as a yt-dlp option (callers also pass `--`).
*/
export function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
// e.id is untrusted JSON from yt-dlp, so percent-encode it rather than splicing
// it raw into the query string (audit T2). A normal 11-char id is unaffected.
if (e.id) return `https://www.youtube.com/watch?v=${encodeURIComponent(e.id)}`
return null
}
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat entries give duration as a number. */
export function fmtDuration(sec?: number): string | undefined {
if (sec == null || !Number.isFinite(sec)) return undefined
const s = Math.max(0, Math.round(sec))
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
const mm = h ? String(m).padStart(2, '0') : String(m)
return `${h ? `${h}:` : ''}${mm}:${String(r).padStart(2, '0')}`
}
// --- Source-URL classification ----------------------------------------------
/** A classified Source URL: the kind, plus a normalised base for tab probing. */
export interface SourceClass {
kind: SourceKind
/**
* For 'channel': the channel root (e.g. https://www.youtube.com/@handle) onto
* which '/videos' and '/playlists' tabs are appended. For 'playlist': the
* canonical playlist URL.
*/
base: string
}
/**
* Classify a pasted URL as a YouTube channel, a playlist, or neither.
*
* Channel forms: /@handle, /channel/<id>, /c/<name>, /user/<name> — any trailing
* tab (/videos, /playlists, /streams, /shorts, /featured) is stripped to the root.
* Playlist form: any URL carrying a `list=` query param. Returns null for a lone
* video or a non-YouTube URL (Phase F scopes the channel walk to YouTube, whose
* /videos + /playlists tab structure this relies on).
*/
export function classifySource(raw: string): SourceClass | null {
let u: URL
try {
u = new URL((raw ?? '').trim())
} catch {
return null
}
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
const isYouTube = host === 'youtube.com' || host.endsWith('.youtube.com') || host === 'youtu.be'
// A playlist is identified purely by its list= param (works on any youtube host).
const list = u.searchParams.get('list')
if (isYouTube && list) {
return {
kind: 'playlist',
base: `https://www.youtube.com/playlist?list=${encodeURIComponent(list)}`
}
}
if (!isYouTube) return null
// Channel roots. The handle/id segment is captured; later path segments
// (the tab) are discarded so we always probe from the channel root.
const m = u.pathname.match(/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)/i)
if (m) return { kind: 'channel', base: `https://www.youtube.com/${m[1]}` }
return null
}
/**
* Channel tab titles from yt-dlp often look like "<Name> - Videos" or
* "<Name> - Playlists". Strip a trailing known-tab suffix to recover the bare
* channel name. Leaves anything else untouched.
*/
export function stripTabSuffix(title: string | undefined): string | undefined {
if (!title) return title
return title.replace(/\s[-–—]\s(Videos|Playlists|Shorts|Live|Streams|Home|Featured)$/i, '').trim()
}
/**
* Deterministic 32-bit FNV-1a hash → 8 hex chars. Used to derive a stable Source
* id from its normalised base URL so re-indexing the same channel updates the
* existing record instead of creating a duplicate. Pure (no crypto import).
*/
export function stableSourceId(base: string): string {
let h = 0x811c9dc5
for (let i = 0; i < base.length; i++) {
h ^= base.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return (h >>> 0).toString(16).padStart(8, '0')
}
// --- RSS fast-check (Phase J: watched sources) ------------------------------
/**
* Build a YouTube RSS feed URL for cheap "anything new?" polling of a watched
* source. Channels feed by `channel_id` (UC…), playlists by `playlist_id` (PL…).
* Returns undefined when the id is missing (the sync then falls back to a full
* re-index). The feed only carries the latest ~15 uploads, so it's a freshness
* check, not a substitute for the initial full index.
*/
export function buildFeedUrl(kind: SourceKind, ytId: string | undefined): string | undefined {
if (!ytId) return undefined
const param = kind === 'channel' ? 'channel_id' : 'playlist_id'
return `https://www.youtube.com/feeds/videos.xml?${param}=${encodeURIComponent(ytId)}`
}
/**
* Guard for the watched-source RSS pre-check (audit T7). The only feed AeroFetch
* ever builds is a YouTube videos.xml feed (see buildFeedUrl), so the sync refuses
* to fetch anything else — this keeps a hand-edited / corrupted sources.json from
* pointing fetch() at an internal service, a cloud-metadata endpoint, or any other
* arbitrary host (SSRF). Requires https, the youtube.com host (www optional), and
* the exact feed path; the channel_id/playlist_id query is free.
*/
export function isYouTubeFeedUrl(url: string): boolean {
try {
const u = new URL(url)
const host = u.hostname.replace(/^www\./i, '').toLowerCase()
return u.protocol === 'https:' && host === 'youtube.com' && u.pathname === '/feeds/videos.xml'
} catch {
return false
}
}
/** Extract the video ids from a YouTube RSS/Atom feed body (the <yt:videoId> tags). */
export function parseRssVideoIds(xml: string): string[] {
const ids: string[] = []
const re = /<yt:videoId>\s*([\w-]+)\s*<\/yt:videoId>/g
let m: RegExpExecArray | null
while ((m = re.exec(xml)) !== null) if (m[1]) ids.push(m[1])
return ids
}
// --- Entry merge / dedup ----------------------------------------------------
/** A named playlist and its (flat) video entries, ready to merge. */
export interface NamedPlaylist {
title: string
entries: RawEntry[]
}
/**
* Merge a Source's playlists (and its catch-all uploads) into a deduped list of
* MediaItem records. Dedup is by video id: the FIRST playlist a video appears in
* wins its folder assignment, so videos grouped into a real playlist land there,
* and only videos in no playlist fall through to the synthetic 'Uploads' folder.
*
* `playlistIndex` is the 1-based position WITHIN the winning playlist (not the
* global upload order), so the on-disk "NNN - Title" numbering matches the
* playlist the file is filed under.
*/
export function buildMediaItems(
sourceId: string,
playlists: NamedPlaylist[],
uploads: RawEntry[]
): MediaItem[] {
const byVideo = new Map<string, MediaItem>()
const add = (e: RawEntry, playlistTitle: string, index: number): void => {
const videoId = e.id
if (!videoId) return
if (byVideo.has(videoId)) return // first playlist wins
const url = entryUrl(e)
if (!url) return // not turnable into a downloadable URL
byVideo.set(videoId, {
id: `${sourceId}:${videoId}`,
sourceId,
videoId,
title: e.title || `Video ${videoId}`,
url,
playlistTitle,
playlistIndex: index,
durationLabel: fmtDuration(e.duration),
downloaded: false
})
}
for (const p of playlists) p.entries.forEach((e, i) => add(e, p.title, i + 1))
uploads.forEach((e, i) => add(e, 'Uploads', i + 1))
return [...byVideo.values()]
}
/**
* Merge a freshly-enumerated item list with the previously-persisted one for the
* same source (incremental re-index; see ROADMAP-PINCHFLAT.md Phase I). The fresh
* list defines the current membership/ordering, but any video already marked
* downloaded keeps its `downloaded`/`downloadedAt`/`filePath` so a re-sync never
* loses that state or re-downloads it. Videos that vanished from the source (now
* private/deleted) are dropped. `newCount` is how many fresh videos weren't in
* the previous set — the "X new since last sync" figure.
*/
export function mergeItemsPreservingState(
existing: MediaItem[],
fresh: MediaItem[]
): { items: MediaItem[]; newCount: number } {
const prevByVideo = new Map(existing.map((m) => [m.videoId, m]))
let newCount = 0
const items = fresh.map((f) => {
const prev = prevByVideo.get(f.videoId)
if (!prev) {
newCount++
return f
}
return prev.downloaded
? { ...f, downloaded: true, downloadedAt: prev.downloadedAt, filePath: prev.filePath }
: f
})
return { items, newCount }
}