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