/** * Pure, dependency-free core for Source indexing (Pinchflat-style media * manager). 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/, /c/, /user/ — 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 " - Videos" or * " - 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 tags). */ export function parseRssVideoIds(xml: string): string[] { const ids: string[] = [] const re = /\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() 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). 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 } }