Merge branch 'develop' into feature/frontend

This commit is contained in:
Samuele Lorefice
2025-07-16 18:04:50 +02:00
9 changed files with 223 additions and 161 deletions

104
Butter/MimeTypes.cs Normal file
View File

@@ -0,0 +1,104 @@
namespace Butter;
public struct MimeTypeMap(string mimeType, string[] extensions) {
public string MimeType { get; set; } = mimeType;
public string[] Extensions { get; set; } = extensions;
}
public static class MimeTypes{
// ReSharper disable ArrangeObjectCreationWhenTypeEvident
public static readonly MimeTypeMap[] Image = [
new("image/avif", [".avif", ".avifs"]),
new("image/bmp}", [".bmp"]),
new("image/cgm", [".cgm"]),
new("image/g3fax", [".g3"]),
new("image/gif", [".gif"]),
new("image/heic", [".heif", ".heic"]),
new("image/ief", [".ief"]),
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/pjpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfi", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/png", [".png"]),
new("image/prs.btif", [".btif"]),
new("image/svg+xml", [".svg", ".svgz"]),
new("image/tiff", [".tif", ".tiff"]),
new("image/vnd.adobe.photoshop", [".psd"]),
new("image/vnd.djvu", [".djv", ".djvu"]),
new("image/vnd.dwg", [".dwg"]),
new("image/vnd.dxf", [".dxf"]),
new("image/vnd.fastbidsheet", [".fbs"]),
new("image/vnd.fpx", [".fpx"]),
new("image/vnd.fst", [".fst"]),
new("image/vnd.fujixerox.edmics-mmr", [".mmr"]),
new("image/vnd.fujixerox.edmics-rlc", [".rlc"]),
new("image/vnd.ms-modi", [".mdi"]),
new("image/vnd.net-fpx", [".npx"]),
new("image/vnd.wap.wbmp", [".wbmp"]),
new("image/vnd.xiff", [".xif"]),
new("image/webp", [".webp"]),
new("image/x-adobe-dng", [".dng"]),
new("image/x-canon-cr2", [".cr2"]),
new("image/x-canon-crw", [".crw"]),
new("image/x-cmu-raster", [".ras"]),
new("image/x-cmx", [".cmx"]),
new("image/x-epson-erf", [".erf"]),
new("image/x-freehand", [".fh", ".fh4", ".fh5", ".fh7", ".fhc"]),
new("image/x-fuji-raf", [".raf"]),
new("image/x-icns", [".icns"]),
new("image/x-icon", [".ico"]),
new("image/x-kodak-dcr", [".dcr"]),
new("image/x-kodak-k25", [".k25"]),
new("image/x-kodak-kdc", [".kdc"]),
new("image/x-minolta-mrw", [".mrw"]),
new("image/x-nikon-nef", [".nef"]),
new("image/x-olympus-orf", [".orf"]),
new("image/x-panasonic-raw", [".raw", ".rw2", ".rwl"]),
new("image/x-pcx", [".pcx"]),
new("image/x-pentax-pef", [".pef", ".ptx"]),
new("image/x-pict", [".pct", ".pic"]),
new("image/x-portable-anymap", [".pnm"]),
new("image/x-portable-bitmap", [".pbm"]),
new("image/x-portable-graymap", [".pgm"]),
new("image/x-portable-pixmap", [".ppm"]),
new("image/x-rgb", [".rgb"]),
new("image/x-sigma-x3f", [".x3f"]),
new("image/x-sony-arw", [".arw"]),
new("image/x-sony-sr2", [".sr2"]),
new("image/x-sony-srf", [".srf"]),
new("image/x-xbitmap", [".xbm"]),
new("image/x-xpixmap", [".xpm"]),
new("image/x-xwindowdump", [".xwd"])
];
public static readonly MimeTypeMap[] Video = [
new("video/3gpp", [".3gp"]),
new("video/3gpp2", [".3g2"]),
new("video/h261", [".h261"]),
new("video/h263", [".h263"]),
new("video/h264", [".h264"]),
new("video/jpeg", [".jpgv"]),
new("video/jpm", [".jpgm, .jpm"]),
new("video/mj2", [".mj2, .mjp2"]),
new("video/mp2t", [".ts"]),
new("video/mp4", [".mp4, .mp4v, .mpg4"]),
new("video/mpeg", [".m1v, .m2v, .mpa, .mpe, .mpeg, .mpg"]),
new("video/ogg", [".ogv"]),
new("video/quicktime", [".mov", ".qt"]),
new("video/vnd.fvt", [".fvt"]),
new("video/vnd.mpegurl", [".m4u", ".mxu"]),
new("video/vnd.ms-playready.media.pyv", [".pyv"]),
new("video/vnd.vivo", [".viv"]),
new("video/webm", [".webm"]),
new("video/x-f4v", [".f4v"]),
new("video/x-fli", [".fli"]),
new("video/x-flv", [".flv"]),
new("video/x-m4v", [".m4v"]),
new("video/x-matroska", [".mkv"]),
new("video/x-ms-asf", [".asf", ".asx"]),
new("video/x-ms-wm", [".wm"]),
new("video/x-ms-wmv", [".wmv"]),
new("video/x-ms-wmx", [".wmx"]),
new("video/x-ms-wvx", [".wvx"]),
new("video/x-msvideo", [".avi"]),
new("video/x-sgi-movie", [".movie"])
];
}

