Add Pinchflat-style media manager: index channels into playlist folders

Index an entire YouTube channel or playlist once, then download it into
organized <Channel>/<Playlist>/<NNN> - <Title> folders, with incremental
re-sync. Built in six rebuild-gated phases (F-K); see ROADMAP-PINCHFLAT.md.

- F: channel-walk indexer (/playlists + /videos) -> persisted Source +
  MediaItem JSON stores; pure classify/dedup logic in indexerCore.ts
- G: Windows-safe folder paths + dir sanitizer (collectionOutputTemplate)
- H: Library tab + source tree + "Download pending" into the existing queue
- I: state-preserving re-index merge + persist-downloaded-on-complete
- J: watched sources, RSS fast-check, Task Scheduler + --sync launch
  (the OS-level scheduling/RSS need a real-install smoke test)
- K: .info.json / thumbnail / .description sidecars for media servers

Also gitignore .gitea-token. 106 unit tests pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:50:13 -04:00
parent 81b97ea429
commit 47da3e6746
26 changed files with 2529 additions and 46 deletions
+54
View File
@@ -8,10 +8,12 @@
* resolving it itself; the caller in download.ts passes `getBinDir()`.
*/
import { join } from 'path'
import {
BEST_FORMAT_ID,
type StartDownloadOptions,
type DownloadOptions,
type CollectionContext,
type CookieBrowser
} from '@shared/ipc'
@@ -138,6 +140,52 @@ export function formatCommandLine(exe: string, args: string[]): string {
return [exe, ...args].map(quoteForDisplay).join(' ')
}
// --- Collection (media-manager) folder paths --------------------------------
/**
* Sanitize one path segment (a channel or playlist name) into a Windows-safe
* directory name. This matters because, unlike the filename yt-dlp itself writes
* (which `--restrict-filenames` can clean), these directory segments are built by
* AeroFetch from untrusted channel/playlist titles and joined onto the output
* dir — so they must be neutered for both illegal characters AND path traversal.
*
* - illegal chars (`< > : " / \ | ? *`) and control chars → space
* - leading/trailing dots and spaces stripped (illegal / invisible on Windows),
* which also turns a bare `..` traversal segment into nothing
* - reserved device names (CON, PRN, NUL, COM1…) get an underscore prefix
* - length-capped so the full path stays well under MAX_PATH
* - empty result falls back to 'Untitled'
*/
export function sanitizeDirSegment(name: string): string {
// C0 control chars (charCode < 0x20) and Windows-illegal chars both become a
// space. The control-char filter is done by char code so no literal control
// byte ever appears in this source file.
let s = Array.from(name ?? '', (ch) => (ch.charCodeAt(0) < 0x20 ? ' ' : ch)).join('')
s = s.replace(/[<>:"/\\|?*]/g, ' ')
s = s.replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '').replace(/^[. ]+/, '')
if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(s)) s = `_${s}`
s = s.slice(0, 80).trim()
return s || 'Untitled'
}
/**
* Build the `-o` output template for a collection download:
* <outDir>/<Channel>/<Playlist>/<NNN> - <baseFilename>
* where NNN is the 1-based playlist index zero-padded to three digits and
* baseFilename keeps its yt-dlp field tokens (e.g. '%(title)s.%(ext)s') so they
* still expand. Channel/playlist are sanitized via sanitizeDirSegment.
*/
export function collectionOutputTemplate(
outDir: string,
c: CollectionContext,
baseFilename: string
): string {
const n = Number.isFinite(c.index) && c.index > 0 ? Math.floor(c.index) : 1
const nnn = String(n).padStart(3, '0')
const segments = [sanitizeDirSegment(c.channel), sanitizeDirSegment(c.playlist)]
return join(outDir, ...segments, `${nnn} - ${baseFilename}`)
}
function accessArgs(a: AccessOptions): string[] {
const args: string[] = []
if (a.proxy.trim()) args.push('--proxy', a.proxy.trim())
@@ -179,6 +227,12 @@ function postProcessArgs(opts: StartDownloadOptions, o: DownloadOptions): string
if (o.embedChapters) args.push('--embed-chapters')
if (o.embedMetadata) args.push('--embed-metadata')
// Media-server sidecar files (Phase K) — written next to the output file so
// Jellyfin/Plex/Kodi can ingest metadata, poster art, and the description.
if (o.writeInfoJson) args.push('--write-info-json')
if (o.writeThumbnailFile) args.push('--write-thumbnail')
if (o.writeDescription) args.push('--write-description')
return args
}
+13 -3
View File
@@ -7,7 +7,12 @@ import { getSettings, getDownloadArchivePath } from './settings'
import { getCookiesFilePath } from './cookies'
import { listTemplates } from './templates'
import { assertHttpUrl } from './url'
import { buildArgs, parseExtraArgs, formatCommandLine } from './buildArgs'
import {
buildArgs,
parseExtraArgs,
formatCommandLine,
collectionOutputTemplate
} from './buildArgs'
import { cleanError } from './log'
import { addErrorLog } from './errorlog'
import {
@@ -161,7 +166,12 @@ function resolveExtraArgs(opts: StartDownloadOptions, settings: Settings): strin
export function buildCommand(opts: StartDownloadOptions): string[] {
const settings = getSettings()
const outDir = opts.outputDir?.trim() || settings.outputDir || app.getPath('downloads')
const template = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
// A collection (media-manager) download is filed into <channel>/<playlist>/
// <NNN> - <title> folders; an ordinary download uses the flat filenameTemplate.
const filenameTemplate = settings.filenameTemplate?.trim() || '%(title)s.%(ext)s'
const outputTemplate = opts.collection
? collectionOutputTemplate(outDir, opts.collection, '%(title)s.%(ext)s')
: join(outDir, filenameTemplate)
// Per-download override wins; otherwise use the persisted defaults.
const options = opts.options ?? settings.downloadOptions
// Silently fall back to yt-dlp's own downloader if aria2c.exe wasn't dropped
@@ -183,7 +193,7 @@ export function buildCommand(opts: StartDownloadOptions): string[] {
downloadArchivePath: settings.downloadArchive ? getDownloadArchivePath() : undefined
}
const extraArgs = resolveExtraArgs(opts, settings)
return buildArgs(opts, join(outDir, template), options, getBinDir(), access, extraArgs)
return buildArgs(opts, outputTemplate, options, getBinDir(), access, extraArgs)
}
/** Build the exact command line for the current form state, without running it. */
+47 -1
View File
@@ -22,6 +22,17 @@ import { openCookieLoginWindow, getCookiesStatus, clearCookies } from './cookies
import { listErrorLog, addErrorLog, clearErrorLog } from './errorlog'
import { exportBackup, importBackup } from './backup'
import { extractIncomingUrl, registerSendToShortcut, focusWindow } from './deeplink'
import {
listSources,
getSource,
removeSource,
listMediaItems,
setMediaItemDownloaded,
setSourceWatched
} from './sources'
import { indexSource } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync, isSyncLaunch } from './schedule'
// Only one instance ever runs. A second launch — e.g. the OS invoking us again
// for an aerofetch:// link or a "Send to AeroFetch" file — hands its argv to
@@ -96,7 +107,10 @@ function createWindow(): void {
mainWindow = win
win.on('ready-to-show', () => {
win.show()
// A scheduled `--sync` launch starts unobtrusively (shown but not focused) so
// the daily background sync doesn't steal focus; a normal launch shows + focuses.
if (isSyncLaunch(process.argv)) win.showInactive()
else win.show()
})
win.on('closed', () => {
@@ -229,6 +243,38 @@ function registerIpcHandlers(): void {
}
return result
})
// --- Media-manager sources (Pinchflat-style index; see ROADMAP-PINCHFLAT.md) ---
ipcMain.handle(IpcChannels.sourcesList, () => listSources())
ipcMain.handle(IpcChannels.sourceItems, (_e, sourceId: string) => listMediaItems(sourceId))
ipcMain.handle(IpcChannels.sourceRemove, (_e, id: string) => removeSource(id))
ipcMain.handle(IpcChannels.sourceItemDownloaded, (_e, id: string, filePath?: string) =>
setMediaItemDownloaded(id, filePath)
)
// Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result.
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
indexSource(url, (p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
)
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
const src = getSource(id)
if (!src) return { ok: false, error: 'Source not found.' }
return indexSource(src.url, (p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
})
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
setSourceWatched(id, watched)
)
ipcMain.handle(IpcChannels.sourcesSync, (e) =>
syncWatchedSources((p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
)
ipcMain.handle(IpcChannels.scheduledSyncGet, () => getScheduledSync())
ipcMain.handle(IpcChannels.scheduledSyncSet, (_e, enabled: boolean) => setScheduledSync(enabled))
}
// Push OS theme/contrast changes to every window, and keep the native
+204
View File
@@ -0,0 +1,204 @@
/**
* 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 { execFile } from 'child_process'
import { existsSync } from 'fs'
import { getYtdlpPath } from './binaries'
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.
*/
function probeFlat(url: string): Promise<FlatInfo> {
return new Promise((resolve, reject) => {
execFile(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
{ windowsHide: true, maxBuffer: 256 * 1024 * 1024, timeout: 180_000 },
(err, stdout, stderr) => {
if (err) {
const msg = (err as { killed?: boolean }).killed
? 'Timed out indexing source. Check the link or your connection.'
: cleanError(stderr) || err.message
reject(new Error(msg))
return
}
try {
resolve(JSON.parse(stdout) as FlatInfo)
} catch {
reject(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): Promise<FlatInfo | null> {
return probeFlat(url).catch(() => null)
}
/**
* 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
): Promise<IndexSourceResult> {
try {
assertHttpUrl(url)
} catch (e) {
return { ok: false, error: (e as Error).message }
}
if (!existsSync(getYtdlpPath())) {
return { ok: false, error: 'yt-dlp.exe not found. Drop it into resources/bin/.' }
}
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`)
const playlistEntries = playlistTab?.entries ?? []
const total = playlistEntries.length
let done = 0
for (const pe of playlistEntries) {
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)
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`)
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 ?? url)
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.' }
}
// 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) {
const msg = e instanceof Error ? e.message : String(e)
onProgress({ url, phase: 'error', message: msg })
return { ok: false, error: msg }
}
}
+226
View File
@@ -0,0 +1,226 @@
/**
* 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
if (e.id) return `https://www.youtube.com/watch?v=${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)}`
}
/** 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) 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 }
}
+1 -33
View File
@@ -4,6 +4,7 @@ import { getYtdlpPath } from './binaries'
import { fmtBytes } from './download'
import { cleanError } from './log'
import { assertHttpUrl } from './url'
import { entryUrl, fmtDuration, type RawEntry } from './indexerCore'
import {
BEST_FORMAT_ID,
type ProbeResult,
@@ -26,16 +27,6 @@ interface RawFormat {
filesize_approx?: number
}
interface RawEntry {
id?: string
title?: string
url?: string
webpage_url?: string
duration?: number
uploader?: string
channel?: string
}
interface RawInfo {
_type?: string
title?: string
@@ -92,29 +83,6 @@ function buildInfo(data: RawInfo): MediaInfo {
}
}
/** Seconds → 'M:SS' or 'H:MM:SS'. Flat playlist entries give duration as a number. */
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')}`
}
/**
* Resolve the URL we'll enqueue for a playlist entry. Prefer an explicit http(s)
* URL; fall back to a YouTube watch URL built from the id (the common case where
* flat entries carry only an id). Returns null when nothing usable is present.
*/
function entryUrl(e: RawEntry): string | null {
const cand = e.url || e.webpage_url
if (cand && /^https?:\/\//i.test(cand)) return cand
if (e.id) return `https://www.youtube.com/watch?v=${e.id}`
return null
}
function buildPlaylist(data: RawInfo): PlaylistInfo {
const entries: PlaylistEntry[] = []
;(data.entries ?? []).forEach((e, i) => {
+67
View File
@@ -0,0 +1,67 @@
/**
* Windows Task Scheduler integration for the daily watched-source sync
* (ROADMAP-PINCHFLAT.md Phase J). Registers a task that launches AeroFetch with
* `--sync` once a day; the app then runs its startup sync of watched sources.
*
* NOTE: this is OS-level wiring and cannot be exercised in the Vite UI preview or
* the unit tests — like the `aerofetch://` protocol registration, it needs a real
* install + manual smoke test. `schtasks` is invoked via execFile (no shell), and
* the only interpolated value is the trusted `process.execPath`.
*/
import { execFile } from 'child_process'
import type { ScheduledSyncStatus } from '@shared/ipc'
const TASK_NAME = 'AeroFetchDailySync'
/** The argv flag the scheduled task passes so startup knows it's a sync launch. */
export const SYNC_FLAG = '--sync'
function schtasks(args: string[]): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
execFile('schtasks', args, { windowsHide: true }, (err, stdout, stderr) => {
const code = err ? ((err as { code?: number }).code ?? 1) : 0
resolve({ code, stdout: String(stdout), stderr: String(stderr) })
})
})
}
/** True when this launch came from the scheduled task (argv carries --sync). */
export function isSyncLaunch(argv: string[]): boolean {
return argv.includes(SYNC_FLAG)
}
/** Whether the daily-sync scheduled task is currently registered. */
export async function getScheduledSync(): Promise<ScheduledSyncStatus> {
const r = await schtasks(['/Query', '/TN', TASK_NAME])
return { enabled: r.code === 0 }
}
/**
* Register or remove the daily-sync scheduled task. Creating runs AeroFetch with
* `--sync` every day at 09:00 (overwriting any prior task of the same name).
*/
export async function setScheduledSync(enabled: boolean): Promise<ScheduledSyncStatus> {
if (enabled) {
const tr = `"${process.execPath}" ${SYNC_FLAG}`
const r = await schtasks([
'/Create',
'/F',
'/SC',
'DAILY',
'/ST',
'09:00',
'/TN',
TASK_NAME,
'/TR',
tr
])
return {
enabled: r.code === 0,
error: r.code === 0 ? undefined : r.stderr.trim() || 'Could not create the scheduled task.'
}
}
const r = await schtasks(['/Delete', '/F', '/TN', TASK_NAME])
// A missing task ("cannot find") is success for our purposes — it's already gone.
const gone = r.code === 0 || /cannot find|does not exist/i.test(r.stderr)
return { enabled: gone ? false : true, error: gone ? undefined : r.stderr.trim() }
}
+6 -1
View File
@@ -36,6 +36,7 @@ const DEFAULTS: Settings = {
customCommandEnabled: false,
defaultTemplateId: null,
notifyOnComplete: true,
autoDownloadNew: true,
hasCompletedOnboarding: false
}
@@ -77,7 +78,10 @@ function sanitizeOptions(input: unknown): DownloadOptions {
embedChapters: bool(o.embedChapters, d.embedChapters),
embedMetadata: bool(o.embedMetadata, d.embedMetadata),
embedThumbnail: bool(o.embedThumbnail, d.embedThumbnail),
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail)
cropThumbnail: bool(o.cropThumbnail, d.cropThumbnail),
writeInfoJson: bool(o.writeInfoJson, d.writeInfoJson),
writeThumbnailFile: bool(o.writeThumbnailFile, d.writeThumbnailFile),
writeDescription: bool(o.writeDescription, d.writeDescription)
}
}
@@ -163,6 +167,7 @@ export function setSettings(partial: Partial<Settings>): Settings {
case 'downloadArchive':
case 'customCommandEnabled':
case 'notifyOnComplete':
case 'autoDownloadNew':
case 'hasCompletedOnboarding':
if (typeof value === 'boolean') s.set(key, value)
break
+135
View File
@@ -0,0 +1,135 @@
/**
* Persistence for the media-manager index (Pinchflat-style; see
* ROADMAP-PINCHFLAT.md). Two plain-JSON stores in userData, mirroring the
* history.ts pattern (and the same deliberate choice to stay on JSON rather than
* better-sqlite3 — revisit if a user indexes many large channels, see the
* Phase H risk note in the roadmap):
*
* sources.json — one Source record per added channel/playlist
* media-items.json — every MediaItem across all sources (queried by sourceId)
*
* Per-row validation on read (validation.ts) so a hand-edited or corrupted file
* can't feed the UI malformed records (same approach as history/errorlog).
*/
import { app } from 'electron'
import { join } from 'path'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import type { Source, MediaItem } from '@shared/ipc'
import { isValidSource, isValidMediaItem } from './validation'
import { mergeItemsPreservingState } from './indexerCore'
// A generous global cap so a runaway index can't grow the file unbounded; large
// enough for several big channels. When exceeded, the most-recently-written
// source's items are kept (they're placed first by replaceMediaItems).
const MAX_ITEMS = 20000
function sourcesFile(): string {
return join(app.getPath('userData'), 'sources.json')
}
function itemsFile(): string {
return join(app.getPath('userData'), 'media-items.json')
}
function readJsonArray<T>(path: string, isValid: (o: unknown) => o is T): T[] {
try {
if (!existsSync(path)) return []
const data = JSON.parse(readFileSync(path, 'utf8'))
return Array.isArray(data) ? data.filter(isValid) : []
} catch {
return []
}
}
function writeJson(path: string, value: unknown): void {
try {
writeFileSync(path, JSON.stringify(value, null, 2))
} catch {
/* best-effort; a read-only data dir just means no persisted index */
}
}
// --- Sources ----------------------------------------------------------------
export function listSources(): Source[] {
return readJsonArray(sourcesFile(), isValidSource)
}
export function getSource(id: string): Source | undefined {
return listSources().find((s) => s.id === id)
}
/** Insert or replace a source by id (a re-index updates the existing record). */
export function upsertSource(source: Source): Source[] {
const sources = [source, ...listSources().filter((s) => s.id !== source.id)]
writeJson(sourcesFile(), sources)
return sources
}
/** Toggle whether a source is watched for new uploads (Phase J). Returns all sources. */
export function setSourceWatched(id: string, watched: boolean): Source[] {
const sources = listSources().map((s) => (s.id === id ? { ...s, watched } : s))
writeJson(sourcesFile(), sources)
return sources
}
/** Remove a source and all of its media items. Returns the remaining sources. */
export function removeSource(id: string): Source[] {
const sources = listSources().filter((s) => s.id !== id)
writeJson(sourcesFile(), sources)
const items = listAllItems().filter((m) => m.sourceId !== id)
writeJson(itemsFile(), items)
return sources
}
// --- Media items ------------------------------------------------------------
function listAllItems(): MediaItem[] {
return readJsonArray(itemsFile(), isValidMediaItem)
}
export function listMediaItems(sourceId: string): MediaItem[] {
return listAllItems().filter((m) => m.sourceId === sourceId)
}
/**
* Replace all media items for one source with a fresh set (the result of a
* (re)index). Other sources' items are preserved; the new items are placed first
* so they survive the MAX_ITEMS cap.
*/
export function replaceMediaItems(sourceId: string, items: MediaItem[]): void {
const others = listAllItems().filter((m) => m.sourceId !== sourceId)
writeJson(itemsFile(), [...items, ...others].slice(0, MAX_ITEMS))
}
/**
* Incrementally merge a freshly-indexed item list into the persisted store,
* preserving the downloaded state of videos already on disk (see Phase I). The
* fresh list defines current membership/order; returns the merged items and how
* many were new since the last index.
*/
export function mergeMediaItems(
sourceId: string,
fresh: MediaItem[]
): { items: MediaItem[]; newCount: number } {
const result = mergeItemsPreservingState(listMediaItems(sourceId), fresh)
replaceMediaItems(sourceId, result.items)
return result
}
/**
* Mark one media item downloaded, recording its file path + time. Returns the
* updated list for that item's source (or [] if the id is unknown). Used by the
* library view once a queued item completes (Phase H/I).
*/
export function setMediaItemDownloaded(id: string, filePath?: string): MediaItem[] {
const all = listAllItems()
const target = all.find((m) => m.id === id)
if (!target) return []
const updated = all.map((m) =>
m.id === id ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
)
writeJson(itemsFile(), updated)
return updated.filter((m) => m.sourceId === target.sourceId)
}
+50
View File
@@ -0,0 +1,50 @@
/**
* Watched-source sync (Pinchflat-style; ROADMAP-PINCHFLAT.md Phase J). Re-indexes
* every watched Source and reports the videos that are new since its last index.
* A YouTube RSS feed is used as a cheap pre-check so a source with no new uploads
* is skipped before the (more expensive) full `yt-dlp` re-index.
*/
import { listSources, listMediaItems } from './sources'
import { indexSource } from './indexer'
import { parseRssVideoIds } from './indexerCore'
import type { IndexProgress, MediaItem, SyncResult } from '@shared/ipc'
/** Fetch a YouTube RSS feed and return its recent video ids. Throws on failure. */
async function fetchFeedIds(feedUrl: string): Promise<string[]> {
const res = await fetch(feedUrl, { signal: AbortSignal.timeout(15_000) })
if (!res.ok) throw new Error(`feed responded ${res.status}`)
return parseRssVideoIds(await res.text())
}
/**
* Re-index every watched source and collect the videos new since the last index.
* RSS pre-check: if the feed shows only ids the source already knows, the full
* re-index is skipped. `onProgress` is forwarded from the underlying indexSource.
*/
export async function syncWatchedSources(
onProgress: (p: IndexProgress) => void
): Promise<SyncResult> {
try {
const watched = listSources().filter((s) => s.watched)
const newItems: MediaItem[] = []
for (const src of watched) {
// Cheap freshness check — skip the full re-index when nothing is new.
if (src.feedUrl) {
const known = new Set(listMediaItems(src.id).map((m) => m.videoId))
const recent = await fetchFeedIds(src.feedUrl).catch(() => null)
if (recent && recent.length > 0 && recent.every((id) => known.has(id))) continue
}
const before = new Set(listMediaItems(src.id).map((m) => m.videoId))
const res = await indexSource(src.url, onProgress)
if (res.ok) {
for (const it of listMediaItems(src.id)) {
if (!before.has(it.videoId)) newItems.push(it)
}
}
}
return { ok: true, newItems }
} catch (e) {
return { ok: false, newItems: [], error: e instanceof Error ? e.message : String(e) }
}
}
+36 -1
View File
@@ -7,7 +7,7 @@
*/
import { isAbsolute } from 'path'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate } from '@shared/ipc'
import type { HistoryEntry, ErrorLogEntry, CommandTemplate, Source, MediaItem } from '@shared/ipc'
// --- Path-traversal sanitization (audit S4) ---------------------------------
@@ -75,3 +75,38 @@ export function isTemplateLike(o: unknown): o is CommandTemplate {
const t = o as Record<string, unknown>
return typeof t.id === 'string' || typeof t.id === 'number'
}
/** A persisted sources.json row must have the right shape or it's dropped on read. */
export function isValidSource(o: unknown): o is Source {
if (!o || typeof o !== 'object') return false
const s = o as Record<string, unknown>
return (
typeof s.id === 'string' &&
typeof s.url === 'string' &&
(s.kind === 'channel' || s.kind === 'playlist') &&
typeof s.title === 'string' &&
typeof s.addedAt === 'number' &&
typeof s.itemCount === 'number' &&
isOptionalString(s.channel) &&
(s.lastIndexedAt === undefined || typeof s.lastIndexedAt === 'number')
)
}
/** A persisted media-items.json row must have the right shape or it's dropped on read. */
export function isValidMediaItem(o: unknown): o is MediaItem {
if (!o || typeof o !== 'object') return false
const m = o as Record<string, unknown>
return (
typeof m.id === 'string' &&
typeof m.sourceId === 'string' &&
typeof m.videoId === 'string' &&
typeof m.title === 'string' &&
typeof m.url === 'string' &&
typeof m.playlistTitle === 'string' &&
typeof m.playlistIndex === 'number' &&
typeof m.downloaded === 'boolean' &&
isOptionalString(m.durationLabel) &&
isOptionalString(m.filePath) &&
(m.downloadedAt === undefined || typeof m.downloadedAt === 'number')
)
}