diff --git a/apps/cms/config/plugins.ts b/apps/cms/config/plugins.ts index 73191dcf..9599b4ba 100644 --- a/apps/cms/config/plugins.ts +++ b/apps/cms/config/plugins.ts @@ -47,9 +47,6 @@ const config = ({ // dynamic-zone unions (reads .kind on undefined nodes). Set high // to avoid the crash path in the library's recursive traversal. depthLimit: 100, - // Cap the maximum number of items returned in a single collection - // field to limit N+1 association queries that exhaust the DB pool. - maxLimit: 100, landingPage: env("NODE_ENV") !== "production", generateArtifacts: true, artifacts: { diff --git a/apps/cms/src/api/video-coverage/controllers/video-coverage.ts b/apps/cms/src/api/video-coverage/controllers/video-coverage.ts new file mode 100644 index 00000000..431c6943 --- /dev/null +++ b/apps/cms/src/api/video-coverage/controllers/video-coverage.ts @@ -0,0 +1,24 @@ +import type { Core } from "@strapi/strapi" +import { queryVideoCoverage } from "../services/video-coverage" + +type StrapiContext = { + status: number + body: unknown + query: Record +} + +export default ({ strapi }: { strapi: Core.Strapi }) => ({ + async index(ctx: StrapiContext) { + const languageIds = ctx.query.languageIds + ? ctx.query.languageIds.split(",").filter(Boolean) + : undefined + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const knex = (strapi.db as any).connection + + const videos = await queryVideoCoverage(knex, languageIds) + + ctx.status = 200 + ctx.body = { videos } + }, +}) diff --git a/apps/cms/src/api/video-coverage/routes/video-coverage.ts b/apps/cms/src/api/video-coverage/routes/video-coverage.ts new file mode 100644 index 00000000..bfad9b0d --- /dev/null +++ b/apps/cms/src/api/video-coverage/routes/video-coverage.ts @@ -0,0 +1,14 @@ +export default { + routes: [ + { + method: "GET", + path: "/video-coverage", + handler: "video-coverage.index", + config: { + auth: false, + policies: [], + middlewares: [], + }, + }, + ], +} diff --git a/apps/cms/src/api/video-coverage/services/video-coverage.ts b/apps/cms/src/api/video-coverage/services/video-coverage.ts new file mode 100644 index 00000000..b0e8d314 --- /dev/null +++ b/apps/cms/src/api/video-coverage/services/video-coverage.ts @@ -0,0 +1,177 @@ +/** + * Video Coverage Service + * + * Computes per-video coverage counts via SQL aggregation. Returns all + * published videos with metadata, parent-child links, image URLs, and + * subtitle/audio coverage counts broken down by human vs AI. + * + * Follows the same SQL pattern as coverage-snapshot service but returns + * per-video detail instead of library-wide aggregates. + * + * Critical: All queries filter `published_at IS NOT NULL` to avoid + * counting Strapi v5 draft rows. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type KnexInstance = any + +type CoverageCounts = { + human: number + ai: number +} + +type VideoCoverageRow = { + document_id: string + core_id: string | null + title: string | null + label: string | null + slug: string | null + ai_metadata: boolean | null + thumbnail: string | null + video_still: string | null + parent_document_ids: string[] | null + sub_human: number + sub_ai: number + aud_human: number + aud_ai: number +} + +type VideoCoverageResult = { + documentId: string + coreId: string | null + title: string | null + label: string | null + slug: string | null + aiMetadata: boolean | null + thumbnailUrl: string | null + videoStillUrl: string | null + parentDocumentIds: string[] + coverage: { + subtitles: CoverageCounts + audio: CoverageCounts + } +} + +export async function queryVideoCoverage( + knex: KnexInstance, + languageIds?: string[], +): Promise { + const hasLangFilter = languageIds && languageIds.length > 0 + + // Two-step approach: first compute per-video, per-language coverage in CTEs, + // then aggregate to per-video counts and join with video metadata. + + const bindings: unknown[] = [] + + // Subtitle coverage CTE + const subLangClause = hasLangFilter ? `AND l.core_id = ANY(?)` : "" + if (hasLangFilter) bindings.push(languageIds) + + // Variant coverage CTE + const audLangClause = hasLangFilter ? `AND l.core_id = ANY(?)` : "" + if (hasLangFilter) bindings.push(languageIds) + + const sql = ` + WITH subtitle_per_lang AS ( + SELECT + v.document_id AS vid, + l.core_id AS lang_core_id, + BOOL_OR(NOT COALESCE(s.ai_generated, true)) AS has_human + FROM video_subtitles s + JOIN video_subtitles_video_lnk svl ON svl.video_subtitle_id = s.id + JOIN video_subtitles_language_lnk sll ON sll.video_subtitle_id = s.id + JOIN videos v ON v.id = svl.video_id AND v.published_at IS NOT NULL + JOIN languages l ON l.id = sll.language_id ${subLangClause} + WHERE s.published_at IS NOT NULL + GROUP BY v.document_id, l.core_id + ), + subtitle_cov AS ( + SELECT + vid, + COUNT(*) FILTER (WHERE has_human) AS sub_human, + COUNT(*) FILTER (WHERE NOT has_human) AS sub_ai + FROM subtitle_per_lang + GROUP BY vid + ), + variant_per_lang AS ( + SELECT + v.document_id AS vid, + l.core_id AS lang_core_id, + BOOL_OR(NOT COALESCE(vr.ai_generated, true)) AS has_human + FROM video_variants vr + JOIN video_variants_video_lnk vvl ON vvl.video_variant_id = vr.id + JOIN video_variants_language_lnk vll ON vll.video_variant_id = vr.id + JOIN videos v ON v.id = vvl.video_id AND v.published_at IS NOT NULL + JOIN languages l ON l.id = vll.language_id ${audLangClause} + WHERE vr.published_at IS NOT NULL + GROUP BY v.document_id, l.core_id + ), + variant_cov AS ( + SELECT + vid, + COUNT(*) FILTER (WHERE has_human) AS aud_human, + COUNT(*) FILTER (WHERE NOT has_human) AS aud_ai + FROM variant_per_lang + GROUP BY vid + ), + parent_links AS ( + SELECT + child.document_id AS child_doc_id, + ARRAY_AGG(DISTINCT parent.document_id) AS parent_document_ids + FROM videos_children_lnk cl + JOIN videos parent ON parent.id = cl.video_id AND parent.published_at IS NOT NULL + JOIN videos child ON child.id = cl.inv_video_id AND child.published_at IS NOT NULL + GROUP BY child.document_id + ), + video_image AS ( + SELECT DISTINCT ON (v.document_id) + v.document_id AS vid, + vi.thumbnail, + vi.video_still + FROM videos v + JOIN video_images_video_lnk vil ON vil.video_id = v.id + JOIN video_images vi ON vi.id = vil.video_image_id AND vi.published_at IS NOT NULL + WHERE v.published_at IS NOT NULL + ORDER BY v.document_id, vi.id + ) + SELECT + v.document_id, + v.core_id, + v.title, + v.label, + v.slug, + v.ai_metadata, + img.thumbnail, + img.video_still, + pl.parent_document_ids, + COALESCE(sc.sub_human, 0)::int AS sub_human, + COALESCE(sc.sub_ai, 0)::int AS sub_ai, + COALESCE(vc.aud_human, 0)::int AS aud_human, + COALESCE(vc.aud_ai, 0)::int AS aud_ai + FROM videos v + LEFT JOIN subtitle_cov sc ON sc.vid = v.document_id + LEFT JOIN variant_cov vc ON vc.vid = v.document_id + LEFT JOIN parent_links pl ON pl.child_doc_id = v.document_id + LEFT JOIN video_image img ON img.vid = v.document_id + WHERE v.published_at IS NOT NULL + ORDER BY v.title NULLS LAST + ` + + const result: { rows: VideoCoverageRow[] } = await knex.raw(sql, bindings) + + return result.rows.map((row) => ({ + documentId: row.document_id, + coreId: row.core_id, + title: row.title, + label: row.label, + slug: row.slug, + aiMetadata: row.ai_metadata, + thumbnailUrl: row.thumbnail, + videoStillUrl: row.video_still, + parentDocumentIds: row.parent_document_ids ?? [], + coverage: { + subtitles: { human: row.sub_human, ai: row.sub_ai }, + audio: { human: row.aud_human, ai: row.aud_ai }, + }, + })) +} diff --git a/apps/manager/src/app/api/languages/route.ts b/apps/manager/src/app/api/languages/route.ts index 45fd8612..485f7bf5 100644 --- a/apps/manager/src/app/api/languages/route.ts +++ b/apps/manager/src/app/api/languages/route.ts @@ -193,8 +193,8 @@ async function fetchLanguagePayload(): Promise { export const languageCache = createSwrCache({ fetcher: fetchLanguagePayload, - ttlMs: 5 * 60_000, // 5 minutes — geo data changes only on core sync - maxStaleMs: 60 * 60_000, // 1 hour — hard limit + ttlMs: 24 * 60 * 60_000, // 24 hours — geo data changes only on core sync + maxStaleMs: 48 * 60 * 60_000, // 48 hours — hard limit label: "language-cache", }) diff --git a/apps/manager/src/app/api/videos/route.ts b/apps/manager/src/app/api/videos/route.ts index f4293309..eb18721b 100644 --- a/apps/manager/src/app/api/videos/route.ts +++ b/apps/manager/src/app/api/videos/route.ts @@ -1,90 +1,29 @@ import { NextResponse } from "next/server" -import { graphql } from "@forge/graphql" import { authenticateRequest } from "@/lib/auth" -import getClient from "@/cms/client" -import { - type PageInfo, - DEFAULT_PAGE_INFO, - fetchAllPages, -} from "@/lib/strapi-pagination" +import { env } from "@/config/env" import { createSwrCache } from "@/lib/swr-cache" // --------------------------------------------------------------------------- -// Typed queries +// Types from CMS /api/video-coverage endpoint // --------------------------------------------------------------------------- -// Flat query: fetches ALL videos at the top level with `parents` for hierarchy -// reconstruction. Explicit `pagination: { limit: -1 }` on nested relations to -// avoid Strapi v5's default limit of 10. -// Only fetches fields needed for coverage computation (aiGenerated + language). -const GET_VIDEOS_CONNECTION = graphql(` - query GetVideosApi($pagination: PaginationArg) { - videos_connection(pagination: $pagination) { - nodes { - documentId - coreId - title - label - slug - aiMetadata - images(pagination: { limit: -1 }) { - thumbnail - videoStill - } - parents(pagination: { limit: -1 }) { - documentId - } - variants(pagination: { limit: -1 }) { - aiGenerated - language { - coreId - } - } - subtitles(pagination: { limit: -1 }) { - aiGenerated - language { - coreId - } - } - } - pageInfo { - page - pageCount - pageSize - total - } - } - } -`) - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -type RawMediaItem = { - aiGenerated: boolean | null - language: { coreId: string | null } | null -} - -type RawImage = { - thumbnail: string | null - videoStill: string | null -} - -type RawVideoNode = { +type CmsVideoCoverage = { documentId: string coreId: string | null title: string | null label: string | null slug: string | null aiMetadata: boolean | null - images: RawImage[] | null - parents: Array<{ documentId: string }> | null - variants: RawMediaItem[] | null - subtitles: RawMediaItem[] | null + thumbnailUrl: string | null + videoStillUrl: string | null + parentDocumentIds: string[] + coverage: { + subtitles: { human: number; ai: number } + audio: { human: number; ai: number } + } } -type CoverageStatus = "human" | "ai" | "none" +type CoverageCounts = { human: number; ai: number; none: number } const LABEL_DISPLAY: Record = { collection: "Collection", @@ -99,74 +38,45 @@ const LABEL_DISPLAY: Record = { } // --------------------------------------------------------------------------- -// Coverage helpers +// Fetch from CMS video-coverage endpoint // --------------------------------------------------------------------------- -function determineCoverageForItems( - items: RawMediaItem[], - selectedLanguageIds: Set, -): CoverageStatus { - // When no languages selected, evaluate ALL items to show global coverage - const matching = - selectedLanguageIds.size === 0 - ? items.filter((item) => item.language?.coreId) - : items.filter( - (item) => - item.language?.coreId && - selectedLanguageIds.has(item.language.coreId), - ) - - if (matching.length === 0) return "none" +async function fetchVideoCoverage( + languageIds?: string[], +): Promise { + const params = new URLSearchParams() + if (languageIds && languageIds.length > 0) { + params.set("languageIds", languageIds.join(",")) + } + const qs = params.toString() + const url = `${env.STRAPI_URL}/api/video-coverage${qs ? `?${qs}` : ""}` - const allAi = matching.every((item) => item.aiGenerated) - return allAi ? "ai" : "human" -} + const response = await fetch(url, { + headers: { Authorization: `Bearer ${env.STRAPI_API_TOKEN}` }, + signal: AbortSignal.timeout(10_000), + }) -function determineCoverage( - video: RawVideoNode, - selectedLanguageIds: Set, -): { subtitles: CoverageStatus; audio: CoverageStatus; meta: CoverageStatus } { - return { - subtitles: determineCoverageForItems( - video.subtitles ?? [], - selectedLanguageIds, - ), - audio: determineCoverageForItems(video.variants ?? [], selectedLanguageIds), - meta: - video.aiMetadata === true - ? "ai" - : video.aiMetadata === false - ? "human" - : "none", + if (!response.ok) { + throw new Error( + `CMS /api/video-coverage returned ${response.status}: ${await response.text()}`, + ) } + + const data = (await response.json()) as { videos: CmsVideoCoverage[] } + return data.videos } // --------------------------------------------------------------------------- -// SWR cache for video nodes (avoids ~4s Strapi query on every request) +// SWR cache — refreshes in <2s (down from 22-47s with GraphQL) // --------------------------------------------------------------------------- -async function fetchVideoNodes(): Promise { - const client = getClient() - return fetchAllPages(async (page) => { - const result = await client.query({ - query: GET_VIDEOS_CONNECTION, - variables: { pagination: { page, pageSize: 5000 } }, - fetchPolicy: "no-cache", - }) - const conn = result.data?.videos_connection - return { - nodes: (conn?.nodes ?? []) as unknown as RawVideoNode[], - pageInfo: (conn?.pageInfo ?? DEFAULT_PAGE_INFO) as PageInfo, - } - }) -} - export const videoCache = createSwrCache({ - fetcher: fetchVideoNodes, - ttlMs: 2 * 60_000, // 2 minutes — actively edited content - maxStaleMs: 30 * 60_000, // 30 minutes — hard limit + fetcher: () => fetchVideoCoverage(), + ttlMs: 2 * 60_000, + maxStaleMs: 30 * 60_000, label: "video-cache", }) + // --------------------------------------------------------------------------- // Route handler // --------------------------------------------------------------------------- @@ -177,23 +87,34 @@ export async function GET(request: Request) { const url = new URL(request.url) const languageIds = url.searchParams.get("languageIds")?.split(",") ?? [] - const selectedSet = new Set(languageIds.filter(Boolean)) + const selectedLanguages = languageIds.filter(Boolean) try { - const videoNodes = await videoCache.get() + // Fetch from CMS — use cached global data for unfiltered requests, + // direct fetch for language-filtered requests (SQL is fast enough). + const videos = + selectedLanguages.length === 0 + ? await videoCache.get() + : await fetchVideoCoverage(selectedLanguages) + + const numSelected = selectedLanguages.length + + function toCoverageCounts(counts: { + human: number + ai: number + }): CoverageCounts { + return { + human: counts.human, + ai: counts.ai, + none: + numSelected > 0 + ? Math.max(0, numSelected - counts.human - counts.ai) + : 0, + } + } - function toVideoItem(video: RawVideoNode) { - const variantLanguageIds = (video.variants ?? []) - .map((v) => v.language?.coreId) - .filter((id): id is string => id != null) - const subtitleLanguageIds = (video.subtitles ?? []) - .map((s) => s.language?.coreId) - .filter((id): id is string => id != null) - - const firstImage = (video.images ?? []).find( - (img) => img.thumbnail || img.videoStill, - ) - const imageUrl = firstImage?.thumbnail ?? firstImage?.videoStill ?? null + function toVideoItem(video: CmsVideoCoverage) { + const imageUrl = video.thumbnailUrl ?? video.videoStillUrl ?? null return { id: String(video.coreId ?? video.documentId), @@ -201,23 +122,28 @@ export async function GET(request: Request) { video.title ?? video.slug ?? String(video.coreId ?? video.documentId), imageUrl, label: video.label ?? "unknown", - coverage: determineCoverage(video, selectedSet), - variantLanguageIds, - subtitleLanguageIds, + coverage: { + subtitles: toCoverageCounts(video.coverage.subtitles), + audio: toCoverageCounts(video.coverage.audio), + meta: { + human: video.aiMetadata === false ? 1 : 0, + ai: video.aiMetadata === true ? 1 : 0, + none: video.aiMetadata == null ? 1 : 0, + } satisfies CoverageCounts, + }, } } - // Reconstruct parent-child hierarchy from the flat video list. - // Each video's `parents` field tells us which videos it belongs to. - const videoMap = new Map(videoNodes.map((v) => [v.documentId, v])) + // Reconstruct parent-child hierarchy from parentDocumentIds + const videoMap = new Map(videos.map((v) => [v.documentId, v])) - const parentChildrenMap = new Map() - for (const video of videoNodes) { - for (const parent of video.parents ?? []) { - let children = parentChildrenMap.get(parent.documentId) + const parentChildrenMap = new Map() + for (const video of videos) { + for (const parentDocId of video.parentDocumentIds) { + let children = parentChildrenMap.get(parentDocId) if (!children) { children = [] - parentChildrenMap.set(parent.documentId, children) + parentChildrenMap.set(parentDocId, children) } children.push(video) } @@ -228,6 +154,11 @@ export async function GET(request: Request) { title: string label: string labelDisplay: string + coverage: { + subtitles: CoverageCounts + audio: CoverageCounts + meta: CoverageCounts + } videos: ReturnType[] }> = [] @@ -235,35 +166,31 @@ export async function GET(request: Request) { const parent = videoMap.get(parentDocId) if (!parent) continue + const parentItem = toVideoItem(parent) + collections.push({ - id: String(parent.coreId ?? parent.documentId), - title: - parent.title ?? - parent.slug ?? - String(parent.coreId ?? parent.documentId), - label: parent.label ?? "unknown", + id: parentItem.id, + title: parentItem.title, + label: parentItem.label, labelDisplay: LABEL_DISPLAY[parent.label ?? "unknown"] ?? parent.label ?? "unknown", + coverage: parentItem.coverage, videos: children.map(toVideoItem), }) } + collections.sort((a, b) => a.title.localeCompare(b.title)) + // Videos that aren't children of any parent and have no children themselves - const standalone = videoNodes.filter( - (v) => - (v.parents ?? []).length === 0 && !parentChildrenMap.has(v.documentId), - ) - if (standalone.length > 0) { - collections.push({ - id: "standalone", - title: "Standalone Videos", - label: "standalone", - labelDisplay: "Standalone", - videos: standalone.map(toVideoItem), - }) - } + const standalone = videos + .filter( + (v) => + v.parentDocumentIds.length === 0 && + !parentChildrenMap.has(v.documentId), + ) + .map(toVideoItem) - return NextResponse.json({ collections }) + return NextResponse.json({ collections, standalone }) } catch (error) { console.error( "[api/videos] Failed to fetch video data:", diff --git a/apps/manager/src/app/globals.css b/apps/manager/src/app/globals.css index 3ca54caf..182b0b91 100644 --- a/apps/manager/src/app/globals.css +++ b/apps/manager/src/app/globals.css @@ -695,6 +695,56 @@ body.jobs-standalone code { color: var(--color-ink-inverse); } +.mode-toggle-button.is-disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.mode-toggle-button.is-disabled:hover, +.mode-toggle-button.is-disabled:focus-visible { + background: transparent; +} + +.mode-toggle-disabled-wrap { + position: relative; +} + +.mode-toggle-disabled-wrap::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 14px); + left: 50%; + transform: translateX(-50%); + padding: 4px 10px; + border-radius: 6px; + background: var(--color-ink, #1e293b); + color: var(--color-ink-inverse, #f8fafc); + font-size: 12px; + font-weight: 500; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.mode-toggle-disabled-wrap::before { + content: ""; + position: absolute; + bottom: calc(100% - 2px); + left: 50%; + transform: translateX(-50%); + border: 8px solid transparent; + border-top-color: var(--color-ink, #1e293b); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; +} + +.mode-toggle-disabled-wrap:hover::after, +.mode-toggle-disabled-wrap:hover::before { + opacity: 1; +} + .filter-pill { display: inline-flex; align-items: center; @@ -1495,7 +1545,7 @@ html[data-coverage-loading="true"] .collections::after { align-items: center; justify-content: space-between; gap: 24px; - padding: 2px 4px 8px; + padding: 0 4px 2px; } .collection-progress { @@ -1506,6 +1556,131 @@ html[data-coverage-loading="true"] .collections::after { min-width: 0; } +.search-filter-card { + background: var(--color-panel, #f8fafc); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 12px; + padding: 12px 14px; + margin: 0 0 8px; +} + +.search-filter-row { + display: flex; + gap: 8px; + align-items: center; +} + +.search-filter-status { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.8rem; + color: var(--color-muted, #94a3b8); + padding-top: 10px; + margin-top: 10px; + border-top: 1px solid var(--color-border, #e2e8f0); +} + +.collection-search { + flex: 1; + padding: 10px 18px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 9999px; + font-size: 15px; + background: var(--color-bg, #fff); + color: var(--color-ink, #1e293b); + outline: none; +} + +.collection-search::placeholder { + color: var(--color-muted, #94a3b8); +} + +.collection-search:focus { + border-color: var(--color-ink, #1e293b); + box-shadow: 0 0 0 1px var(--color-ink, #1e293b); +} + +.filter-dropdown-shell { + position: relative; +} + +.filter-dropdown-trigger { + display: inline-grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 6px; + padding: 10px 18px; + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 9999px; + font-size: 15px; + background: var(--color-bg, #fff); + color: var(--color-ink, #1e293b); + cursor: pointer; + white-space: nowrap; +} + +.filter-dropdown-sizer { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + visibility: hidden; + height: 0; + overflow: hidden; +} + +.filter-dropdown-sizer-item { + display: block; +} + +.filter-dropdown-label { + grid-column: 1; + grid-row: 1; + text-align: left; +} + +.filter-dropdown-trigger:hover { + background: var(--color-hover, #f1f5f9); +} + +.filter-dropdown-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + min-width: 100%; + background: var(--color-bg, #fff); + border: 1px solid var(--color-border, #e2e8f0); + border-radius: 10px; + box-shadow: 0 6px 14px rgba(12, 17, 29, 0.12); + padding: 4px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.filter-dropdown-option { + border: none; + background: none; + padding: 8px 14px; + font-size: 14px; + color: var(--color-ink, #1e293b); + text-align: left; + cursor: pointer; + border-radius: 6px; + white-space: nowrap; +} + +.filter-dropdown-option:hover { + background: var(--color-hover, #f1f5f9); +} + +.filter-dropdown-option.is-selected { + font-weight: 600; + background: var(--color-hover, #f1f5f9); +} + .collection-progress-text { display: inline-flex; align-items: center; @@ -1516,6 +1691,22 @@ html[data-coverage-loading="true"] .collections::after { white-space: nowrap; } +.clear-filters-button { + border: none; + background: none; + color: var(--color-ink, #1e293b); + font-size: 0.8rem; + font-weight: 500; + text-decoration: underline; + cursor: pointer; + padding: 0; + opacity: 0.7; +} + +.clear-filters-button:hover { + opacity: 1; +} + .collection-cache-clear { display: inline-flex; align-items: center; @@ -1831,6 +2022,7 @@ html[data-coverage-loading="true"] .collections::after { gap: 8px; margin-top: 6px; align-items: center; + padding: 2px; max-height: 240px; opacity: 1; transform: translateY(0); @@ -1869,8 +2061,31 @@ html[data-coverage-loading="true"] .collections::after { gap: 16px; } +.collection-empty--filtered { + width: 100%; + text-align: center; + padding: 48px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.collection-empty-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-ink, #1e293b); +} + +.collection-empty-hint { + font-size: 0.9rem; + color: var(--color-muted, #94a3b8); + max-width: 300px; +} + .collection-empty-icon { color: #c4b8ac; + margin-bottom: 4px; } .tile { @@ -1900,6 +2115,31 @@ html[data-coverage-loading="true"] .collections::after { height: 32px; } +.tile--collection { + width: 32px; + height: 32px; +} + +.tile--partial { + border-style: dashed; +} + +.tile--search-match { + background: rgba(251, 191, 36, 0.35) !important; + border-color: #f59e0b !important; + box-shadow: 0 0 0 1px #f59e0b; +} + +.detail-row-checkbox--search-match { + border-color: #f59e0b !important; + background: rgba(251, 191, 36, 0.35) !important; +} + +.detail-row--search-match { + background: rgba(251, 191, 36, 0.1); + border-radius: 4px; +} + .tile--select { cursor: pointer; background: transparent; @@ -2343,6 +2583,10 @@ html[data-coverage-loading="true"] .collections::after { background: #ef4444; } +.detail-row-checkbox--partial { + border-style: dashed; +} + .detail-row-checkbox:disabled { opacity: 0.5; cursor: default; @@ -2745,6 +2989,31 @@ html[data-coverage-loading="true"] .collections::after { color: #fecaca; } +.detail-pill { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 500; + padding: 2px 8px; + border-radius: 9999px; + margin-right: 4px; +} + +.detail-pill--human { + color: #86efac; + background: rgba(22, 163, 74, 0.18); +} + +.detail-pill--ai { + color: #c4b5fd; + background: rgba(124, 58, 237, 0.18); +} + +.detail-pill--none { + color: #fecaca; + background: rgba(239, 68, 68, 0.18); +} + .detail-media { display: flex; align-items: center; diff --git a/apps/manager/src/features/coverage/LanguageGeoSelector.tsx b/apps/manager/src/features/coverage/LanguageGeoSelector.tsx index 37e5618c..062c62cd 100644 --- a/apps/manager/src/features/coverage/LanguageGeoSelector.tsx +++ b/apps/manager/src/features/coverage/LanguageGeoSelector.tsx @@ -563,7 +563,13 @@ export function LanguageGeoSelector({ key={language.id} type="button" className="geo-selected-pill" - onClick={() => handleSelect(language.id)} + onClick={() => { + const next = draftLanguages.filter( + (id) => id !== language.id, + ) + setDraftLanguages(next) + applyUrlParams(next) + }} aria-label={`Remove ${language.label}`} > {language.label} diff --git a/apps/manager/src/features/coverage/coverage-report-client.tsx b/apps/manager/src/features/coverage/coverage-report-client.tsx index 16727805..d379da87 100644 --- a/apps/manager/src/features/coverage/coverage-report-client.tsx +++ b/apps/manager/src/features/coverage/coverage-report-client.tsx @@ -65,6 +65,7 @@ type ClientVideo = { errors: Array<{ step: WorkflowStepName; message: string; at: string }> artifacts: Record coverageStatus: CoverageStatus + coverageCounts: CoverageCounts stepCompleteness: { completed: number; total: number } } @@ -86,18 +87,18 @@ type LanguageOption = { // CMS-sourced types (used by the server page component) // --------------------------------------------------------------------------- +type CoverageCounts = { human: number; ai: number; none: number } + export type CmsVideo = { id: string title: string imageUrl: string | null label: string coverage: { - subtitles: CoverageStatus - audio: CoverageStatus - meta: CoverageStatus + subtitles: CoverageCounts + audio: CoverageCounts + meta: CoverageCounts } - variantLanguageIds: string[] - subtitleLanguageIds: string[] } export type CmsCollection = { @@ -105,9 +106,20 @@ export type CmsCollection = { title: string label: string labelDisplay: string + coverage: { + subtitles: CoverageCounts + audio: CoverageCounts + meta: CoverageCounts + } videos: CmsVideo[] } +function countsToStatus(counts: CoverageCounts): CoverageStatus { + if (counts.human > 0) return "human" + if (counts.ai > 0) return "ai" + return "none" +} + interface CoverageReportClientProps { gatewayConfigured: boolean initialErrorMessage: string | null @@ -241,6 +253,7 @@ function jobToClientVideo(job: JobRecord): ClientVideo { errors: job.errors, artifacts: job.artifacts, coverageStatus: computeCoverageStatus(job), + coverageCounts: { human: 0, ai: 0, none: 0 }, stepCompleteness: { completed: completedCount, total: FORGE_STEPS.length, @@ -279,7 +292,8 @@ function cmsVideoToClientVideo( video: CmsVideo, reportType: ReportType, ): ClientVideo { - const coverageStatus = video.coverage[reportType] + const counts = video.coverage[reportType] + const coverageStatus = countsToStatus(counts) return { id: video.id, title: video.title, @@ -287,9 +301,7 @@ function cmsVideoToClientVideo( muxAssetId: video.id, muxPlaybackId: "", status: "completed", - languages: [ - ...new Set([...video.variantLanguageIds, ...video.subtitleLanguageIds]), - ], + languages: [], steps: FORGE_STEPS.map((name) => ({ name, status: @@ -301,6 +313,45 @@ function cmsVideoToClientVideo( errors: [], artifacts: {}, coverageStatus, + coverageCounts: counts, + stepCompleteness: { + completed: + coverageStatus === "human" + ? FORGE_STEPS.length + : coverageStatus === "ai" + ? 1 + : 0, + total: FORGE_STEPS.length, + }, + } +} + +function collectionToClientVideo( + collection: CmsCollection, + reportType: ReportType, +): ClientVideo { + const counts = collection.coverage[reportType] + const coverageStatus = countsToStatus(counts) + return { + id: `collection:${collection.id}`, + title: collection.title, + imageUrl: null, + muxAssetId: collection.id, + muxPlaybackId: "", + status: "completed", + languages: [], + steps: FORGE_STEPS.map((name) => ({ + name, + status: + coverageStatus === "human" + ? ("completed" as const) + : ("pending" as const), + retries: 0, + })), + errors: [], + artifacts: {}, + coverageStatus, + coverageCounts: counts, stepCompleteness: { completed: coverageStatus === "human" @@ -322,7 +373,12 @@ function cmsCollectionsToClientCollections( title: collection.title, label: collection.label, labelDisplay: collection.labelDisplay, - videos: collection.videos.map((v) => cmsVideoToClientVideo(v, reportType)), + videos: [ + ...(collection.id === "standalone" + ? [] + : [collectionToClientVideo(collection, reportType)]), + ...collection.videos.map((v) => cmsVideoToClientVideo(v, reportType)), + ], })) } @@ -401,9 +457,11 @@ function useSessionReportType( function ModeToggle({ mode, onChange, + translateDisabled, }: { mode: Mode onChange: (mode: Mode) => void + translateDisabled?: boolean }) { return (
@@ -429,27 +487,33 @@ function ModeToggle({ Explore - + + Translate + +
) @@ -546,6 +610,88 @@ function CoverageBar({ ) } +function CoverageFilterDropdown({ + value, + onChange, + labels, + options: customOptions, +}: { + value: string + onChange: (value: string) => void + labels?: Record + options?: Array<{ value: string; label: string }> +}) { + const [isOpen, setIsOpen] = useState(false) + const shellRef = useRef(null) + + const options: Array<{ value: string; label: string }> = customOptions ?? [ + { value: "all", label: "All" }, + { value: "human", label: labels?.human ?? "Verified" }, + { value: "ai", label: labels?.ai ?? "AI" }, + { value: "none", label: labels?.none ?? "None" }, + ] + + const currentLabel = options.find((o) => o.value === value)?.label ?? "All" + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setIsOpen(false) + } + const handleClickOutside = (event: MouseEvent) => { + if (shellRef.current && !shellRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + document.addEventListener("keydown", handleKeyDown) + document.addEventListener("mousedown", handleClickOutside) + return () => { + document.removeEventListener("keydown", handleKeyDown) + document.removeEventListener("mousedown", handleClickOutside) + } + }, []) + + return ( + + + {isOpen && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ) +} + function ReportTypeSelector({ value, onChange, @@ -641,6 +787,7 @@ type CollectionCardProps = { isExpanded: boolean isSelectMode: boolean selectedVideoIds: Set + searchMatchIds: Set onToggleExpanded: (collectionId: string) => void onHoverVideo: (details: HoveredVideoDetails | null) => void onToggleVideo: (videoId: string) => void @@ -653,6 +800,7 @@ const CollectionCard = memo(function CollectionCard({ isExpanded, isSelectMode, selectedVideoIds, + searchMatchIds, onToggleExpanded, onHoverVideo, onToggleVideo, @@ -824,7 +972,12 @@ const CollectionCard = memo(function CollectionCard({ {(["human", "ai", "none"] as const).map((groupStatus) => { const groupVideos = filteredVideos .filter((v) => v.coverageStatus === groupStatus) - .sort((a, b) => a.title.localeCompare(b.title)) + .sort((a, b) => { + const aIsCollection = a.id.startsWith("collection:") + const bIsCollection = b.id.startsWith("collection:") + if (aIsCollection !== bIsCollection) return aIsCollection ? -1 : 1 + return a.title.localeCompare(b.title) + }) if (groupVideos.length === 0) return null return ( @@ -842,7 +995,7 @@ const CollectionCard = memo(function CollectionCard({ return ( ) })} @@ -882,8 +1039,8 @@ const CollectionCard = memo(function CollectionCard({ role={isSelectMode ? "checkbox" : undefined} aria-checked={isSelectMode ? isSelected : undefined} tabIndex={isSelectMode ? 0 : undefined} - className={`tile tile--video tile--${status}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} - title={`${video.title} -- ${statusLabel}`} + className={`tile ${video.id.startsWith("collection:") ? "tile--collection" : "tile--video"} tile--${status}${status !== "none" && video.coverageCounts.none > 0 ? " tile--partial" : ""}${searchMatchIds.has(video.id) ? " tile--search-match" : ""}${isSelectMode ? " tile--select" : " tile--explore"}${isSelected ? " is-selected" : ""}`} + title={`${video.title} — ${statusLabel}`} onClick={isSelectMode ? () => onToggleVideo(video.id) : undefined} onKeyDown={ isSelectMode @@ -970,6 +1127,28 @@ export function CoverageReportClient({ }, [videoCollections, initialJobs, reportType]) const selectedLanguageIds = initialSelectedLanguageIds const languageOptions = initialLanguages + const [languageNameMap, setLanguageNameMap] = useState>( + new Map(), + ) + // Fetch language names once for display in the selection bar + useEffect(() => { + void (async () => { + try { + const response = await apiFetch("/api/languages") + if (!response.ok) return + const payload = (await response.json()) as { + languages: Array<{ id: string; englishLabel: string }> + } + const map = new Map() + for (const lang of payload.languages ?? []) { + map.set(lang.id, lang.englishLabel) + } + setLanguageNameMap(map) + } catch { + // ignore — will fall back to IDs + } + })() + }, []) const errorMessage = initialErrorMessage const [filter, setFilter] = useState("all") const [hoveredVideo, setHoveredVideo] = useState( @@ -982,6 +1161,8 @@ export function CoverageReportClient({ ) const hydrated = useHydrated() const reportConfig = REPORT_CONFIG[reportType] + const [searchQuery, setSearchQuery] = useState("") + const [typeFilter, setTypeFilter] = useState("all") const [interactionMode, setInteractionMode] = useSessionMode("explore") const isSelectMode = interactionMode === "select" @@ -1024,10 +1205,24 @@ export function CoverageReportClient({ } const payload = (await response.json()) as { collections: CmsCollection[] + standalone: CmsVideo[] } - if (payload?.collections) { - setVideoCollections(payload.collections) + const allCollections = [...(payload?.collections ?? [])] + if (payload?.standalone?.length > 0) { + allCollections.push({ + id: "standalone", + title: "Standalone Videos", + label: "standalone", + labelDisplay: "Standalone", + coverage: { + subtitles: { human: 0, ai: 0, none: 0 }, + audio: { human: 0, ai: 0, none: 0 }, + meta: { human: 0, ai: 0, none: 0 }, + }, + videos: payload.standalone, + }) } + setVideoCollections(allCollections) } catch { if (!controller.signal.aborted) { setVideoCollectionsLoadFailed(true) @@ -1058,6 +1253,18 @@ export function CoverageReportClient({ })() }, []) + const collectionTypeOptions = useMemo(() => { + const types = new Map() + for (const c of collections) { + if (c.label && !types.has(c.label)) { + types.set(c.label, c.labelDisplay) + } + } + return [...types.entries()] + .sort((a, b) => a[1].localeCompare(b[1])) + .map(([value, label]) => ({ value, label })) + }, [collections]) + // Derive header bar counts from snapshot data (instant, pre-computed) const snapshotCounts = useMemo(() => { if (!snapshot) return null @@ -1130,17 +1337,42 @@ export function CoverageReportClient({ const effectiveFilter = filter + const searchMatchIds = useMemo(() => { + const q = searchQuery.trim().toLowerCase() + if (!q) return new Set() + const matched = new Set() + for (const collection of collections) { + for (const video of collection.videos) { + if (video.title.toLowerCase().includes(q) || video.id.toLowerCase().includes(q)) { + matched.add(video.id) + } + } + } + return matched + }, [collections, searchQuery]) + const visibleCollections = useMemo(() => { - if (effectiveFilter === "all") return collections - return collections - .map((collection) => ({ - ...collection, - videos: collection.videos.filter( - (video) => video.coverageStatus === effectiveFilter, - ), - })) - .filter((collection) => collection.videos.length > 0) - }, [collections, effectiveFilter]) + let result = collections + if (typeFilter !== "all") { + result = result.filter((c) => c.label === typeFilter) + } + if (effectiveFilter !== "all") { + result = result + .map((collection) => ({ + ...collection, + videos: collection.videos.filter( + (video) => video.coverageStatus === effectiveFilter, + ), + })) + .filter((collection) => collection.videos.length > 0) + } + if (searchMatchIds.size > 0) { + result = result.filter((collection) => + collection.videos.some((video) => searchMatchIds.has(video.id)), + ) + } + return result + }, [collections, typeFilter, effectiveFilter, searchMatchIds]) const toggleExpanded = useCallback((collectionId: string) => { setExpandedCollections((prev) => @@ -1178,7 +1410,12 @@ export function CoverageReportClient({
{ + setReportType(next) + if (next !== "subtitles" && interactionMode === "select") { + handleModeChange("explore") + } + }} />
@@ -1212,50 +1449,70 @@ export function CoverageReportClient({ )} - {gatewayConfigured && !errorMessage && totalCollections > 0 && ( -
-
-
- Showing {totalCollections} collection - {totalCollections === 1 ? "" : "s"} -
-
-
- )} {showCoverageControls && (
- {hydrated && reportType === "subtitles" && ( - + {hydrated && ( + )}

- {hydrated && reportType === "subtitles" && isSelectMode + {hydrated && isSelectMode && reportType === "subtitles" ? "Select videos for translation." : reportConfig.hintExplore}

- {filter !== "all" && ( -
- Filtering: {reportConfig.statusLabels[filter]} - + Clear filters + + )}
)}
@@ -1319,6 +1576,7 @@ export function CoverageReportClient({ isExpanded={isExpanded} isSelectMode={isSelectMode} selectedVideoIds={selectedVideoIds} + searchMatchIds={searchMatchIds} onToggleExpanded={toggleExpanded} onHoverVideo={handleHoverVideo} onToggleVideo={toggleVideoSelection} @@ -1330,12 +1588,38 @@ export function CoverageReportClient({ className={ collections.length === 0 ? "collection-empty collection-empty--no-data" - : "collection-empty" + : "collection-empty collection-empty--filtered" } > - {collections.length === 0 - ? "No videos are available yet." - : "No videos match this filter."} + {collections.length === 0 ? ( + "No videos are available yet." + ) : ( + <> + + + No results found + + + Try adjusting your search or filters to find what + you're looking for. + + + )} )} {totalCollections > 0 && ( @@ -1362,7 +1646,12 @@ export function CoverageReportClient({ {selectedVideoIds.size === 1 ? "" : "s"} selected
- Languages: {selectedLanguageIds.join(", ") || "None"} + Languages:{" "} + {selectedLanguageIds.length > 0 + ? selectedLanguageIds + .map((id) => languageNameMap.get(id) ?? id) + .join(", ") + : "None"}
@@ -1457,11 +1746,36 @@ export function CoverageReportClient({
- - {reportConfig.statusLabels[hoveredVideo.status]} - + {(() => { + const c = hoveredVideo.video.coverageCounts + const noneCount = + selectedLanguageIds.length > 0 + ? c.none + : Math.max( + 0, + languageOptions.length - c.human - c.ai, + ) + const typeName = reportConfig.label.toLowerCase() + return ( + <> + {c.human > 0 && ( + + {c.human} verified {typeName} + + )} + {c.ai > 0 && ( + + {c.ai} AI {typeName} + + )} + {noneCount > 0 && ( + + {noneCount} no {typeName} + + )} + + ) + })()}
diff --git a/docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md b/docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md new file mode 100644 index 00000000..153fe1c1 --- /dev/null +++ b/docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md @@ -0,0 +1,54 @@ +--- +date: 2026-04-02 +topic: manager-coverage-query-performance +--- + +# Manager Coverage Query Performance + +## Problem Frame + +The manager's `/api/videos` endpoint fetches all 414K video variant rows and 20K subtitle rows through GraphQL to compute per-video coverage status (human/ai/none) in JavaScript. A single cache refresh takes ~22-47 seconds and saturates the CMS, blocking authentication checks (`/api/users/me`) behind it. This causes sessions to appear "expired" — users sign in, see the dashboard briefly, then get logged out when their next auth check times out. + +Additionally, the language picker only shows ~100 of 4,560 languages due to GraphQL pagination limits. + +## Requirements + +- R1. The `/api/videos` response must return coverage **counts** per video: `{ human: N, ai: N, none: N }` for each coverage type (subtitles, audio, metadata), where counts are based on selected language filters (or all languages if none selected). +- R2. Collections (parent videos) show coverage for their own data, not rolled up from children. Feature films have their own subtitles/variants. +- R3. Standalone videos (no parents, no children) are returned separately from collections. +- R4. Coverage must be computed server-side (SQL or CMS-level), not by fetching all variant/subtitle rows into the manager app. +- R5. The cache refresh must not block or noticeably slow authentication checks. +- R6. The language picker must show all available languages (currently 4,560), not just the first 100. +- R7. Revert the `maxLimit: 100` GraphQL config change from PR #626 — it capped top-level pagination to 100/page, breaking the intentional `pageSize: 5000` and causing 11 sequential round trips instead of 1. + +## Success Criteria + +- Dashboard loads without logging the user out +- CMS `/api/users/me` responds in <1s during cache refresh +- Video cache refresh completes in <5s (down from 22-47s) +- Language picker shows all languages + +## Scope Boundaries + +- Coverage snapshots (the daily stats bar) are a separate feature — not in scope +- No changes to the coverage report UI layout or visual design +- Metadata coverage remains a single boolean (`aiMetadata`) for now + +## Key Decisions + +- **Counts, not statuses**: Coverage per video is `{ human: N, ai: N, none: N }` rather than a single "human/ai/none" string. This lets the UI show richer info when multiple languages are selected. +- **Collections show own coverage**: A collection (e.g. feature film) shows its own subtitle/variant coverage, not a rollup of its children's coverage. +- **Global = any language**: When no language filter is active, coverage considers all languages. +- **Server-side computation**: The database computes coverage counts via SQL aggregation rather than the app downloading raw rows. + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R4][Needs research] Should coverage be computed via a custom Strapi endpoint (REST API with raw SQL), a custom GraphQL resolver, or a Knex query from the manager directly? +- [Affects R1][Technical] What's the best way to handle the "no language selected" aggregation efficiently — `COUNT(DISTINCT language_id)` per video, or a simpler `EXISTS` check? +- [Affects R6][Technical] How to serve 4,560 languages without the GraphQL pagination cap — bump `maxLimit` for languages specifically, use REST, or paginate client-side? + +## Next Steps + +-> `/ce:plan` for structured implementation planning diff --git a/docs/plans/2026-04-02-001-fix-manager-coverage-query-performance-plan.md b/docs/plans/2026-04-02-001-fix-manager-coverage-query-performance-plan.md new file mode 100644 index 00000000..68595192 --- /dev/null +++ b/docs/plans/2026-04-02-001-fix-manager-coverage-query-performance-plan.md @@ -0,0 +1,305 @@ +--- +title: "fix: Optimize manager video coverage query performance" +type: fix +status: active +date: 2026-04-02 +origin: docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md +--- + +# fix: Optimize manager video coverage query performance + +## Overview + +Replace the manager's 414K-row GraphQL fetch with a custom CMS REST endpoint that computes per-video coverage counts via SQL aggregation. Benchmarked at 60-660ms (vs 22-47s today). Also revert the `maxLimit: 100` GraphQL regression and fix language pagination. + +## Problem Frame + +The manager's `/api/videos` endpoint fetches all video variant rows (414K) and subtitle rows (20K) through Strapi GraphQL to compute per-video coverage status in JavaScript. This saturates the CMS for 22-47 seconds during every cache refresh, blocking `/api/users/me` auth checks behind it. Users sign in, see the dashboard briefly, then get logged out when their auth check exceeds the 5s timeout. (see origin: `docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md`) + +## Requirements Trace + +- R1. Coverage counts `{ human, ai, none }` per video per coverage type, filtered by selected languages +- R2. Collections show their own coverage, not rolled up from children +- R3. Standalone videos returned separately +- R4. Coverage computed server-side via SQL, not by fetching raw rows +- R5. Cache refresh must not block auth checks +- R6. Language picker shows all available languages +- R7. Revert `maxLimit: 100` from PR #626 + +## Scope Boundaries + +- Coverage snapshots (daily stats bar) are out of scope +- No visual design changes to the coverage report UI +- Metadata coverage stays as a single boolean (`aiMetadata`) +- The `coverage-report-client.tsx` component changes are limited to data consumption — visual layout stays the same + +## Context & Research + +### Relevant Code and Patterns + +- `apps/cms/src/api/coverage-snapshot/services/coverage-snapshot.ts` — existing SQL aggregation pattern using `strapi.db.connection` (knex) with `BOOL_OR(NOT COALESCE(m.ai_generated, false))` per (video, language) pair. This is the direct template for the new endpoint. +- `apps/cms/src/api/data-snapshot/` — example of custom CMS REST endpoints with controller/routes/services/middleware structure +- `apps/manager/src/app/api/videos/route.ts` — current route handler, SWR cache, GraphQL query, and coverage computation +- `apps/manager/src/lib/swr-cache.ts` — shared SWR cache utility (module-scoped, single-process) +- `apps/manager/src/features/coverage/coverage-report-client.tsx` — frontend component consuming `/api/videos` +- `videos_children_lnk` table — `video_id` (parent) → `inv_video_id` (child) with ordering columns + +### Institutional Learnings + +- Strapi v5 GraphQL has no DataLoader batching — each nested relation fires N+1 queries (see `docs/solutions/performance-issues/strapi-nested-relation-truncation-and-n-plus-one-manager-20260328.md`) +- All queries must filter `published_at IS NOT NULL` to avoid double-counting draft rows +- Custom CMS endpoints need explicit route files for REST exposure; use `strapi.db.connection` for raw knex +- The `maxLimit: 100` set in PR #626 caps GraphQL pagination to 100/page, breaking the intentional `pageSize: 5000` — must be reverted + +### Benchmark Results (production data) + +| Query | Time | +| -------------------------------------------------------- | ------------------- | +| All videos, all languages, subtitles + variants coverage | **660ms** | +| All videos, single language filter (English) | **60ms** | +| Current GraphQL fetch (414K rows through ORM) | **22,000-47,000ms** | + +## Key Technical Decisions + +- **Custom CMS REST endpoint over GraphQL**: The CMS already has precedent for raw SQL endpoints (`coverage-snapshot`, `data-snapshot`, `core-sync`). A custom endpoint avoids GraphQL N+1 entirely and returns exactly the data needed. (see origin decisions) +- **Coverage computed per-request with language param**: The SQL is fast enough (60-660ms) that per-request computation is viable. The manager SWR cache stores the result for 2 minutes. Changing the language filter triggers a fresh fetch (~60ms for filtered, ~660ms for global). This is acceptable given the current 22-47s baseline. +- **Language cache TTL increased to 24 hours**: Language/geo data changes only during core sync (rare). The current 5-minute TTL causes unnecessary CMS load for data that's essentially static. +- **`none` count for language-filtered requests**: When languages are selected, `none` = number of selected languages minus (human + ai). For global mode (no filter), `none` is 0 (we only count languages that have content). + +## Open Questions + +### Resolved During Planning + +- **Where to compute coverage?** Custom CMS REST endpoint with raw SQL. Follows existing `coverage-snapshot` pattern. SQL benchmarks confirm sub-second performance. +- **How to handle language filtering?** Accept `languageIds` query parameter. SQL uses `l.core_id = ANY($1)` for filtered, omits clause for global. Frontend passes selected languages, gets back counts. +- **How to serve all languages?** Revert `maxLimit: 100` (R7). The `pageSize: 5000` in `fetchAllPages` will work again as intended. Additionally, increase language cache TTL from 5 min to 24 hours since geo data rarely changes. +- **How to handle `none` count?** Computed as: `selectedLanguages.length - (human + ai)` on the manager side. No SQL needed for this — the manager knows how many languages were selected. + +### Deferred to Implementation + +- Exact knex parameterization syntax for the `ANY($1::text[])` language filter — validate during implementation +- Whether `videos_children_lnk.video_id` is the parent or child — verify with a quick DB query during implementation +- Whether the images query (thumbnail/videoStill) should stay in the GraphQL query or move to a SQL join in the new endpoint + +## High-Level Technical Design + +> _This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce._ + +``` +Browser Manager (Next.js) CMS (Strapi) + | | | + |-- GET /api/videos | | + | ?languageIds=529,21028 | | + | |-- GET /api/video-coverage | + | | ?languageIds=529,21028 | + | | |-- SQL: video metadata + | | |-- SQL: subtitle coverage (CTE) + | | |-- SQL: variant coverage (CTE) + | | |-- SQL: parent-child links + | |<--- JSON: videos[] with -----+ + | | per-video coverage counts + | | + | |-- Reconstruct collections + | | from parent-child links + | |-- Compute `none` counts + |<--- JSON: { collections, --+ + | standalone } | +``` + +## Implementation Units + +- [ ] **Unit 1: Revert `maxLimit: 100` from GraphQL config** + + **Goal:** Remove the GraphQL pagination cap that broke `pageSize: 5000` and caused 11-page sequential fetching. + + **Requirements:** R7 + + **Dependencies:** None + + **Files:** + - Modify: `apps/cms/config/plugins.ts` + + **Approach:** + - Remove the `maxLimit: 100` line added in PR #626 + - The GraphQL plugin defaults to `maxLimit: -1` (unlimited), which is the intended behavior + + **Patterns to follow:** + - The previous plan (`docs/plans/2026-03-28-002-fix-optimize-videos-graphql-query-plan.md`) explicitly documented that GraphQL `maxLimit` defaults to `-1` and `pageSize: 5000` is intentional + + **Verification:** + - `apps/cms/config/plugins.ts` has no `maxLimit` in the graphql config + - Language and video pagination return full pages at `pageSize: 5000` + +- [ ] **Unit 2: Add CMS `/api/video-coverage` REST endpoint** + + **Goal:** Create a custom Strapi REST endpoint that returns all published videos with their metadata, parent-child links, and per-video coverage counts computed via SQL aggregation. + + **Requirements:** R1, R2, R4 + + **Dependencies:** None (can be built in parallel with Unit 1) + + **Files:** + - Create: `apps/cms/src/api/video-coverage/routes/video-coverage.ts` + - Create: `apps/cms/src/api/video-coverage/controllers/video-coverage.ts` + - Create: `apps/cms/src/api/video-coverage/services/video-coverage.ts` + + **Approach:** + - Follow the exact pattern from `apps/cms/src/api/coverage-snapshot/` — controller/routes/services, knex via `strapi.db.connection` + - Accept optional `languageIds` query parameter (comma-separated `core_id` values) + - Service runs two materialized CTEs: one for subtitle coverage, one for variant coverage + - Each CTE computes per-video `COUNT(DISTINCT l.core_id) FILTER (WHERE has_human/ai)` grouped by `v.document_id` + - Main query joins videos with both CTEs and returns: `document_id`, `core_id`, `title`, `label`, `slug`, `ai_metadata`, image fields, parent document IDs (from `videos_children_lnk`), and coverage counts + - Filter `published_at IS NOT NULL` on all tables + - When `languageIds` is provided, add `AND l.core_id = ANY($1)` to both CTEs + - Auth: use the same API token auth that the manager already uses for GraphQL calls (Strapi API token in `Authorization: Bearer` header — no custom middleware needed since Strapi's built-in API token auth covers custom routes when `auth: false` is NOT set) + + **Technical design:** _(directional guidance, not implementation specification)_ + + ``` + Response shape: + { + videos: [ + { + documentId, coreId, title, label, slug, aiMetadata, + thumbnailUrl, videoStillUrl, + parentDocumentIds: string[], + coverage: { + subtitles: { human: N, ai: N }, + audio: { human: N, ai: N } + } + } + ] + } + ``` + + Note: `meta` coverage (aiMetadata) is already on the video row — no SQL aggregation needed. The `none` count for language-filtered requests is computed on the manager side. + + **Patterns to follow:** + - `apps/cms/src/api/coverage-snapshot/services/coverage-snapshot.ts` — knex raw SQL with `BOOL_OR`, link table joins, `published_at` filtering + - `apps/cms/src/api/data-snapshot/routes/data-snapshot.ts` — custom route file structure + + **Test scenarios:** + - No `languageIds` param → returns global coverage (all languages) + - Single language → returns coverage for that language only + - Multiple languages → returns coverage counts across selected languages + - Video with no subtitles/variants → coverage counts are 0 + - Only published videos returned (no draft rows) + + **Verification:** + - Endpoint returns all ~1083 published videos with coverage data + - Response time < 1s with language filter, < 2s without + - Coverage counts match manual SQL verification for a sample video + +- [ ] **Unit 3: Update manager `/api/videos` to use new CMS endpoint** + + **Goal:** Replace the GraphQL-based video fetch with a call to the new CMS REST endpoint. Update the response shape to include coverage counts. + + **Requirements:** R1, R2, R3, R4, R5 + + **Dependencies:** Unit 2 + + **Files:** + - Modify: `apps/manager/src/app/api/videos/route.ts` + - Modify: `apps/manager/src/lib/strapi-pagination.ts` (may no longer be needed for videos) + - Modify: `apps/manager/src/instrumentation.ts` (cache warming) + + **Approach:** + - Replace `GET_VIDEOS_CONNECTION` GraphQL query and `fetchAllPages` loop with a single `fetch()` call to the CMS `/api/video-coverage` endpoint + - Pass `languageIds` from the request query string through to the CMS endpoint + - The SWR cache fetcher now calls the REST endpoint instead of GraphQL + - Keep the parent-child hierarchy reconstruction logic (grouping into collections + standalone), but use `parentDocumentIds` from the response instead of a separate `parents` GraphQL field + - Remove the `determineCoverage` and `determineCoverageForItems` functions — coverage comes pre-computed from CMS + - Compute `none` count on the manager side: for language-filtered requests, `none = selectedLanguages.length - (human + ai)` per coverage type per video. For global, `none = 0` + - Update response shape: change `coverage: { subtitles: "human", audio: "human", meta: "human" }` to `coverage: { subtitles: { human, ai, none }, audio: { human, ai, none }, meta: { human, ai, none } }` + - For `meta` coverage: derive from `aiMetadata` boolean — `{ human: aiMetadata === false ? 1 : 0, ai: aiMetadata === true ? 1 : 0, none: aiMetadata == null ? 1 : 0 }` + + **Patterns to follow:** + - `apps/manager/src/cms/client.ts` — how the manager makes authenticated calls to CMS + - Existing SWR cache usage in `apps/manager/src/app/api/videos/route.ts` + + **Test scenarios:** + - Dashboard loads without auth timeout (primary success criterion) + - Language filter change returns updated coverage counts + - No language filter returns global coverage + - Collections correctly grouped from parent-child links + - Standalone videos (no parents, no children) in separate array + - Cache warm on startup completes in < 5s + + **Verification:** + - `videoCache` refresh completes in < 2s (vs 22-47s today) + - CMS `/api/users/me` responds in < 1s during cache refresh + - Dashboard loads and stays logged in through navigation + +- [ ] **Unit 4: Update frontend to consume coverage counts** + + **Goal:** Update the coverage report client component to handle the new `{ human, ai, none }` count format instead of the current single `"human" | "ai" | "none"` string. + + **Requirements:** R1 + + **Dependencies:** Unit 3 + + **Files:** + - Modify: `apps/manager/src/features/coverage/coverage-report-client.tsx` + + **Approach:** + - Update the `CoverageStatus` type or add a new `CoverageCounts` type: `{ human: number, ai: number, none: number }` + - Update how each video's `coverageStatus` is derived for the active report type — from a direct string to a derived status from counts (e.g., `counts.human > 0 ? "human" : counts.ai > 0 ? "ai" : "none"` for the traffic light) + - The `CoverageBar` component already accepts `counts: { human, ai, none }` — feed it the counts directly from the API instead of computing them from statuses + - Collection-level coverage counts: aggregate children's counts for the collection's coverage bar + - No visual changes — same traffic lights, same bar proportions, same color scheme + + **Patterns to follow:** + - Existing `CoverageBar` component props (already accepts count objects) + - Existing `REPORT_CONFIG` for report type switching + + **Test scenarios:** + - Report type switching (subtitles/audio/meta) still works instantly + - Coverage bar shows correct proportions from counts + - Language filter change triggers refetch and updates counts + - Collection bar aggregates children counts correctly + + **Verification:** + - Dashboard renders the same visual output as before for the same data + - Switching report types is instant (no refetch) + - Switching languages triggers a refetch and updates within ~1s + +- [ ] **Unit 5: Increase language cache TTL and fix language pagination** + + **Goal:** Increase the language cache TTL from 5 minutes to 24 hours (geo data rarely changes) and ensure all 4,560 languages are served. + + **Requirements:** R6 + + **Dependencies:** Unit 1 (maxLimit revert enables pageSize: 5000 to work) + + **Files:** + - Modify: `apps/manager/src/app/api/languages/route.ts` + + **Approach:** + - Change `ttlMs` from `5 * 60_000` (5 min) to `24 * 60 * 60_000` (24 hours) + - Keep `maxStaleMs` at `60 * 60_000` (1 hour) or increase proportionally + - With Unit 1's maxLimit revert, the existing `pageSize: 5000` in `fetchAllPages` will fetch all languages in 1 page instead of being capped at 100/page + + **Verification:** + - Language picker shows all ~4,560 languages + - Language cache only refreshes once per 24 hours (check log frequency) + +## System-Wide Impact + +- **Interaction graph:** The new CMS endpoint is called by the manager's `/api/videos` route handler. No other consumers need changes. The existing GraphQL schema is untouched. +- **Error propagation:** If the CMS endpoint returns an error, the SWR cache serves stale data (existing behavior). The manager should log the error and surface it only if stale data exceeds `maxStaleMs`. +- **State lifecycle risks:** The SWR cache stores language-agnostic or language-specific data depending on the request. Concurrent requests with different language filters will trigger cache refreshes, but the SWR deduplication handles this. +- **API surface parity:** The frontend is the only consumer of `/api/videos`. No other apps call this endpoint. +- **Auth check impact:** With cache refresh dropping from 22-47s to <2s, auth checks via `/api/users/me` are no longer blocked. This directly resolves the session expiry symptom. + +## Risks & Dependencies + +- **CMS endpoint auth**: The new REST endpoint must be accessible with the same Strapi API token the manager already uses. Strapi's built-in API token middleware should handle this by default for custom routes, but needs verification during implementation. +- **`videos_children_lnk` column semantics**: Need to verify whether `video_id` is the parent and `inv_video_id` is the child. A quick DB query during implementation will confirm. +- **`maxStaleMs` for language cache**: Increasing TTL to 24h means `maxStaleMs` should also be increased (to e.g. 48h) to avoid blocking requests if the CMS is briefly unavailable during the daily refresh window. + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md](docs/brainstorms/2026-04-02-manager-coverage-query-performance-requirements.md) +- Related code: `apps/cms/src/api/coverage-snapshot/services/coverage-snapshot.ts` (SQL pattern) +- Related plan: `docs/plans/2026-03-28-002-fix-optimize-videos-graphql-query-plan.md` (prior optimization, documents maxLimit behavior) +- Related PRs: #626 (pool fix that introduced maxLimit regression), #627 (NULL boolean backfill)