Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"fetch-secrets": "doppler secrets download --project forge-manager --config dev --format env --no-file > .env",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "echo \"no tests configured\""
"test": "tsx --test src/**/*.test.ts"
},
"dependencies": {
"@apollo/client": "^4.1.4",
Expand All @@ -26,10 +26,12 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^24.5.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.0.0",
"eslint-config-next": "^16.1.6",
"tsx": "^4.21.0",
"typescript": "^5"
}
}
171 changes: 5 additions & 166 deletions apps/manager/src/app/api/videos/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import {
fetchAllPages,
} from "@/lib/strapi-pagination"
import { createSwrCache } from "@/lib/swr-cache"
import {
buildVideoCollections,
type RawVideoNode,
} from "@/lib/video-collections"

// ---------------------------------------------------------------------------
// Typed queries
Expand Down Expand Up @@ -57,90 +61,6 @@ const GET_VIDEOS_CONNECTION = graphql(`
}
`)

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

type RawMediaItem = {
aiGenerated: boolean | null
language: { coreId: string | null } | null
}

type RawImage = {
thumbnail: string | null
videoStill: string | null
}

type RawVideoNode = {
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
}

type CoverageStatus = "human" | "ai" | "none"

const LABEL_DISPLAY: Record<string, string> = {
collection: "Collection",
episode: "Episode",
featureFilm: "Feature Film",
segment: "Segment",
series: "Series",
shortFilm: "Short Film",
trailer: "Trailer",
behindTheScenes: "Behind the Scenes",
unknown: "Other",
}

// ---------------------------------------------------------------------------
// Coverage helpers
// ---------------------------------------------------------------------------

function determineCoverageForItems(
items: RawMediaItem[],
selectedLanguageIds: Set<string>,
): 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"

const allAi = matching.every((item) => item.aiGenerated)
return allAi ? "ai" : "human"
}

function determineCoverage(
video: RawVideoNode,
selectedLanguageIds: Set<string>,
): { 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",
}
}

// ---------------------------------------------------------------------------
// SWR cache for video nodes (avoids ~4s Strapi query on every request)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -181,88 +101,7 @@ export async function GET(request: Request) {

try {
const videoNodes = await videoCache.get()

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

return {
id: String(video.coreId ?? video.documentId),
title:
video.title ?? video.slug ?? String(video.coreId ?? video.documentId),
imageUrl,
label: video.label ?? "unknown",
coverage: determineCoverage(video, selectedSet),
variantLanguageIds,
subtitleLanguageIds,
}
}

// 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]))

const parentChildrenMap = new Map<string, RawVideoNode[]>()
for (const video of videoNodes) {
for (const parent of video.parents ?? []) {
let children = parentChildrenMap.get(parent.documentId)
if (!children) {
children = []
parentChildrenMap.set(parent.documentId, children)
}
children.push(video)
}
}

const collections: Array<{
id: string
title: string
label: string
labelDisplay: string
videos: ReturnType<typeof toVideoItem>[]
}> = []

for (const [parentDocId, children] of parentChildrenMap) {
const parent = videoMap.get(parentDocId)
if (!parent) continue

collections.push({
id: String(parent.coreId ?? parent.documentId),
title:
parent.title ??
parent.slug ??
String(parent.coreId ?? parent.documentId),
label: parent.label ?? "unknown",
labelDisplay:
LABEL_DISPLAY[parent.label ?? "unknown"] ?? parent.label ?? "unknown",
videos: children.map(toVideoItem),
})
}

// 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 collections = buildVideoCollections(videoNodes, selectedSet)
return NextResponse.json({ collections })
} catch (error) {
console.error(
Expand Down
47 changes: 47 additions & 0 deletions apps/manager/src/lib/swr-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import test from "node:test"
import assert from "node:assert/strict"
import { createSwrCache } from "./swr-cache"

test("SWR cache returns stale data during failure backoff after a refresh failure", async () => {
let fetchCount = 0
let shouldFail = false

const cache = createSwrCache({
fetcher: async () => {
fetchCount += 1
if (shouldFail) throw new Error("upstream down")
return `value-${fetchCount}`
},
ttlMs: 0,
maxStaleMs: 60_000,
failureBackoffMs: 10_000,
label: "test-cache",
})

const first = await cache.get()
assert.equal(first, "value-1")

shouldFail = true
const second = await cache.get()
assert.equal(second, "value-1")

await new Promise((resolve) => setTimeout(resolve, 0))

const third = await cache.get()
assert.equal(third, "value-1")
assert.equal(fetchCount, 2)
})

test("SWR cache throws when empty cache refresh fails", async () => {
const cache = createSwrCache({
fetcher: async () => {
throw new Error("boom")
},
ttlMs: 0,
maxStaleMs: 60_000,
failureBackoffMs: 10_000,
label: "empty-cache",
})

await assert.rejects(cache.get(), /boom/)
})
1 change: 0 additions & 1 deletion apps/manager/src/lib/swr-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ export function createSwrCache<T>({
}
return cached
}

// Deduplicate concurrent refreshes via shared promise
const promise = refresh()

Expand Down
81 changes: 81 additions & 0 deletions apps/manager/src/lib/video-collections.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import test from "node:test"
import assert from "node:assert/strict"
import {
buildVideoCollections,
determineCoverageForItems,
type RawVideoNode,
} from "./video-collections"

function buildVideoNode(
overrides: Partial<RawVideoNode> & Pick<RawVideoNode, "documentId">,
): RawVideoNode {
return {
documentId: overrides.documentId,
coreId: overrides.coreId ?? overrides.documentId,
title: overrides.title ?? overrides.documentId,
label: overrides.label ?? "episode",
slug: overrides.slug ?? overrides.documentId,
aiMetadata: overrides.aiMetadata ?? null,
images: overrides.images ?? [],
parents: overrides.parents ?? [],
variants: overrides.variants ?? [],
subtitles: overrides.subtitles ?? [],
}
}

test("buildVideoCollections groups children under present parents", () => {
const parent = buildVideoNode({
documentId: "parent-1",
label: "collection",
title: "Collection One",
})
const child = buildVideoNode({
documentId: "child-1",
title: "Episode One",
parents: [{ documentId: "parent-1" }],
})

const collections = buildVideoCollections([parent, child], new Set(["en"]))

assert.equal(collections.length, 1)
assert.equal(collections[0]?.title, "Collection One")
assert.deepEqual(
collections[0]?.videos.map((video) => video.title),
["Episode One"],
)
})

test("buildVideoCollections falls back to standalone when a parent is missing", () => {
const orphanedChild = buildVideoNode({
documentId: "child-1",
title: "Lost Episode",
parents: [{ documentId: "missing-parent" }],
})

const collections = buildVideoCollections([orphanedChild], new Set(["en"]))

assert.equal(collections.length, 1)
assert.equal(collections[0]?.id, "standalone")
assert.deepEqual(
collections[0]?.videos.map((video) => video.title),
["Lost Episode"],
)
})

test("determineCoverageForItems evaluates all available languages when none are selected", () => {
const coverage = determineCoverageForItems(
[
{
aiGenerated: true,
language: { coreId: "en" },
},
{
aiGenerated: false,
language: { coreId: "es" },
},
],
new Set(),
)

assert.equal(coverage, "human")
})
Loading
Loading