feat: 视频缩略图生成、最近文件面板与前端视图重构

- 新增 VideoThumbnailService,基于 ffmpeg 截取视频缩略图,ffprobe 提取时长
  - 新增 ManagedThumbnailMap 模型及多数据库迁移,存储缩略图元数据
  - 新增 /api/thumbnails/{id} 缩略图流端点
  - 新增最近添加/最近播放 API 与前端面板,支持列表/网格双视图切换
  - FileRecordDto 扩展 thumbnailUrl、videoDuration、lastPlayedAt 字段
  - 前端新增文件库 Tab 导航、卡片网格视图、视频海报与时长信息栏
  - 添加文件库目录不再同步全量扫描,改为后台异步自动扫描
This commit is contained in:
luoqian 2026-05-22 17:01:49 +08:00
parent 6acc92ca27
commit 2c20f9bb54
47 changed files with 5975 additions and 64 deletions

1
.gitignore vendored
View File

@ -32,3 +32,4 @@
/bin
/obj
/.claude
/.codex-build

View File

@ -40,6 +40,14 @@ namespace FileShare_API.Configuration
// ---- 业务服务 ----
services.AddScoped<WeatherForecastService>();
var thumbnailOptions = configuration
.GetSection(nameof(ThumbnailStorageOptions))
.Get<ThumbnailStorageOptions>()
?? new ThumbnailStorageOptions();
services.AddSingleton(thumbnailOptions);
services.AddSingleton<IVideoThumbnailService>(sp =>
new VideoThumbnailService(sp.GetRequiredService<ThumbnailStorageOptions>()));
services.AddScoped<IThumbnailStreamService, ThumbnailStreamService>();
services.AddScoped<IFileLibraryService, FileLibraryService>();
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
services.AddScoped<IFileStreamService, FileStreamService>();

View File

@ -43,6 +43,35 @@ namespace FileShare_API.Extensions
.WithName("StreamManagedFileById")
.WithTags("FileLibrary");
app.MapMethods(
"/api/thumbnails/{id:int}",
["GET", "HEAD"],
async (int id, IThumbnailStreamService thumbnailStreamService, HttpContext httpContext) =>
{
var thumbnail = await thumbnailStreamService.GetThumbnailAsync(id);
if (thumbnail is null)
{
return Results.NotFound();
}
var stream = System.IO.File.Open(
thumbnail.FilePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite);
httpContext.Response.Headers.ContentDisposition =
$"inline; filename=\"{Uri.EscapeDataString(thumbnail.FileName)}\"";
httpContext.Response.Headers.CacheControl = "public, max-age=3600";
return Results.File(
stream,
contentType: thumbnail.ContentType,
lastModified: thumbnail.LastModified);
})
.WithName("StreamManagedThumbnailById")
.WithTags("FileLibrary");
return app;
}
}

View File

@ -26,6 +26,8 @@
<ItemGroup>
<Folder Include="Controllers\" />
<Content Include="..\tools\ffmpeg\bin\ffmpeg.exe" Link="tools\ffmpeg\bin\ffmpeg.exe" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\tools\ffmpeg\bin\ffprobe.exe" Link="tools\ffmpeg\bin\ffprobe.exe" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<Target Name="RestoreFrontendPackages" BeforeTargets="Build" Condition="'$(SkipFrontendBuild)' != 'true' And Exists('..\FileShare-Web-VUE\package.json') And !Exists('..\FileShare-Web-VUE\node_modules')">

View File

@ -24,5 +24,10 @@
"RecreateDatabase": false,
"EnableDetailedLog": false,
"Timeout": 30
},
"ThumbnailStorageOptions": {
"RootPath": "thumbnails",
"FfmpegPath": "tools/ffmpeg/bin/ffmpeg.exe",
"FfprobePath": "tools/ffmpeg/bin/ffprobe.exe"
}
}

View File

