From 6c06dfc4f83f30292e65c08a3cb0c48401d4bfa7 Mon Sep 17 00:00:00 2001 From: Jake Mannens Date: Wed, 25 Mar 2026 01:57:19 +1100 Subject: v0.2a --- Controllers/MediaController.cs | 2 +- Exception.cs | 10 ++- Pages/Component/MediaTagTable.razor | 2 +- Pages/Gallery.razor | 133 +++++++++++++------------------ Pages/Gallery.razor.css | 2 +- Pages/TagDefinitions.razor | 2 +- Program.cs | 2 +- Server.csproj | 6 +- Services/ConfigService.cs | 4 + Services/FeedService.cs | 155 ++++++++++++++++++++++++++++++++++++ Services/MediaService.cs | 75 +++++++++-------- Services/OcrService.cs | 18 +++-- Services/SearchService.cs | 117 --------------------------- 13 files changed, 281 insertions(+), 247 deletions(-) create mode 100644 Services/FeedService.cs delete mode 100644 Services/SearchService.cs diff --git a/Controllers/MediaController.cs b/Controllers/MediaController.cs index 3368f45..2c015f7 100644 --- a/Controllers/MediaController.cs +++ b/Controllers/MediaController.cs @@ -32,7 +32,7 @@ public class MediaController : Controller { var fs = System.IO.File.OpenRead(mediaService.GetPath(media)); - return new FileStreamResult(fs, media.CurrentUploadedFile.MimeType); + return new FileStreamResult(fs, media.CurrentUploadedFile!.MimeType); } [HttpGet("thumb/{mediaId}")] diff --git a/Exception.cs b/Exception.cs index 1e070eb..fc3feda 100644 --- a/Exception.cs +++ b/Exception.cs @@ -47,11 +47,13 @@ public class TagDuplicateException : TagException { } public class MediaException : HBException { - public Media? Media { get; private init; } + public Guid? MediaId { get; private init; } = null; public MediaException(string message) : base(message) {} + public MediaException(string message, Guid mediaId) : base(message) => + MediaId = mediaId; public MediaException(string message, Media media) : base(message) => - Media = media; + MediaId = media.Guid; } public class MediaCreateException : MediaException { @@ -60,6 +62,8 @@ public class MediaCreateException : MediaException { } public class ThumbnailException : MediaException { + public ThumbnailException(string message, Guid mediaId) + : base(message, mediaId) {} public ThumbnailException(string message, Media media) - :base(message, media) {} + : base(message, media) {} } diff --git a/Pages/Component/MediaTagTable.razor b/Pages/Component/MediaTagTable.razor index e367f7f..e687529 100644 --- a/Pages/Component/MediaTagTable.razor +++ b/Pages/Component/MediaTagTable.razor @@ -19,7 +19,7 @@ } - + @if(e.isImplicit) { @e.tagDef.Name } else { diff --git a/Pages/Gallery.razor b/Pages/Gallery.razor index 762ef7f..c037979 100644 --- a/Pages/Gallery.razor +++ b/Pages/Gallery.razor @@ -1,8 +1,7 @@ @page "/" @page "/Gallery" -@inject IDbContextFactory dbFactory @inject ITagService tagService -@inject ISearchService searchService +@inject IFeedService feedService @inject IUserService userService @inject IJSRuntime jsRuntime @implements IDisposable @@ -11,31 +10,36 @@ @Title @if(Ingest && !userService.UserSessionState.ShowNsfw) { -
+

Ingest feed is not available unless NSFW mode is enabled!

You must enable NSFW mode to continue...

-} - -
- @foreach(var media in displayMedia) { - // Precalculate thumbnail size to help the browser - // lay out the images during initial page load - int width = (int) media.CurrentUploadedFile.Width! * 200 / (int) media.CurrentUploadedFile.Height!; - - - - } -
+} else if(TagId is not null && Query is not null) { +
+

Invalid query parameters! Both a search query and

+

a tag ID have been specified!

+
+} else { +
+ @foreach(var media in displayMedia) { + // Precalculate thumbnail size to help the browser + // lay out the images during initial page load + int width = (int) media.CurrentUploadedFile!.Width! * 200 / (int) media.CurrentUploadedFile.Height!; + + + + } +
-
+
+} @code { + [Parameter] + [SupplyParameterFromQuery(Name = "t")] + public Guid? TagId { get; set; } + [Parameter] [SupplyParameterFromQuery(Name = "q")] public string? Query { get; set; } @@ -64,13 +72,11 @@ } private List displayMedia; - private Media[] queryResult; - private IEnumerator mediaEnumerator; protected override void OnInitialized() => userService.UserSessionState.OnStateChange += ShowNsfwChanged; - protected override void OnParametersSet() => LoadMedia(); + protected override void OnParametersSet() => LoadMedia(true); protected override void OnAfterRender(bool firstRender) { if(firstRender) @@ -79,71 +85,44 @@ DotNetObjectReference.Create(this)); } - private void LoadMedia() { - using var db = dbFactory.CreateDbContext(); - - if(Query is not null) { - queryResult = searchService.Search(Query) - .OrderByDescending(m => m.ObjectId) - .ToArray(); + [JSInvokable("LoadMedia")] + public void LoadMedia(bool initial = false) { + Media? key = displayMedia?.Any() ?? false && !initial ? displayMedia.Last() : null; + + if(initial) + displayMedia = new(); + + if(TagId is not null && Query is null) { + displayMedia!.AddRange(feedService.LoadChunk( + selectIngest: Ingest, + includeNsfw: userService.UserSessionState.ShowNsfw, + tagId: (Guid) TagId!, + key: key, + count: PageSize)); + } else if(Query is not null && TagId is null) { + displayMedia!.AddRange(feedService.LoadChunk( + selectIngest: Ingest, + includeNsfw: userService.UserSessionState.ShowNsfw, + query: string.IsNullOrWhiteSpace(Query) ? null : Query, + key: key, + count: PageSize)); } else { - queryResult = db.Media - .Include(m => m.Tags) - .Include(m => m.CurrentUploadedFile) - .OrderByDescending(m => m.ObjectId) - .ToArray(); + displayMedia!.AddRange(feedService.LoadChunk( + selectIngest: Ingest, + includeNsfw: userService.UserSessionState.ShowNsfw, + key: key, + count: PageSize)); } - mediaEnumerator = FilterMedia(queryResult).GetEnumerator(); - - displayMedia = new(); - - LoadMore(); - } - - [JSInvokable("LoadMore")] - public void LoadMore() { - for(int i = 0; i < PageSize; i++) { - if(!mediaEnumerator.MoveNext()) - break; - displayMedia.Add(mediaEnumerator.Current); - } - StateHasChanged(); - } - - private IEnumerable FilterMedia(IEnumerable media) { - var nsfwTags = tagService.TagsThatImply(HBContext.NsfwTag) - .Select(td => td.ObjectId) - .ToArray(); - - using var enumerator = media.GetEnumerator(); - - while(true) { - bool success = enumerator.MoveNext(); - if(!success) - break; - Media? m = enumerator.Current; - - if(!userService.UserSessionState.ShowNsfw) - if(m.Tags.Select(t => t.TagDefinitionId).Intersect(nsfwTags).Any() || m.IsIngest) - continue; - - if(m.IsIngest != Ingest) - continue; - - yield return m; - } + StateHasChanged(); } private async void ShowNsfwChanged(UserSessionState userSessionState) { await InvokeAsync(() => { - LoadMedia(); - StateHasChanged(); + LoadMedia(true); }); } - public void Dispose() { - mediaEnumerator.Dispose(); - userService.UserSessionState.OnStateChange -= ShowNsfwChanged; - } + public void Dispose() => + userService.UserSessionState.OnStateChange -= ShowNsfwChanged; } diff --git a/Pages/Gallery.razor.css b/Pages/Gallery.razor.css index 0e01e0e..989e252 100644 --- a/Pages/Gallery.razor.css +++ b/Pages/Gallery.razor.css @@ -3,7 +3,7 @@ max-height: 200px; } -div#ingest-warning { +div#feed-error { position: relative; top: 50%; left: 50%; diff --git a/Pages/TagDefinitions.razor b/Pages/TagDefinitions.razor index e2e4df6..f3dca0f 100644 --- a/Pages/TagDefinitions.razor +++ b/Pages/TagDefinitions.razor @@ -26,7 +26,7 @@ @tagDef.Alias - + @tagDef.Name diff --git a/Program.cs b/Program.cs index f80b996..e7f1e26 100644 --- a/Program.cs +++ b/Program.cs @@ -22,7 +22,7 @@ public class Program { // Add our custom services builder.Services.AddSingleton(); builder.Services.AddDbContextFactory(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/Server.csproj b/Server.csproj index e3ca595..7ce894a 100644 --- a/Server.csproj +++ b/Server.csproj @@ -6,9 +6,9 @@ enable HyperBooru HyperBooru - 0.1.0.0 + 0.2.0.0 $(AssemblyVersion) - 0.1-alpha + 0.2-alpha 2907567f-4640-4581-8f4d-0977952d26bd @@ -21,7 +21,7 @@ - + all diff --git a/Services/ConfigService.cs b/Services/ConfigService.cs index b42b80c..8460fd0 100644 --- a/Services/ConfigService.cs +++ b/Services/ConfigService.cs @@ -5,6 +5,7 @@ public interface IConfigService { public string DbConnectionString { get; } public string MediaBasePath { get; } public string ThumbnailBasePath { get; } + public bool EnableOcr { get; } } public class ConfigService : IConfigService { @@ -46,6 +47,9 @@ public class ConfigService : IConfigService { public string ThumbnailBasePath => Path.Join(DataPath, "thumb"); + public bool EnableOcr => + bool.TryParse(config["DisableOcr"], out bool x) ? !x : true; + public ConfigService(IConfiguration config) { this.config = config; InitDirectoryStructure(); diff --git a/Services/FeedService.cs b/Services/FeedService.cs new file mode 100644 index 0000000..864a751 --- /dev/null +++ b/Services/FeedService.cs @@ -0,0 +1,155 @@ +using Microsoft.EntityFrameworkCore; + +namespace HyperBooru.Services; + +public interface IFeedService { + public Media[] LoadChunk( + bool selectIngest, + bool includeNsfw, + Media? key = null, + int count = 50); + + public Media[] LoadChunk( + bool selectIngest, + bool includeNsfw, + string query, + Media? key = null, + int count = 50); + + public Media[] LoadChunk( + bool selectIngest, + bool includeNsfw, + Guid tagId, + Media? key = null, + int count = 50); +} + +public class FeedService : IFeedService { + private IDbContextFactory dbFactory; + + public FeedService(IDbContextFactory dbFactory) => + this.dbFactory = dbFactory; + + public Media[] LoadChunk( + bool selectIngest, + bool includeNsfw, + Media? key, + int count) => LoadChunkInternal(selectIngest, includeNsfw, null, null, key, count); + + public Media[] LoadChunk( + bool selectIngest, + bool includeNsfw, + string query, + Media? key, + int count) => LoadChunkInternal(selectIngest, includeNsfw, query, null, key, count); + + public Media[] LoadChunk( + bool selectIngest, + bool includeNsfw, + Guid tagId, + Media? key, + int count) => LoadChunkInternal(selectIngest, includeNsfw, null, tagId, key, count); + + private Media[] LoadChunkInternal( + bool selectIngest, + bool includeNsfw, + string? query, + Guid? tagId, + Media? key, + int count) { + + if(selectIngest && !includeNsfw) + return Array.Empty(); + + using var db = dbFactory.CreateDbContext(); + + IQueryable media = db.Media + .AsSingleQuery() + .AsNoTracking() + .Include(m => m.Tags) + .Include(m => m.CurrentUploadedFile); + + if(!includeNsfw) + media = media + .Where(m => !TagsThatImply(db, HBContext.NsfwTag) + .Intersect(m.Tags.Select(t => t.TagDefinitionId)) + .Any()); + + if(selectIngest) { + media = media + .Where(m => m.Tags + .Select(t => t.TagDefinitionId) + .Contains((int) HBObjectId.IngestTag)); + } else { + media = media + .Where(m => !m.Tags + .Select(t => t.TagDefinitionId) + .Contains((int) HBObjectId.IngestTag)); + } + + if(query is not null) { + media = Search(media, query); + } else if(tagId is not null) { + media = media + .Where(m => TagsThatImply(db, (Guid) tagId) + .Intersect(m.Tags.Select(t => t.TagDefinitionId)) + .Any()); + } + + if(key is not null) + media = media.Where(m => m.ObjectId > key.ObjectId); + + return media + .OrderBy(m => m.ObjectId) + .Take(count) + .ToArray(); + } + + private static IQueryable Search(IQueryable media, string query) { + // TODO: search implicit tags as well + + query = query.ToLower().Trim(); + + return media + .Where(m => + (m.ShortDescription != null && m.ShortDescription.ToLower().Contains(query)) || + (m.LongDescription != null && m.LongDescription.ToLower().Contains(query)) || + (m.UploadedFiles.Any(uf => uf.Filename != null && uf.Filename.ToLower().Contains(query))) || + (m.OcrData != null && m.OcrData.SearchableText.ToLower().Contains(query)) || + (m.Tags.Any(t => t.TagDefinition.Name.ToLower().Contains(query)))); + } + + private static IQueryable TagsThatImply(HBContext db, Guid tagId) => + db.Database.SqlQueryRaw(""" + WITH RECURSIVE basetag AS ( + SELECT "ObjectId" FROM "Objects" WHERE "Guid" = {0} + ), + impliedtags AS ( + SELECT + "TagDefinitionObjectId" + FROM + "TagDefinitionTagDefinition" + INNER JOIN + basetag + ON + "ImplicitTagsObjectId" = basetag."ObjectId" + UNION + SELECT + "TagDefinitionTagDefinition"."TagDefinitionObjectId" + FROM + "TagDefinitionTagDefinition" + INNER JOIN + impliedtags + ON + impliedtags."TagDefinitionObjectId" = "TagDefinitionTagDefinition"."ImplicitTagsObjectId" + ) + SELECT DISTINCT + "TagDefinitionObjectId" AS "Value" + FROM impliedtags + UNION + SELECT + "ObjectId" AS "Value" + FROM + basetag + """, tagId); +} diff --git a/Services/MediaService.cs b/Services/MediaService.cs index 104d0db..a5803f9 100644 --- a/Services/MediaService.cs +++ b/Services/MediaService.cs @@ -31,8 +31,10 @@ public interface IMediaService { public void DeleteThumbnails(Media media); public Stream GetThumbnail(Guid media, int? width, int? height); public Stream GetThumbnail(Media media, int? width, int? height); + public string GetPath(Guid media); + public string GetPath(Guid media, int? width, int? height); public string GetPath(Media media); - public string GetPath(Media media, int width, int height); + public string GetPath(Media media, int? width, int? height); } @@ -259,37 +261,29 @@ public class MediaService : IMediaService { public void DeleteThumbnails(Media media) => DeleteThumbnails(media.Guid); - public Stream GetThumbnail(Guid media, int? width, int? height) { - using var db = dbFactory.CreateDbContext(); + public Stream GetThumbnail(Guid mediaId, int? width, int? height) { + if(width is null && height is null) + throw new ThumbnailException( + "Both width and height cannot be null!", + mediaId); - var m = db.Media - .Include(m => m.CurrentUploadedFile) - .First(m => m.Guid == media); - if(m is null) - throw new ObjectNotFoundException(media); + var thumbPath = GetPath(mediaId, width, height); - if(m.CurrentUploadedFile.MimeType.Split("/")[0] != "image") - throw new ThumbnailException("Media object not an image", m); + if(File.Exists(thumbPath)) + return System.IO.File.OpenRead(thumbPath); - using var image = new MagickImage(GetPath(m)); + if(!File.Exists(GetPath(mediaId))) + throw new ObjectNotFoundException(mediaId); - if(width is null && height is null) - throw new ThumbnailException("Both width and height cannot be null!", m); + using var image = new MagickImage(GetPath(mediaId)); if(width > image.Width || height > image.Height) - throw new ThumbnailException("Requested thumbnail size is larger than original media", m); - - #pragma warning disable CS8629 - int w = (int) (width is not null ? width : image.Width * height / image.Height); - int h = (int) (height is not null ? height : image.Height * width / image.Width); - #pragma warning restore CS8629 - - var thumbPath = GetPath(m, w, h); + throw new ThumbnailException( + "Requested thumbnail size is larger than original media", + mediaId); - if(!File.Exists(thumbPath)) { - image.Resize((uint) w, (uint) h); - image.Write(thumbPath); - } + image.Thumbnail((uint) (width ?? -1), (uint) (height ?? -1)); + image.Write(thumbPath, MagickFormat.Jpeg); return System.IO.File.OpenRead(thumbPath); } @@ -297,29 +291,40 @@ public class MediaService : IMediaService { public Stream GetThumbnail(Media media, int? width, int? height) => GetThumbnail(media.Guid, width, height); - public string GetPath(Media media) { + public string GetPath(Guid mediaId) { var fileInfo = new FileInfo( Path.Join( config.MediaBasePath, - media.Guid.ToString().Substring(0, 2), - media.Guid.ToString().Substring(2, 2), - media.Guid.ToString())); + mediaId.ToString().Substring(0, 2), + mediaId.ToString().Substring(2, 2), + mediaId.ToString())); - Directory.CreateDirectory(fileInfo.Directory.FullName); + Directory.CreateDirectory(fileInfo.Directory!.FullName); return fileInfo.FullName; } - public string GetPath(Media media, int width, int height) { + public string GetPath(Guid mediaId, int? width, int? height) { + if(width is null && height is null) + throw new ThumbnailException( + "Both width and height cannot be null!", + mediaId); + var fileInfo = new FileInfo(Path.Join( config.ThumbnailBasePath, - media.Guid.ToString().Substring(0, 2), - media.Guid.ToString().Substring(2, 2), - $"{media.Guid.ToString()}-{width}-{height}")); + mediaId.ToString().Substring(0, 2), + mediaId.ToString().Substring(2, 2), + $"{mediaId.ToString()}-{(width ?? 0)}-{(height ?? 0)}")); - Directory.CreateDirectory(fileInfo.Directory.FullName); + Directory.CreateDirectory(fileInfo.Directory!.FullName); return fileInfo.FullName; } + public string GetPath(Media media) => + GetPath(media.Guid); + + public string GetPath(Media media, int? width, int? height) => + GetPath(media.Guid, width, height); + private int GetUploadedFileHash(UploadedFile uf) => ( uf.CreateTime, uf.LastWriteTime, diff --git a/Services/OcrService.cs b/Services/OcrService.cs index 4d21705..40905aa 100644 --- a/Services/OcrService.cs +++ b/Services/OcrService.cs @@ -18,18 +18,21 @@ public class OcrService : IHostedService { private Timer timer; + private IConfigService configService; private IServiceScopeFactory scopeFactory; private ILogger logger; private IDbContextFactory dbFactory; public OcrService( + IConfigService configService, IServiceScopeFactory scopeFactory, ILogger logger, IDbContextFactory dbFactory) { - this.scopeFactory = scopeFactory; - this.logger = logger; - this.dbFactory = dbFactory; + this.configService = configService; + this.scopeFactory = scopeFactory; + this.logger = logger; + this.dbFactory = dbFactory; timer = new((object? state) => { if(task is not null && !task.IsCompleted) @@ -40,8 +43,11 @@ public class OcrService : IHostedService { } public Task StartAsync(CancellationToken ct) { - logger.LogInformation("Service starting..."); - timer.Change(StartupDelay, ProcessInterval); + if(configService.EnableOcr) { + logger.LogInformation("Service starting..."); + timer.Change(StartupDelay, ProcessInterval); + } + return Task.CompletedTask; } @@ -53,8 +59,6 @@ public class OcrService : IHostedService { } async Task ProcessAllAsync(CancellationToken ct) { - return; - using var scope = scopeFactory.CreateScope(); var mediaService = scope.ServiceProvider .GetRequiredService(); diff --git a/Services/SearchService.cs b/Services/SearchService.cs deleted file mode 100644 index 5ca12e1..0000000 --- a/Services/SearchService.cs +++ /dev/null @@ -1,117 +0,0 @@ -using Microsoft.EntityFrameworkCore; - -namespace HyperBooru.Services; - -public interface ISearchService { - public Media[] Search(string query); -} - -public class SearchService : ISearchService { - private ITagService tagService; - - private IDbContextFactory dbFactory; - - public SearchService( - IDbContextFactory dbFactory, - ITagService tagService) { - - this.tagService = tagService; - this.dbFactory = dbFactory; - } - - public Media[] Search(string query) { - var db = dbFactory.CreateDbContext(); - - query = query.ToLower().Trim(); - - int[] descriptionResults = SearchDescription(query); - int[] filenameResults = SearchFilenames(query); - int[] ocrResults = SearchOcr(query); - - var matchedTag = db.TagDefinitions - .FirstOrDefault(td => td.Name.ToLower() == query); - - int[] tags; - if(matchedTag is not null) { - tags = tagService - .TagsThatImply(matchedTag) - .Select(td => td.ObjectId) - .ToArray(); - } else { - // TODO: Expand scope to all tags that imply - tags = db.TagDefinitions - .Where(td => td.Name.ToLower().Contains(query)) - .Select(td => td.ObjectId) - .ToArray(); - } - - int[] tagResults = SearchTags(tags); - - int[] mediaIds = descriptionResults - .Union(filenameResults) - .Union(ocrResults) - .Union(tagResults) - .OrderDescending() - .ToArray(); - - return db.Media - .Include(m => m.Tags) - .Include(m => m.CurrentUploadedFile) - .Where(m => mediaIds.Contains(m.ObjectId)) - .ToArray(); - } - - // TODO: Make asynchronous - private int[] SearchDescription(string query) { - return Task.Run(() => { - using var db = dbFactory.CreateDbContext(); - query = query.ToLower(); - return db.Media - .Where(m => - (m.ShortDescription != null && m.ShortDescription.ToLower().Contains(query)) || - (m.LongDescription != null && m.LongDescription.ToLower().Contains(query))) - .Select(m => m.ObjectId) - .ToArray(); - }).GetAwaiter().GetResult(); - } - - // TODO: Make asynchronous - private int[] SearchFilenames(string query) { - return Task.Run(() => { - using var db = dbFactory.CreateDbContext(); - query = query.ToLower(); - return db.UploadedFiles - .Include(uf => uf.Media) - .Where(uf => uf.Filename != null && uf.Filename.ToLower().Contains(query)) - .Select(uf => uf.Media.ObjectId) - .Distinct() - .ToArray(); - }).GetAwaiter().GetResult(); - } - - // TODO: Make asynchronous - private int[] SearchOcr(string query) { - return Task.Run(() => { - using var db = dbFactory.CreateDbContext(); - query = query.ToLower(); - return db.OcrData - .Include(o => o.Media) - .Where(o => o.SearchableText.Contains(query)) - .Select(o => o.Media.ObjectId) - .ToArray(); - }).GetAwaiter().GetResult(); - } - - // TODO: Make asynchronous - private int[] SearchTags(int[] tags) { - return Task.Run(() => { - using var db = dbFactory.CreateDbContext(); - return db.Media - .Include(m => m.Tags) - .AsEnumerable() - .Where(m => m.Tags.IntersectBy(tags, t => t.TagDefinitionId).Any()) - .Select(m => m.ObjectId) - .ToArray(); - }).GetAwaiter().GetResult(); - } -} -- cgit v1.3