e29374dcfa
The ROADMAP/ROADMAP-PINCHFLAT docs were removed; this drops the now-dead "see ROADMAP-PINCHFLAT.md" breadcrumbs (and one "the roadmap" mention) from source comments, preserving the surrounding Pinchflat-style context. Comment-only; typecheck (node+web) green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
250 lines
9.3 KiB
TypeScript
250 lines
9.3 KiB
TypeScript
/**
|
|
* 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/<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). 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 }
|
|
}
|