Files
MilkyShots/Lactose/Repositories/StatsRepository.cs
T
REDCODE f9385e2d9d refactor(stats): move command timeout to connection string
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
2026-08-18 00:16:58 +02:00

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+"
};
}