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
Addresses review findings on PR #178:
1. Backend: AssetsWithNoAlbum / AssetsWithNoPerson now exclude
soft-deleted assets. The COUNT(DISTINCT AlbumAsset.AssetsId) previously
included deleted assets, which (since TotalAssets only counts live
assets) inflated the in-album count and undercounted orphans. The
distinct count is now restricted to live asset IDs via an
Id IN (SELECT Id FROM Assets WHERE DeletedAt IS NULL) predicate.
2. Frontend: StatChart now re-renders when its data parameters change
instead of only on first render, so the Refresh button updates the
four Chart.js charts alongside the tables/cards.
Refs #173, #177
Adds a Refresh button to the stats page header with an in-progress
spinner, plus a 'last updated' local-time timestamp. Clicking it refetches
the stats payload (bypassing nothing server-side; the API cache still
applies its 60s TTL).
Refs #177
AssetsWithNoAlbum / AssetsWithNoPerson still exceeded the 30s command
timeout even after querying the AlbumAsset join table directly, because
a count(*) anti-join against the full Assets table is inherently
O(assets).
Rewrite both as computed differences from the already-known TotalAssets:
- AssetsWithNoAlbum = TotalAssets - COUNT(DISTINCT AlbumAsset.AssetsId)
- AssetsWithNoPerson = TotalAssets - COUNT(DISTINCT AlbumAsset.AssetsId
WHERE AlbumsId in person-owned albums)
These are single-table aggregate scans over the indexed join table
(no anti-join against Assets), clamped to >= 0. A 120s command timeout
is applied around these two queries as a safety net and restored
afterwards so other repos are unaffected.
Also adds OrderBy to the grouped FirstOrDefaultAsync to silence the
FirstWithoutOrderByAndFilter warning.
Refs #173
AssetsWithNoAlbum / AssetsWithNoPerson previously anti-joined through
Albums -> AlbumAsset -> Assets, which generated a large correlated join
that exceeded the 30s DB command timeout at scale.
Rewrite both to anti-join against the AlbumAsset join table directly,
using its existing AssetsId index:
- assets with no album: Assets.Id NOT IN (SELECT AlbumAsset.AssetsId)
- assets with no person: Assets.Id NOT IN (SELECT AlbumAsset.AssetsId
WHERE AlbumsId in person-owned albums)
This avoids touching the full Assets table in the subquery and lets the
planner use a hash anti-join instead of a nested loop.
Refs #173
Adds Chart.js visualizations for the stats distributions alongside the
existing tables:
- Vendors Chart.js (4.4.7) into wwwroot/lib/js/ and adds a small
statsCharts.js interop wrapper with byte formatting for the storage
chart's y-axis and tooltips.
- New StatChart shared component (canvas + IJSRuntime lifecycle,
following the existing ProfileCropper interop pattern).
- Stats page charts: storage by type (bar), file formats (doughnut),
resolution distribution (bar), monthly growth (line).
Refs #177
Breaks up the monolithic Stats.razor into reusable pieces under
Components/Shared/ per the AGENTS.md extraction rule:
- StatCountCard: covers the three count-card shapes used 19 times
(solid top-level, outline data-completeness, icon-title sections).
- StatTable: reusable card+table scaffold for the seven distribution
tables, with optional header/footer row fragments.
- Formatting: static FormatBytes / PercentOfTotal helpers (were private
methods in Stats.razor).
Stats.razor now composes these; visual output is unchanged.
Refs #176
Caches the assembled StatsDto in IMemoryCache with a 60s TTL. The cache
key is fingerprinted with the ThumbnailSize/PreviewSize setting values so
changing those settings immediately invalidates the stale counts on the
next request. Registers AddMemoryCache() in Program.cs.
Refs #175
Rewrites StatsRepository to be fully async and collapses the ~30
synchronous round-trips into a bounded set of aggregated queries:
- Single grouped pass over non-deleted assets computes totals, storage,
visibility breakdown, orphans, 7/30-day activity, and all
data-completeness counts (missing metadata/thumbnail/preview/phash).
- AssetsByType + StorageByType come from one GROUP BY on Type.
- UsersByAccessLevel, TotalUsers, and users registered in 30 days come
from one GROUP BY on AccessLevel.
- MIME breakdown and resolution buckets are now GROUP BY'd in SQL; the
resolution bucket key is a translatable CASE expression on the max
dimension, so no more loading every asset's dimensions into memory.
- AssetsWithNoAlbum / AssetsWithNoPerson use set-based NOT IN anti-joins
over the Album.Assets navigation instead of per-row NOT EXISTS.
- TopTags uses the Tag.Assets navigation (join table indexed on TagsId).
- Drops IDisposable/Dispose() so the transient repo no longer disposes
the shared scoped DbContext.
- StatsController action is now async.
StatsDto shape is unchanged, so the existing .http tests remain valid.
Refs #173
Adds partial indexes so stats count queries are index scans instead of
full table scans over 1M+ assets:
- IX_Assets_Stats_CreatedAt: (CreatedAt) WHERE DeletedAt IS NULL
- IX_Assets_Stats_Type: (Type) WHERE DeletedAt IS NULL
- IX_Assets_Stats_MissingThumbnail: (ThumbnailPath) WHERE ThumbnailPath = '' AND DeletedAt IS NULL
- IX_Assets_Stats_MissingPreview: (PreviewPath) WHERE PreviewPath = '' AND DeletedAt IS NULL
- IX_Assets_Stats_MissingPhash: (Hash) WHERE Hash = zero bit(64) AND DeletedAt IS NULL
- IX_Assets_Stats_Resolution: (ResolutionWidth, ResolutionHeight, Type) WHERE DeletedAt IS NULL
The orphan-asset count (FolderId IS NULL) reuses the existing
IX_Assets_FolderId index; a separate filtered index on the same column is
not supported by EF's index model.
Refs #174
CosplayerDetail now fetches album pages from GET /api/album?personOwnerId=
instead of re-fetching the entire PersonDetailedDto per scroll page. The home
browse page opts out of the asset count query (includeCount=false).
Thumbnails/previews are immutable per asset, so responses are marked
Cache-Control: public, max-age=86400, eliminating repeat fetches on scroll
and back-navigation.
Random ordering now uses a pg_catalog.md5-based deterministic shuffle in SQL
instead of materializing every matching asset ID in memory. The listing
projects directly to AssetPreviewDto (no full entity transfer) with album and
cosplayer names via one grouped query. includeCount=false skips the count
query entirely.
FindVisible filters and projects assets at the database level instead of
loading full entities then filtering/sorting in memory. Find no longer
eager-loads assets; FindWithAssets covers the update path. SearchQuery gains
personOwnerId filter and AsNoTracking.
No longer loads the person's full album/asset graph into memory; uses ID-first
paging, ILike search against the trgm GIN index, and DB-level counts. Slims
Find() to a PK lookup for write paths.
- Extract PathFromGuid to shared PathUtils.cs (PreviewJob + ThumbnailJob)
- Consolidate RegexHighlighter duplicate methods via delegation
- Fix default port 3306 -> 5432 in appsettings.json + docker-compose.yml
- Remove port gotcha section from AGENTS.md
- Fix ITagRepository XML docs after parameter rename (id -> tag)
- Remove 16 unused using System; imports from migration files (implicit usings available)
- Fix 6 empty catch blocks with proper exception logging
- Remove unused import (false positive flagged as wontfix)
- Skip test coverage, orphaned, and stale exclude issues as false positives
- Add missing namespace to IPersonRepository.cs
- Rename IFolderRepository Create/Delete to Insert/Remove for CRUD consistency
- Fix ITagRepository.Delete parameter name from 'id' to 'tag'
- Add missing IDisposable to IMediaRepository
- Rename SettingsExtensions to SettingsExtension for naming consistency
- Rename PagedParametersDTO.cs to PagedParametersDto.cs
- Add .desloppify/ to .gitignore
Inject git describe output into the assembly's InformationalVersion at build
time via an MSBuild target (local dev) or Docker build arg (CI/Docker).
Local builds use v1.0.2-21-g9e67c59-dirty; Docker builds take
APP_VERSION as a build arg to avoid MSBuild property collision with VERSION.
Co-authored-by: opencode <opencode@anomaly.co>
- Remove IntersectionObserver, sentinel elements, and scroll reattach
from albumObserver and cosplayerObserver
- Add wheel, keydown, and touch event listeners that react only to
explicit user input commands (scroll down/up, arrow keys, swipe)
- Check page position (isAtBottom/isAtTop) before invoking load
- No observer to get stuck, no cascade from auto-scroll/load completion
- Replace per-frame scroll reattach with 300ms debounced scroll-end
reattach to prevent cascade during fast/momentum scrolling
- Add overflow-anchor: none to grid containers and sentinels to
prevent browsers from auto-scrolling when content is added below
- Add PageBuffer<T> utility class (capped at 10 pages, drops oldest) to keep
DOM and memory bounded during infinite scroll
- Refactor AlbumGrid and CosplayerGrid to use PageBuffer instead of unbounded
List/Dictionary, add top sentinel for bi-directional scroll-back loading
- Add observeTop and scroll-based sentinel reattach to albumObserver and
cosplayerObserver in masonryObserver.js — prevents IntersectionObserver
getting stuck when sentinel stays visible after load by re-evaluating on
each scroll frame
Root cause: ViewMode="ViewMode" (no @ prefix) was interpreted as string
literal "ViewMode" by Blazor, not property value "masonry". GetCardStyle()
never produced aspect-ratio, so off-screen lazy-loaded images had 0 height
and _doApply stacked them 8px apart in one column.
Fixes:
- AlbumGrid.razor: ViewMode="ViewMode" -> ViewMode="@ViewMode"
- AlbumCard.razor: GetCardStyle() always returns aspect-ratio for masonry
- AlbumGrid.razor.css / AlbumDetail.razor.css: CSS aspect-ratio fallback
- masonryObserver.js: MutationObserver-based setup/teardown replaces
OnAfterRenderAsync-based apply, eliminating layout-timing races
- AlbumGrid.razor / AlbumDetail.razor: one-time setup with _masonrySetup guard
ProgressBar segments now use the same --status-* CSS custom properties as the status dots, ensuring visual consistency. ProgressBar applies CSS variable colors via inline background-color style.
Add AlbumTotalCount [NotMapped] to Person model, set before pagination in FindVisible, read in PersonMapper instead of paginated Albums.Count. Also apply inline aspect-ratio only in masonry mode so grid mode uses CSS aspect-ratio: 1 for square crops.
Added overflow-y: scroll to html element to always reserve scrollbar
space. When content grows beyond viewport, the scrollbar appearance
would reduce container width by ~17px, causing CSS column masonry to
recalculate and shift items leftward. Fixes#146.
QueueFileSystemCrawl() relied on the 'folders' field which is only
populated when FolderScanEnabled is true. Now falls back to fetching
active folders from the DB when the cached list is empty. Fixes#151.
Adds GET /api/auth/register endpoint to check registration status.
Register page now pre-checks on load and shows EmptyState instead of
the form when registration is disabled. Fixes#149.
LoginService.Register() now returns HttpStatusCode instead of bool,
so the frontend can distinguish between 403 (registration disabled),
409 (duplicate), and other failures. Fixes#149.
Frontend was requesting page 1 as the first page, but the backend
uses zero-based pagination (page 0 = first). The past jobs tab never
loaded any results. Fixes#145.
Removed the explicit AddJsonFile calls in Program.cs — WebApplication.CreateBuilder
already adds them internally, but in the wrong order (after env vars). This caused
DatabaseAddress__Host env var from docker-compose to be silently overridden by
appsettings.json defaults, resulting in connection to 127.0.0.1:3306 instead of
database:5432.
Also updated docker-compose.yml to set DatabaseAddress__Host and DatabaseAddress__Port
explicitly, and default ASPNETCORE_ENVIRONMENT to Production.