@ -25,6 +25,8 @@ namespace FileShare_EFCore.Database
/// <summary>文件库文件记录数据</summary>
public DbSet<ManagedFileRecord> ManagedFileRecords => Set<ManagedFileRecord>();
public DbSet<ManagedThumbnailMap> ManagedThumbnailMaps => Set<ManagedThumbnailMap>();
/// <summary>
/// 配置实体映射,包括主键、索引和属性约束。
/// </summary>
@ -66,8 +68,10 @@ namespace FileShare_EFCore.Database
{
entity.HasKey(e => e.Id).HasName("pk-managed-file-record");
entity.HasIndex(e => e.LibraryRootId).HasDatabaseName("idx-managed-file-record-root-id");
entity.HasIndex(e => e.ThumbnailId).HasDatabaseName("idx-managed-file-record-thumbnail-id");
entity.HasIndex(e => e.AbsolutePath).IsUnique().HasDatabaseName("idx-managed-file-record-absolute-path");
entity.HasIndex(e => new { e.MediaType, e.Exists }).HasDatabaseName("idx-managed-file-record-media-type-exists");
entity.HasIndex(e => e.LastPlayedAt).HasDatabaseName("idx-managed-file-record-last-played-at");
entity.Property(e => e.FileName).HasMaxLength(260);
entity.Property(e => e.RelativePath).HasMaxLength(1024);
entity.Property(e => e.AbsolutePath).HasMaxLength(2048);
@ -78,6 +82,23 @@ namespace FileShare_EFCore.Database
.WithMany(e => e.Files)
.HasForeignKey(e => e.LibraryRootId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Thumbnail)
.WithMany(e => e.Files)
.HasForeignKey(e => e.ThumbnailId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ManagedThumbnailMap>(entity =>
{
entity.HasKey(e => e.Id).HasName("pk-managed-thumbnail-map");
entity.HasIndex(e => e.LibraryRootId).HasDatabaseName("idx-managed-thumbnail-map-root-id");
entity.HasIndex(e => e.RelativePath).IsUnique().HasDatabaseName("idx-managed-thumbnail-map-relative-path");
entity.Property(e => e.RelativePath).HasMaxLength(1024);
entity.Property(e => e.ContentType).HasMaxLength(100);
entity.HasOne(e => e.LibraryRoot)
.WithMany(e => e.Thumbnails)
.HasForeignKey(e => e.LibraryRootId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

View File

@ -0,0 +1,370 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FileShare_EFCore.Migrations.MySQL
{
[DbContext(typeof(MySqlAppDataContext))]
[Migration("20260522082856_AutoMigration_20260522162758")]
partial class AutoMigration_20260522162758
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime(6)")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("varchar(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("tinyint(1)")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("varchar(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("datetime(6)")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<string>("ThumbnailPath")
.HasMaxLength(512)
.HasColumnType("varchar(512)")
.HasColumnName("thumbnail-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("double")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("int")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,113 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using MySql.EntityFrameworkCore.Metadata;
#nullable disable
namespace FileShare_EFCore.Migrations.MySQL
{
/// <inheritdoc />
public partial class AutoMigration_20260522162758 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "managed-library-root",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
path = table.Column<string>(type: "varchar(1024)", maxLength: 1024, nullable: false),
displayname = table.Column<string>(name: "display-name", type: "varchar(200)", maxLength: 200, nullable: false),
isenabled = table.Column<bool>(name: "is-enabled", type: "tinyint(1)", nullable: false),
isavailable = table.Column<bool>(name: "is-available", type: "tinyint(1)", nullable: false, defaultValue: true),
scanintervalminutes = table.Column<int>(name: "scan-interval-minutes", type: "int", nullable: false),
lastscanstartedat = table.Column<DateTime>(name: "last-scan-started-at", type: "datetime(6)", nullable: true),
lastscancompletedat = table.Column<DateTime>(name: "last-scan-completed-at", type: "datetime(6)", nullable: true),
lastscanerror = table.Column<string>(name: "last-scan-error", type: "varchar(2000)", maxLength: 2000, nullable: true),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime(6)", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-library-root", x => x.id);
},
comment: "文件库根目录")
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateTable(
name: "managed-file-record",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
libraryrootid = table.Column<int>(name: "library-root-id", type: "int", nullable: false),
filename = table.Column<string>(name: "file-name", type: "varchar(260)", maxLength: 260, nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "varchar(1024)", maxLength: 1024, nullable: false),
absolutepath = table.Column<string>(name: "absolute-path", type: "varchar(2048)", maxLength: 2048, nullable: false),
extension = table.Column<string>(type: "varchar(32)", maxLength: 32, nullable: false),
sizebytes = table.Column<long>(name: "size-bytes", type: "bigint", nullable: false),
lastwritetimeutc = table.Column<DateTime>(name: "last-write-time-utc", type: "datetime(6)", nullable: false),
mediatype = table.Column<string>(name: "media-type", type: "varchar(20)", maxLength: 20, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "varchar(100)", maxLength: 100, nullable: false),
exists = table.Column<bool>(type: "tinyint(1)", nullable: false),
lastseenat = table.Column<DateTime>(name: "last-seen-at", type: "datetime(6)", nullable: false),
thumbnailpath = table.Column<string>(name: "thumbnail-path", type: "varchar(512)", maxLength: 512, nullable: true),
videoduration = table.Column<double>(name: "video-duration", type: "double", nullable: true),
lastplayedat = table.Column<DateTime>(name: "last-played-at", type: "datetime(6)", nullable: true),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime(6)", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-file-record", x => x.id);
table.ForeignKey(
name: "FK_managed-file-record_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件库文件记录")
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-absolute-path",
table: "managed-file-record",
column: "absolute-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-last-played-at",
table: "managed-file-record",
column: "last-played-at");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-media-type-exists",
table: "managed-file-record",
columns: new[] { "media-type", "exists" });
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-root-id",
table: "managed-file-record",
column: "library-root-id");
migrationBuilder.CreateIndex(
name: "idx-managed-library-root-path",
table: "managed-library-root",
column: "path",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-library-root");
}
}
}

View File

@ -0,0 +1,444 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FileShare_EFCore.Migrations.MySQL
{
[DbContext(typeof(MySqlAppDataContext))]
[Migration("20260522084325_AddThumbnailMap")]
partial class AddThumbnailMap
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime(6)")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("varchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime(6)")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("varchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("varchar(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("tinyint(1)")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("varchar(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("datetime(6)")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("int")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("double")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("int")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("varchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,101 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using MySql.EntityFrameworkCore.Metadata;
#nullable disable
namespace FileShare_EFCore.Migrations.MySQL
{
/// <inheritdoc />
public partial class AddThumbnailMap : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "thumbnail-path",
table: "managed-file-record");
migrationBuilder.AddColumn<int>(
name: "thumbnail-id",
table: "managed-file-record",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "managed-thumbnail-map",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("MySQL:ValueGenerationStrategy", MySQLValueGenerationStrategy.IdentityColumn),
libraryrootid = table.Column<int>(name: "library-root-id", type: "int", nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "varchar(1024)", maxLength: 1024, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "varchar(100)", maxLength: 100, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime(6)", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime(6)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-thumbnail-map", x => x.id);
table.ForeignKey(
name: "FK_managed-thumbnail-map_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件缩略图映射记录")
.Annotation("MySQL:Charset", "utf8mb4");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id");
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-relative-path",
table: "managed-thumbnail-map",
column: "relative-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-root-id",
table: "managed-thumbnail-map",
column: "library-root-id");
migrationBuilder.AddForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id",
principalTable: "managed-thumbnail-map",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-thumbnail-map");
migrationBuilder.DropIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "thumbnail-id",
table: "managed-file-record");
migrationBuilder.AddColumn<string>(
name: "thumbnail-path",
table: "managed-file-record",
type: "varchar(512)",
maxLength: 512,
nullable: true);
}
}
}

View File

@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
@ -79,6 +79,228 @@ namespace FileShare_EFCore.Migrations.MySQL
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("varchar(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("tinyint(1)")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("varchar(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("varchar(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("datetime(6)")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("varchar(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("int")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("double")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("varchar(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("tinyint(1)")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("tinyint(1)")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("varchar(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("datetime(6)")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("int")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("varchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("varchar(1024)")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime(6)")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
@ -172,6 +394,47 @@ namespace FileShare_EFCore.Migrations.MySQL
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}

View File

@ -0,0 +1,383 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FileShare_EFCore.Migrations.PostgreSQL
{
[DbContext(typeof(PostgreSqlAppDataContext))]
[Migration("20260522082843_AutoMigration_20260522162758")]
partial class AutoMigration_20260522162758
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("boolean")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("integer")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<string>("ThumbnailPath")
.HasMaxLength(512)
.HasColumnType("character varying(512)")
.HasColumnName("thumbnail-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("double precision")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("integer")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("用户主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("天气预报主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("integer")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FileShare_EFCore.Migrations.PostgreSQL
{
/// <inheritdoc />
public partial class AutoMigration_20260522162758 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "managed-library-root",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
path = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
displayname = table.Column<string>(name: "display-name", type: "character varying(200)", maxLength: 200, nullable: false),
isenabled = table.Column<bool>(name: "is-enabled", type: "boolean", nullable: false),
isavailable = table.Column<bool>(name: "is-available", type: "boolean", nullable: false, defaultValue: true),
scanintervalminutes = table.Column<int>(name: "scan-interval-minutes", type: "integer", nullable: false),
lastscanstartedat = table.Column<DateTime>(name: "last-scan-started-at", type: "timestamp with time zone", nullable: true),
lastscancompletedat = table.Column<DateTime>(name: "last-scan-completed-at", type: "timestamp with time zone", nullable: true),
lastscanerror = table.Column<string>(name: "last-scan-error", type: "character varying(2000)", maxLength: 2000, nullable: true),
createdat = table.Column<DateTime>(name: "created-at", type: "timestamp with time zone", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-library-root", x => x.id);
},
comment: "文件库根目录");
migrationBuilder.CreateTable(
name: "managed-file-record",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
libraryrootid = table.Column<int>(name: "library-root-id", type: "integer", nullable: false),
filename = table.Column<string>(name: "file-name", type: "character varying(260)", maxLength: 260, nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "character varying(1024)", maxLength: 1024, nullable: false),
absolutepath = table.Column<string>(name: "absolute-path", type: "character varying(2048)", maxLength: 2048, nullable: false),
extension = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
sizebytes = table.Column<long>(name: "size-bytes", type: "bigint", nullable: false),
lastwritetimeutc = table.Column<DateTime>(name: "last-write-time-utc", type: "timestamp with time zone", nullable: false),
mediatype = table.Column<string>(name: "media-type", type: "character varying(20)", maxLength: 20, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "character varying(100)", maxLength: 100, nullable: false),
exists = table.Column<bool>(type: "boolean", nullable: false),
lastseenat = table.Column<DateTime>(name: "last-seen-at", type: "timestamp with time zone", nullable: false),
thumbnailpath = table.Column<string>(name: "thumbnail-path", type: "character varying(512)", maxLength: 512, nullable: true),
videoduration = table.Column<double>(name: "video-duration", type: "double precision", nullable: true),
lastplayedat = table.Column<DateTime>(name: "last-played-at", type: "timestamp with time zone", nullable: true),
createdat = table.Column<DateTime>(name: "created-at", type: "timestamp with time zone", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-file-record", x => x.id);
table.ForeignKey(
name: "FK_managed-file-record_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件库文件记录");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-absolute-path",
table: "managed-file-record",
column: "absolute-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-last-played-at",
table: "managed-file-record",
column: "last-played-at");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-media-type-exists",
table: "managed-file-record",
columns: new[] { "media-type", "exists" });
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-root-id",
table: "managed-file-record",
column: "library-root-id");
migrationBuilder.CreateIndex(
name: "idx-managed-library-root-path",
table: "managed-library-root",
column: "path",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-library-root");
}
}
}

View File

@ -0,0 +1,459 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FileShare_EFCore.Migrations.PostgreSQL
{
[DbContext(typeof(PostgreSqlAppDataContext))]
[Migration("20260522084325_AddThumbnailMap")]
partial class AddThumbnailMap
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("integer")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("boolean")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("integer")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("integer")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("double precision")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("integer")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("integer")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("用户主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id")
.HasComment("天气预报主键");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("integer")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FileShare_EFCore.Migrations.PostgreSQL
{
/// <inheritdoc />
public partial class AddThumbnailMap : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "thumbnail-path",
table: "managed-file-record");
migrationBuilder.AddColumn<int>(
name: "thumbnail-id",
table: "managed-file-record",
type: "integer",
nullable: true);
migrationBuilder.CreateTable(
name: "managed-thumbnail-map",
columns: table => new
{
id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
libraryrootid = table.Column<int>(name: "library-root-id", type: "integer", nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "character varying(1024)", maxLength: 1024, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "character varying(100)", maxLength: 100, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "timestamp with time zone", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-thumbnail-map", x => x.id);
table.ForeignKey(
name: "FK_managed-thumbnail-map_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件缩略图映射记录");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id");
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-relative-path",
table: "managed-thumbnail-map",
column: "relative-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-root-id",
table: "managed-thumbnail-map",
column: "library-root-id");
migrationBuilder.AddForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id",
principalTable: "managed-thumbnail-map",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-thumbnail-map");
migrationBuilder.DropIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "thumbnail-id",
table: "managed-file-record");
migrationBuilder.AddColumn<string>(
name: "thumbnail-path",
table: "managed-file-record",
type: "character varying(512)",
maxLength: 512,
nullable: true);
}
}
}

View File

@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
@ -84,6 +84,234 @@ namespace FileShare_EFCore.Migrations.PostgreSQL
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("character varying(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("boolean")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("character varying(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("integer")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("integer")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("double precision")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("integer")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("integer")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
@ -181,6 +409,47 @@ namespace FileShare_EFCore.Migrations.PostgreSQL
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}

View File

@ -0,0 +1,368 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FileShare_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
[Migration("20260522082814_AutoMigration_20260522162758")]
partial class AutoMigration_20260522162758
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("INTEGER")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("TEXT")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("TEXT")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("TEXT")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("INTEGER")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER")
.HasColumnName("size-bytes");
b.Property<string>("ThumbnailPath")
.HasMaxLength(512)
.HasColumnType("TEXT")
.HasColumnName("thumbnail-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("REAL")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("TEXT")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("TEXT")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("TEXT")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("INTEGER")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("INTEGER")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FileShare_EFCore.Migrations.SQLite
{
/// <inheritdoc />
public partial class AutoMigration_20260522162758 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "last-played-at",
table: "managed-file-record",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "thumbnail-path",
table: "managed-file-record",
type: "TEXT",
maxLength: 512,
nullable: true);
migrationBuilder.AddColumn<double>(
name: "video-duration",
table: "managed-file-record",
type: "REAL",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-last-played-at",
table: "managed-file-record",
column: "last-played-at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx-managed-file-record-last-played-at",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "last-played-at",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "thumbnail-path",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "video-duration",
table: "managed-file-record");
}
}
}

View File

@ -0,0 +1,442 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FileShare_EFCore.Migrations.SQLite
{
[DbContext(typeof(SqliteAppDataContext))]
[Migration("20260522084259_AddThumbnailMap")]
partial class AddThumbnailMap
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("TEXT")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("TEXT")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("TEXT")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("TEXT")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("INTEGER")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("TEXT")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("TEXT")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("TEXT")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("INTEGER")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("INTEGER")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("REAL")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("TEXT")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("TEXT")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("TEXT")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("INTEGER")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("INTEGER")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("用户主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("TEXT")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id")
.HasComment("天气预报主键");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("TEXT")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("INTEGER")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FileShare_EFCore.Migrations.SQLite
{
/// <inheritdoc />
public partial class AddThumbnailMap : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "thumbnail-path",
table: "managed-file-record");
migrationBuilder.AddColumn<int>(
name: "thumbnail-id",
table: "managed-file-record",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateTable(
name: "managed-thumbnail-map",
columns: table => new
{
id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
libraryrootid = table.Column<int>(name: "library-root-id", type: "INTEGER", nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "TEXT", maxLength: 1024, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "TEXT", maxLength: 100, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "TEXT", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-thumbnail-map", x => x.id);
table.ForeignKey(
name: "FK_managed-thumbnail-map_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件缩略图映射记录");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id");
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-relative-path",
table: "managed-thumbnail-map",
column: "relative-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-root-id",
table: "managed-thumbnail-map",
column: "library-root-id");
migrationBuilder.AddForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id",
principalTable: "managed-thumbnail-map",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-thumbnail-map");
migrationBuilder.DropIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "thumbnail-id",
table: "managed-file-record");
migrationBuilder.AddColumn<string>(
name: "thumbnail-path",
table: "managed-file-record",
type: "TEXT",
maxLength: 512,
nullable: true);
}
}
}

View File

@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
@ -116,6 +116,10 @@ namespace FileShare_EFCore.Migrations.SQLite
.HasColumnType("TEXT")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("TEXT")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("TEXT")
.HasColumnName("last-seen-at");
@ -144,10 +148,18 @@ namespace FileShare_EFCore.Migrations.SQLite
.HasColumnType("INTEGER")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("INTEGER")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("REAL")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
@ -155,9 +167,15 @@ namespace FileShare_EFCore.Migrations.SQLite
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
@ -234,6 +252,53 @@ namespace FileShare_EFCore.Migrations.SQLite
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("INTEGER")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
@ -336,10 +401,35 @@ namespace FileShare_EFCore.Migrations.SQLite
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});

View File

@ -0,0 +1,383 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FileShare_EFCore.Migrations.SqlServer
{
[DbContext(typeof(SqlServerAppDataContext))]
[Migration("20260522082829_AutoMigration_20260522162758")]
partial class AutoMigration_20260522162758
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("bit")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("datetime2")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime2")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("datetime2")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<string>("ThumbnailPath")
.HasMaxLength(512)
.HasColumnType("nvarchar(512)")
.HasColumnName("thumbnail-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("float")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("bit")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("datetime2")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("datetime2")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("int")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,110 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FileShare_EFCore.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AutoMigration_20260522162758 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "managed-library-root",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
path = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
displayname = table.Column<string>(name: "display-name", type: "nvarchar(200)", maxLength: 200, nullable: false),
isenabled = table.Column<bool>(name: "is-enabled", type: "bit", nullable: false),
isavailable = table.Column<bool>(name: "is-available", type: "bit", nullable: false, defaultValue: true),
scanintervalminutes = table.Column<int>(name: "scan-interval-minutes", type: "int", nullable: false),
lastscanstartedat = table.Column<DateTime>(name: "last-scan-started-at", type: "datetime2", nullable: true),
lastscancompletedat = table.Column<DateTime>(name: "last-scan-completed-at", type: "datetime2", nullable: true),
lastscanerror = table.Column<string>(name: "last-scan-error", type: "nvarchar(2000)", maxLength: 2000, nullable: true),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime2", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-library-root", x => x.id);
},
comment: "文件库根目录");
migrationBuilder.CreateTable(
name: "managed-file-record",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
libraryrootid = table.Column<int>(name: "library-root-id", type: "int", nullable: false),
filename = table.Column<string>(name: "file-name", type: "nvarchar(260)", maxLength: 260, nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "nvarchar(1024)", maxLength: 1024, nullable: false),
absolutepath = table.Column<string>(name: "absolute-path", type: "nvarchar(2048)", maxLength: 2048, nullable: false),
extension = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
sizebytes = table.Column<long>(name: "size-bytes", type: "bigint", nullable: false),
lastwritetimeutc = table.Column<DateTime>(name: "last-write-time-utc", type: "datetime2", nullable: false),
mediatype = table.Column<string>(name: "media-type", type: "nvarchar(20)", maxLength: 20, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "nvarchar(100)", maxLength: 100, nullable: false),
exists = table.Column<bool>(type: "bit", nullable: false),
lastseenat = table.Column<DateTime>(name: "last-seen-at", type: "datetime2", nullable: false),
thumbnailpath = table.Column<string>(name: "thumbnail-path", type: "nvarchar(512)", maxLength: 512, nullable: true),
videoduration = table.Column<double>(name: "video-duration", type: "float", nullable: true),
lastplayedat = table.Column<DateTime>(name: "last-played-at", type: "datetime2", nullable: true),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime2", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-file-record", x => x.id);
table.ForeignKey(
name: "FK_managed-file-record_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件库文件记录");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-absolute-path",
table: "managed-file-record",
column: "absolute-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-last-played-at",
table: "managed-file-record",
column: "last-played-at");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-media-type-exists",
table: "managed-file-record",
columns: new[] { "media-type", "exists" });
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-root-id",
table: "managed-file-record",
column: "library-root-id");
migrationBuilder.CreateIndex(
name: "idx-managed-library-root-path",
table: "managed-library-root",
column: "path",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-library-root");
}
}
}

