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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:50:13 -04:00
parent 81b97ea429
commit 47da3e6746
26 changed files with 2529 additions and 46 deletions
+2
View File
@@ -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>
)
}
+568
View File
@@ -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>
)
}
+3 -1
View File
@@ -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 /> }
]
+53 -1
View File
@@ -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: () => () => {}
}
}
+21 -2
View File
@@ -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()
+1
View File
@@ -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
+288
View File
@@ -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
}
})
})
}