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:
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user