View File

@ -0,0 +1,459 @@
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FileShare_EFCore.Migrations.SqlServer
{
[DbContext(typeof(SqlServerAppDataContext))]
[Migration("20260522084325_AddThumbnailMap")]
partial class AddThumbnailMap
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("FileShare_EFCore.Models.ApiRefreshTokenEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("Device")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("device");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2")
.HasColumnName("expires-at");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("ip-address");
b.Property<string>("ReplacedByTokenHash")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("replaced-by-token-hash");
b.Property<DateTime?>("RevokedAt")
.HasColumnType("datetime2")
.HasColumnName("revoked-at");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("token-hash");
b.Property<int>("UserId")
.HasColumnType("int")
.HasColumnName("user-id");
b.HasKey("Id")
.HasName("pk-api-refresh-token");
b.HasIndex("TokenHash")
.IsUnique()
.HasDatabaseName("idx-api-refresh-token-hash");
b.HasIndex("UserId")
.HasDatabaseName("idx-api-refresh-token-user-id");
b.ToTable("api-refresh-token", t =>
{
t.HasComment("API refresh token");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("bit")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("datetime2")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime2")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("datetime2")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("int")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("float")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("bit")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("datetime2")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("datetime2")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("int")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("用户主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("email")
.HasComment("用户邮箱");
b.Property<string>("Name")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("name")
.HasComment("用户名称");
b.Property<string>("PasswordHash")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("password-hash")
.HasComment("密码哈希值");
b.Property<string>("PhoneNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)")
.HasColumnName("phone-number")
.HasComment("电话号码");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-user");
b.ToTable("user", t =>
{
t.HasComment("用户实体,演示数据库 CRUD 操作");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.WeatherForecastEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id")
.HasComment("天气预报主键");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at")
.HasComment("创建时间");
b.Property<DateOnly>("Date")
.HasColumnType("date")
.HasColumnName("date")
.HasComment("预报日期");
b.Property<string>("Summary")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("summary")
.HasComment("天气摘要");
b.Property<int>("TemperatureC")
.HasColumnType("int")
.HasColumnName("temperature-c")
.HasComment("摄氏温度");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at")
.HasComment("更新时间");
b.HasKey("Id")
.HasName("pk-weather-forecast");
b.ToTable("weather-forecast", t =>
{
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,99 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FileShare_EFCore.Migrations.SqlServer
{
/// <inheritdoc />
public partial class AddThumbnailMap : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "thumbnail-path",
table: "managed-file-record");
migrationBuilder.AddColumn<int>(
name: "thumbnail-id",
table: "managed-file-record",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "managed-thumbnail-map",
columns: table => new
{
id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
libraryrootid = table.Column<int>(name: "library-root-id", type: "int", nullable: false),
relativepath = table.Column<string>(name: "relative-path", type: "nvarchar(1024)", maxLength: 1024, nullable: false),
contenttype = table.Column<string>(name: "content-type", type: "nvarchar(100)", maxLength: 100, nullable: false),
createdat = table.Column<DateTime>(name: "created-at", type: "datetime2", nullable: false),
updatedat = table.Column<DateTime>(name: "updated-at", type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk-managed-thumbnail-map", x => x.id);
table.ForeignKey(
name: "FK_managed-thumbnail-map_managed-library-root_library-root-id",
column: x => x.libraryrootid,
principalTable: "managed-library-root",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
},
comment: "文件缩略图映射记录");
migrationBuilder.CreateIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id");
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-relative-path",
table: "managed-thumbnail-map",
column: "relative-path",
unique: true);
migrationBuilder.CreateIndex(
name: "idx-managed-thumbnail-map-root-id",
table: "managed-thumbnail-map",
column: "library-root-id");
migrationBuilder.AddForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record",
column: "thumbnail-id",
principalTable: "managed-thumbnail-map",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_managed-file-record_managed-thumbnail-map_thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropTable(
name: "managed-thumbnail-map");
migrationBuilder.DropIndex(
name: "idx-managed-file-record-thumbnail-id",
table: "managed-file-record");
migrationBuilder.DropColumn(
name: "thumbnail-id",
table: "managed-file-record");
migrationBuilder.AddColumn<string>(
name: "thumbnail-path",
table: "managed-file-record",
type: "nvarchar(512)",
maxLength: 512,
nullable: true);
}
}
}

View File

@ -1,4 +1,4 @@
// <auto-generated />
// <auto-generated />
using System;
using FileShare_EFCore.Database;
using Microsoft.EntityFrameworkCore;
@ -84,6 +84,234 @@ namespace FileShare_EFCore.Migrations.SqlServer
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("AbsolutePath")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)")
.HasColumnName("absolute-path");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<bool>("Exists")
.HasColumnType("bit")
.HasColumnName("exists");
b.Property<string>("Extension")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)")
.HasColumnName("extension");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)")
.HasColumnName("file-name");
b.Property<DateTime?>("LastPlayedAt")
.HasColumnType("datetime2")
.HasColumnName("last-played-at");
b.Property<DateTime>("LastSeenAt")
.HasColumnType("datetime2")
.HasColumnName("last-seen-at");
b.Property<DateTime>("LastWriteTimeUtc")
.HasColumnType("datetime2")
.HasColumnName("last-write-time-utc");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("MediaType")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)")
.HasColumnName("media-type");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("relative-path");
b.Property<long>("SizeBytes")
.HasColumnType("bigint")
.HasColumnName("size-bytes");
b.Property<int?>("ThumbnailId")
.HasColumnType("int")
.HasColumnName("thumbnail-id");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.Property<double?>("VideoDuration")
.HasColumnType("float")
.HasColumnName("video-duration");
b.HasKey("Id")
.HasName("pk-managed-file-record");
b.HasIndex("AbsolutePath")
.IsUnique()
.HasDatabaseName("idx-managed-file-record-absolute-path");
b.HasIndex("LastPlayedAt")
.HasDatabaseName("idx-managed-file-record-last-played-at");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-file-record-root-id");
b.HasIndex("ThumbnailId")
.HasDatabaseName("idx-managed-file-record-thumbnail-id");
b.HasIndex("MediaType", "Exists")
.HasDatabaseName("idx-managed-file-record-media-type-exists");
b.ToTable("managed-file-record", t =>
{
t.HasComment("文件库文件记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)")
.HasColumnName("display-name");
b.Property<bool>("IsAvailable")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true)
.HasColumnName("is-available");
b.Property<bool>("IsEnabled")
.HasColumnType("bit")
.HasColumnName("is-enabled");
b.Property<DateTime?>("LastScanCompletedAt")
.HasColumnType("datetime2")
.HasColumnName("last-scan-completed-at");
b.Property<string>("LastScanError")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasColumnName("last-scan-error");
b.Property<DateTime?>("LastScanStartedAt")
.HasColumnType("datetime2")
.HasColumnName("last-scan-started-at");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("path");
b.Property<int>("ScanIntervalMinutes")
.HasColumnType("int")
.HasColumnName("scan-interval-minutes");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-library-root");
b.HasIndex("Path")
.IsUnique()
.HasDatabaseName("idx-managed-library-root-path");
b.ToTable("managed-library-root", t =>
{
t.HasComment("文件库根目录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasColumnName("id");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)")
.HasColumnName("content-type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("created-at");
b.Property<int>("LibraryRootId")
.HasColumnType("int")
.HasColumnName("library-root-id");
b.Property<string>("RelativePath")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)")
.HasColumnName("relative-path");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("updated-at");
b.HasKey("Id")
.HasName("pk-managed-thumbnail-map");
b.HasIndex("LibraryRootId")
.HasDatabaseName("idx-managed-thumbnail-map-root-id");
b.HasIndex("RelativePath")
.IsUnique()
.HasDatabaseName("idx-managed-thumbnail-map-relative-path");
b.ToTable("managed-thumbnail-map", t =>
{
t.HasComment("文件缩略图映射记录");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.UserEntity", b =>
{
b.Property<int>("Id")
@ -181,6 +409,47 @@ namespace FileShare_EFCore.Migrations.SqlServer
t.HasComment("天气预报数据实体");
});
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedFileRecord", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Files")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FileShare_EFCore.Models.ManagedThumbnailMap", "Thumbnail")
.WithMany("Files")
.HasForeignKey("ThumbnailId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LibraryRoot");
b.Navigation("Thumbnail");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.HasOne("FileShare_EFCore.Models.ManagedLibraryRoot", "LibraryRoot")
.WithMany("Thumbnails")
.HasForeignKey("LibraryRootId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("LibraryRoot");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedLibraryRoot", b =>
{
b.Navigation("Files");
b.Navigation("Thumbnails");
});
modelBuilder.Entity("FileShare_EFCore.Models.ManagedThumbnailMap", b =>
{
b.Navigation("Files");
});
#pragma warning restore 612, 618
}
}

View File

@ -67,6 +67,18 @@ namespace FileShare_EFCore.Models
[Column("last-seen-at")]
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
/// <summary>视频缩略图路径(相对于 wwwroot。</summary>
[Column("thumbnail-id")]
public int? ThumbnailId { get; set; }
/// <summary>视频时长(秒)。</summary>
[Column("video-duration")]
public double? VideoDuration { get; set; }
/// <summary>最近一次播放时间 UTC。</summary>
[Column("last-played-at")]
public DateTime? LastPlayedAt { get; set; }
/// <summary>创建时间。</summary>
[Column("created-at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@ -77,5 +89,7 @@ namespace FileShare_EFCore.Models
/// <summary>所属根目录。</summary>
public ManagedLibraryRoot? LibraryRoot { get; set; }
public ManagedThumbnailMap? Thumbnail { get; set; }
}
}

View File

@ -62,5 +62,7 @@ namespace FileShare_EFCore.Models
/// <summary>文件记录。</summary>
public List<ManagedFileRecord> Files { get; set; } = new();
public List<ManagedThumbnailMap> Thumbnails { get; set; } = new();
}
}

