diff --git a/Pages/Folder.cshtml b/Pages/Folder.cshtml index 7ec3a11..df3df21 100644 --- a/Pages/Folder.cshtml +++ b/Pages/Folder.cshtml @@ -17,7 +17,7 @@
@foreach (var tag in Model.Keywords) { - @tag + @tag }
} diff --git a/Pages/Folder.cshtml.cs b/Pages/Folder.cshtml.cs index 4b1a7d3..e4cd854 100644 --- a/Pages/Folder.cshtml.cs +++ b/Pages/Folder.cshtml.cs @@ -23,18 +23,20 @@ public FanboxFolder? Folder { get; private set; } public IReadOnlyList Keywords { get; private set; } = Array.Empty(); public IReadOnlyList Images { get; private set; } = Array.Empty(); + public string? Author { get; private set; } public string? Keyword { get; private set; } public int? ReturnPage { get; private set; } public string BackUrl => - Url.Page("Index", null, new { keyword = Keyword, p = ReturnPage }) ?? Url.Page("Index") ?? "/"; + Url.Page("Gallery", null, new { author = Author, keyword = Keyword, p = ReturnPage }) ?? Url.Page("Index") ?? "/"; /// /// 加载指定文件夹详情与图片列表。 /// - public async Task OnGetAsync(Guid id, string? keyword, int? p) + public async Task OnGetAsync(Guid id, string? author, string? keyword, int? p) { // 归一化查询参数,便于返回时保持状态。 + Author = string.IsNullOrWhiteSpace(author) ? null : author.Trim(); Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); ReturnPage = p is > 1 ? p : null; @@ -45,6 +47,7 @@ return; } + Author = Folder.Author; if (!string.IsNullOrWhiteSpace(Folder.Keywords)) { // 展示用关键词列表。 @@ -58,7 +61,7 @@ return; } - var folderPath = Path.Combine(_rootPath, Folder.FolderName); + var folderPath = Path.Combine(_rootPath, Folder.Author, Folder.FolderName); if (!Directory.Exists(folderPath)) { return; @@ -100,7 +103,7 @@ if (folder is null) return NotFound(); - var filePath = Path.Combine(_rootPath, folder.FolderName, safeFile); + var filePath = Path.Combine(_rootPath, folder.Author, folder.FolderName, safeFile); if (!System.IO.File.Exists(filePath)) return NotFound(); @@ -139,7 +142,7 @@ if (!System.IO.File.Exists(thumbPath)) { // 缩略图不存在时尝试生成。 - await _thumbnailService.EnsureThumbnailAsync(folder.Id, folder.FolderName, safeFile, HttpContext.RequestAborted); + await _thumbnailService.EnsureThumbnailAsync(folder.Id, folder.Author, folder.FolderName, safeFile, HttpContext.RequestAborted); } if (!System.IO.File.Exists(thumbPath)) diff --git a/Pages/Gallery.cshtml b/Pages/Gallery.cshtml new file mode 100644 index 0000000..c27fde6 --- /dev/null +++ b/Pages/Gallery.cshtml @@ -0,0 +1,166 @@ +@page +@model GalleryModel +@{ + ViewData["Title"] = string.IsNullOrWhiteSpace(Model.Author) ? "Gallery" : $"{Model.Author} Gallery"; +} + +
+ Authors + +
+ +
+ +
+
+ + Clear +
+
+
+ +@if (string.IsNullOrWhiteSpace(Model.Author)) +{ +
Select an author to view folders.
+} +else +{ +
+ @if (Model.Folders.Count == 0) + { +
+
No records yet.
+
+ } + else + { + foreach (var card in Model.Folders) + { + var item = card.Folder; + + } + } +
+} + +@if (Model.TotalPages > 1 && !string.IsNullOrWhiteSpace(Model.Author)) +{ + +} + +
+
+
+ +
Please wait, generating thumbnails...
+
+
+
+ +@section Scripts { + +} diff --git a/Pages/Gallery.cshtml.cs b/Pages/Gallery.cshtml.cs new file mode 100644 index 0000000..f2c7b66 --- /dev/null +++ b/Pages/Gallery.cshtml.cs @@ -0,0 +1,159 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; +using Sakayaki.Models; +using Sakayaki.Services; + +namespace Sakayaki.Pages; + +public class GalleryModel(ThumbnailService thumbnailService, AppDbContext dbContext, IConfiguration configuration) : PageModel +{ + private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp" + }; + + private readonly ThumbnailService _thumbnailService = thumbnailService; + private readonly AppDbContext _dbContext = dbContext; + private readonly string? _rootPath = configuration["Fanbox:RootPath"]; + + public sealed class FolderCard + { + public required FanboxFolder Folder { get; init; } + public string? CoverUrl { get; init; } + public string? CoverFileName { get; init; } + } + + public IReadOnlyList Folders { get; private set; } = Array.Empty(); + public string? Keyword { get; private set; } + public string? Author { get; private set; } + public int PageNumber { get; private set; } = 1; + public int TotalPages { get; private set; } = 1; + public int PageSize { get; } = 30; + + /// + /// 加载作者目录列表与分页数据。 + /// + public async Task OnGetAsync(string? author, string? keyword, int p = 1) + { + // 归一化检索关键词与页码。 + Author = string.IsNullOrWhiteSpace(author) ? null : author.Trim(); + Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); + PageNumber = Math.Max(1, p); + + if (string.IsNullOrWhiteSpace(Author)) + { + Folders = Array.Empty(); + TotalPages = 1; + return; + } + + var query = _dbContext.FanboxFolders.AsNoTracking() + .Where(x => x.Author == Author); + if (!string.IsNullOrWhiteSpace(Keyword)) + { + // 关键词模糊匹配数据库字段。 + var pattern = $"%{Keyword}%"; + query = query.Where(x => x.Keywords != null && EF.Functions.Like(x.Keywords, pattern)); + } + + var totalCount = await query.CountAsync(); + TotalPages = Math.Max(1, (int)Math.Ceiling(totalCount / (double)PageSize)); + if (PageNumber > TotalPages) + { + PageNumber = TotalPages; + } + + var folders = await query + .OrderByDescending(x => x.Date) + .ThenByDescending(x => x.CreatedAt) + .Skip((PageNumber - 1) * PageSize) + .Take(PageSize) + .ToListAsync(); + + var cards = new List(folders.Count); + foreach (var folder in folders) + { + string? coverFile = null; + string? coverUrl = null; + + if (!string.IsNullOrWhiteSpace(_rootPath)) + { + var folderPath = Path.Combine(_rootPath, folder.Author, folder.FolderName); + if (Directory.Exists(folderPath)) + { + // 优先以 0.* 图片作为封面。 + coverFile = Directory.EnumerateFiles(folderPath) + .Select(Path.GetFileName) + .FirstOrDefault(name => + !string.IsNullOrWhiteSpace(name) && + name.StartsWith("0.", StringComparison.OrdinalIgnoreCase) && + ImageExtensions.Contains(Path.GetExtension(name))); + } + } + + if (!string.IsNullOrWhiteSpace(coverFile)) + { + coverUrl = Url.Page("Gallery", "Image", new { id = folder.Id, file = coverFile }); + } + + cards.Add(new FolderCard + { + Folder = folder, + CoverUrl = coverUrl, + CoverFileName = coverFile + }); + } + + Folders = cards; + } + + /// + /// 为指定文件夹生成缺失缩略图并返回数量。 + /// + public async Task OnGetEnsureThumbnailsAsync(Guid id) + { + var folder = await _dbContext.FanboxFolders.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + if (folder is null) + { + return NotFound(); + } + + var created = await _thumbnailService.EnsureFolderThumbnailsAsync(folder.Id, folder.Author, folder.FolderName, HttpContext.RequestAborted); + return new JsonResult(new { created }); + } + + /// + /// 返回指定图片的原图文件。 + /// + public async Task OnGetImageAsync(Guid id, string file) + { + if (string.IsNullOrWhiteSpace(_rootPath) || string.IsNullOrWhiteSpace(file)) + return NotFound(); + + // 仅允许安全文件名与图片扩展名。 + var safeFile = Path.GetFileName(file); + if (string.IsNullOrWhiteSpace(safeFile)) + return NotFound(); + if (!ImageExtensions.Contains(Path.GetExtension(safeFile))) + return NotFound(); + + var folder = await _dbContext.FanboxFolders.AsNoTracking() + .FirstOrDefaultAsync(x => x.Id == id); + if (folder is null) + return NotFound(); + + var filePath = Path.Combine(_rootPath, folder.Author, folder.FolderName, safeFile); + if (!System.IO.File.Exists(filePath)) + return NotFound(); + + // 根据扩展名获取 Content-Type。 + var provider = new FileExtensionContentTypeProvider(); + if (!provider.TryGetContentType(filePath, out var contentType)) + contentType = "application/octet-stream"; + + return PhysicalFile(filePath, contentType); + } +} diff --git a/Pages/Index.cshtml b/Pages/Index.cshtml index ea27846..352f8f0 100644 --- a/Pages/Index.cshtml +++ b/Pages/Index.cshtml @@ -1,28 +1,13 @@ @page @model IndexModel @{ - ViewData["Title"] = "Gallery"; + ViewData["Title"] = "Authors"; }
- -
-
- -
-
- - Clear -
-
@if (!string.IsNullOrWhiteSpace(Model.StatusMessage)) @@ -31,31 +16,26 @@ }
- @if (Model.Folders.Count == 0) + @if (Model.Authors.Count == 0) {
-
No records yet.
+
No authors yet.
} else { - foreach (var card in Model.Folders) + foreach (var author in Model.Authors) { - var item = card.Folder; - -@if (Model.TotalPages > 1) -{ - -} - -
-
-
- -
Please wait, generating thumbnails...
-
-
-
- -@section Scripts { - -} diff --git a/Pages/Index.cshtml.cs b/Pages/Index.cshtml.cs index cde49a3..6f8128d 100644 --- a/Pages/Index.cshtml.cs +++ b/Pages/Index.cshtml.cs @@ -1,107 +1,47 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; -using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; -using Sakayaki.Models; using Sakayaki.Services; namespace Sakayaki.Pages; -public class IndexModel(SyncService syncService, ThumbnailService thumbnailService, AppDbContext dbContext, IConfiguration configuration) : PageModel +public class IndexModel(SyncService syncService, AppDbContext dbContext, IConfiguration configuration) : PageModel { - private static readonly HashSet ImageExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp" - }; - private readonly SyncService _syncService = syncService; - private readonly ThumbnailService _thumbnailService = thumbnailService; private readonly AppDbContext _dbContext = dbContext; private readonly string? _rootPath = configuration["Fanbox:RootPath"]; - private readonly string? _author = configuration["Fanbox:Author"]; [TempData] public string? StatusMessage { get; set; } - public sealed class FolderCard + public sealed class AuthorCard { - public required FanboxFolder Folder { get; init; } - public string? CoverUrl { get; init; } - public string? CoverFileName { get; init; } + public required string Author { get; init; } + public int FolderCount { get; init; } + public DateTime? LatestDate { get; init; } } - public IReadOnlyList Folders { get; private set; } = Array.Empty(); - public string? Keyword { get; private set; } - public int PageNumber { get; private set; } = 1; - public int TotalPages { get; private set; } = 1; - public int PageSize { get; } = 30; + public IReadOnlyList Authors { get; private set; } = Array.Empty(); /// - /// 加载首页列表与分页数据。 + /// 加载作者列表。 /// - public async Task OnGetAsync(string? keyword, int p = 1) + public async Task OnGetAsync() { - // 归一化检索关键词与页码。 - Keyword = string.IsNullOrWhiteSpace(keyword) ? null : keyword.Trim(); - PageNumber = Math.Max(1, p); - - var query = _dbContext.FanboxFolders.AsNoTracking(); - if (!string.IsNullOrWhiteSpace(Keyword)) - { - // 关键词模糊匹配数据库字段。 - var pattern = $"%{Keyword}%"; - query = query.Where(x => x.Keywords != null && EF.Functions.Like(x.Keywords, pattern)); - } - - var totalCount = await query.CountAsync(); - TotalPages = Math.Max(1, (int)Math.Ceiling(totalCount / (double)PageSize)); - if (PageNumber > TotalPages) - { - PageNumber = TotalPages; - } - - var folders = await query - .OrderByDescending(x => x.Date) - .ThenByDescending(x => x.CreatedAt) - .Skip((PageNumber - 1) * PageSize) - .Take(PageSize) + var authors = await _dbContext.FanboxFolders.AsNoTracking() + .Where(x => x.Author != null && x.Author != string.Empty) + .GroupBy(x => x.Author) + .Select(group => new AuthorCard + { + Author = group.Key!, + FolderCount = group.Count(), + LatestDate = group.Max(x => (DateTime?)x.Date) + }) + .OrderByDescending(x => x.LatestDate) + .ThenBy(x => x.Author) .ToListAsync(); - var cards = new List(folders.Count); - foreach (var folder in folders) - { - string? coverFile = null; - string? coverUrl = null; - - if (!string.IsNullOrWhiteSpace(_rootPath)) - { - var folderPath = Path.Combine(_rootPath, folder.FolderName); - if (Directory.Exists(folderPath)) - { - // 优先以 0.* 图片作为封面。 - coverFile = Directory.EnumerateFiles(folderPath) - .Select(Path.GetFileName) - .FirstOrDefault(name => - !string.IsNullOrWhiteSpace(name) && - name.StartsWith("0.", StringComparison.OrdinalIgnoreCase) && - ImageExtensions.Contains(Path.GetExtension(name))); - } - } - - if (!string.IsNullOrWhiteSpace(coverFile)) - { - coverUrl = Url.Page("Index", "Image", new { id = folder.Id, file = coverFile }); - } - - cards.Add(new FolderCard - { - Folder = folder, - CoverUrl = coverUrl, - CoverFileName = coverFile - }); - } - - Folders = cards; + Authors = authors; } /// @@ -109,63 +49,15 @@ /// public async Task OnPostUpdateAsync() { - if (string.IsNullOrWhiteSpace(_rootPath) || string.IsNullOrWhiteSpace(_author)) + if (string.IsNullOrWhiteSpace(_rootPath)) { - StatusMessage = "Fanbox settings missing (RootPath/Author)."; + StatusMessage = "Fanbox settings missing (RootPath)."; return Page(); } - var inserted = await _syncService.SyncFanboxFoldersAsync(_rootPath, _author, HttpContext.RequestAborted); + var inserted = await _syncService.SyncFanboxFoldersAsync(_rootPath, HttpContext.RequestAborted); StatusMessage = $"Inserted {inserted} rows."; return RedirectToPage(); } - - /// - /// 为指定文件夹生成缺失缩略图并返回数量。 - /// - public async Task OnGetEnsureThumbnailsAsync(Guid id) - { - var folder = await _dbContext.FanboxFolders.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == id); - if (folder is null) - { - return NotFound(); - } - - var created = await _thumbnailService.EnsureFolderThumbnailsAsync(folder.Id, folder.FolderName, HttpContext.RequestAborted); - return new JsonResult(new { created }); - } - - /// - /// 返回指定图片的原图文件。 - /// - public async Task OnGetImageAsync(Guid id, string file) - { - if (string.IsNullOrWhiteSpace(_rootPath) || string.IsNullOrWhiteSpace(file)) - return NotFound(); - - // 仅允许安全文件名与图片扩展名。 - var safeFile = Path.GetFileName(file); - if (string.IsNullOrWhiteSpace(safeFile)) - return NotFound(); - if (!ImageExtensions.Contains(Path.GetExtension(safeFile))) - return NotFound(); - - var folder = await _dbContext.FanboxFolders.AsNoTracking() - .FirstOrDefaultAsync(x => x.Id == id); - if (folder is null) - return NotFound(); - - var filePath = Path.Combine(_rootPath, folder.FolderName, safeFile); - if (!System.IO.File.Exists(filePath)) - return NotFound(); - - // 根据扩展名获取 Content-Type。 - var provider = new FileExtensionContentTypeProvider(); - if (!provider.TryGetContentType(filePath, out var contentType)) - contentType = "application/octet-stream"; - - return PhysicalFile(filePath, contentType); - } } diff --git a/Services/SyncService.cs b/Services/SyncService.cs index 829cebc..2a72b5b 100644 --- a/Services/SyncService.cs +++ b/Services/SyncService.cs @@ -9,18 +9,15 @@ private readonly AppDbContext _dbContext = dbContext; /// - /// 扫描指定作者的 Fanbox 目录并同步新文件夹到数据库。 + /// 扫描多个作者的 Fanbox 目录并同步新文件夹到数据库。 /// public async Task SyncFanboxFoldersAsync( string root, - string author, CancellationToken cancellationToken = default) { // 入口参数校验,确保根路径与作者信息有效。 if (string.IsNullOrWhiteSpace(root)) throw new ArgumentException("Root path is required.", nameof(root)); - if (string.IsNullOrWhiteSpace(author)) - throw new ArgumentException("Author is required.", nameof(author)); var inserted = 0; @@ -28,48 +25,57 @@ // 预加载已有关键词,后续用于关键词复用与命中。 var existingKeywords = await LoadExistingKeywordsAsync(cancellationToken); - foreach (var dir in Directory.GetDirectories(root)) + foreach (var authorDir in Directory.GetDirectories(root)) { cancellationToken.ThrowIfCancellationRequested(); - var folderName = Path.GetFileName(dir); - // 约定:以 yyyy-MM-dd- 开头的目录才参与同步。 - if (folderName.Length < 11 || folderName[10] != '-') + var author = Path.GetFileName(authorDir); + if (string.IsNullOrWhiteSpace(author)) continue; - var datePart = folderName.Substring(0, 10); - // 解析目录名前 10 位日期,失败则跳过。 - if (!DateTime.TryParseExact( - datePart, - "yyyy-MM-dd", - CultureInfo.InvariantCulture, - DateTimeStyles.None, - out var date)) - continue; - - var title = folderName.Substring(11); - // 组合标题与已有关键词,生成本次关键词列表。 - var keywordsStr = BuildKeywords(title, existingKeywords); - // 统计目录内文件数量,用于展示/校验。 - var fileCount = Directory.GetFiles(dir).Length; - - // 数据库中已存在相同作者 + 日期 + 标题时跳过。 - var exists = await _dbContext.FanboxFolders.AsNoTracking().AnyAsync( - x => x.Author == author && x.Date == date && x.Title == title, - cancellationToken); - if (exists) - continue; - - // 待插入列表先暂存,最后一次性写入。 - pending.Add(new FanboxFolder + foreach (var dir in Directory.GetDirectories(authorDir)) { - FolderName = folderName, - Author = author, - Date = date, - Title = title, - Keywords = keywordsStr, - FileCount = fileCount - }); + cancellationToken.ThrowIfCancellationRequested(); + + var folderName = Path.GetFileName(dir); + // 约定:以 yyyy-MM-dd- 开头的目录才参与同步。 + if (folderName.Length < 11 || folderName[10] != '-') + continue; + + var datePart = folderName.Substring(0, 10); + // 解析目录名前 10 位日期,失败则跳过。 + if (!DateTime.TryParseExact( + datePart, + "yyyy-MM-dd", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out var date)) + continue; + + var title = folderName.Substring(11); + // 组合标题与已有关键词,生成本次关键词列表。 + var keywordsStr = BuildKeywords(title, existingKeywords); + // 统计目录内文件数量,用于展示/校验。 + var fileCount = Directory.GetFiles(dir).Length; + + // 数据库中已存在相同作者 + 日期 + 标题时跳过。 + var exists = await _dbContext.FanboxFolders.AsNoTracking().AnyAsync( + x => x.Author == author && x.Date == date && x.Title == title, + cancellationToken); + if (exists) + continue; + + // 待插入列表先暂存,最后一次性写入。 + pending.Add(new FanboxFolder + { + FolderName = folderName, + Author = author, + Date = date, + Title = title, + Keywords = keywordsStr, + FileCount = fileCount + }); + } } if (pending.Count == 0) diff --git a/Services/ThumbnailService.cs b/Services/ThumbnailService.cs index f01296d..b795711 100644 --- a/Services/ThumbnailService.cs +++ b/Services/ThumbnailService.cs @@ -56,33 +56,43 @@ /// /// 获取原始文件路径。 /// - public string? GetOriginalPath(string folderName, string fileName) + public string? GetOriginalPath(string author, string folderName, string fileName) { if (string.IsNullOrWhiteSpace(_rootPath)) { return null; } + if (string.IsNullOrWhiteSpace(author)) + { + return null; + } + var safeName = Path.GetFileName(fileName); if (string.IsNullOrWhiteSpace(safeName)) { return null; } - return Path.Combine(_rootPath, folderName, safeName); + return Path.Combine(_rootPath, author, folderName, safeName); } /// /// 为指定文件夹生成缺失的缩略图,返回新建数量。 /// - public async Task EnsureFolderThumbnailsAsync(Guid folderId, string folderName, CancellationToken cancellationToken) + public async Task EnsureFolderThumbnailsAsync(Guid folderId, string author, string folderName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_rootPath)) { return 0; } - var folderPath = Path.Combine(_rootPath, folderName); + if (string.IsNullOrWhiteSpace(author)) + { + return 0; + } + + var folderPath = Path.Combine(_rootPath, author, folderName); if (!Directory.Exists(folderPath)) { return 0; @@ -122,7 +132,7 @@ { try { - if (await EnsureThumbnailAsync(folderId, folderName, fileName, cancellationToken)) + if (await EnsureThumbnailAsync(folderId, author, folderName, fileName, cancellationToken)) { Interlocked.Increment(ref created); } @@ -141,14 +151,14 @@ /// /// 为单个文件生成缩略图,返回是否存在或成功生成。 /// - public Task EnsureThumbnailAsync(Guid folderId, string folderName, string fileName, CancellationToken cancellationToken) + public Task EnsureThumbnailAsync(Guid folderId, string author, string folderName, string fileName, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_rootPath)) { return Task.FromResult(false); } - var originalPath = GetOriginalPath(folderName, fileName); + var originalPath = GetOriginalPath(author, folderName, fileName); if (string.IsNullOrWhiteSpace(originalPath) || !File.Exists(originalPath)) { return Task.FromResult(false); diff --git a/appsettings.Development.json b/appsettings.Development.json index 77ef33d..bda12af 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -8,7 +8,6 @@ }, "Fanbox": { "RootPath": "E:\\Picture\\fanbox\\月代", - "Author": "月代", "ThumbnailRoot": "D:\\Workspace\\Temp\\fanbox-thumbnail" } } diff --git a/appsettings.json b/appsettings.json index c99a150..356779d 100644 --- a/appsettings.json +++ b/appsettings.json @@ -10,7 +10,6 @@ }, "Fanbox": { "RootPath": "/mnt/media/Picture/fanbox/月代", - "Author": "月代", "ThumbnailRoot": "/var/thumbnail" }, "AllowedHosts": "*"