Files
AeroFetch/src/renderer/src/store/sources.ts
T
debont80 7e3e5af52d Fix C1/C2 + reliability cluster (L140/L141/L148, R6/R7)
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>
2026-06-30 20:26:18 -04:00

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
}
})
})
}