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.
175 lines
6.2 KiB
JavaScript
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}`);
|
|
});
|