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}`); });