Compare commits

..

No commits in common. "main" and "issue-7-admin-ui" have entirely different histories.

6 changed files with 4 additions and 606 deletions

View file

@ -68,50 +68,5 @@
) { };
}
);
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,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

@ -3,8 +3,6 @@
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>JellyfinLessons</RootNamespace>
<AssemblyName>jellyfin-lessons</AssemblyName>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
@ -18,12 +16,4 @@
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration/lessonsPage.html" />
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>

View file

@ -1,14 +1,12 @@
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>, IHasWebPages
public class Plugin : BasePlugin<PluginConfiguration>
{
public Plugin(IApplicationPaths appPaths, IXmlSerializer xmlSerializer)
: base(appPaths, xmlSerializer)
@ -20,20 +18,4 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
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 +1,11 @@
using Jellyfin.Plugin.Lessons.Data;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Common.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Lessons;
public class ServiceRegistrator : IPluginServiceRegistrator
{
public void RegisterServices(IServiceCollection services, IServerApplicationHost appHost)
public void RegisterServices(IServiceCollection services)
{
services.AddSingleton<LessonStore>(sp =>
new LessonStore("~/.local/share/jellyfin-lessons/lessons.db"));
}
}