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); 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 string GetPath(Media media); public string GetPath(Media media, int width, int height); } public class MediaService : IMediaService { private IDbContextFactory dbFactory; private IConfigService config; private ContentInspector inspector; public MediaService(IDbContextFactory dbFactory, IConfigService config) { this.dbFactory = dbFactory; this.config = config; ContentInspectorBuilder inspectorBuilder = new() { Definitions = Default.FileTypes.Images.All() .Union(Default.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); 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) { 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"); var fileRecord = new UploadedFile() { Filename = fileName, Length = fileData.Length, OriginalChecksum = hash, UploadTime = DateTime.UtcNow, LastAccessTime = lastAccessTime, LastWriteTime = lastWriteTime, CreateTime = createTime }; // 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 .FirstOrDefault(m => m.Checksum == hash); if(media is null) { var ingestTagDef = db.TagDefinitions .First(td => td.Guid == HBContext.IngestTag); media = new() { Checksum = hash, MimeType = mime, Width = magickImage.Width, Height = magickImage.Height, UploadedFiles = new() { fileRecord }, Tags = new() { new() { TagDefinition = ingestTagDef } } }; using var newFile = System.IO.File.Create(GetPath(media)); fileData.Seek(0, SeekOrigin.Begin); fileData.CopyTo(newFile); newFile.Flush(); db.Media.Add(media); } else { media.UploadedFiles.Add(fileRecord); 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 media, int? width, int? height) { using var db = dbFactory.CreateDbContext(); var m = db.Media.First(m => m.Guid == media); if(m is null) throw new ObjectNotFoundException(media); if(m.MimeType.Split("/")[0] != "image") throw new ThumbnailException("Media object not an image", m); using var image = new MagickImage(GetPath(m)); if(width is null && height is null) throw new ThumbnailException("Both width and height cannot be null!", m); 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); if(!System.IO.File.Exists(thumbPath)) { image.Resize(w, h); image.Write(thumbPath); } return System.IO.File.OpenRead(thumbPath); } public Stream GetThumbnail(Media media, int? width, int? height) => GetThumbnail(media.Guid, width, height); public string GetPath(Media media) { var fileInfo = new FileInfo( Path.Join( config.MediaBasePath, media.Guid.ToString().Substring(0, 2), media.Guid.ToString().Substring(2, 2), media.Guid.ToString())); Directory.CreateDirectory(fileInfo.Directory.FullName); return fileInfo.FullName; } public string GetPath(Media media, int width, int height) { 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}")); Directory.CreateDirectory(fileInfo.Directory.FullName); return fileInfo.FullName; } }