View File

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FileShare_EFCore.Models
{
[Comment("文件缩略图映射记录")]
[Table("managed-thumbnail-map")]
public class ManagedThumbnailMap
{
[Key]
[Column("id")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public int Id { get; set; }
[Column("library-root-id")]
public int LibraryRootId { get; set; }
[Column("relative-path")]
[MaxLength(1024)]
public string RelativePath { get; set; } = string.Empty;
[Column("content-type")]
[MaxLength(100)]
public string ContentType { get; set; } = "image/jpeg";
[Column("created-at")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Column("updated-at")]
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ManagedLibraryRoot? LibraryRoot { get; set; }
public List<ManagedFileRecord> Files { get; set; } = new();
}
}

View File

@ -13,6 +13,8 @@
<Content Include="www\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\tools\ffmpeg\bin\ffmpeg.exe" Link="tools\ffmpeg\bin\ffmpeg.exe" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
<Content Include="..\tools\ffmpeg\bin\ffprobe.exe" Link="tools\ffmpeg\bin\ffprobe.exe" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>

View File

@ -12,6 +12,7 @@ using FileShare_Services.Services.FileLibrary;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
using System.IO;
namespace FileShare_PC
{
@ -71,9 +72,13 @@ namespace FileShare_PC
services.AddSingleton<PcGlobalTokenService>();
services.AddSingleton<IAuthService, PcAuthService>();
services.AddSingleton<IPcAuthEndpointService, PcAuthEndpointService>();
services.AddSingleton(new ThumbnailStorageOptions());
services.AddSingleton<IVideoThumbnailService>(sp =>
new VideoThumbnailService(sp.GetRequiredService<ThumbnailStorageOptions>()));
services.AddScoped<IFileLibraryService, FileLibraryService>();
services.AddScoped<IFileLibraryEndpointService, FileLibraryEndpointService>();
services.AddScoped<IFileStreamService, FileStreamService>();
services.AddScoped<IThumbnailStreamService, ThumbnailStreamService>();
// ---- 端点注册 ----
var endpointBuilder = new ServiceEndpointBuilder();

View File

@ -554,7 +554,8 @@ namespace FileShare_PC.Views
{
try
{
if (await TryHandleLocalMediaStreamAsync(context))
if (await TryHandleLocalMediaStreamAsync(context)
|| await TryHandleLocalThumbnailAsync(context))
{
return;
}
@ -691,6 +692,69 @@ namespace FileShare_PC.Views
return true;
}
private async Task<bool> TryHandleLocalThumbnailAsync(HttpListenerContext context)
{
var request = context.Request;
var response = context.Response;
var segments = (request.Url?.AbsolutePath ?? string.Empty)
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (segments.Length != 3
|| !string.Equals(segments[0], "api", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(segments[1], "thumbnails", StringComparison.OrdinalIgnoreCase))
{
return false;
}
AddLocalMediaHeaders(response);
if (!string.Equals(request.HttpMethod, "GET", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase))
{
response.StatusCode = (int)HttpStatusCode.MethodNotAllowed;
response.Close();
return true;
}
if (!int.TryParse(segments[2], out var id) || id <= 0)
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
using var scope = _services.CreateScope();
var thumbnailService = scope.ServiceProvider.GetService<IThumbnailStreamService>();
var thumbnail = thumbnailService is null ? null : await thumbnailService.GetThumbnailAsync(id);
if (thumbnail is null || !File.Exists(thumbnail.FilePath))
{
response.StatusCode = (int)HttpStatusCode.NotFound;
response.Close();
return true;
}
var fileInfo = new FileInfo(thumbnail.FilePath);
response.StatusCode = (int)HttpStatusCode.OK;
response.ContentType = thumbnail.ContentType;
response.ContentLength64 = fileInfo.Length;
response.Headers["Cache-Control"] = "public, max-age=3600";
response.Headers["Content-Disposition"] =
$"inline; filename=\"{Uri.EscapeDataString(thumbnail.FileName)}\"";
response.Headers["Last-Modified"] = thumbnail.LastModified.ToUniversalTime().ToString("R");
if (string.Equals(request.HttpMethod, "HEAD", StringComparison.OrdinalIgnoreCase)
|| fileInfo.Length == 0)
{
response.Close();
return true;
}
await using var input = File.Open(thumbnail.FilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
await input.CopyToAsync(response.OutputStream);
response.OutputStream.Close();
return true;
}
/// <summary>
/// 为媒体流响应添加 CORS 和 Range 请求头,允许浏览器跨域访问和分段请求。
/// </summary>

View File

@ -69,6 +69,14 @@ namespace FileShare_Services.Endpoints
.WithOpenApi("FileLibrary", "浏览文件库目录结构。")
.WithName("BrowseDirectory");
endpoints.MapGet<IFileLibraryEndpointService, RecentFilesRequest>("api/files/recent", (service, request, _) => service.GetRecentFilesAsync(request))
.WithOpenApi("FileLibrary", "获取最近添加或最近播放的文件。")
.WithName("GetRecentFiles");
endpoints.MapPost<IFileLibraryEndpointService, MarkFilePlayedRequest>("api/files/played", (service, request, _) => service.MarkFilePlayedAsync(request))
.WithOpenApi("FileLibrary", "标记文件已播放。")
.WithName("MarkFilePlayed");
endpoints.MapGet<IFileLibraryEndpointService, FileQueryRequest>("api/files/detail", (service, request, _) => service.GetFileAsync(request))
.WithOpenApi("FileLibrary", "查询文件详情。")
.WithName("GetFileDetail");

View File

@ -46,6 +46,19 @@ namespace FileShare_Services.Services.FileLibrary
public sealed record FileQueryRequest(
[property: JsonPropertyName("id")] int Id);
/// <summary>
/// 获取最近文件的请求。
/// </summary>
public sealed record RecentFilesRequest(
[property: JsonPropertyName("type")] string Type = "added",
[property: JsonPropertyName("count")] int Count = 12);
/// <summary>
/// 标记文件已播放的请求。
/// </summary>
public sealed record MarkFilePlayedRequest(
[property: JsonPropertyName("id")] int Id);
/// <summary>
/// 分页搜索已扫描文件的请求。
/// </summary>
@ -105,7 +118,10 @@ namespace FileShare_Services.Services.FileLibrary
string ContentType,
string StreamUrl,
string? TextUrl,
bool BrowserPlayable);
bool BrowserPlayable,
string? ThumbnailUrl = null,
double? VideoDuration = null,
DateTime? LastPlayedAt = null);
/// <summary>
/// 浏览文件库目录结构的请求。

View File

@ -29,7 +29,7 @@ namespace FileShare_Services.Services.FileLibrary
/// <inheritdoc />
public async Task<IApiResponse> AddRootAsync(AddLibraryRootRequest request)
{
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加并完成扫描。");
return ResponseHelper.Ok(await fileLibrary.AddRootAsync(request), "文件库目录已添加,后续扫描将自动入库。");
}
/// <inheritdoc />
@ -87,6 +87,20 @@ namespace FileShare_Services.Services.FileLibrary
return ResponseHelper.Ok(result);
}
/// <inheritdoc />
public async Task<IApiResponse> GetRecentFilesAsync(RecentFilesRequest request)
{
var items = await fileLibrary.GetRecentFilesAsync(request.Type, request.Count);
return ResponseHelper.Ok(items);
}
/// <inheritdoc />
public async Task<IApiResponse> MarkFilePlayedAsync(MarkFilePlayedRequest request)
{
await fileLibrary.MarkFilePlayedAsync(request.Id);
return ResponseHelper.Succeed();
}
/// <summary>
/// 验证文件 ID 是否有效,无效时抛出 <see cref="ArgumentException"/>。
/// </summary>

View File

@ -10,7 +10,7 @@ namespace FileShare_Services.Services.FileLibrary
/// <summary>
/// 文件库核心业务服务,实现磁盘枚举、目录管理、文件扫描与检索。
/// </summary>
public sealed class FileLibraryService(AppDataContext db) : IFileLibraryService
public sealed class FileLibraryService(AppDataContext db, IVideoThumbnailService thumbnailService) : IFileLibraryService
{
/// <summary>
/// 默认扫描间隔(分钟),当请求未指定间隔时使用。
@ -80,7 +80,9 @@ namespace FileShare_Services.Services.FileLibrary
existing.DisplayName = ResolveDisplayName(normalized, request.DisplayName);
existing.ScanIntervalMinutes = NormalizeInterval(request.ScanIntervalMinutes);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(existing.Id, cancellationToken);
var existingCount = await db.ManagedFileRecords
.CountAsync(file => file.LibraryRootId == existing.Id && file.Exists, cancellationToken);
return ToRootDto(existing, existingCount);
}
var root = new ManagedLibraryRoot
@ -95,7 +97,7 @@ namespace FileShare_Services.Services.FileLibrary
db.ManagedLibraryRoots.Add(root);
await db.SaveChangesAsync(cancellationToken);
return await ScanRootAsync(root.Id, cancellationToken);
return ToRootDto(root, 0);
}
/// <inheritdoc />
@ -178,6 +180,24 @@ namespace FileShare_Services.Services.FileLibrary
record.ContentType = contentType;
record.Exists = true;
record.LastSeenAt = DateTime.UtcNow;
if (mediaType == "video" && record.ThumbnailId is null)
{
var thumbnail = await thumbnailService.GenerateThumbnailAsync(root.Id, absolutePath, cancellationToken);
if (thumbnail is not null)
{
var map = new ManagedThumbnailMap
{
LibraryRootId = root.Id,
RelativePath = thumbnail.RelativePath,
ContentType = thumbnail.ContentType,
};
db.ManagedThumbnailMaps.Add(map);
record.Thumbnail = map;
}
record.VideoDuration ??= thumbnailService.GetVideoDuration(absolutePath);
}
}
foreach (var stale in existing.Values.Where(file => !seen.Contains(file.AbsolutePath)))
@ -356,6 +376,34 @@ namespace FileShare_Services.Services.FileLibrary
return new TextPreviewDto(file.Id, file.FileName, content, stream.Length > MaxTextPreviewBytes);
}
/// <inheritdoc />
public async Task<List<FileRecordDto>> GetRecentFilesAsync(string type, int count = 12, CancellationToken cancellationToken = default)
{
var query = db.ManagedFileRecords
.AsNoTracking()
.Where(file => file.Exists && file.LibraryRoot != null && file.LibraryRoot.IsAvailable);
query = type == "played"
? query.Where(file => file.LastPlayedAt != null).OrderByDescending(file => file.LastPlayedAt)
: query.OrderByDescending(file => file.CreatedAt);
return await query
.Take(Math.Clamp(count, 1, 48))
.Select(file => ToFileDto(file))
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public async Task MarkFilePlayedAsync(int id, CancellationToken cancellationToken = default)
{
var record = await db.ManagedFileRecords.FindAsync([id], cancellationToken);
if (record is not null)
{
record.LastPlayedAt = DateTime.UtcNow;
await db.SaveChangesAsync(cancellationToken);
}
}
/// <summary>
/// 深度优先遍历目录树,枚举所有被 <see cref="MediaFileTypes"/> 支持的媒体文件路径。
/// 遇到无权限的目录时跳过该分支继续遍历。
@ -508,7 +556,10 @@ namespace FileShare_Services.Services.FileLibrary
file.ContentType,
$"/api/files/{file.Id}/stream",
file.MediaType == "text" ? $"/api/files/text?id={file.Id}" : null,
MediaFileTypes.IsBrowserPlayable(file.Extension));
MediaFileTypes.IsBrowserPlayable(file.Extension),
file.ThumbnailId is null ? null : $"/api/thumbnails/{file.ThumbnailId}",
file.VideoDuration,
file.LastPlayedAt);
}
}

