Compare commits

..

No commits in common. "main" and "issue-3-typst-template" have entirely different histories.

18 changed files with 0 additions and 3022 deletions

14
.gitignore vendored
View file

@ -1,14 +0,0 @@
# .NET build artifacts
bin/
obj/
*.dll
# Nix
.result/
result
# IDE
.vs/
.vscode/
*.user
*.suo

2
admin-ui/.gitignore vendored
View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,13 +0,0 @@
{
"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"
}
}

View file

@ -1,408 +0,0 @@
<!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>

View file

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

152
deps.json
View file

@ -1,152 +0,0 @@
[
{
"pname": "Diacritics",
"version": "3.3.29",
"hash": "sha256-sIbdJ3yMthnmJHly3WheUdYjtwPakcczTJx9ycxtgrY="
},
{
"pname": "ICU4N",
"version": "60.1.0-alpha.356",
"hash": "sha256-1QyOgO7pNMeoEgBtl6o8IG4o91wD2hFUgRI0jM0ltxY="
},
{
"pname": "ICU4N.Transliterator",
"version": "60.1.0-alpha.356",
"hash": "sha256-RLNwQNVqNz8Omfb/mC/rzQWVq8c7uCnNdG2qi4xJmds="
},
{
"pname": "J2N",
"version": "2.0.0",
"hash": "sha256-YvtIWErlm2O49hg3lIRm5Ha8/owkQkfMudzuldC3EhA="
},
{
"pname": "Jellyfin.Common",
"version": "10.10.7",
"hash": "sha256-9EIigrDheob4vRP+UBAoIPHH4fyz6Cl27GUpelEGpBg="
},
{
"pname": "Jellyfin.Controller",
"version": "10.10.7",
"hash": "sha256-/obWAuxWpSn+NlMES+fjyrf1g+qbxmSYQUBvgXQYltQ="
},
{
"pname": "Jellyfin.Data",
"version": "10.10.7",
"hash": "sha256-lRQjg/HkFAtCN0woL1gX6j5dMfVLP/fzQTKjFTcRth4="
},
{
"pname": "Jellyfin.Extensions",
"version": "10.10.7",
"hash": "sha256-AOGJ2IoT2v+LnlEqnb2O4FnXapcQiH0V9ny8GUysNHg="
},
{
"pname": "Jellyfin.Model",
"version": "10.10.7",
"hash": "sha256-ubsClGTLq/aFnMiBCaHVUX2b88beuL07Yn5VXNbTCjQ="
},
{
"pname": "Jellyfin.Naming",
"version": "10.10.7",
"hash": "sha256-/jlIS0X4FuBo6Ac1cjLGzgdJ3vR6VotBspxpNrqfMzo="
},
{
"pname": "Microsoft.Extensions.Caching.Abstractions",
"version": "2.0.0",
"hash": "sha256-Eg1MES40kzkGW9tZmjaKtbWI00Kbv7fLJQmjrigjxqk="
},
{
"pname": "Microsoft.Extensions.Caching.Memory",
"version": "2.0.0",
"hash": "sha256-1fnNvp62KrviVwYlqVl1CbdaZVpCDah9eCZeNDGDbWM="
},
{
"pname": "Microsoft.Extensions.Configuration.Abstractions",
"version": "8.0.0",
"hash": "sha256-4eBpDkf7MJozTZnOwQvwcfgRKQGcNXe0K/kF+h5Rl8o="
},
{
"pname": "Microsoft.Extensions.Configuration.Binder",
"version": "8.0.2",
"hash": "sha256-aGB0VuoC34YadAEqrwoaXLc5qla55pswDV2xLSmR7SE="
},
{
"pname": "Microsoft.Extensions.DependencyInjection",
"version": "8.0.1",
"hash": "sha256-O9g0jWS+jfGoT3yqKwZYJGL+jGSIeSbwmvomKDC3hTU="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "2.0.0",
"hash": "sha256-H1rEnq/veRWvmp8qmUsrQkQIcVlKilUNzmmKsxJ0md8="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "8.0.0",
"hash": "sha256-75KzEGWjbRELczJpCiJub+ltNUMMbz5A/1KQU+5dgP8="
},
{
"pname": "Microsoft.Extensions.DependencyInjection.Abstractions",
"version": "8.0.2",
"hash": "sha256-UfLfEQAkXxDaVPC7foE/J3FVEXd31Pu6uQIhTic3JgY="
},
{
"pname": "Microsoft.Extensions.Logging",
"version": "8.0.1",
"hash": "sha256-vkfVw4tQEg86Xg18v6QO0Qb4Ysz0Njx57d1XcNuj6IU="
},
{
"pname": "Microsoft.Extensions.Logging.Abstractions",
"version": "8.0.2",
"hash": "sha256-cHpe8X2BgYa5DzulZfq24rg8O2K5Lmq2OiLhoyAVgJc="
},
{
"pname": "Microsoft.Extensions.Options",
"version": "2.0.0",
"hash": "sha256-EMvaXxGzueI8lT97bYJQr0kAj1IK0pjnAcWN82hTnzw="
},
{
"pname": "Microsoft.Extensions.Options",
"version": "8.0.2",
"hash": "sha256-AjcldddddtN/9aH9pg7ClEZycWtFHLi9IPe1GGhNQys="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "2.0.0",
"hash": "sha256-q44LtMvyNEKSvgERvA+BrasKapP92Sc91QR4u2TJ9/Y="
},
{
"pname": "Microsoft.Extensions.Primitives",
"version": "8.0.0",
"hash": "sha256-FU8qj3DR8bDdc1c+WeGZx/PCZeqqndweZM9epcpXjSo="
},
{
"pname": "Microsoft.NETCore.Platforms",
"version": "1.1.0",
"hash": "sha256-FeM40ktcObQJk4nMYShB61H/E8B7tIKfl9ObJ0IOcCM="
},
{
"pname": "Microsoft.NETCore.Targets",
"version": "1.1.0",
"hash": "sha256-0AqQ2gMS8iNlYkrD+BxtIg7cXMnr9xZHtKAuN4bjfaQ="
},
{
"pname": "System.Globalization",
"version": "4.3.0",
"hash": "sha256-caL0pRmFSEsaoeZeWN5BTQtGrAtaQPwFi8YOZPZG5rI="
},
{
"pname": "System.Runtime",
"version": "4.3.0",
"hash": "sha256-51813WXpBIsuA6fUtE5XaRQjcWdQ2/lmEokJt97u0Rg="
},
{
"pname": "System.Text.Json",
"version": "8.0.5",
"hash": "sha256-yKxo54w5odWT6nPruUVsaX53oPRe+gKzGvLnnxtwP68="
},
{
"pname": "System.Threading.Tasks.Dataflow",
"version": "8.0.1",
"hash": "sha256-hgCfF91BDd/eOtLEd5jhjzgJdvwmVv4/b42fXRr3nvo="
}
]

