jellyfin-lessons/plugin/Data/LessonStore.cs
Scott Scoble deecc1dd7c feat: add SQLite data store with Lesson and LessonMedia models
- Add Microsoft.Data.Sqlite NuGet package to csproj
- Create Lesson model (Id, Title, Grade, Subject, LessonDate, Description, SourcePath, CompiledHtml, CreatedAt, UpdatedAt)
- Create LessonMedia model (LessonId, JellyfinItemId, SortOrder)
- Create LessonStore with SQLite-backed CRUD operations:
  - Constructor expands ~ in dbPath to home directory
  - Creates lessons and lesson_media tables on init
  - GetAllLessons(), GetLessonById(), GetLessonsByGrade()
  - UpsertLesson() with media association handling
  - DeleteLesson() with FK cascade
- Enables foreign key enforcement via PRAGMA
- Build passes with 0 warnings, 0 errors

Closes #4
2026-05-15 21:37:56 -07:00

223 lines
No EOL
8.5 KiB
C#

#nullable enable
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Data.Sqlite;
using JellyfinLessons.Data.Models;
namespace JellyfinLessons.Data;
/// <summary>
/// SQLite-backed data store for lessons and their media associations.
/// </summary>
public class LessonStore
{
private readonly string _connectionString;
/// <summary>
/// Creates the data store, expanding ~ in the dbPath to the user's home directory.
/// </summary>
public LessonStore(string dbPath)
{
if (dbPath.StartsWith("~/") || dbPath == "~")
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
dbPath = Path.Combine(home, dbPath.TrimStart('~', '/'));
}
_connectionString = $"Data Source={dbPath}";
CreateTables();
}
private void CreateTables()
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.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
);
";
command.ExecuteNonQuery();
using var pragmaCmd = connection.CreateCommand();
pragmaCmd.CommandText = "PRAGMA foreign_keys = ON;";
pragmaCmd.ExecuteNonQuery();
}
/// <summary>
/// Returns all lessons (without media).
/// </summary>
public List<Lesson> GetAllLessons()
{
var lessons = new List<Lesson>();
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM lessons ORDER BY lesson_date DESC, title ASC";
using var reader = command.ExecuteReader();
while (reader.Read())
lessons.Add(MapLesson(reader));
return lessons;
}
/// <summary>
/// Returns a lesson by its id, or null if not found.
/// </summary>
public Lesson? GetLessonById(string id)
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM lessons WHERE id = @id";
command.Parameters.AddWithValue("@id", id);
using var reader = command.ExecuteReader();
if (reader.Read())
{
var lesson = MapLesson(reader);
lesson.MediaItems = GetMediaForLesson(id);
return lesson;
}
return null;
}
/// <summary>
/// Returns all lessons for a given grade level.
/// </summary>
public List<Lesson> GetLessonsByGrade(int grade)
{
var lessons = new List<Lesson>();
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM lessons WHERE grade = @grade ORDER BY lesson_date DESC, title ASC";
command.Parameters.AddWithValue("@grade", grade);
using var reader = command.ExecuteReader();
while (reader.Read())
lessons.Add(MapLesson(reader));
return lessons;
}
/// <summary>
/// Inserts or updates a lesson and its associated media entries.
/// </summary>
public void UpsertLesson(Lesson lesson, List<LessonMedia> media)
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var transaction = connection.BeginTransaction();
using var command = connection.CreateCommand();
command.CommandText = @"
INSERT INTO lessons (id, title, grade, subject, lesson_date, description, source_path, compiled_html, created_at, updated_at)
VALUES (@id, @title, @grade, @subject, @lessonDate, @description, @sourcePath, @compiledHtml, @createdAt, @updatedAt)
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;
";
command.Parameters.AddWithValue("@id", lesson.Id);
command.Parameters.AddWithValue("@title", lesson.Title);
command.Parameters.AddWithValue("@grade", lesson.Grade);
command.Parameters.AddWithValue("@subject", lesson.Subject);
command.Parameters.AddWithValue("@lessonDate", lesson.LessonDate);
command.Parameters.AddWithValue("@description", (object?)lesson.Description ?? DBNull.Value);
command.Parameters.AddWithValue("@sourcePath", lesson.SourcePath);
command.Parameters.AddWithValue("@compiledHtml", (object?)lesson.CompiledHtml ?? DBNull.Value);
command.Parameters.AddWithValue("@createdAt", lesson.CreatedAt);
command.Parameters.AddWithValue("@updatedAt", lesson.UpdatedAt);
command.ExecuteNonQuery();
using var deleteMediaCmd = connection.CreateCommand();
deleteMediaCmd.CommandText = "DELETE FROM lesson_media WHERE lesson_id = @lessonId";
deleteMediaCmd.Parameters.AddWithValue("@lessonId", lesson.Id);
deleteMediaCmd.ExecuteNonQuery();
foreach (var item in media)
{
using var insertMediaCmd = connection.CreateCommand();
insertMediaCmd.CommandText = "INSERT INTO lesson_media (lesson_id, jellyfin_item_id, sort_order) VALUES (@lessonId, @jellyfinItemId, @sortOrder)";
insertMediaCmd.Parameters.AddWithValue("@lessonId", lesson.Id);
insertMediaCmd.Parameters.AddWithValue("@jellyfinItemId", item.JellyfinItemId);
insertMediaCmd.Parameters.AddWithValue("@sortOrder", item.SortOrder);
insertMediaCmd.ExecuteNonQuery();
}
transaction.Commit();
}
/// <summary>
/// Deletes a lesson and its associated media entries (cascaded by FK if enabled).
/// </summary>
public void DeleteLesson(string id)
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "DELETE FROM lessons WHERE id = @id";
command.Parameters.AddWithValue("@id", id);
command.ExecuteNonQuery();
}
private List<LessonMedia> GetMediaForLesson(string lessonId)
{
var items = new List<LessonMedia>();
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT * FROM lesson_media WHERE lesson_id = @lessonId ORDER BY sort_order ASC";
command.Parameters.AddWithValue("@lessonId", lessonId);
using var reader = command.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 MapLesson(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)
};
}
}