From 2df642cf5263887decd36962d534134989e31e26 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Fri, 15 May 2026 21:48:43 -0700 Subject: [PATCH 1/3] 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 --- plugin/Api/LessonsController.cs | 34 ++++++ plugin/Configuration/lessonsPage.html | 152 ++++++++++++++++++++++++++ plugin/Jellyfin.Plugin.Lessons.csproj | 10 ++ plugin/Plugin.cs | 22 +++- plugin/ServiceRegistrator.cs | 8 +- 5 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 plugin/Api/LessonsController.cs create mode 100644 plugin/Configuration/lessonsPage.html diff --git a/plugin/Api/LessonsController.cs b/plugin/Api/LessonsController.cs new file mode 100644 index 0000000..aad1b18 --- /dev/null +++ b/plugin/Api/LessonsController.cs @@ -0,0 +1,34 @@ +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); + } +} diff --git a/plugin/Configuration/lessonsPage.html b/plugin/Configuration/lessonsPage.html new file mode 100644 index 0000000..30034e4 --- /dev/null +++ b/plugin/Configuration/lessonsPage.html @@ -0,0 +1,152 @@ + + + + + + Lessons + + + +
+

📚 Lessons

+
Loading lessons…
+
+ + + + diff --git a/plugin/Jellyfin.Plugin.Lessons.csproj b/plugin/Jellyfin.Plugin.Lessons.csproj index faba00e..5682323 100644 --- a/plugin/Jellyfin.Plugin.Lessons.csproj +++ b/plugin/Jellyfin.Plugin.Lessons.csproj @@ -3,6 +3,8 @@ net9.0 JellyfinLessons jellyfin-lessons + enable + enable @@ -16,4 +18,12 @@ runtime + + + + + + + + diff --git a/plugin/Plugin.cs b/plugin/Plugin.cs index 59f6f42..52c4269 100644 --- a/plugin/Plugin.cs +++ b/plugin/Plugin.cs @@ -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 +public class Plugin : BasePlugin, IHasWebPages { public Plugin(IApplicationPaths appPaths, IXmlSerializer xmlSerializer) : base(appPaths, xmlSerializer) @@ -18,4 +20,20 @@ public class Plugin : BasePlugin public override Guid Id => Guid.Parse("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); public override string Description => "Homeschool lesson plans with embedded media"; + + public IEnumerable GetPages() + { + return new[] + { + new PluginPageInfo + { + Name = Name, + DisplayName = "Lessons", + EmbeddedResourcePath = GetType().Namespace + ".Configuration.lessonsPage.html", + EnableInMainMenu = true, + MenuSection = "plugins", + MenuIcon = "book" + } + }; + } } diff --git a/plugin/ServiceRegistrator.cs b/plugin/ServiceRegistrator.cs index cae7cc3..378341e 100644 --- a/plugin/ServiceRegistrator.cs +++ b/plugin/ServiceRegistrator.cs @@ -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(sp => + new LessonStore("~/.local/share/jellyfin-lessons/lessons.db")); } } From 64945677138eb3563216a56ae2b59352c56b7446 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Fri, 15 May 2026 21:53:08 -0700 Subject: [PATCH 2/3] 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 --- plugin/Api/LessonsController.cs | 31 +++ plugin/Configuration/lessonsPage.html | 372 +++++++++++++++++++++++--- 2 files changed, 371 insertions(+), 32 deletions(-) diff --git a/plugin/Api/LessonsController.cs b/plugin/Api/LessonsController.cs index aad1b18..d2f53c8 100644 --- a/plugin/Api/LessonsController.cs +++ b/plugin/Api/LessonsController.cs @@ -31,4 +31,35 @@ public class LessonsController : ControllerBase 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 + }) + }); + } } diff --git a/plugin/Configuration/lessonsPage.html b/plugin/Configuration/lessonsPage.html index 30034e4..c226509 100644 --- a/plugin/Configuration/lessonsPage.html +++ b/plugin/Configuration/lessonsPage.html @@ -23,6 +23,32 @@ 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; } @@ -57,40 +83,158 @@ color: #999; margin-top: 0.25em; } - .empty-state { - text-align: center; - padding: 3em; + + /* 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; } - .error-state { + + /* States */ + .empty-state, .error-state, .loading { text-align: center; padding: 3em; - color: #c44; - } - .loading { - text-align: center; - padding: 3em; - color: #666; } + .empty-state { color: #666; } + .error-state { color: #c44; } + .loading { color: #666; }
-

📚 Lessons

+

📚 Lessons

Loading lessons…
From 22da56ff4a0417cc057b77b6f023b545e86b1ac4 Mon Sep 17 00:00:00 2001 From: Scott Scoble Date: Fri, 15 May 2026 21:55:10 -0700 Subject: [PATCH 3/3] 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. --- flake.nix | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/flake.nix b/flake.nix index 2645a68..114ed09 100644 --- a/flake.nix +++ b/flake.nix @@ -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}"; + }; + }; + }; + }; }; }