summaryrefslogtreecommitdiff
path: root/Services/MediaService.cs
diff options
context:
space:
mode:
Diffstat (limited to 'Services/MediaService.cs')
-rw-r--r--Services/MediaService.cs400
1 files changed, 400 insertions, 0 deletions
diff --git a/Services/MediaService.cs b/Services/MediaService.cs
new file mode 100644
index 0000000..2d1533c
--- /dev/null
+++ b/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<string,MagickFormat> FormatMap = new() {
+ ["image/jpeg"] = MagickFormat.Jpeg,
+ ["image/jpg"] = MagickFormat.Jpg,
+ ["image/png"] = MagickFormat.Png,
+ ["image/webp"] = MagickFormat.WebP
+ };
+
+ private IDbContextFactory<HBContext> dbFactory;
+ private IConfigService config;
+
+ private IContentInspector inspector;
+
+ public MediaService(IDbContextFactory<HBContext> 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<TagDefinition>();
+ 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<Exception> 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();
+}