feat(import): Packt parser and packt-video job
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
34f27d05c9
commit
07e2223c8a
8 files changed
+473
No files matched your search
@@ -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<string, string | undefined> = {}): Promise<TestEnv> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 } }]);
|
||||
});
|
||||
@@ -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("<html><h1>Hello</h1></html>")).toThrow(/no "Section N/);
|
||||
});
|
||||
Reference in new issue
Block a user