View File

@@ -1,4 +1,4 @@
namespace Lactose;
namespace Butter;
public enum Settings {
UserRegistrationEnabled, //Enable or disable self user registration

View File

@@ -63,7 +63,6 @@ AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
// Add services to initialize an empty the database
builder.Services.AddScoped<IDbInitializer, DbInitializer>();
// Add services to the container.
builder.Services.AddTransient<IPasswordHasher<User>, PasswordHasher<User>>();
builder.Services.AddTransient<ISettingsRepository, SettingsRepository>();
@@ -75,7 +74,7 @@ builder.Services.AddTransient<IAlbumRepository, AlbumRepository>();
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>();
builder.Services.AddTransient<FileSystemScannerService>();
builder.Services.AddSingleton<FileSystemScannerService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();

View File

@@ -3,7 +3,7 @@ using Lactose.Models;
namespace Lactose.Repositories;
public class FolderRepository(LactoseDbContext context) : IFolderRepository {
public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAsyncDisposable {
public void Create(Folder folder) => context.Folders.Add(folder);
public void Update(Guid id, Folder folder) {
@@ -25,4 +25,8 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository {
public Folder? Get(Guid id) => context.Folders.Find(id);
public IEnumerable<Folder> GetAll() => context.Folders;
public void Dispose() { context.Dispose(); }
public async ValueTask DisposeAsync() { await context.DisposeAsync(); }
}

View File

@@ -5,7 +5,7 @@ namespace Lactose.Repositories;
/// <summary>
/// Interface for folder repository operations.
/// </summary>
public interface IFolderRepository {
public interface IFolderRepository : IDisposable {
/// <summary>
/// Creates a new folder.
/// </summary>

View File

@@ -5,7 +5,7 @@ namespace Lactose.Repositories;
/// <summary>
/// Interface for managing settings in the repository.
/// </summary>
public interface ISettingsRepository {
public interface ISettingsRepository : IDisposable {
/// <summary>
/// Creates a settings entry in the repository. If a settings entry already exists, it will be overwritten.
/// </summary>

View File

@@ -4,7 +4,7 @@ using System.Data;
namespace Lactose.Repositories;
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository, IAsyncDisposable {
public void Create(Setting setting) {
//Check if the settings already exist
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
@@ -46,4 +46,8 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
if (existingSettings == null) throw new KeyNotFoundException("The setting with the specified name does not exist: " + name);
Delete(existingSettings);
}
public void Dispose() { context.Dispose(); }
public async ValueTask DisposeAsync() { await context.DisposeAsync(); }
}

View File

@@ -1,7 +1,9 @@
using Butter;
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Microsoft.AspNetCore.Identity;
using System.Diagnostics.CodeAnalysis;
namespace Lactose.Services;
@@ -9,32 +11,76 @@ interface IDbInitializer {
void Initialize();
}
class DbInitializer(
LactoseDbContext dbContext,
IPasswordHasher<User> passwordHasher
): IDbInitializer
{
class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> passwordHasher) : IDbInitializer {
public void Initialize() {
ArgumentNullException.ThrowIfNull(dbContext, nameof(dbContext));
var task = dbContext.Database.GetPendingMigrationsAsync();
task.ContinueWith(
(r) => {
if (r.Result.Any()) dbContext.Database.Migrate();
}
).Wait();
task.ContinueWith((r) => {
if (r.Result.Any()) dbContext.Database.Migrate();
}
)
.Wait();
// if(dbContext.Database.GetPendingMigrations().Any()) // Timeoout
if (dbContext.Users.Any()) return;
var user = new User {
Username = "admin",
Email = "admin@admin.com",
AccessLevel = EAccessLevel.Admin,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
user.Password = passwordHasher.HashPassword(user, "admin");
dbContext.Users.Add(user);
#region Users
if (!dbContext.Users.Any()) {
var user = new User {
Username = "admin",
Email = "admin@admin.com",
AccessLevel = EAccessLevel.Admin,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
user.Password = passwordHasher.HashPassword(user, "admin");
dbContext.Users.Add(user);
dbContext.SaveChanges();
}
#endregion
#region Settings
if (!dbContext.Settings.Any()) {
foreach (Settings settingRow in Enum.GetValues(typeof(Settings))) {
var setting = new Setting {
Name = settingRow.AsString(),
Value = settingRow.AsValueType() switch {
// note: ReSharper tries to convert this to a {} t when t == typeof(T). This actually adds
// an extra allocation and is even less readable.
// ReSharper disable ConvertTypeCheckPatternToNullCheck
Type t when t == typeof(bool) => "true",
Type t when t == typeof(int) => "0",
Type t when t == typeof(string) => string.Empty,
// ReSharper restore ConvertTypeCheckPatternToNullCheck
_ => throw new ArgumentOutOfRangeException(nameof(settingRow), settingRow, null)
}
};
dbContext.Settings.Add(setting);
}
dbContext.SaveChanges();
}
#endregion
#region Folders
if (!dbContext.Folders.Any()) {
dbContext.Folders.Add(
new Folder() {
BasePath = "/diary",
Active = false
}
);
}
dbContext.SaveChanges();
#endregion
}
}
}

View File

@@ -1,126 +1,30 @@
using Butter;
using Butter.Types;
using Butter.Dtos.Settings;
using Lactose.Models;
using Lactose.Repositories;
using System.Data;
using System.Diagnostics;
using static System.String;
namespace Lactose.Services;
public class FileSystemScannerService(
ILogger<FileSystemScannerService> logger,
IAssetRepository assetRepository,
IFolderRepository folderRepository,
ISettingsRepository settingsRepository
) : BackgroundService {
public bool IsRunning { get; private set; } = false;
public bool IsInitialized { get; private set; } = false;
public TimeSpan ScanInterval { get; private set; }
public class FileSystemScannerService(ILogger<FileSystemScannerService> logger, IServiceProvider serviceProvider)
: BackgroundService {
public bool IsRunning { get; private set; } = false;
public bool IsInitialized { get; private set; } = false;
public TimeSpan ScanInterval { get; private set; }
List<Folder> folders = [];
readonly List<Task> tasks = [];
// ReSharper disable ArrangeObjectCreationWhenTypeEvident
static readonly MimeTypeMap[] ImageMime = [
new("image/avif", [".avif", ".avifs"]),
new("image/bmp}", [".bmp"]),
new("image/cgm", [".cgm"]),
new("image/g3fax", [".g3"]),
new("image/gif", [".gif"]),
new("image/heic", [".heif", ".heic"]),
new("image/ief", [".ief"]),
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/pjpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfi", ".jfif", ".jfif-tbnl", ".jif"]),
new("image/png", [".png"]),
new("image/prs.btif", [".btif"]),
new("image/svg+xml", [".svg", ".svgz"]),
new("image/tiff", [".tif", ".tiff"]),
new("image/vnd.adobe.photoshop", [".psd"]),
new("image/vnd.djvu", [".djv", ".djvu"]),
new("image/vnd.dwg", [".dwg"]),
new("image/vnd.dxf", [".dxf"]),
new("image/vnd.fastbidsheet", [".fbs"]),
new("image/vnd.fpx", [".fpx"]),
new("image/vnd.fst", [".fst"]),
new("image/vnd.fujixerox.edmics-mmr", [".mmr"]),
new("image/vnd.fujixerox.edmics-rlc", [".rlc"]),
new("image/vnd.ms-modi", [".mdi"]),
new("image/vnd.net-fpx", [".npx"]),
new("image/vnd.wap.wbmp", [".wbmp"]),
new("image/vnd.xiff", [".xif"]),
new("image/webp", [".webp"]),
new("image/x-adobe-dng", [".dng"]),
new("image/x-canon-cr2", [".cr2"]),
new("image/x-canon-crw", [".crw"]),
new("image/x-cmu-raster", [".ras"]),
new("image/x-cmx", [".cmx"]),
new("image/x-epson-erf", [".erf"]),
new("image/x-freehand", [".fh", ".fh4", ".fh5", ".fh7", ".fhc"]),
new("image/x-fuji-raf", [".raf"]),
new("image/x-icns", [".icns"]),
new("image/x-icon", [".ico"]),
new("image/x-kodak-dcr", [".dcr"]),
new("image/x-kodak-k25", [".k25"]),
new("image/x-kodak-kdc", [".kdc"]),
new("image/x-minolta-mrw", [".mrw"]),
new("image/x-nikon-nef", [".nef"]),
new("image/x-olympus-orf", [".orf"]),
new("image/x-panasonic-raw", [".raw", ".rw2", ".rwl"]),
new("image/x-pcx", [".pcx"]),
new("image/x-pentax-pef", [".pef", ".ptx"]),
new("image/x-pict", [".pct", ".pic"]),
new("image/x-portable-anymap", [".pnm"]),
new("image/x-portable-bitmap", [".pbm"]),
new("image/x-portable-graymap", [".pgm"]),
new("image/x-portable-pixmap", [".ppm"]),
new("image/x-rgb", [".rgb"]),
new("image/x-sigma-x3f", [".x3f"]),
new("image/x-sony-arw", [".arw"]),
new("image/x-sony-sr2", [".sr2"]),
new("image/x-sony-srf", [".srf"]),
new("image/x-xbitmap", [".xbm"]),
new("image/x-xpixmap", [".xpm"]),
new("image/x-xwindowdump", [".xwd"])
];
static readonly MimeTypeMap[] VideoMime = [
new("video/3gpp", [".3gp"]),
new("video/3gpp2", [".3g2"]),
new("video/h261", [".h261"]),
new("video/h263", [".h263"]),
new("video/h264", [".h264"]),
new("video/jpeg", [".jpgv"]),
new("video/jpm", [".jpgm, .jpm"]),
new("video/mj2", [".mj2, .mjp2"]),
new("video/mp2t", [".ts"]),
new("video/mp4", [".mp4, .mp4v, .mpg4"]),
new("video/mpeg", [".m1v, .m2v, .mpa, .mpe, .mpeg, .mpg"]),
new("video/ogg", [".ogv"]),
new("video/quicktime", [".mov", ".qt"]),
new("video/vnd.fvt", [".fvt"]),
new("video/vnd.mpegurl", [".m4u", ".mxu"]),
new("video/vnd.ms-playready.media.pyv", [".pyv"]),
new("video/vnd.vivo", [".viv"]),
new("video/webm", [".webm"]),
new("video/x-f4v", [".f4v"]),
new("video/x-fli", [".fli"]),
new("video/x-flv", [".flv"]),
new("video/x-m4v", [".m4v"]),
new("video/x-matroska", [".mkv"]),
new("video/x-ms-asf", [".asf", ".asx"]),
new("video/x-ms-wm", [".wm"]),
new("video/x-ms-wmv", [".wmv"]),
new("video/x-ms-wmx", [".wmx"]),
new("video/x-ms-wvx", [".wvx"]),
new("video/x-msvideo", [".avi"]),
new("video/x-sgi-movie", [".movie"])
];
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
if(settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value == "false") {
// Resolve the repositories from the service provider
var settingsRepository = serviceProvider.GetRequiredService<ISettingsRepository>();
if (settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value == "false") {
logger.LogInformation("Folder scanning is disabled. Service will not start.");
return Task.CompletedTask;
}
var service = new Task(() => ServiceLogic(stoppingToken));
service.Start();
return service;
@@ -128,10 +32,14 @@ public class FileSystemScannerService(
//TODO: This really needs a decent name.
void ServiceLogic(CancellationToken stoppingToken) {
// Resolve the repositories from the service provider
using var folderRepository = serviceProvider.GetRequiredService<IFolderRepository>();
using var settingsRepository = serviceProvider.GetRequiredService<ISettingsRepository>();
PeriodicTimer scanTimer;
logger.LogInformation("Filesystem Scan Service starting...");
var value = settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value;
if (value != null && !int.TryParse(value, out var interval)) {
logger.LogWarning("Invalid scan interval setting. Defaulting to 30 minutes.");
interval = 30; // Default to 30 minutes if parsing fails
@@ -139,19 +47,20 @@ public class FileSystemScannerService(
ScanInterval = TimeSpan.FromMinutes(interval);
logger.LogInformation($"Service will scan every {ScanInterval.TotalMinutes} minutes.");
scanTimer = new (ScanInterval);
scanTimer = new(ScanInterval);
IsInitialized = true;
while (!stoppingToken.IsCancellationRequested) {
logger.LogInformation("Retrieving folder paths to scan...");
folders = folderRepository.GetAll().ToList();
// Wait for all tasks to complete before starting a new scan
if(tasks.Count > 0) {
if (tasks.Count > 0) {
logger.LogInformation("Waiting for {count} tasks to complete...", tasks.Count);
Task.WaitAll(tasks.ToArray(), stoppingToken);
IsRunning = false;
}
if (folders.Count != 0) {
logger.LogInformation("Found {count} folders to scan.", folders.Count);
logger.LogInformation("Starting Tasks for scanning folders...");
@@ -166,6 +75,8 @@ public class FileSystemScannerService(
}
void ScanFolder(string path) {
using var assetRepository = serviceProvider.GetRequiredService<IAssetRepository>();
List<string> filePaths = [];
List<string> folderPaths = [];
@@ -179,8 +90,7 @@ public class FileSystemScannerService(
folderPaths.ForEach(folderPath => { tasks.Add(Task.Run(() => ScanFolder(folderPath))); });
// Process all files
filePaths.ForEach(
filePath => {
filePaths.ForEach(filePath => {
// Check if the file is already in the database
if (assetRepository.FindByPath(filePath) != null) return;
@@ -188,45 +98,40 @@ public class FileSystemScannerService(
var finfo = new FileInfo(filePath);
// Determine the type of the file
string ext = finfo.Extension.ToLower();
string ext = finfo.Extension.ToLower();
EAssetType? type;
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
var mimeImg = ImageMime.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = VideoMime.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else return;
// Create a new asset
var asset = new Asset() {
OriginalPath = finfo.FullName,
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
Type = type.Value,
IsPubliclyShared = false,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
},
FileSize = finfo.Length,
Hash = []
Hash = []
};
//TODO: check if the file is already in the database
// Add the asset to the database
try { assetRepository.Insert(asset); }
catch (DuplicateNameException e) {
logger.LogError(e, $"Duplicate asset name \"{finfo.FullName}\", skipped."); }
try { assetRepository.Insert(asset); } catch (DuplicateNameException e) {
logger.LogError(e, $"Duplicate asset name \"{finfo.FullName}\", skipped.");
}
}
);
}
}
public struct MimeTypeMap(string mimeType, string[] extensions) {
public string MimeType { get; set; } = mimeType;
public string[] Extensions { get; set; } = extensions;
}