View File

@ -84,5 +84,9 @@ namespace FileShare_Services.Services.FileLibrary
/// <param name="request">包含根目录 ID 和路径的请求。</param>
/// <returns>API 响应。</returns>
Task<IApiResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request);
Task<IApiResponse> GetRecentFilesAsync(RecentFilesRequest request);
Task<IApiResponse> MarkFilePlayedAsync(MarkFilePlayedRequest request);
}
}

View File

@ -97,5 +97,9 @@ namespace FileShare_Services.Services.FileLibrary
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>目录浏览响应,包含子目录和文件列表。</returns>
Task<BrowseDirectoryResponse> BrowseDirectoryAsync(BrowseDirectoryRequest request, CancellationToken cancellationToken = default);
Task<List<FileRecordDto>> GetRecentFilesAsync(string type, int count = 12, CancellationToken cancellationToken = default);
Task MarkFilePlayedAsync(int id, CancellationToken cancellationToken = default);
}
}

View File

@ -0,0 +1,11 @@
namespace FileShare_Services.Services.FileLibrary
{
public interface IVideoThumbnailService
{
Task<GeneratedThumbnail?> GenerateThumbnailAsync(int libraryRootId, string videoPath, CancellationToken ct = default);
string GetAbsolutePath(string relativePath);
double? GetVideoDuration(string videoPath);
}
public sealed record GeneratedThumbnail(string RelativePath, string ContentType);
}

