5 Commits
12 changed files with 259 additions and 207 deletions
+5 -5
View File
@@ -106,7 +106,7 @@ public class AlbumController(
album.UpdatedAt = DateTime.UtcNow;
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
album.Visibility = albumDto.Visibility ?? album.Visibility;
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
album.Assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : album.Assets;
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
// Save changes
@@ -159,7 +159,7 @@ public class AlbumController(
album.UpdatedAt = DateTime.UtcNow;
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
album.Assets = bulkDto.Data.Assets != null ? await assetRepository.FindBulkAsync(bulkDto.Data.Assets, CancellationToken.None) : album.Assets;
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
}
@@ -168,8 +168,8 @@ public class AlbumController(
var visibility = bulkDto.Data.Visibility;
if (cascade?.ToAssets == true && visibility.HasValue) {
result.AssetsUpdated = assetRepository.BulkSetVisibilityByAlbumIds(
albums.Select(a => a.Id).ToList(), visibility.Value, uid, accessLevel);
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByAlbumIdsAsync(
albums.Select(a => a.Id).ToList(), visibility.Value, uid, accessLevel, CancellationToken.None);
}
await albumRepository.SaveAsync(CancellationToken.None);
@@ -190,7 +190,7 @@ public class AlbumController(
public async Task<ActionResult> Create([FromBody] AlbumCreateDto albumDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
var assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : [];
var album = new Album {
Id = Guid.NewGuid(),
+28 -24
View File
@@ -34,9 +34,10 @@ public class AssetController(
/// Gets an asset by its ID with full details, respecting access level.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
[HttpGet("{id}")]
public ActionResult<AssetDto> Get(Guid id) {
public async Task<ActionResult<AssetDto>> Get(Guid id, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
@@ -48,7 +49,7 @@ public class AssetController(
"""
);
var asset = assetRepository.FindVisible(id, uid, accessLevel);
var asset = await assetRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found or not visible!");
@@ -78,9 +79,10 @@ public class AssetController(
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
/// </summary>
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of asset previews.</returns>
[HttpGet]
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
public async Task<ActionResult<List<AssetPreviewDto>>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto, CancellationToken cancellationToken) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
@@ -97,12 +99,12 @@ public class AssetController(
return BadRequest();
}
var assets = assetRepository.GetAssets(
var (assets, total) = await assetRepository.GetAssetsAsync(
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
searchOptionsDto.Page, searchOptionsDto.PageSize,
uid, accessLevel,
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
searchOptionsDto.IncludeCount
searchOptionsDto.IncludeCount, cancellationToken
);
logger.LogTrace(
@@ -117,7 +119,7 @@ public class AssetController(
"""
);
logger.LogTrace($"Returning {assets.Count()} assets (total: {total})");
logger.LogTrace($"Returning {assets.Count} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(assets);
}
@@ -130,14 +132,14 @@ public class AssetController(
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
[HttpGet("unlinked-groups")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
public async Task<ActionResult<List<AssetGroupDto>>> GetFolderGroups([FromQuery] bool unlinkedOnly = true, CancellationToken cancellationToken = default) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
var groups = assetRepository.GetFolderGroups(uid, accessLevel, unlinkedOnly);
var groups = await assetRepository.GetFolderGroupsAsync(uid, accessLevel, unlinkedOnly, cancellationToken);
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
return Ok(groups);
}
@@ -171,17 +173,18 @@ public class AssetController(
/// </summary>
/// <param name="page">The zero-based page number.</param>
/// <param name="pageSize">The number of assets per page.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of broken assets; total count in the <c>X-Total-Count</c> header.</returns>
[HttpGet("broken")]
[Authorize(Roles = "Curator,Admin")]
public ActionResult<List<AssetPreviewDto>> GetBroken([FromQuery] int page = 0, [FromQuery] int pageSize = 50) {
public async Task<ActionResult<List<AssetPreviewDto>>> GetBroken([FromQuery] int page = 0, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize)
return BadRequest();
var assets = assetRepository.GetBrokenAssets(page, pageSize, out int total);
var (assets, total) = await assetRepository.GetBrokenAssetsAsync(page, pageSize, cancellationToken);
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(assets);
}
@@ -191,17 +194,18 @@ public class AssetController(
/// for re-processing on the next job run.
/// </summary>
/// <param name="ids">The list of asset IDs to retry.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost("broken/retry")]
[Authorize(Roles = "Curator,Admin")]
public IStatusCodeActionResult RetryBroken([FromBody] List<Guid> ids) {
public async Task<IStatusCodeActionResult> RetryBroken([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
if (ids.Count == 0) return BadRequest();
assetRepository.ClearProcessError(ids);
assetRepository.Save();
await assetRepository.ClearProcessErrorAsync(ids, cancellationToken);
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -217,7 +221,7 @@ public class AssetController(
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.FindWithAlbums(id);
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
@@ -246,7 +250,7 @@ public class AssetController(
logger.LogTrace($"Updating asset {id}\n{log}");
assetRepository.Update(asset);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -262,7 +266,7 @@ public class AssetController(
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(bulkDto.Ids);
var assets = await assetRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
var enumerable = assets as List<Asset> ?? assets.ToList();
@@ -294,7 +298,7 @@ public class AssetController(
);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -309,7 +313,7 @@ public class AssetController(
public async Task<IStatusCodeActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.FindWithAlbums(id);
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
@@ -324,7 +328,7 @@ public class AssetController(
asset.DeletedAt = DateTime.UtcNow;
assetRepository.Update(asset);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -340,7 +344,7 @@ public class AssetController(
public async Task<IStatusCodeActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(ids);
var assets = await assetRepository.FindBulkAsync(ids, cancellationToken);
var enumerable = assets as List<Asset> ?? assets.ToList();
@@ -363,7 +367,7 @@ public class AssetController(
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -408,8 +412,8 @@ public class AssetController(
IngestedAt = DateTime.UtcNow
};
assetRepository.Insert(asset);
assetRepository.Save();
await assetRepository.InsertAsync(asset, cancellationToken);
await assetRepository.SaveAsync(cancellationToken);
return Ok(asset.Id);
}
+1 -1
View File
@@ -127,7 +127,7 @@ namespace Lactose.Controllers;
count = Math.Clamp(count, 1, 50);
var paths = assetRepository.GetRandomPathsByFolder(id, count);
var paths = await assetRepository.GetRandomPathsByFolderAsync(id, count, cancellationToken);
return Ok(new FolderSamplePathsDto {
FolderId = id,
+3 -3
View File
@@ -43,7 +43,7 @@ public class MediaController(
[EnableRateLimiting("media_original")]
public async Task<ActionResult> GetImage(Guid id, CancellationToken cancellationToken) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindAsync(id, cancellationToken);
if (asset == null) return NotFound();
@@ -115,7 +115,7 @@ public class MediaController(
[EnableRateLimiting("media_thumb")]
public async Task<ActionResult> GetThumb(Guid id, CancellationToken cancellationToken) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindAsync(id, cancellationToken);
if (asset == null) return NotFound();
@@ -142,7 +142,7 @@ public class MediaController(
[EnableRateLimiting("media_preview")]
public async Task<ActionResult> GetPreview(Guid id, CancellationToken cancellationToken) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindAsync(id, cancellationToken);
if (asset == null) return NotFound();
+1 -1
View File
@@ -144,7 +144,7 @@ public class PersonController(
if (cascade.ToAlbums)
result.AlbumsUpdated = await albumRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
if (cascade.ToAssets)
result.AssetsUpdated = assetRepository.BulkSetVisibilityByPersonIds(personBulkDto.Ids, visibility.Value);
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
}
await personRepository.SaveAsync(CancellationToken.None);
+3 -3
View File
@@ -169,7 +169,7 @@ public sealed class ConvertAnimatedJob : Job {
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
@@ -218,7 +218,7 @@ public sealed class ConvertAnimatedJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsNeedingConversion();
totalAssets = await AssetRepository.CountAssetsNeedingConversionAsync(token);
Logger.LogInformation("Found {Count} animated assets needing conversion.", totalAssets);
if (totalAssets == 0) {
@@ -230,7 +230,7 @@ public sealed class ConvertAnimatedJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsNeedingConversion(batchSize, offset);
var batch = await AssetRepository.GetAssetsNeedingConversionAsync(batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+5 -5
View File
@@ -55,11 +55,11 @@ public sealed class MetadataJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -103,7 +103,7 @@ public sealed class MetadataJob : Job {
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
@@ -130,7 +130,7 @@ public sealed class MetadataJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingMetadata();
totalAssets = await AssetRepository.CountAssetsMissingMetadataAsync(token);
Logger.LogInformation("Found {Count} image assets missing metadata.", totalAssets);
if (totalAssets == 0) {
@@ -142,7 +142,7 @@ public sealed class MetadataJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingMetadata(batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingMetadataAsync(batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+5 -5
View File
@@ -58,11 +58,11 @@ public sealed class PHashJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -114,7 +114,7 @@ public sealed class PHashJob : Job {
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
@@ -141,7 +141,7 @@ public sealed class PHashJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingPHash();
totalAssets = await AssetRepository.CountAssetsMissingPHashAsync(token);
Logger.LogInformation("Found {Count} assets missing pHash.", totalAssets);
if (totalAssets == 0) {
@@ -153,7 +153,7 @@ public sealed class PHashJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingPHash(batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingPHashAsync(batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+5 -5
View File
@@ -82,11 +82,11 @@ public class PreviewJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -166,7 +166,7 @@ public class PreviewJob : Job {
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
@@ -216,7 +216,7 @@ public class PreviewJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingOrWrongPreview(previewSize, ExpectedFormat);
totalAssets = await AssetRepository.CountAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, token);
Logger.LogInformation("Found {Count} assets needing previews.", totalAssets);
if (totalAssets == 0) {
@@ -228,7 +228,7 @@ public class PreviewJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingOrWrongPreview(previewSize, ExpectedFormat, batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+5 -5
View File
@@ -82,11 +82,11 @@ public class ThumbnailJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -166,7 +166,7 @@ public class ThumbnailJob : Job {
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
@@ -216,7 +216,7 @@ public class ThumbnailJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat);
totalAssets = await AssetRepository.CountAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, token);
Logger.LogInformation("Found {Count} assets needing thumbnails.", totalAssets);
if (totalAssets == 0) {
@@ -228,7 +228,7 @@ public class ThumbnailJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat, batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+115 -102
View File
@@ -13,12 +13,13 @@ namespace Lactose.Repositories;
/// <inheritdoc />
public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository> logger) : IAssetRepository {
/// <inheritdoc />
public Asset? Find(Guid id) => context.Assets.Find(id);
/// <inheritdoc />
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
public async Task<Asset?> FindAsync(Guid id, CancellationToken cancellationToken) =>
await context.Assets.FindAsync([id], cancellationToken);
/// <inheritdoc />
public Task<Asset?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
IQueryable<Asset> query = context.Assets
.AsNoTracking()
.AsSplitQuery()
@@ -26,50 +27,51 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
.Where(a => a.Id == id);
return accessLevel switch {
>= EAccessLevel.Admin => query.FirstOrDefault(),
EAccessLevel.Curator => query.FirstOrDefault(a =>
a.DeletedAt == null || a.UploadedBy == userId),
>= EAccessLevel.Admin => query.FirstOrDefaultAsync(cancellationToken),
EAccessLevel.Curator => query.FirstOrDefaultAsync(a =>
a.DeletedAt == null || a.UploadedBy == userId, cancellationToken),
EAccessLevel.Maintainer when userId.HasValue => query
.Include(a => a.Albums)
.FirstOrDefault(a => a.DeletedAt == null && (
.FirstOrDefaultAsync(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId ||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
context.PersonMaintainers.Any(pm =>
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
)),
_ => query.FirstOrDefault(a =>
), cancellationToken),
_ => query.FirstOrDefaultAsync(a =>
a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
))
), cancellationToken)
};
}
/// <inheritdoc />
public Asset? FindWithAlbums(Guid id) => context.Assets
public Task<Asset?> FindWithAlbumsAsync(Guid id, CancellationToken cancellationToken) => context.Assets
.AsSplitQuery()
.Include(a => a.Albums)
.FirstOrDefault(a => a.Id == id);
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
public Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to).ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
public Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, int pageNumber, int pageSize, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
.Skip(pageNumber * pageSize)
.Take(pageSize);
/// <inheritdoc />
public void Save() => context.SaveChanges();
.Take(pageSize)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public void Insert(Asset asset) {
if(!context.Assets.Any(x => x.OriginalPath == asset.OriginalPath))
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public async Task InsertAsync(Asset asset, CancellationToken cancellationToken) {
if(!await context.Assets.AnyAsync(x => x.OriginalPath == asset.OriginalPath, cancellationToken))
context.Assets.Add(asset);
else
throw new DuplicateNameException("The required asset already exists in the database.");
@@ -90,10 +92,11 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
}
/// <inheritdoc />
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.AsNoTracking().Where(a => a.UploadedBy == uploaderId);
public Task<List<Asset>> FindByUploaderAsync(Guid uploaderId, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().Where(a => a.UploadedBy == uploaderId).ToListAsync(cancellationToken);
/// <inheritdoc />
public int BulkSetVisibilityByAlbumIds(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel) {
public Task<int> BulkSetVisibilityByAlbumIdsAsync(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
// Scope the update to assets the caller is permitted to modify (R2). Curators/admins
// see all non-deleted assets; maintainers additionally respect their uploads and
// the albums of people they maintain.
@@ -112,130 +115,140 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
_ => query.Where(a => false)
};
return query.ExecuteUpdate(s => s
return query.ExecuteUpdateAsync(s => s
.SetProperty(a => a.Visibility, visibility)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow));
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
}
/// <inheritdoc />
public int BulkSetVisibilityByPersonIds(IEnumerable<Guid> personIds, EVisibility visibility) =>
public Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken) =>
context.Assets
.Where(a => a.DeletedAt == null && a.Albums!.Any(al =>
al.PersonOwnerId.HasValue && personIds.Contains(al.PersonOwnerId.Value)))
.ExecuteUpdate(s => s
.ExecuteUpdateAsync(s => s
.SetProperty(a => a.Visibility, visibility)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow));
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
public Task<List<Asset>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) => context.Assets
.AsSplitQuery()
.Include(a => a.Albums)
.Where(a => ids.Contains(a.Id));
/// <inheritdoc />
public Asset? FindByPath(string path) => context.Assets.AsNoTracking().FirstOrDefault(a => a.OriginalPath == path);
.Where(a => ids.Contains(a.Id))
.ToListAsync(cancellationToken);
/// <inheritdoc />
public int CountAssetsMissingPHash() {
public Task<Asset?> FindByPathAsync(string path, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().FirstOrDefaultAsync(a => a.OriginalPath == path, cancellationToken);
/// <inheritdoc />
public Task<int> CountAssetsMissingPHashAsync(CancellationToken cancellationToken) {
var empty = new BitArray(64);
return context.Assets.Count(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null);
return context.Assets.CountAsync(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null, cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset) {
public Task<List<Asset>> GetAssetsMissingPHashAsync(int limit, int offset, CancellationToken cancellationToken) {
var empty = new BitArray(64);
return context.Assets.Where(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<List<Asset>> GetDuplicates() => context.Assets
public Task<List<List<Asset>>> GetDuplicatesAsync(CancellationToken cancellationToken) => context.Assets
.GroupBy(a => new { a.Hash })
.Where(g => g.Count() > 1)
.Select(g => g.ToList());
.Select(g => g.ToList())
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, int distance) {
if(distance < 0 || distance > 63)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 64.");
return context.Assets
public async Task<List<Asset>> GetWithinHammingDistanceAsync(ulong phash, float distance, CancellationToken cancellationToken) {
if(distance < 0 || distance > 1)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 1.");
int intDistance = (int)(distance * 64);
if(intDistance < 0 || intDistance > 63)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 1.");
return await context.Assets
.OrderBy(a => a.HammingDistance(phash))
.Where(a => a.Hash.HammingDistance(phash) <= distance);
.Where(a => a.Hash.HammingDistance(phash) <= intDistance)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance)
=> GetWithinHammingDistance(phash, (int)(distance * 64));
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingThumbnail(out int count) {
count = context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif");
return context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif");
public async Task<(List<Asset> Assets, int Total)> GetAssetsMissingThumbnailAsync(CancellationToken cancellationToken) {
var total = await context.Assets.CountAsync(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
var assets = await context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif").ToListAsync(cancellationToken);
return (assets, total);
}
/// <inheritdoc />
public int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat) =>
context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif");
public Task<int> CountAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset) =>
public Task<List<Asset>> GetAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken) =>
context.Assets
.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif")
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat) =>
context.Assets.Count(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif");
public Task<int> CountAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset) =>
public Task<List<Asset>> GetAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken) =>
context.Assets
.Where(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif")
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
/// <inheritdoc />
public int CountAssetsMissingMetadata() =>
context.Assets.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset) =>
public Task<int> CountAssetsMissingMetadataAsync(CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null, cancellationToken);
/// <inheritdoc />
public Task<List<Asset>> GetAssetsMissingMetadataAsync(int limit, int offset, CancellationToken cancellationToken) =>
context.Assets.Where(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public int CountAssetsNeedingConversion() =>
context.Assets.Count(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
public Task<int> CountAssetsNeedingConversionAsync(CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
a.ConvertedPath == null || a.ConvertedPath == "" ||
a.ThumbnailPath == null || a.ThumbnailPath == ""));
a.ThumbnailPath == null || a.ThumbnailPath == ""), cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsNeedingConversion(int limit, int offset) =>
public Task<List<Asset>> GetAssetsNeedingConversionAsync(int limit, int offset, CancellationToken cancellationToken) =>
context.Assets
.Where(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
a.ConvertedPath == null || a.ConvertedPath == "" ||
a.ThumbnailPath == null || a.ThumbnailPath == ""))
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public int CountBrokenAssets() =>
context.Assets.Count(a => a.ProcessFailedAt != null && a.DeletedAt == null);
public Task<int> CountBrokenAssetsAsync(CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => a.ProcessFailedAt != null && a.DeletedAt == null, cancellationToken);
/// <inheritdoc />
public IEnumerable<AssetPreviewDto> GetBrokenAssets(int page, int pageSize, out int total) {
total = CountBrokenAssets();
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetBrokenAssetsAsync(int page, int pageSize, CancellationToken cancellationToken) {
var total = await CountBrokenAssetsAsync(cancellationToken);
var assets = context.Assets
var assets = await context.Assets
.AsNoTracking()
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
@@ -243,14 +256,14 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
.OrderBy(a => a.ProcessFailedAt)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
.ToListAsync(cancellationToken);
return assets.Select(a => a.ToAssetPreviewDto(EAccessLevel.Admin, null)).ToList();
return (assets.Select(a => a.ToAssetPreviewDto(EAccessLevel.Admin, null)).ToList(), total);
}
/// <inheritdoc />
public void ClearProcessError(IEnumerable<Guid> ids) {
var assets = context.Assets.Where(a => ids.Contains(a.Id)).ToList();
public async Task ClearProcessErrorAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) {
var assets = await context.Assets.Where(a => ids.Contains(a.Id)).ToListAsync(cancellationToken);
foreach (var asset in assets) {
asset.ProcessFailedAt = null;
asset.ProcessErrorMessage = null;
@@ -258,7 +271,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
}
/// <inheritdoc />
public IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true) {
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, CancellationToken cancellationToken) {
var query = context.Assets.AsNoTracking().AsQueryable();
if (type.HasValue)
query = query.Where(a => a.Type == type.Value);
@@ -294,7 +307,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
))
};
total = includeCount ? query.Count() : 0;
var total = includeCount ? await query.CountAsync(cancellationToken) : 0;
// Deterministic page of IDs first: random order is a seeded md5 shuffle on the server,
// so the full ID set is never materialized in memory.
@@ -304,16 +317,16 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
false => query.OrderByDescending(a => a.CreatedAt)
};
var pageIds = ordered
var pageIds = await ordered
.Skip(pageNumber * pageSize)
.Take(pageSize)
.Select(a => a.Id)
.ToList();
.ToListAsync(cancellationToken);
if (pageIds.Count == 0)
return [];
return ([], total);
var assets = query
var assets = await query
.Where(a => pageIds.Contains(a.Id))
.Select(a => new AssetPreviewDto {
Id = a.Id,
@@ -326,14 +339,14 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
})
.ToList();
.ToListAsync(cancellationToken);
// Album and cosplayer names per asset via a single grouped query
var albumLinks = (from a in context.Assets
var albumLinks = await (from a in context.Assets
from al in a.Albums!
where pageIds.Contains(a.Id)
select new { AssetId = a.Id, al.Id, al.Title, al.PersonOwnerId, PersonName = al.PersonOwner!.Name })
.ToList();
.ToListAsync(cancellationToken);
var linksByAsset = albumLinks
.GroupBy(x => x.AssetId)
@@ -348,23 +361,23 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
}
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
return ([.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))], total);
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
/// <inheritdoc />
public List<string> GetRandomPathsByFolder(Guid folderId, int count) =>
public Task<List<string>> GetRandomPathsByFolderAsync(Guid folderId, int count, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking()
.Where(a => a.FolderId == folderId && a.DeletedAt == null)
.OrderBy(_ => Guid.NewGuid())
.Take(count)
.Select(a => a.OriginalPath)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true) {
public async Task<List<Butter.Dtos.Asset.AssetGroupDto>> GetFolderGroupsAsync(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly, CancellationToken cancellationToken) {
logger.LogTrace("GetFolderGroups: userId={UserId}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", userId, accessLevel, unlinkedOnly);
var query = context.Assets.AsNoTracking()
@@ -375,7 +388,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
if (accessLevel < EAccessLevel.Curator) {
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
var count = query.Count(a => a.UploadedBy == userId);
var count = await query.CountAsync(a => a.UploadedBy == userId, cancellationToken);
return [new Butter.Dtos.Asset.AssetGroupDto {
FolderId = null,
Name = "My Uploads",
@@ -385,17 +398,17 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
return [];
}
var groups = query
var groups = await query
.GroupBy(a => a.FolderId)
.Select(g => new {
FolderId = g.Key,
AssetCount = g.Count()
})
.ToList();
.ToListAsync(cancellationToken);
var folderNames = context.Folders
var folderNames = await context.Folders
.Where(f => groups.Select(g => g.FolderId).Contains(f.Id))
.ToDictionary(f => f.Id, f => f.BasePath);
.ToDictionaryAsync(f => f.Id, f => f.BasePath, cancellationToken);
var result = groups
.Where(g => g.AssetCount > 0)
@@ -410,4 +423,4 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
return result;
}
}
}
+83 -48
View File
@@ -12,8 +12,9 @@ public interface IAssetRepository : IDisposable {
/// Finds an asset by its ID.
/// </summary>
/// <param name="id">The ID of the asset.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns>
public Asset? Find(Guid id);
Task<Asset?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds an asset by its ID, respecting the requesting user's access level.
@@ -22,29 +23,33 @@ public interface IAssetRepository : IDisposable {
/// <param name="id">The ID of the asset.</param>
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The asset if found and visible; otherwise null.</returns>
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
Task<Asset?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Finds an asset by its ID with its album navigation properties loaded.
/// </summary>
/// <param name="id">The ID of the asset.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Null if not found, otherwise the requested asset with albums.</returns>
public Asset? FindWithAlbums(Guid id);
Task<Asset?> FindWithAlbumsAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds a set of assets by their IDs.
/// </summary>
/// <param name="ids">The IDs of the assets.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets with the specified IDs.</returns>
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
Task<List<Asset>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets uploaded by the user.
/// </summary>
/// <param name="uploaderId">The ID of the uploader.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets uploaded by the specified user.</returns>
public IEnumerable<Asset> FindByUploader(Guid uploaderId);
Task<List<Asset>> FindByUploaderAsync(Guid uploaderId, CancellationToken cancellationToken);
/// <summary>
/// Bulk-updates the visibility of non-deleted assets in the specified albums via a single SQL UPDATE,
@@ -54,8 +59,9 @@ public interface IAssetRepository : IDisposable {
/// <param name="visibility">The target visibility level.</param>
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of assets updated.</returns>
public int BulkSetVisibilityByAlbumIds(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel);
Task<int> BulkSetVisibilityByAlbumIdsAsync(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Bulk-updates the visibility of non-deleted assets in any album owned by the specified people
@@ -63,16 +69,18 @@ public interface IAssetRepository : IDisposable {
/// </summary>
/// <param name="personIds">The IDs of the people whose album assets to update.</param>
/// <param name="visibility">The target visibility level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of assets updated.</returns>
public int BulkSetVisibilityByPersonIds(IEnumerable<Guid> personIds, EVisibility visibility);
Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets created or updated between the given dates.
/// </summary>
/// <param name="from">The start date of the range.</param>
/// <param name="to">The end date of the range.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets created or updated within the specified date range.</returns>
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to);
Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets created or updated between the given dates with pagination.
@@ -81,73 +89,88 @@ public interface IAssetRepository : IDisposable {
/// <param name="to">The end date of the range.</param>
/// <param name="pageNumber">The page number for pagination.</param>
/// <param name="pageSize">The number of items per page.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets created or updated within the specified date range.</returns>
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize);
Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, int pageNumber, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Saves all the changes to the model.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Inserts a new asset.
/// Inserts a new asset. Throws when an asset with the same original path already exists.
/// </summary>
/// <param name="asset">The asset to insert.</param>
public void Insert(Asset asset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task InsertAsync(Asset asset, CancellationToken cancellationToken);
/// <summary>
/// Updates a given asset.
/// </summary>
/// <param name="asset">The asset to update.</param>
public void Update(Asset asset);
void Update(Asset asset);
/// <summary>
/// Updates a list of assets.
/// </summary>
/// <param name="assets">The list of assets to update.</param>
public void UpdateBulk(IEnumerable<Asset> assets);
void UpdateBulk(IEnumerable<Asset> assets);
/// <summary>
/// Gets an asset by its file path.
/// </summary>
/// <param name="filePath">The file path of the asset.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns>
Asset? FindByPath(string filePath);
/// Gets an asset by its file path.
/// </summary>
/// <param name="filePath">The file path of the asset.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns>
Task<Asset?> FindByPathAsync(string filePath, CancellationToken cancellationToken);
/// <summary>
/// Counts all assets that are missing a perceptual hash (pHash).
/// </summary>
int CountAssetsMissingPHash();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountAssetsMissingPHashAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets that are missing a perceptual hash (pHash).
/// </summary>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Asset>> GetAssetsMissingPHashAsync(int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Groups assets sharing an identical perceptual hash (pHash).
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Groups of duplicate assets (only groups with more than one asset).</returns>
Task<List<List<Asset>>> GetDuplicatesAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds assets within a normalized Hamming distance (0 to 1).
/// </summary>
/// <param name="phash">The perceptual hash to compare against.</param>
/// <param name="distance">The maximum distance as a fraction (0 to 1).</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Assets whose hash is within the specified distance.</returns>
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance);
Task<List<Asset>> GetWithinHammingDistanceAsync(ulong phash, float distance, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets that are missing a thumbnail.
/// Finds all assets that are missing a thumbnail, together with the total count.
/// </summary>
/// <param name="totalAssets">The total number of assets missing thumbnails.</param>
/// <returns>Assets that do not have a thumbnail path set.</returns>
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Assets that do not have a thumbnail path set, and the total count of such assets.</returns>
Task<(List<Asset> Assets, int Total)> GetAssetsMissingThumbnailAsync(CancellationToken cancellationToken);
/// <summary>
/// Counts all assets that need thumbnails — either missing entirely, with wrong dimensions, or wrong format.
/// </summary>
/// <param name="thumbnailSize">The expected longest-side size in pixels.</param>
/// <param name="expectedFormat">The expected thumbnail format (e.g. "webp").</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The total count of assets needing thumbnails.</returns>
int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat);
Task<int> CountAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets that need thumbnails — either missing entirely, with wrong dimensions, or wrong format.
@@ -156,16 +179,18 @@ public interface IAssetRepository : IDisposable {
/// <param name="expectedFormat">The expected thumbnail format (e.g. "webp").</param>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of assets that need thumbnails regenerated.</returns>
IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset);
Task<List<Asset>> GetAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts all assets that need previews — either missing entirely, with wrong dimensions, or wrong format.
/// </summary>
/// <param name="previewSize">The expected longest-side size in pixels.</param>
/// <param name="expectedFormat">The expected preview format (e.g. "webp").</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The total count of assets needing previews.</returns>
int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat);
Task<int> CountAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets that need previews — either missing entirely, with wrong dimensions, or wrong format.
@@ -174,62 +199,71 @@ public interface IAssetRepository : IDisposable {
/// <param name="expectedFormat">The expected preview format (e.g. "webp").</param>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of assets that need previews regenerated.</returns>
IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset);
Task<List<Asset>> GetAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts image assets that are missing resolution metadata.
/// </summary>
int CountAssetsMissingMetadata();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountAssetsMissingMetadataAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of image assets missing resolution metadata.
/// </summary>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Asset>> GetAssetsMissingMetadataAsync(int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts animated assets (GIF) that need a converted video and/or first-frame thumbnail.
/// Excludes deleted and broken assets.
/// </summary>
int CountAssetsNeedingConversion();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountAssetsNeedingConversionAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of animated assets (GIF) that need a converted video and/or first-frame thumbnail.
/// </summary>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
IEnumerable<Asset> GetAssetsNeedingConversion(int limit, int offset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Asset>> GetAssetsNeedingConversionAsync(int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts assets currently marked broken (processing failed, not deleted).
/// </summary>
int CountBrokenAssets();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountBrokenAssetsAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets marked broken (processing failed, not deleted), most recently broken first.
/// Finds a page of assets marked broken (processing failed, not deleted), most recently broken first,
/// together with the total count.
/// </summary>
/// <param name="page">The zero-based page number.</param>
/// <param name="pageSize">The number of assets per page.</param>
/// <param name="total">The total count of broken assets.</param>
/// <returns>A page of broken assets as preview DTOs.</returns>
IEnumerable<AssetPreviewDto> GetBrokenAssets(int page, int pageSize, out int total);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of broken assets as preview DTOs and the total count.</returns>
Task<(List<AssetPreviewDto> Assets, int Total)> GetBrokenAssetsAsync(int page, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Clears the broken (processing failed) state for the given assets so they are picked up for
/// re-processing on the next job run. Caller must invoke <see cref="Save"/> afterwards.
/// re-processing on the next job run. Caller must invoke <see cref="SaveAsync"/> afterwards.
/// </summary>
/// <param name="ids">The IDs of the assets to retry.</param>
void ClearProcessError(IEnumerable<Guid> ids);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task ClearProcessErrorAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
/// <summary>
/// Gets a random sample of asset paths from the specified folder.
/// </summary>
/// <param name="folderId">The folder ID.</param>
/// <param name="count">The number of random paths to return.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of asset original paths.</returns>
List<string> GetRandomPathsByFolder(Guid folderId, int count);
Task<List<string>> GetRandomPathsByFolderAsync(Guid folderId, int count, CancellationToken cancellationToken);
/// <summary>
/// Queries assets with optional type filter, date range, random ordering, and visibility scoping.
@@ -241,16 +275,16 @@ public interface IAssetRepository : IDisposable {
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
/// <param name="pageNumber">The zero-based page number.</param>
/// <param name="pageSize">The number of items per page.</param>
/// <param name="total">The total count of matching assets (after visibility filtering).</param>
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
/// <param name="accessLevel">The requesting user's access level for visibility filtering.</param>
/// <param name="unlinked">If true, filters for assets not assigned to any album.</param>
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
/// <param name="includeCount">If true (default), computes the total matching count. When false, <paramref name="total"/> is set to zero and the count query is skipped.</param>
/// <returns>A paginated collection of asset previews matching the filters.</returns>
IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true);
/// <param name="includeCount">If true (default), computes the total matching count. When false, the returned total is zero and the count query is skipped.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A paginated collection of asset previews matching the filters, and the total matching count.</returns>
Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, CancellationToken cancellationToken);
/// <summary>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
@@ -260,6 +294,7 @@ public interface IAssetRepository : IDisposable {
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="unlinkedOnly">If true (default), only counts assets not assigned to any album.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true);
Task<List<Butter.Dtos.Asset.AssetGroupDto>> GetFolderGroupsAsync(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly, CancellationToken cancellationToken);
}