feat(import): Packt parser and packt-video job

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 03:15:09 +01:00
1 parent 34f27d05c9
commit 07e2223c8a
8 files changed
+473

No files matched your search

+102
View File
@@ -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<string> {
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<string>(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<boolean> {
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<string> {
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<string> {
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 <dataDir>/courses qualify: overlays and authored repos are never edited. */
export async function managedCourse(d: Deps, courseId: string | undefined): Promise<ManagedCourse> {
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<void> {
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<void> {
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<string[]> {
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;
}