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(); }