Files
STCourses/packages/feature-studio/src/server/media-refs.ts
T
Lucas WintherandClaude Opus 5.5 771d755da4 fix(studio): review round 1 for imported-course editing
- Media exclusions move from the editable root .gitignore to the repo's
  info/exclude, and dirtyFiles never counts files listed in media.json, so an
  author's or remote's .gitignore cannot block publish or Sync. Publish and
  Sync refuse commits that track a file over referenced media.
- Importer jobs (code-repo, pdf-deck, transcribe) and the cover route run
  under the course lock now shared with the studio (core withCourseLock) and
  re-check the course inside it; after conversion they fail with "this course
  is now edited in the studio". Convert answers 409 while an import job for
  the course is queued or running, and the job re-checks.
- Crash-safe conversion: the import folder is hidden (.converting-<id>) before
  the published worktree exists; cross-filesystem moves copy into a staging
  dir, fsync, verify size and sha256, then rename; the source is retired as
  .converted-<id>.<n>. Recovery runs at server start and before every convert;
  a re-run of a finished conversion reports success. The loader skips
  dot-folders.
- Minor: no raw NUL bytes in source, link fallback only on EXDEV/EPERM/EMLINK,
  referenced media are not read whole by the editor, missing media are logged,
  mediaDir JSDoc, docs on per-author copies across filesystems.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:53:50 +01:00

62 lines
3.1 KiB
TypeScript

import { extname } from "node:path";
import { checkRelPath } from "./relpath";
/**
* Large media of a converted import stay out of git (v1.1 S, docs/operations.md "Imported courses in the studio").
* The repo commits `MEDIA_REFS` (path, size and checksum per file, spec 5); the repo's `info/exclude` (not the
* author-editable `.gitignore`) gets one line per path, and the studio never counts them as changes. Every studio worktree gets the files as hard links to `<dataDir>/media/<courseId>/<path>`.
*/
export const MEDIA_REFS = ".stcourses/media.json";
export const LARGE_FILE_BYTES = 8 * 1024 * 1024;
const MEDIA_EXT = new Set(["mp4", "m4v", "webm", "mov", "mkv", "avi", "mp3", "m4a", "wav", "ogg", "oga", "opus", "flac"]);
export interface MediaRef { path: string; size: number; sha256: string }
/** Video and audio always; anything else only when it is over `LARGE_FILE_BYTES`. */
export function isLargeMedia(rel: string, size: number): boolean {
return MEDIA_EXT.has(extname(rel).slice(1).toLowerCase()) || size > LARGE_FILE_BYTES;
}
/** One anchored `.gitignore` pattern matching exactly `rel`. Throws for names a `.gitignore` line cannot hold. */
export function gitignoreLine(rel: string): string {
if (/[\n\r]/.test(rel)) throw new Error(`a file name with a line break cannot be kept out of git: ${JSON.stringify(rel)}`);
let s = rel.replace(/[\\*?[\]]/g, (c) => `\\${c}`);
const trail = /( +)$/.exec(s)?.[1]?.length ?? 0;
if (trail) s = s.slice(0, -trail) + "\\ ".repeat(trail);
return `/${s}`;
}
export const MEDIA_EXCLUDE_HEADER = "# Imported media kept outside git: see .stcourses/media.json (STCourses)";
/** Ignore-file content: `existing` (if any) first, then the header and one line per referenced file. */
export function mediaGitignore(paths: string[], existing: string | null): string {
const own = existing ? (existing.endsWith("\n") ? existing : existing + "\n") + "\n" : "";
return `${own}${MEDIA_EXCLUDE_HEADER}\n${paths.map(gitignoreLine).join("\n")}\n`;
}
export async function sha256File(abs: string): Promise<string> {
const h = new Bun.CryptoHasher("sha256");
for await (const chunk of Bun.file(abs).stream()) h.update(chunk);
return h.digest("hex");
}
export function stringifyMediaRefs(files: MediaRef[]): string {
return JSON.stringify({ schema: 1, files }, null, 2) + "\n";
}
/** The reference list, or null when the text is not a valid one (every path must pass `checkRelPath`). */
export function parseMediaRefs(text: string): MediaRef[] | null {
let raw: unknown;
try { raw = JSON.parse(text); } catch { return null; }
const r = raw as { schema?: unknown; files?: unknown } | null;
if (!r || r.schema !== 1 || !Array.isArray(r.files)) return null;
const out: MediaRef[] = [];
for (const f of r.files as Partial<MediaRef>[]) {
if (!f || typeof f.path !== "string" || typeof f.size !== "number" || !Number.isInteger(f.size) || f.size < 0) return null;
if (typeof f.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(f.sha256)) return null;
try { if (checkRelPath(f.path) !== f.path) return null; } catch { return null; }
out.push({ path: f.path, size: f.size, sha256: f.sha256 });
}
return out;
}