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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user