View File

@ -0,0 +1,9 @@
namespace FileShare_Services.Services.FileLibrary
{
public sealed class ThumbnailStorageOptions
{
public string RootPath { get; set; } = Path.Combine(AppContext.BaseDirectory, "thumbnails");
public string FfmpegPath { get; set; } = Path.Combine("tools", "ffmpeg", "bin", "ffmpeg.exe");
public string FfprobePath { get; set; } = Path.Combine("tools", "ffmpeg", "bin", "ffprobe.exe");
}
}

View File

@ -0,0 +1,38 @@
using FileShare_EFCore.Database;
using FileShare_Services.Core;
using Microsoft.EntityFrameworkCore;
namespace FileShare_Services.Services.FileLibrary
{
public interface IThumbnailStreamService
{
Task<FileStreamResponse?> GetThumbnailAsync(int id, CancellationToken cancellationToken = default);
}
public sealed class ThumbnailStreamService(AppDataContext db, IVideoThumbnailService thumbnails) : IThumbnailStreamService
{
public async Task<FileStreamResponse?> GetThumbnailAsync(int id, CancellationToken cancellationToken = default)
{
var thumbnail = await db.ManagedThumbnailMaps
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == id, cancellationToken);
if (thumbnail is null)
{
return null;
}
var absolutePath = thumbnails.GetAbsolutePath(thumbnail.RelativePath);
if (!File.Exists(absolutePath))
{
return null;
}
return new FileStreamResponse(
absolutePath,
Path.GetFileName(absolutePath),
thumbnail.ContentType,
File.GetLastWriteTimeUtc(absolutePath));
}
}
}

View File

