7e3e5af52d
Critical: - C1: single source of truth for IPC mock + Settings defaults. DEFAULT_SETTINGS in @shared/ipc; main DEFAULTS, renderer FALLBACK, and preview MOCK_SETTINGS all derive from it. Whole browser mock collapsed into one typed mockApi.ts; main.tsx shrinks to window.api = mockApi. PREVIEW check single-sourced in isPreview.ts. - C2: break the downloads<->sources circular import via a typed event bus (store/coordinator.ts). App eagerly imports sources for side-effects so startup load + scheduled --sync + downloadCompleted subscription still run. Reliability: - L140/L148: cancel/pause release the active slot synchronously; identity-guarded releaseActive; per-spawn cookie jar so a same-id retry/resume is never rejected by a stale entry nor run over the concurrency cap. - L141: renderer history capped at 500 + url de-dup to match main. - R6: writeJsonAtomic reports/logs write failures and keeps data dirty for retry instead of an empty catch. - R7: encryptSecret warns before the plaintext fallback. typecheck + 243 tests + eslint green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
import { create } from 'zustand'
|
|
import type { Source, MediaItem, IndexProgress, MediaKind } from '@shared/ipc'
|
|
import { isPreview as PREVIEW } from '../isPreview'
|
|
import { useSettings } from './settings'
|
|
import { emit, on } from './coordinator'
|
|
import { logError } from '../reportError'
|
|
|
|
// --- 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.
|
|
* Queues all of them -- one click can fill the queue with an entire channel,
|
|
* which maxConcurrent then gates into download slots. Returns how many were
|
|
* enqueued.
|
|
*/
|
|
enqueueItems: (sourceId: string, items: MediaItem[], kind?: MediaKind) => 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(logError('listSources'))
|
|
},
|
|
|
|
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(logError('listSourceItems'))
|
|
}
|
|
},
|
|
|
|
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 (e) {
|
|
logError('reindexSource')(e)
|
|
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(logError('removeSource'))
|
|
},
|
|
|
|
enqueueItems: (sourceId, items, kindOverride) => {
|
|
const src = get().sources.find((s) => s.id === sourceId)
|
|
const settings = useSettings.getState()
|
|
// Per-batch override (Library video/audio toggle) falls back to the global default (M27).
|
|
const kind = kindOverride ?? settings.defaultKind
|
|
const quality = kind === 'video' ? settings.defaultVideoQuality : settings.defaultAudioQuality
|
|
// Hand the batch to the downloads store via the event bus (C2) rather than a
|
|
// direct import. addMany applies it as one batched state update + pump, so
|
|
// queuing a whole channel stays O(n).
|
|
emit(
|
|
'enqueueDownloads',
|
|
items.map((it) => ({
|
|
url: it.url,
|
|
kind,
|
|
quality,
|
|
opts: {
|
|
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 items.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(logError('markSourceItemDownloaded'))
|
|
},
|
|
|
|
setWatched: (id, watched) => {
|
|
set((s) => ({ sources: s.sources.map((x) => (x.id === id ? { ...x, watched } : x)) }))
|
|
if (!PREVIEW) window.api.setSourceWatched(id, watched).catch(logError('setSourceWatched'))
|
|
},
|
|
|
|
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 })
|
|
}
|
|
}
|
|
}))
|
|
|
|
// When a download tied to one of our MediaItems finishes, the downloads store
|
|
// emits this on the bus (C2) — mark the item downloaded so the library reflects it.
|
|
on('downloadCompleted', ({ mediaItemId, filePath }) =>
|
|
useSources.getState().markDownloaded(mediaItemId, filePath)
|
|
)
|
|
|
|
// 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(logError('startup listSources'))
|
|
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
|
|
}
|
|
})
|
|
})
|
|
}
|