jellyfin-lessons/admin-ui/server.js
Engineering (agent-engineering) e4967e1cda fix: sync admin UI schema with plugin (UUID keys, lesson_media table)
Schema now matches plugin Data/LessonStore.cs exactly:
- id TEXT PRIMARY KEY (UUIDs)
- lesson_media table (not media_items)
- Full column set with NOT NULL constraints matching plugin

Addresses PR #14 review feedback.
2026-05-15 21:50:21 -07:00

175 lines
6.2 KiB
JavaScript

const express = require('express');
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const os = require('os');
const { execSync } = require('child_process');
const app = express();
const PORT = 3099;
// DB path shared with plugin
const DB_PATH = path.join(os.homedir(), '.local', 'share', 'jellyfin-lessons', 'lessons.db');
// Ensure DB directory exists
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
// UUID helper
function generateId() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = Math.random() * 16 | 0;
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
}
// Initialize schema — must match plugin Data/LessonStore.cs
const now = () => new Date().toISOString();
db.exec(`
CREATE TABLE IF NOT EXISTS lessons (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
grade INTEGER NOT NULL,
subject TEXT NOT NULL,
lesson_date TEXT NOT NULL,
description TEXT,
source_path TEXT NOT NULL,
compiled_html TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS lesson_media (
lesson_id TEXT NOT NULL,
jellyfin_item_id TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (lesson_id, jellyfin_item_id),
FOREIGN KEY (lesson_id) REFERENCES lessons(id) ON DELETE CASCADE
);
`);
// Middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.static(path.join(__dirname, 'public')));
// CORS for local dev
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
res.header('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.sendStatus(200);
next();
});
// GET /api/lessons — list all lessons
app.get('/api/lessons', (req, res) => {
try {
const lessons = db.prepare('SELECT * FROM lessons ORDER BY updated_at DESC').all();
res.json(lessons);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// GET /api/lessons/:id — single lesson with media items
app.get('/api/lessons/:id', (req, res) => {
try {
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(req.params.id);
if (!lesson) return res.status(404).json({ error: 'Lesson not found' });
const mediaItems = db.prepare('SELECT * FROM lesson_media WHERE lesson_id = ? ORDER BY sort_order').all(req.params.id);
res.json({ ...lesson, mediaItems });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/lessons — create or update lesson
app.post('/api/lessons', (req, res) => {
try {
const { id, title, grade, subject, lessonDate, description, sourcePath, compiledHtml, mediaItems } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const upsertLesson = db.transaction(() => {
let lessonId = id || generateId();
const ts = now();
const existing = id ? db.prepare('SELECT created_at FROM lessons WHERE id = ?').get(id) : null;
const createdAt = existing ? existing.created_at : ts;
db.prepare(`
INSERT INTO lessons (id, title, grade, subject, lesson_date, description, source_path, compiled_html, created_at, updated_at)
VALUES (@id, @title, @grade, @subject, @lesson_date, @description, @source_path, @compiled_html, @created_at, @updated_at)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title, grade = excluded.grade, subject = excluded.subject,
lesson_date = excluded.lesson_date, description = excluded.description,
source_path = excluded.source_path, compiled_html = excluded.compiled_html,
updated_at = excluded.updated_at
`).run({
id: lessonId, title, grade: grade || 0,
subject: subject || '', lesson_date: lessonDate || new Date().toISOString().split('T')[0],
description: description || null, source_path: sourcePath || '',
compiled_html: compiledHtml || null, created_at: createdAt, updated_at: ts
});
// Replace media items
db.prepare('DELETE FROM lesson_media WHERE lesson_id = ?').run(lessonId);
if (mediaItems && Array.isArray(mediaItems)) {
const insertMedia = db.prepare('INSERT INTO lesson_media (lesson_id, jellyfin_item_id, sort_order) VALUES (?, ?, ?)');
for (const item of mediaItems) {
insertMedia.run(lessonId, item.jellyfinItemId, item.sortOrder || 0);
}
}
return lessonId;
});
const lessonId = upsertLesson();
const lesson = db.prepare('SELECT * FROM lessons WHERE id = ?').get(lessonId);
const media = db.prepare('SELECT * FROM lesson_media WHERE lesson_id = ? ORDER BY sort_order').all(lessonId);
res.json({ ...lesson, mediaItems: media });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// DELETE /api/lessons/:id
app.delete('/api/lessons/:id', (req, res) => {
try {
const result = db.prepare('DELETE FROM lessons WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Lesson not found' });
res.json({ deleted: true });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/compile — compile Typst to HTML
app.post('/api/compile', (req, res) => {
try {
const { typst } = req.body;
if (!typst) return res.status(400).json({ error: 'typst source is required' });
// Write to temp file
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'typst-'));
const srcFile = path.join(tmpDir, 'lesson.typ');
const outFile = path.join(tmpDir, 'lesson.html');
fs.writeFileSync(srcFile, typst);
try {
execSync(`typst compile "${srcFile}" "${outFile}"`, { timeout: 10000, encoding: 'utf8' });
const html = fs.readFileSync(outFile, 'utf8');
res.json({ html });
} finally {
// Cleanup
fs.rmSync(tmpDir, { recursive: true, force: true });
}
} catch (err) {
res.status(500).json({ error: err.stderr || err.message });
}
});
app.listen(PORT, () => {
console.log(`Jellyfin Lessons Admin UI running at http://localhost:${PORT}`);
});