@ -0,0 +1,136 @@
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
namespace FileShare_Services.Services.FileLibrary
{
public sealed class VideoThumbnailService : IVideoThumbnailService
{
private readonly string _thumbnailDir;
private readonly string _ffmpegPath;
private readonly string _ffprobePath;
public VideoThumbnailService(ThumbnailStorageOptions options)
{
_thumbnailDir = Path.IsPathRooted(options.RootPath)
? options.RootPath
: Path.Combine(AppContext.BaseDirectory, options.RootPath);
_ffmpegPath = ResolveExecutablePath(options.FfmpegPath);
_ffprobePath = ResolveExecutablePath(options.FfprobePath);
Directory.CreateDirectory(_thumbnailDir);
}
public Task<GeneratedThumbnail?> GenerateThumbnailAsync(int libraryRootId, string videoPath, CancellationToken ct = default)
{
var hash = ComputeHash(videoPath);
var relativePath = Path.Combine($"root-{libraryRootId}", $"{hash}.jpg");
var outputPath = GetAbsolutePath(relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
if (File.Exists(outputPath))
return Task.FromResult<GeneratedThumbnail?>(new GeneratedThumbnail(relativePath, "image/jpeg"));
try
{
var args = $"-ss 00:00:01 -i \"{videoPath}\" -vframes 1 -q:v 5 -vf \"scale=320:-2\" \"{outputPath}\" -y";
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _ffmpegPath,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
},
EnableRaisingEvents = true,
};
process.Start();
using (ct.Register(() =>
{
try { process.Kill(); } catch { /* 进程可能已退出 */ }
}))
{
process.WaitForExit(10000);
}
if (File.Exists(outputPath) && new FileInfo(outputPath).Length > 0)
return Task.FromResult<GeneratedThumbnail?>(new GeneratedThumbnail(relativePath, "image/jpeg"));
return Task.FromResult<GeneratedThumbnail?>(null);
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "生成视频缩略图失败 {VideoPath}", videoPath);
return Task.FromResult<GeneratedThumbnail?>(null);
}
}
public string GetAbsolutePath(string relativePath)
{
var root = Path.GetFullPath(_thumbnailDir);
var fullPath = Path.GetFullPath(Path.Combine(root, relativePath));
if (!fullPath.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Thumbnail path is outside of the configured storage root.");
}
return fullPath;
}
private static string ResolveExecutablePath(string executablePath)
{
if (Path.IsPathRooted(executablePath))
{
return executablePath;
}
return executablePath.Contains(Path.DirectorySeparatorChar)
|| executablePath.Contains(Path.AltDirectorySeparatorChar)
? Path.Combine(AppContext.BaseDirectory, executablePath)
: executablePath;
}
public double? GetVideoDuration(string videoPath)
{
try
{
var args = $"-v error -show_entries format=duration -of csv=p=0 \"{videoPath}\"";
using var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _ffprobePath,
Arguments = args,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
},
};
process.Start();
var output = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit(5000);
if (double.TryParse(output, out var duration) && duration > 0)
return Math.Round(duration, 1);
return null;
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "获取视频时长失败 {VideoPath}", videoPath);
return null;
}
}
public string ComputeHash(string videoPath) =>
Convert.ToHexString(MD5.HashData(Encoding.UTF8.GetBytes(videoPath))).ToLowerInvariant();
}
}

View File

