Removes the per-request SetCommandTimeout mutation on the shared scoped DbContext (flagged in review as a minor smell). The 120s command timeout is now configured once on the Npgsql connection string, which also covers other potentially slow maintenance/scan queries. The no-album / no-person anti-joins no longer need to temporarily mutate and restore the context's timeout. Refs #173
199 lines
9.2 KiB
C#
199 lines
9.2 KiB
C#
using Butter.Dtos.Stats;
|
|
using Butter.Settings;
|
|
using Butter.Types;
|
|
using Lactose.Context;
|
|
using Lactose.Models;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using System.Collections;
|
|
|
|
namespace Lactose.Repositories;
|
|
|
|
/// <inheritdoc />
|
|
public class StatsRepository(
|
|
LactoseDbContext context,
|
|
ISettingsRepository settingsRepo,
|
|
IMemoryCache cache
|
|
) : IStatsRepository {
|
|
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
|
|
|
|
/// <inheritdoc />
|
|
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
|
|
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
|
|
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
|
|
|
|
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
|
|
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
|
|
return cached;
|
|
|
|
_ = int.TryParse(thumbnailSizeSetting?.Value, out var expectedThumbnailSize);
|
|
_ = int.TryParse(previewSizeSetting?.Value, out var expectedPreviewSize);
|
|
|
|
var stats = await ComputeStatsAsync(cancellationToken, expectedThumbnailSize, expectedPreviewSize);
|
|
cache.Set(cacheKey, stats, CacheTtl);
|
|
return stats;
|
|
}
|
|
|
|
private async Task<StatsDto> ComputeStatsAsync(
|
|
CancellationToken cancellationToken,
|
|
int expectedThumbnailSize,
|
|
int expectedPreviewSize) {
|
|
var dto = new StatsDto();
|
|
var now = DateTime.UtcNow;
|
|
|
|
var emptyHash = new BitArray(64);
|
|
|
|
var assetStats = await context.Assets
|
|
.Where(a => a.DeletedAt == null)
|
|
.GroupBy(a => 1)
|
|
.Select(g => new {
|
|
Total = g.Count(),
|
|
TotalStorage = g.Sum(a => (long?)a.FileSize) ?? 0,
|
|
Public = g.Count(a => a.Visibility == EVisibility.Public),
|
|
Protected = g.Count(a => a.Visibility == EVisibility.Protected),
|
|
Private = g.Count(a => a.Visibility == EVisibility.Private),
|
|
Orphan = g.Count(a => a.FolderId == null),
|
|
Added7d = g.Count(a => a.CreatedAt >= now.AddDays(-7)),
|
|
Added30d = g.Count(a => a.CreatedAt >= now.AddDays(-30)),
|
|
MissingMetadata = g.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0),
|
|
MissingThumbnail = g.Count(a => a.ThumbnailPath == null || a.ThumbnailPath == ""),
|
|
MissingThumbnailStale = expectedThumbnailSize > 0
|
|
? g.Count(a => a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize)
|
|
: 0,
|
|
MissingPreview = g.Count(a => a.PreviewPath == null || a.PreviewPath == ""),
|
|
MissingPreviewStale = expectedPreviewSize > 0
|
|
? g.Count(a => a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize)
|
|
: 0,
|
|
MissingPhash = g.Count(a => a.Hash == emptyHash)
|
|
})
|
|
.OrderBy(x => 1)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (assetStats is not null) {
|
|
dto.TotalAssets = assetStats.Total;
|
|
dto.TotalStorageBytes = assetStats.TotalStorage;
|
|
dto.PublicAssets = assetStats.Public;
|
|
dto.ProtectedAssets = assetStats.Protected;
|
|
dto.PrivateAssets = assetStats.Private;
|
|
dto.OrphanAssets = assetStats.Orphan;
|
|
dto.AssetsAddedLast7Days = assetStats.Added7d;
|
|
dto.AssetsAddedLast30Days = assetStats.Added30d;
|
|
dto.AssetsMissingMetadata = assetStats.MissingMetadata;
|
|
dto.AssetsMissingThumbnail = assetStats.MissingThumbnail;
|
|
dto.AssetsMissingThumbnailStale = assetStats.MissingThumbnailStale;
|
|
dto.AssetsMissingPreviews = assetStats.MissingPreview;
|
|
dto.AssetsMissingPreviewsStale = assetStats.MissingPreviewStale;
|
|
dto.AssetsMissingPhash = assetStats.MissingPhash;
|
|
}
|
|
|
|
var byType = await context.Assets
|
|
.Where(a => a.DeletedAt == null)
|
|
.GroupBy(a => a.Type)
|
|
.Select(g => new { g.Key, Count = g.Count(), Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
|
|
.ToListAsync(cancellationToken);
|
|
dto.AssetsByType = byType.ToDictionary(x => x.Key, x => x.Count);
|
|
dto.StorageByType = byType.ToDictionary(x => x.Key, x => x.Size);
|
|
|
|
var userStats = await context.Users
|
|
.Where(u => u.DeletedAt == null)
|
|
.GroupBy(u => u.AccessLevel)
|
|
.Select(g => new { g.Key, Count = g.Count(), Registered30 = g.Count(u => u.CreatedAt >= now.AddDays(-30)) })
|
|
.ToListAsync(cancellationToken);
|
|
dto.UsersByAccessLevel = userStats.ToDictionary(x => x.Key, x => x.Count);
|
|
dto.TotalUsers = userStats.Sum(x => x.Count);
|
|
dto.UsersRegisteredLast30Days = userStats.Sum(x => x.Registered30);
|
|
|
|
dto.TotalAlbums = await context.Albums.CountAsync(cancellationToken);
|
|
dto.TotalTags = await context.Tags.CountAsync(cancellationToken);
|
|
dto.TotalPeople = await context.People.CountAsync(cancellationToken);
|
|
dto.TotalFaces = await context.Faces.CountAsync(cancellationToken);
|
|
dto.TotalFolders = await context.Folders.CountAsync(cancellationToken);
|
|
dto.AlbumsMissingCover = await context.Albums.CountAsync(a => a.CoverAssetId == null, cancellationToken);
|
|
dto.CosplayersMissingProfile = await context.People.CountAsync(p => p.ProfileAssetId == null, cancellationToken);
|
|
|
|
var albumAsset = context.Set<Dictionary<string, object>>("AlbumAsset");
|
|
var liveAssetIds = context.Assets.Where(a => a.DeletedAt == null).Select(a => a.Id);
|
|
|
|
var assetsInAnyAlbum = await albumAsset
|
|
.Select(aa => EF.Property<Guid>(aa, "AssetsId"))
|
|
.Where(id => liveAssetIds.Contains(id))
|
|
.Distinct()
|
|
.CountAsync(cancellationToken);
|
|
dto.AssetsWithNoAlbum = Math.Max(0, dto.TotalAssets - assetsInAnyAlbum);
|
|
|
|
var personAlbumIds = context.Albums
|
|
.Where(al => al.PersonOwnerId != null)
|
|
.Select(al => al.Id);
|
|
var assetsInPersonAlbum = await albumAsset
|
|
.Where(aa => personAlbumIds.Contains(EF.Property<Guid>(aa, "AlbumsId")))
|
|
.Select(aa => EF.Property<Guid>(aa, "AssetsId"))
|
|
.Where(id => liveAssetIds.Contains(id))
|
|
.Distinct()
|
|
.CountAsync(cancellationToken);
|
|
dto.AssetsWithNoPerson = Math.Max(0, dto.TotalAssets - assetsInPersonAlbum);
|
|
|
|
dto.TopTags = await context.Tags
|
|
.Select(t => new TagStatDto {
|
|
Id = t.Id,
|
|
Name = t.Name,
|
|
AssetCount = t.Assets!.Count(a => a.DeletedAt == null)
|
|
})
|
|
.OrderByDescending(t => t.AssetCount)
|
|
.Take(10)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
dto.FileFormatBreakdown = await context.Assets
|
|
.Where(a => a.DeletedAt == null)
|
|
.GroupBy(a => a.MimeType)
|
|
.Select(g => new MimeTypeStatDto { MimeType = g.Key, Count = g.Count() })
|
|
.OrderByDescending(m => m.Count)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var maxDims = await context.Assets
|
|
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
|
|
.GroupBy(a => a.ResolutionWidth > a.ResolutionHeight ? a.ResolutionWidth : a.ResolutionHeight)
|
|
.Select(g => new { MaxDim = g.Key, Count = g.Count() })
|
|
.ToListAsync(cancellationToken);
|
|
|
|
dto.ResolutionDistribution = maxDims
|
|
.GroupBy(x => ResolveBucket(x.MaxDim))
|
|
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Sum(x => x.Count) })
|
|
.OrderBy(r => r.Count)
|
|
.ToList();
|
|
|
|
var twelveMonthsAgo = now.AddMonths(-12);
|
|
|
|
var monthlyAssets = await context.Assets
|
|
.Where(a => a.DeletedAt == null && a.CreatedAt >= twelveMonthsAgo)
|
|
.GroupBy(a => new { a.CreatedAt.Year, a.CreatedAt.Month })
|
|
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var monthlyUsers = await context.Users
|
|
.Where(u => u.CreatedAt >= twelveMonthsAgo)
|
|
.GroupBy(u => new { u.CreatedAt.Year, u.CreatedAt.Month })
|
|
.Select(g => new { g.Key.Year, g.Key.Month, Count = g.Count() })
|
|
.ToListAsync(cancellationToken);
|
|
|
|
var months = Enumerable.Range(0, 12)
|
|
.Select(i => twelveMonthsAgo.AddMonths(i + 1))
|
|
.Select(d => new { d.Year, d.Month })
|
|
.ToList();
|
|
|
|
dto.MonthlyGrowth = months.Select(m => new MonthlyStatDto {
|
|
Year = m.Year,
|
|
Month = m.Month,
|
|
NewAssets = monthlyAssets.FirstOrDefault(x => x.Year == m.Year && x.Month == m.Month)?.Count ?? 0,
|
|
NewUsers = monthlyUsers.FirstOrDefault(x => x.Year == m.Year && x.Month == m.Month)?.Count ?? 0
|
|
}).ToList();
|
|
|
|
return dto;
|
|
}
|
|
|
|
private static string ResolveBucket(int maxDim) => maxDim switch {
|
|
<= 480 => "SD (≤480p)",
|
|
<= 720 => "HD (≤720p)",
|
|
<= 1080 => "Full HD (≤1080p)",
|
|
<= 2160 => "4K (≤2160p)",
|
|
_ => "4K+"
|
|
};
|
|
} |