Compare commits

..

15 commits

Author SHA1 Message Date
3000ec870c Merge pull request 'feat: add NixOS module for Jellyfin integration' (#17) from issue-8-nixos-module into main 2026-05-15 21:55:59 -07:00
Scott Scoble
22da56ff4a feat: add NixOS module for Jellyfin integration
Add nixosModules.default with:
- services.jellyfin-lessons.enable toggle
- Configurable dataDir and adminPort options
- Plugin DLL auto-copy to Jellyfin plugins dir
- Admin UI as a user systemd service

The projectFile path was already correct (Jellyfin.Plugin.Lessons.csproj).
nix flake check passes.
2026-05-15 21:55:10 -07:00
1e7c3bce55 Merge pull request 'Lesson detail view with Typst HTML + media player (Issue #6)' (#16) from issue-6-detail-view into main 2026-05-15 21:53:52 -07:00
Scott Scoble
6494567713 feat: add lesson detail view with Typst HTML rendering and Jellyfin media player
- Add Detail endpoint to LessonsController (GET /Plugins/Lessons/Detail?id=...)
- Update lessonsPage.html with detail view, click handlers, and back navigation
- Media cards fetch Jellyfin item details and render thumbnails & durations
- Clicking media card launches Jellyfin native player via appRouter.showItem
- Compiled Typst HTML rendered via innerHTML with dark theme styling
- Works on web and Fire TV Silk browser

Closes #6
2026-05-15 21:53:08 -07:00
68c6861e70 Merge pull request 'feat: web admin UI for lesson management (#7)' (#14) from issue-7-admin-ui into main 2026-05-15 21:50:30 -07:00
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
5e9fdd1748 Merge pull request 'feat: Lessons page with API and sidebar nav entry (#5)' (#15) from issue-5-lessons-page into main 2026-05-15 21:49:31 -07:00
Scott Scoble
2df642cf52 feat: add Lessons page with API and sidebar nav entry (#5)
- Add LessonsController with GET /Plugins/Lessons/List endpoint
- Add LessonStore DI registration in ServiceRegistrator
- Add lessonsPage.html embedded resource with grouped lesson listing
- Implement IHasWebPages in Plugin for sidebar nav entry
- Add ImplicitUsings/Nullable to csproj for net9.0 compatibility
- Add Microsoft.AspNetCore.App FrameworkReference for MVC
- Fix ServiceRegistrator to match 10.10.x IPluginServiceRegistrator signature
2026-05-15 21:48:43 -07:00
Scott Scoble
6e7d2372e7 feat: add web admin UI for lesson management (#7)
- REST API server on port 3099 with better-sqlite3
- CRUD endpoints for lessons, Typst compile endpoint
- Shared DB path with plugin (~/.local/share/jellyfin-lessons/lessons.db)
- Single-page admin UI with sidebar (grouped by grade/subject)
- Lesson editor with Typst source editing, live preview via compile
- Media items management (Jellyfin item UUIDs with sort order)
- CORS enabled for local dev
2026-05-15 21:43:34 -07:00
08f040d317 Merge pull request 'feat: Typst lesson template with Jellyfin media frontmatter' (#9) from issue-3-typst-template into main 2026-05-15 21:39:37 -07:00
8b9d156166 fix: make media-ref inline with block: false
raw() defaults to block mode which wraps output in <pre><code>,
breaking list-item rendering. block: false keeps it inline.

Addresses PR #9 review feedback.
2026-05-15 21:39:28 -07:00
fa91314c49 Merge pull request 'feat: SQLite lesson data store with schema and C# access layer' (#13) from issue-4-data-store into main 2026-05-15 21:38:51 -07:00
8915beef8e Merge pull request 'feat: Jellyfin plugin skeleton (Plugin.cs, Configuration, ServiceRegistrator)' (#11) from issue-2-plugin-skeleton into main 2026-05-15 21:38:50 -07:00
838d0cbbec feat: SQLite lesson data store with schema and C# access layer
- Lesson.cs and LessonMedia.cs model classes
- LessonStore.cs with CRUD operations
- Microsoft.Data.Sqlite package reference
- Default DB path: ~/.local/share/jellyfin-lessons/lessons.db

Closes #4
2026-05-15 21:38:35 -07:00
Scott Scoble
0700c35310 feat: add Typst lesson template with Jellyfin media frontmatter
- Add templates/lesson.typ: reusable template with lesson-template() function
  - Accepts title, grade, subject, date, media, description parameters
  - Comment-based frontmatter convention documented at top
  - Renders title heading, metadata line, description, body, media section
  - Media references use raw(lang: "jellyfin-media") for identifiable HTML elements
- Add templates/example-lesson.typ: complete example (Water Cycle lesson)
  - Demonstrates all template features
  - Christian-homeschool appropriate content (Psalm 135:7)
  - Includes 2 Jellyfin media references

Closes #3
2026-05-15 21:18:04 -07:00
16 changed files with 2879 additions and 4 deletions

2
admin-ui/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
*.db

1251
admin-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

13
admin-ui/package.json Normal file
View file

@ -0,0 +1,13 @@
{
"name": "jellyfin-lessons-admin-ui",
"version": "1.0.0",
"description": "Web admin UI for managing Jellyfin Lessons",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"express": "^4.21.0"
}
}

408
admin-ui/public/index.html Normal file
View file

@ -0,0 +1,408 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jellyfin Lessons — Admin</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5; color: #333; min-height: 100vh;
}
#app { display: flex; min-height: 100vh; }
/* Sidebar */
#sidebar {
width: 280px; background: #1a1a2e; color: #eee;
display: flex; flex-direction: column; flex-shrink: 0;
}
#sidebar h2 {
padding: 20px; font-size: 18px; border-bottom: 1px solid #333;
display: flex; justify-content: space-between; align-items: center;
}
#sidebar h2 button {
background: #0f3460; color: #e94560; border: none; padding: 6px 14px;
border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 600;
}
#sidebar h2 button:hover { background: #16213e; }
#lesson-list { flex: 1; overflow-y: auto; padding: 10px 0; }
.grade-group { margin-bottom: 4px; }
.grade-header {
padding: 8px 20px; font-size: 12px; font-weight: 700; text-transform: uppercase;
color: #999; cursor: pointer; user-select: none;
}
.grade-header:hover { color: #e94560; }
.grade-header::before { content: '▸ '; display: inline-block; transition: transform 0.2s; font-size: 10px; }
.grade-header.open::before { transform: rotate(90deg); }
.grade-lessons { display: none; }
.grade-lessons.open { display: block; }
.lesson-item {
padding: 8px 20px 8px 32px; cursor: pointer; font-size: 14px;
border-left: 3px solid transparent; transition: all 0.15s;
}
.lesson-item:hover { background: #16213e; }
.lesson-item.active { background: #0f3460; border-left-color: #e94560; font-weight: 600; }
.lesson-item .subject-tag {
font-size: 11px; color: #888; display: block;
}
/* Main */
#main {
flex: 1; padding: 30px 40px; overflow-y: auto;
}
#main h1 { font-size: 24px; margin-bottom: 24px; color: #1a1a2e; }
#editor { max-width: 800px; }
.field { margin-bottom: 16px; }
.field label {
display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; color: #555;
}
.field input, .field textarea, .field select {
width: 100%; padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px;
font-size: 14px; font-family: inherit;
}
.field textarea { resize: vertical; }
.field textarea#typst-source {
font-family: 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace;
min-height: 300px; tab-size: 2;
}
.row { display: flex; gap: 16px; }
.row .field { flex: 1; }
.btn-row { display: flex; gap: 10px; margin-top: 20px; }
button {
padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer;
font-size: 14px; font-weight: 600; transition: background 0.15s;
}
.btn-primary { background: #e94560; color: #fff; }
.btn-primary:hover { background: #c73650; }
.btn-secondary { background: #0f3460; color: #eee; }
.btn-secondary:hover { background: #16213e; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-danger:hover { background: #b52a38; }
/* Status */
#status { margin-top: 12px; font-size: 13px; min-height: 20px; }
.status-ok { color: #28a745; }
.status-err { color: #dc3545; }
/* Preview */
#preview-section { margin-top: 24px; }
#preview-section h3 {
font-size: 16px; margin-bottom: 10px; color: #1a1a2e;
}
#preview-frame {
width: 100%; min-height: 400px; border: 1px solid #ddd; border-radius: 4px;
background: #fff;
}
#preview-frame iframe {
width: 100%; height: 500px; border: none;
}
/* Media items */
#media-section { margin-top: 24px; border-top: 1px solid #ddd; padding-top: 16px; }
#media-section h3 { font-size: 16px; margin-bottom: 10px; color: #1a1a2e; }
.media-row {
display: flex; gap: 8px; align-items: center; margin-bottom: 8px;
}
.media-row input { flex: 1; }
.media-row input.sort { width: 70px; flex: none; }
.media-row button {
padding: 6px 12px; font-size: 12px; background: #666; color: #fff; border-radius: 4px;
}
.media-row button:hover { background: #444; }
.add-media-btn { background: #0f3460; color: #eee; margin-top: 4px; }
.add-media-btn:hover { background: #16213e; }
/* Empty state */
.empty-state {
text-align: center; padding: 60px 20px; color: #999;
}
.empty-state p { font-size: 16px; }
</style>
</head>
<body>
<div id="app">
<div id="sidebar">
<h2>Lessons <button onclick="newLesson()" title="New Lesson">+ New</button></h2>
<div id="lesson-list"><div class="empty-state"><p>Loading…</p></div></div>
</div>
<div id="main">
<h1>Lesson Editor</h1>
<div id="editor" style="display:none;">
<div class="row">
<div class="field"><label>Title</label><input id="title" placeholder="Lesson title"></div>
<div class="field"><label>Subject</label><input id="subject" placeholder="e.g. Math, History"></div>
</div>
<div class="row">
<div class="field"><label>Grade</label><input id="grade" type="number" placeholder="Grade level" min="0"></div>
<div class="field"><label>Date</label><input id="lesson-date" type="date"></div>
</div>
<div class="field"><label>Description</label><textarea id="description" rows="2" placeholder="Brief description of the lesson"></textarea></div>
<div class="field"><label>Typst Source</label><textarea id="typst-source" placeholder="#set page(width: auto, height: auto, margin: 15pt)
#set text(font: &quot;Linux Libertine&quot;, size: 12pt)
= Lesson Title
Your content here..."></textarea></div>
<div class="btn-row">
<button class="btn-secondary" onclick="compilePreview()">Preview</button>
<button class="btn-primary" onclick="saveLesson()">Save</button>
<button class="btn-danger" onclick="deleteLesson()" id="delete-btn" style="display:none;">Delete</button>
</div>
<div id="status"></div>
<div id="preview-section" style="display:none;">
<h3>Preview</h3>
<div id="preview-frame"></div>
</div>
<div id="media-section">
<h3>Media Items</h3>
<div id="media-list"></div>
<button class="add-media-btn" onclick="addMediaItem()">+ Add Media Item</button>
</div>
</div>
<div class="empty-state" id="no-selection">
<p>Select a lesson from the sidebar or create a new one.</p>
</div>
</div>
</div>
<script>
const API = '/api/lessons';
let currentLesson = null;
let allLessons = [];
// --- Sidebar ---
async function loadLessons() {
try {
const res = await fetch(API);
allLessons = await res.json();
renderSidebar();
} catch (e) {
document.getElementById('lesson-list').innerHTML = '<div class="empty-state"><p>Error loading lessons</p></div>';
}
}
function renderSidebar() {
const container = document.getElementById('lesson-list');
if (allLessons.length === 0) {
container.innerHTML = '<div class="empty-state"><p>No lessons yet</p></div>';
return;
}
// Group by grade
const byGrade = {};
for (const l of allLessons) {
const g = l.grade ?? 'Ungrouped';
if (!byGrade[g]) byGrade[g] = [];
byGrade[g].push(l);
}
const sortedGrades = Object.keys(byGrade).sort((a, b) => {
if (a === 'Ungrouped') return 1;
if (b === 'Ungrouped') return -1;
return Number(a) - Number(b);
});
let html = '';
for (const grade of sortedGrades) {
const label = grade === 'Ungrouped' ? 'Ungrouped' : `Grade ${grade}`;
html += `<div class="grade-group">
<div class="grade-header open" onclick="toggleGrade(this)">${label} (${byGrade[grade].length})</div>
<div class="grade-lessons open">`;
for (const l of byGrade[grade].sort((a, b) => a.title.localeCompare(b.title))) {
const active = currentLesson && currentLesson.id === l.id ? ' active' : '';
const subject = l.subject ? `<span class="subject-tag">${escHtml(l.subject)}</span>` : '';
html += `<div class="lesson-item${active}" onclick="selectLesson(${l.id})">
${escHtml(l.title)}${subject}
</div>`;
}
html += '</div></div>';
}
container.innerHTML = html;
}
function toggleGrade(el) {
el.classList.toggle('open');
el.nextElementSibling.classList.toggle('open');
}
function escHtml(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// --- Editor ---
async function selectLesson(id) {
try {
const res = await fetch(`${API}/${id}`);
if (!res.ok) throw new Error('Not found');
currentLesson = await res.json();
renderSidebar();
showEditor();
} catch (e) {
setStatus('Error loading lesson: ' + e.message, 'err');
}
}
function newLesson() {
currentLesson = {
id: null, title: '', grade: '', subject: '', lesson_date: '',
description: '', source_path: '', compiled_html: '', mediaItems: []
};
renderSidebar();
showEditor();
}
function showEditor() {
document.getElementById('editor').style.display = 'block';
document.getElementById('no-selection').style.display = 'none';
document.getElementById('delete-btn').style.display = currentLesson.id ? 'inline-block' : 'none';
document.getElementById('preview-section').style.display = 'none';
document.getElementById('title').value = currentLesson.title || '';
document.getElementById('subject').value = currentLesson.subject || '';
document.getElementById('grade').value = currentLesson.grade ?? '';
document.getElementById('lesson-date').value = currentLesson.lesson_date || '';
document.getElementById('description').value = currentLesson.description || '';
document.getElementById('typst-source').value = currentLesson.source_path || '';
document.getElementById('status').textContent = '';
renderMediaItems();
}
// --- Media Items ---
function renderMediaItems() {
const container = document.getElementById('media-list');
const items = currentLesson.mediaItems || [];
let html = '';
items.forEach((item, i) => {
html += `<div class="media-row">
<input type="text" value="${escHtml(item.jellyfin_item_id)}"
onchange="updateMediaItem(${i}, 'jellyfinItemId', this.value)" placeholder="Jellyfin Item UUID">
<input type="number" class="sort" value="${item.sort_order || 0}"
onchange="updateMediaItem(${i}, 'sortOrder', parseInt(this.value) || 0)" placeholder="Order" min="0">
<button onclick="removeMediaItem(${i})">✕</button>
</div>`;
});
container.innerHTML = html;
}
function addMediaItem() {
if (!currentLesson) return;
if (!currentLesson.mediaItems) currentLesson.mediaItems = [];
currentLesson.mediaItems.push({ jellyfinItemId: '', sortOrder: currentLesson.mediaItems.length });
renderMediaItems();
}
function updateMediaItem(index, key, value) {
if (!currentLesson || !currentLesson.mediaItems) return;
currentLesson.mediaItems[index][key] = value;
}
function removeMediaItem(index) {
if (!currentLesson || !currentLesson.mediaItems) return;
currentLesson.mediaItems.splice(index, 1);
renderMediaItems();
}
// --- Compile ---
async function compilePreview() {
const typst = document.getElementById('typst-source').value;
if (!typst.trim()) {
setStatus('Typst source is empty', 'err');
return;
}
setStatus('Compiling…', '');
try {
const res = await fetch('/api/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ typst })
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Compilation failed');
}
const data = await res.json();
// Show in iframe for proper rendering
const frame = document.getElementById('preview-frame');
frame.innerHTML = `<iframe srcdoc="${data.html.replace(/"/g, '&quot;')}"></iframe>`;
document.getElementById('preview-section').style.display = 'block';
// Also store compiled HTML
currentLesson.compiled_html = data.html;
setStatus('Compiled successfully', 'ok');
} catch (e) {
setStatus('Compile error: ' + e.message, 'err');
document.getElementById('preview-section').style.display = 'block';
document.getElementById('preview-frame').innerHTML =
`<pre style="padding:16px;color:#dc3545;white-space:pre-wrap;">${escHtml(e.message)}</pre>`;
}
}
// --- Save ---
async function saveLesson() {
const title = document.getElementById('title').value.trim();
if (!title) { setStatus('Title is required', 'err'); return; }
// Re-read fields to capture last-seen media items
const body = {
id: currentLesson.id || undefined,
title,
grade: parseInt(document.getElementById('grade').value) || null,
subject: document.getElementById('subject').value.trim() || null,
lessonDate: document.getElementById('lesson-date').value || null,
description: document.getElementById('description').value.trim() || null,
sourcePath: document.getElementById('typst-source').value,
compiledHtml: currentLesson.compiled_html || null,
mediaItems: (currentLesson.mediaItems || []).map(m => ({
jellyfinItemId: m.jellyfinItemId,
sortOrder: m.sortOrder
}))
};
setStatus('Saving…', '');
try {
const res = await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || 'Save failed');
}
currentLesson = await res.json();
setStatus('Saved!', 'ok');
loadLessons();
showEditor();
} catch (e) {
setStatus('Save error: ' + e.message, 'err');
}
}
// --- Delete ---
async function deleteLesson() {
if (!currentLesson || !currentLesson.id) return;
if (!confirm(`Delete "${currentLesson.title}"? This cannot be undone.`)) return;
try {
const res = await fetch(`${API}/${currentLesson.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
currentLesson = null;
setStatus('');
loadLessons();
document.getElementById('editor').style.display = 'none';
document.getElementById('no-selection').style.display = 'block';
} catch (e) {
setStatus('Delete error: ' + e.message, 'err');
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = cls ? 'status-' + cls : '';
}
// Init
loadLessons();
</script>
</body>
</html>

175
admin-ui/server.js Normal file
View file

@ -0,0 +1,175 @@
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}`);
});

View file

@ -68,5 +68,50 @@
) { };
}
);
nixosModules.default =
{ config, lib, pkgs, ... }:
let
cfg = config.services.jellyfin-lessons;
in
{
options.services.jellyfin-lessons = {
enable = lib.mkEnableOption "Jellyfin Lessons plugin";
dataDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/jellyfin-lessons";
description = "Directory for lessons database";
};
adminPort = lib.mkOption {
type = lib.types.port;
default = 3099;
description = "Port for admin UI";
};
};
config = lib.mkIf cfg.enable {
# Copy plugin DLL to Jellyfin plugins directory on jellyfin preStart
systemd.services.jellyfin = {
preStart = let
pluginPkg = self.packages.${pkgs.system}.default;
in ''
mkdir -p /var/lib/jellyfin/plugins/Lessons
cp -f ${pluginPkg}/lib/jellyfin-lessons/*.dll /var/lib/jellyfin/plugins/Lessons/
'';
};
# Admin UI as a user systemd service
systemd.user.services.jellyfin-lessons-admin = {
description = "Jellyfin Lessons Admin UI";
after = [ "network.target" ];
wantedBy = [ "default.target" ];
serviceConfig = {
ExecStart = "${pkgs.nodejs}/bin/node ${self}/admin-ui/server.js";
Restart = "on-failure";
Environment = "PORT=${toString cfg.adminPort}";
};
};
};
};
};
}

View file

@ -0,0 +1,65 @@
using Microsoft.AspNetCore.Mvc;
using Jellyfin.Plugin.Lessons.Data;
namespace Jellyfin.Plugin.Lessons.Api;
[ApiController]
[Route("Plugins/Lessons")]
public class LessonsController : ControllerBase
{
private readonly LessonStore _store;
public LessonsController(LessonStore store)
{
_store = store;
}
[HttpGet("List")]
public IActionResult List()
{
var lessons = _store.GetAllLessons();
var summaries = lessons.Select(l => new
{
id = l.Id,
title = l.Title,
grade = l.Grade,
subject = l.Subject,
lessonDate = l.LessonDate,
description = l.Description
});
return Ok(summaries);
}
[HttpGet("Detail")]
public IActionResult Detail([FromQuery] string id)
{
if (string.IsNullOrEmpty(id))
return BadRequest("Lesson id is required.");
var lesson = _store.GetLessonById(id);
if (lesson == null)
return NotFound();
return Ok(new
{
id = lesson.Id,
title = lesson.Title,
grade = lesson.Grade,
subject = lesson.Subject,
lessonDate = lesson.LessonDate,
description = lesson.Description,
sourcePath = lesson.SourcePath,
compiledHtml = lesson.CompiledHtml,
createdAt = lesson.CreatedAt,
updatedAt = lesson.UpdatedAt,
mediaItems = lesson.MediaItems.Select(m => new
{
lessonId = m.LessonId,
jellyfinItemId = m.JellyfinItemId,
sortOrder = m.SortOrder
})
});
}
}

View file

@ -0,0 +1,460 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lessons</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #101010;
color: #e0e0e0;
margin: 0;
padding: 0;
}
.page {
padding: 2em;
max-width: 960px;
margin: 0 auto;
}
h1 {
font-size: 1.8em;
font-weight: 600;
margin-bottom: 1em;
color: #fff;
}
h2 {
font-size: 1.3em;
font-weight: 600;
color: #fff;
margin: 0.5em 0;
}
/* Back button */
.back-btn {
display: inline-block;
background: #2a2a2a;
color: #00a4dc;
border: 1px solid #444;
border-radius: 4px;
padding: 0.4em 0.8em;
cursor: pointer;
font-size: 0.9em;
margin-bottom: 1em;
transition: background 0.2s, border-color 0.2s;
}
.back-btn:hover {
background: #333;
border-color: #00a4dc;
}
/* List view */
.grade-group {
margin-bottom: 2em;
}
.grade-header {
font-size: 1.2em;
font-weight: 600;
color: #00a4dc;
margin-bottom: 0.5em;
padding-bottom: 0.3em;
border-bottom: 1px solid #333;
}
.lesson-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 0.75em 1em;
margin-bottom: 0.5em;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.lesson-card:hover {
background: #222;
border-color: #00a4dc;
}
.lesson-title {
font-weight: 600;
color: #fff;
font-size: 1.05em;
}
.lesson-meta {
font-size: 0.85em;
color: #999;
margin-top: 0.25em;
}
/* Detail view */
.detail-meta {
color: #aaa;
font-size: 0.9em;
margin-bottom: 1.5em;
}
.detail-meta span {
margin-right: 1.5em;
}
.detail-body {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 1.5em;
margin-bottom: 2em;
line-height: 1.6;
overflow-wrap: break-word;
}
.detail-body h1, .detail-body h2, .detail-body h3 {
color: #fff;
margin-top: 1.2em;
margin-bottom: 0.5em;
}
.detail-body h1 { font-size: 1.5em; }
.detail-body h2 { font-size: 1.3em; }
.detail-body h3 { font-size: 1.1em; }
.detail-body p { margin: 0.5em 0; }
.detail-body ul, .detail-body ol { padding-left: 1.5em; }
.detail-body img { max-width: 100%; border-radius: 4px; }
.detail-body table { border-collapse: collapse; width: 100%; }
.detail-body td, .detail-body th { border: 1px solid #444; padding: 0.4em 0.6em; }
.detail-body a { color: #00a4dc; }
/* Media section */
.media-section {
margin-bottom: 2em;
}
.media-section h2 {
margin-bottom: 0.75em;
}
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75em;
}
.media-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.media-card:hover {
background: #222;
border-color: #00a4dc;
}
.media-thumb {
width: 100%;
height: 112px;
object-fit: cover;
background: #252525;
display: block;
}
.media-thumb-placeholder {
width: 100%;
height: 112px;
background: #252525;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
color: #555;
}
.media-info {
padding: 0.5em 0.6em;
}
.media-name {
font-weight: 600;
font-size: 0.9em;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.media-duration {
font-size: 0.8em;
color: #888;
margin-top: 0.15em;
}
.media-loading {
color: #666;
font-size: 0.85em;
padding: 0.5em 0;
}
/* States */
.empty-state, .error-state, .loading {
text-align: center;
padding: 3em;
}
.empty-state { color: #666; }
.error-state { color: #c44; }
.loading { color: #666; }
</style>
</head>
<body>
<div class="page">
<h1 id="page-title">📚 Lessons</h1>
<div id="content" class="loading">Loading lessons&hellip;</div>
</div>
<script>
(function() {
var container = document.getElementById('content');
var pageTitle = document.getElementById('page-title');
var currentView = 'list'; // 'list' | 'detail'
function apiBase() {
return window.location.origin + '/Plugins/Lessons';
}
function esc(str) {
if (!str) return '';
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function formatDuration(ticks) {
if (!ticks && ticks !== 0) return '';
var totalSeconds = Math.floor(ticks / 10000000);
var hours = Math.floor(totalSeconds / 3600);
var minutes = Math.floor((totalSeconds % 3600) / 60);
var seconds = totalSeconds % 60;
if (hours > 0) {
return hours + ':' + pad(minutes) + ':' + pad(seconds);
}
return minutes + ':' + pad(seconds);
}
function pad(n) {
return n < 10 ? '0' + n : '' + n;
}
// --- List view ---
function renderList(lessons) {
currentView = 'list';
pageTitle.textContent = '📚 Lessons';
if (!lessons || lessons.length === 0) {
container.className = 'empty-state';
container.innerHTML = '<p>No lessons found. Lessons will appear here after you build them.</p>';
return;
}
// Group by grade
var gradeMap = {};
lessons.forEach(function(l) {
var grade = l.grade || 'Unknown';
if (!gradeMap[grade]) gradeMap[grade] = [];
gradeMap[grade].push(l);
});
var html = '';
var grades = Object.keys(gradeMap).sort(function(a, b) { return a - b; });
grades.forEach(function(grade) {
html += '<div class="grade-group">';
html += '<div class="grade-header">Grade ' + grade + '</div>';
gradeMap[grade].forEach(function(l) {
html += '<div class="lesson-card" data-lesson-id="' + esc(l.id) + '" onclick="viewLesson(\'' + esc(l.id) + '\')">';
html += '<div class="lesson-title">' + esc(l.title) + '</div>';
html += '<div class="lesson-meta">';
html += esc(l.subject || '');
if (l.lessonDate) html += ' &middot; ' + esc(l.lessonDate);
html += '</div>';
if (l.description) {
html += '<div class="lesson-meta">' + esc(l.description) + '</div>';
}
html += '</div>';
});
html += '</div>';
});
container.innerHTML = html;
}
// --- Detail view ---
function renderDetail(lesson) {
currentView = 'detail';
pageTitle.textContent = lesson.title;
var html = '';
// Back button
html += '<button class="back-btn" onclick="showList()">← Back to Lessons</button>';
// Metadata
html += '<div class="detail-meta">';
html += '<span>Grade ' + esc(String(lesson.grade)) + '</span>';
html += '<span>' + esc(lesson.subject) + '</span>';
if (lesson.lessonDate) html += '<span>' + esc(lesson.lessonDate) + '</span>';
html += '</div>';
if (lesson.description) {
html += '<div class="detail-meta">' + esc(lesson.description) + '</div>';
}
// Compiled HTML body
if (lesson.compiledHtml) {
html += '<div class="detail-body">' + lesson.compiledHtml + '</div>';
} else {
html += '<div class="detail-body" style="color:#666;">No lesson content available.</div>';
}
// Media section
if (lesson.mediaItems && lesson.mediaItems.length > 0) {
html += '<div class="media-section">';
html += '<h2>📺 Media</h2>';
html += '<div class="media-grid" id="media-grid">';
html += '<div class="media-loading">Loading media details&hellip;</div>';
html += '</div>';
html += '</div>';
container.innerHTML = html;
// Fetch media details asynchronously
loadMediaItems(lesson.mediaItems);
} else {
container.innerHTML = html;
}
}
function loadMediaItems(mediaItems) {
var grid = document.getElementById('media-grid');
if (!grid) return;
var userId = null;
try {
userId = Dashboard.getCurrentUserId();
} catch (e) {
// Dashboard may not be available in all contexts
}
if (!userId) {
grid.innerHTML = '<div class="media-loading">Unable to identify user for media playback.</div>';
return;
}
var promises = mediaItems.map(function(m) {
return fetch('/Users/' + userId + '/Items/' + m.jellyfinItemId)
.then(function(r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(function(item) {
return renderMediaCard(item);
})
.catch(function(err) {
return '<div class="media-card" style="cursor:default;opacity:0.6;">' +
'<div class="media-thumb-placeholder">⚠️</div>' +
'<div class="media-info">' +
'<div class="media-name">' + esc(m.jellyfinItemId) + '</div>' +
'<div class="media-duration">Unavailable</div>' +
'</div></div>';
});
});
Promise.all(promises).then(function(cards) {
grid.innerHTML = cards.join('');
});
}
function renderMediaCard(item) {
var imageTag = '';
if (item.ImageTags && item.ImageTags.Primary) {
var imgUrl = window.ApiClient.getScaledImageUrl(item.Id, { type: 'Primary', tag: item.ImageTags.Primary });
imageTag = '<img class="media-thumb" src="' + imgUrl + '" alt="' + esc(item.Name) + '">';
} else if (item.BackdropImageTags && item.BackdropImageTags.length > 0) {
var backdropUrl = window.ApiClient.getScaledImageUrl(item.Id, { type: 'Backdrop', tag: item.BackdropImageTags[0] });
imageTag = '<img class="media-thumb" src="' + backdropUrl + '" alt="' + esc(item.Name) + '">';
} else {
imageTag = '<div class="media-thumb-placeholder">▶️</div>';
}
var durationStr = '';
if (item.RunTimeTicks) {
durationStr = formatDuration(item.RunTimeTicks);
}
// Use JSON.stringify to safely embed the item for the click handler
var itemJson = JSON.stringify(item).replace(/'/g, "\\'").replace(/"/g, '&quot;');
return '<div class="media-card" onclick="playMedia(\'' + item.Id + '\')">' +
imageTag +
'<div class="media-info">' +
'<div class="media-name">' + esc(item.Name) + '</div>' +
(durationStr ? '<div class="media-duration">' + esc(durationStr) + '</div>' : '') +
'</div></div>';
}
// --- Navigation & playback (exposed globally for onclick handlers) ---
window.viewLesson = function(id) {
container.className = 'loading';
container.innerHTML = 'Loading lesson&hellip;';
fetch(apiBase() + '/Detail?id=' + encodeURIComponent(id))
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(function(lesson) {
renderDetail(lesson);
})
.catch(function(err) {
container.className = 'error-state';
container.innerHTML = '<p>Failed to load lesson: ' + esc(err.message) + '</p>' +
'<button class="back-btn" onclick="showList()">← Back to Lessons</button>';
});
};
window.showList = function() {
container.className = 'loading';
container.innerHTML = 'Loading lessons&hellip;';
fetch(apiBase() + '/List')
.then(function(resp) {
if (!resp.ok) throw new Error('HTTP ' + resp.status);
return resp.json();
})
.then(function(data) {
renderList(data);
})
.catch(function(err) {
container.className = 'error-state';
container.innerHTML = '<p>Failed to load lessons: ' + esc(err.message) + '</p>';
});
};
window.playMedia = function(itemId) {
// Use Jellyfin's appRouter to navigate to the item detail/player
try {
if (window.appRouter && typeof window.appRouter.showItem === 'function') {
window.appRouter.showItem(itemId);
} else if (window.ApiClient && typeof window.ApiClient.getItem === 'function') {
var userId = Dashboard.getCurrentUserId();
window.ApiClient.getItem(userId, itemId).then(function(item) {
if (window.appRouter) {
window.appRouter.showItem(item);
} else if (window.Dashboard && window.Dashboard.navigate) {
Dashboard.navigate('itemdetails.html?id=' + itemId);
} else {
window.location.href = '#!/details?id=' + itemId;
}
});
} else {
// Fallback: navigate to item details page directly
window.location.href = '#!/details?id=' + itemId;
}
} catch (e) {
// Last resort fallback
window.location.href = '#!/details?id=' + itemId;
}
};
// --- Initial load ---
showList();
})();
</script>
</body>
</html>

223
plugin/Data/LessonStore.cs Normal file
View file

@ -0,0 +1,223 @@
using Microsoft.Data.Sqlite;
using Jellyfin.Plugin.Lessons.Data.Models;
namespace Jellyfin.Plugin.Lessons.Data;
public class LessonStore
{
private readonly string _connectionString;
public LessonStore(string dbPath)
{
if (dbPath.StartsWith("~"))
dbPath = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + dbPath[1..];
var dir = Path.GetDirectoryName(dbPath);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
Directory.CreateDirectory(dir);
_connectionString = $"Data Source={dbPath}";
CreateTables();
}
private void CreateTables()
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
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
);";
cmd.ExecuteNonQuery();
}
public List<Lesson> GetAllLessons()
{
var lessons = new List<Lesson>();
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM lessons ORDER BY lesson_date DESC, subject, title";
using var reader = cmd.ExecuteReader();
while (reader.Read())
lessons.Add(ReadLesson(reader));
return lessons;
}
public Lesson? GetLessonById(string id)
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM lessons WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
using var reader = cmd.ExecuteReader();
if (!reader.Read())
return null;
var lesson = ReadLesson(reader);
lesson.MediaItems = GetMediaForLesson(lesson.Id);
return lesson;
}
public List<Lesson> GetLessonsByGrade(int grade)
{
var lessons = new List<Lesson>();
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM lessons WHERE grade = @grade ORDER BY lesson_date DESC, subject, title";
cmd.Parameters.AddWithValue("@grade", grade);
using var reader = cmd.ExecuteReader();
while (reader.Read())
lessons.Add(ReadLesson(reader));
return lessons;
}
public void UpsertLesson(Lesson lesson, List<LessonMedia> mediaItems)
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var tx = conn.BeginTransaction();
var now = DateTime.UtcNow.ToString("o");
if (string.IsNullOrEmpty(lesson.Id))
lesson.Id = Guid.NewGuid().ToString();
lesson.CreatedAt = string.IsNullOrEmpty(lesson.CreatedAt) ? now : lesson.CreatedAt;
lesson.UpdatedAt = now;
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = @"
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";
cmd.Parameters.AddWithValue("@id", lesson.Id);
cmd.Parameters.AddWithValue("@title", lesson.Title);
cmd.Parameters.AddWithValue("@grade", lesson.Grade);
cmd.Parameters.AddWithValue("@subject", lesson.Subject);
cmd.Parameters.AddWithValue("@lesson_date", lesson.LessonDate);
cmd.Parameters.AddWithValue("@description", (object?)lesson.Description ?? DBNull.Value);
cmd.Parameters.AddWithValue("@source_path", lesson.SourcePath);
cmd.Parameters.AddWithValue("@compiled_html", (object?)lesson.CompiledHtml ?? DBNull.Value);
cmd.Parameters.AddWithValue("@created_at", lesson.CreatedAt);
cmd.Parameters.AddWithValue("@updated_at", lesson.UpdatedAt);
cmd.ExecuteNonQuery();
}
// Replace media items
using (var delCmd = conn.CreateCommand())
{
delCmd.CommandText = "DELETE FROM lesson_media WHERE lesson_id = @lesson_id";
delCmd.Parameters.AddWithValue("@lesson_id", lesson.Id);
delCmd.ExecuteNonQuery();
}
foreach (var m in mediaItems)
{
using var insCmd = conn.CreateCommand();
insCmd.CommandText = @"
INSERT INTO lesson_media (lesson_id, jellyfin_item_id, sort_order)
VALUES (@lesson_id, @jellyfin_item_id, @sort_order)";
insCmd.Parameters.AddWithValue("@lesson_id", lesson.Id);
insCmd.Parameters.AddWithValue("@jellyfin_item_id", m.JellyfinItemId);
insCmd.Parameters.AddWithValue("@sort_order", m.SortOrder);
insCmd.ExecuteNonQuery();
}
tx.Commit();
}
public void DeleteLesson(string id)
{
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM lessons WHERE id = @id";
cmd.Parameters.AddWithValue("@id", id);
cmd.ExecuteNonQuery();
}
private List<LessonMedia> GetMediaForLesson(string lessonId)
{
var items = new List<LessonMedia>();
using var conn = new SqliteConnection(_connectionString);
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT * FROM lesson_media WHERE lesson_id = @id ORDER BY sort_order";
cmd.Parameters.AddWithValue("@id", lessonId);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
items.Add(new LessonMedia
{
LessonId = reader.GetString(0),
JellyfinItemId = reader.GetString(1),
SortOrder = reader.GetInt32(2)
});
}
return items;
}
private static Lesson ReadLesson(SqliteDataReader reader)
{
return new Lesson
{
Id = reader.GetString(0),
Title = reader.GetString(1),
Grade = reader.GetInt32(2),
Subject = reader.GetString(3),
LessonDate = reader.GetString(4),
Description = reader.IsDBNull(5) ? null : reader.GetString(5),
SourcePath = reader.GetString(6),
CompiledHtml = reader.IsDBNull(7) ? null : reader.GetString(7),
CreatedAt = reader.GetString(8),
UpdatedAt = reader.GetString(9)
};
}
}

View file

@ -0,0 +1,17 @@
namespace Jellyfin.Plugin.Lessons.Data.Models;
public class Lesson
{
public string Id { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public int Grade { get; set; }
public string Subject { get; set; } = string.Empty;
public string LessonDate { get; set; } = string.Empty;
public string? Description { get; set; }
public string SourcePath { get; set; } = string.Empty;
public string? CompiledHtml { get; set; }
public string CreatedAt { get; set; } = string.Empty;
public string UpdatedAt { get; set; } = string.Empty;
public List<LessonMedia> MediaItems { get; set; } = new();
}

View file

@ -0,0 +1,8 @@
namespace Jellyfin.Plugin.Lessons.Data.Models;
public class LessonMedia
{
public string LessonId { get; set; } = string.Empty;
public string JellyfinItemId { get; set; } = string.Empty;
public int SortOrder { get; set; }
}

View file

@ -3,6 +3,8 @@
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>JellyfinLessons</RootNamespace>
<AssemblyName>jellyfin-lessons</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
@ -12,5 +14,16 @@
<PackageReference Include="Jellyfin.Model" Version="10.10.*">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.*">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration/lessonsPage.html" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View file

@ -1,12 +1,14 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Microsoft.Extensions.Logging;
using Jellyfin.Plugin.Lessons.Configuration;
namespace Jellyfin.Plugin.Lessons;
public class Plugin : BasePlugin<PluginConfiguration>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public Plugin(IApplicationPaths appPaths, IXmlSerializer xmlSerializer)
: base(appPaths, xmlSerializer)
@ -18,4 +20,20 @@ public class Plugin : BasePlugin<PluginConfiguration>
public override Guid Id => Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
public override string Description => "Homeschool lesson plans with embedded media";
public IEnumerable<PluginPageInfo> GetPages()
{
return new[]
{
new PluginPageInfo
{
Name = Name,
DisplayName = "Lessons",
EmbeddedResourcePath = GetType().Namespace + ".Configuration.lessonsPage.html",
EnableInMainMenu = true,
MenuSection = "plugins",
MenuIcon = "book"
}
};
}
}

View file

@ -1,11 +1,15 @@
using MediaBrowser.Common.Plugins;
using Jellyfin.Plugin.Lessons.Data;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Lessons;
public class ServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services)
public void RegisterServices(IServiceCollection services, IServerApplicationHost appHost)
{
services.AddSingleton<LessonStore>(sp =>
new LessonStore("~/.local/share/jellyfin-lessons/lessons.db"));
}
}

View file

@ -0,0 +1,83 @@
// example-lesson.typ — Complete example demonstrating the lesson template
// ============================================================
// Frontmatter:
// title: God's Creation: The Water Cycle
// grade: 3
// subject: Science
// date: 2026-01-15
// media: jellyfin://uuid-water-cycle, jellyfin://uuid-cloud-formation
// description: Learn how God designed water to move through His creation —
// evaporation, condensation, and precipitation working together to sustain life.
#import "lesson.typ": lesson-template, media-ref
#show: doc => lesson-template(
doc,
title: "God's Creation: The Water Cycle",
grade: "3",
subject: "Science",
date: "2026-01-15",
media: (
("uuid-water-cycle", "The Water Cycle Explained"),
("uuid-cloud-formation", "Cloud Formation and Weather"),
),
description: "Learn how God designed water to move through His creation — evaporation, condensation, and precipitation working together to sustain life on Earth.",
)
= What Is the Water Cycle?
Water is one of God's most amazing creations! The water cycle is how water moves
all around our planet. It never runs out — it just keeps going around and around.
There are four main steps: evaporation, condensation, precipitation, and collection.
== Evaporation
When the sun shines on oceans, lakes, and rivers, the water gets warm and turns
into a gas called *water vapor*. This gas rises up into the sky. You can't see it,
but it's there! Evaporation happens every day, especially on warm, sunny days.
== Condensation
When the water vapor goes high up where it's cold, it turns back into tiny water
droplets. These droplets come together to make clouds. Have you ever looked at
clouds and seen shapes? Those shapes are made of millions of tiny water droplets
sticking together!
== Precipitation
When the clouds get too heavy with water droplets, the water falls back down to
Earth. This is called *precipitation*. It can be:
- Rain — liquid water falling from the sky
- Snow — frozen crystals that fall in cold weather
- Sleet — tiny ice pellets that bounce when they land
- Hail — hard balls of ice that form in big storms
God sends precipitation to water the plants and fill the rivers, lakes, and oceans.
== Collection
The water that falls onto the ground collects in rivers, lakes, and oceans. Some
of it soaks into the ground and becomes *groundwater* — water stored underground
that plants can reach with their roots. Then the sun warms the water, and the
whole cycle starts all over again!
= Why Is the Water Cycle Important?
God designed the water cycle to do important work:
- Give plants the water they need to grow
- Provide fresh water for animals and people to drink
- Keep our rivers and lakes full of water
- Help control the temperature of our whole planet
- Clean the water naturally as it moves through the cycle
*"He causes the vapors to ascend from the ends of the earth; He makes lightning
for the rain; He brings the wind out of His treasuries."* — Psalm 135:7
= Think About It
1. What happens to water when the sun shines on a lake?
2. Why do clouds form high up in the sky?
3. What are the four types of precipitation?
4. How does the water cycle show God's care for His creation?

90
templates/lesson.typ Normal file
View file

@ -0,0 +1,90 @@
// lesson.typ — Typst template for homeschool lessons
// ============================================================
// This template produces clean, structured lesson documents.
// Designed to compile to HTML for rendering inside Jellyfin.
//
// Frontmatter convention (documented as comments at top of each lesson):
// // title: The Lesson Title
// // grade: 3
// // subject: Science
// // date: 2026-01-15
// // media: jellyfin://uuid-1, jellyfin://uuid-2
// // description: Short description of the lesson
//
// Usage:
// #import "lesson.typ": lesson-template, media-ref
// #show: doc => lesson-template(
// doc,
// title: "The Water Cycle",
// grade: "3",
// subject: "Science",
// date: "2026-01-15",
// media: (),
// description: "Learn about evaporation and condensation.",
// )
// Render a single Jellyfin media reference.
// Produces a <code data-lang="jellyfin-media"> element in HTML output
// that the Jellyfin plugin can parse and link to the player.
#let media-ref(id, title) = {
raw(lang: "jellyfin-media", id + "|" + title, block: false)
}
// Render a list of media references as a formatted section.
#let media-section(media-items) = {
if media-items.len() > 0 {
heading(level: 2)[Media]
for item in media-items {
list.item(media-ref(item.at(0), item.at(1)))
}
}
}
// The main lesson template.
// - body: The lesson content (passed via show rule)
// - title: Lesson title (string)
// - grade: Grade level (string, e.g. "3")
// - subject: Subject name (string)
// - date: Lesson date in YYYY-MM-DD format
// - media: Array of (id, title) pairs for linked Jellyfin media
// - description: Short description of the lesson
#let lesson-template(
body,
title: "Untitled Lesson",
grade: none,
subject: none,
date: none,
media: (),
description: none,
) = {
// Document metadata for PDF output
set document(title: title)
// Title — prominent heading
heading(level: 1, title)
// Metadata line: Grade X — Subject — Date
let meta-parts = ()
if grade != none { meta-parts.push([Grade ] + grade) }
if subject != none { meta-parts.push(subject) }
if date != none { meta-parts.push(date) }
if meta-parts.len() > 0 {
let sep = [ — ]
let meta-text = meta-parts.first()
for i in range(1, meta-parts.len()) {
meta-text += sep + meta-parts.at(i)
}
par(text(style: "italic", size: 0.9em, meta-text))
}
// Description paragraph
if description != none {
par([*Description:* ] + description)
}
// Lesson body content (renders user-authored sections, paragraphs, lists, etc.)
body
// Media references section — listed with identifiable jellyfin-media codes
media-section(media)
}