@ -43,6 +43,9 @@ export interface FileRecordDto {
streamUrl: string
textUrl: string | null
browserPlayable: boolean
thumbnailUrl: string | null
videoDuration: number | null
lastPlayedAt: string | null
}
export interface BrowseDirectoryResponse {
@ -98,5 +101,10 @@ export const api = {
getTextPreview: (id: number) =>
request<TextPreviewDto>(`files/text${qs({ id })}`),
mediaUrl: (path: string) => apiUrl(path),
thumbnailUrl: (path: string) => apiUrl(path),
getRecentFiles: (type: string, count = 12) =>
request<FileRecordDto[]>(`files/recent${qs({ type, count })}`),
markFilePlayed: (id: number) =>
request('files/played', { method: 'POST', body: { id } }),
qrCode: () => request<{ url: string; qrCodeBase64: string }>('qrcode'),
}

View File

@ -498,19 +498,6 @@ a {
font-weight: 800;
}
.player-panel video {
width: 100%;
max-height: 54vh;
margin-top: 12px;
border-radius: 10px;
background: #111827;
}
.player-panel audio {
width: 100%;
margin-top: 18px;
}
.open-media-link {
display: block;
width: 100%;
@ -524,19 +511,6 @@ a {
font-weight: 800;
}
.player-panel pre {
overflow: auto;
max-height: 54vh;
margin: 12px 0 0;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
background: #f8fafc;
color: #111827;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.mobile-list {
display: grid;
gap: 9px;
@ -724,6 +698,257 @@ a {
background: var(--panel);
}
/* Root tabs */
.root-tabs {
display: flex;
gap: 4px;
margin-bottom: 14px;
border: 1px solid var(--line);
border-radius: 12px;
padding: 4px;
background: var(--panel);
}
.root-tabs button {
flex: 1;
min-height: 36px;
border: none;
border-radius: 10px;
font-weight: 700;
font-size: 14px;
color: var(--muted);
background: transparent;
}
.root-tabs button.active {
color: #fff;
background: var(--accent);
}
/* View toggle */
.view-toggle-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.view-toggle {
display: flex;
gap: 0;
border: 1px solid var(--line);
border-radius: 8px;
overflow: hidden;
}
.view-toggle button {
min-height: 30px;
border: none;
border-radius: 0;
padding: 4px 14px;
font-size: 13px;
font-weight: 700;
color: var(--muted);
background: transparent;
}
.view-toggle button.active {
color: #fff;
background: var(--accent);
}
.view-toggle button + button {
border-left: 1px solid var(--line);
}
/* Section header with view toggle inline */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-header h3 {
margin: 0;
font-size: 15px;
font-weight: 800;
color: var(--muted);
}
/* File grid / card view */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 10px;
border: 1px solid var(--line);
border-radius: 14px;
padding: 10px;
background: var(--panel);
}
.file-card {
display: grid;
grid-template-rows: auto 1fr;
width: 100%;
min-height: 0;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0;
overflow: hidden;
text-align: left;
background: #fff;
}
.file-card.active {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent);
}
.file-card:hover:not(:disabled) {
border-color: var(--accent);
}
.card-thumb {
display: block;
width: 100%;
aspect-ratio: 16 / 10;
object-fit: contain;
background: #111827;
}
.card-thumb-placeholder {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 16 / 10;
border-radius: 999px;
margin: 12px auto 0;
width: 44px;
height: 44px;
color: #fff;
background: var(--accent-strong);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
}
.card-body {
display: grid;
align-content: start;
gap: 3px;
padding: 10px 10px 12px;
min-width: 0;
}
.card-body strong {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 14px;
font-weight: 700;
line-height: 1.3;
}
.card-body small {
color: var(--muted);
font-size: 12px;
}
/* File thumbnail in list view */
.file-thumb {
flex: 0 0 72px;
width: 72px;
height: 48px;
border-radius: 6px;
object-fit: contain;
background: #111827;
}
.file-list .mobile-file {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
width: 100%;
min-height: 60px;
padding: 8px 10px;
text-align: left;
}
.file-list .mobile-file > span:last-child {
display: grid;
min-width: 0;
}
/* Video info bar */
.video-info-bar {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
padding: 8px 0;
font-size: 13px;
color: var(--muted);
}
.video-info-bar span {
border-radius: 999px;
padding: 3px 9px;
background: #f2f4f7;
font-weight: 600;
}
/* Player media wrapper */
.player-media-wrapper {
margin-top: 12px;
}
.player-media-wrapper video {
width: 100%;
max-height: 54vh;
border-radius: 10px;
background: #111827;
}
.player-media-wrapper audio {
width: 100%;
margin-top: 6px;
}
.player-media-wrapper pre {
overflow: auto;
max-height: 54vh;
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
background: #f8fafc;
color: #111827;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.unsupported-player {
text-align: center;
}
.unsupported-thumb {
display: block;
width: 100%;
max-width: 360px;
margin: 0 auto 12px;
border-radius: 10px;
aspect-ratio: 16 / 9;
object-fit: contain;
background: #111827;
}
/* Recent files section */
.recent-files {
margin-top: 4px;
}
@media (max-width: 1100px) {
.admin-layout,
.admin-browser {

View File

@ -15,6 +15,12 @@ const rootId = ref<number | undefined>()
const browsePath = ref<string[]>([])
const isBrowsingRoots = ref(true)
// Recent files & tabs
const activeTab = ref<'recent-added' | 'recent-played' | 'libraries'>('libraries')
const recentFiles = ref<FileRecordDto[]>([])
const recentLoading = ref(false)
const viewMode = ref<'list' | 'grid'>('list')
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
const activeRoots = computed(() => roots.value.filter((root) => root.isEnabled && root.isAvailable))
@ -34,8 +40,13 @@ const breadcrumbs = computed(() => {
})
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
const selectedThumbnailUrl = computed(() =>
selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : ''
)
const clientTitle = computed(() => {
if (activeTab.value === 'recent-added') return '最近添加'
if (activeTab.value === 'recent-played') return '最近播放'
if (isBrowsingRoots.value) return '文件库'
const root = roots.value.find((r) => r.id === rootId.value)
const dir = browsePath.value.length > 0 ? browsePath.value[browsePath.value.length - 1] : ''
@ -54,6 +65,13 @@ function formatSize(bytes: number) {
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`
}
function formatDuration(seconds: number | null) {
if (!seconds) return ''
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function formatDate(value: string | null) {
if (!value) return ''
return new Date(value).toLocaleString()
@ -80,12 +98,35 @@ async function browseDirectory() {
}
}
async function loadRecentFiles(type: string) {
try {
errorMessage.value = ''
recentLoading.value = true
recentFiles.value = await api.getRecentFiles(type, 24)
} catch (error) {
setError(error)
} finally {
recentLoading.value = false
}
}
function switchTab(tab: 'recent-added' | 'recent-played' | 'libraries') {
activeTab.value = tab
isBrowsingRoots.value = true
browseData.value = null
selectedFile.value = null
textPreview.value = null
if (tab === 'recent-added') loadRecentFiles('added')
else if (tab === 'recent-played') loadRecentFiles('played')
}
async function enterRoot(id: number) {
rootId.value = id
isBrowsingRoots.value = false
browsePath.value = []
selectedFile.value = null
textPreview.value = null
activeTab.value = 'libraries'
await browseDirectory()
}
@ -96,6 +137,7 @@ function backToRoots() {
browsePath.value = []
selectedFile.value = null
textPreview.value = null
activeTab.value = 'libraries'
}
async function navigateTo(path: string) {
@ -119,6 +161,11 @@ async function enterSubdirectory(name: string) {
async function selectFile(file: FileRecordDto) {
selectedFile.value = file
textPreview.value = null
if (file.mediaType === 'video' || file.mediaType === 'audio') {
api.markFilePlayed(file.id).catch(() => {})
}
if (file.mediaType !== 'text') return
try {
@ -146,7 +193,9 @@ onMounted(async () => {
<div>
<h1>{{ clientTitle }}</h1>
<p>
<template v-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
<template v-if="activeTab === 'recent-added'">{{ recentFiles.length }} 个文件</template>
<template v-else-if="activeTab === 'recent-played'">{{ recentFiles.length }} 个文件</template>
<template v-else-if="isBrowsingRoots">{{ activeRoots.length }} 个目录</template>
<template v-else-if="browseData">
{{ browseData.subdirectories.length }} 个文件夹 · {{ browseData.files.length }} 个文件
</template>
@ -161,8 +210,99 @@ onMounted(async () => {
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<!-- Root tabs -->
<nav v-if="isBrowsingRoots" class="root-tabs">
<button
type="button"
:class="{ active: activeTab === 'recent-added' }"
@click="switchTab('recent-added')"
>最近添加</button>
<button
type="button"
:class="{ active: activeTab === 'recent-played' }"
@click="switchTab('recent-played')"
>最近播放</button>
<button
type="button"
:class="{ active: activeTab === 'libraries' }"
@click="switchTab('libraries')"
>文件库</button>
</nav>
<!-- Recently added / played files -->
<section v-if="isBrowsingRoots && activeTab !== 'libraries'" class="recent-files">
<div class="view-toggle-bar">
<div></div>
<div class="view-toggle">
<button type="button" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">列表</button>
<button type="button" :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'">网格</button>
</div>
</div>
<p v-if="recentLoading" class="empty-state">加载中...</p>
<p v-else-if="recentFiles.length === 0" class="empty-state">
{{ activeTab === 'recent-added' ? '暂无最近添加的文件' : '暂无播放记录' }}
</p>
<!-- Grid view -->
<div v-else-if="viewMode === 'grid'" class="file-grid">
<button
v-for="file in recentFiles"
:key="file.id"
class="file-card"
type="button"
@click="selectFile(file)"
>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="card-thumb"
alt=""
loading="lazy"
/>
<div v-else class="card-thumb-placeholder">{{ file.mediaType }}</div>
<div class="card-body">
<strong>{{ file.fileName }}</strong>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
</small>
</div>
</button>
</div>
<!-- List view -->
<div v-else class="file-list">
<button
v-for="file in recentFiles"
:key="file.id"
class="mobile-file"
@click="selectFile(file)"
>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="file-thumb"
alt=""
loading="lazy"
/>
<span v-else class="type-badge">{{ file.mediaType }}</span>
<span>
<strong>{{ file.fileName }}</strong>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
<template v-if="file.lastPlayedAt && activeTab === 'recent-played'">
· {{ formatDate(file.lastPlayedAt) }}
</template>
</small>
</span>
</button>
</div>
</section>
<!-- Library root tiles -->
<section v-if="isBrowsingRoots" class="root-picker">
<section v-if="isBrowsingRoots && activeTab === 'libraries'" class="root-picker">
<button
v-for="root in activeRoots"
:key="root.id"
@ -178,7 +318,7 @@ onMounted(async () => {
</section>
<!-- Directory browser -->
<template v-else>
<template v-else-if="!isBrowsingRoots">
<!-- Breadcrumb -->
<nav class="breadcrumb-nav">
<template v-for="(crumb, index) in breadcrumbs" :key="crumb.path">
@ -222,26 +362,46 @@ onMounted(async () => {
<span>{{ selectedFile.extension }}</span>
</div>
<video
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
playsinline
webkit-playsinline
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</video>
<audio
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</audio>
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
<p v-else class="unsupported">浏览器不支持在线播放此格式</p>
<div v-if="selectedFile.mediaType === 'video'" class="video-info-bar">
<span v-if="selectedFile.videoDuration">时长 {{ formatDuration(selectedFile.videoDuration) }}</span>
<span>{{ formatSize(selectedFile.sizeBytes) }}</span>
<span>{{ selectedFile.contentType }}</span>
</div>
<div class="player-media-wrapper">
<video
v-if="selectedFile.mediaType === 'video' && selectedFile.browserPlayable"
:key="selectedFile.id"
:poster="selectedThumbnailUrl || undefined"
controls
playsinline
webkit-playsinline
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</video>
<div
v-else-if="selectedFile.mediaType === 'video' && !selectedFile.browserPlayable"
class="unsupported-player"
>
<img
v-if="selectedThumbnailUrl"
:src="selectedThumbnailUrl"
class="unsupported-thumb"
alt=""
/>
<p class="unsupported">浏览器不支持在线播放此格式</p>
</div>
<audio
v-else-if="selectedFile.mediaType === 'audio' && selectedFile.browserPlayable"
:key="selectedFile.id"
controls
preload="metadata"
>
<source :src="selectedMediaUrl" :type="selectedFile.contentType" />
</audio>
<pre v-else-if="selectedFile.mediaType === 'text'">{{ textPreview?.content ?? '加载中...' }}</pre>
</div>
<a
v-if="selectedFile.mediaType !== 'text'"
class="open-media-link"
@ -256,8 +416,44 @@ onMounted(async () => {
<!-- Files -->
<section v-if="browseData.files.length > 0" class="browse-section">
<h3>文件</h3>
<div class="file-list">
<div class="section-header">
<h3>文件</h3>
<div class="view-toggle">
<button type="button" :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">列表</button>
<button type="button" :class="{ active: viewMode === 'grid' }" @click="viewMode = 'grid'">网格</button>
</div>
</div>
<!-- Grid view -->
<div v-if="viewMode === 'grid'" class="file-grid">
<button
v-for="file in browseData.files"
:key="file.id"
class="file-card"
:class="{ active: selectedFile?.id === file.id }"
type="button"
@click="selectFile(file)"
>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="card-thumb"
alt=""
loading="lazy"
/>
<div v-else class="card-thumb-placeholder">{{ file.mediaType }}</div>
<div class="card-body">
<strong>{{ file.fileName }}</strong>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
</small>
</div>
</button>
</div>
<!-- List view -->
<div v-else class="file-list">
<button
v-for="file in browseData.files"
:key="file.id"
@ -266,10 +462,20 @@ onMounted(async () => {
type="button"
@click="selectFile(file)"
>
<span class="type-badge">{{ file.mediaType }}</span>
<img
v-if="file.thumbnailUrl"
:src="api.thumbnailUrl(file.thumbnailUrl)"
class="file-thumb"
alt=""
loading="lazy"
/>
<span v-else class="type-badge">{{ file.mediaType }}</span>
<span>
<strong>{{ file.fileName }}</strong>
<small>{{ formatSize(file.sizeBytes) }} · {{ formatDate(file.lastWriteTimeUtc) }}</small>
<small>
{{ formatSize(file.sizeBytes) }}
<template v-if="file.videoDuration"> · {{ formatDuration(file.videoDuration) }}</template>
</small>
</span>
</button>
</div>

BIN
tools/ffmpeg/bin/ffmpeg.exe Normal file

Binary file not shown.

Binary file not shown.