From 07e2223c8a80c63dd9cee1e15579c838a16ae677 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 03:15:09 +0100 Subject: [PATCH] feat(import): Packt parser and packt-video job Co-Authored-By: Claude Opus 5.5 --- packages/feature-import/src/context.ts | 102 ++++++++++++++++++ packages/feature-import/src/jobs/common.ts | 48 +++++++++ .../feature-import/src/jobs/packt-video.ts | 86 +++++++++++++++ packages/feature-import/src/packt.ts | 74 +++++++++++++ packages/feature-import/src/util/source.ts | 55 ++++++++++ packages/feature-import/test/harness.ts | 21 ++++ .../feature-import/test/packt-video.test.ts | 59 ++++++++++ packages/feature-import/test/packt.test.ts | 28 +++++ 8 files changed, 473 insertions(+) create mode 100644 packages/feature-import/src/context.ts create mode 100644 packages/feature-import/src/jobs/common.ts create mode 100644 packages/feature-import/src/jobs/packt-video.ts create mode 100644 packages/feature-import/src/packt.ts create mode 100644 packages/feature-import/src/util/source.ts create mode 100644 packages/feature-import/test/harness.ts create mode 100644 packages/feature-import/test/packt-video.test.ts create mode 100644 packages/feature-import/test/packt.test.ts diff --git a/packages/feature-import/src/context.ts b/packages/feature-import/src/context.ts new file mode 100644 index 0000000..caeb3d2 --- /dev/null +++ b/packages/feature-import/src/context.ts @@ -0,0 +1,102 @@ +import { mkdir, readdir, rename, stat } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import type { ServerContext } from "@stc/contracts"; +import { readRawManifest, writeManifest, type RawManifest } from "./util/manifest"; +import { slugify, uniqueId } from "./util/text"; +import { extractFrame, type Tools } from "./media"; + +export interface ImportEnv { [k: string]: string | undefined } +export interface ImportOptions { env?: ImportEnv } + +export interface Deps { + ctx: ServerContext; + env: ImportEnv; + tools: Tools; + python: string; +} + +export const coursesDir = (d: Deps) => resolve(d.ctx.config.dataDir, "courses"); +export const cacheDir = (d: Deps) => resolve(d.ctx.config.dataDir, "cache"); +export const deckCacheDir = (d: Deps, courseId: string, deckId: string) => join(cacheDir(d), "decks", courseId, deckId); +export const coverCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "covers", courseId); +export const transcriptCacheDir = (d: Deps, courseId: string) => join(cacheDir(d), "transcripts", courseId); + +export const isPlainId = (s: string) => /^[a-z0-9][a-z0-9-]*$/.test(s); + +/** A fresh course id from a title, unique among managed folders and every course the server already knows. */ +export async function freshCourseId(d: Deps, wanted: string | undefined, title: string): Promise { + if (wanted !== undefined) { + if (!isPlainId(wanted)) throw new Error(`invalid courseId "${wanted}" (use lowercase letters, digits and dashes)`); + if (await exists(join(coursesDir(d), wanted)) || d.ctx.courses.get(wanted)) throw new Error(`a course with id "${wanted}" already exists`); + return wanted; + } + const taken = new Set(d.ctx.courses.list().map((c) => c.id)); + for (const e of await readdir(coursesDir(d)).catch(() => [] as string[])) taken.add(e); + return uniqueId(slugify(title), (x) => taken.has(x)); +} + +export async function exists(p: string): Promise { + return stat(p).then(() => true, () => false); +} + +/** Scratch folder on the same filesystem as the courses, so finished work is moved in with one rename. */ +export async function stageDir(d: Deps, label: string): Promise { + const dir = resolve(d.ctx.config.dataDir, "tmp", `${label}-${Bun.randomUUIDv7()}`); + await mkdir(dir, { recursive: true }); + return dir; +} + +export async function publishCourse(d: Deps, stage: string, id: string): Promise { + await mkdir(coursesDir(d), { recursive: true }); + const dest = join(coursesDir(d), id); + await rename(stage, dest); + return dest; +} + +export interface ManagedCourse { id: string; dir: string; manifest: RawManifest } + +/** An existing course the importer may add to. Only folders directly under /courses qualify: overlays and authored repos are never edited. */ +export async function managedCourse(d: Deps, courseId: string | undefined): Promise { + if (!courseId) throw new Error("options.courseId is required: choose the course to add to"); + const dir = join(coursesDir(d), courseId); + const rel = relative(coursesDir(d), dir); + if (!isPlainId(courseId) || rel.startsWith("..") || !(await exists(join(dir, "stcourse.yaml")))) { + throw new Error(`course "${courseId}" is not an imported course under ${coursesDir(d)}; the importer only edits courses it created`); + } + const manifest = await readRawManifest(dir); + if (manifest.id !== courseId) throw new Error(`course folder "${courseId}" has manifest id "${manifest.id}"`); + return { id: courseId, dir, manifest }; +} + +/** Reloads the repository and turns load problems into warnings. */ +export async function finish(d: Deps, id: string, warnings: string[]): Promise { + await d.ctx.courses.reload(id); + const c = d.ctx.courses.get(id); + if (!c) { + warnings.push(`course "${id}" is not visible to the server: add ${coursesDir(d)} to STC_COURSE_DIRS`); + return; + } + for (const diag of c.diagnostics) warnings.push(`${diag.level}: ${diag.message}${diag.path ? ` (${diag.path})` : ""}`); +} + +export async function saveManifest(dir: string, manifest: RawManifest): Promise { + await writeManifest(dir, manifest); +} + +export function sourceBlock(kind: string, origin: string) { + return { kind, origin, importedAt: new Date().toISOString() }; +} + +/** Writes up to `count` frame candidates from evenly spaced clips; returns the candidate file names. Failures are skipped. */ +export async function frameCandidates(d: Deps, courseId: string, clips: string[], count = 3): Promise { + const out: string[] = []; + const n = Math.min(count, clips.length); + for (let i = 0; i < n; i++) { + const name = `frame-${i + 1}.jpg`; + try { + await extractFrame(d.tools, clips[Math.floor((i * clips.length) / n)]!, join(coverCacheDir(d, courseId), name), 0.2); + out.push(name); + } catch { /* a bad clip must not fail the import */ } + } + return out; +} diff --git a/packages/feature-import/src/jobs/common.ts b/packages/feature-import/src/jobs/common.ts new file mode 100644 index 0000000..f8158fc --- /dev/null +++ b/packages/feature-import/src/jobs/common.ts @@ -0,0 +1,48 @@ +import type { ImportResult, ImportSource, JobContext } from "@stc/contracts"; +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { coverCacheDir, exists, type Deps } from "../context"; + +export type Opts = Record; + +export function parsePayload(payload: unknown, kind: ImportSource["kind"]): { path: string; options: Opts } { + const p = payload as Partial | null; + if (!p || typeof p.path !== "string" || p.path === "") throw new Error("payload.path is required"); + if (p.kind !== undefined && p.kind !== kind) throw new Error(`payload.kind "${p.kind}" does not match job kind "${kind}"`); + return { path: p.path, options: (p.options ?? {}) as Opts }; +} + +export const optStr = (o: Opts, k: string): string | undefined => (typeof o[k] === "string" && (o[k] as string).trim() !== "" ? (o[k] as string).trim() : undefined); +export const optBool = (o: Opts, k: string): boolean => o[k] === true || o[k] === "true"; + +/** Runs a job body with a scratch folder that is always removed, and a result shape shared by all import jobs. */ +export async function withStage(stage: string, fn: () => Promise): Promise { + try { + return await fn(); + } finally { + await rm(stage, { recursive: true, force: true }); + } +} + +/** Copies the first cover candidate into the course (relative path returned) so the library has a cover straight away. */ +export async function adoptCover(d: Deps, courseId: string, courseDir: string, candidate: string | undefined): Promise { + if (!candidate) return undefined; + const src = join(coverCacheDir(d, courseId), candidate); + if (!(await exists(src))) return undefined; + const rel = "media/cover.jpg"; + await mkdir(dirname(join(courseDir, rel)), { recursive: true }); + await copyFile(src, join(courseDir, rel)); + return rel; +} + +export async function maybeQueueTranscription(d: Deps, courseId: string, wanted: boolean, warnings: string[]): Promise { + if (!wanted) return; + if (!d.env.STC_TRANSCRIBE_URL) { + warnings.push("transcription requested but STC_TRANSCRIBE_URL is not set; skipped"); + return; + } + await d.ctx.jobs.enqueue("import.transcribe", { courseId }); +} + +export type Reporter = JobContext["reportProgress"]; +export type { ImportResult }; diff --git a/packages/feature-import/src/jobs/packt-video.ts b/packages/feature-import/src/jobs/packt-video.ts new file mode 100644 index 0000000..cca74f1 --- /dev/null +++ b/packages/feature-import/src/jobs/packt-video.ts @@ -0,0 +1,86 @@ +import { mkdir, rename } from "node:fs/promises"; +import { basename, dirname, join, posix } from "node:path"; +import type { ImportResult, JobContext } from "@stc/contracts"; +import { finish, frameCandidates, freshCourseId, publishCourse, saveManifest, sourceBlock, stageDir, type Deps } from "../context"; +import { probeDuration } from "../media"; +import { parsePacktIndex } from "../packt"; +import { openSource } from "../util/source"; +import { adoptCover, maybeQueueTranscription, optBool, optStr, parsePayload, withStage } from "./common"; + +export async function importPacktVideo(d: Deps, payload: unknown, jc: JobContext): Promise { + const { path, options } = parsePayload(payload, "packt-video"); + const warnings: string[] = []; + await jc.reportProgress(0.02, "Reading the package"); + const src = await openSource(path); + + // The player page is the shallowest index.html that lists sections. + const candidates = src.entries.filter((e) => /(^|\/)index\.html$/i.test(e)).sort((a, b) => a.split("/").length - b.split("/").length); + let indexEntry: string | undefined; + let index: ReturnType | undefined; + for (const c of candidates) { + try { index = parsePacktIndex(await src.readText(c)); indexEntry = c; break; } catch { /* try the next */ } + } + if (!index || indexEntry === undefined) throw new Error("no Packt player index.html (with \"Section N: …\" headings) found in the package"); + warnings.push(...index.warnings); + const base = dirname(indexEntry) === "." ? "" : dirname(indexEntry); + const at = (rel: string) => posix.normalize(base ? `${base}/${rel}` : rel); + + const title = optStr(options, "title") ?? index.title; + const id = await freshCourseId(d, optStr(options, "courseId"), title); + const stage = await stageDir(d, "packt"); + + return withStage(stage, async () => { + const known = new Set(src.entries); + const modules: any[] = []; + const used = new Set(); + const clipFiles: string[] = []; + const total = index.sections.reduce((n, s) => n + s.clips.length, 0) || 1; + let done = 0; + for (const sec of index.sections) { + const mid = `m${sec.number}`; + const entries: { clip: (typeof sec.clips)[number]; entry: string }[] = []; + for (const clip of sec.clips) { + const entry = at(clip.video); + if (!known.has(entry)) { warnings.push(`video for ${sec.number}.${clip.number} is missing from the package: ${clip.video}`); continue; } + entries.push({ clip, entry }); + used.add(entry); + } + await jc.reportProgress(0.05 + 0.65 * (done / total), `Copying section ${sec.number}: ${sec.title}`); + const tmp = join(stage, "x"); + await src.extract(entries.map((e) => e.entry), tmp, jc.signal); + const lessons: any[] = []; + for (const { clip, entry } of entries) { + const rel = `media/${basename(entry)}`; + await mkdir(join(stage, "media"), { recursive: true }); + await rename(join(tmp, entry), join(stage, rel)); + clipFiles.push(join(stage, rel)); + const duration = await probeDuration(d.tools, join(stage, rel)); + lessons.push({ id: `${mid}-l${clip.number}`, title: clip.title, type: "video", file: rel, ...(duration ? { duration } : {}) }); + done++; + } + modules.push({ id: mid, title: sec.title.replace(/^Section\s+\d+\s*[:.-]\s*/i, ""), lessons }); + } + const unused = src.entries.filter((e) => /\.(mp4|webm|m4v|mov)$/i.test(e) && !used.has(e)); + if (unused.length) warnings.push(`${unused.length} video file(s) in the package are not listed in index.html and were left out`); + + await jc.reportProgress(0.75, "Making cover candidates"); + const covers = await frameCandidates(d, id, clipFiles); + if (covers.length === 0 && clipFiles.length) warnings.push("could not extract cover frames (is ffmpeg available?)"); + const cover = await adoptCover(d, id, stage, covers[0]); + + const manifest = { + schema: 1, id, title, + visibility: "restricted", + ...(cover ? { cover } : {}), + source: sourceBlock("packt-video", basename(path)), + modules, + }; + await saveManifest(stage, manifest); + await jc.reportProgress(0.9, "Publishing the course"); + const dir = await publishCourse(d, stage, id); + await finish(d, id, warnings); + await maybeQueueTranscription(d, id, optBool(options, "transcribe"), warnings); + await jc.reportProgress(1, `Imported ${total} clips`); + return { courseId: id, manifestPath: join(dir, "stcourse.yaml"), warnings }; + }); +} diff --git a/packages/feature-import/src/packt.ts b/packages/feature-import/src/packt.ts new file mode 100644 index 0000000..07073d2 --- /dev/null +++ b/packages/feature-import/src/packt.ts @@ -0,0 +1,74 @@ +import { decodeEntities, stripTags } from "./util/text"; + +export interface PacktClip { number: number; title: string; video: string; } +export interface PacktSection { number: number; title: string; clips: PacktClip[] } +export interface PacktIndex { title: string; sections: PacktSection[]; warnings: string[] } + +/** + * Parses the Packt player's index.html structurally: + * - the section overview: `

Section N: …

` followed by a `
    ` of clip titles, in order, usually WITHOUT "Video N.M:" prefixes; + * - the per-section chapter blocks (`class="chapter" id="content-N"`) with one `
  • ` per clip: `

    N.M title

    ` and `data-video="…"`. + * Chapter blocks are authoritative for the video file; the overview list fills in any title or whole section that is missing. + */ +export function parsePacktIndex(html: string): PacktIndex { + const warnings: string[] = []; + const intro = /]*class="intro"[\s\S]*?]*>([\s\S]*?)<\/h1>/i.exec(html); + const titleTag = /]*>([\s\S]*?)<\/title>/i.exec(html); + const title = stripTags(intro?.[1] ?? titleTag?.[1] ?? "") || "Imported course"; + + const chapterStart = html.search(/]*class="chapter"/i); + const overview = chapterStart >= 0 ? html.slice(0, chapterStart) : html; + const chaptersHtml = chapterStart >= 0 ? html.slice(chapterStart) : ""; + + // overview: sections in document order + const secRe = /]*>\s*Section\s+(\d+)\s*[:.-]\s*([\s\S]*?)<\/h1>/gi; + const heads = [...overview.matchAll(secRe)]; + if (heads.length === 0) throw new Error("not a Packt video package: no \"Section N: …\" headings found in index.html"); + const overviewTitles = new Map(); + const sectionTitles = new Map(); + heads.forEach((h, i) => { + const n = Number(h[1]); + sectionTitles.set(n, stripTags(h[2]!)); + const block = overview.slice(h.index! + h[0].length, heads[i + 1]?.index ?? overview.length); + const ul = /]*>([\s\S]*?)<\/ul>/i.exec(block); + const items = ul ? [...ul[1]!.matchAll(/]*>([\s\S]*?)<\/li>/gi)].map((m) => stripTags(m[1]!)).filter(Boolean) : []; + const firstP = /]*>\s*Video\s+\d+\.\d+\s*:\s*([\s\S]*?)<\/p>/i.exec(block); + if (items.length === 0 && firstP) items.push(stripTags(firstP[1]!)); + overviewTitles.set(n, items); + }); + + // chapters: clips with their files + const chapterClips = new Map(); + const chapRe = /]*class="chapter"[^>]*id="content-(\d+)"[^>]*>/gi; + const chaps = [...chaptersHtml.matchAll(chapRe)]; + chaps.forEach((c, i) => { + const sec = Number(c[1]); + const block = chaptersHtml.slice(c.index! + c[0].length, chaps[i + 1]?.index ?? chaptersHtml.length); + const clipRe = /]*>\s*(?:Video\s+)?(\d+)\.(\d+)\s*[:.]?\s*([\s\S]*?)<\/h1>/gi; + const hs = [...block.matchAll(clipRe)]; + const clips: PacktClip[] = []; + hs.forEach((h, j) => { + const part = block.slice(h.index!, hs[j + 1]?.index ?? block.length); + const vid = /data-video="([^"]+)"/i.exec(part)?.[1] ?? /href="([^"]+\.(?:mp4|webm|m4v|mov))"/i.exec(part)?.[1]; + const num = Number(h[2]); + if (!vid) { warnings.push(`clip ${sec}.${num} has no video file in index.html`); return; } + clips.push({ number: num, title: stripTags(h[3]!), video: decodeEntities(vid) }); + }); + chapterClips.set(sec, clips); + }); + + const sections: PacktSection[] = []; + for (const n of [...sectionTitles.keys()].sort((a, b) => a - b)) { + let clips = chapterClips.get(n); + const titles = overviewTitles.get(n) ?? []; + if (!clips || clips.length === 0) { + warnings.push(`section ${n} has no chapter block; clips were taken from the overview list and mapped to videos/video${n}_M.mp4 in order`); + clips = titles.map((t, i) => ({ number: i + 1, title: t, video: `videos/video${n}_${i + 1}.mp4` })); + } else { + clips = clips.map((c, i) => ({ ...c, title: c.title || titles[i] || `Video ${n}.${c.number}` })); + if (titles.length && titles.length !== clips.length) warnings.push(`section ${n}: overview lists ${titles.length} clips but the chapter has ${clips.length}`); + } + sections.push({ number: n, title: sectionTitles.get(n)!, clips }); + } + return { title, sections, warnings }; +} diff --git a/packages/feature-import/src/util/source.ts b/packages/feature-import/src/util/source.ts new file mode 100644 index 0000000..43bf664 --- /dev/null +++ b/packages/feature-import/src/util/source.ts @@ -0,0 +1,55 @@ +import { cp, mkdir, readdir, readFile, stat } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { run } from "./exec"; + +/** A read-only view of an input: a zip file or an already extracted folder. Nothing in here writes to the input. */ +export interface Source { + kind: "zip" | "dir"; + entries: string[]; // files only, "/"-separated, relative to the source root + readText(entry: string): Promise; + /** Copies the given entries to `dest`, keeping their relative paths. */ + extract(entries: string[], dest: string, signal?: AbortSignal): Promise; +} + +const globEscape = (s: string) => s.replace(/[\\[\]*?]/g, (c) => `\\${c}`); + +async function walk(root: string, rel = "", out: string[] = []): Promise { + for (const e of await readdir(join(root, rel), { withFileTypes: true })) { + const r = rel ? `${rel}/${e.name}` : e.name; + if (e.isDirectory()) await walk(root, r, out); + else if (e.isFile()) out.push(r); + } + return out; +} + +export async function openSource(path: string): Promise { + const st = await stat(path).catch(() => null); + if (!st) throw new Error(`source not found: ${path}`); + if (st.isDirectory()) { + return { + kind: "dir", + entries: await walk(path), + readText: (e) => readFile(join(path, e), "utf8"), + async extract(entries, dest) { + for (const e of entries) { + await mkdir(dirname(join(dest, e)), { recursive: true }); + await cp(join(path, e), join(dest, e)); + } + }, + }; + } + const listing = await run(["unzip", "-Z1", path]); + const entries = listing.split("\n").filter((l) => l && !l.endsWith("/")); + return { + kind: "zip", + entries, + readText: (e) => run(["unzip", "-p", path, globEscape(e)]), + async extract(list, dest, signal) { + await mkdir(dest, { recursive: true }); + // unzip treats arguments as patterns, so glob characters in names are escaped; batches keep argv small. + for (let i = 0; i < list.length; i += 200) { + await run(["unzip", "-q", "-o", path, ...list.slice(i, i + 200).map(globEscape), "-d", dest], { signal }); + } + }, + }; +} diff --git a/packages/feature-import/test/harness.ts b/packages/feature-import/test/harness.ts new file mode 100644 index 0000000..d39d528 --- /dev/null +++ b/packages/feature-import/test/harness.ts @@ -0,0 +1,21 @@ +import { join } from "node:path"; +import type { JobContext, ServerContext } from "@stc/contracts"; +import { createCourseRepository } from "@stc/course-format"; +import type { Deps } from "../src/context"; +import { PYTHON, tmp } from "./fixtures"; + +export interface TestEnv { deps: Deps; dataDir: string; queued: { kind: string; payload: unknown }[]; jc: JobContext; progress: [number, string | undefined][] } + +export async function makeEnv(env: Record = {}): Promise { + const dataDir = await tmp("stc-data-"); + const courses = await createCourseRepository([join(dataDir, "courses")]); + const queued: TestEnv["queued"] = []; + const progress: TestEnv["progress"] = []; + const ctx = { + config: { port: 0, databaseUrl: "sqlite://:memory:", dataDir, courseDirs: [join(dataDir, "courses")], secret: "t", publicUrl: "http://x" }, + courses, + jobs: { enqueue: async (kind: string, payload: unknown) => { queued.push({ kind, payload }); return "j1"; }, get: async () => null, list: async () => [] }, + } as unknown as ServerContext; + const jc: JobContext = { signal: new AbortController().signal, reportProgress: async (f, m) => { progress.push([f, m]); } }; + return { deps: { ctx, env, tools: { ffmpeg: "ffmpeg", ffprobe: "ffprobe" }, python: PYTHON }, dataDir, queued, jc, progress }; +} diff --git a/packages/feature-import/test/packt-video.test.ts b/packages/feature-import/test/packt-video.test.ts new file mode 100644 index 0000000..63b4c8f --- /dev/null +++ b/packages/feature-import/test/packt-video.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { importPacktVideo } from "../src/jobs/packt-video"; +import { makeEnv } from "./harness"; +import { makePacktZip, tmp } from "./fixtures"; + +test("imports a Packt zip into a restricted course with sections, titles and media", async () => { + const env = await makeEnv(); + const zip = await makePacktZip(await tmp()); + const before = await readFile(zip); + const r = await importPacktVideo(env.deps, { kind: "packt-video", path: zip, options: { title: "Docker Test" } }, env.jc); + expect(r.courseId).toBe("docker-test"); + expect(r.warnings).toEqual([]); + const c = env.deps.ctx.courses.get("docker-test")!; + expect(c.status).toBe("ok"); + expect(c.diagnostics).toEqual([]); + const m = c.manifest!; + expect(m.visibility).toBe("restricted"); + expect(m.source?.kind).toBe("packt-video"); + expect(m.source?.origin).toBe("video_zip.zip"); + expect(m.source?.importedAt).toBeTruthy(); + expect(m.modules.map((x) => x.title)).toEqual(["Intro to Docker", "Images (Sample)"]); + expect(m.modules.map((x) => x.lessons.map((l) => l.title))).toEqual([["Course Introduction", "Docker & Friends"], ["Topic Introduction - Images"]]); + expect(m.modules[0]!.lessons[0]).toMatchObject({ id: "m1-l1", type: "video", file: "media/video1_1.mp4", duration: 2 }); + expect(env.deps.ctx.courses.resolvePath("docker-test", "media/video2_1.mp4")).toBeTruthy(); + expect(m.cover).toBe("media/cover.jpg"); + expect((await readdir(join(env.dataDir, "cache/covers/docker-test"))).sort()).toEqual(["frame-1.jpg", "frame-2.jpg", "frame-3.jpg"]); + expect(await readdir(join(env.dataDir, "tmp"))).toEqual([]); // scratch removed + expect(Buffer.compare(await readFile(zip), before)).toBe(0); // original untouched + expect(env.progress.at(-1)![0]).toBe(1); +}); + +test("second import of the same title gets a fresh id; transcribe requested without a URL warns", async () => { + const env = await makeEnv(); + const zip = await makePacktZip(await tmp()); + await importPacktVideo(env.deps, { path: zip, options: { title: "Same" } }, env.jc); + const r = await importPacktVideo(env.deps, { path: zip, options: { title: "Same", transcribe: true } }, env.jc); + expect(r.courseId).toBe("same-2"); + expect(r.warnings.join()).toContain("STC_TRANSCRIBE_URL is not set"); + expect(env.queued).toEqual([]); +}); + +test("a zip that is not a Packt package fails and leaves nothing behind", async () => { + const env = await makeEnv(); + const d = await tmp(); + await Bun.write(join(d, "t", "readme.txt"), "x"); + const { zipDir } = await import("./fixtures"); + await zipDir(join(d, "t"), join(d, "bad.zip")); + await expect(importPacktVideo(env.deps, { path: join(d, "bad.zip") }, env.jc)).rejects.toThrow(/index\.html/); + expect(await stat(join(env.dataDir, "courses")).catch(() => null)).toBeNull(); +}); + +test("transcribe option queues the transcription job when a URL is configured", async () => { + const env = await makeEnv({ STC_TRANSCRIBE_URL: "http://127.0.0.1:1/v1/audio/transcriptions" }); + const zip = await makePacktZip(await tmp()); + const r = await importPacktVideo(env.deps, { path: zip, options: { transcribe: true } }, env.jc); + expect(env.queued).toEqual([{ kind: "import.transcribe", payload: { courseId: r.courseId } }]); +}); diff --git a/packages/feature-import/test/packt.test.ts b/packages/feature-import/test/packt.test.ts new file mode 100644 index 0000000..dbdf306 --- /dev/null +++ b/packages/feature-import/test/packt.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { parsePacktIndex } from "../src/packt"; +import { packtIndexHtml } from "./fixtures"; + +test("sections, clip titles and video files come from the structure", () => { + const p = parsePacktIndex(packtIndexHtml()); + expect(p.title).toBe("Fake Docker Course"); + expect(p.sections.map((s) => s.title)).toEqual(["Intro to Docker", "Images (Sample)"]); + expect(p.sections[0]!.clips).toEqual([ + { number: 1, title: "Course Introduction", video: "videos/video1_1.mp4" }, + { number: 2, title: "Docker & Friends", video: "videos/video1_2.mp4" }, + ]); + expect(p.sections[1]!.clips[0]!.video).toBe("videos/video2_1.mp4"); + expect(p.warnings).toEqual([]); +}); + +test("a section without a chapter block falls back to the overview list", () => { + const p = parsePacktIndex(packtIndexHtml({ dropChapter: 1 })); + expect(p.sections[0]!.clips.map((c) => [c.title, c.video])).toEqual([ + ["Course Introduction", "videos/video1_1.mp4"], + ["Docker & Friends", "videos/video1_2.mp4"], + ]); + expect(p.warnings.join()).toContain("section 1 has no chapter block"); +}); + +test("not a Packt page is a clear error", () => { + expect(() => parsePacktIndex("

    Hello

    ")).toThrow(/no "Section N/); +});