本文主要记录在 octans 类 .NET Web 项目中接入 EF Core 与 SQLite 的过程:安装包、定义 MediaItem / Movie / MediaFile、配置 AppDbContext(TPH、JSON 列表转换、唯一索引与 Value Comparer),以及 dotnet ef migrations 生成库文件。

接上文 创建 Web 应用与 MediaInfo 接入(项目可由 MediaManager 更名为 octans,命名空间以 octans 为准)。本文只覆盖电影 + 物理文件落库骨架,剧集实体可后续扩展。代码为脚手架示意,与线上仓库实现可能有差异。

背景说明

目标:

  1. 用 EF Core 管理 SQLite 文件库(开发阶段零独立数据库服务)。
  2. 逻辑媒体(标题、海报等)与 物理文件(路径、编码探针)分离:一部影片可对应多个文件(多版本/多码率)。
  3. 音轨、字幕等 List<string> 以 JSON 形式落库,并对 FilePath 建唯一索引防重复扫描。

环境信息

项目说明
项目ASP.NET Core Web(Razor Pages 等均可)
命名空间octans.Models / octans.Data
ORMEF Core + SQLite 提供程序
数据库文件连接串示例 Data Source=octans.db(生成于工作目录)
CLI全局工具 dotnet-ef

安装 NuGet 包

在项目根目录(含 .csproj)执行:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Relational
dotnet add package Microsoft.EntityFrameworkCore.Design

安装成功后,.csprojPackageReference 中应出现上述包名。

数据模型

在项目下建立 Models/ 目录。

MediaItem 基类

Models/MediaItem.cs

using System.ComponentModel.DataAnnotations;

namespace octans.Models;

// 抽象基类:库中存 Movie 等具体类型,而非“纯 MediaItem”实例
public abstract class MediaItem
{
    [Key]
    public int Id { get; set; }

    public int? TMDBId { get; set; }

    [Required]
    [MaxLength(200)]
    public string Title { get; set; } = string.Empty;

    [MaxLength(200)]
    public string? OriginalTitle { get; set; }

    public string? Overview { get; set; }
    public DateTime? ReleaseDate { get; set; }

    [MaxLength(500)]
    public string? PosterPath { get; set; }

    [MaxLength(500)]
    public string? BackdropPath { get; set; }

    public DateTime DateAdded { get; set; } = DateTime.UtcNow;

    public int? CollectionId { get; set; }
}

要点:[Key] / [Required] / [MaxLength] 为数据注解;int? / string? 表示可空列。

Movie

Models/Movie.cs

namespace octans.Models;

public class Movie : MediaItem
{
    public string? Tagline { get; set; }
}

在 TPH(Table-Per-Hierarchy)下,EF 通常将继承层次映射到同一表,并用鉴别器列区分类型。

MediaFile(物理文件)

Models/MediaFile.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace octans.Models;

public class MediaFile
{
    [Key]
    public int Id { get; set; }

    [Required]
    public int MediaItemId { get; set; }

    [ForeignKey(nameof(MediaItemId))]
    public MediaItem? MediaItem { get; set; }

    [Required]
    [MaxLength(1000)]
    public string FilePath { get; set; } = string.Empty;

    public long FileSize { get; set; }
    public long Duration { get; set; }

    [MaxLength(50)]
    public string? VideoFormat { get; set; }

    [MaxLength(100)]
    public string? VideoProfile { get; set; }

    [MaxLength(100)]
    public string? PixelFormat { get; set; }

    public int Width { get; set; }
    public int Height { get; set; }

    [MaxLength(50)]
    public string? FrameRate { get; set; }

    public long BitRate { get; set; }

    [MaxLength(50)]
    public string? HdrDisplay { get; set; }

    [MaxLength(50)]
    public string? OriginalSourceMedium { get; set; }

    [MaxLength(50)]
    public string? ReleaseType { get; set; }

    [MaxLength(50)]
    public string? ContainerFormat { get; set; }

    public List<string> AudioTracks { get; set; } = new();
    public List<string> Subtitles { get; set; } = new();

    public DateTime DateScanned { get; set; } = DateTime.UtcNow;
}

关系示意:一张逻辑影片一行 MediaItem/Movie;多个物理版本多行 MediaFile,共用同一 MediaItemId(1:N)。

AppDbContext

建立 Data/AppDbContext.cs。最终版本应包含 Value Comparer,避免对 List<string> 做 value conversion 后出现更新丢失与警告。

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using octans.Models;
using System.Text.Json;

