Files
MilkyShots/Lactose/Jobs/MetadataJob.cs
T
REDCODE 9d9491253b refactor: clean up imports, simplify checks, and add SearchDropdown component
- Remove unused using directives across C# and Razor files
- Remove unused IServiceProvider from SettingsRepository
- Simplify null/empty string checks in StatsRepository
- Add null-safe navigation for Albums/Tags in stats queries
- Initialize Asset.Hash default to prevent null refs
- Deduplicate AssetIds in AssetPicker
- Add OnStartedWaiting/OnFinishedWaiting/OnProgressChanged to Job
- Add global SearchDropdown component with keyboard nav
- Fix XML doc param mismatches
2026-07-12 20:47:24 +02:00

193 lines
6.9 KiB
C#

using Butter.Settings;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
namespace Lactose.Jobs;
/// <summary>
/// Extracts resolution metadata for image assets missing it.
/// Master creates one sub-job per batch of assets.
/// </summary>
public sealed class MetadataJob : Job {
MetadataJob? ParentJob;
Asset[]? Batch;
IAssetRepository AssetRepository;
ISettingsRepository? SettingsRepository;
JobManager? JobManager;
ILogger<MetadataJob> Logger;
int processedAssets;
int failedAssets;
int totalAssets = 1;
int batchSize;
List<Job> childJobs = [];
/// <inheritdoc />
public override string Name { get; }
/// <summary>
/// Creates a master metadata extraction job.
/// </summary>
public MetadataJob(ILogger<MetadataJob> logger, IAssetRepository assetRepository, ISettingsRepository settingsRepository, JobManager jobManager) {
Logger = logger;
AssetRepository = assetRepository;
SettingsRepository = settingsRepository;
JobManager = jobManager;
Name = "Metadata Extraction Job";
}
/// <summary>
/// Creates a sub-job to extract metadata for a batch of assets.
/// </summary>
public MetadataJob(MetadataJob parentJob, IEnumerable<Asset> batch, int batchSize, ILogger<MetadataJob> logger, IAssetRepository assetRepository) {
ParentJob = parentJob;
ParentJobId = parentJob.Id;
Batch = batch.ToArray();
Name = $"Metadata Batch ({Batch.Length} assets)";
Logger = logger;
AssetRepository = assetRepository;
this.batchSize = batchSize;
totalAssets = Batch.Length;
}
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
if (Batch == null || Batch.Length == 0) {
Logger.LogWarning("Sub-job started with an empty batch. Completing.");
JobStatus.Complete("Empty batch — nothing to process.");
return;
}
JobStatus.Start();
for (int i = 0; i < Batch.Length; i++) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
var asset = Batch[i];
JobStatus.UpdateProgress((float)i / Batch.Length, $"Processing {asset.OriginalPath}");
try {
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
Logger.LogWarning($"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}");
failedAssets++;
continue;
}
using var image = Image.Load(asset.OriginalPath);
asset.ResolutionWidth = image.Width;
asset.ResolutionHeight = image.Height;
processedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to extract metadata for asset ID {asset.Id}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
if (failedAssets == Batch.Length)
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
else if (failedAssets > 0)
JobStatus.CompleteWithErrors(msg);
else
JobStatus.Complete(msg);
}
async Task MasterJob(CancellationToken token) {
if (JobManager == null) {
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
JobStatus.Fail("JobManager is not available.");
return;
}
JobStatus.Start();
batchSize = 200;
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingMetadata();
Logger.LogInformation("Found {Count} image assets missing metadata.", totalAssets);
if (totalAssets == 0) {
JobStatus.Complete("No image assets need metadata extraction.");
return;
}
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingMetadata(batchSize, offset);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
var job = JobManager.CreateJob<MetadataJob>(this, batchList, batchSize);
job.LinkParentToken(token);
lock (childJobs) { childJobs.Add(job); }
job.Done += (o, _) => {
var sub = (MetadataJob)o!;
lock (childJobs) { childJobs.Remove(sub); }
Interlocked.Add(ref processedAssets, sub.processedAssets);
Interlocked.Add(ref failedAssets, sub.failedAssets);
if (Interlocked.Decrement(ref pending) == 0)
childTcs.TrySetResult();
};
Interlocked.Increment(ref pending);
JobManager.EnqueueJob(job);
JobStatus.UpdateProgress(
(float)offset / totalAssets,
$"Created batch at {offset:N0}/{totalAssets:N0}."
);
}
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
var remain = Interlocked.CompareExchange(ref pending, 0, 0);
if (remain == 0) {
childTcs.TrySetResult();
} else {
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
try {
await childTcs.Task;
} catch (TaskCanceledException) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
}
if (failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} metadata extractions failed.");
else
JobStatus.Complete("All metadata extracted successfully.");
}
}