import type { MarketApiResult } from "./api-client"; import { createMarketCacheStore } from "./cache-store"; import type { RowErrorReason, MarketRowState } from "./row-state"; import type { RequiredAfterSearchRates } from "./types"; interface BatchLoaderRow { authorId: string | null; render( state: MarketRowState, options?: { onRetry?: () => Promise | void } ): void; } interface LoadRowsOptions { listSeq: number; rows: BatchLoaderRow[]; shouldRenderResult?: (params: { authorId: string; listSeq: number; row: BatchLoaderRow; }) => boolean; } interface MarketBatchLoaderOptions { apiClient: { loadAuthorAseInfo(authorId: string): Promise; }; cacheStore?: ReturnType; concurrency?: number; } export function createMarketBatchLoader(options: MarketBatchLoaderOptions) { const cacheStore = options.cacheStore ?? createMarketCacheStore(); const concurrency = Math.max(options.concurrency ?? 4, 1); return { async loadRows(loadOptions: LoadRowsOptions) { const groupedRows = new Map(); for (const row of loadOptions.rows) { if (!row.authorId) { row.render({ authorId: null, listSeq: loadOptions.listSeq, reason: "missing-author-id", retryable: false, state: "error" }); continue; } const cached = cacheStore.getSuccess(row.authorId); if (cached) { row.render({ authorId: row.authorId, listSeq: loadOptions.listSeq, personalVideoAfterSearchRate: cached.rates.personalVideoAfterSearchRate, singleVideoAfterSearchRate: cached.rates.singleVideoAfterSearchRate, source: "cache", state: "success" }); continue; } row.render({ authorId: row.authorId, listSeq: loadOptions.listSeq, state: "loading" }); const rowsForAuthor = groupedRows.get(row.authorId) ?? []; rowsForAuthor.push(row); groupedRows.set(row.authorId, rowsForAuthor); } const tasks = Array.from(groupedRows.entries(), ([authorId, rows]) => { return () => loadAuthorRows(authorId, rows, loadOptions); }); await runWithConcurrency(tasks, concurrency); } }; async function loadAuthorRows( authorId: string, rows: BatchLoaderRow[], loadOptions: LoadRowsOptions ) { const result = await requestAuthor(authorId); for (const row of rows) { if ( loadOptions.shouldRenderResult && !loadOptions.shouldRenderResult({ authorId, listSeq: loadOptions.listSeq, row }) ) { continue; } renderAuthorResult(row, authorId, loadOptions.listSeq, result, false); } } async function retryRow(row: BatchLoaderRow, listSeq: number) { if (!row.authorId) { return; } row.render({ authorId: row.authorId, listSeq, state: "loading" }); const result = await requestAuthor(row.authorId); renderAuthorResult(row, row.authorId, listSeq, result, false); } async function requestAuthor(authorId: string): Promise { const cached = cacheStore.getSuccess(authorId); if (cached) { return { rates: cached.rates, success: true }; } const inflight = cacheStore.getInflight(authorId); if (inflight) { return inflight; } const requestPromise = options.apiClient .loadAuthorAseInfo(authorId) .then((result) => { if (result.success) { cacheStore.setSuccess(authorId, result.rates); } return result; }) .finally(() => { cacheStore.clearInflight(authorId); }); cacheStore.setInflight(authorId, requestPromise); return requestPromise; } function renderAuthorResult( row: BatchLoaderRow, authorId: string, listSeq: number, result: MarketApiResult, fromCache: boolean ) { if (result.success) { const rates = result.rates as RequiredAfterSearchRates; row.render({ authorId, listSeq, personalVideoAfterSearchRate: rates.personalVideoAfterSearchRate, singleVideoAfterSearchRate: rates.singleVideoAfterSearchRate, source: fromCache ? "cache" : "network", state: "success" }); return; } row.render( { authorId, listSeq, reason: result.reason as RowErrorReason, retryable: true, state: "error" }, { onRetry: () => retryRow(row, listSeq) } ); } } async function runWithConcurrency( tasks: Array<() => Promise>, concurrency: number ) { let nextTaskIndex = 0; const workers = Array.from( { length: Math.min(concurrency, tasks.length) }, async () => { while (nextTaskIndex < tasks.length) { const task = tasks[nextTaskIndex]; nextTaskIndex += 1; await task(); } } ); await Promise.all(workers); }