diff --git a/plugin/Data/LessonStore.cs b/plugin/Data/LessonStore.cs
new file mode 100644
index 0000000..09888b9
--- /dev/null
+++ b/plugin/Data/LessonStore.cs
@@ -0,0 +1,223 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using Microsoft.Data.Sqlite;
+using JellyfinLessons.Data.Models;
+
+namespace JellyfinLessons.Data;
+
+///
+/// SQLite-backed data store for lessons and their media associations.
+///
+public class LessonStore
+{
+ private readonly string _connectionString;
+
+ ///
+ /// Creates the data store, expanding ~ in the dbPath to the user's home directory.
+ ///
+ 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();
+ }
+
+ ///
+ /// Returns all lessons (without media).
+ ///
+ public List GetAllLessons()
+ {
+ var lessons = new List();
+ 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;
+ }
+
+ ///
+ /// Returns a lesson by its id, or null if not found.
+ ///
+ 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;
+ }
+
+ ///
+ /// Returns all lessons for a given grade level.
+ ///
+ public List GetLessonsByGrade(int grade)
+ {
+ var lessons = new List();
+ 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;
+ }
+
+ ///
+ /// Inserts or updates a lesson and its associated media entries.
+ ///
+ public void UpsertLesson(Lesson lesson, List 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();
+ }
+
+ ///
+ /// Deletes a lesson and its associated media entries (cascaded by FK if enabled).
+ ///
+ 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 GetMediaForLesson(string lessonId)
+ {
+ var items = new List();
+ 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)
+ };
+ }
+}
\ No newline at end of file
diff --git a/plugin/Data/Models/Lesson.cs b/plugin/Data/Models/Lesson.cs
new file mode 100644
index 0000000..d902501
--- /dev/null
+++ b/plugin/Data/Models/Lesson.cs
@@ -0,0 +1,65 @@
+#nullable enable
+using System.Collections.Generic;
+
+namespace JellyfinLessons.Data.Models;
+
+///
+/// Represents a homeschool lesson plan.
+///
+public class Lesson
+{
+ ///
+ /// Unique identifier (Guid).
+ ///
+ public string Id { get; set; } = string.Empty;
+
+ ///
+ /// Lesson title.
+ ///
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Target grade level.
+ ///
+ public int Grade { get; set; }
+
+ ///
+ /// Subject area (e.g. Math, Science, History).
+ ///
+ public string Subject { get; set; } = string.Empty;
+
+ ///
+ /// Date the lesson covers (ISO 8601 format).
+ ///
+ public string LessonDate { get; set; } = string.Empty;
+
+ ///
+ /// Optional description of the lesson.
+ ///
+ public string? Description { get; set; }
+
+ ///
+ /// Path to the source markdown or plan file.
+ ///
+ public string SourcePath { get; set; } = string.Empty;
+
+ ///
+ /// Pre-compiled HTML rendering of the lesson.
+ ///
+ public string? CompiledHtml { get; set; }
+
+ ///
+ /// ISO 8601 creation timestamp.
+ ///
+ public string CreatedAt { get; set; } = string.Empty;
+
+ ///
+ /// ISO 8601 last-updated timestamp.
+ ///
+ public string UpdatedAt { get; set; } = string.Empty;
+
+ ///
+ /// Associated media items (lazy-loaded).
+ ///
+ public List MediaItems { get; set; } = new();
+}
\ No newline at end of file
diff --git a/plugin/Data/Models/LessonMedia.cs b/plugin/Data/Models/LessonMedia.cs
new file mode 100644
index 0000000..1f30402
--- /dev/null
+++ b/plugin/Data/Models/LessonMedia.cs
@@ -0,0 +1,22 @@
+namespace JellyfinLessons.Data.Models;
+
+///
+/// Join model linking lessons to Jellyfin media items.
+///
+public class LessonMedia
+{
+ ///
+ /// Foreign key to the lesson.
+ ///
+ public string LessonId { get; set; } = string.Empty;
+
+ ///
+ /// Identifier of the Jellyfin media item.
+ ///
+ public string JellyfinItemId { get; set; } = string.Empty;
+
+ ///
+ /// Display order within the lesson.
+ ///
+ public int SortOrder { get; set; }
+}
\ No newline at end of file
diff --git a/plugin/Jellyfin.Plugin.Lessons.csproj b/plugin/Jellyfin.Plugin.Lessons.csproj
index 37bd5cc..faba00e 100644
--- a/plugin/Jellyfin.Plugin.Lessons.csproj
+++ b/plugin/Jellyfin.Plugin.Lessons.csproj
@@ -12,5 +12,8 @@
runtime
+
+ runtime
+