From 12eaa5814ef20b0910e8d64a753378b6f6797989 Mon Sep 17 00:00:00 2001 From: Jake Mannens Date: Fri, 22 May 2026 00:52:16 +1000 Subject: Initial commit --- Server/Services/MediaService.cs | 400 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 Server/Services/MediaService.cs (limited to 'Server/Services/MediaService.cs') diff --git a/Server/Services/MediaService.cs b/Server/Services/MediaService.cs new file mode 100644 index 0000000..e497570 --- /dev/null +++ b/Server/Services/MediaService.cs @@ -0,0 +1,400 @@ +using HyperBooru.ApiModels; +using ImageMagick; +using Microsoft.EntityFrameworkCore; +using MimeDetective; +using MimeDetective.Definitions; +using System.Security.Cryptography; +using System.Text.RegularExpressions; + +namespace HyperBooru.Services; + +public interface IMediaService { + public void SetDescription( + Media media, + string? shortDescription, + string? longDescription); + + public void SetIngest(Media media, bool ingest); + + public Media Create( + Stream fileData, + string fileName, + string? checksum = null, + DateTime? lastAccessTime = null, + DateTime? lastWriteTime = null, + DateTime? createTime = null, + string? path = null, + PathType? pathType = null, + Guid[]? tagIds = null); + + public void Delete(Guid media); + public void Delete(Media media); + public void DeleteThumbnails(Guid media); + public void DeleteThumbnails(Media media); + public Stream GetThumbnail(Guid media, int? width, int? height); + public Stream GetThumbnail(Media media, int? width, int? height); + public Stream GetConverted(Guid mediaId, string mimeType = "image/png"); + public Stream GetConverted(Media media, string mimeType = "image/png"); + public string GetPath(Guid media); + public string GetPath(Media media); + +} + +public class MediaService : IMediaService { + private readonly Dictionary FormatMap = new() { + ["image/jpeg"] = MagickFormat.Jpeg, + ["image/jpg"] = MagickFormat.Jpg, + ["image/png"] = MagickFormat.Png, + ["image/webp"] = MagickFormat.WebP + }; + + private IDbContextFactory dbFactory; + private IConfigService config; + + private IContentInspector inspector; + + public MediaService(IDbContextFactory dbFactory, + IConfigService config) { + + this.dbFactory = dbFactory; + this.config = config; + + ContentInspectorBuilder inspectorBuilder = new() { + Definitions = + DefaultDefinitions.FileTypes.Images.All() + .Union(DefaultDefinitions.FileTypes.Video.All()) + .ToList() + }; + + inspector = inspectorBuilder.Build(); + } + + public void SetIngest(Media media, bool ingest) { + using var db = dbFactory.CreateDbContext(); + media = db.Media + .Include(m => m.Tags) + .ThenInclude(t => t.TagDefinition) + .First(m => m.Guid == media.Guid); + var ingestTag = db.TagDefinitions + .First(td => td.Guid == HBContext.IngestTag); + + if(ingest) { + if(!media.Tags.Select(t => t.TagDefinition.Guid).Contains(HBContext.IngestTag)) + media.Tags.Add(new(ingestTag)); + } else { + media.Tags.RemoveAll(t => t.TagDefinition.Guid == HBContext.IngestTag); + } + + db.SaveChanges(); + } + + public void SetDescription( + Media media, + string? shortDescription, + string? longDescription) { + + using var db = dbFactory.CreateDbContext(); + var m = db.Media.First(m => m.Guid == media.Guid); + + shortDescription = shortDescription?.Trim(); + longDescription = longDescription?.Trim(); + + if(string.IsNullOrEmpty(shortDescription)) + shortDescription = null; + if(string.IsNullOrEmpty(longDescription)) + longDescription = null; + + m.ShortDescription = shortDescription; + m.LongDescription = longDescription; + + db.SaveChanges(); + } + + public Media Create( + Stream fileData, + string fileName, + string? checksum = null, + DateTime? lastAccessTime = null, + DateTime? lastWriteTime = null, + DateTime? createTime = null, + string? path = null, + PathType? pathType = null, + Guid[]? tagIds = null) { + + using var db = dbFactory.CreateDbContext(); + using var transaction = db.Database.BeginTransaction(); + + if(fileData.Length == 0) + throw new MediaCreateException("File is empty"); + + // Calculate the checksum using the in-memory file contents + var hash = BitConverter + .ToString(MD5.Create().ComputeHash(fileData)) + .Replace("-", "") + .ToLower(); + + if(checksum is not null && hash != checksum.ToLower()) + throw new MediaCreateException("Checksum does not match"); + + // Determine the MIME type + fileData.Seek(0, SeekOrigin.Begin); + var defs = inspector.Inspect(fileData); + var mime = defs.ByMimeType().FirstOrDefault()?.MimeType; + if(mime is null) + throw new MediaCreateException("Unsupported file type"); + + // Read the image with ImageMagick to determine the width and height + fileData.Seek(0, SeekOrigin.Begin); + using var magickImage = new MagickImage(fileData); + + var media = db.Media + .Include(m => m.UploadedFiles) + .Include(m => m.Tags) + .FirstOrDefault(m => m.UploadedFiles.Any(uf => uf.Checksum == hash)); + + var fileRecord = new UploadedFile() { + Filename = fileName, + Length = fileData.Length, + Checksum = hash, + ChecksumVerified = checksum is not null, + MimeType = mime, + Width = (int) magickImage.Width, + Height = (int) magickImage.Height, + UploadTime = DateTime.UtcNow, + LastAccessTime = lastAccessTime, + LastWriteTime = lastWriteTime, + CreateTime = createTime, + Path = pathType is null ? null : path, + PathType = pathType + }; + + var tags = Array.Empty(); + if(tagIds is not null) { + tagIds = tagIds.Distinct().ToArray(); + + tags = db.TagDefinitions + .Where(td => tagIds.Contains(td.Guid)) + .ToArray(); + + if(tags.Count() < tagIds.Count()) { + var badIds = tagIds + .Where(x => !tags.Select(td => td.Guid).Contains(x)) + .Order(); + + throw new MediaCreateException( + $"Non-existent tags specified: {string.Join(", ", badIds)}"); + } + } + + if(media is null) { + var ingestTagDef = db.TagDefinitions + .First(td => td.Guid == HBContext.IngestTag); + + media = new() { + UploadedFiles = new() { + fileRecord + }, + Tags = tags is null ? [ new() { TagDefinition = ingestTagDef } ] : tags + .Select(td => new Tag() { TagDefinition = td }) + .ToList() + }; + + using var newFile = File.Create(GetPath(media)); + + fileData.Seek(0, SeekOrigin.Begin); + fileData.CopyTo(newFile); + newFile.Flush(); + + db.Media.Add(media); + db.SaveChanges(); + media.CurrentUploadedFile = fileRecord; + db.SaveChanges(); + } else { + var fileHashes = media.UploadedFiles + .Select(uf => GetUploadedFileHash(uf)); + // Only add the uploaded file record if it contains new information + if(!fileHashes.Contains(GetUploadedFileHash(fileRecord))) + media.UploadedFiles.Add(fileRecord); + // Add new tags if needed + var missingTags = tags + .Where(td => !media.Tags.Select(t => t.TagDefinition.Guid).Contains(td.Guid)); + media.Tags.AddRange(missingTags.Select(td => new Tag() { TagDefinition = td })); + db.Update(media); + db.SaveChanges(); + } + + transaction.Commit(); + + return media; + } + + public void Delete(Guid media) { + using var db = dbFactory.CreateDbContext(); + var m = db.Media.First(m => m.Guid == media); + + var path = Path.Join( + config.MediaBasePath, + m.Guid.ToString().Substring(0, 2), + m.Guid.ToString().Substring(2, 2), + m.Guid.ToString()); + + try { + var fileInfo = new FileInfo(path); + fileInfo.Delete(); + fileInfo.Directory?.Delete(); + fileInfo.Directory?.Parent?.Delete(); + } catch(IOException) {} + + try { + DeleteThumbnails(media); + } catch {} + + db.Media.Remove(m); + db.SaveChanges(); + } + + public void Delete(Media media) => + Delete(media.Guid); + + public void DeleteThumbnails(Guid media) { + var dir = new DirectoryInfo(Path.Join( + config.ThumbnailBasePath, + media.ToString().Substring(0, 2), + media.ToString().Substring(2, 2))); + + var pattern = new Regex($"^{media}-[0-9]+-[0-9]+$"); + var toDelete = dir.GetFiles() + .Where(f => pattern.IsMatch(f.Name)) + .ToList(); + + List exceptions = new(); + + foreach(var file in toDelete) { + try { + file.Delete(); + } catch(Exception e) { + exceptions.Add(e); + } + } + + try { + dir.Delete(); + dir.Parent?.Delete(); + } catch(Exception e) { + exceptions.Add(e); + } + + // TODO: wrap the AggregateException in a ThumbnailException + if(exceptions.Count() > 1) + throw new AggregateException(exceptions); + } + + public void DeleteThumbnails(Media media) => + DeleteThumbnails(media.Guid); + + 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 thumbPath = GetThumbnailPath(mediaId, width, height); + + if(File.Exists(thumbPath)) + return System.IO.File.OpenRead(thumbPath); + + if(!File.Exists(GetPath(mediaId))) + throw new ObjectNotFoundException(mediaId); + + using var image = new MagickImage(GetPath(mediaId)); + + if(width > image.Width || height > image.Height) { + width = (int) image.Width; + height = (int) image.Height; + } + + image.Thumbnail((uint) (width ?? -1), (uint) (height ?? -1)); + image.Write(thumbPath, MagickFormat.Jpeg); + + return System.IO.File.OpenRead(thumbPath); + } + + public Stream GetConverted(Guid mediaId, string mimeType) { + if(!FormatMap.TryGetValue(mimeType, out var format)) + throw new MediaException($"Cannot convert to unknown format ({mimeType})", mediaId); + + var convertedPath = GetConvertedPath(mediaId, mimeType); + + if(File.Exists(convertedPath)) + return System.IO.File.OpenRead(convertedPath); + + if(!File.Exists(GetPath(mediaId))) + throw new ObjectNotFoundException(mediaId); + + using var image = new MagickImage(GetPath(mediaId)); + image.Write(convertedPath, format); + + return System.IO.File.OpenRead(convertedPath); + } + + public Stream GetThumbnail(Media media, int? width, int? height) => + GetThumbnail(media.Guid, width, height); + + public Stream GetConverted(Media media, string mimeType) => + GetConverted(media.Guid, mimeType); + + public string GetPath(Guid mediaId) { + var fileInfo = new FileInfo( + Path.Join( + config.MediaBasePath, + mediaId.ToString().Substring(0, 2), + mediaId.ToString().Substring(2, 2), + mediaId.ToString())); + + Directory.CreateDirectory(fileInfo.Directory!.FullName); + return fileInfo.FullName; + } + + public string GetThumbnailPath(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, + mediaId.ToString().Substring(0, 2), + mediaId.ToString().Substring(2, 2), + $"{mediaId.ToString()}-{(width ?? 0)}-{(height ?? 0)}")); + + Directory.CreateDirectory(fileInfo.Directory!.FullName); + return fileInfo.FullName; + } + + public string GetConvertedPath(Guid mediaId, string mimeType) { + var fileInfo = new FileInfo(Path.Join( + config.ConvertedMediaBasePath, + mediaId.ToString().Substring(0, 2), + mediaId.ToString().Substring(2, 2), + $"{mediaId.ToString()}-{mimeType.Split('/')[1]}")); + + Directory.CreateDirectory(fileInfo.Directory!.FullName); + return fileInfo.FullName; + } + + public string GetPath(Media media) => + GetPath(media.Guid); + + public string GetThumbnailPath(Media media, int? width, int? height) => + GetThumbnailPath(media.Guid, width, height); + + public string GetConvertedPath(Media media, string mimeType) => + GetConvertedPath(media.Guid, mimeType); + + private int GetUploadedFileHash(UploadedFile uf) => ( + uf.CreateTime, + uf.LastWriteTime, + uf.Filename, + uf.Length, + uf.Checksum).GetHashCode(); +} -- cgit v1.3