Merge pull request 'Async migration PR 2: Tag, Settings & JobRecord repositories' (#195) from feature/webapi/async-tag-settings-jobs into develop

Reviewed-on: #195
This commit was merged in pull request #195.
This commit is contained in:
2026-08-21 15:23:01 +00:00
19 changed files with 271 additions and 213 deletions
+4 -3
View File
@@ -101,10 +101,11 @@ public class AuthController(
/// <summary>
/// Checks whether user registration is currently enabled.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 if enabled, 403 if disabled.</returns>
[HttpGet("register")]
public ActionResult CheckRegistrationEnabled() {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
public async Task<ActionResult> CheckRegistrationEnabled(CancellationToken cancellationToken) {
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
}
@@ -117,7 +118,7 @@ public class AuthController(
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
[HttpPost("register")]
public async Task<ActionResult> Register([FromBody] UserRegisterDto dto, CancellationToken cancellationToken) {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
if (regSetting?.Value != "true")
return StatusCode(403, "Registration is currently disabled on this server");
+12 -4
View File
@@ -35,13 +35,17 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// <summary>
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
/// </summary>
/// <param name="page">The zero-based page number.</param>
/// <param name="pageSize">The number of records per page.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of past jobs and the total count.</returns>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
public async Task<ActionResult<PastJobsResponse>> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5, CancellationToken cancellationToken = default) {
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
var (jobs, total) = await jobManager.GetPastRootJobsAsync(page, pageSize, cancellationToken);
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
}
@@ -50,12 +54,16 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// Supports delta sync — pass <paramref name="since"/> to receive only children changed after that timestamp.
/// The <see cref="ChildrenResponse.Since"/> field contains the timestamp to use for the next delta poll.
/// </summary>
/// <param name="parentId">The parent job ID.</param>
/// <param name="since">Optional timestamp for delta sync.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The children of the job, plus the timestamp for the next delta poll.</returns>
[HttpGet("{parentId}/children")]
[Authorize(Roles = "Admin")]
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
public async Task<ActionResult<ChildrenResponse>> GetChildren(Guid parentId, [FromQuery] DateTime? since, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var children = jobManager.GetChildren(parentId, since);
var children = await jobManager.GetChildrenAsync(parentId, since, cancellationToken);
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
}
+17 -11
View File
@@ -27,22 +27,24 @@ public class SettingsController(
/// Creates a new application setting.
/// </summary>
/// <param name="settingDto">The setting to create.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Create([FromBody] SettingDto settingDto) {
public async Task<ActionResult> Create([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
settingsRepository.Create(
await settingsRepository.CreateAsync(
new Setting {
Name = settingDto.Name,
Value = settingDto.Value,
Description = settingDto.Description,
Type = settingDto.Type,
DisplayType = settingDto.DisplayType
}
},
cancellationToken
);
return Ok();
@@ -51,28 +53,30 @@ public class SettingsController(
/// <summary>
/// Gets all application settings.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of all settings.</returns>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<SettingDto> Get() {
public async Task<ActionResult<SettingDto>> Get(CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
return Ok((await settingsRepository.GetAllAsync(cancellationToken)).Select(s => s.ToSettingDto()).ToList());
}
/// <summary>
/// Updates an existing application setting.
/// </summary>
/// <param name="settingDto">The updated setting data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public ActionResult Update([FromBody] SettingDto settingDto) {
public async Task<ActionResult> Update([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Update(settingDto.ToSetting());
await settingsRepository.UpdateAsync(settingDto.ToSetting(), cancellationToken);
return Ok();
}
@@ -80,29 +84,31 @@ public class SettingsController(
/// Deletes a specific application setting.
/// </summary>
/// <param name="settingDto">The setting to delete.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpDelete]
[Authorize(Roles = "Admin")]
public ActionResult Delete([FromBody] SettingDto settingDto) {
public async Task<ActionResult> Delete([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Delete(settingDto.ToSetting());
await settingsRepository.DeleteAsync(settingDto.ToSetting(), cancellationToken);
return Ok();
}
/// <summary>
/// Deletes all application settings.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
[Route("delete-all")]
public ActionResult DeleteAll() {
public async Task<ActionResult> DeleteAll(CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Delete();
await settingsRepository.DeleteAllAsync(cancellationToken);
return Ok();
}
}
+35 -28
View File
@@ -29,10 +29,11 @@ public class TagController(
/// <param name="id">The tag ID.</param>
/// <param name="children">Whether to include child tags.</param>
/// <param name="ancestors">Whether to include ancestor tags.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
Tag? tag = tagRepository.Find(id);
public async Task<ActionResult<TagDto>> Get(Guid id, bool children = false, bool ancestors = false, CancellationToken cancellationToken = default) {
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
if (tag == null) { return NotFound(); }
var tagDto = TagsMapper.ToTagDto(tag);
@@ -40,7 +41,7 @@ public class TagController(
if (children) {
tagDto.Children = [];
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
(await tagRepository.GetChildrenAsync(tag.Id, cancellationToken)).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
}
//TODO: Protect from overflow
@@ -65,14 +66,15 @@ public class TagController(
/// Searches tags with pagination and optional search term.
/// </summary>
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of tags matching the search criteria.</returns>
[HttpGet]
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
public async Task<ActionResult<List<TagDto>>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
pagedSearch.Search ??= string.Empty;
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
List<Tag> tags = await tagRepository.SearchAsync(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize, cancellationToken);
List<TagDto> tagsDto = [];
tags.ForEach(
@@ -91,14 +93,15 @@ public class TagController(
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="tagDto">The updated tag data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost("{id}")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto, CancellationToken cancellationToken) {
IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent);
IStatusCodeActionResult result = await UpdateTag(id, tagDto.Name, tagDto.Parent, cancellationToken);
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
return result;
}
@@ -107,18 +110,19 @@ public class TagController(
/// Updates multiple tags in a bulk operation.
/// </summary>
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto, CancellationToken cancellationToken) {
foreach (Guid id in tagBulkDto.Ids) {
IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent);
IStatusCodeActionResult result = await UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent, cancellationToken);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
await tagRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -126,13 +130,14 @@ public class TagController(
/// Deletes a tag by its ID (soft delete).
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete("{id}")]
public ActionResult Delete([FromRoute] Guid id) {
IStatusCodeActionResult result = DeleteTag(id);
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
return (ActionResult)result;
}
@@ -141,17 +146,18 @@ public class TagController(
/// Deletes multiple tags in a bulk operation.
/// </summary>
/// <param name="ids">The list of tag IDs to delete.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
foreach (Guid id in ids) {
IStatusCodeActionResult result = DeleteTag(id);
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
await tagRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -159,29 +165,30 @@ public class TagController(
/// Creates a new tag.
/// </summary>
/// <param name="tagDto">The tag creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPut]
public ActionResult Create([FromBody] TagCreateDto tagDto) {
public async Task<ActionResult> Create([FromBody] TagCreateDto tagDto, CancellationToken cancellationToken) {
if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
if (await tagRepository.FindByNameAsync(tagDto.Name, cancellationToken) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
var tag = new Tag() {
Name = tagDto.Name
};
if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) {
if (tagDto.Parent != null && await tagRepository.FindAsync(tagDto.Parent.Value, cancellationToken) == null) {
return BadRequest($"No Tag with id {tagDto.Parent} found");
}
tag.ParentId = tagDto.Parent;
tagRepository.Insert(tag);
tagRepository.Save();
await tagRepository.SaveAsync(cancellationToken);
return Ok();
}
IStatusCodeActionResult DeleteTag(Guid id) {
Tag? tag = tagRepository.Find(id);
async Task<IStatusCodeActionResult> DeleteTag(Guid id, CancellationToken cancellationToken) {
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
if (tag == null) { return NotFound($"Tag {id} not found"); }
tagRepository.Delete(tag);
@@ -189,14 +196,14 @@ public class TagController(
}
IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) {
async Task<IStatusCodeActionResult> UpdateTag(Guid id, string? name, Guid? parentId, CancellationToken cancellationToken) {
Tag? tag = tagRepository.Find(id);
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
if (tag == null) { return NotFound($"Tag {id} not found"); }
if (name != null) {
Tag? foundTag = tagRepository.FindByName(name);
Tag? foundTag = await tagRepository.FindByNameAsync(name, cancellationToken);
if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); }
@@ -206,7 +213,7 @@ public class TagController(
if (parentId == null) { return Ok(); }
if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else {
Tag? parent = tagRepository.Find(parentId.Value);
Tag? parent = await tagRepository.FindAsync(parentId.Value, cancellationToken);
if (parent == null) { return NotFound($"Parent tag on {id} was not found"); }
tag.Parent = parent;
+6 -6
View File
@@ -192,12 +192,12 @@ public sealed class ConvertAnimatedJob : Job {
JobStatus.Start();
Logger.LogInformation("Starting master animated conversion job.");
var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
var convertedPathSetting = SettingsRepository.Get(Settings.ConvertedPath.AsString());
var convertedFormatSetting = SettingsRepository.Get(Settings.ConvertedFormat.AsString());
var ffmpegPathSetting = SettingsRepository.Get(Settings.FfmpegPath.AsString());
var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString());
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
var convertedPathSetting = await SettingsRepository.GetAsync(Settings.ConvertedPath.AsString(), token);
var convertedFormatSetting = await SettingsRepository.GetAsync(Settings.ConvertedFormat.AsString(), token);
var ffmpegPathSetting = await SettingsRepository.GetAsync(Settings.FfmpegPath.AsString(), token);
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (thumbPathSetting == null || convertedPathSetting == null
|| string.IsNullOrWhiteSpace(thumbPathSetting.Value) || string.IsNullOrWhiteSpace(convertedPathSetting.Value)) {
+1 -1
View File
@@ -59,7 +59,7 @@ public sealed class FileSystemCrawlJob : Job {
var settingsRepo = Scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
Guid? uploaderId = null;
var uploaderSetting = settingsRepo.Get(Settings.SystemUploaderId.AsString())?.Value;
var uploaderSetting = (await settingsRepo.GetAsync(Settings.SystemUploaderId.AsString(), token))?.Value;
if (Guid.TryParse(uploaderSetting, out var parsed)) uploaderId = parsed;
// Separate context for asset reconciliation so the change tracker can be cleared per directory
+12 -11
View File
@@ -35,10 +35,10 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary>
/// Returns a page of past root jobs from the database.
/// </summary>
public (List<JobStatusDto> Jobs, int TotalCount) GetPastRootJobs(int page, int pageSize) {
public async Task<(List<JobStatusDto> Jobs, int TotalCount)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = repo.GetPastRootJobs(page, pageSize, out var total);
var (records, total) = await repo.GetPastRootJobsAsync(page, pageSize, cancellationToken);
logger.LogInformation("GetPastRootJobs page={Page} pageSize={PageSize}: {Count} records out of {Total}", page, pageSize, records.Count, total);
return (records.Select(r => r.ToJobStatusDto()).ToList(), total);
}
@@ -49,7 +49,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// </summary>
/// <param name="parentId">The parent job ID.</param>
/// <param name="since">If set, only children changed after this timestamp are returned. Null returns all children.</param>
public List<JobStatusDto> GetChildren(Guid parentId, DateTime? since) {
/// <param name="cancellationToken">Token to cancel the operation.</param>
public async Task<List<JobStatusDto>> GetChildrenAsync(Guid parentId, DateTime? since, CancellationToken cancellationToken) {
var seenIds = new HashSet<Guid>();
// Active children still in memory
@@ -71,8 +72,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = since is null
? repo.GetChildren(parentId)
: repo.GetChildrenModifiedSince(parentId, since.Value);
? await repo.GetChildrenAsync(parentId, cancellationToken)
: await repo.GetChildrenModifiedSinceAsync(parentId, since.Value, cancellationToken);
results.AddRange(records.Where(r => !seenIds.Contains(r.Id)).Select(r => r.ToJobStatusDto()));
} catch (Exception ex) {
logger.LogWarning(ex, "Could not load children for parent {ParentId}", parentId);
@@ -178,14 +179,14 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
}
///<inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
logger.LogInformation("Preparing JobManager...");
// Load past jobs from database on startup
try {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
foreach (var record in repo.GetAll()) {
foreach (var record in await repo.GetAllAsync(stoppingToken)) {
pastJobs.TryAdd(record.Id, record.ToJobStatusDto());
}
logger.LogInformation($"Loaded {pastJobs.Count} past job(s) from database.");
@@ -197,7 +198,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
try {
using var scope = serviceProvider.CreateScope();
var settingsRepo = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
var maxJobsSetting = settingsRepo.Get(Settings.MaxConcurrentJobs.AsString());
var maxJobsSetting = await settingsRepo.GetAsync(Settings.MaxConcurrentJobs.AsString(), stoppingToken);
if (maxJobsSetting != null && int.TryParse(maxJobsSetting.Value, out var maxJobs) && maxJobs > 0) {
MaxConcurrentJobs = maxJobs;
logger.LogInformation("Loaded MaxConcurrentJobs from settings: {Value}", MaxConcurrentJobs);
@@ -227,7 +228,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
}
foreach (var job in queued) {
job.Done += (sender, status) => {
job.Done += async (sender, status) => {
var j = (Job)sender!;
var dto = j.ToJobStatusDto();
@@ -247,7 +248,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
Progress = dto.Progress
};
repo.Insert(record);
repo.Save();
await repo.SaveAsync(CancellationToken.None);
} catch (Exception ex) {
logger.LogError(ex, "Failed to persist job {JobId}", status.Id);
}
@@ -266,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
);
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
await service;
}
}
+1 -1
View File
@@ -126,7 +126,7 @@ public sealed class MetadataJob : Job {
JobStatus.Start();
batchSize = 200;
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
+1 -1
View File
@@ -137,7 +137,7 @@ public sealed class PHashJob : Job {
JobStatus.Start();
batchSize = 200;
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
+4 -4
View File
@@ -189,10 +189,10 @@ public class PreviewJob : Job {
JobStatus.Start();
Logger.LogInformation("Starting master Preview job.");
var previewPathSetting = SettingsRepository.Get(Settings.PreviewPath.AsString());
var previewSizeSetting = SettingsRepository.Get(Settings.PreviewSize.AsString());
var previewQualitySetting = SettingsRepository.Get(Settings.PreviewQuality.AsString());
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
var previewPathSetting = await SettingsRepository.GetAsync(Settings.PreviewPath.AsString(), token);
var previewSizeSetting = await SettingsRepository.GetAsync(Settings.PreviewSize.AsString(), token);
var previewQualitySetting = await SettingsRepository.GetAsync(Settings.PreviewQuality.AsString(), token);
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (previewPathSetting == null) {
Logger.LogError("Preview path not found. Cannot proceed.");
+4 -4
View File
@@ -189,10 +189,10 @@ public class ThumbnailJob : Job {
JobStatus.Start();
Logger.LogInformation("Starting master Thumbnail job.");
var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString());
var thumbQualitySetting = SettingsRepository.Get(Settings.ThumbnailQuality.AsString());
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
var thumbQualitySetting = await SettingsRepository.GetAsync(Settings.ThumbnailQuality.AsString(), token);
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (thumbPathSetting == null) {
Logger.LogError("Thumbnail path not found. Cannot proceed.");
+16 -9
View File
@@ -19,38 +19,45 @@ public interface IJobRecordRepository : IDisposable {
/// <summary>
/// Retrieves all job records, ordered by most recent.
/// </summary>
List<JobRecord> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<JobRecord>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Retrieves a page of past (completed/failed/canceled) root job records.
/// Retrieves a page of past (completed/failed/canceled) root job records with the total count.
/// </summary>
/// <param name="page">The page number.</param>
/// <param name="pageSize">The number of records per page.</param>
/// <param name="total">The total number of past root jobs.</param>
List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The page of records and the total number of past root jobs.</returns>
Task<(List<JobRecord> Jobs, int Total)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Retrieves child job records for a given parent job.
/// </summary>
/// <param name="parentId">The parent job ID.</param>
List<JobRecord> GetChildren(Guid parentId);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<JobRecord>> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken);
/// <summary>
/// Retrieves child job records for a given parent job that have been modified since the specified timestamp.
/// </summary>
/// <param name="parentId">The parent job ID.</param>
/// <param name="since">Only return records modified after this timestamp.</param>
List<JobRecord> GetChildrenModifiedSince(Guid parentId, DateTime since);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<JobRecord>> GetChildrenModifiedSinceAsync(Guid parentId, DateTime since, CancellationToken cancellationToken);
/// <summary>
/// Retrieves a job record by its ID.
/// </summary>
/// <param name="id">The job record ID.</param>
JobRecord? GetById(Guid id);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<JobRecord?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Deletes job records that finished before the specified cutoff date.
/// </summary>
/// <param name="cutoff">The cutoff date.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of deleted records.</returns>
int DeleteOldJobs(DateTime cutoff);
Task<int> DeleteOldJobsAsync(DateTime cutoff, CancellationToken cancellationToken);
/// <summary>
/// Persists changes to the database.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
}
+20 -13
View File
@@ -10,41 +10,48 @@ public interface ISettingsRepository : IDisposable {
/// Creates a settings entry in the repository. If a settings entry already exists, it will be overwritten.
/// </summary>
/// <param name="setting">The settings to create.</param>
public void Create(Setting setting);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task CreateAsync(Setting setting, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the settings from the repository.
/// Retrieves all settings from the repository.
/// </summary>
/// <returns>The settings if found; otherwise, null.</returns>
public IEnumerable<Setting> Get();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all settings.</returns>
Task<List<Setting>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Try to get a specific setting by its name.
/// </summary>
/// <param name="name">name key of the setting</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns> The setting if found; otherwise, null.</returns>
public Setting? Get(string name);
Task<Setting?> GetAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Updates the settings in the repository.
/// </summary>
/// <param name="setting">The settings to update.</param>
public void Update(Setting setting);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task UpdateAsync(Setting setting, CancellationToken cancellationToken);
/// <summary>
/// Deletes the settings from the repository.
/// Deletes all settings from the repository.
/// </summary>
public void Delete();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task DeleteAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Delete a specific setting by its name.
/// </summary>
/// <param name="name">name key of the setting to remove</param>
public void Delete(string name);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task DeleteAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Deletes a specific setting from the repository.
/// </summary>
/// <param name="setting">Setting instance to delete</param>
public void Delete(Setting setting);
}
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task DeleteAsync(Setting setting, CancellationToken cancellationToken);
}
+22 -17
View File
@@ -12,51 +12,56 @@ public interface ITagRepository : IDisposable {
/// <param name="searchQuery">the search query</param>
/// <param name="page"> the number of the page for the given query</param>
/// <param name="pageSize">the size of the page</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>list of tags of type <see cref="Tag"/> with the size <paramref name="pageSize"/></returns>
List<Tag> Search(string searchQuery, int page, int pageSize);
Task<List<Tag>> SearchAsync(string searchQuery, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Returns all the tags
/// Returns all the tags.
/// </summary>
/// <returns></returns>
IEnumerable<Tag> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all tags.</returns>
Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Find a specific Tag given the id
/// Find a specific Tag given the id.
/// </summary>
/// <param name="id">identifier of the tag</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>the <see cref="Tag"/> or null</returns>
Tag? Find(Guid id);
Task<Tag?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Find a specific Tag given the name
/// Find a specific Tag given the name.
/// </summary>
/// <param name="name">the exact name of the tag</param>
/// <returns></returns>
Tag? FindByName(string name);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>the <see cref="Tag"/> or null</returns>
Task<Tag?> FindByNameAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Return the list of the children of the given tag
/// Return the list of the children of the given tag.
/// </summary>
/// <param name="id">identifier of the tag</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>the list of <see cref="Tag"/>, could be empty</returns>
List<Tag> GetChildren(Guid id);
Task<List<Tag>> GetChildrenAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Saves all changes to the repository.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Deletes the given tag from the repository.
/// </summary>
/// <param name="tag"></param>
/// <param name="tag">The tag to delete.</param>
void Delete(Tag tag);
/// <summary>
/// Insert a new Tag
/// Insert a new Tag.
/// </summary>
/// <param name="tag"></param>
/// <param name="tag">The tag to insert.</param>
void Insert(Tag tag);
}
+19 -16
View File
@@ -1,6 +1,7 @@
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -19,50 +20,52 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
}
/// <inheritdoc />
public List<JobRecord> GetAll() =>
public Task<List<JobRecord>> GetAllAsync(CancellationToken cancellationToken) =>
context.JobRecords
.OrderByDescending(j => j.Finished ?? j.Created)
.ToList();
.ToListAsync(cancellationToken);
static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.CompletedWithErrors, EJobStatus.Failed, EJobStatus.Canceled];
/// <inheritdoc />
public List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total) {
public async Task<(List<JobRecord> Jobs, int Total)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
var query = context.JobRecords
.Where(j => j.ParentJobId == null && PastStatuses.Contains(j.Status));
total = query.Count();
var total = await query.CountAsync(cancellationToken);
return query
var jobs = await query
.OrderByDescending(j => j.Finished ?? j.Created)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
.ToListAsync(cancellationToken);
return (jobs, total);
}
/// <inheritdoc />
public List<JobRecord> GetChildren(Guid parentId) =>
public Task<List<JobRecord>> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken) =>
context.JobRecords
.Where(j => j.ParentJobId == parentId)
.OrderBy(j => j.Created)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public List<JobRecord> GetChildrenModifiedSince(Guid parentId, DateTime since) =>
public Task<List<JobRecord>> GetChildrenModifiedSinceAsync(Guid parentId, DateTime since, CancellationToken cancellationToken) =>
context.JobRecords
.Where(j => j.ParentJobId == parentId && j.ModifiedAt != null && j.ModifiedAt > since)
.OrderBy(j => j.Created)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public JobRecord? GetById(Guid id) =>
context.JobRecords.Find(id);
public async Task<JobRecord?> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
await context.JobRecords.FindAsync([id], cancellationToken);
/// <inheritdoc />
public int DeleteOldJobs(DateTime cutoff) {
var old = context.JobRecords
public async Task<int> DeleteOldJobsAsync(DateTime cutoff, CancellationToken cancellationToken) {
var old = await context.JobRecords
.Where(j => j.Finished != null && j.Finished < cutoff)
.ToList();
.ToListAsync(cancellationToken);
var count = old.Count;
if (count > 0) {
context.JobRecords.RemoveRange(old);
@@ -71,7 +74,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
}
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public void Dispose() => context.Dispose();
+25 -22
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
using System.Data;
namespace Lactose.Repositories;
@@ -7,32 +8,33 @@ namespace Lactose.Repositories;
/// <inheritdoc />
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
/// <inheritdoc />
public void Create(Setting setting) {
public async Task CreateAsync(Setting setting, CancellationToken cancellationToken) {
//Check if the settings already exist
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
if (existingSettings != null)
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
if (existingSettings != null)
throw new DuplicateNameException("A setting with the same key already exists: " + setting.Name);
SettingChange(setting);
//Adds the new settings
context.Settings.Add(setting);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Setting> Get() => context.Settings.AsEnumerable();
public Task<List<Setting>> GetAllAsync(CancellationToken cancellationToken) => context.Settings.ToListAsync(cancellationToken);
/// <inheritdoc />
public Setting? Get(string name) => context.Settings.FirstOrDefault(s => s.Name == name);
public Task<Setting?> GetAsync(string name, CancellationToken cancellationToken) =>
context.Settings.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
/// <inheritdoc />
public void Update(Setting setting) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
public async Task UpdateAsync(Setting setting, CancellationToken cancellationToken) {
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
if (existingSettings != null) {
if (existingSettings.Value != setting.Value) SettingChange(setting);
existingSettings.Value = setting.Value;
context.Settings.Update(existingSettings);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
} else {
// If the settings entry does not exist
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
@@ -40,37 +42,38 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
}
/// <inheritdoc />
public void Delete() {
public async Task DeleteAllAsync(CancellationToken cancellationToken) {
context.Settings.RemoveRange(context.Settings);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public void Delete(Setting setting) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
public async Task DeleteAsync(Setting setting, CancellationToken cancellationToken) {
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
if (existingSettings != null) {
context.Settings.Remove(existingSettings);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
} else {
// If the settings entry does not exist
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
}
}
/// <inheritdoc />
public void Delete(string name) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == name);
public async Task DeleteAsync(string name, CancellationToken cancellationToken) {
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
if (existingSettings == null) throw new KeyNotFoundException("The setting with the specified name does not exist: " + name);
Delete(existingSettings);
context.SaveChanges();
context.Settings.Remove(existingSettings);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Occurs when a setting value changes.
/// </summary>
public static event EventHandler<Setting>? SettingChanged;
static void OnSettingChanged(Setting e) => SettingChanged?.Invoke(null, e);
//TODO: May need to be change on a Event based system, because adding here all the Setting Actions could be too much ramification
void SettingChange(Setting setting) {
OnSettingChanged(setting);
@@ -91,4 +94,4 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
/// <inheritdoc />
public void Dispose() => context.Dispose();
}
}
+2 -2
View File
@@ -18,8 +18,8 @@ public class StatsRepository(
/// <inheritdoc />
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
var thumbnailSizeSetting = await settingsRepo.GetAsync(Settings.ThumbnailSize.AsString(), cancellationToken);
var previewSizeSetting = await settingsRepo.GetAsync(Settings.PreviewSize.AsString(), cancellationToken);
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
+11 -8
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -9,25 +10,27 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
public void Dispose() => context.Dispose();
/// <inheritdoc />
public List<Tag> Search(string searchQuery, int page = 0, int pageSize = 200) =>
public Task<List<Tag>> SearchAsync(string searchQuery, int page, int pageSize, CancellationToken cancellationToken) =>
context.Tags.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))
.OrderByDescending(t => t.Name)
.Skip((page) * pageSize)
.Take(pageSize)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Tag> GetAll() => context.Tags;
public Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken) => context.Tags.ToListAsync(cancellationToken);
/// <inheritdoc />
public Tag? Find(Guid id) => context.Tags.Find(id);
public async Task<Tag?> FindAsync(Guid id, CancellationToken cancellationToken) =>
await context.Tags.FindAsync([id], cancellationToken);
/// <inheritdoc />
public Tag? FindByName(string name) => context.Tags.FirstOrDefault(t => t.Name == name);
public Task<Tag?> FindByNameAsync(string name, CancellationToken cancellationToken) =>
context.Tags.FirstOrDefaultAsync(t => t.Name == name, cancellationToken);
/// <inheritdoc />
public List<Tag> GetChildren(Guid id) => context.Tags.Where(t=>t.ParentId==id).ToList();
public Task<List<Tag>> GetChildrenAsync(Guid id, CancellationToken cancellationToken) =>
context.Tags.Where(t => t.ParentId == id).ToListAsync(cancellationToken);
/// <inheritdoc />
public void Delete(Tag tag) => context.Tags.Remove(tag);
@@ -36,5 +39,5 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
public void Insert(Tag tag) => context.Tags.Add(tag);
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
}
+59 -52
View File
@@ -17,42 +17,45 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
JobManager? jobManager;
/// <inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
var service = new Task(() => {
logger?.LogInformation("Preparing JobScheduler...");
jobManager = serviceProvider.GetRequiredService<JobManager>();
logger?.LogInformation("JobScheduler started!");
// fetch settings on startup
FetchSettings();
// listen for settings changes
SettingsRepository.SettingChanged += OnSettingsChanged;
// keep the task alive until the service is stopping
stoppingToken.WaitHandle.WaitOne();
}, stoppingToken
);
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
// wait until the application has started before preparing the scheduler
var startedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
lifetime.ApplicationStarted.Register(() => startedTcs.SetResult());
try {
await startedTcs.Task;
logger?.LogInformation("Preparing JobScheduler...");
jobManager = serviceProvider.GetRequiredService<JobManager>();
logger?.LogInformation("JobScheduler started!");
// fetch settings on startup
await FetchSettingsAsync(stoppingToken);
// listen for settings changes
SettingsRepository.SettingChanged += OnSettingsChanged;
// keep the service alive until the host is stopping
await Task.Delay(Timeout.Infinite, stoppingToken);
} catch (OperationCanceledException) {
// normal shutdown
}
}
void FetchSettings() {
async Task FetchSettingsAsync(CancellationToken cancellationToken = default) {
logger?.LogInformation("Fetching settings...");
using var scope = serviceProvider.CreateScope();
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
var prevFolderScanEnabled = folderScanEnabled;
// check if folder scanning is enabled
folderScanEnabled = bool.Parse(settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value ?? "false");
folderScanEnabled = bool.Parse((await settingsRepository.GetAsync(Settings.FolderScanEnabled.AsString(), cancellationToken))?.Value ?? "false");
if (folderScanEnabled && !prevFolderScanEnabled) {
FolderRepository.FolderAdded += OnFolderAdded;
FolderRepository.FolderRemoved += OnFolderRemoved;
// defaults to 30 minutes
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
int.TryParse((await settingsRepository.GetAsync(Settings.FolderScanInterval.AsString(), cancellationToken))?.Value ?? "30", out var interval);
fileSystemCrawl = new Timer(QueueFileSystemCrawlTimer, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
logger?.LogInformation($"Periodic folder scanning is now enabled every {interval} minutes.");
var folderRepository = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
folders = folderRepository.GetAll().Where(f => f.Active).ToList();
} else if(!folderScanEnabled && prevFolderScanEnabled) {
@@ -63,13 +66,13 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation("Periodic folder scanning is now disabled.");
} else if (folderScanEnabled && prevFolderScanEnabled) {
// update interval
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
int.TryParse((await settingsRepository.GetAsync(Settings.FolderScanInterval.AsString(), cancellationToken))?.Value ?? "30", out var interval);
fileSystemCrawl?.Change(TimeSpan.Zero, TimeSpan.FromMinutes(interval));
logger?.LogInformation($"Periodic folder scanning interval updated to {interval} minutes.");
}
// Job cleanup timer
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
int.TryParse((await settingsRepository.GetAsync(Settings.JobRetentionDays.AsString(), cancellationToken))?.Value, out var retentionDays);
if (retentionDays > 0) {
if (jobCleanup == null) {
jobCleanup = new Timer(RunJobCleanup, null, TimeSpan.Zero, TimeSpan.FromHours(1));
@@ -81,70 +84,70 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation("Job cleanup is now disabled (retention set to 0 or invalid).");
}
EnsureJobSettings(settingsRepository);
await EnsureJobSettingsAsync(settingsRepository, cancellationToken);
}
void EnsureJobSettings(ISettingsRepository settingsRepository) {
async Task EnsureJobSettingsAsync(ISettingsRepository settingsRepository, CancellationToken cancellationToken) {
var batchSizeName = Settings.JobBatchSize.AsString();
if (settingsRepository.Get(batchSizeName) == null) {
settingsRepository.Create(new Setting {
if (await settingsRepository.GetAsync(batchSizeName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = batchSizeName,
Value = "200",
Description = "Number of assets to process per sub-job batch.",
Type = EType.Integer,
DisplayType = DisplayType.IntNumber
});
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = 200.", batchSizeName);
}
var convertedPathName = Settings.ConvertedPath.AsString();
if (settingsRepository.Get(convertedPathName) == null) {
settingsRepository.Create(new Setting {
if (await settingsRepository.GetAsync(convertedPathName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = convertedPathName,
Value = Path.Combine(Environment.CurrentDirectory, "converted"),
Description = "Path where converted videos for animated assets (GIF -> mp4) are stored.",
Type = EType.String,
DisplayType = DisplayType.Text
});
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = {Value}.", convertedPathName, Path.Combine(Environment.CurrentDirectory, "converted"));
}
var convertedFormatName = Settings.ConvertedFormat.AsString();
if (settingsRepository.Get(convertedFormatName) == null) {
settingsRepository.Create(new Setting {
if (await settingsRepository.GetAsync(convertedFormatName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = convertedFormatName,
Value = "mp4",
Description = "Container format for converted videos (e.g. mp4).",
Type = EType.String,
DisplayType = DisplayType.Text
});
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = mp4.", convertedFormatName);
}
var ffmpegPathName = Settings.FfmpegPath.AsString();
if (settingsRepository.Get(ffmpegPathName) == null) {
settingsRepository.Create(new Setting {
if (await settingsRepository.GetAsync(ffmpegPathName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = ffmpegPathName,
Value = "ffmpeg",
Description = "Path to the ffmpeg executable. Defaults to 'ffmpeg' on PATH.",
Type = EType.String,
DisplayType = DisplayType.Text
});
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = ffmpeg.", ffmpegPathName);
}
}
void RunJobCleanup(object? state) {
async void RunJobCleanup(object? state) {
try {
using var scope = serviceProvider.CreateScope();
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
int.TryParse((await settingsRepository.GetAsync(Settings.JobRetentionDays.AsString(), CancellationToken.None))?.Value, out var retentionDays);
if (retentionDays <= 0) return;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var deleted = repo.DeleteOldJobs(cutoff);
repo.Save();
var deleted = await repo.DeleteOldJobsAsync(cutoff, CancellationToken.None);
await repo.SaveAsync(CancellationToken.None);
if (jobManager != null) {
jobManager.CleanupPastJobs(cutoff);
@@ -167,16 +170,20 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation($"Folder removed: {e.Id}");
}
void OnSettingsChanged(object? sender, Setting e) {
var name = e.Name;
if (name == Settings.FolderScanEnabled.AsString()
|| name == Settings.FolderScanInterval.AsString()
|| name == Settings.JobRetentionDays.AsString()) {
FetchSettings();
}
if (name == Settings.MaxConcurrentJobs.AsString() && jobManager != null && int.TryParse(e.Value, out var maxJobs) && maxJobs > 0) {
jobManager.MaxConcurrentJobs = maxJobs;
logger?.LogInformation("MaxConcurrentJobs updated to {Value}.", maxJobs);
async void OnSettingsChanged(object? sender, Setting e) {
try {
var name = e.Name;
if (name == Settings.FolderScanEnabled.AsString()
|| name == Settings.FolderScanInterval.AsString()
|| name == Settings.JobRetentionDays.AsString()) {
await FetchSettingsAsync();
}
if (name == Settings.MaxConcurrentJobs.AsString() && jobManager != null && int.TryParse(e.Value, out var maxJobs) && maxJobs > 0) {
jobManager.MaxConcurrentJobs = maxJobs;
logger?.LogInformation("MaxConcurrentJobs updated to {Value}.", maxJobs);
}
} catch (Exception ex) {
logger?.LogWarning(ex, "Failed to apply settings change for {SettingName}.", e.Name);
}
}