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')
|
||||
)
|
||||
}
|
||||
|
||||
+49
-1
@@ -17,7 +17,13 @@ import {
|
||||
type ErrorLogEntry,
|
||||
type BackupExportResult,
|
||||
type BackupImportResult,
|
||||
type SystemThemeInfo
|
||||
type SystemThemeInfo,
|
||||
type Source,
|
||||
type MediaItem,
|
||||
type IndexProgress,
|
||||
type IndexSourceResult,
|
||||
type SyncResult,
|
||||
type ScheduledSyncStatus
|
||||
} from '@shared/ipc'
|
||||
|
||||
// The surface exposed to the renderer. Keep this thin: it only forwards to IPC.
|
||||
@@ -121,6 +127,48 @@ const api = {
|
||||
const listener = (_e: IpcRendererEvent, url: string): void => cb(url)
|
||||
ipcRenderer.on(IpcChannels.externalUrl, listener)
|
||||
return () => ipcRenderer.removeListener(IpcChannels.externalUrl, listener)
|
||||
},
|
||||
|
||||
// --- Media-manager sources (Pinchflat-style; see ROADMAP-PINCHFLAT.md) ---
|
||||
|
||||
listSources: (): Promise<Source[]> => ipcRenderer.invoke(IpcChannels.sourcesList),
|
||||
|
||||
/** Index (or re-index) a channel/playlist URL into the persisted source list. */
|
||||
indexSource: (url: string): Promise<IndexSourceResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.sourceIndex, url),
|
||||
|
||||
/** Re-index an existing source by id (refreshes its media-item list). */
|
||||
reindexSource: (id: string): Promise<IndexSourceResult> =>
|
||||
ipcRenderer.invoke(IpcChannels.sourceReindex, id),
|
||||
|
||||
removeSource: (id: string): Promise<Source[]> =>
|
||||
ipcRenderer.invoke(IpcChannels.sourceRemove, id),
|
||||
|
||||
listSourceItems: (sourceId: string): Promise<MediaItem[]> =>
|
||||
ipcRenderer.invoke(IpcChannels.sourceItems, sourceId),
|
||||
|
||||
/** Persist that a media item has finished downloading (drives incremental sync). */
|
||||
markSourceItemDownloaded: (id: string, filePath?: string): Promise<MediaItem[]> =>
|
||||
ipcRenderer.invoke(IpcChannels.sourceItemDownloaded, id, filePath),
|
||||
|
||||
/** Toggle whether a source is watched for new uploads. */
|
||||
setSourceWatched: (id: string, watched: boolean): Promise<Source[]> =>
|
||||
ipcRenderer.invoke(IpcChannels.sourceSetWatched, id, watched),
|
||||
|
||||
/** Re-index all watched sources; resolves with the videos found new. */
|
||||
syncSources: (): Promise<SyncResult> => ipcRenderer.invoke(IpcChannels.sourcesSync),
|
||||
|
||||
/** Read / write the Windows daily-sync scheduled task. */
|
||||
getScheduledSync: (): Promise<ScheduledSyncStatus> =>
|
||||
ipcRenderer.invoke(IpcChannels.scheduledSyncGet),
|
||||
setScheduledSync: (enabled: boolean): Promise<ScheduledSyncStatus> =>
|
||||
ipcRenderer.invoke(IpcChannels.scheduledSyncSet, enabled),
|
||||
|
||||
/** Subscribe to live indexing progress. Returns an unsubscribe function. */
|
||||
onIndexProgress: (cb: (p: IndexProgress) => void): (() => void) => {
|
||||
const listener = (_e: IpcRendererEvent, p: IndexProgress): void => cb(p)
|
||||
ipcRenderer.on(IpcChannels.indexProgress, listener)
|
||||
return () => ipcRenderer.removeListener(IpcChannels.indexProgress, listener)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { FluentProvider, makeStyles, tokens } from '@fluentui/react-components'
|
||||
import { Sidebar, type TabValue } from './components/Sidebar'
|
||||
import { DownloadsView } from './components/DownloadsView'
|
||||
import { LibraryView } from './components/LibraryView'
|
||||
import { HistoryView } from './components/HistoryView'
|
||||
import { SettingsView } from './components/SettingsView'
|
||||
import { Onboarding } from './components/Onboarding'
|
||||
@@ -66,6 +67,7 @@ function App(): React.JSX.Element {
|
||||
|
||||
<main className={styles.content}>
|
||||
{tab === 'downloads' && <DownloadsView />}
|
||||
{tab === 'library' && <LibraryView />}
|
||||
{tab === 'history' && <HistoryView />}
|
||||
{tab === 'settings' && <SettingsView />}
|
||||
</main>
|
||||
|
||||
@@ -230,6 +230,29 @@ export function DownloadOptionsForm({ value, onChange }: Props): React.JSX.Eleme
|
||||
</Caption1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="Sidecar files"
|
||||
hint="Write separate metadata/poster/description files next to each download — handy for Jellyfin, Plex or Kodi libraries."
|
||||
>
|
||||
<div className={styles.subGroup}>
|
||||
<Checkbox
|
||||
checked={value.writeInfoJson}
|
||||
onChange={(_, d) => setOpt('writeInfoJson', !!d.checked)}
|
||||
label="Metadata (.info.json)"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={value.writeThumbnailFile}
|
||||
onChange={(_, d) => setOpt('writeThumbnailFile', !!d.checked)}
|
||||
label="Thumbnail image file"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={value.writeDescription}
|
||||
onChange={(_, d) => setOpt('writeDescription', !!d.checked)}
|
||||
label="Description (.description)"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Subtitle2,
|
||||
Body1,
|
||||
Caption1,
|
||||
Text,
|
||||
Input,
|
||||
Button,
|
||||
Checkbox,
|
||||
Switch,
|
||||
Spinner,
|
||||
makeStyles,
|
||||
mergeClasses,
|
||||
tokens,
|
||||
shorthands
|
||||
} from '@fluentui/react-components'
|
||||
import {
|
||||
SearchRegular,
|
||||
ArrowSyncRegular,
|
||||
ArrowClockwiseRegular,
|
||||
DeleteRegular,
|
||||
ArrowDownloadRegular,
|
||||
ChevronDownRegular,
|
||||
ChevronRightRegular,
|
||||
AppsListRegular,
|
||||
VideoClipMultipleRegular,
|
||||
AlertRegular,
|
||||
LibraryRegular
|
||||
} from '@fluentui/react-icons'
|
||||
import type { MediaItem, Source } from '@shared/ipc'
|
||||
import { useSources, MAX_ENQUEUE_BATCH } from '../store/sources'
|
||||
import { useSettings } from '../store/settings'
|
||||
import { useDownloads, type DownloadStatus } from '../store/downloads'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
/** Per-item status shown in the library: a live queue status, or pending/downloaded. */
|
||||
type ItemStatus = DownloadStatus | 'pending'
|
||||
|
||||
const STATUS_LABEL: Record<ItemStatus, string> = {
|
||||
pending: 'Pending',
|
||||
queued: 'Queued',
|
||||
downloading: 'Downloading',
|
||||
completed: 'Downloaded',
|
||||
error: 'Failed',
|
||||
canceled: 'Canceled'
|
||||
}
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { display: 'flex', flexDirection: 'column', gap: '18px' },
|
||||
header: { display: 'flex', flexDirection: 'column', gap: '2px' },
|
||||
sub: { color: tokens.colorNeutralForeground3 },
|
||||
addRow: { display: 'flex', gap: '8px' },
|
||||
addInput: { flexGrow: 1 },
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '14px',
|
||||
flexWrap: 'wrap',
|
||||
paddingTop: '2px'
|
||||
},
|
||||
toolbarSpacer: { flexGrow: 1 },
|
||||
switchRow: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2px',
|
||||
color: tokens.colorNeutralForeground2
|
||||
},
|
||||
progress: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
color: tokens.colorNeutralForeground2,
|
||||
fontSize: tokens.fontSizeBase200
|
||||
},
|
||||
error: { color: tokens.colorPaletteRedForeground1 },
|
||||
empty: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '56px 16px',
|
||||
color: tokens.colorNeutralForeground3,
|
||||
textAlign: 'center'
|
||||
},
|
||||
list: { display: 'flex', flexDirection: 'column', gap: '10px' },
|
||||
card: {
|
||||
border: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
...shorthands.borderRadius(tokens.borderRadiusLarge),
|
||||
backgroundColor: tokens.colorNeutralBackground1,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
cardHead: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 14px',
|
||||
cursor: 'pointer',
|
||||
':hover': { backgroundColor: tokens.colorNeutralBackground1Hover }
|
||||
},
|
||||
srcIcon: {
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
flexShrink: 0,
|
||||
borderRadius: tokens.borderRadiusMedium,
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '18px'
|
||||
},
|
||||
srcMeta: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||
srcTitleRow: { display: 'flex', alignItems: 'center', gap: '8px', minWidth: 0 },
|
||||
srcTitle: { fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1 },
|
||||
watchBadge: {
|
||||
flexShrink: 0,
|
||||
fontSize: tokens.fontSizeBase100,
|
||||
padding: '0 7px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
srcSub: { color: tokens.colorNeutralForeground3 },
|
||||
detail: {
|
||||
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
|
||||
padding: '12px 14px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px'
|
||||
},
|
||||
actionRow: { display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' },
|
||||
actionSpacer: { flexGrow: 1 },
|
||||
group: { display: 'flex', flexDirection: 'column' },
|
||||
groupHead: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '6px 4px',
|
||||
color: tokens.colorNeutralForeground2
|
||||
},
|
||||
groupTitle: { fontWeight: tokens.fontWeightSemibold, flexGrow: 1, minWidth: 0 },
|
||||
rows: { display: 'flex', flexDirection: 'column' },
|
||||
row: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '5px 4px 5px 18px'
|
||||
},
|
||||
rowMain: { display: 'flex', flexDirection: 'column', minWidth: 0, flexGrow: 1 },
|
||||
rowTitle: {
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
color: tokens.colorNeutralForeground1
|
||||
},
|
||||
rowMeta: { color: tokens.colorNeutralForeground3 },
|
||||
pill: {
|
||||
flexShrink: 0,
|
||||
fontSize: tokens.fontSizeBase200,
|
||||
padding: '1px 8px',
|
||||
...shorthands.borderRadius(tokens.borderRadiusCircular),
|
||||
backgroundColor: tokens.colorNeutralBackground3,
|
||||
color: tokens.colorNeutralForeground3
|
||||
},
|
||||
pillDownloading: {
|
||||
backgroundColor: tokens.colorBrandBackground2,
|
||||
color: tokens.colorBrandForeground2
|
||||
},
|
||||
pillCompleted: {
|
||||
backgroundColor: tokens.colorPaletteGreenBackground2,
|
||||
color: tokens.colorPaletteGreenForeground2
|
||||
},
|
||||
pillError: {
|
||||
backgroundColor: tokens.colorPaletteRedBackground2,
|
||||
color: tokens.colorPaletteRedForeground2
|
||||
}
|
||||
})
|
||||
|
||||
/** Group items by playlist, sorted by index within a group; 'Uploads' sinks last. */
|
||||
function groupByPlaylist(items: MediaItem[]): { title: string; items: MediaItem[] }[] {
|
||||
const map = new Map<string, MediaItem[]>()
|
||||
for (const it of items) {
|
||||
const arr = map.get(it.playlistTitle) ?? []
|
||||
arr.push(it)
|
||||
map.set(it.playlistTitle, arr)
|
||||
}
|
||||
const groups = [...map.entries()].map(([title, its]) => ({
|
||||
title,
|
||||
items: [...its].sort((a, b) => a.playlistIndex - b.playlistIndex)
|
||||
}))
|
||||
groups.sort(
|
||||
(a, b) =>
|
||||
(a.title === 'Uploads' ? 1 : 0) - (b.title === 'Uploads' ? 1 : 0) ||
|
||||
a.title.localeCompare(b.title)
|
||||
)
|
||||
return groups
|
||||
}
|
||||
|
||||
function relTime(ms?: number): string {
|
||||
if (!ms) return 'never'
|
||||
const mins = Math.round((Date.now() - ms) / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins} min ago`
|
||||
const hrs = Math.round(mins / 60)
|
||||
if (hrs < 24) return `${hrs} h ago`
|
||||
return `${Math.round(hrs / 24)} d ago`
|
||||
}
|
||||
|
||||
export function LibraryView(): React.JSX.Element {
|
||||
const styles = useStyles()
|
||||
const sources = useSources((s) => s.sources)
|
||||
const itemsBySource = useSources((s) => s.itemsBySource)
|
||||
const selectedSourceId = useSources((s) => s.selectedSourceId)
|
||||
const indexing = useSources((s) => s.indexing)
|
||||
const selectSource = useSources((s) => s.selectSource)
|
||||
const indexSource = useSources((s) => s.indexSource)
|
||||
const reindexSource = useSources((s) => s.reindexSource)
|
||||
const removeSource = useSources((s) => s.removeSource)
|
||||
const enqueueItems = useSources((s) => s.enqueueItems)
|
||||
const setWatched = useSources((s) => s.setWatched)
|
||||
const syncWatched = useSources((s) => s.syncWatched)
|
||||
const syncing = useSources((s) => s.syncing)
|
||||
const downloadItems = useDownloads((s) => s.items)
|
||||
const autoDownloadNew = useSettings((s) => s.autoDownloadNew)
|
||||
const updateSettings = useSettings((s) => s.update)
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [syncNote, setSyncNote] = useState<string | null>(null)
|
||||
const [scheduled, setScheduled] = useState(false)
|
||||
|
||||
const watchedCount = sources.filter((s) => s.watched).length
|
||||
|
||||
// Load the current scheduled-sync (Task Scheduler) state once.
|
||||
useEffect(() => {
|
||||
if (PREVIEW) return
|
||||
window.api.getScheduledSync().then((s) => setScheduled(s.enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
async function onCheckNew(): Promise<void> {
|
||||
setSyncNote(null)
|
||||
const n = await syncWatched()
|
||||
setSyncNote(n > 0 ? `Found ${n} new video${n === 1 ? '' : 's'}.` : 'No new videos.')
|
||||
}
|
||||
|
||||
async function toggleScheduled(next: boolean): Promise<void> {
|
||||
setScheduled(next) // optimistic
|
||||
if (PREVIEW) return
|
||||
const res = await window.api.setScheduledSync(next)
|
||||
setScheduled(res.enabled)
|
||||
if (res.error) setError(res.error)
|
||||
}
|
||||
|
||||
// Reset the selection whenever the expanded source changes.
|
||||
useEffect(() => setSelected(new Set()), [selectedSourceId])
|
||||
|
||||
// Live per-URL queue status so a video row reflects its real download state.
|
||||
const statusByUrl = useMemo(() => {
|
||||
const m = new Map<string, DownloadStatus>()
|
||||
for (const d of downloadItems) m.set(d.url, d.status)
|
||||
return m
|
||||
}, [downloadItems])
|
||||
|
||||
const items = selectedSourceId ? (itemsBySource[selectedSourceId] ?? []) : []
|
||||
const groups = useMemo(() => groupByPlaylist(items), [items])
|
||||
|
||||
const effStatus = (it: MediaItem): ItemStatus =>
|
||||
statusByUrl.get(it.url) ?? (it.downloaded ? 'completed' : 'pending')
|
||||
|
||||
const pendingItems = items.filter((it) => effStatus(it) === 'pending')
|
||||
|
||||
async function onIndex(): Promise<void> {
|
||||
setError(null)
|
||||
const u = url.trim()
|
||||
if (!u || indexing.active) return
|
||||
const res = await indexSource(u)
|
||||
if (res.ok) setUrl('')
|
||||
else setError(res.error ?? 'Could not index that link.')
|
||||
}
|
||||
|
||||
function toggle(id: string, on: boolean): void {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(id)
|
||||
else next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleGroup(groupItems: MediaItem[], on: boolean): void {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
for (const it of groupItems) {
|
||||
if (on) next.add(it.id)
|
||||
else next.delete(it.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function downloadSelected(): void {
|
||||
if (!selectedSourceId) return
|
||||
const chosen = items.filter((it) => selected.has(it.id))
|
||||
enqueueItems(selectedSourceId, chosen)
|
||||
setSelected(new Set())
|
||||
}
|
||||
|
||||
function downloadPending(): void {
|
||||
if (!selectedSourceId) return
|
||||
enqueueItems(selectedSourceId, pendingItems)
|
||||
}
|
||||
|
||||
function pillClass(status: ItemStatus): string {
|
||||
if (status === 'downloading' || status === 'queued') return mergeClasses(styles.pill, styles.pillDownloading)
|
||||
if (status === 'completed') return mergeClasses(styles.pill, styles.pillCompleted)
|
||||
if (status === 'error') return mergeClasses(styles.pill, styles.pillError)
|
||||
return styles.pill
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.header}>
|
||||
<Subtitle2>Library</Subtitle2>
|
||||
<Caption1 className={styles.sub}>
|
||||
Index a channel or playlist once, then download it into organized folders.
|
||||
</Caption1>
|
||||
</div>
|
||||
|
||||
<div className={styles.addRow}>
|
||||
<Input
|
||||
className={styles.addInput}
|
||||
value={url}
|
||||
onChange={(_, d) => setUrl(d.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onIndex()}
|
||||
placeholder="Paste a channel or playlist URL…"
|
||||
size="large"
|
||||
contentBefore={<LibraryRegular />}
|
||||
disabled={indexing.active}
|
||||
/>
|
||||
<Button
|
||||
size="large"
|
||||
appearance="primary"
|
||||
icon={indexing.active ? <Spinner size="tiny" /> : <SearchRegular />}
|
||||
onClick={onIndex}
|
||||
disabled={!url.trim() || indexing.active}
|
||||
>
|
||||
Index
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.toolbar}>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="secondary"
|
||||
icon={syncing ? <Spinner size="tiny" /> : <ArrowClockwiseRegular />}
|
||||
onClick={onCheckNew}
|
||||
disabled={syncing || watchedCount === 0}
|
||||
>
|
||||
Check {watchedCount > 0 ? `${watchedCount} watched` : 'watched'} for new
|
||||
</Button>
|
||||
{syncNote && <Caption1 className={styles.sub}>{syncNote}</Caption1>}
|
||||
<div className={styles.toolbarSpacer} />
|
||||
<span className={styles.switchRow}>
|
||||
<Caption1>Auto-download new</Caption1>
|
||||
<Switch
|
||||
checked={autoDownloadNew}
|
||||
onChange={(_, d) => updateSettings({ autoDownloadNew: d.checked })}
|
||||
aria-label="Auto-download new uploads"
|
||||
/>
|
||||
</span>
|
||||
<span className={styles.switchRow}>
|
||||
<Caption1>Daily sync</Caption1>
|
||||
<Switch
|
||||
checked={scheduled}
|
||||
onChange={(_, d) => toggleScheduled(d.checked)}
|
||||
aria-label="Daily background sync"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{indexing.active && (
|
||||
<div className={styles.progress}>
|
||||
<Spinner size="tiny" />
|
||||
<Text>
|
||||
{indexing.message ?? 'Indexing…'}
|
||||
{indexing.current && indexing.total ? ` (${indexing.current}/${indexing.total})` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{error && <Caption1 className={styles.error}>{error}</Caption1>}
|
||||
|
||||
{sources.length === 0 && !indexing.active ? (
|
||||
<div className={styles.empty}>
|
||||
<LibraryRegular fontSize={40} />
|
||||
<Body1>No channels or playlists yet. Paste one above to index it.</Body1>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.list}>
|
||||
{sources.map((src) => (
|
||||
<SourceCard
|
||||
key={src.id}
|
||||
styles={styles}
|
||||
source={src}
|
||||
expanded={selectedSourceId === src.id}
|
||||
onToggleExpand={() =>
|
||||
selectSource(selectedSourceId === src.id ? null : src.id)
|
||||
}
|
||||
>
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.actionRow}>
|
||||
<Caption1 className={styles.srcSub}>
|
||||
{items.length} videos · {pendingItems.length} pending · indexed{' '}
|
||||
{relTime(src.lastIndexedAt)}
|
||||
</Caption1>
|
||||
<div className={styles.actionSpacer} />
|
||||
{selected.size > 0 ? (
|
||||
<>
|
||||
<Button size="small" appearance="subtle" onClick={() => setSelected(new Set())}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={downloadSelected}
|
||||
>
|
||||
Download {Math.min(selected.size, MAX_ENQUEUE_BATCH)} selected
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
appearance="primary"
|
||||
icon={<ArrowDownloadRegular />}
|
||||
onClick={downloadPending}
|
||||
disabled={pendingItems.length === 0}
|
||||
>
|
||||
Download {Math.min(pendingItems.length, MAX_ENQUEUE_BATCH)} pending
|
||||
</Button>
|
||||
)}
|
||||
<span className={styles.switchRow}>
|
||||
<Caption1>Watch</Caption1>
|
||||
<Switch
|
||||
checked={!!src.watched}
|
||||
onChange={(_, d) => setWatched(src.id, !!d.checked)}
|
||||
aria-label={`Watch ${src.title} for new uploads`}
|
||||
/>
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowSyncRegular />}
|
||||
onClick={() => reindexSource(src.id)}
|
||||
disabled={indexing.active}
|
||||
>
|
||||
Re-index
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<DeleteRegular />}
|
||||
onClick={() => removeSource(src.id)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{groups.map((g) => {
|
||||
const allOn = g.items.every((it) => selected.has(it.id))
|
||||
return (
|
||||
<div key={g.title} className={styles.group}>
|
||||
<div className={styles.groupHead}>
|
||||
<AppsListRegular />
|
||||
<span className={styles.groupTitle}>{g.title}</span>
|
||||
<Caption1 className={styles.srcSub}>{g.items.length}</Caption1>
|
||||
<Button
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
onClick={() => toggleGroup(g.items, !allOn)}
|
||||
>
|
||||
{allOn ? 'None' : 'All'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.rows}>
|
||||
{g.items.map((it) => {
|
||||
const status = effStatus(it)
|
||||
return (
|
||||
<div key={it.id} className={styles.row}>
|
||||
<Checkbox
|
||||
checked={selected.has(it.id)}
|
||||
onChange={(_, d) => toggle(it.id, !!d.checked)}
|
||||
aria-label={`Select ${it.title}`}
|
||||
/>
|
||||
<div className={styles.rowMain}>
|
||||
<span className={styles.rowTitle}>
|
||||
{it.playlistIndex}. {it.title}
|
||||
</span>
|
||||
{it.durationLabel && (
|
||||
<Caption1 className={styles.rowMeta}>{it.durationLabel}</Caption1>
|
||||
)}
|
||||
</div>
|
||||
<span className={pillClass(status)}>{STATUS_LABEL[status]}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SourceCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One collapsible source card (header always shown; children render when expanded). */
|
||||
function SourceCard({
|
||||
styles,
|
||||
source,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
children
|
||||
}: {
|
||||
styles: ReturnType<typeof useStyles>
|
||||
source: Source
|
||||
expanded: boolean
|
||||
onToggleExpand: () => void
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div
|
||||
className={styles.cardHead}
|
||||
onClick={onToggleExpand}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && onToggleExpand()}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
|
||||
<div className={styles.srcIcon}>
|
||||
<VideoClipMultipleRegular />
|
||||
</div>
|
||||
<div className={styles.srcMeta}>
|
||||
<span className={styles.srcTitleRow}>
|
||||
<span className={styles.srcTitle}>{source.title}</span>
|
||||
{source.watched && (
|
||||
<span className={styles.watchBadge}>
|
||||
<AlertRegular fontSize={11} /> Watching
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Caption1 className={styles.srcSub}>
|
||||
{source.kind === 'channel' ? 'Channel' : 'Playlist'} · {source.itemCount} videos
|
||||
{source.channel && source.channel !== source.title ? ` · ${source.channel}` : ''}
|
||||
</Caption1>
|
||||
</div>
|
||||
</div>
|
||||
{expanded && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
ArrowDownloadFilled,
|
||||
ArrowDownloadRegular,
|
||||
HistoryRegular,
|
||||
LibraryRegular,
|
||||
SettingsRegular,
|
||||
WeatherMoonRegular,
|
||||
WeatherSunnyRegular
|
||||
} from '@fluentui/react-icons'
|
||||
|
||||
export type TabValue = 'downloads' | 'history' | 'settings'
|
||||
export type TabValue = 'downloads' | 'library' | 'history' | 'settings'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
@@ -114,6 +115,7 @@ const useStyles = makeStyles({
|
||||
|
||||
const NAV: { value: TabValue; label: string; icon: React.JSX.Element }[] = [
|
||||
{ value: 'downloads', label: 'Downloads', icon: <ArrowDownloadRegular /> },
|
||||
{ value: 'library', label: 'Library', icon: <LibraryRegular /> },
|
||||
{ value: 'history', label: 'History', icon: <HistoryRegular /> },
|
||||
{ value: 'settings', label: 'Settings', icon: <SettingsRegular /> }
|
||||
]
|
||||
|
||||
@@ -31,6 +31,7 @@ if (import.meta.env.DEV && !window.api) {
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
hasCompletedOnboarding: true
|
||||
}
|
||||
// Stands in for the cookies.txt file's mtime — lets the Cookies card's
|
||||
@@ -139,7 +140,58 @@ if (import.meta.env.DEV && !window.api) {
|
||||
getSystemTheme: async () => ({ shouldUseDarkColors: false, shouldUseHighContrastColors: false }),
|
||||
onSystemThemeUpdate: () => () => {},
|
||||
openHighContrastSettings: async () => {},
|
||||
onExternalUrl: () => () => {}
|
||||
onExternalUrl: () => () => {},
|
||||
// Media-manager sources — a seeded channel so the (Phase H) Library view has
|
||||
// demo data in this browser-only preview.
|
||||
listSources: async () => [
|
||||
{
|
||||
id: 'src-demo',
|
||||
url: 'https://www.youtube.com/@DevChannel',
|
||||
kind: 'channel',
|
||||
title: 'DevChannel',
|
||||
channel: 'DevChannel',
|
||||
addedAt: Date.now() - 86_400_000,
|
||||
lastIndexedAt: Date.now() - 3_600_000,
|
||||
itemCount: 7
|
||||
}
|
||||
],
|
||||
indexSource: async (url) => {
|
||||
await new Promise((r) => setTimeout(r, 800)) // simulate the index walk
|
||||
return {
|
||||
ok: true,
|
||||
source: {
|
||||
id: 'src-demo',
|
||||
url,
|
||||
kind: 'channel',
|
||||
title: 'DevChannel',
|
||||
channel: 'DevChannel',
|
||||
addedAt: Date.now(),
|
||||
lastIndexedAt: Date.now(),
|
||||
itemCount: 7
|
||||
},
|
||||
itemCount: 7
|
||||
}
|
||||
},
|
||||
reindexSource: async () => ({ ok: true, itemCount: 7, newCount: 0 }),
|
||||
removeSource: async () => [],
|
||||
markSourceItemDownloaded: async () => [],
|
||||
setSourceWatched: async () => [],
|
||||
syncSources: async () => ({ ok: true, newItems: [] }),
|
||||
getScheduledSync: async () => ({ enabled: false }),
|
||||
setScheduledSync: async (enabled: boolean) => ({ enabled }),
|
||||
listSourceItems: async (sourceId) =>
|
||||
Array.from({ length: 7 }, (_, i) => ({
|
||||
id: `${sourceId}:vid${i + 1}`,
|
||||
sourceId,
|
||||
videoId: `vid${i + 1}`,
|
||||
title: `Episode ${i + 1}`,
|
||||
url: `https://youtube.com/watch?v=vid${i + 1}`,
|
||||
playlistTitle: i < 5 ? 'Full Electron Course' : 'Uploads',
|
||||
playlistIndex: i < 5 ? i + 1 : i - 4,
|
||||
durationLabel: `${10 + i}:0${i}`,
|
||||
downloaded: i < 2
|
||||
})),
|
||||
onIndexProgress: () => () => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { create } from 'zustand'
|
||||
import type { DownloadEvent, DownloadMeta, DownloadOptions } from '@shared/ipc'
|
||||
import type { DownloadEvent, DownloadMeta, DownloadOptions, CollectionContext } from '@shared/ipc'
|
||||
import { useSettings } from './settings'
|
||||
import { useHistory } from './history'
|
||||
import { useSources } from './sources'
|
||||
|
||||
export type MediaKind = 'video' | 'audio'
|
||||
|
||||
@@ -42,6 +43,10 @@ export interface DownloadItem {
|
||||
incognito?: boolean
|
||||
/** metadata already fetched at probe time; forwarded to main so it skips a redundant probe */
|
||||
probedMeta?: DownloadMeta
|
||||
/** media-manager folder context — files this into <channel>/<playlist>/<NNN> - <title> */
|
||||
collection?: CollectionContext
|
||||
/** when set, the source MediaItem id this download came from — marked downloaded on completion */
|
||||
mediaItemId?: string
|
||||
}
|
||||
|
||||
export const QUALITY_OPTIONS: Record<MediaKind, string[]> = {
|
||||
@@ -68,6 +73,8 @@ interface DownloadState {
|
||||
options?: DownloadOptions
|
||||
extraArgs?: string
|
||||
incognito?: boolean
|
||||
collection?: CollectionContext
|
||||
mediaItemId?: string
|
||||
}
|
||||
) => void
|
||||
retry: (id: string) => void
|
||||
@@ -210,6 +217,11 @@ function startFakeTicker(
|
||||
completedAt: Date.now()
|
||||
})
|
||||
}
|
||||
// Mirror the real applyEvent path so collection completions mark their
|
||||
// source item downloaded in the preview too.
|
||||
if (finished?.mediaItemId) {
|
||||
useSources.getState().markDownloaded(finished.mediaItemId, finished.filePath)
|
||||
}
|
||||
get().pump() // a slot just freed — promote the next queued item
|
||||
}
|
||||
}, 650)
|
||||
@@ -240,7 +252,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
formatHasAudio: item.formatHasAudio,
|
||||
options: item.options,
|
||||
extraArgs: item.extraArgs,
|
||||
meta: item.probedMeta
|
||||
meta: item.probedMeta,
|
||||
collection: item.collection
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res.ok) markError(item.id, res.error)
|
||||
@@ -301,6 +314,8 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
options: opts?.options,
|
||||
extraArgs: opts?.extraArgs,
|
||||
incognito: opts?.incognito,
|
||||
collection: opts?.collection,
|
||||
mediaItemId: opts?.mediaItemId,
|
||||
probedMeta
|
||||
}
|
||||
set((s) => ({ items: [item, ...s.items] }))
|
||||
@@ -411,6 +426,10 @@ export const useDownloads = create<DownloadState>((set, get) => {
|
||||
completedAt: Date.now()
|
||||
})
|
||||
}
|
||||
// Persist media-manager completion so an incremental re-sync skips it.
|
||||
if (item?.mediaItemId) {
|
||||
useSources.getState().markDownloaded(item.mediaItemId, item.filePath)
|
||||
}
|
||||
}
|
||||
// A finished item frees a slot — promote whatever is queued next.
|
||||
if (ev.type === 'done' || ev.type === 'error') pump()
|
||||
|
||||
@@ -25,6 +25,7 @@ const FALLBACK: Settings = {
|
||||
customCommandEnabled: false,
|
||||
defaultTemplateId: null,
|
||||
notifyOnComplete: true,
|
||||
autoDownloadNew: true,
|
||||
// True in preview so design work isn't blocked behind the welcome screen;
|
||||
// a real first launch gets `false` from main/settings.ts's DEFAULTS instead.
|
||||
hasCompletedOnboarding: PREVIEW
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { create } from 'zustand'
|
||||
import type { Source, MediaItem, IndexProgress } from '@shared/ipc'
|
||||
import { useDownloads } from './downloads'
|
||||
import { useSettings } from './settings'
|
||||
|
||||
// True in the standalone browser preview (no Electron preload).
|
||||
const PREVIEW = typeof window === 'undefined' || !window.electron
|
||||
|
||||
// Cap on how many items one "Download" click enqueues, so an entire channel
|
||||
// can't flood the live queue at once — the rest stay pending for a follow-up
|
||||
// click. (The automatic incremental feeder is Phase I; see ROADMAP-PINCHFLAT.md.)
|
||||
export const MAX_ENQUEUE_BATCH = 100
|
||||
|
||||
// --- Preview seed data ------------------------------------------------------
|
||||
|
||||
function seedItems(sourceId: string, playlist: string, n: number, downloadedUpTo = 0): MediaItem[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `${sourceId}:${playlist}-${i + 1}`,
|
||||
sourceId,
|
||||
videoId: `${playlist}-${i + 1}`,
|
||||
title: `${playlist} — part ${i + 1}`,
|
||||
url: `https://youtube.com/watch?v=${sourceId}${i + 1}`,
|
||||
playlistTitle: playlist,
|
||||
playlistIndex: i + 1,
|
||||
durationLabel: `${10 + i}:${String(i * 7).padStart(2, '0')}`,
|
||||
downloaded: i < downloadedUpTo
|
||||
}))
|
||||
}
|
||||
|
||||
const seedSources: Source[] = PREVIEW
|
||||
? [
|
||||
{
|
||||
id: 'src-dev',
|
||||
url: 'https://www.youtube.com/@DevChannel',
|
||||
kind: 'channel',
|
||||
title: 'DevChannel',
|
||||
channel: 'DevChannel',
|
||||
addedAt: Date.now() - 86_400_000,
|
||||
lastIndexedAt: Date.now() - 3_600_000,
|
||||
itemCount: 9
|
||||
},
|
||||
{
|
||||
id: 'src-mix',
|
||||
url: 'https://www.youtube.com/playlist?list=PLmix',
|
||||
kind: 'playlist',
|
||||
title: 'Lo-fi Coding Mixes',
|
||||
channel: 'ChillStudio',
|
||||
addedAt: Date.now() - 2 * 86_400_000,
|
||||
lastIndexedAt: Date.now() - 7_200_000,
|
||||
itemCount: 5
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const seedItemsBySource: Record<string, MediaItem[]> = PREVIEW
|
||||
? {
|
||||
'src-dev': [
|
||||
...seedItems('src-dev', 'Full Electron Course', 6, 2),
|
||||
...seedItems('src-dev', 'Uploads', 3)
|
||||
],
|
||||
'src-mix': seedItems('src-mix', 'Lo-fi Coding Mixes', 5, 1)
|
||||
}
|
||||
: {}
|
||||
|
||||
// --- Store ------------------------------------------------------------------
|
||||
|
||||
/** Live indexing status for the add-source bar. */
|
||||
interface IndexingState {
|
||||
active: boolean
|
||||
url?: string
|
||||
message?: string
|
||||
current?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
interface SourcesState {
|
||||
sources: Source[]
|
||||
/** media items per source, lazily loaded when a source is expanded */
|
||||
itemsBySource: Record<string, MediaItem[]>
|
||||
/** which source is expanded in the library view (null = none) */
|
||||
selectedSourceId: string | null
|
||||
indexing: IndexingState
|
||||
loadSources: () => void
|
||||
selectSource: (id: string | null) => void
|
||||
/** index a pasted channel/playlist URL; resolves with ok + an error message */
|
||||
indexSource: (url: string) => Promise<{ ok: boolean; error?: string }>
|
||||
reindexSource: (id: string) => Promise<void>
|
||||
removeSource: (id: string) => void
|
||||
/**
|
||||
* Enqueue the given media items into the download queue with folder context,
|
||||
* capped at MAX_ENQUEUE_BATCH. Returns how many were actually enqueued.
|
||||
*/
|
||||
enqueueItems: (sourceId: string, items: MediaItem[]) => number
|
||||
/** mark an item downloaded once its queue download completes (called by the downloads store) */
|
||||
markDownloaded: (itemId: string, filePath?: string) => void
|
||||
/** toggle a source's watched-for-new-uploads flag (Phase J) */
|
||||
setWatched: (id: string, watched: boolean) => void
|
||||
/** whether a sync is currently running (the "Check for new" button's busy state) */
|
||||
syncing: boolean
|
||||
/**
|
||||
* Re-index all watched sources and (when autoDownloadNew is on) enqueue the new
|
||||
* videos. Resolves with how many new videos were found.
|
||||
*/
|
||||
syncWatched: () => Promise<number>
|
||||
}
|
||||
|
||||
export const useSources = create<SourcesState>((set, get) => ({
|
||||
sources: seedSources,
|
||||
itemsBySource: seedItemsBySource,
|
||||
selectedSourceId: null,
|
||||
indexing: { active: false },
|
||||
syncing: false,
|
||||
|
||||
loadSources: () => {
|
||||
if (PREVIEW) return
|
||||
window.api
|
||||
.listSources()
|
||||
.then((sources) => set({ sources }))
|
||||
.catch(() => {})
|
||||
},
|
||||
|
||||
selectSource: (id) => {
|
||||
set({ selectedSourceId: id })
|
||||
// Lazily fetch a source's items the first time it's expanded.
|
||||
if (id && !get().itemsBySource[id] && !PREVIEW) {
|
||||
window.api
|
||||
.listSourceItems(id)
|
||||
.then((items) => set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } })))
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
|
||||
indexSource: async (url) => {
|
||||
set({ indexing: { active: true, url, message: 'Reading source…' } })
|
||||
if (PREVIEW) {
|
||||
await new Promise((r) => setTimeout(r, 900)) // simulate the index walk
|
||||
set({ indexing: { active: false } })
|
||||
return { ok: true }
|
||||
}
|
||||
try {
|
||||
const res = await window.api.indexSource(url)
|
||||
set({ indexing: { active: false } })
|
||||
if (res.ok) {
|
||||
get().loadSources()
|
||||
if (res.source) get().selectSource(res.source.id)
|
||||
}
|
||||
return { ok: res.ok, error: res.error }
|
||||
} catch (e) {
|
||||
set({ indexing: { active: false } })
|
||||
return { ok: false, error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
},
|
||||
|
||||
reindexSource: async (id) => {
|
||||
const src = get().sources.find((s) => s.id === id)
|
||||
set({ indexing: { active: true, url: src?.url, message: 'Re-indexing…' } })
|
||||
if (PREVIEW) {
|
||||
await new Promise((r) => setTimeout(r, 700))
|
||||
set({ indexing: { active: false } })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await window.api.reindexSource(id)
|
||||
set({ indexing: { active: false } })
|
||||
if (res.ok) {
|
||||
get().loadSources()
|
||||
const items = await window.api.listSourceItems(id)
|
||||
set((s) => ({ itemsBySource: { ...s.itemsBySource, [id]: items } }))
|
||||
}
|
||||
} catch {
|
||||
set({ indexing: { active: false } })
|
||||
}
|
||||
},
|
||||
|
||||
removeSource: (id) => {
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => x.id !== id),
|
||||
selectedSourceId: s.selectedSourceId === id ? null : s.selectedSourceId
|
||||
}))
|
||||
if (!PREVIEW) window.api.removeSource(id).catch(() => {})
|
||||
},
|
||||
|
||||
enqueueItems: (sourceId, items) => {
|
||||
const src = get().sources.find((s) => s.id === sourceId)
|
||||
const settings = useSettings.getState()
|
||||
const kind = settings.defaultKind
|
||||
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
||||
const batch = items.slice(0, MAX_ENQUEUE_BATCH)
|
||||
const add = useDownloads.getState().addFromUrl
|
||||
for (const it of batch) {
|
||||
add(it.url, kind, quality, {
|
||||
title: it.title,
|
||||
channel: src?.channel,
|
||||
durationLabel: it.durationLabel,
|
||||
collection: {
|
||||
channel: src?.channel || src?.title || 'Channel',
|
||||
playlist: it.playlistTitle,
|
||||
index: it.playlistIndex
|
||||
},
|
||||
mediaItemId: it.id
|
||||
})
|
||||
}
|
||||
return batch.length
|
||||
},
|
||||
|
||||
markDownloaded: (itemId, filePath) => {
|
||||
set((s) => {
|
||||
const next: Record<string, MediaItem[]> = {}
|
||||
let changed = false
|
||||
for (const [sid, list] of Object.entries(s.itemsBySource)) {
|
||||
if (list.some((m) => m.id === itemId)) {
|
||||
changed = true
|
||||
next[sid] = list.map((m) =>
|
||||
m.id === itemId ? { ...m, downloaded: true, downloadedAt: Date.now(), filePath } : m
|
||||
)
|
||||
} else {
|
||||
next[sid] = list
|
||||
}
|
||||
}
|
||||
return changed ? { itemsBySource: next } : {}
|
||||
})
|
||||
if (!PREVIEW) window.api.markSourceItemDownloaded(itemId, filePath).catch(() => {})
|
||||
},
|
||||
|
||||
setWatched: (id, watched) => {
|
||||
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
|
||||
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(() => {})
|
||||
},
|
||||
|
||||
syncWatched: async () => {
|
||||
if (get().syncing) return 0
|
||||
set({ syncing: true })
|
||||
try {
|
||||
if (PREVIEW) {
|
||||
await new Promise((r) => setTimeout(r, 700))
|
||||
return 0 // preview has no real feed to poll
|
||||
}
|
||||
const res = await window.api.syncSources()
|
||||
if (!res.ok) return 0
|
||||
get().loadSources()
|
||||
// Refresh any expanded source's items so new videos appear in the tree.
|
||||
const sel = get().selectedSourceId
|
||||
if (sel) {
|
||||
const items = await window.api.listSourceItems(sel)
|
||||
set((s) => ({ itemsBySource: { ...s.itemsBySource, [sel]: items } }))
|
||||
}
|
||||
// Auto-enqueue the new videos, grouped by source, when the setting is on.
|
||||
if (useSettings.getState().autoDownloadNew && res.newItems.length > 0) {
|
||||
const bySource = new Map<string, MediaItem[]>()
|
||||
for (const it of res.newItems) {
|
||||
const arr = bySource.get(it.sourceId) ?? []
|
||||
arr.push(it)
|
||||
bySource.set(it.sourceId, arr)
|
||||
}
|
||||
for (const [sid, items] of bySource) get().enqueueItems(sid, items)
|
||||
}
|
||||
return res.newItems.length
|
||||
} finally {
|
||||
set({ syncing: false })
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Load persisted sources on startup, and subscribe to live indexing progress.
|
||||
if (!PREVIEW) {
|
||||
window.api
|
||||
.listSources()
|
||||
.then((sources) => {
|
||||
useSources.setState({ sources })
|
||||
// If any source is watched, kick off a sync shortly after launch (covers the
|
||||
// scheduled `--sync` launch and a normal launch alike).
|
||||
if (sources.some((s) => s.watched)) {
|
||||
setTimeout(() => useSources.getState().syncWatched(), 1500)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
window.api.onIndexProgress((p: IndexProgress) => {
|
||||
useSources.setState({
|
||||
indexing: {
|
||||
active: p.phase !== 'done' && p.phase !== 'error',
|
||||
url: p.url,
|
||||
message: p.message,
|
||||
current: p.current,
|
||||
total: p.total
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
+147
-2
@@ -40,7 +40,24 @@ export const IpcChannels = {
|
||||
openHighContrastSettings: 'shell:open-high-contrast-settings',
|
||||
/** main → renderer push channel: a URL handed to AeroFetch from outside the app
|
||||
* (the aerofetch:// protocol, or a .url file via Explorer's "Send to" menu) */
|
||||
externalUrl: 'external-url'
|
||||
externalUrl: 'external-url',
|
||||
// --- Sources & media-manager indexing (Pinchflat-style, see ROADMAP-PINCHFLAT.md) ---
|
||||
sourcesList: 'sources:list',
|
||||
sourceIndex: 'sources:index',
|
||||
sourceReindex: 'sources:reindex',
|
||||
sourceRemove: 'sources:remove',
|
||||
sourceItems: 'sources:items',
|
||||
/** mark a media item downloaded once its collection download completes */
|
||||
sourceItemDownloaded: 'sources:item-downloaded',
|
||||
/** toggle whether a source is watched for new uploads (Phase J) */
|
||||
sourceSetWatched: 'sources:set-watched',
|
||||
/** re-index all watched sources and return the videos that are new */
|
||||
sourcesSync: 'sources:sync',
|
||||
/** get / set the Windows scheduled-sync task (Task Scheduler) */
|
||||
scheduledSyncGet: 'sources:scheduled-sync-get',
|
||||
scheduledSyncSet: 'sources:scheduled-sync-set',
|
||||
/** main → renderer push channel for live indexing progress */
|
||||
indexProgress: 'sources:index-progress'
|
||||
} as const
|
||||
|
||||
/** Sentinel format id meaning "let yt-dlp pick the best video+audio". */
|
||||
@@ -134,6 +151,12 @@ export interface DownloadOptions {
|
||||
embedThumbnail: boolean
|
||||
/** crop embedded audio artwork to a centred square (Seal's "crop artwork") */
|
||||
cropThumbnail: boolean
|
||||
/** write a .info.json metadata sidecar (Jellyfin/Plex/Kodi ingest — Phase K) */
|
||||
writeInfoJson: boolean
|
||||
/** write the thumbnail as a separate image file alongside the video */
|
||||
writeThumbnailFile: boolean
|
||||
/** write the video description to a .description sidecar file */
|
||||
writeDescription: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
||||
@@ -149,7 +172,10 @@ export const DEFAULT_DOWNLOAD_OPTIONS: DownloadOptions = {
|
||||
embedChapters: false,
|
||||
embedMetadata: true,
|
||||
embedThumbnail: true,
|
||||
cropThumbnail: false
|
||||
cropThumbnail: false,
|
||||
writeInfoJson: false,
|
||||
writeThumbnailFile: false,
|
||||
writeDescription: false
|
||||
}
|
||||
|
||||
export interface YtdlpVersionResult {
|
||||
@@ -225,6 +251,24 @@ export interface ProbeResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Media-manager folder context for a collection download (see ROADMAP-PINCHFLAT.md
|
||||
* Phase G). When present on a StartDownloadOptions, the file is filed into
|
||||
* `<outputDir>/<channel>/<playlist>/<NNN> - <title>.<ext>` instead of the flat
|
||||
* filenameTemplate. The directory segments are sanitized for Windows at argv-build
|
||||
* time, and `index` (the 1-based playlist position from the persisted MediaItem)
|
||||
* drives the NNN prefix — not yt-dlp's %(playlist_index)s, which is empty under
|
||||
* the per-video `--no-playlist` path these downloads still take.
|
||||
*/
|
||||
export interface CollectionContext {
|
||||
/** channel / uploader name — the top folder level */
|
||||
channel: string
|
||||
/** playlist title — the second folder level ('Uploads' for the catch-all) */
|
||||
playlist: string
|
||||
/** 1-based position within the playlist, for the NNN filename prefix */
|
||||
index: number
|
||||
}
|
||||
|
||||
/** Sent renderer → main to kick off a download. The renderer owns the id. */
|
||||
export interface StartDownloadOptions {
|
||||
id: string
|
||||
@@ -255,6 +299,12 @@ export interface StartDownloadOptions {
|
||||
* race where the late probe overwrites a good title the renderer supplied.
|
||||
*/
|
||||
meta?: DownloadMeta
|
||||
/**
|
||||
* When set, this is a media-manager (collection) download — file it into
|
||||
* `<channel>/<playlist>/<NNN> - <title>` folders instead of the flat
|
||||
* filenameTemplate. See CollectionContext.
|
||||
*/
|
||||
collection?: CollectionContext
|
||||
}
|
||||
|
||||
export interface StartDownloadResult {
|
||||
@@ -327,6 +377,8 @@ export interface Settings {
|
||||
defaultTemplateId: string | null
|
||||
/** show a native OS notification when a download finishes or fails */
|
||||
notifyOnComplete: boolean
|
||||
/** auto-enqueue newly-found videos from watched sources during a sync (Phase J) */
|
||||
autoDownloadNew: boolean
|
||||
/** false until the first-run welcome screen has been dismissed */
|
||||
hasCompletedOnboarding: boolean
|
||||
}
|
||||
@@ -410,3 +462,96 @@ export interface BackupImportResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
// --- Sources & media-manager indexing (Pinchflat-style) ---------------------
|
||||
// See ROADMAP-PINCHFLAT.md. A Source (channel/playlist) is indexed once into a
|
||||
// persisted list of MediaItem records; the live download queue then pulls from
|
||||
// that list a batch at a time, so "download an entire channel" never has to hold
|
||||
// the whole collection in the queue at once.
|
||||
|
||||
/** Whether a Source is a whole channel or a single playlist. */
|
||||
export type SourceKind = 'channel' | 'playlist'
|
||||
|
||||
/**
|
||||
* A monitored channel or playlist that AeroFetch has indexed. Its full video
|
||||
* list lives as MediaItem records (one JSON store), keyed back by `id`.
|
||||
*/
|
||||
export interface Source {
|
||||
id: string
|
||||
/** the URL the user added (used for re-indexing) */
|
||||
url: string
|
||||
kind: SourceKind
|
||||
title: string
|
||||
/** channel / uploader name, when known */
|
||||
channel?: string
|
||||
/** epoch ms the source was first added */
|
||||
addedAt: number
|
||||
/** epoch ms of the last successful (re)index; undefined if never indexed */
|
||||
lastIndexedAt?: number
|
||||
/** number of MediaItems at the last index (cached for list display) */
|
||||
itemCount: number
|
||||
/** watched for new uploads — included in scheduled / startup sync (Phase J) */
|
||||
watched?: boolean
|
||||
/** YouTube RSS feed URL for cheap "anything new?" checks; undefined if unknown */
|
||||
feedUrl?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One video discovered while indexing a Source. `playlistTitle`/`playlistIndex`
|
||||
* drive the on-disk folder layout (<Channel>/<Playlist>/<NNN> - <Title>); the
|
||||
* `downloaded` flag + `filePath` let the library view show per-item state and
|
||||
* let re-syncs skip what's already on disk.
|
||||
*/
|
||||
export interface MediaItem {
|
||||
/** globally unique, formed as `${sourceId}:${videoId}` */
|
||||
id: string
|
||||
sourceId: string
|
||||
/** the yt-dlp video id — the dedup key within a source */
|
||||
videoId: string
|
||||
title: string
|
||||
url: string
|
||||
/** the playlist this item is filed under; 'Uploads' for videos in no playlist */
|
||||
playlistTitle: string
|
||||
/** 1-based position within its playlist, for NNN numbering */
|
||||
playlistIndex: number
|
||||
durationLabel?: string
|
||||
downloaded: boolean
|
||||
downloadedAt?: number
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
/** Live progress pushed while a Source is being indexed (main → renderer). */
|
||||
export interface IndexProgress {
|
||||
/** the Source URL being indexed (correlates events back to the request) */
|
||||
url: string
|
||||
phase: 'start' | 'playlists' | 'playlist' | 'uploads' | 'done' | 'error'
|
||||
/** human-readable status line */
|
||||
message: string
|
||||
/** for the per-playlist phase: playlists processed so far / total */
|
||||
current?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** Result of indexing (or re-indexing) a Source. */
|
||||
export interface IndexSourceResult {
|
||||
ok: boolean
|
||||
source?: Source
|
||||
itemCount?: number
|
||||
/** how many videos were new since the previous index (all of them on first index) */
|
||||
newCount?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Result of syncing all watched sources: the videos found new across them. */
|
||||
export interface SyncResult {
|
||||
ok: boolean
|
||||
/** newly-discovered media items across all watched sources (empty if nothing new) */
|
||||
newItems: MediaItem[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** Whether the Windows scheduled-sync task is currently registered. */
|
||||
export interface ScheduledSyncStatus {
|
||||
enabled: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user