namespace octans.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
    {
    }

    public DbSet<MediaItem> MediaItems { get; set; }
    public DbSet<Movie> Movies { get; set; }
    public DbSet<MediaFile> MediaFiles { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        modelBuilder.Entity<MediaItem>()
            .HasDiscriminator<string>("MediaType")
            .HasValue<Movie>("Movie");

        var stringListComparer = new ValueComparer<List<string>>(
            (c1, c2) => c1!.SequenceEqual(c2!),
            c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
            c => c.ToList());

        modelBuilder.Entity<MediaFile>()
            .Property(e => e.AudioTracks)
            .HasConversion(
                v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
                v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>())
            .Metadata.SetValueComparer(stringListComparer);

        modelBuilder.Entity<MediaFile>()
            .Property(e => e.Subtitles)
            .HasConversion(
                v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
                v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>())
            .Metadata.SetValueComparer(stringListComparer);

        modelBuilder.Entity<MediaFile>()
            .HasIndex(e => e.FilePath)
            .IsUnique();
    }
}

说明:

  • TPHMediaType 鉴别器区分 Movie 等。
  • HasConversion:SQLite 侧用文本存 JSON。
  • ValueComparer:变更跟踪能感知列表元素变化;缺少时 dotnet ef 可能告警 10620,且 SaveChanges 可能漏更新。
  • 唯一索引:同一 FilePath 不可重复入库。

连接串与依赖注入

appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=octans.db"
  },
  "MediaConfig": {
    "MovieDirectory": "E:\\Movies",
    "LogRawMediaInfo": false,
    "SupportedVideoExtensions": [
      ".mkv", ".mp4", ".ts", ".m2ts", ".m2t",
      ".avi", ".rmvb", ".wmv", ".mov", ".flv", ".webm"
    ]
  }
}

路径与扩展名按环境修改。数据库文件名可改为绝对路径;勿将生产密钥写入公开仓库。

Program.cs 注册

using Microsoft.EntityFrameworkCore;
using octans.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddRazorPages();

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(connectionString));

var app = builder.Build();
// 其余中间件与 MapRazorPages 等保持项目原有结构

关于 SQLite:无需单独部署实例

SQLite 为进程内、单文件引擎:NuGet 提供程序已带引擎库,程序直接读写 octans.db,不需要本机再装 PostgreSQL 式服务或监听端口。

开发期可用 VS Code 的 SQLite 插件、DB Browser for SQLite 或 DBeaver 打开文件查看表结构。多进程并发写仍有锁限制,生产高并发写场景应评估换 PostgreSQL 等 Client-Server 库。

迁移与建库

安装全局工具(本机一次)

dotnet tool install --global dotnet-ef
# 已安装时可:dotnet tool update --global dotnet-ef

添加迁移并更新数据库

在项目根目录:

dotnet ef migrations add InitialCreate
dotnet ef database update

成功后项目下出现 Migrations/,工作目录生成 octans.db(路径取决于连接串与启动目录)。控制台可能打印 CREATE TABLE 一类语句并以 Done. 结束。

若在未挂 Value Comparer 时已 migrations add,可能看到:

warn: ... The property 'MediaFile.AudioTracks' is a collection or enumeration type
  with a value converter but with no value comparer. ...
warn: ... 'MediaFile.Subtitles' ...

处理:补全上文 AppDbContext 中的 comparer 后:

dotnet ef migrations remove
dotnet ef migrations add InitialCreate
dotnet ef database update

(若库已应用旧迁移,remove 前需确认未在生产使用;必要时删库文件重来,仅限可丢弃的开发库。)

验证结果

检查项期望
包引用.csproj 含 Sqlite / Design 等
迁移Migrations 目录存在且无 10620 警告
库文件存在 octans.db(或连接串指定路径)
表结构含媒体表与 MediaFilesFilePath 为 UNIQUE
鉴别器TPH 相关列存在(如 MediaType

可用 SQLite 客户端打开 .db 核对列名与索引。

注意事项

  • octans.db*-shm*-wal 应加入 .gitignore,避免误提交(见 Git untrack 相关文)。
  • 迁移是结构变更的版本化手段;多人协作需约定迁移提交顺序,勿随意改已上线迁移哈希。
  • JSON 转换与 comparer 的组合是常见坑,改集合元素后务必实测 SaveChanges
  • 本文未接入扫描服务写入逻辑;下一步才是把扫描结果 Add/SaveChanges 进库。

参考资料