27
flake.lock generated
View file

@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
"narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

117
flake.nix
View file

@ -1,117 +0,0 @@
{
description = "Jellyfin Lessons — a Jellyfin plugin";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
forAllSystems =
fn: nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed fn;
in
{
devShells = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.mkShell {
packages = [
pkgs.dotnetCorePackages.sdk_9_0
pkgs.typst
pkgs.nodejs
];
shellHook = ''
echo "jellyfin-lessons dev shell"
echo " dotnet: $(dotnet --version)"
echo " typst: $(typst --version)"
echo " node: $(node --version)"
'';
};
}
);
packages = forAllSystems (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.callPackage (
{
buildDotnetModule,
dotnetCorePackages,
lib,
}:
buildDotnetModule {
pname = "jellyfin-lessons";
version = "0.1.0";
src = self;
projectFile = "plugin/Jellyfin.Plugin.Lessons.csproj";
nugetDeps = ./deps.json;
dotnet-sdk = dotnetCorePackages.sdk_9_0;
dotnet-runtime = dotnetCorePackages.aspnetcore_9_0;
meta = {
description = "Jellyfin Lessons plugin";
license = lib.licenses.mit;
platforms = lib.platforms.all;
};
}
) { };
}
);
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

@ -1,65 +0,0 @@
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

@ -1,7 +0,0 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Lessons.Configuration;
public class PluginConfiguration : BasePluginConfiguration
{
}

View file

@ -1,460 +0,0 @@
<!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>

View file

@ -1,223 +0,0 @@
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

@ -1,17 +0,0 @@
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

@ -1,8 +0,0 @@
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

@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>JellyfinLessons</RootNamespace>
<AssemblyName>jellyfin-lessons</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.10.*">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<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,39 +0,0 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using Jellyfin.Plugin.Lessons.Configuration;
namespace Jellyfin.Plugin.Lessons;
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
public Plugin(IApplicationPaths appPaths, IXmlSerializer xmlSerializer)
: base(appPaths, xmlSerializer)
{
}
public override string Name => "Lessons";
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,15 +0,0 @@
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, IServerApplicationHost appHost)
{
services.AddSingleton<LessonStore>(sp =>
new LessonStore("~/.local/share/jellyfin-lessons/lessons.db"));
}
}