87 Commits
Author SHA1 Message Date
REDCODE 9eb333093a fix(jobs): case-sensitive path handling and drop pre-count in favor of live progress 2026-08-20 00:51:01 +02:00
REDCODE 30ad404efd fix(jobs): use IngestedAt as incremental discovery cursor 2026-08-20 00:50:56 +02:00
REDCODE 7823d9f4d4 feat(db): add Asset.IngestedAt cursor with backfill and keyset index 2026-08-20 00:50:50 +02:00
REDCODE 82ab09adcb Merge branch 'develop' into feature/incremental-scans 2026-08-19 13:04:25 +00:00
REDCODE 4d068216df fix(jobs): scope directory lookup by path prefix instead of FolderId 2026-08-19 14:51:39 +02:00
REDCODE 24d6109d33 fix(jobs): reconcile assets by exact path to prevent duplicate re-insertion 2026-08-19 14:45:28 +02:00
REDCODE 68a583d584 feat(jobs): report accurate walk progress for directory scans 2026-08-19 14:38:52 +02:00
REDCODE 1c7ede30af fix(jobs): reconcile soft-deleted assets so live files are not re-inserted 2026-08-19 14:27:43 +02:00
REDCODE a5dda805fd feat(ui): add launch modal with folder selection and per-folder scan actions 2026-08-19 14:10:38 +02:00
REDCODE 87259a02a5 feat(jobs): support deep and folder-scoped scan/discovery runs 2026-08-19 14:10:29 +02:00
REDCODE e2bdff9277 feat(jobs): merge create persons into the discovery job with incremental scanning 2026-08-19 14:10:21 +02:00
REDCODE 9beeb92ff0 perf(jobs): rewrite file-system crawl for incremental mtime-based scans 2026-08-19 14:10:02 +02:00
REDCODE a798a2cf30 feat(db): add incremental scan state tables and folder cursors 2026-08-19 14:09:57 +02:00
REDCODE 2fd03c4d14 Merge pull request 'refactor: adopt primary-constructor style in SettingsService and UserService' (#171) from feature/regularize-services into develop
Reviewed-on: #171
2026-08-18 14:47:18 +00:00
REDCODE 6026c9ec43 Merge branch 'develop' into feature/regularize-services 2026-08-18 14:44:46 +00:00
REDCODE d96473eca5 Merge pull request 'refactor: optimize stats endpoint & page for scale' (#178) from feature/stats-optimization into develop
Reviewed-on: #178
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-08-17 22:39:38 +00:00
REDCODE 34080648d6 Merge branch 'develop' into feature/stats-optimization 2026-08-17 22:19:00 +00:00
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
REDCODE e995388af0 fix(stats): correct no-album/person counts and refresh charts on reload
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
2026-08-18 00:12:13 +02:00
REDCODE 3fbdd56c10 Merge pull request 'fix: use real anchor links for navigation so they open in a new tab' (#179) from feature/anchor-nav-links into develop
Reviewed-on: #179
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-08-17 21:59:20 +00:00
REDCODE 026b5d8fe8 Merge branch 'develop' into feature/anchor-nav-links 2026-08-17 21:59:01 +00:00
REDCODE 7ded2b068b fix: use anchor link for back to albums on missing album 2026-08-17 23:56:51 +02:00
REDCODE 99117347d7 fix: use anchor link for login on register page 2026-08-17 23:56:48 +02:00
REDCODE 42154e9e5c fix: use anchor link for register on login page 2026-08-17 23:56:43 +02:00
REDCODE 230202337a fix: use anchor links in user dropdown for new-tab navigation 2026-08-17 23:56:38 +02:00
REDCODE 2ded40d2a0 feat(stats): add manual refresh button with last-updated timestamp
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
2026-08-17 23:44:02 +02:00
REDCODE 965caadc93 fix(stats): compute no-album/person counts as differences
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
2026-08-17 23:40:02 +02:00
REDCODE 8a1b39347d fix(stats): use indexed join-table anti-joins for no-album/person
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
2026-08-17 23:33:21 +02:00
REDCODE d3124c55bd feat(stats): add charts to visualize distributions
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
2026-08-17 22:58:32 +02:00
REDCODE c97b16ada9 refactor(stats): extract shared components & formatting helper
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
2026-08-17 22:55:54 +02:00
REDCODE 5e7c053218 feat(stats): cache aggregated results in-memory
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
2026-08-17 22:53:24 +02:00
REDCODE e1bb7d3330 refactor(stats): consolidate and async-ify stats queries
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
2026-08-17 22:52:27 +02:00
REDCODE 0c8e653a5f perf(stats): add filtered database indexes
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
2026-08-17 22:50:35 +02:00
REDCODE 84d3f63a97 Merge pull request 'fix: resolve nullable dereference warnings' (#172) from feature/null-deref-fixes into develop
Reviewed-on: #172
2026-08-17 20:31:01 +00:00
REDCODE 9e9d091f60 fix: resolve nullable dereference warnings in list/detail pages 2026-08-17 22:12:28 +02:00
REDCODE 556c4a15e0 fix: use inherited LoginService property to avoid CS9107 2026-08-17 22:02:51 +02:00
REDCODE 3374fe6ad4 refactor: adopt primary-constructor style in SettingsService and UserService 2026-08-17 22:01:22 +02:00
REDCODE e1839d4e8f Merge pull request 'refactor: repurpose MediaService as shared media URL builder' (#168) from feature/media-url-builder into develop
Reviewed-on: #168
2026-08-17 19:16:59 +00:00
REDCODE ee73c8ad90 Merge remote-tracking branch 'gitea/develop' into feature/media-url-builder 2026-08-17 21:14:05 +02:00
REDCODE a9d58606a7 Merge pull request 'chore: remove dead frontend code' (#169) from feature/remove-dead-code into develop
Reviewed-on: #169
2026-08-17 19:12:56 +00:00
REDCODE 588095f370 chore: remove dead frontend code 2026-08-17 20:14:15 +02:00
REDCODE 9f5d9c0c11 refactor: repurpose MediaService as shared media URL builder 2026-08-17 19:30:59 +02:00
REDCODE e6a8799965 chore: refresh GIT_VERSION env to v1.0.3.2 describe 2026-08-17 13:46:14 +02:00
REDCODE ff2e7ff6dc Merge branch 'master' into develop 2026-08-17 13:45:10 +02:00
REDCODE cd1e5841cf Merge branch 'release/v1.0.3.2'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m51s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 3m6s
2026-08-17 13:44:50 +02:00
REDCODE d318400504 chore: add v1.0.3.2 changelog 2026-08-17 13:44:45 +02:00
REDCODE 9d70516da2 Merge pull request 'db query optimizations' (#152) from feature/db-query-optimizations into develop
Reviewed-on: #152
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-08-15 22:54:32 +00:00
REDCODE 52e026d7e1 perf(ui): page cosplayer albums via album endpoint and skip count queries
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).
2026-08-11 13:11:13 +02:00
REDCODE a811221228 perf(media): cache thumbnails and previews in browser for 24h
Thumbnails/previews are immutable per asset, so responses are marked
Cache-Control: public, max-age=86400, eliminating repeat fetches on scroll
and back-navigation.
2026-08-11 13:10:46 +02:00
REDCODE 3ffee8df78 perf(db): add filtered IX_Assets_VisibleCreatedAt index for default listing
Serves OrderByDescending(CreatedAt) on the visibility-filtered asset listing,
replacing a sort of the full filtered set with an index-ordered scan.
2026-08-11 13:10:19 +02:00
REDCODE 63e3413141 perf(asset): server-side seeded shuffle, DTO projection, opt-in count
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.
2026-08-11 13:10:16 +02:00
REDCODE b83b97934c perf(album): project assets to DTO in SQL and slim Find paths
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.
2026-08-11 13:09:48 +02:00
REDCODE 6bf627e429 perf(person): push album filter/search/sort/pagination to SQL in FindVisible
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.
2026-08-11 13:09:44 +02:00
REDCODE 7ef8d8155f feat(dto): add personOwnerId album filter and opt-in asset IncludeCount 2026-08-11 13:09:17 +02:00
REDCODE 1aeb644e30 chore: updated scorecard 2026-07-29 20:19:33 +02:00
REDCODE 8913fa7c43 fix: add auto-save middleware as safety net for Save() pattern 2026-07-29 20:15:53 +02:00
REDCODE f42c2e4d04 docs: add desloppify scorecard to README 2026-07-29 20:13:49 +02:00
REDCODE 0480b00766 fix: desloppify review fixes - PathFromGuid extraction, RegexHighlighter dedup, port default fix
- 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)
2026-07-29 20:12:58 +02:00
REDCODE 26674e05e5 refactor: quality improvements from desloppify scan
- 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
2026-07-29 19:52:49 +02:00
REDCODE 29eb39e545 chore: bump version to v1.0.3.1, add changelog and version env generation 2026-07-22 17:48:59 +02:00
REDCODE fbcc7a317d Merge branch 'master' into develop 2026-07-22 15:14:11 +02:00
REDCODE ee07306a4d Merge branch 'develop'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m29s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m52s
2026-07-22 15:13:01 +02:00
REDCODE 294f7a6cae chore: removed outdated agent.md 2026-07-22 15:11:31 +02:00
REDCODE 595399385b chore: scripts and run configs to automate version injection 2026-07-22 15:09:52 +02:00
REDCODEandopencode 77ee80cd63 feat: embed git version in WASM footer via AssemblyInformationalVersion
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>
2026-07-22 15:09:06 +02:00
REDCODE 9e67c597ef refactor: replace IntersectionObserver with input-event-based infinite scroll
- 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
2026-07-22 14:31:08 +02:00
REDCODE 05ff6e8f2d fix: prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
- 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
2026-07-22 14:08:29 +02:00
REDCODE 07e43b5023 feat: add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
- 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
2026-07-22 13:58:30 +02:00
REDCODE 00e0dd42d1 fix: masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
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
2026-07-21 18:13:24 +02:00
REDCODE 6f60ade6df fix: eliminate masonry layout race on infinite scroll and banner double-fetch (#146) 2026-07-21 16:55:02 +02:00
REDCODE eaa067134e fix: use status CSS variables for progress bar segment colors
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.
2026-07-21 16:39:22 +02:00
REDCODE 701c4360b4 Merge branch 'hotfix/v1.1-bugfixes'
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m42s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 3m6s
2026-07-21 15:59:23 +02:00
REDCODE e132d269eb Merge branch 'hotfix/v1.1-bugfixes' into develop 2026-07-21 15:59:14 +02:00
REDCODE 79252d363d fix: sort album assets by OriginalFilename instead of CreatedAt (#144) 2026-07-21 15:55:58 +02:00
REDCODE 8ff21fa56b fix: use pre-pagination album count (#143)
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.
2026-07-21 15:47:21 +02:00
REDCODE 2305e44e68 fix: restore masonryLayout.dispose that was lost in reset edit 2026-07-21 15:11:30 +02:00
REDCODE 439df56f2b fix: use per-card aspect-ratio from cover dimensions for stable masonry layout (#146) 2026-07-21 15:05:02 +02:00
REDCODE f70f256c07 fix: add aspect-ratio to album cards for stable masonry height (#146) 2026-07-21 14:39:27 +02:00
REDCODE 3b1f26cdf3 fix: replace overflow-y with JS masonry for stable album layout (#146) 2026-07-21 14:26:31 +02:00
REDCODE 9965a13f40 fix: order album assets by CreatedAt in album detail (#144) 2026-07-21 14:26:29 +02:00
REDCODE fbafa54322 fix: prevent masonry layout shift on infinite scroll
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.
2026-07-21 14:16:07 +02:00
REDCODE 9607746912 fix: allow manual folder scan when scheduled scan is off
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.
2026-07-21 14:14:41 +02:00
REDCODE 785d33e289 fix: hide register form when registration is disabled
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.
2026-07-21 14:14:18 +02:00
REDCODE a3eeb8b6d1 fix: show registration disabled message on 403
LoginService.Register() now returns HttpStatusCode instead of bool,
so the frontend can distinguish between 403 (registration disabled),
409 (duplicate), and other failures. Fixes #149.
2026-07-21 14:13:06 +02:00
REDCODE cae61116c8 fix: jobs past tab zero-based pagination
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.
2026-07-21 14:12:25 +02:00
REDCODE ced0f61b87 fix: cropper handle size constant mismatch (14→18 px)
CSS defines handles as 18×18px but JS positioned them as 14px,
causing visible misalignment. Fixes #150.
2026-07-21 14:11:42 +02:00
REDCODE 9751c81574 Merge branch 'master' into develop 2026-07-17 04:47:00 +02:00
131 changed files with 6488 additions and 1983 deletions
+1
View File
@@ -0,0 +1 @@
GIT_VERSION=v1.0.3.2-1-gff2e7ff
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
if [ "${{ matrix.service }}" = "Lactose" ]; then
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
else
echo "BUILD_ARGS=" >> $GITHUB_ENV
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
fi
- name: Build and push ${{ matrix.service }}
-320
View File
@@ -1,320 +0,0 @@
# MilkyShots Agent Guidelines
This file captures the coding conventions, architecture rules, and standards extracted from the codebase. Follow them in every contribution to keep the project consistent.
---
## Project Overview
MilkyShots is a .NET 8 media-library application composed of four projects:
| Project | Role |
|---|---|
| **Butter** | Shared class library DTOs, enums, MIME-type tables, settings definitions |
| **Lactose** | ASP.NET Core Web API backend controllers, EF Core models, repositories, services, background jobs |
| **MilkStream** | Blazor WebAssembly host serves static WASM files and provides dynamic configuration |
| **MilkStream.Client** | Blazor WASM client Razor components, frontend services, SCSS styles (runs in browser) |
The backend stores data in **PostgreSQL** (with the `pgvecto-rs` extension) accessed via **Entity Framework Core**.
The WASM client runs entirely in the browser and communicates with the Lactose backend over HTTP/JSON using named `HttpClient` instances.
---
## Technology Stack
- **.NET 8 / C# 12** — target framework and language version for all projects.
- **ASP.NET Core** (Lactose) and **Blazor WebAssembly** (MilkStream.Client hosted by MilkStream).
- **Entity Framework Core** with `Npgsql.EntityFrameworkCore.PostgreSQL`.
- **JWT Bearer** authentication + refresh-token rotation.
- **Bootstrap 5** and **SCSS** (compiled by `AspNetCore.SassCompiler` in debug) for styling.
- **Docker / docker-compose** for containerised deployment.
---
## Solution Structure
```
MilkyShots/
├── Butter/ # Shared library
│ ├── Dtos/ # DTOs grouped by entity (Album/, Asset/, …)
│ ├── Settings/ # Settings enum + extensions
│ └── Types/ # Shared enums (EAccessLevel, EAssetType)
├── Lactose/ # Web API
│ ├── Authorization/ # Claims transformation + authorization handlers
│ ├── Configuration/ # Options classes (SignKeyConfiguration, SignKeyProvider)
│ ├── Context/ # EF Core DbContext (LactoseDbContext)
│ ├── Controllers/ # API controllers
│ ├── Jobs/ # Background job infrastructure + concrete jobs
│ ├── Mapper/ # Model → DTO mapping (static extension methods)
│ ├── Migrations/ # EF Core migrations (generated do NOT hand-edit)
│ ├── Models/ # EF Core entity classes
│ ├── Repositories/ # Interfaces + implementations for data access
│ ├── Services/ # Application services (auth, DB initialiser, scheduler)
│ └── Utils/ # Small helpers / extension methods
├── MilkStream/ # Blazor WASM static host
│ ├── Program.cs # Minimal API serves WASM files + dynamic /appsettings.json endpoint
│ └── appsettings.json # Server-side config (LactoseBaseUrl for dynamic injection)
└── MilkStream.Client/ # Blazor WASM client (runs in browser)
├── Components/ # Razor components (Pages/, Layout/, SettingBoxes/, …)
├── Services/ # Frontend services (LoginService, MediaService, …)
├── Styles/ # SCSS source files
└── wwwroot/ # Static assets bundled into WASM
```
---
## Naming Conventions
### C# identifiers
| Category | Pattern | Example |
|---|---|---|
| Enums | `E` prefix | `EAccessLevel`, `EAssetType`, `EJobStatus` |
| Interfaces | `I` prefix | `IAssetRepository`, `IAlbumRepository` |
| DTOs | `…Dto` suffix (PascalCase) | `AssetDto`, `AlbumCreateDto` |
| DTO sets: create / update / preview / full | `…CreateDto`, `…UpdateDto`, `…PreviewDto`, `…FullDto` | `AlbumCreateDto` |
| Repository interfaces | `I…Repository` | `IAssetRepository` |
| Repository implementations | `…Repository` | `AssetRepository` |
| Mapper classes | `…Mapper` | `AssetsMapper`, `UsersMapper` |
| Controller classes | `…Controller` | `AssetController` |
| Background jobs | extend `Job` | `FileSystemCrawlJob` |
| Services | `…Service` | `LactoseAuthService`, `LoginService` |
> **Note:** The suffix `DTO` (all-caps) appears in one legacy file (`PagedParametersDTO.cs`).
> New files must use `Dto` (mixed-case) to match the rest of the codebase.
### Files and directories
- One public type per file; file name equals the type name.
- Folders mirror the namespace suffix (e.g. `Lactose.Repositories``Lactose/Repositories/`).
---
## C# Coding Style
- Enable **nullable reference types** (`<Nullable>enable</Nullable>`) and **implicit usings** in all projects.
- Prefer **primary constructors** (C# 12) for constructor injection:
```csharp
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository
) : ControllerBase { … }
```
- Use **`var`** for local variables when the type is obvious from the right-hand side.
- Use **object initialisers** when creating entity or DTO instances.
- Use the **`required`** keyword on properties that must be set during object construction.
- Default string properties to `string.Empty`, not `null`.
- Default collection navigation properties to `null` (lazy) — the EF Core model builder wires up relationships.
- Mark navigation property regions with `#region Navigation Properties … #endregion`.
### Documentation comments
- Every **model class** and every property on it must have a `/// <summary>` XML comment.
- Every **interface method** must have `<summary>`, `<param>`, and `<returns>` XML comments.
- **Mapper classes** must have a `<summary>` on the class and on each method.
- Controllers may omit XML comments on action methods (use inline comments instead).
### Logging
- Inject `ILogger<T>` into every controller and service.
- Use structured logging with message templates (avoid string concatenation in log calls where possible).
- Use verbatim interpolated string literals (`$"""…"""`) for multi-line log entries.
- Log-level conventions:
| Level | When to use |
|---|---|
| `LogTrace` | Detailed per-request data (IDs, field values) |
| `LogDebug` | Medium-detail operational info |
| `LogInformation` | Key lifecycle events (job started, user registered, …) |
| `LogWarning` | Non-fatal problems (missing resource, permission violation) |
| `LogError` | Exceptions and failures |
---
## Entity / Model Rules
- Primary keys are **`Guid`** annotated with `[Key]`.
- String columns must specify `[Column(TypeName = "VARCHAR(n)")]` with an appropriate max length.
- Foreign-key scalar properties use `[ForeignKey(nameof(NavigationProperty))]`.
- Timestamps follow the pattern: `CreatedAt`, `UpdatedAt`, `DeletedAt` (nullable `DateTime?` for soft delete).
- **Soft delete** — entities are never hard-deleted from the database via normal application flows; set `DeletedAt = DateTime.Now` instead.
- `[Index(nameof(Field), IsUnique = true)]` is used on fields that must be unique at the DB level.
- Model classes live in `Lactose.Models`; shared enums/types live in `Butter.Types`.
---
## Repository Pattern
- Every repository exposes its contract through an `I…Repository` interface in the same folder.
- All repository interfaces implement `IDisposable` (or `IAsyncDisposable` where async teardown is needed).
- Standard method set:
| Method | Signature |
|---|---|
| Find by PK | `T? Find(Guid id)` |
| Find multiple | `IEnumerable<T> FindBulk(IEnumerable<Guid> ids)` |
| Insert | `void Insert(T entity)` |
| Update | `void Update(T entity)` (sets `UpdatedAt = DateTime.Now`) |
| Bulk update | `void UpdateBulk(IEnumerable<T> entities)` |
| Save | `void Save()` → `context.SaveChanges()` |
| Delete | `void Delete(T entity)` or `void Delete(Guid id)` |
- **`Save()` must be called explicitly** after insert/update/delete operations; repositories do not auto-save except `FolderRepository.Create()` (which needs to fire an event after commit).
- Repositories may expose **static events** for cross-service notification (e.g. `FolderRepository.FolderAdded`, `SettingsRepository.SettingChanged`). Keep these static and `EventHandler<T>` typed.
- Register all repositories as **`Transient`** in `Program.cs`.
---
## Mapper / DTO Rules
- Mapper classes are **`public static`** and live in `Lactose.Mapper`.
- Mapping methods are **extension methods** on the model type:
```csharp
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) { … }
```
- Naming convention: `To<TargetType>()` or `To<TargetType>(extraParam)`.
- Never put business logic in mappers — only field assignments.
---
## Controller Rules
- Decorate every controller with `[ApiController]` and `[Route("api/[controller]")]`.
- Route URLs are lowercase (`builder.Services.Configure<RouteOptions>(o => o.LowercaseUrls = true)`).
- HTTP verb → CRUD mapping:
| Verb | Operation |
|---|---|
| `[HttpGet]` / `[HttpGet("{id}")]` | Read single / search/list |
| `[HttpPut]` | Create a new resource |
| `[HttpPost("{id}")]` | Update a specific resource |
| `[HttpPost]` | Bulk update |
| `[HttpDelete("{id}")]` | Delete a specific resource |
| `[HttpDelete]` | Bulk delete |
- Return `ActionResult<T>` for endpoints that return a body, and `IStatusCodeActionResult` or `ActionResult` for status-only responses.
- Standard error returns: `NotFound()`, `BadRequest()`, `Unauthorized()`, `Forbid()`, `Conflict(…)`.
- Always validate `null` after a repository lookup and return `NotFound()` before proceeding.
- Access-level checks must be performed inside the action method as well as via `[Authorize]` attributes (defence-in-depth).
---
## Authentication & Authorisation
- Auth uses **JWT Bearer** tokens (`JwtBearerDefaults.AuthenticationScheme`).
- Access is controlled by three roles (in ascending privilege order):
| Role | Description |
|---|---|
| `User` | Read-only access to publicly shared / explicitly shared content |
| `Curator` | Can create/update/delete their own content |
| `Admin` | Unrestricted access |
- `[Authorize]` with no roles = any authenticated user.
- `[Authorize(Roles = "Admin")]` or `[Authorize(Roles = "Admin, Curator")]` for restricted endpoints.
Note the space after the comma in the role list — this is the project's established style.
- Use `LactoseAuthService.GetUserData(User)` inside action methods to retrieve the typed `LactoseAuthenticatedUser` from claims.
- Refresh tokens are stored (hashed by ASP.NET Identity) in the `User` table.
`RefreshTokenTransformation` (a `IClaimsTransformation`) validates the refresh token on every request and adds a `"LoginValid"` claim.
- Access tokens expire after **10 minutes**; refresh tokens expire after **60 minutes**.
---
## Background Jobs System
- All background jobs **extend `Job`** (abstract base in `Lactose.Jobs`).
- Override `TaskJob(CancellationToken token)` to implement job logic.
- Check `token.IsCancellationRequested` inside loops and call `Status.Cancel()` + `return` when set.
- Use `Status.UpdateProgress(float, string)` to report progress (value between 0 and 1).
- Use `Status.Complete(string)` / `Status.Fail(string)` for terminal states.
- New job types must be instantiatable via `ActivatorUtilities.CreateInstance<T>(serviceProvider, extraArgs)`.
- Enqueue jobs with `JobManager.CreateJob<T>(args)` then `JobManager.EnqueueJob(job)`.
- Maximum concurrent jobs defaults to **8** (`JobManager.MaxConcurrentJobs`).
- Timer-based scheduling is handled by `JobScheduler` which reacts to `SettingsRepository.SettingChanged`.
---
## Settings System
- Runtime settings are persisted in the `Settings` database table.
- Canonical names are defined in `Butter.Settings.Settings` enum with `AsString()` extension.
- Default values and metadata are loaded from **`Lactose/DefaultSettings.json`** on startup by `DbInitializer`.
- When adding a new setting:
1. Add an entry to `Settings` enum and `AsString()`.
2. Add the default entry to `DefaultSettings.json`.
3. React to changes via `SettingsRepository.SettingChanged` event.
---
## Frontend (MilkStream.Client / Blazor WASM)
- Use **Blazor WebAssembly** the client runs entirely in the browser.
- Pages live in `MilkStream.Client/Components/Pages/`, reusable components in `MilkStream.Client/Components/` (or a subfolder by concern).
- Services that call the backend extend either `ServiceBase` (no auth) or `AuthServiceBase` (requires auth).
- `AuthServiceBase` subscribes to `LoginService.AuthInfoChanged` to keep the `Authorization` header up to date.
- Use the **`"MilkStreamClient"`** named `HttpClient` (configured with `JwtTokenRefresher`) for any authenticated API call.
- `JwtTokenRefresher` automatically refreshes the access token when it is expired or expires within 1 minute.
- Use `ProtectedLocalStorage` / `ProtectedSessionStorage` (Blazor's encrypted storage) for auth-related persistence.
### Dynamic Configuration
- MilkStream's `Program.cs` registers a `GET /appsettings.json` endpoint **before** `UseStaticFiles()`, shadowing the static file.
- This endpoint returns `{ "BaseUrl": "<LactoseBaseUrl>" }` from server-side configuration, ensuring the WASM client always receives the externally-reachable Lactose URL.
- The `LactoseBaseUrl` setting is injected via environment variable in Docker deployments and configured in `appsettings.json` / `appsettings.Development.json` for local development.
- SCSS source files go in `MilkStream.Client/Styles/`; compiled output is written to `MilkStream.Client/wwwroot/css/` (do not edit the compiled files by hand).
---
## Docker / Deployment
- Each deployable project has its own **multi-stage `Dockerfile`** (build → publish → base).
- `docker-compose.yml` at repo root wires up `lactose`, `milkstream`, and `database` services.
- Media is mounted as a **read-only volume** at `/diary` inside the `lactose` container.
- Data-protection keys for MilkStream are persisted via a named Docker volume.
- Environment-specific config is loaded from `appsettings.{EnvironmentName}.json`; secrets are injected via environment variables or Docker secrets — **never commit real credentials**.
- When `CorsAllowedOrigins` is set to `["*"]` in Lactose configuration, the API accepts requests from any origin (useful for browser-based WASM clients in unpredictable deployment environments). Lock this down to specific origins in production if needed.
---
## Configuration & Secrets
- `appsettings.json` contains placeholder/default values only.
- Sensitive values (DB password, JWT signing key) must be provided via environment variables or .NET User Secrets (development only, keyed by `UserSecretsId`).
- The JWT signing key (`SignKey:Key`) must be **at least 32 characters**.
- The DB port mapping in `docker-compose.yml` exposes PostgreSQL on host port `3306` (maps to container port `5432`).
---
## Git & Commit Conventions
This project uses **Conventional Commits** and generates a changelog with **git-cliff**.
| Prefix | Purpose |
|---|---|
| `feat:` | New feature |
| `fix:` | Bug fix |
| `refactor:` | Code restructuring without behaviour change |
| `perf:` | Performance improvement |
| `doc:` | Documentation changes |
| `style:` | Formatting / style only |
| `test:` | Tests |
| `chore:` / `ci:` | Build system, tooling, CI |
| `revert:` | Revert a previous commit |
- Commits with `chore(release):`, `chore(deps)`, `chore(pr)`, `chore(pull)` are excluded from the changelog automatically.
- Breaking changes must be marked with `[**breaking**]` in the commit body or footer.
---
## TODOs & Known Gaps (as of initial analysis)
The following items appear as `TODO` comments in the codebase and should be tracked:
- `AssetController.Create` — endpoint marked `[NonAction]`; purpose unclear, may need removal or redesign.
- `AlbumController`, `FolderController`, `SettingsController`, `TagController` — logging not yet wired in.
- `AuthController.Register` — should return a full `AuthResultDto` instead of a bare Guid.
- `UserController.Get` / `GetAll` — lower access levels should receive reduced user information.
- `FileSystemCrawlJob.AssetFromPath` — folder ID is not populated when crawling.
- `TagController.Get` (ancestors traversal) — not protected against circular references.
- Self-registration flow in `AuthController` is gated by the `UserRegistrationEnabled` setting but that check is not yet implemented in the controller.
+3 -1
View File
@@ -9,4 +9,6 @@ dotnet-tools.json
.opencode/
*.css.map
# SixLabors license file
/Lactose/sixlabors.lic
/Lactose/sixlabors.lic
.desloppify/
.claude/
+3 -1
View File
@@ -20,6 +20,8 @@
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
<method v="2">
<option name="RunConfigurationTask" enabled="true" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
</method>
</configuration>
</component>
+3 -1
View File
@@ -29,6 +29,8 @@
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
<method v="2">
<option name="RunConfigurationTask" enabled="false" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
</method>
</configuration>
</component>
+17
View File
@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="VersionEnvGen" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.sh" />
<option name="SCRIPT_OPTIONS" value="" />
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
<option name="INTERPRETER_PATH" value="/bin/zsh" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="true" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
</component>
-2
View File
@@ -44,8 +44,6 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
## Infrastructure gotchas
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
+8
View File
@@ -27,6 +27,14 @@ public class AlbumPreviewDto {
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the cover image width, if a cover exists.
/// </summary>
public int? CoverWidth { get; set; }
/// <summary>
/// Gets or sets the cover image height, if a cover exists.
/// </summary>
public int? CoverHeight { get; set; }
/// <summary>
/// Gets or sets the number of assets in the album.
/// </summary>
public int AssetCount { get; set; }
@@ -14,4 +14,9 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
/// Only returns albums that contain at least one asset uploaded by the specified user.
/// </summary>
public Guid? AssetUploadedBy { get; set; }
/// <summary>
/// Gets or sets the person ID to filter albums by their owner.
/// </summary>
public Guid? PersonOwnerId { get; set; }
}
@@ -38,4 +38,9 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// Gets or sets the uploader user ID to filter assets by their uploader.
/// </summary>
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
/// Skipping it avoids a full count query over the filtered result set.
/// </summary>
public bool IncludeCount { get; set; } = true;
}
+1 -1
View File
@@ -39,7 +39,7 @@ public enum Settings {
/// <summary>
/// Provides extension methods for the <see cref="Settings"/> enum.
/// </summary>
public static class SettingsExtensions {
public static class SettingsExtension {
/// <summary>
/// Converts a <see cref="Settings"/> value to its human-readable display string.
/// </summary>
+859
View File
@@ -0,0 +1,859 @@
# Changelog
## [1.0.3.2] - 2026-08-17
### 🚀 Features
- Add personOwnerId album filter and opt-in asset IncludeCount
### ⚡ Performance
- Page cosplayer albums via the album endpoint and skip count queries
- Cache thumbnails and previews in the browser for 24h
- Add filtered IX_Assets_VisibleCreatedAt index for the default listing
- Server-side seeded shuffle, DTO projection, and opt-in count
- Project album assets to DTO in SQL and slim the Find paths
- Push album filter/search/sort/pagination to SQL in person FindVisible
### 🐛 Bug Fixes
- Add auto-save middleware as a safety net for the Save() pattern
- Desloppify review fixes: PathFromGuid extraction, RegexHighlighter dedup, port default fix
### 🚜 Refactor
- Quality improvements from the desloppify scan
### ⚙️ Miscellaneous Tasks
- Update desloppify scorecard and add it to the README
## [1.0.3.1] - 2026-07-22
### 🚀 Features
- Add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
- Embed git version in WASM footer via AssemblyInformationalVersion
### 🐛 Bug Fixes
- Use status CSS variables for progress bar segment colors
- Eliminate masonry layout race on infinite scroll and banner double-fetch (#146)
- Masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
- Prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
### 🚜 Refactor
- Replace IntersectionObserver with input-event-based infinite scroll
### ⚙️ Miscellaneous Tasks
- Scripts and run configs to automate version injection
- Removed outdated agent.md
## [1.0.3] - 2026-07-21
### 🐛 Bug Fixes
- Cropper handle size constant mismatch (14→18 px)
- Jobs past tab zero-based pagination
- Show registration disabled message on 403
- Hide register form when registration is disabled
- Allow manual folder scan when scheduled scan is off
- Prevent masonry layout shift on infinite scroll
- Order album assets by CreatedAt in album detail (#144)
- Replace overflow-y with JS masonry for stable album layout (#146)
- Add aspect-ratio to album cards for stable masonry height (#146)
- Use per-card aspect-ratio from cover dimensions for stable masonry layout (#146)
- Restore masonryLayout.dispose that was lost in reset edit
- Use pre-pagination album count (#143)
- Sort album assets by OriginalFilename instead of CreatedAt (#144)
## [1.0.2] - 2026-07-17
### 🐛 Bug Fixes
- Remove redundant AddJsonFile calls that overrode env vars
### ⚙️ Miscellaneous Tasks
- Auto-run on tags only, manual dispatch on branches
## [1.0.1] - 2026-07-17
### 🐛 Bug Fixes
- Use explicit domain git.r3d.codes instead of gitea.server_url
- Sanitize tag names for Docker compatibility (replace / with -)
- Lowercase repository name for Docker compatibility
- Match matrix service names to actual directory case (Lactose/MilkStream)
- Remove gha cache config (cache server unreachable from builder)
### ⚡ Performance
- Add BuildKit cache mounts for NuGet packages in Dockerfiles
### ⚙️ Miscellaneous Tasks
- Add workflow_dispatch trigger with version input
- Also run on push to master
- Also run on develop and hotfix/* branches
- Pass SixLaborsLicenseKey as build arg for lactose
- Use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
### CI
- Added sixlabors license key to the docker file for lactose
## [1.0] - 2026-07-16
### 🚀 Features
- *(API)* Adds Authentication System (Needs Testing)
- *(API)* Adds UserRepository.cs
- *(API)* Adds Password hashing
- *(API)* Adds Password hashing
- *(API)* Implements user delete endpoint
- *(API)* Adds stubs for AssetController.cs
- *(API)* Adds Album endpoint Stubs
- *(API)* Adds TagController class
- *(API)* Adds Tag endpoint implementation
- *(API)* Adds Repository implementation (not tested yet)
- *(API)* Implements TagController With Bulk Requests
- *(API)* Adds person stub endpoint
- *(API)* Added Resolution, mimetype and extra media data on asset table
- *(API)* Adds computed file hash field in Asset
- *(API)* Adds Folder and Asset entities in the DB
- *(API)* Implemented FolderController and their respective Mappers/DTO/Repository interface and it's own implementation
- [**breaking**] Upgraded project to .Net 9.0
- *(API)* Implemented Settings Endpoint
- *(API)* Implemented Media endpoint and relative Repository and DTO
- *(DB)* Switched to PostgreSQL connector
- *(DB)* [**breaking**] Change to use database pgvecto-rs
- *(DB)* [**breaking**] Regenerated migrations for PostgreSQL switch
- Switched to on the fly connection string generation
- *(DbContext)* Adds Initialization for Development environment
- *(API)* Adds Authentication Result as DTO for authentication
- *(Lactose)* Adds RefreshToken
- *(Lactose)* Adds DB Context Snapshot
- *(Lactose)* Adds RefreshToken Endpoint
- *(DB)* Changed Asset table to have OriginalPath as Unique Index
- Added a settings enum + it's own extension class to retrieve interpretation type and string name in DB
- *(db)* Adds Migration for Settings in LactoseDbContext
- Added disposability to some repositories, changed the FileSystemScanner service to be a singleton.
- *(API)* Added Access level to the data returned from authentication actions
- *(frontend)* Upgraded login to new API changes
- *(API)* Added get-all endpoint for settings controller.
- *(frontend)* Added settings service
- *(API)* Added Access level to the data returned from authentication actions
- *(frontend)* Added login and JWT token auto-refresh
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
- *(frontend)* Login and logout now properly work
- *(frontend)* Added folders service
- *(frontend)* Nav menu restyle
- *(API)* Adds method to modify the FileWatcherService: folders, time
- *(API)* Edits SettingsRepository to manage FileWatcher Service
- *(frontend)* Editing of settings and addition/editing of folder paths
- *(frontend)* Added folder removal in settings
- *(API)* Added get-all endpoint for settings controller.
- *(frontend)* Added settings service
- *(API)* Added Access level to the data returned from authentication actions
- *(frontend)* Added login and JWT token auto-refresh
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
- *(frontend)* Login and logout now properly work
- *(frontend)* Added folders service
- *(frontend)* Nav menu restyle
- *(API)* Adds method to modify the FileWatcherService: folders, time
- *(API)* Edits SettingsRepository to manage FileWatcher Service
- *(frontend)* Editing of settings and addition/editing of folder paths
- *(frontend)* Added folder removal in settings
- Migrate MilkStream frontend from Blazor SSR to Blazor WASM
- *(cors)* Support semicolon-separated CorsAllowedOrigins env var
- Add ParentJobId to JobStatusDto for job hierarchy tracking
- Add ParentJobId to Job base class
- Set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob
- Map ParentJobId in JobMapper
- Add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector
- Add JobRow, JobChildrenSummary, JobSection, JobTree components
- Redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard
- Optimize jobs API with pagination, DB persistence, and cleanup
- Add GetAssetsMissingMetadata repository method for metadata job
- Implement MetadataJob for image resolution extraction
- *(docker)* Add named volumes for thumbnails and previews
- Unify settings pages into sidebar + detail panel with auto-save
- *(assets)* Add ThumbnailSize column to track generated thumbnail dimensions
- *(repository)* Add GetAssetsMissingOrWrongThumbnail to interface
- *(repository)* Implement GetAssetsMissingOrWrongThumbnail query
- *(thumbs)* Read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen
- *(db)* Add migration for ThumbnailSize column on Assets
- *(jobs)* Add CompletedWithErrors status for parents with failed sub-jobs
- *(ui)* Show CompletedWithErrors as orange and group sub-jobs by status
- *(jobs)* Apply batching to PHashJob and MetadataJob
- *(settings)* Add JobBatchSize setting for batch job sizing
- *(settings)* Add JobBatchSize setting for batch job sizing
- *(settings)* Add runtime-configurable Po2W slider limits + split thumbnail/preview ranges
- *(folders)* Add regex pattern field with named groups, syntax highlighting, and sample path preview
- Masonry gallery on homepage with infinite scroll
- Pass JWT token in media URLs for authenticated thumbnail/preview requests
- Make navbar sticky during infinite scroll
- Add PreviewJob and upgrade thumbnails to WebP
- Add CreatePersons and CreateAlbums jobs
- Add stacked progress bar for parent jobs with sub-job breakdown
- Improve logging and progress reporting for album/person jobs
- Add statistics page with aggregate asset/user/system stats via fast DB queries
- Add thousands separators to job status numbers
- Extend AssetPreviewDto with album and cosplayer names
- Add cosplayer and album name overlays on home page
- Add full-size view and download buttons in preview overlay
- Add bidirectional infinite scroll (up and down)
- Implement seed-based deterministic random ordering for home page
- Add data completeness indicators to statistics page
- Add CoverAssetId to albums with resolution data in full DTO
- Add AlbumService and PersonService to WASM client
- Add albums list page and album detail page with inline preview
- Auto-assign album cover from first alphabetically-sorted asset
- Make album names clickable links in home tile hover and preview
- Justified gallery layout for album detail page
- View mode selector for albums page (masonry/grid/list)
- *(admin)* Add user management page for admins
- *(admin)* Add inline editing for username, email, and password
- *(stats)* Add percentage and stale counters to completeness cards
- *(backend)* Add ProfileAssetId to Person model + migration
- *(dtos)* Update Person DTOs with ProfileAssetId and album previews
- *(backend)* Eager-load ProfileAsset and Albums in PersonRepository
- *(backend)* Add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto
- *(backend)* Implement person detail, update, and delete endpoints
- *(client)* Extract AlbumCard reusable component, update Albums.razor
- *(client)* Add PersonService client methods and PersonForm component
- *(client)* Add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs
- *(css)* Add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles
- *(backend)* Add ProfileCropX/Y to Person model for profile picture cropping
- *(ui)* Add circular profile picture cropper with drag-to-pan
- *(ui)* Redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support
- *(ui)* Add New Cosplayer button on list page with create endpoint and form
- *(ui)* Unassigned album selector in PersonForm, Add Albums button in CosplayerDetail
- *(dto)* Add RemovePerson flag to AlbumUpdateDto for explicit person unlinking
- Person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select
- Infinite scroll for albums on cosplayer detail page
- Migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
- Make album name in image previewer an anchor element for native navigation
- Allow users to update own albums and curators to update any album
- Add OldPassword field to UserUpdateDto for self-service password change
- Enforce old-password verification on own password change, allow admin override
- Add password change UI with strength bar and validation on user profile page
- Add server-side password policy validation (admin bypass)
- *(ui)* Redesign user profile page with avatar initials and edit profile modal
- *(ui)* Rewrite admin users page with sortable table, search, and modal-based editing
- *(ui)* Show avatar initial in navbar instead of generic icon
- *(ui)* Album detail multi-select edit mode, cosplayer link, move edit button (#63)
- *(api)* Add BulkDeleteAlbumsAsync for bulk album deletion
- *(ui)* Add album select/edit mode with bulk deletion
- *(ui)* Redesign jobs page with tabs, search, filters, and mobile cards
- Add LastChange tracking to JobStatus
- Add ModifiedAt to JobRecord with delta query support
- Implement delta-based children endpoint with ?since parameter
- Add delta merge logic to job tree components
- Poll children for all active roots and fix segment progress bar colors
- Return-url login redirects + keepalive + cosplayers fix
- Add password strength validation to registration page
- Hide cosplayers with no visible content from regular users
- *(css)* SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
- *(ui)* CSS variables for .razor.css, job type colors, interactive states, gradients
- *(jobs)* Extract all job-type colors to configurable SCSS variables
- *(ui)* CosplayerDetail banner empty gradient uses accent theme
- *(ui)* Aggressive gradients on navbar, buttons, modal headers
- *(ui)* Page background gradient, status/strength CSS vars, nav/image polish
- *(ui)* Replace default favicon with MilkyShot logo SVG
- *(cropper)* Circle handles with accent color, black border, drop shadow
- *(gradients)* Strengthen all gradients for more aggressive effect
- *(ui)* Apply metallic gradient to all buttons and surfaces
- *(ui)* Add eye toggle button to show/hide password on all password fields
- *(api)* Add sort parameters to pagination DTOs
- *(api)* Add sorting and unassigned filter to AlbumRepository.SearchQuery
- *(api)* Pass sort and filter params through AlbumController.Search
- *(ui)* Add SortFilterBar component and update Albums page with search/sort
- *(ui)* Extract AlbumGrid to isolate grid re-renders from SortFilterBar
- *(api)* Add SearchQuery to PersonRepository with sort/search/pagination
- *(api)* Paginate PersonController.GetAll with sort/search params
- *(ui)* Convert Cosplayers page to infinite scroll with sort/search toolbar
- *(ui)* Extract CosplayerGrid to isolate grid re-renders from SortFilterBar
- Add SearchDropdown typeahead component
- Wire SearchDropdown into NavMenu
- Make search dropdown headers selectable with keyboard and mouse
- Parse ?search= query param on Cosplayers and Albums pages
- *(cosplayer-detail)* Add SortFilterBar for album search and sort
- *(butter)* Add EVisibility enum, Maintainer access level, and SystemUploaderId setting
- *(butter)* Update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields
- *(models)* Update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer
- *(infra)* Update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility
- *(repos,controllers)* Update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
- *(db)* Add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy
- *(auth)* Complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
- Add MaintainedPersonIds to UserInfoDto with backend plumbing
- Add Maintainer to admin user dropdowns and role badges
- Add Visibility dropdown to album and person create/edit forms
- Add Protected Assets card to stats page
- Expand UI gating to include Maintainer with scope-aware CanEdit
- Maintainer cosplayer assignment in admin user edit modal
- Maintainer assignment UI for cosplayers, maintainer display on user page
- Expand user page edit modal for admins with access level, maintainer assignment, and danger zone
- Make usernames clickable on admin users page, linking to /User/{id}
- Add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
- Add role-visible colored borders on albums and assets based on item state (#78)
- Add bulk delete and visibility change to AlbumDetail select mode
- Show visibility/deleted breakdown in album header in select mode
- Add Select All / Deselect All button in album select mode
- Add album-level bulk actions to CosplayerDetail page
- Cascade visibility to albums and assets with lower-only rule
- Add multiselect editing controls to Cosplayers page
- Wire up SystemUploaderId setting — seed, UI, and crawl job backfill
- Bulk cosplayer assignment for albums from multiselect mode
- Add reusable ConfirmDialog component
- Add visibility-colored borders to cosplayer cards in selection mode
- Add rate limiting with configurable buckets
- Apply rate-limit buckets to controllers
- Add AlbumMergeDto and PersonMergeDto
- Add MergeAlbums to album repository
- Add MergePeople to person repository
- Add merge endpoints to AlbumController and PersonController
- Add frontend service methods for merge, bulk update, and bulk delete people
- Add MergeModal shared component for merge destination selection
- Add Merge button to Albums.razor with AlbumGrid preview access
- Add Merge button to Cosplayers.razor with CosplayerGrid preview access
- Add Merge Albums button to CosplayerDetail.razor
- Add Unlink cosplayer button to AlbumDetail.razor
- Add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters
- Add trigram GIN index on Asset.OriginalFilename for efficient ILike search
- Extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups
- Extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params
- Extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync
- Add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select
- Integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler
- Add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath
- Redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX
- Add collapsible sidebar toggle to AlbumAssetPicker folder browser
- Add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page
- Add visibility and create-album actions, fix embedded viewport sizing
### 🐛 Bug Fixes
- *(API)* Split authentication from user endpoint
- *(API)* Fix retrieve of user authentication data
- *(API)* Split authentication from user endpoint
- *(API)* Fixes null exception against anonymous users requests
- *(API)* Fix exception for UserRepository.cs
- *(API)* Fix Password hasher dependency resolver
- *(API)* Adds last login update on login
- *(API)* Fixes null exception against anonymous users requests
- *(API)* Fix exception for UserRepository.cs
- *(API)* Fix Password hasher dependency resolver
- *(API)* Adds last login update on login
- *(API)* Remove login permission to Banned or Disabled Users
- *(API)* User endpoints to be in line with new structure
- *(API)* Switches tagSearch method from uint to int
- *(docker)* Fixed Dockerfile stuck on old project name and build configs
- *(API)* Added support for pagination on Asset GetAll Query
- *(API)* Added deleted at field in UserInfo mappers
- *(API)* Implements GetAll tags in tag Repository
- *(API)* Wrong logger in album controller
- *(API)* Standardized DTO namespaces
- *(API)* Added required instead of null initialized strings where it didn't make sense to have a null string in Users DTOs
- *(API)* Added missing Email field in UserInfoDto
- *(API)* Marked as required both fields of BulkDto
- *(API)* Marked as required both fields of credentials DTOs
- *(API)* Marked as required the tag name in TagCreateDto
- *(API)* Marked as required all the various required fields in person related DTOs
- *(API)* Added null checking to folderRepository get operations in FolderController
- *(API)* Added GetAll method to Folder Repository and controller
- *(API)* [**breaking**] Marked required properties in Asset as required
- *(API)* Added FindByPath in AssetRepository
- *(API)* Allow Anonymous to use MediaEndpoint
- *(docker)* Dockerfiles updated to use Net 8 as base images
- *(docker)* Solved mismatch with base folders in both dockerfile and compose file
- *(docker)* Applied "Docker" as environment when building the application in a container thus avoiding not getting the correct configuration overloads at run time.
- *(Docker)* Removes Env var from where is not needed and add it to teh compose
- *(Docker)* Add internal db port in appsettings.Development.json
- *(docker)* [**breaking**] Postgres volume data was not named properly
- *(db)* Added postgres switch for Legacy Timestamp behaviour
- *(API)* Correct reference to the return type of result
- *(API)* Fixes the Db Initializer on Production Environment
- *(DB)* Added check for already existing assets on DB entry.
- *(Services)* Added FileSystem scan service to the startup of the webserver.
- *(frontend)* Adds the new services to the app startup configuration
- *(frontend)* Added correct headers and response format to LoginService
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
- *(API)* Solved filesystem scanner not scanning
- *(frontend)* Adds the new services to the app startup configuration
- *(frontend)* Added correct headers and response format to LoginService
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
- *(API)* Solved filesystem scanner not scanning
- Address code review findings (blocking .Result, async void handlers, email logging)
- Handle stale ProtectedLocalStorage value crashing NavMenu deserialization
- Serve appsettings.json dynamically from MilkStream to fix WASM BaseUrl in Docker
- Support wildcard CORS origin for self-hosted Docker deployments
- Replace CORS wildcard with explicit safe origins per environment
- Add DB healthcheck and service_healthy condition to prevent lactose startup race condition
- Catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation
- Notify auth info changed after token refresh and handle polling errors
- Change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel
- Handle 401 responses in JwtTokenRefresher by refreshing token and retrying
- Redirect to login when token refresh fails in JwtTokenRefresher
- Handle 401 in JobsService directly with refresh+retry
- Add missing Butter.Types import in AssetRepository for EAssetType
- Remove completed sub-jobs from list so master job can finish
- Prevent master PHashJob from spinning indefinitely on sub-job completion
- Use oninput for live slider value update and widen range layout
- Add minutes unit to Folder Scan Interval description in default settings
- Handle 401 transparently at AuthServiceBase level for all services
- Redirect unauthenticated users from settings page to /login
- Redirect unauthenticated users from jobs page to /login
- *(ui)* Render child jobs in JobTree by skipping root filter at deeper levels
- *(db)* Always override ThumbnailPath/PreviewPath values with local paths on init
- *(docker)* Create thumbnails/previews dirs as app user so subdirs can be created at runtime
- *(ui)* Restore auth in App.razor before rendering to prevent race redirect to /login
- *(phash)* Display child jobs grouped by status
- *(jobs)* Prevent silent crash on large thumbnail jobs and auto-refresh child jobs
- *(ui)* Apply poll interval to child jobs, add @key for DOM reuse, guard admin pages
- *(ui)* Submit login form on Enter key press
- *(jobs)* Fix IntegrityCheckJob conditional + use soft-delete
- *(jobs)* Add delay to JobManager main loop to prevent CPU spin
- Add DeletedAt filter to asset batch queries to exclude soft-deleted assets
- Add AssetUser many-to-many join table for SharedWith
- Remove AspNetCore.SassCompiler to fix Docker startup crash
- Proper bidirectional SharedWith many-to-many migration
- *(Lactose)* Admin and Curators media visibility
- Wire MaxConcurrentJobs to database setting on startup and runtime changes
- Propagate cancellation to sub-jobs in batch processing jobs
- Cancel past refresh loop when poll rate is set to Off
- Make job cancellation work for all job states and types
- Per-batch progress messages and counting bug in album/person jobs
- Skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run
- Add JobBatchSize to DefaultSettings.json to prevent reset on restart
- Populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
- Rewrite TopTags query to avoid EF Core collection navigation translation error
- Remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats
- Move preview info text below image instead of overlaying on bottom border
- Download button now fetches blob and triggers actual file download
- Remove scroll position correction from LoadPreviousPage for smoother scroll-up
- Propagate cancellation tokens through linked child job CTS
- Album controller Save calls, person list endpoint, media empty path guard
- Scoped CSS deep selectors for preview info text and filename binding
- Switch album detail page to row-based flexbox masonry
- Remove preventDefault from album links so they actually navigate
- Remove content-visibility and contain-intrinsic-size from album detail tiles
- Switch album detail grid from flexbox to CSS Grid
- Use flexbox with align-items:flex-start for album detail grid
- Unlock body scroll on navigation, reorder masonry for left-to-right flow
- JS-based masonry for left-to-right item flow with varying heights
- Faulty migration that could cause lactose crash on migration apply
- Use CSS columns for albums page, keep JS masonry only for album detail
- Override masonry inline styles with !important for grid mode
- Force square aspect-ratio in grid mode over inline style
- *(controllers)* Tighten asset visibility and media access checks
- *(AssetController)* Correct search visibility filter for #11
- *(AlbumController)* Exclude deleted assets from search visibility for #11
- *(admin)* Add confirm/cancel for role change and prevent admin self-demotion
- *(user)* Hash password in Create endpoint instead of storing raw
- *(ui)* Remove broken onfocusout handler from role edit dropdown
- *(admin)* Show inline errors instead of replacing entire page
- *(auth)* Show ban/disabled message on login and force-logout on refresh
- *(auth)* Detect banned/deleted user on page load via InitializeAsync
- *(frontend)* Sorts setting by alphabetical
- *(Lactose)* Change to use UtcNow, even if alredy working without timeline
- *(backend)* Add [FromQuery] to person list endpoint to fix 415 error
- *(css)* Move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors
- *(ui)* Larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle
- *(auth)* Force logout on any refresh failure, redirect to login
- *(ui)* Add scoped modal CSS to PersonForm for proper overlay rendering
- *(ui)* Cosplayer cards now match album card style — image fills square, text overlay at bottom
- *(js)* Set onload before src to handle cached images
- *(ui)* Remove empty filler cells from banner mosaic, let overlay cover gaps naturally
- *(ui)* Always render ProfileCropper in tree, use OnParametersSet to trigger JS init
- *(ui)* Random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static
- *(ui)* 4 corner handles on square boundary for resizing instead of single circular handle
- *(js)* Handle positions relative to square, not viewport
- *(js)* Corner-drag resize with fixed opposite corner, proper visual rate matching
- *(css)* Show full image in cropper viewport (contain instead of cover)
- *(ui)* Correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position
- *(js)* Adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle
- *(js)* Store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save
- *(ui)* Use pixel-exact transform positioning for avatar display instead of background-size formula
- *(js)* Use correct opposite corners for TL/TR/BL resize handles
- *(js)* Skip resize on first mousemove if mouse hasn't moved from mousedown position
- *(js)* Resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images
- *(api)* Allow unlinking person from album by directly assigning null PersonOwnerId
- *(build)* Remove stray semicolon in onclick lambda
- Prevent album title from being overwritten when only setting Person in update DTO
- *(pr)* Implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery]
- *(tests)* Fix pre-existing auth route tests and handle empty unassigned albums gracefully
- *(test)* Use response.body directly instead of JSON.parse (already parsed object)
- *(test)* Update name assertion to match renamed person
- Create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param
- Cache random banner covers in field, not computed on every render
- *(ui)* Responsive layout fixes for mobile portrait mode (closes #48)
- *(ui)* Collapse AdminUsers table columns and actions on mobile (#48)
- *(frontend)* Removed faulty d-flex tag on main object
- *(milkystream)* Fix sorted masonry for albums
- *(ui)* Prevent JobRow horizontal overflow on mobile portrait
- *(ui)* Change AdminUsers mobile actions dropdown from dropup to dropdown
- *(ui)* Reduce password input width on AdminUsers to prevent cell overflow
- *(ui)* Stack setting card description and control vertically on mobile
- *(ui)* Prevent layout shift from save indicator width change
- *(ui)* Prevent save indicator from shifting adjacent controls
- *(ui)* Stack setting card body vertically — description above, control + indicator below
- *(ui)* Right-align number inputs, let them grow to fill card width
- *(ui)* Replace inline save indicator with Bootstrap toast notification
- *(ui)* Restore progress bar visibility on job rows
- Resolve UI issues #61, #62, #66
- *(#62)* Move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
- *(#62)* Use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile
- *(#62)* Lock body scroll when modal forms open to prevent position:fixed breakage on mobile
- Propagate server-side error messages from UpdateUserAsync to UI
- Auto-close modal on password change success, add page-level success message
- Center profile card on page
- Wire profile icon in navbar to user profile page
- Default sort users table by role descending (admin on top)
- Use bold (700) instead of semi-bold for reliable font rendering
- *(mobile)* Keep back button inline with title, collapse admin actions into dropdown
- Batch-processing subjobs report correct status on asset failures
- Remove double-count of failed assets in master Done handler
- Metadata icon in dropdown uses text-light for visibility
- Metadata icon visible in job list rows
- Pass null since on first child fetch instead of DateTime.MinValue
- Eagerly fetch children on load and hide sub-jobs placeholder
- Re-render after eager child fetch so icons appear immediately
- Eager-fetch children for all roots including past jobs
- Update home logged-out message to match cosplayers wording
- Update albums logged-out message to match home/cosplayers
- Translate GetAllVisible to client-side eval for SharedWith navigation
- Suppress nullable warnings on Include chain in GetAllVisible
- Remove soft-delete from Person model
- *(jobs)* Add hover/active variants for all job types via darken()
- *(ui)* Modal icon order, fill all outline buttons
- *(favicon)* Render MilkyShot logo as PNG favicon
- *(ui)* Page bg radial gradient, navbar radial glow, favicon cache
- *(cropper)* Revert ProfileCropper to inline modal
- *(cropper)* Restore missing cropper CSS styles
- *(cropper)* Add pointer-events:auto on handles, fix cursors
- *(ui)* Fix float values in the cosplayer thumbnail and cosplayer card
- *(ui)* Fix remaining float culture issues in CSS style attributes
- *(ui)* Prevent infinite loading loop on cosplayers page with no data
- *(ui)* Keep header and toolbar visible when albums search returns empty
- *(ui)* Debounce search input and prevent backspace navigation
- *(ui)* Refocus search input after debounce triggers reload
- *(ui)* Move album grid CSS to AlbumGrid.razor.css for scoped isolation
- *(ui)* Add height:100% to album-card-fallback so icon centers vertically in grid mode
- *(ui)* Add margin-bottom to SortFilterBar for spacing from grid
- *(ui)* Add ActionButtons slot to SortFilterBar, move album actions into bar
- *(ui)* Push ActionButtons to right with ms-auto wrapper
- *(ui)* Keep header and toolbar visible when cosplayers search returns empty
- *(ui)* Move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation
- *(ui)* Move cosplayer actions into SortFilterBar, fix sortAsc always sending
- Close outer div and remove invalid @{} blocks in SearchDropdown
- Pressing enter with nothing selected does nothing
- Move query param parsing to OnParametersSet
- Parse search query before await in Albums OnInitializedAsync
- Use case-insensitive ILike search for persons and albums
- Override Bootstrap dropdown-menu display:none
- Return 401 Unauthorized when user data is null in PersonController
- Allow anonymous search for persons and albums
- Materialize person search query before client-side visibility filter
- Restrict user list to admin only
- Add [FromQuery] to TagController.GetAll parameter
- Use zero-based page numbers in AssetController to match API convention
- Guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query
- Restore Include(p => p.Albums) in PersonRepository queries
- Paginate PersonRepository.SearchQuery by ID before loading albums
- Prevent duplicate @key errors across all list renderings
- Filter album count by user visibility in person search
- Restore ToAlbumPreviewDto mapper method per review feedback #91
- Standardize pagination to zero-based across all layers
- Move SharedWith visibility check to client side for translatable queries
- Only reject Page < 0, keep original PageSize validation
- Restore PageSize < 1 validation with upper limit of 250
- *(cosplayer-detail)* Prevent banner image shuffle on album search/sort
- *(cosplayer-detail)* Debounce search, keep page intact on filter changes
- *(cosplayer-detail)* Remove search debounce, instant inline reload
- Replace untranslatable Album navigation in stats queries with direct join table queries
- Replace untranslatable Tags navigation in TopTags stats query with direct join table query
- *(albums)* Use MaxPageSize constant for unassigned album fetch
- Implement CosplayersMissingProfile stat
- Make card components middle-clickable
- Remove underline from album card person link
- *(album-form)* Searchable person combobox with modal-safe positioning
- *(jobs)* Use grey for queued segments in layered progress bar
- *(repo)* Scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
- *(controller)* Scope Maintainer album visibility to only maintained persons
- *(controller)* Scope Maintainer asset access to only maintained cosplayers in Get
- *(controller)* Add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
- *(mapper)* Allow Maintainer to see Visibility field in DTO responses
- *(media)* Replace bool parameter with EAccessLevel, add deleted asset check
- *(stats)* Add ProtectedAssets count, fix PrivateAssets to count only Private
- *(dto)* Default AssetCreateDto.Visibility to Private instead of Protected
- *(repo)* Use PagedParametersDto.MaxPageSize const instead of hardcoded 150
- *(migration)* Remap existing EAccessLevel values to prevent data corruption
- *(migration)* Default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
- *(dto)* Make AssetCreateDto.UploadedBy nullable for scanner-created assets
- *(model)* Add [PrimaryKey] annotation to PersonMaintainer for clarity
- *(repo)* Scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
- *(controller)* Scope AssetController maintainer permissions via album→person chain
- *(mapper)* Replace null-forgiving operator with null-conditional in AlbumMapper
- *(controller)* Split Maintainer from User in AssetController.GetAll visibility filter
- *(media)* Allow Maintainer to access media files for cosplayers they maintain
- *(mapper)* Scope asset counts in album/ person previews to visible assets
- Add missing app.css link to index.html
- Null reference in User page maintainer display
- Remove underline from user links, hide email from non-admin viewers
- Remove underline from maintainer links, load assigned person names in user edit modal
- NRE on user page when user data not yet loaded
- Show email to self-viewing users on user page
- Enforce data visibility per access level with test coverage
- Replace jsonPath with response.body for null-value checks in visibility tests
- Replace remaining jsonPath calls in asset visibility tests
- Allow maintainer assignment for all user roles in UI
- Address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
- Show maintained cosplayers section for all user types in admin edit modal
- Show maintained cosplayers section for all user types in user profile edit modal
- Add entity-level visibility gating for albums and persons
- Update border colors and browse-mode visibility in AlbumDetail
- Show borders for maintainers in select mode
- Prevent jagged wrapping of toolbar and action buttons
- Album-card-select and cosplayer-card-select only on selected cards
- Wrap CosplayerDetail action buttons into mobile dropdown
- Add cascade checkbox to CosplayerDetail album visibility modal
- Cosplayers page bugs
- Prevent private assets with null UploadedBy leaking to anonymous users
- Homepage preview navigation, grid observer crash, and cascade labels
- Label Delete button and cropper modal overlay
- Album detail back button uses history.back() with fallback to /albums
- Soft-delete users in UserController.Delete instead of hard-delete
- Add ConfirmDialog to AdminUsers delete user flow
- Add ConfirmDialog to User delete user flow
- Add ConfirmDialog to AlbumDetail delete flows
- Add ConfirmDialog to CosplayerDetail delete flows
- Add ConfirmDialog to Albums bulk delete flow
- Add ConfirmDialog to Cosplayers bulk delete flow
- Add ConfirmDialog to Settings folder deletion flow
- *(UI)* Make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page
- Apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
- Split multi-request test blocks into single-request blocks
- Guard against null PersonOwnerId in MergePeople albums query
- Gate registration behind UserRegistrationEnabled setting
- Handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker
- Use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging
- Use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init
- Use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error
- Filter directory names to only paths containing a slash after prefix, excluding filenames
- Restrict maintenance page and nav link to admin only
- Add scoped CSS for maintenance page to make embedded picker fill viewport
- Inline all markup in .razor template to restore CSS isolation
- Change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime
- Simplify maintenance page CSS to flex-fill container without vh calc
- Adjust embedded picker viewport height offset
- Address PR #142 review issues
### 🚜 Refactor
- *(API)* Switch to repository pattern
- *(API)* Switch to use UserRepository AuthController.cs
- *(API)* Switch to repository pattern
- *(API)* Switch to use UserRepository AuthController.cs
- *(API)* Cleans the code
- *(API)* Cleans the code
- *(API)* Extract BulkDtoinseparate file
- *(API)* Switch to use a class for paged search
- *(API)* Adds Bulk capabilities
- *(API)* Move classes and Standardlize
- *(API)* Rename Asset Dtos
- *(API)* AlbumController explicitly marked all of the x in lambdas as they were accidentally hiding previous stage, also improved null checking
- Reformatted AssetController and AssetCreateDto files
- *(API)* Asset controller reformatted params
- Improve readability of CorsAllowedOrigins parsing block
- *(jobs)* Flatten ThumbnailJob to batch-based sub-jobs
- *(jobs)* Flatten PHashJob to batch-based sub-jobs
- *(jobs)* Flatten MetadataJob to batch-based sub-jobs
- Make LoginService singleton, centralize auth state and persistence
- Extract ImagePreview shared component from Home and AlbumDetail
- Remove list view, add grid/masonry toggle to album detail
- *(css)* Move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS
- *(css)* Remove SCSS, switch to plain CSS with Bootstrap custom properties
- *(api)* Fold unassigned album filter into search endpoint via query param
- *(dto)* Move Unassigned filter into PagedSearchParametersDto
- Replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint
- Replace custom masonry JS with Bootstrap grid + Masonry.js approach
- *(auth)* Extract AuthServiceBase, replace retry with re-auth on 401
- *(auth)* Remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
- *(ui)* Replace inline password form with modal
- *(ui)* Complete user profile page redesign with card layout
- Move cosplayer link to same line as album name
- Center-align header, album name on top, cosplayer smaller below
- Extract password strength UI into shared PasswordField component
- Replace tuple + custom header with ChildrenResponse DTO
- *(ui)* ModalFrame + EmptyState components, remove duplicate CSS, remove main border
- *(jobs)* Use darken() for phash and create-albums hover/active too
- Project PersonPreviewDto directly in repository instead of [NotMapped] entity property
- Project AlbumPreviewDto directly in repository with visibility-aware asset count
- Clean up imports, simplify checks, and add SearchDropdown component
- *(albums)* Extract visibility-aware AssetCount into AlbumMapper overload
- Replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize
- Use MaxPageSize constant as default pageSize across all frontend services
- Reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
- Move all visibility filtering from mappers and controllers to repositories
- Rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
- Extract role badge colors to SCSS theme layer
- Generate access level dropdown from enum values
- Extract role checks to LoginService, update all consumers
- Move CanEdit logic to LoginService, remove local page properties
- Extract person search/assign UI into shared SearchAssignBadges component
- Extract VisibilityModal shared component, add editing controls to Albums page
- Centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
- Replace inline visibility modal with shared VisibilityModal in Cosplayers.razor
- Move Unlink cosplayer button from album header to album edit form
- Add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names
- Rename UnlinkedAssetGroupDto to AssetGroupDto
- Replace individual [FromQuery] params with AssetBrowseOptionsDto
### 📚 Documentation
- Update agents.md for Blazor WASM architecture
- Add CORS configuration section to README
- *(readme)* Add wildcard CORS warning and MilkStream CORS setup guide
- Add XML documentation to all public APIs across all projects
- Update AGENTS.md with XML doc enforcement and CORS guidance
- Update AGENTS.md with verified infrastructure gotchas
- Add API reuse convention to AGENTS.md — prefer optional query params over new endpoints
- Add Gitea repo owner note to AGENTS.md
- Add granular commit convention to AGENTS.md
- Document REST Client tests in AGENTS.md
- Update AGENTS.md with missing conventions and project info
- Add use case catalog for new auth/permissions model
- Add Gitea label levels note to AGENTS.md
- Add PR workflow section to AGENTS.md
- Reconcile AGENTS.md with current codebase
- *(repo)* Fix stale pageSize default in IAlbumRepository XML doc
- Add data size estimates to AGENTS.md
- Clarify Curators and Admins can be maintainers for credited attribution
- Fix stale column references and tooling versions in AGENTS.md
- Add visibility rules (R1R7) to AGENTS.md
### ⚡ Performance
- Push visibility filter into SQL for PersonRepository queries
- Add partial index on Assets for visibility-aware search queries
- Simplify ORDER BY to total count and add trigram GIN indexes
- Add AsSplitQuery to repository queries with multiple Include calls
- Replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter
- Replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges
### 🎨 Styling
- Style cosplayer link with inherited color and person icon
- Match cosplayer link font size to h3, remove icon, keep regular weight
- Consistent inline typography for album title and cosplayer name with dash separator
- Borderless edit pencil with hover reveal on header
- Cosplayer link dims on hover instead of blue/underline
- Replace badge backgrounds with colored icons + matching launch buttons
- Custom job color palette based on purpose, avoid red
- Mobile launch button cyan for better contrast
- Soften accent gradient stops (5%/10% instead of 15%/30%)
- Use rounded-square thumbnails for album results
- Add chevron-right indicator to search dropdown headers
- Use Bootstrap dropdown-menu and dropdown-item classes
- *(repo)* Convert visibility filters to switch expressions for readability
- *(repo)* Expand switch expression arms for readability
- *(repo)* Use explicit named enum cases in PersonRepository switch expressions
- Standardize access level comparison to switch expressions
### 🧪 Testing
- More unit testing on HTTP client
- *(api)* Add person endpoint tests to WepApiTest.http
- *(api)* Add comprehensive http tests for all person and album endpoints
- Add album update test without removePerson field
- Use real album from search for update test, not zero-GUID fallback
- All tests create their own data — create album returns ID, all mutations use self-created resources
- Add endpoint tests for refresh token, albums, curator auth levels
- Add anonymous search tests for persons and albums
- Handle empty person search results gracefully
- Set personId from admin search to ensure it's always populated
- Add user id to profile update request body
- Fix curator user access level from Admin (2) to Curator (1)
- Restructure WepApiTest.http with three clearly separated users
- Add cleanup of regular test user in REST Client suite
- Add page=0&pageSize=5 to paginated list endpoints
- Add merge and bulk visibility tests to WepApiTest.http
- Flatten test numbering to sequential 1137
- Added rate limiting stress test
### ⚙️ Miscellaneous Tasks
- Removes auto generated docker-compose file for deployment
- *(docker)* Removes auto generated docker-compose file for deployment
- *(API)* Changes the category name of the logger
- Added datagrip files
- *(config)* Separates connection string defaults for docker and local webserver run
- Removed unused using in Program.cs
- Swapped empty array initializer with empty collection initializer in Asset hash
- Commented out unused logger in Album controller and added a TODO: about adding logging capabilities
- Marked navigation property in folder as nullable
- Commented out logger in FolderController and added a TODO regarding logging
- Removed redundant type cast in tag search
- Removed useless using declarations in TagMapper
- Commented out the logger in tagController and added a TODO regarding logging
- Removed more unused using directives
- *(docker)* Added NoCache build option for lactose
- *(docker)* Updated dockerfile to build using dotnet 9.0
- *(NuGet)* [**breaking**] Added dependency to Magick.NET-Q16-HDRI-OpenMP-x64
- Updated nuget packages to latest releases supporting Net 8.0, removed ImageMagick package.
- Removed MySql package from required nuget packages
- Refined db connector params
- *(API)* Removes Redundant Check
- Split TokenGeneratorProvider file in their respective classes
- Moved milkstream services under a services namespace
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
- Updated run profiles
- Moved milkstream services under a services namespace
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
- Updated run profiles
- Remove temporary debug panel from Jobs page
- Remove redundant XML doc comments from ServiceBase
- Upgrade target framework to net10.0
- Add dotnet-tools.json for ef tool
- *(dev)* Fix ovveride PM Compose deploy
- Remove debug auth logging middleware from Program.cs
- Remove unused banner-cell-empty CSS class
- *(test)* Remove Swagger test — disabled in container environments
- Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts
- Revert docker-compose.yml to match develop
- Updated packages, removed unused packages. ImageSharp now requires a license
- Commit compiled CSS for role badge classes
- Fix Dockerfiles, add debug compose to solution
- Use record for config classes
- Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage
- Add Gitea Actions workflow to build and push containers on version tags
### 🛡️ Security
- Upgrade to .NET 10 — packages, Docker images, and Swagger fix
### ◀️ Revert
- Switch album detail back to masonry layout
- Restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident
- Restore docker-compose.yml LactoseBaseUrl to 192.168.50.100
- Remove AllowAnonymous from AlbumController.Search and PersonController.GetAll
- Change LoginService back to Singleton (DI error was from docker fast mode)
### CI
- Added build + push to internal registry action
### Fix
- *(API)* Disables Asset Create
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
+48
View File
@@ -46,6 +46,14 @@ public class LactoseDbContext : DbContext {
/// Gets or sets the person-maintainer join table.
/// </summary>
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
/// <summary>
/// Gets or sets the per-directory scan-state table used by incremental file-system scans.
/// </summary>
public DbSet<DirectoryScanState> DirectoryScanStates { get; set; }
/// <summary>
/// Gets or sets the temporary staging table used by the person/album discovery job.
/// </summary>
public DbSet<AssetAlbumStaging> AssetAlbumStaging { get; set; }
/// <summary>
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
@@ -65,6 +73,7 @@ public class LactoseDbContext : DbContext {
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.HasPostgresExtension("vector");
modelBuilder.HasPostgresExtension("pg_trgm");
modelBuilder.HasDbFunction(() => PgFunctions.Md5(default!));
//Album Relationships
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
@@ -78,10 +87,37 @@ public class LactoseDbContext : DbContext {
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.CreatedAt })
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// Stats-supporting partial indexes
modelBuilder.Entity<Asset>().HasIndex(e => e.CreatedAt)
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.Type)
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.ThumbnailPath)
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.PreviewPath)
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => e.Hash)
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
modelBuilder.Entity<Asset>().HasIndex(e => new { e.ResolutionWidth, e.ResolutionHeight, e.Type })
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
// Keyset pagination for scan jobs:
// WHERE FolderId = X AND DeletedAt IS NULL AND IngestedAt >= @cursor AND Id > @last ORDER BY Id
modelBuilder.Entity<Asset>().HasIndex(e => new { e.FolderId, e.IngestedAt, e.Id })
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
// People
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
.HasDatabaseName("IX_People_Name_Trgm")
@@ -111,5 +147,17 @@ public class LactoseDbContext : DbContext {
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
//Folder Relationships
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
//Scan state
modelBuilder.Entity<DirectoryScanState>().HasOne(e => e.Folder).WithMany().HasForeignKey(e => e.FolderId);
modelBuilder.Entity<DirectoryScanState>()
.HasIndex(e => new { e.FolderId, e.Path })
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path")
.IsUnique();
//Discovery staging
modelBuilder.Entity<AssetAlbumStaging>().HasOne(e => e.Asset).WithMany().HasForeignKey(e => e.AssetId);
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.AssetId)
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.PersonName)
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
}
}
+16
View File
@@ -0,0 +1,16 @@
using Microsoft.EntityFrameworkCore;
namespace Lactose.Context;
/// <summary>
/// Maps PostgreSQL functions not covered by the Npgsql provider for use in LINQ-to-SQL queries.
/// </summary>
public static class PgFunctions {
/// <summary>
/// Maps to the PostgreSQL <c>md5</c> function. Only callable inside LINQ-to-SQL queries.
/// </summary>
/// <param name="value">The string to hash.</param>
/// <returns>The md5 hex digest of <paramref name="value"/>.</returns>
[DbFunction("md5", "pg_catalog")]
public static string Md5(string value) => throw new NotSupportedException("PgFunctions.Md5 is only usable inside LINQ-to-SQL queries.");
}
+3 -2
View File
@@ -67,7 +67,8 @@ public class AlbumController(
pagingOptions.Unassigned,
uid ?? default,
accessLevel,
pagingOptions.AssetUploadedBy
pagingOptions.AssetUploadedBy,
pagingOptions.PersonOwnerId
).ToList();
return Ok(albums);
@@ -85,7 +86,7 @@ public class AlbumController(
Guid? uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
var album = albumRepository.FindWithAssets(id);
// If the album does not exist, return a 404
if (album == null) return NotFound();
+6 -6
View File
@@ -99,7 +99,8 @@ public class AssetController(
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
uid, accessLevel,
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
searchOptionsDto.IncludeCount
);
logger.LogTrace(
@@ -114,11 +115,9 @@ public class AssetController(
"""
);
var dtoList = assets.ToAssetPreviewDto(accessLevel, uid).ToList();
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
logger.LogTrace($"Returning {assets.Count()} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(dtoList);
return Ok(assets);
}
/// <summary>
@@ -358,7 +357,8 @@ public class AssetController(
Duration = dto.Duration ?? 0,
FrameRate = dto.FrameRate ?? 0,
OriginalPath = String.Empty,
OriginalFilename = String.Empty
OriginalFilename = String.Empty,
IngestedAt = DateTime.UtcNow
};
assetRepository.Insert(asset);
+10
View File
@@ -97,6 +97,16 @@ public class AuthController(
};
}
/// <summary>
/// Checks whether user registration is currently enabled.
/// </summary>
/// <returns>200 if enabled, 403 if disabled.</returns>
[HttpGet("register")]
public ActionResult CheckRegistrationEnabled() {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
}
/// <summary>
/// Registers a new user account.
/// </summary>
+2 -2
View File
@@ -43,7 +43,7 @@ namespace Lactose.Controllers;
Active = false
};
folderRepository.Create(newFolder);
folderRepository.Insert(newFolder);
return Ok();
}
@@ -145,7 +145,7 @@ namespace Lactose.Controllers;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
folderRepository.Delete(id);
folderRepository.Remove(id);
return Ok();
}
}
+40 -12
View File
@@ -106,10 +106,9 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
switch (jobRequest.JobType) {
case EJobType.FileSystemScan:
if (jobRequest.Parameters.Count != 0)
return BadRequest("FullFileSystemScan job does not require any parameters.");
jobScheduler.QueueFileSystemCrawl(null);
if (!TryParseJobOptions(jobRequest.Parameters, out var scanDeep, out var scanFolders, out var scanError))
return BadRequest(scanError);
jobScheduler.QueueFileSystemCrawl(scanDeep, scanFolders);
return Ok();
case EJobType.ThumbnailGeneration:
if (jobRequest.Parameters.Count != 0)
@@ -135,15 +134,10 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
jobScheduler.QueueIntegrityCheck(null);
return Ok();
case EJobType.CreatePersons:
if (jobRequest.Parameters.Count != 0)
return BadRequest("CreatePersons job does not require any parameters.");
jobScheduler.QueueCreatePersons(null);
return Ok();
case EJobType.CreateAlbums:
if (jobRequest.Parameters.Count != 0)
return BadRequest("CreateAlbums job does not require any parameters.");
jobScheduler.QueueCreateAlbums(null);
if (!TryParseJobOptions(jobRequest.Parameters, out var albumsDeep, out var albumsFolders, out var albumsError))
return BadRequest(albumsError);
jobScheduler.QueueCreateAlbums(albumsDeep, albumsFolders);
return Ok();
case EJobType.PHashGeneration:
if (jobRequest.Parameters.Count > 1)
@@ -153,4 +147,38 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
default: return BadRequest("Invalid job type.");
}
}
/// <summary>
/// Parses the common job parameters for scan/discovery jobs: the literal <c>deep</c> plus repeatable
/// <c>folder=&lt;guid&gt;</c> entries. Unknown parameters are rejected.
/// </summary>
/// <param name="parameters">The raw job parameters.</param>
/// <param name="deep">Whether a deep scan was requested.</param>
/// <param name="folderIds">The selected folder ids (empty means all active folders).</param>
/// <param name="error">An error message when parsing fails, otherwise null.</param>
/// <returns>True when the parameters are valid.</returns>
static bool TryParseJobOptions(List<string> parameters, out bool deep, out List<Guid> folderIds, out string? error) {
deep = false;
folderIds = new List<Guid>();
error = null;
foreach (var raw in parameters) {
var param = raw.Trim();
if (string.Equals(param, "deep", StringComparison.OrdinalIgnoreCase)) {
deep = true;
} else if (param.StartsWith("folder=", StringComparison.OrdinalIgnoreCase)) {
var idText = param["folder=".Length..].Trim();
if (!Guid.TryParse(idText, out var folderId)) {
error = $"Invalid folder parameter '{param}'.";
return false;
}
folderIds.Add(folderId);
} else {
error = $"Unknown job parameter '{param}'.";
return false;
}
}
return true;
}
}
+6
View File
@@ -65,6 +65,9 @@ public class MediaController(
if (asset == null) return NotFound();
// Thumbnails are immutable per asset — allow browser/proxy caching
Response.Headers.CacheControl = "public, max-age=86400";
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
return PhysicalFile(asset.ThumbnailPath, "image/webp");
@@ -88,6 +91,9 @@ public class MediaController(
if (asset == null) return NotFound();
// Previews are immutable per asset — allow browser/proxy caching
Response.Headers.CacheControl = "public, max-age=86400";
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
return PhysicalFile(asset.PreviewPath, "image/webp");
+3 -2
View File
@@ -20,11 +20,12 @@ public class StatsController(
/// <summary>
/// Returns comprehensive aggregate statistics about the instance.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
[HttpGet]
public ActionResult<StatsDto> Get() {
public async Task<ActionResult<StatsDto>> Get(CancellationToken cancellationToken) {
logger.LogTrace("Stats requested");
var stats = statsRepository.GetStats();
var stats = await statsRepository.GetStatsAsync(cancellationToken);
return Ok(stats);
}
}
+239 -286
View File
@@ -2,399 +2,330 @@ using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using System.Collections.Concurrent;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Lactose.Jobs;
/// <summary>
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
/// creates missing Person and Album entities, and assigns matching assets to their albums.
/// Discovers persons and albums from asset paths using folder regex patterns.
/// Scans active folders in a streaming, keyset-paginated fashion (suitable for millions of assets),
/// creates any missing Person and Album entities, assigns matching assets to their albums, and
/// backfills cover images. Runs incrementally (only new assets since the last scan) unless a deep scan
/// or a changed regex pattern forces a full rescan.
/// </summary>
public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider serviceProvider) : Job {
const int ScanBatchSize = 5000;
const int SaveBatchSize = 1000;
public class CreateAlbumsJob(
bool deep,
IReadOnlyList<Guid>? folderIds,
ILogger<CreateAlbumsJob> logger,
IServiceProvider serviceProvider) : Job {
const int ScanBatchSize = 5000;
const int StagingFlushSize = 2000;
const int AssignmentBatchSize = 2000;
const int LookupChunkSize = 1000;
/// <inheritdoc />
public override string Name => "Create Albums Job";
public override string Name => "Create Persons & Albums Job";
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
try {
logger.LogInformation("Starting create albums job...");
logger.LogInformation("Starting create persons & albums job (deep={Deep}, folders={FolderCount})...",
deep, folderIds?.Count ?? 0);
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
var albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
var albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
var folders = folderRepo.GetAll()
var activeFolders = folderRepo.GetAll()
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
.ToList();
var folders = folderIds is { Count: > 0 }
? activeFolders.Where(f => folderIds.Contains(f.Id)).ToList()
: activeFolders;
if (folders.Count == 0) {
logger.LogInformation("No active folders with a regex pattern found.");
JobStatus.Complete("No active folders with a regex pattern found.");
logger.LogInformation("No matching active folders with a regex pattern found.");
JobStatus.Complete("No matching active folders with a regex pattern found.");
return;
}
logger.LogInformation("Scanning {FolderCount} active folders for person/album patterns...", folders.Count);
// Fresh staging table for this run.
await db.Database.ExecuteSqlRawAsync("TRUNCATE TABLE \"AssetAlbumStaging\"", token);
logger.LogInformation("Scanning {FolderCount} folder(s) for person/album patterns...", folders.Count);
// ── Phase 1: Incremental scan → stage matched (asset, person, album) rows ──
var personAlbumCounts = new ConcurrentDictionary<string, long>(StringComparer.OrdinalIgnoreCase);
int uniquePersons = 0, uniqueAlbums = 0, totalAssetsScanned = 0, totalAssetsMatched = 0;
var stagedBuffer = new List<AssetAlbumStaging>();
var folderCursorUpdates = new List<(Guid FolderId, DateTime ScanAt, string Regex)>();
// ── Phase 1: Streaming scan → collect unique (person, album) names only ──
var personAlbumNames = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
float folderIndex = 0;
int uniquePersons = 0;
int uniqueAlbums = 0;
int totalAssetsScanned = 0;
int totalAssetsMatched = 0;
foreach (var folder in folders) {
token.ThrowIfCancellationRequested();
bool folderDeep = deep || folder.LastDiscoveryScannedRegex != folder.RegexPattern;
DateTime? cursor = folderDeep ? null : folder.LastDiscoveryScanAt;
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
var groupNames = regex.GetGroupNames().Select(g => g.ToLowerInvariant()).ToHashSet();
if (!groupNames.Contains("person") || !groupNames.Contains("group")) {
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks 'person' and/or 'group' named groups. Skipping.", folder.Id, folder.BasePath);
if (!groupNames.Contains("person")) {
logger.LogWarning("Folder {FolderId} ({BasePath}) regex lacks a 'person' named group. Skipping.", folder.Id, folder.BasePath);
continue;
}
bool hasGroup = groupNames.Contains("group");
var totalInFolder = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.CountAsync(token);
int folderScanned = 0, folderMatched = 0;
Guid lastId = Guid.Empty;
using var threadLocalRegex = new ThreadLocal<Regex>(() =>
new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled));
int folderScanned = 0;
int folderMatched = 0;
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
while (true) {
token.ThrowIfCancellationRequested();
var paths = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
var batch = await db.Assets.AsNoTracking()
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null && a.Id > lastId
&& (cursor == null || a.IngestedAt >= cursor.Value))
.OrderBy(a => a.Id)
.Skip(offset)
.Take(ScanBatchSize)
.Select(a => a.OriginalPath)
.Select(a => new { a.Id, a.OriginalPath })
.ToListAsync(token);
int batchMatched = 0;
foreach (var path in paths) {
token.ThrowIfCancellationRequested();
var match = regex.Match(path);
if (!match.Success) continue;
if (batch.Count == 0) break;
lastId = batch[^1].Id;
var matched = new List<AssetAlbumStaging>(batch.Count);
Parallel.ForEach(batch, new ParallelOptions {
CancellationToken = token,
MaxDegreeOfParallelism = Environment.ProcessorCount
}, item => {
var match = threadLocalRegex.Value!.Match(item.OriginalPath);
if (!match.Success) return;
var personGroup = match.Groups["person"];
var groupGroup = match.Groups["group"];
if (!personGroup.Success || !groupGroup.Success) continue;
if (!personGroup.Success) return;
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
var albumName = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
if (string.IsNullOrWhiteSpace(personName)) return;
if (string.IsNullOrWhiteSpace(personName) || string.IsNullOrWhiteSpace(albumName))
continue;
if (!personAlbumNames.TryGetValue(personName, out var albums)) {
albums = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
personAlbumNames[personName] = albums;
uniquePersons++;
string? albumName = null;
if (hasGroup) {
var groupGroup = match.Groups["group"];
if (groupGroup.Success) {
var candidate = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
if (!string.IsNullOrWhiteSpace(candidate)) albumName = candidate;
}
}
if (albums.Add(albumName))
uniqueAlbums++;
batchMatched++;
lock (matched) matched.Add(new AssetAlbumStaging {
Id = Guid.NewGuid(), AssetId = item.Id, PersonName = personName, AlbumName = albumName
});
});
stagedBuffer.AddRange(matched);
folderScanned += batch.Count;
folderMatched += matched.Count;
if (stagedBuffer.Count >= StagingFlushSize) {
await FlushStaging(db, stagedBuffer, token);
stagedBuffer.Clear();
}
folderScanned += paths.Count;
folderMatched += batchMatched;
totalAssetsScanned += paths.Count;
totalAssetsMatched += batchMatched;
// Track unique counts for progress display (cheap approximate).
foreach (var m in matched) {
if (!personAlbumCounts.ContainsKey(m.PersonName)) uniquePersons++;
personAlbumCounts.AddOrUpdate(m.PersonName, 1, (_, v) => v + 1);
if (m.AlbumName != null) uniqueAlbums++;
}
// Keep progress bar at per-folder level, but update the message per batch
// so the expanded details panel shows live counts
float progress = folderIndex / folders.Count * 0.15f;
float progress = folderIndex / folders.Count * 0.25f;
JobStatus.UpdateProgress(progress,
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {uniquePersons} persons, {uniqueAlbums} albums found so far");
$"Scanning {folder.BasePath}: {folderScanned:N0} scanned — {uniquePersons} persons, {uniqueAlbums} albums");
}
if (stagedBuffer.Count > 0) {
await FlushStaging(db, stagedBuffer, token);
stagedBuffer.Clear();
}
totalAssetsScanned += folderScanned;
totalAssetsMatched += folderMatched;
folderCursorUpdates.Add((folder.Id, DateTime.UtcNow, folder.RegexPattern!));
folderIndex++;
float folderProgress = folderIndex / folders.Count * 0.15f;
JobStatus.UpdateProgress(folderProgress,
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched} matched — {uniquePersons} persons, {uniqueAlbums} albums");
JobStatus.UpdateProgress(folderIndex / folders.Count * 0.25f,
$"Scanned {folder.BasePath}: {folderMatched:N0} matched — {uniquePersons} persons, {uniqueAlbums} albums");
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} persons, {Albums} albums",
folder.BasePath, folderScanned, folderMatched, uniquePersons, uniqueAlbums);
}
if (personAlbumNames.Count == 0) {
if (totalAssetsMatched == 0)
logger.LogInformation("No person/album pairs extracted from asset paths.");
JobStatus.Complete("No person/album pairs extracted from asset paths.");
return;
JobStatus.UpdateProgress(0.25f, $"Discovered {uniquePersons} persons, {uniqueAlbums} albums from {totalAssetsMatched:N0} matched assets.");
// ── Phase 2: Create missing persons ──
var distinctPersons = await db.AssetAlbumStaging.Select(s => s.PersonName).Distinct().ToListAsync(token);
var existingPersons = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var chunk in distinctPersons.Chunk(LookupChunkSize)) {
foreach (var p in personRepo.FindByNames(chunk))
existingPersons.TryAdd(p.Name, p.Id);
}
int totalGroups = personAlbumNames.Sum(kv => kv.Value.Count);
logger.LogInformation("Discovery complete: {PersonCount} persons, {GroupCount} album groups from {Matched:N0} matched assets.",
personAlbumNames.Count, totalGroups, totalAssetsMatched);
JobStatus.UpdateProgress(0.15f, $"Discovered {personAlbumNames.Count} persons, {totalGroups} albums from {totalAssetsMatched:N0} matched assets.");
token.ThrowIfCancellationRequested();
// ── Phase 2: Create missing persons and albums ──
var personNames = personAlbumNames.Keys;
var existingPersons = personRepo.FindByNames(personNames)
.ToDictionary(p => p.Name, p => p.Id, StringComparer.OrdinalIgnoreCase);
var missingPersonCount = personNames.Count(n => !existingPersons.ContainsKey(n));
if (missingPersonCount > 0)
logger.LogInformation("{Existing} existing persons found, {Missing} missing to create.", existingPersons.Count, missingPersonCount);
else
logger.LogInformation("All {Count} persons already exist.", existingPersons.Count);
var personCreated = 0;
foreach (var name in personNames) {
int personCreated = 0;
foreach (var name in distinctPersons) {
if (existingPersons.ContainsKey(name)) continue;
token.ThrowIfCancellationRequested();
var person = new Person {
Id = Guid.NewGuid(),
Name = name,
CreatedAt = DateTime.UtcNow
};
var person = new Person { Id = Guid.NewGuid(), Name = name, CreatedAt = DateTime.UtcNow };
personRepo.Insert(person);
existingPersons[name] = person.Id;
personCreated++;
if (personCreated % 10 == 0) {
JobStatus.UpdateProgress(0.15f + (float)personCreated / missingPersonCount * 0.10f,
$"Creating persons: {personCreated}/{missingPersonCount} — {name}");
}
}
if (personCreated > 0) {
personRepo.Save();
logger.LogInformation("Created {Count} missing persons.", personCreated);
}
JobStatus.UpdateProgress(0.25f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
JobStatus.UpdateProgress(0.35f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
var existingAlbumKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var allAlbumTitles = personAlbumNames.Values.SelectMany(v => v).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (allAlbumTitles.Count > 0) {
var existing = await db.Albums
.Where(a => allAlbumTitles.Contains(a.Title))
.Select(a => new { a.Title, a.PersonOwnerId })
.ToListAsync(token);
foreach (var a in existing)
existingAlbumKeys.Add($"{a.Title}|{a.PersonOwnerId}");
}
// ── Phase 3: Create missing albums ──
var pairs = await db.AssetAlbumStaging
.Where(s => s.AlbumName != null)
.Select(s => new { s.PersonName, AlbumName = s.AlbumName! })
.Distinct()
.ToListAsync(token);
logger.LogInformation("Found {Count} existing album-person pairings in DB.", existingAlbumKeys.Count);
// albumName|personId → albumId (existing + newly created)
var albumIdByKey = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
var albumIdMap = new Dictionary<(string Person, string Album), Guid>();
int albumCreated = 0;
int totalAlbumSlots = personAlbumNames.Sum(kv => kv.Value.Count);
int newAlbumSlots = totalAlbumSlots - existingAlbumKeys.Count;
int albumsChecked = 0;
foreach (var group in pairs.GroupBy(p => p.PersonName, StringComparer.OrdinalIgnoreCase)) {
if (!existingPersons.TryGetValue(group.Key, out var personId)) continue;
var titles = group.Select(p => p.AlbumName).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
if (newAlbumSlots > 0)
logger.LogInformation("{New} new albums to create out of {Total} total.", newAlbumSlots, totalAlbumSlots);
foreach (var (personName, albumNames) in personAlbumNames) {
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
foreach (var albumName in albumNames) {
token.ThrowIfCancellationRequested();
albumsChecked++;
var existsKey = $"{albumName}|{personId}";
if (existingAlbumKeys.Contains(existsKey)) continue;
var albumId = Guid.NewGuid();
albumRepo.Insert(new Album {
Id = albumId,
Title = albumName,
PersonOwnerId = personId,
CreatedAt = DateTime.UtcNow
});
albumIdMap[(personName, albumName)] = albumId;
existingAlbumKeys.Add(existsKey);
albumCreated++;
if (albumCreated % 10 == 0) {
JobStatus.UpdateProgress(0.25f + (float)albumsChecked / totalAlbumSlots * 0.15f,
$"Creating albums: {albumCreated}/{newAlbumSlots} new — {albumName}");
}
foreach (var titleChunk in titles.Chunk(LookupChunkSize)) {
var existing = await db.Albums
.Where(a => a.PersonOwnerId == personId && titleChunk.Contains(a.Title))
.Select(a => new { a.Title, a.Id })
.ToListAsync(token);
foreach (var a in existing)
albumIdByKey.TryAdd($"{a.Title}|{personId}", a.Id);
}
}
int albumCreated = 0;
foreach (var pair in pairs) {
if (!existingPersons.TryGetValue(pair.PersonName, out var personId)) continue;
var key = $"{pair.AlbumName}|{personId}";
if (albumIdByKey.ContainsKey(key)) continue;
token.ThrowIfCancellationRequested();
var albumId = Guid.NewGuid();
albumRepo.Insert(new Album {
Id = albumId, Title = pair.AlbumName, PersonOwnerId = personId, CreatedAt = DateTime.UtcNow
});
albumIdByKey[key] = albumId;
albumCreated++;
}
if (albumCreated > 0) {
albumRepo.Save();
logger.LogInformation("Created {Count} new albums.", albumCreated);
}
// Build lookup for the assignment phase (covers both new and pre-existing albums)
var albumLookup = new Dictionary<(string Person, string Album), Guid>();
foreach (var (personName, albumNames) in personAlbumNames) {
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
JobStatus.UpdateProgress(0.45f, $"Entities complete: {personCreated} persons, {albumCreated} albums. Assigning assets...");
foreach (var albumName in albumNames) {
if (albumIdMap.TryGetValue((personName, albumName), out var id)) {
albumLookup[(personName, albumName)] = id;
} else {
var existing = await db.Albums
.Where(a => a.Title == albumName && a.PersonOwnerId == personId)
.Select(a => a.Id)
.FirstOrDefaultAsync(token);
if (existing != Guid.Empty)
albumLookup[(personName, albumName)] = existing;
}
}
}
JobStatus.UpdateProgress(0.40f, $"Entities complete: {personCreated} persons, {albumCreated} albums created. Assigning assets...");
token.ThrowIfCancellationRequested();
// ── Phase 3: Re-scan assets in batches, assign to albums via Asset.Albums ──
// ── Phase 4: Assign assets to albums (bulk, idempotent) ──
var personIdByName = existingPersons; // name → id (includes newly created)
int assetAssignments = 0;
int pendingBeforeSave = 0;
folderIndex = 0;
long processed = 0;
Guid lastStagingId = Guid.Empty;
var assignmentBuffer = new List<(Guid AlbumId, Guid AssetId)>(AssignmentBatchSize);
long totalToAssign = await db.AssetAlbumStaging.CountAsync(s => s.AlbumName != null, token);
foreach (var folder in folders) {
while (true) {
token.ThrowIfCancellationRequested();
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
var groupNames = regex.GetGroupNames().Select(g => g.ToLowerInvariant()).ToHashSet();
var rows = await db.AssetAlbumStaging.AsNoTracking()
.Where(s => s.Id > lastStagingId && s.AlbumName != null)
.OrderBy(s => s.Id)
.Take(ScanBatchSize)
.Select(s => new { s.Id, AssetId = s.AssetId, s.PersonName, AlbumName = s.AlbumName! })
.ToListAsync(token);
if (!groupNames.Contains("person") || !groupNames.Contains("group"))
continue;
if (rows.Count == 0) break;
lastStagingId = rows[^1].Id;
processed += rows.Count;
var totalInFolder = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.CountAsync(token);
int folderAssigned = 0;
int folderScanned = 0;
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
token.ThrowIfCancellationRequested();
var assets = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(ScanBatchSize)
.Select(a => new { a.Id, a.OriginalPath })
.ToListAsync(token);
var batchAssetIds = assets.Select(a => a.Id).ToList();
var existingPairs = new HashSet<(Guid AlbumId, Guid AssetId)>();
if (batchAssetIds.Count > 0) {
var existing = await db.Albums
.Where(a => a.Assets!.Any(asset => batchAssetIds.Contains(asset.Id)))
.SelectMany(a => a.Assets!.Select(asset => new { AlbumId = a.Id, AssetId = asset.Id }))
.Where(x => batchAssetIds.Contains(x.AssetId))
.Select(x => new { x.AlbumId, x.AssetId })
.ToListAsync(token);
foreach (var pair in existing)
existingPairs.Add((pair.AlbumId, pair.AssetId));
foreach (var row in rows) {
if (!personIdByName.TryGetValue(row.PersonName, out var personId)) continue;
if (!albumIdByKey.TryGetValue($"{row.AlbumName}|{personId}", out var albumId)) continue;
assignmentBuffer.Add((albumId, row.AssetId));
if (assignmentBuffer.Count >= AssignmentBatchSize) {
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
assetAssignments += assignmentBuffer.Count;
assignmentBuffer.Clear();
}
foreach (var asset in assets) {
token.ThrowIfCancellationRequested();
var match = regex.Match(asset.OriginalPath);
if (!match.Success) continue;
var personGroup = match.Groups["person"];
var groupGroup = match.Groups["group"];
if (!personGroup.Success || !groupGroup.Success) continue;
var personName = ToTitleCase(personGroup.Value.Replace('_', ' ').Trim());
var albumName = ToTitleCase(groupGroup.Value.Replace('_', ' ').Trim());
if (string.IsNullOrWhiteSpace(personName) || string.IsNullOrWhiteSpace(albumName))
continue;
if (!albumLookup.TryGetValue((personName, albumName), out var albumId))
continue;
var assetStub = db.Assets.Local.FirstOrDefault(a => a.Id == asset.Id);
if (assetStub == null) {
assetStub = new Asset {
Id = asset.Id,
OriginalPath = string.Empty,
OriginalFilename = string.Empty,
Type = EAssetType.Image,
CreatedAt = DateTime.MinValue
};
db.Entry(assetStub).State = EntityState.Unchanged;
}
var albumStub = db.Albums.Local.FirstOrDefault(a => a.Id == albumId);
if (albumStub == null) {
albumStub = new Album { Id = albumId };
db.Entry(albumStub).State = EntityState.Unchanged;
}
if (assetStub.Albums == null)
assetStub.Albums = new List<Album>();
if (!existingPairs.Contains((albumId, asset.Id)) && !assetStub.Albums.Any(a => a.Id == albumId)) {
assetStub.Albums.Add(albumStub);
assetAssignments++;
folderAssigned++;
}
pendingBeforeSave++;
if (pendingBeforeSave >= SaveBatchSize) {
await db.SaveChangesAsync(token);
db.ChangeTracker.Clear();
pendingBeforeSave = 0;
}
}
folderScanned += assets.Count;
// Per-batch message update while keeping progress at per-folder level
float batchScanFraction = Math.Min(1f, (float)(offset + ScanBatchSize) / totalInFolder);
float progress = 0.40f + (folderIndex + batchScanFraction) / folders.Count * 0.60f;
JobStatus.UpdateProgress(progress,
$"Assigning in {folder.BasePath}: {Math.Min(offset + ScanBatchSize, totalInFolder):N0}/{totalInFolder:N0} — {assetAssignments:N0} total");
}
folderIndex++;
float folderProgress = 0.40f + folderIndex / folders.Count * 0.60f;
JobStatus.UpdateProgress(folderProgress,
$"Assigned {folderAssigned:N0} assets in {folder.BasePath} ({assetAssignments:N0} total so far)");
logger.LogInformation("Assigned {FolderAssignments:N0} assets in folder {BasePath} ({Total:N0} total)",
folderAssigned, folder.BasePath, assetAssignments);
float assignFraction = totalToAssign == 0 ? 1f : (float)processed / totalToAssign;
JobStatus.UpdateProgress(0.45f + Math.Clamp(assignFraction, 0f, 1f) * 0.25f,
$"Assigning assets: {assetAssignments:N0} assigned");
}
if (pendingBeforeSave > 0)
await db.SaveChangesAsync(token);
if (assignmentBuffer.Count > 0) {
await InsertAlbumAssetBatch(db, assignmentBuffer, token);
assetAssignments += assignmentBuffer.Count;
assignmentBuffer.Clear();
}
db.ChangeTracker.Clear();
JobStatus.UpdateProgress(0.70f, $"Assigned {assetAssignments:N0} assets. Setting cover images...");
logger.LogInformation("Setting cover images for albums without one...");
// ── Phase 5: Bulk cover assignment (one grouped query) ──
int coversAssigned = 0;
var albumsNeedingCover = await db.Albums
var covers = await db.Albums
.Where(a => a.CoverAssetId == null)
.Select(a => new {
AlbumId = a.Id,
AssetId = a.Assets!
.Where(s => s.DeletedAt == null)
.OrderBy(s => s.OriginalFilename)
.Select(s => s.Id)
.FirstOrDefault()
})
.Where(x => x.AssetId != Guid.Empty)
.ToListAsync(token);
foreach (var album in albumsNeedingCover) {
var firstAsset = await db.Assets
.Where(a => a.Albums!.Any(al => al.Id == album.Id) && a.DeletedAt == null)
.OrderBy(a => a.OriginalFilename)
.Select(a => new { a.Id })
.FirstOrDefaultAsync(token);
if (firstAsset != null) {
album.CoverAssetId = firstAsset.Id;
if (covers.Count > 0) {
var albumIds = covers.Select(c => c.AlbumId).Distinct().ToList();
var albums = await db.Albums.Where(a => albumIds.Contains(a.Id)).ToListAsync(token);
foreach (var cover in covers) {
var album = albums.FirstOrDefault(a => a.Id == cover.AlbumId);
if (album == null || album.CoverAssetId != null) continue;
album.CoverAssetId = cover.AssetId;
coversAssigned++;
}
if (coversAssigned > 0) {
await db.SaveChangesAsync(token);
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
}
}
if (coversAssigned > 0) {
await db.SaveChangesAsync(token);
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
// ── Phase 6: Commit folder cursors + clean staging ──
foreach (var (folderId, scanAt, regex) in folderCursorUpdates) {
var f = await db.Folders.FirstOrDefaultAsync(x => x.Id == folderId, token);
if (f == null) continue;
f.LastDiscoveryScanAt = scanAt;
f.LastDiscoveryScannedRegex = regex;
}
await db.SaveChangesAsync(token);
await TruncateStaging(db, token);
logger.LogInformation("Completed: {Assigned} assets assigned across {PersonsCreated} persons, {AlbumsCreated} albums.",
assetAssignments, personCreated, albumCreated);
@@ -405,6 +336,28 @@ public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider s
}
}
static async Task FlushStaging(LactoseDbContext db, List<AssetAlbumStaging> buffer, CancellationToken token) {
db.AssetAlbumStaging.AddRange(buffer);
await db.SaveChangesAsync(token);
db.ChangeTracker.Clear();
}
static async Task TruncateStaging(LactoseDbContext db, CancellationToken token) =>
await db.Database.ExecuteSqlRawAsync("TRUNCATE TABLE \"AssetAlbumStaging\"", token);
static async Task InsertAlbumAssetBatch(LactoseDbContext db, List<(Guid AlbumId, Guid AssetId)> batch, CancellationToken token) {
var sql = new StringBuilder("INSERT INTO \"AlbumAsset\" (\"AlbumsId\",\"AssetsId\") VALUES ");
var parameters = new List<NpgsqlParameter>(batch.Count * 2);
for (int i = 0; i < batch.Count; i++) {
if (i > 0) sql.Append(',');
sql.Append($"(@a{i},@s{i})");
parameters.Add(new NpgsqlParameter($"@a{i}", batch[i].AlbumId));
parameters.Add(new NpgsqlParameter($"@s{i}", batch[i].AssetId));
}
sql.Append(" ON CONFLICT DO NOTHING");
await db.Database.ExecuteSqlRawAsync(sql.ToString(), parameters.ToArray(), token);
}
static string ToTitleCase(string input) =>
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
}
-160
View File
@@ -1,160 +0,0 @@
using Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Lactose.Jobs;
/// <summary>
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
/// extracts person names from asset paths, and creates any missing Person entities.
/// </summary>
public class CreatePersonsJob(ILogger<CreatePersonsJob> logger, IServiceProvider serviceProvider) : Job {
const int ScanBatchSize = 5000;
/// <inheritdoc />
public override string Name => "Create Persons Job";
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
try {
logger.LogInformation("Starting create persons job...");
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
var folders = folderRepo.GetAll()
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
.ToList();
if (folders.Count == 0) {
logger.LogInformation("No active folders with a regex pattern found.");
JobStatus.Complete("No active folders with a regex pattern found.");
return;
}
logger.LogInformation("Scanning {FolderCount} active folders for person names...", folders.Count);
// ── Phase 1: Streaming scan → collect unique person names ──
var personNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
float folderIndex = 0;
int totalAssetsMatched = 0;
foreach (var folder in folders) {
token.ThrowIfCancellationRequested();
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
if (!regex.GetGroupNames().Contains("person", StringComparer.OrdinalIgnoreCase)) {
logger.LogWarning("Folder {FolderId} ({BasePath}) regex is missing the 'person' named group. Skipping.", folder.Id, folder.BasePath);
continue;
}
var totalInFolder = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.CountAsync(token);
int folderScanned = 0;
int folderMatched = 0;
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
token.ThrowIfCancellationRequested();
var paths = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(ScanBatchSize)
.Select(a => a.OriginalPath)
.ToListAsync(token);
int batchMatched = 0;
foreach (var path in paths) {
token.ThrowIfCancellationRequested();
var match = regex.Match(path);
if (!match.Success) continue;
var group = match.Groups["person"];
if (!group.Success) continue;
var name = ToTitleCase(group.Value.Replace('_', ' ').Trim());
if (!string.IsNullOrWhiteSpace(name)) {
personNames.Add(name);
batchMatched++;
}
}
folderScanned += paths.Count;
folderMatched += batchMatched;
// Per-batch message update while keeping progress at per-folder level
float progress = folderIndex / folders.Count * 0.5f;
JobStatus.UpdateProgress(progress,
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {personNames.Count:N0} persons found so far");
}
totalAssetsMatched += folderMatched;
folderIndex++;
float folderProgress = folderIndex / folders.Count * 0.5f;
JobStatus.UpdateProgress(folderProgress,
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched:N0} matched — {personNames.Count:N0} unique persons");
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} unique persons",
folder.BasePath, folderScanned, folderMatched, personNames.Count);
}
if (personNames.Count == 0) {
logger.LogInformation("No person names extracted from asset paths.");
JobStatus.Complete("No person names extracted from asset paths.");
return;
}
logger.LogInformation("Extracted {Count} unique person names from {Matched:N0} matched assets. Checking existing...",
personNames.Count, totalAssetsMatched);
JobStatus.UpdateProgress(0.5f, $"Extracted {personNames.Count:N0} unique person names from {totalAssetsMatched:N0} matched assets.");
token.ThrowIfCancellationRequested();
// ── Phase 2: Create missing persons ──
var existingNames = personRepo.FindByNames(personNames)
.Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missingNames = personNames
.Where(n => !existingNames.Contains(n))
.ToList();
if (missingNames.Count == 0) {
logger.LogInformation("All {Count} persons already exist.", personNames.Count);
JobStatus.Complete($"All {personNames.Count:N0} persons already exist.");
return;
}
logger.LogInformation("Creating {Count} missing persons ({Existing} already exist).",
missingNames.Count, existingNames.Count);
float created = 0;
foreach (var name in missingNames) {
token.ThrowIfCancellationRequested();
personRepo.Insert(new Person {
Id = Guid.NewGuid(),
Name = name,
CreatedAt = DateTime.UtcNow
});
created++;
float progress = 0.5f + created / missingNames.Count * 0.5f;
JobStatus.UpdateProgress(progress, $"Creating persons: {created:N0}/{missingNames.Count:N0} — {name}");
}
personRepo.Save();
logger.LogInformation("Created {Count} new persons.", missingNames.Count);
JobStatus.Complete($"Created {missingNames.Count:N0} new persons from {totalAssetsMatched:N0} matched assets.");
} catch (OperationCanceledException) {
JobStatus.Cancel();
}
}
static string ToTitleCase(string input) =>
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
}
+230 -139
View File
@@ -1,186 +1,290 @@
using Butter;
using Butter.Settings;
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
using Microsoft.EntityFrameworkCore;
using Npgsql;
using System.Collections;
using System.Data;
using static System.String;
namespace Lactose.Jobs;
/// <summary>
/// Crawls a file system directory, recursively discovering assets and creating child crawl jobs for subdirectories.
/// Crawls a file system directory, incrementally discovering and reconciling assets against the database.
/// Uses per-directory modification timestamps to skip file enumeration in unchanged directories,
/// and batches all database writes instead of saving once per file.
/// </summary>
public sealed class FileSystemCrawlJob : Job {
/// <inheritdoc />
public override string Name { get; } = "Directory Scan: ";
public override string Name { get; }
/// <inheritdoc />
public override JobStatus JobStatus { get; }
IAssetRepository assetRepository;
ISettingsRepository settingsRepository;
string workingPath;
Guid folderId;
ILogger<FileSystemCrawlJob> logger;
JobManager jobManager;
List<Job> childJobs = [];
int pendingChildren;
readonly Guid folderId;
readonly string workingPath;
readonly bool deep;
readonly ILogger<FileSystemCrawlJob> logger;
const int ProgressUpdateEveryDirs = 200;
/// <summary>
/// Initialises a new file system crawl job for the specified folder.
/// Initialises a new file-system crawl job for the specified folder.
/// </summary>
/// <param name="folderId">The folder ID.</param>
/// <param name="workingPath">The base path to crawl.</param>
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="assetRepository">The asset repository.</param>
/// <param name="settingsRepository">The settings repository.</param>
/// <param name="jobManager">The job manager.</param>
public FileSystemCrawlJob(
Guid folderId,
string workingPath,
ILogger<FileSystemCrawlJob> logger,
IAssetRepository assetRepository,
ISettingsRepository settingsRepository,
JobManager jobManager
) {
JobStatus = new(this);
this.logger = logger;
this.assetRepository = assetRepository;
this.settingsRepository = settingsRepository;
this.workingPath = workingPath;
public FileSystemCrawlJob(Guid folderId, string workingPath, bool deep, ILogger<FileSystemCrawlJob> logger) {
JobStatus = new(this);
this.folderId = folderId;
this.jobManager = jobManager;
Name += $"{workingPath}";
this.workingPath = workingPath;
this.deep = deep;
this.logger = logger;
Name = $"Directory Scan: {workingPath}";
}
///<inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
logger.LogInformation($"Started crawling directory {workingPath}");
logger.LogInformation("Started crawling directory {Path} (deep={Deep})", workingPath, deep);
DirectoryInfo dir = new(workingPath);
if (!dir.Exists) {
var root = new DirectoryInfo(workingPath);
if (!root.Exists) {
logger.LogWarning("Directory {Dir} does not exist. Skipping crawl.", workingPath);
JobStatus.Complete("Directory does not exist.");
return;
}
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var db = Scope!.ServiceProvider.GetRequiredService<LactoseDbContext>();
var settingsRepo = Scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
int steps,
totalSteps;
// Resolve system uploader from settings once
Guid? uploaderId = null;
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
if (Guid.TryParse(uploaderSetting, out var parsed))
uploaderId = parsed;
var uploaderSetting = settingsRepo.Get(Settings.SystemUploaderId.AsString())?.Value;
if (Guid.TryParse(uploaderSetting, out var parsed)) uploaderId = parsed;
var files = dir.GetFiles();
logger.LogDebug($"Found {files.Length} files.");
// Separate context for asset reconciliation so the change tracker can be cleared per directory
// without detaching the scan-state entities tracked by `db`.
using var assetScope = Scope.ServiceProvider.CreateScope();
var assetDb = assetScope.ServiceProvider.GetRequiredService<LactoseDbContext>();
var folders = dir.GetDirectories();
logger.LogDebug($"Found {folders.Length} folders.");
var existingStates = await db.DirectoryScanStates
.Where(s => s.FolderId == folderId)
.ToDictionaryAsync(s => s.Path, StringComparer.Ordinal, token);
totalSteps = files.Length + folders.Length;
steps = 0;
// Progress denominator: reuse the number of directories recorded by the previous scan, which is
// accurate once a first run has completed. On the very first run (no state yet) there is no
// denominator, so the bar ramps heuristically while the live message shows real progress.
int totalDirs = Math.Max(existingStates.Count, 1);
bool haveDenominator = existingStates.Count > 0;
logger.LogInformation("Crawling {Path}: {Total} known directories.", workingPath, totalDirs);
JobStatus.UpdateProgress(0.02f, haveDenominator
? $"Scanning {totalDirs:N0} directories..."
: "Scanning... (first run)");
foreach (var folder in folders) {
if (token.IsCancellationRequested) {
JobStatus.Cancel();
return;
}
int dirsProcessed = 0, dirsSkipped = 0, walkedDirs = 0,
filesAdded = 0, filesUpdated = 0, filesDeleted = 0;
var lastProgressUpdate = DateTime.UtcNow;
var seenPaths = new HashSet<string>(StringComparer.Ordinal);
steps++;
var job = jobManager.CreateJob<FileSystemCrawlJob>(folderId, folder.FullName);
job.ParentJobId = Id;
job.LinkParentToken(token);
job.Done += (o, _) => {
lock (childJobs) { childJobs.Remove((o as Job)!); }
if (Interlocked.Decrement(ref pendingChildren) == 0)
childTcs.TrySetResult();
};
Interlocked.Increment(ref pendingChildren);
lock (childJobs) { childJobs.Add(job); }
jobManager.EnqueueJob(job);
logger.LogDebug($"Enqueued crawl job for folder {folder.FullName}");
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing folder {folder.FullName}");
}
var stack = new Stack<string>();
stack.Push(root.FullName);
foreach (var file in files) {
if (token.IsCancellationRequested) {
JobStatus.Cancel();
return;
}
while (stack.Count > 0) {
token.ThrowIfCancellationRequested();
var currentPath = stack.Pop();
seenPaths.Add(currentPath);
steps++;
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Processing file {file.FullName}");
var currentDir = new DirectoryInfo(currentPath);
if (!currentDir.Exists) continue;
walkedDirs++;
var existing = assetRepository.FindByPath(file.FullName);
if (existing != null) {
if (!file.Exists) {
existing.DeletedAt = DateTime.UtcNow;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Soft-deleted asset {Asset} — file no longer exists.", existing.OriginalPath);
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
existing.FileSize = file.Length;
existing.UpdatedAt = file.LastWriteTimeUtc;
if (existing.UploadedBy == null && uploaderId.HasValue)
existing.UploadedBy = uploaderId;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
existing.UploadedBy = uploaderId;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
// A directory's mtime changes only when its direct children change; descendant changes bump
// only their own parent. So we must always descend, but skip file reconciliation for clean dirs.
bool changed = deep;
if (!changed) {
if (!existingStates.TryGetValue(currentPath, out var state)) {
changed = true; // new directory
} else {
changed = state.LastMtime != currentDir.LastWriteTimeUtc;
}
}
List<DirectoryInfo> subdirs;
FileInfo[]? files = null;
try {
subdirs = currentDir.GetDirectories().ToList();
if (changed) files = currentDir.GetFiles();
} catch (Exception ex) {
logger.LogWarning(ex, "Could not enumerate directory {Path}.", currentPath);
continue;
}
var asset = AssetFromPath(file.FullName, uploaderId);
if (asset == null) continue;
if (changed) {
dirsProcessed++;
await ReconcileDirectory(assetDb, currentPath, files!, uploaderId, token,
(added, updated, deleted) => { filesAdded += added; filesUpdated += updated; filesDeleted += deleted; });
try {
assetRepository.Insert(asset);
assetRepository.Save();
logger.LogInformation("Inserted asset {Asset} into the database.", asset.OriginalPath);
} catch (DuplicateNameException) {
logger.LogWarning("Asset {Asset} already exists in the database. Skipping.", asset.OriginalPath);
} catch (Exception e) {
logger.LogError(e, "Error inserting asset {Asset} into the database. {e}", asset.OriginalPath, e);
if (existingStates.TryGetValue(currentPath, out var existingState)) {
existingState.LastMtime = currentDir.LastWriteTimeUtc;
} else {
existingStates[currentPath] = new DirectoryScanState {
Id = Guid.NewGuid(),
FolderId = folderId,
Path = currentPath,
LastMtime = currentDir.LastWriteTimeUtc
};
db.DirectoryScanStates.Add(existingStates[currentPath]);
}
} else {
dirsSkipped++;
}
foreach (var subdir in subdirs)
stack.Push(subdir.FullName);
if (walkedDirs % ProgressUpdateEveryDirs == 0
|| DateTime.UtcNow - lastProgressUpdate > TimeSpan.FromMilliseconds(500)) {
lastProgressUpdate = DateTime.UtcNow;
// Ramp heuristically when we have no accurate denominator (first run); otherwise
// progress is walkedDirs/totalDirs. Cap before the final 0.95 "saving" step.
float progress = haveDenominator
? Math.Clamp(0.05f + 0.90f * walkedDirs / totalDirs, 0.05f, 0.90f)
: Math.Clamp(0.05f + 0.80f * Math.Min(1f, walkedDirs / 5000f), 0.05f, 0.85f);
JobStatus.UpdateProgress(progress,
haveDenominator
? $"Scanning {workingPath}: {walkedDirs:N0}/{totalDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed"
: $"Scanning {workingPath}: {walkedDirs:N0} dirs — {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed");
}
}
var childCount = Interlocked.CompareExchange(ref pendingChildren, 0, 0);
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childCount:N0} child jobs to complete.");
JobStatus.Wait();
JobStatus.UpdateProgress(0.95f, "Saving scan state...");
if (childCount > 0) {
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
try {
await childTcs.Task.ConfigureAwait(false);
} catch (TaskCanceledException) {
JobStatus.Cancel();
return;
}
// Drop scan-state rows for directories that no longer exist on disk.
var staleStates = existingStates.Values.Where(s => !seenPaths.Contains(s.Path)).ToList();
db.DirectoryScanStates.RemoveRange(staleStates);
var folderEntity = await db.Folders.FirstOrDefaultAsync(f => f.Id == folderId, token);
if (folderEntity != null) {
folderEntity.LastFileScanAt = DateTime.UtcNow;
}
JobStatus.Complete("Crawl completed successfully.");
await db.SaveChangesAsync(token);
logger.LogInformation("Crawl complete for {Path}: {Dirs} dirs processed, {Skipped} skipped; {Added} added, {Updated} updated, {Deleted} removed.",
workingPath, dirsProcessed, dirsSkipped, filesAdded, filesUpdated, filesDeleted);
JobStatus.Complete($"Scanned {workingPath}: {filesAdded:N0} added, {filesUpdated:N0} updated, {filesDeleted:N0} removed, {dirsSkipped:N0} dirs unchanged.");
}
/// <summary>
/// Reconciles the direct children of a directory against the database: inserts new assets,
/// updates changed ones, and soft-deletes previously tracked files that no longer exist.
/// All writes are batched into a single <see cref="DbContext.SaveChangesAsync(CancellationToken)"/> call.
/// </summary>
/// <param name="assetDb">A dedicated context for asset writes; cleared after each directory.</param>
/// <param name="dirPath">The directory being reconciled.</param>
/// <param name="files">The current direct files of the directory.</param>
/// <param name="uploaderId">The system uploader id to backfill, if any.</param>
/// <param name="token">Cancellation token.</param>
/// <param name="report">Callback receiving (added, updated, deleted) counts.</param>
async Task ReconcileDirectory(LactoseDbContext assetDb, string dirPath, FileInfo[] files, Guid? uploaderId,
CancellationToken token, Action<int, int, int> report) {
var prefix = dirPath.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
var lo = prefix;
var hi = prefix + "\uFFFF";
// Load known assets that are direct children of this directory. Soft-deleted assets are included
// so a file that still exists on disk is reconciled (or left deleted) instead of re-inserted,
// which would violate the unique index on OriginalPath. The path prefix (not FolderId) is what
// scopes the lookup — OriginalPath is globally unique, so a row can exist under any folder.
var known = (await assetDb.Assets.AsNoTracking()
.Where(a => a.OriginalPath.CompareTo(lo) >= 0 && a.OriginalPath.CompareTo(hi) < 0)
.ToListAsync(token))
.Where(a => !a.OriginalPath.AsSpan(prefix.Length).Contains(Path.DirectorySeparatorChar))
.ToDictionary(a => a.OriginalPath, StringComparer.Ordinal);
// Exact-path fallback: OriginalPath is globally unique, so a row can exist under a different
// folder (e.g. a recreated folder with a new ID) or fall outside the range query above.
// Such files must be reconciled, never re-inserted.
var unmatched = files
.Select(f => f.FullName)
.Where(p => !known.ContainsKey(p))
.Distinct(StringComparer.Ordinal)
.ToList();
foreach (var chunk in unmatched.Chunk(1000)) {
var existing = await assetDb.Assets.AsNoTracking()
.Where(a => chunk.Contains(a.OriginalPath))
.ToListAsync(token);
foreach (var a in existing)
known.TryAdd(a.OriginalPath, a);
}
int added = 0, updated = 0, deleted = 0;
foreach (var file in files) {
token.ThrowIfCancellationRequested();
if (known.TryGetValue(file.FullName, out var asset)) {
known.Remove(file.FullName);
if (file.LastWriteTimeUtc > (asset.UpdatedAt ?? asset.CreatedAt)) {
assetDb.Attach(asset);
asset.FileSize = file.Length;
asset.UpdatedAt = file.LastWriteTimeUtc;
if (asset.UploadedBy == null && uploaderId.HasValue) asset.UploadedBy = uploaderId;
updated++;
} else if (asset.UploadedBy == null && uploaderId.HasValue) {
assetDb.Attach(asset);
asset.UploadedBy = uploaderId;
updated++;
}
} else {
var newAsset = AssetFromPath(file.FullName, uploaderId);
if (newAsset != null) {
assetDb.Assets.Add(newAsset);
added++;
}
}
}
foreach (var gone in known.Values) {
token.ThrowIfCancellationRequested();
if (gone.DeletedAt != null) continue; // already soft-deleted
assetDb.Attach(gone);
gone.DeletedAt = DateTime.UtcNow;
deleted++;
}
if (added + updated + deleted > 0) {
try {
await assetDb.SaveChangesAsync(token);
} catch (DbUpdateException ex) when (IsDuplicateKey(ex)) {
// Last-resort safety net for genuine races (e.g. two overlapping folders inserting the
// same new path concurrently). The batch is dropped; the file is found next run.
logger.LogDebug(ex, "Dropped {Count} asset change(s) in {Path} due to a duplicate asset path.",
added + updated + deleted, dirPath);
} finally {
assetDb.ChangeTracker.Clear();
}
}
report(added, updated, deleted);
}
static bool IsDuplicateKey(DbUpdateException ex) =>
ex.InnerException is PostgresException { SqlState: "23505" }
|| ex.InnerException?.InnerException is PostgresException { SqlState: "23505" };
/// <summary>
/// Builds an <see cref="Asset"/> from a file path, or null when the extension is not a supported media type.
/// </summary>
/// <param name="filePath">The full path of the file.</param>
/// <param name="uploaderId">The uploader id to assign, if any.</param>
/// <returns>A new asset, or null if the file type is not supported.</returns>
Asset? AssetFromPath(string filePath, Guid? uploaderId) {
logger.LogTrace("Loading asset from file path {filePath}", filePath);
// Get the file info
var finfo = new FileInfo(filePath);
// Determine the type of the file
string ext = finfo.Extension.ToLower();
string ext = finfo.Extension.ToLower();
EAssetType? type;
// ReSharper disable ArrangeObjectCreationWhenTypeNotEvident
var mimeImg = MimeTypes.Image.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
var mimeVid = MimeTypes.Video.FirstOrDefault(mime => mime.Extensions.Contains(ext), new(Empty, []));
@@ -188,17 +292,17 @@ public sealed class FileSystemCrawlJob : Job {
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else return null;
// Create a new asset
var asset = new Asset() {
return new Asset {
FolderId = folderId,
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
Visibility = EVisibility.Private,
Visibility = EVisibility.Private,
UploadedBy = uploaderId,
CreatedAt = finfo.CreationTimeUtc,
UpdatedAt = finfo.LastWriteTimeUtc,
MimeType = type.Value switch {
IngestedAt = DateTime.UtcNow,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
@@ -206,18 +310,5 @@ public sealed class FileSystemCrawlJob : Job {
FileSize = finfo.Length,
Hash = new BitArray(64)
};
logger.LogDebug(
$"""
Asset created from path: {asset.OriginalPath}
Data:
- Filename: {asset.OriginalFilename}
- Type: {asset.Type}
- MimeType: {asset.MimeType}
- CreatedAt: {asset.CreatedAt}
- UpdatedAt: {asset.UpdatedAt}
- FileSize: {asset.FileSize} bytes
""");
return asset;
}
}
+15
View File
@@ -0,0 +1,15 @@
namespace Lactose.Jobs;
static class PathUtils {
internal static string PathFromGuid(Guid id, string root) {
var s = id.ToString("N");
return Path.Combine(
root,
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.webp"
);
}
}
+1 -13
View File
@@ -140,7 +140,7 @@ public class PreviewJob : Job {
)
);
var path = PathFromGuid(asset.Id, PreviewPath!);
var path = PathUtils.PathFromGuid(asset.Id, PreviewPath!);
if (!Directory.Exists(Path.GetDirectoryName(path)))
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
@@ -279,16 +279,4 @@ public class PreviewJob : Job {
else
JobStatus.Complete("All previews generated successfully.");
}
static string PathFromGuid(Guid id, string root) {
var s = id.ToString("N");
return Path.Combine(
root,
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.webp"
);
}
}
+1 -13
View File
@@ -140,7 +140,7 @@ public class ThumbnailJob : Job {
)
);
var path = PathFromGuid(asset.Id, ThumbnailPath!);
var path = PathUtils.PathFromGuid(asset.Id, ThumbnailPath!);
if (!Directory.Exists(Path.GetDirectoryName(path)))
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
@@ -279,16 +279,4 @@ public class ThumbnailJob : Job {
else
JobStatus.Complete("All thumbnails generated successfully.");
}
static string PathFromGuid(Guid id, string root) {
var s = id.ToString("N");
return Path.Combine(
root,
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.webp"
);
}
}
+30 -21
View File
@@ -21,6 +21,8 @@ public static class AlbumMapper {
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
CoverWidth = album.CoverAsset?.ResolutionWidth,
CoverHeight = album.CoverAsset?.ResolutionHeight,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
DeletedAt = null
@@ -28,31 +30,38 @@ public static class AlbumMapper {
/// <summary>
/// Maps an Album object to an AlbumFullDto object.
/// Uses pre-projected <see cref="Album.VisibleAssetPreviews"/> when available; otherwise falls back to mapping loaded assets.
/// </summary>
/// <param name="album">The album to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>An AlbumFullDto object.</returns>
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
Id = album.Id,
Name = album.Title,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
DeletedAt = null,
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
Id = asset.Id,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
}).ToList() ?? []
};
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) {
var previews = album.VisibleAssetPreviews
?? album.Assets?.Select(asset => new AlbumAssetPreviewDto {
Id = asset.Id,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
}).ToList()
?? [];
return new AlbumFullDto {
Id = album.Id,
Name = album.Title,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = previews.Count(p => p.DeletedAt == null),
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
DeletedAt = null,
Images = previews.Select(preview => preview.Id).ToList(),
AssetPreviews = previews
};
}
}
-1
View File
@@ -30,7 +30,6 @@ public static class JobMapper {
IntegrityCheckJob => EJobType.IntegrityCheck,
PHashJob => EJobType.PHashGeneration,
MetadataJob => EJobType.MetadataExtraction,
CreatePersonsJob => EJobType.CreatePersons,
CreateAlbumsJob => EJobType.CreateAlbums,
_ => throw new ArgumentOutOfRangeException(nameof(job))
}
+7 -3
View File
@@ -27,10 +27,14 @@ public static class PersonMapper {
ProfileCropX = person.ProfileCropX,
ProfileCropY = person.ProfileCropY,
ProfileCropZoom = person.ProfileCropZoom,
TotalAlbums = albums.Count,
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
TotalAlbums = person.AlbumTotalCount,
TotalAssets = person.TotalAssetCount,
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
Albums = albums.Select(a => {
var dto = a.ToAlbumPreviewDto(accessLevel, viewerId);
dto.AssetCount = person.AlbumAssetCounts?.GetValueOrDefault(a.Id, 0) ?? 0;
return dto;
}).ToList(),
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
};
-1
View File
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
+1 -2
View File
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
-1
View File
@@ -1,5 +1,4 @@
// <auto-generated />
using System;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -1,5 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -0,0 +1,601 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260811104813_AddAssetVisibleCreatedAtIndex")]
partial class AddAssetVisibleCreatedAtIndex
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetVisibleCreatedAtIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleCreatedAt",
table: "Assets",
columns: new[] { "Visibility", "CreatedAt" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleCreatedAt",
table: "Assets");
}
}
}
@@ -0,0 +1,625 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260817205017_OptimizeStatsIndexes")]
partial class OptimizeStatsIndexes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class OptimizeStatsIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_CreatedAt",
table: "Assets",
column: "CreatedAt",
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_MissingPhash",
table: "Assets",
column: "Hash",
filter: "\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_MissingPreview",
table: "Assets",
column: "PreviewPath",
filter: "\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_MissingThumbnail",
table: "Assets",
column: "ThumbnailPath",
filter: "\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_Resolution",
table: "Assets",
columns: new[] { "ResolutionWidth", "ResolutionHeight", "Type" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Assets_Stats_Type",
table: "Assets",
column: "Type",
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_CreatedAt",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_MissingPhash",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_MissingPreview",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_MissingThumbnail",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_Resolution",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Assets_Stats_Type",
table: "Assets");
}
}
}
@@ -0,0 +1,710 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260819004150_AddIncrementalScanState")]
partial class AddIncrementalScanState
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("FolderId", "Id")
.HasDatabaseName("IX_Assets_FolderId_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,130 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddIncrementalScanState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId",
table: "Assets");
migrationBuilder.AddColumn<DateTime>(
name: "LastDiscoveryScanAt",
table: "Folders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastDiscoveryScannedRegex",
table: "Folders",
type: "VARCHAR(2048)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastFileScanAt",
table: "Folders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "AssetAlbumStaging",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
PersonName = table.Column<string>(type: "VARCHAR(255)", nullable: false),
AlbumName = table.Column<string>(type: "VARCHAR(255)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AssetAlbumStaging", x => x.Id);
table.ForeignKey(
name: "FK_AssetAlbumStaging_Assets_AssetId",
column: x => x.AssetId,
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DirectoryScanStates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FolderId = table.Column<Guid>(type: "uuid", nullable: false),
Path = table.Column<string>(type: "VARCHAR(2048)", nullable: false),
LastMtime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DirectoryScanStates", x => x.Id);
table.ForeignKey(
name: "FK_DirectoryScanStates_Folders_FolderId",
column: x => x.FolderId,
principalTable: "Folders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets",
columns: new[] { "FolderId", "Id" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_AssetAlbumStaging_AssetId",
table: "AssetAlbumStaging",
column: "AssetId");
migrationBuilder.CreateIndex(
name: "IX_AssetAlbumStaging_PersonName",
table: "AssetAlbumStaging",
column: "PersonName");
migrationBuilder.CreateIndex(
name: "IX_DirectoryScanStates_FolderId_Path",
table: "DirectoryScanStates",
columns: new[] { "FolderId", "Path" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AssetAlbumStaging");
migrationBuilder.DropTable(
name: "DirectoryScanStates");
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets");
migrationBuilder.DropColumn(
name: "LastDiscoveryScanAt",
table: "Folders");
migrationBuilder.DropColumn(
name: "LastDiscoveryScannedRegex",
table: "Folders");
migrationBuilder.DropColumn(
name: "LastFileScanAt",
table: "Folders");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId",
table: "Assets",
column: "FolderId");
}
}
}
@@ -0,0 +1,713 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260819224929_AddAssetIngestedAt")]
partial class AddAssetIngestedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetIngestedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets");
migrationBuilder.AddColumn<DateTime>(
name: "IngestedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()");
// Backfill existing rows so an incremental discovery scan does not reprocess the whole library:
// for assets already in the database, ingestion time is taken to be their creation time.
migrationBuilder.Sql("""
UPDATE "Assets"
SET "IngestedAt" = "CreatedAt";
""");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_IngestedAt_Id",
table: "Assets",
columns: new[] { "FolderId", "IngestedAt", "Id" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_IngestedAt_Id",
table: "Assets");
migrationBuilder.DropColumn(
name: "IngestedAt",
table: "Assets");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets",
columns: new[] { "FolderId", "Id" },
filter: "\"DeletedAt\" IS NULL");
}
}
}
@@ -123,6 +123,9 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("bit(64)");
b.Property<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
@@ -177,7 +180,13 @@ namespace Lactose.Migrations
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
@@ -188,15 +197,91 @@ namespace Lactose.Migrations
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
@@ -249,6 +334,15 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -515,6 +609,28 @@ namespace Lactose.Migrations
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
+8
View File
@@ -1,3 +1,4 @@
using Butter.Dtos.Album;
using Butter.Types;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -66,4 +67,11 @@ public class Album {
public Asset? CoverAsset { get; set; }
#endregion
/// <summary>
/// Gets or sets the visibility-filtered asset previews for the album detail view.
/// Populated by <c>FindVisible</c>; ignored by EF Core.
/// </summary>
[NotMapped]
public List<AlbumAssetPreviewDto>? VisibleAssetPreviews { get; set; }
}
+8
View File
@@ -101,6 +101,14 @@ public class Asset {
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets the timestamp when this asset was ingested into the system.
/// Unlike <see cref="CreatedAt"/> (the file's on-disk creation time), this reflects when the
/// asset was first inserted into the database, which is what incremental discovery scans use as their cursor.
/// </summary>
[Required]
public required DateTime IngestedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
+41
View File
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lactose.Models;
/// <summary>
/// Temporary staging row produced by the person/album discovery job.
/// Records a matched asset together with the person and (optional) album name extracted from its path.
/// The table is truncated at the start and end of each discovery run.
/// </summary>
public class AssetAlbumStaging {
/// <summary>
/// Gets or sets the unique identifier for the staging row.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the matched asset.
/// </summary>
[ForeignKey(nameof(Asset))]
public Guid AssetId { get; set; }
/// <summary>
/// Gets or sets the normalized person name extracted from the asset path.
/// </summary>
[Column(TypeName = "VARCHAR(255)")]
public string PersonName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the normalized album name extracted from the asset path.
/// Null when the folder's regex has no <c>group</c> group or the group did not match.
/// </summary>
[Column(TypeName = "VARCHAR(255)")]
public string? AlbumName { get; set; }
/// <summary>
/// Gets or sets the matched asset.
/// </summary>
public Asset? Asset { get; set; }
}
+38
View File
@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lactose.Models;
/// <summary>
/// Stores the last-known filesystem modification timestamp of a scanned directory.
/// Used by the file-system crawl job to skip directories whose mtime is unchanged since the last scan.
/// </summary>
public class DirectoryScanState {
/// <summary>
/// Gets or sets the unique identifier for the scan-state entry.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the folder this directory belongs to.
/// </summary>
[ForeignKey(nameof(Folder))]
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets the full filesystem path of the scanned directory.
/// </summary>
[Column(TypeName = "VARCHAR(2048)")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the last-known <c>LastWriteTimeUtc</c> of the directory.
/// </summary>
public DateTime LastMtime { get; set; }
/// <summary>
/// Gets or sets the folder this directory belongs to.
/// </summary>
public Folder? Folder { get; set; }
}
+19
View File
@@ -32,6 +32,25 @@ public class Folder {
[MaxLength(2048)]
public string? RegexPattern { get; set; }
/// <summary>
/// Gets or sets the timestamp of the last file-system scan of this folder.
/// Used by incremental scans to skip directories whose mtime is unchanged.
/// </summary>
public DateTime? LastFileScanAt { get; set; }
/// <summary>
/// Gets or sets the timestamp of the last person/album discovery scan of this folder.
/// Incremental discovery only processes assets created at or after this timestamp.
/// </summary>
public DateTime? LastDiscoveryScanAt { get; set; }
/// <summary>
/// Gets or sets the regex pattern used during the last discovery scan.
/// When this differs from <see cref="RegexPattern"/>, discovery automatically re-runs as a full (deep) scan.
/// </summary>
[Column(TypeName = "VARCHAR(2048)")]
public string? LastDiscoveryScannedRegex { get; set; }
#region Navigation Properties
/// <summary>
+21
View File
@@ -79,4 +79,25 @@ public class Person {
public List<User>? Maintainers { get; set; }
#endregion
/// <summary>
/// Gets or sets the total number of albums (before pagination).
/// Populated by <c>FindVisible</c>; ignored by EF Core.
/// </summary>
[NotMapped]
public int AlbumTotalCount { get; set; }
/// <summary>
/// Gets or sets the total number of non-deleted assets across all visible albums.
/// Populated by <c>FindVisible</c>; ignored by EF Core.
/// </summary>
[NotMapped]
public int TotalAssetCount { get; set; }
/// <summary>
/// Gets or sets the per-album visible asset counts for the paged album list.
/// Populated by <c>FindVisible</c>; ignored by EF Core.
/// </summary>
[NotMapped]
public Dictionary<Guid, int>? AlbumAssetCounts { get; set; }
}
+21 -5
View File
@@ -117,11 +117,12 @@ string host = builder.Configuration["DatabaseAddress:Host"] ?? "localhost";
string port = builder.Configuration["DatabaseAddress:Port"] ?? "3306";
var strBuilder = new NpgsqlConnectionStringBuilder() {
Host = host,
Port = int.Parse(port),
Username = user,
Password = password,
Database = database
Host = host,
Port = int.Parse(port),
Username = user,
Password = password,
Database = database,
CommandTimeout = 120
};
builder.Services.AddDbContext<LactoseDbContext>(
@@ -167,6 +168,7 @@ builder.Services.AddSingleton<JobManager>();
builder.Services.AddSingleton<JobScheduler>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddControllers();
builder.Services.AddMemoryCache();
builder.Services.AddSwaggerGen(
options => {
@@ -245,4 +247,18 @@ app.UseAuthentication();
app.UseAuthorization();
//app.UseHttpsRedirection();
app.MapControllers();
// Auto-save middleware: ensures pending changes are persisted at the end of every
// non-GET request even if the caller forgets to call Save() explicitly.
// This is a safety net — explicit Save() calls in controllers and jobs still work
// as before and take effect immediately. The middleware's SaveChanges is a no-op
// when no tracked changes remain.
app.Use(async (context, next) => {
await next();
if (context.Request.Method != "GET") {
var db = context.RequestServices.GetRequiredService<LactoseDbContext>();
await db.SaveChangesAsync();
}
});
app.Run();
+39 -28
View File
@@ -27,8 +27,8 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
}
/// <inheritdoc />
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
IQueryable<Album> albumsQuery = context.Albums;
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null) {
IQueryable<Album> albumsQuery = context.Albums.AsNoTracking();
if (!string.IsNullOrEmpty(query))
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
@@ -39,6 +39,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
if (uploadedBy.HasValue)
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
if (personOwnerId.HasValue)
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == personOwnerId.Value);
// Apply visibility filter scoped to access level
albumsQuery = accessLevel switch {
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
@@ -69,7 +72,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
return [];
var pagedAlbums = context.Albums
.AsNoTracking()
.Include(a => a.PersonOwner)
.Include(a => a.CoverAsset)
.Where(a => albumIds.Contains(a.Id))
.ToList();
@@ -108,20 +113,19 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
public void Save() => context.SaveChanges();
/// <inheritdoc />
public Album? Find(Guid id) => context.Albums
public Album? Find(Guid id) => context.Albums.FirstOrDefault(a => a.Id == id);
/// <inheritdoc />
public Album? FindWithAssets(Guid id) => context.Albums
.AsSplitQuery()
.Include(a => a.CoverAsset)
.Include(a => a.PersonOwner)
.Include(a => a.Assets)
.FirstOrDefault(a => a.Id == id);
/// <inheritdoc />
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
var album = context.Albums
.AsSplitQuery()
.Include(a => a.CoverAsset)
.AsNoTracking()
.Include(a => a.PersonOwner)
.Include(a => a.Assets)
.FirstOrDefault(a => a.Id == id);
if (album == null) return null;
@@ -138,33 +142,40 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
if (!canSeeAlbum) return null;
if (album.Assets == null) return album;
// R2: Filter assets at the database level and project only the DTO fields
IQueryable<Asset> assetsQuery = context.Assets
.AsNoTracking()
.Where(a => a.Albums!.Any(al => al.Id == id));
album.Assets = accessLevel switch {
EAccessLevel.Admin => album.Assets,
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).ToList(),
EAccessLevel.Maintainer when userId.HasValue => FilterAssetsForMaintainer(album, userId.Value),
_ => album.Assets.Where(a => a.DeletedAt == null && (
assetsQuery = accessLevel switch {
EAccessLevel.Admin => assetsQuery,
EAccessLevel.Curator => assetsQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer when userId.HasValue && album.PersonOwnerId.HasValue
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)
=> assetsQuery.Where(a => a.DeletedAt == null),
_ => assetsQuery.Where(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Protected && (userId.HasValue || accessLevel >= EAccessLevel.Maintainer)) ||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
)).ToList()
))
};
album.VisibleAssetPreviews = assetsQuery
.OrderBy(a => a.OriginalFilename)
.Select(a => new AlbumAssetPreviewDto {
Id = a.Id,
ResolutionWidth = a.ResolutionWidth,
ResolutionHeight = a.ResolutionHeight,
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
})
.ToList();
return album;
}
private List<Asset> FilterAssetsForMaintainer(Album album, Guid userId) {
if (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
pm.UserId == userId && pm.PersonId == album.PersonOwnerId.Value))
return album.Assets!.Where(a => a.DeletedAt == null).ToList();
return album.Assets!.Where(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId
)).ToList();
}
/// <inheritdoc />
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
+51 -34
View File
@@ -1,3 +1,4 @@
using Butter.Dtos.Asset;
using Butter.Types;
using Lactose.Context;
using Lactose.Mapper;
@@ -177,8 +178,8 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
.Take(limit);
/// <inheritdoc />
public IEnumerable<Asset> 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) {
var query = context.Assets.AsQueryable();
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) {
var query = context.Assets.AsNoTracking().AsQueryable();
if (type.HasValue)
query = query.Where(a => a.Type == type.Value);
if (from.HasValue)
@@ -213,45 +214,61 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
))
};
total = query.Count();
total = includeCount ? query.Count() : 0;
if (orderRandomly && seed.HasValue) {
var allIds = query.Select(a => a.Id).ToList();
var seedHash = seed.Value.GetHashCode();
var shuffledIds = allIds
.OrderBy(id => DeterministicHash(id, seedHash))
.Skip(pageNumber * pageSize)
.Take(pageSize)
.ToList();
// 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.
IQueryable<Asset> ordered = orderRandomly switch {
true when seed.HasValue => query.OrderBy(a => PgFunctions.Md5(a.Id.ToString() + seed.Value.ToString())),
true => query.OrderBy(a => a.Id),
false => query.OrderByDescending(a => a.CreatedAt)
};
var assets = context.Assets
.AsSplitQuery()
.Where(a => shuffledIds.Contains(a.Id))
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
.ToList();
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
}
IQueryable<Asset> dataQuery = query
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
return dataQuery
var pageIds = ordered
.Skip(pageNumber * pageSize)
.Take(pageSize)
.Select(a => a.Id)
.ToList();
}
private static int DeterministicHash(Guid id, int seed) {
var bytes = id.ToByteArray();
unchecked {
var hash = seed;
for (var i = 0; i < bytes.Length; i++)
hash = hash * 31 + bytes[i];
return hash & 0x7FFFFFFF;
if (pageIds.Count == 0)
return [];
var assets = query
.Where(a => pageIds.Contains(a.Id))
.Select(a => new AssetPreviewDto {
Id = a.Id,
MimeType = a.MimeType,
ResolutionWidth = a.ResolutionWidth,
ResolutionHeight = a.ResolutionHeight,
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
})
.ToList();
// Album and cosplayer names per asset via a single grouped query
var albumLinks = (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();
var linksByAsset = albumLinks
.GroupBy(x => x.AssetId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (var dto in assets) {
if (!linksByAsset.TryGetValue(dto.Id, out var links)) continue;
dto.AlbumNames = links.Select(x => x.Title).Distinct().ToList();
dto.AlbumIds = links.Select(x => x.Id).Distinct().ToList();
dto.CosplayerNames = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonName).Distinct().ToList();
dto.CosplayerIds = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonOwnerId!.Value).Distinct().ToList();
}
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
}
/// <inheritdoc />
+2 -2
View File
@@ -17,7 +17,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
static void OnFolderRemoved(Folder e) => FolderRemoved?.Invoke(null, e);
/// <inheritdoc />
public void Create(Folder folder) {
public void Insert(Folder folder) {
context.Folders.Add(folder);
context.SaveChanges();
if (folder.Active) OnFolderAdded(folder);
@@ -48,7 +48,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
}
/// <inheritdoc />
public void Delete(Guid id) {
public void Remove(Guid id) {
var folder = context.Folders.Find(id);
if (folder != null) {
+9 -1
View File
@@ -39,6 +39,13 @@ public interface IAlbumRepository : IDisposable {
/// <returns>The album if found; otherwise, null.</returns>
public Album? Find(Guid id);
/// <summary>
/// Finds an album by its ID with its assets loaded for collection replacement.
/// </summary>
/// <param name="id">The ID of the album.</param>
/// <returns>The album with its asset collection loaded if found; otherwise, null.</returns>
public Album? FindWithAssets(Guid id);
/// <summary>
/// Finds an album by its ID with assets filtered by the requesting user's access level.
/// </summary>
@@ -96,7 +103,8 @@ public interface IAlbumRepository : IDisposable {
/// <param name="userId">The current user's ID for access-level filtering.</param>
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
/// <param name="personOwnerId">Optional filter for albums owned by a specific person.</param>
/// <returns>A list of album previews matching the search query.</returns>
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null);
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null);
}
+4 -2
View File
@@ -1,3 +1,4 @@
using Butter.Dtos.Asset;
using Butter.Types;
using Lactose.Models;
@@ -193,8 +194,9 @@ public interface IAssetRepository : IDisposable {
/// <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>
/// <returns>A paginated collection of assets matching the filters.</returns>
IEnumerable<Asset> 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);
/// <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);
/// <summary>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
+4 -4
View File
@@ -10,7 +10,7 @@ public interface IFolderRepository : IDisposable {
/// Creates a new folder.
/// </summary>
/// <param name="folder">The folder to create.</param>
void Create(Folder folder);
void Insert(Folder folder);
/// <summary>
/// Updates an existing folder.
@@ -20,10 +20,10 @@ public interface IFolderRepository : IDisposable {
void Update(Guid id, Folder folder);
/// <summary>
/// Deletes a folder by ID.
/// Removes a folder by ID.
/// </summary>
/// <param name="id">The ID of the folder to delete.</param>
void Delete(Guid id);
/// <param name="id">The ID of the folder to remove.</param>
void Remove(Guid id);
/// <summary>
/// Retrieves a folder by ID.
+1 -1
View File
@@ -6,7 +6,7 @@ namespace Lactose.Repositories;
/// <summary>
/// Interface for media repository to handle media data retrieval.
/// </summary>
public interface IMediaRepository {
public interface IMediaRepository : IDisposable {
/// <summary>
/// Retrieves the original media data.
/// </summary>
@@ -3,6 +3,8 @@ using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Repositories;
/// <summary>
/// Interface for person repository operations.
/// </summary>
+4 -3
View File
@@ -5,10 +5,11 @@ namespace Lactose.Repositories;
/// <summary>
/// Repository for gathering aggregate statistics from the database.
/// </summary>
public interface IStatsRepository : IDisposable {
public interface IStatsRepository {
/// <summary>
/// Gathers all aggregate statistics into a single DTO.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A fully populated <see cref="StatsDto"/>.</returns>
StatsDto GetStats();
}
Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken);
}
+3 -3
View File
@@ -49,10 +49,10 @@ public interface ITagRepository : IDisposable {
void Save();
/// <summary>
/// Deletes a tag by the given <paramref name="id"/>
/// Deletes the given tag from the repository.
/// </summary>
/// <param name="id"></param>
void Delete(Tag id);
/// <param name="tag"></param>
void Delete(Tag tag);
/// <summary>
/// Insert a new Tag
+6
View File
@@ -92,4 +92,10 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
Path = media.PreviewPath
};
}
/// <inheritdoc />
public void Dispose() {
context.Dispose();
personRepository.Dispose();
}
}
+113 -72
View File
@@ -16,23 +16,14 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
public void Save() => context.SaveChanges();
/// <inheritdoc />
public Person? Find(Guid id) => context.People
.AsSplitQuery()
.Include(p => p.ProfileAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == id);
public Person? Find(Guid id) => context.People.FirstOrDefault(p => p.Id == id);
/// <inheritdoc />
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
var person = context.People
.AsSplitQuery()
.Include(p => p.ProfileAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
.AsNoTracking()
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == id);
@@ -49,77 +40,118 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
if (!canSeePerson) return null;
if (person.Albums == null) return person;
bool isMaintainerOfPerson = accessLevel == EAccessLevel.Maintainer && userId.HasValue
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id);
// Filter albums and assets by visibility
switch (accessLevel) {
case EAccessLevel.Admin:
case EAccessLevel.Curator:
IQueryable<Album> albumsQuery = context.Albums
.AsNoTracking()
.Where(a => a.PersonOwnerId == id);
// Album search — ILike so the trgm GIN index on Title can be used
if (!string.IsNullOrEmpty(albumSearch))
albumsQuery = albumsQuery.Where(a => EF.Functions.ILike(a.Title, $"%{albumSearch}%"));
// Album visibility filter. Admin, curators, and maintainers of this person see all albums;
// everyone else sees Protected-or-below albums plus albums that contain at least one visible asset.
bool seesAllAlbums = accessLevel >= EAccessLevel.Curator || isMaintainerOfPerson;
if (!seesAllAlbums) {
albumsQuery = albumsQuery.Where(a => a.Visibility <= EVisibility.Protected || a.Assets!.Any(
asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
)));
}
IOrderedQueryable<Album> ordered;
switch (albumSortBy?.ToLowerInvariant()) {
case "name":
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title);
break;
case EAccessLevel.Maintainer when userId.HasValue
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
case "created":
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
break;
default:
person.Albums = person.Albums
.Where(a => a.Visibility <= EVisibility.Protected || (
a.Assets != null && a.Assets.Any(asset =>
asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
)
)
))
.ToList();
foreach (var album in person.Albums) {
if (album.Assets != null) {
album.Assets = album.Assets
.Where(asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
))
.ToList();
}
case "updated":
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt);
break;
case "assets":
if (seesAllAlbums) {
ordered = albumSortAsc
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null))
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null));
} else {
ordered = albumSortAsc
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))))
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))));
}
break;
default:
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
break;
}
// Apply album search
if (!string.IsNullOrEmpty(albumSearch)) {
person.Albums = person.Albums
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
.ToList();
// Total album count (after search and visibility filters, before pagination)
person.AlbumTotalCount = ordered.Count();
// Total visible assets across all visible albums of the person (before search and pagination)
if (seesAllAlbums) {
person.TotalAssetCount = context.Assets
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
.Count(asset => asset.DeletedAt == null);
} else {
person.TotalAssetCount = context.Assets
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
.Count(asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)));
}
// Apply album sorting
if (!string.IsNullOrEmpty(albumSortBy)) {
person.Albums = albumSortBy.ToLowerInvariant() switch {
"name" => albumSortAsc
? [..person.Albums.OrderBy(a => a.Title)]
: [..person.Albums.OrderByDescending(a => a.Title)],
"created" => albumSortAsc
? [..person.Albums.OrderBy(a => a.CreatedAt)]
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
"updated" => albumSortAsc
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
"assets" => albumSortAsc
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
_ => person.Albums
};
}
// Apply album pagination
person.Albums = person.Albums
var albumIds = ordered
.Skip(albumPage * albumPageSize)
.Take(albumPageSize)
.Select(a => a.Id)
.ToList();
if (albumIds.Count == 0)
return person;
person.Albums = context.Albums
.AsNoTracking()
.AsSplitQuery()
.Include(a => a.PersonOwner)
.Include(a => a.CoverAsset)
.Where(a => albumIds.Contains(a.Id))
.ToList();
// Per-album visible asset counts at the database level
if (seesAllAlbums) {
person.AlbumAssetCounts = context.Albums
.Where(a => albumIds.Contains(a.Id))
.Select(a => new { a.Id, Count = a.Assets!.Count(asset => asset.DeletedAt == null) })
.ToDictionary(x => x.Id, x => x.Count);
} else {
person.AlbumAssetCounts = context.Albums
.Where(a => albumIds.Contains(a.Id))
.Select(a => new {
a.Id,
Count = a.Assets!.Count(asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)))
})
.ToDictionary(x => x.Id, x => x.Count);
}
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
person.Albums = [.. person.Albums.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
return person;
}
@@ -202,12 +234,21 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
return [];
var pagedPeople = context.People
.AsSplitQuery()
.Include(p => p.Albums)
.AsNoTracking()
.Where(p => personIds.Contains(p.Id))
.ToList();
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
// Album counts via a single grouped query instead of loading every album row
var albumCounts = context.People
.Where(p => personIds.Contains(p.Id))
.Select(p => new { p.Id, Count = p.Albums!.Count })
.ToDictionary(x => x.Id, x => x.Count);
var dtos = pagedPeople.Select(p => {
var dto = p.ToPersonPreviewDto(accessLevel);
dto.TotalAlbums = albumCounts.GetValueOrDefault(p.Id, 0);
return dto;
}).ToList();
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
+148 -128
View File
@@ -3,150 +3,176 @@ 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) : IStatsRepository {
public class StatsRepository(
LactoseDbContext context,
ISettingsRepository settingsRepo,
IMemoryCache cache
) : IStatsRepository {
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);
/// <inheritdoc />
public StatsDto GetStats() {
var dto = new StatsDto();
var now = DateTime.UtcNow;
dto.TotalAssets = context.Assets.Count(a => a.DeletedAt == null);
dto.TotalUsers = context.Users.Count(u => u.DeletedAt == null);
dto.TotalAlbums = context.Albums.Count();
dto.TotalTags = context.Tags.Count();
dto.TotalPeople = context.People.Count();
dto.TotalFaces = context.Faces.Count();
dto.TotalFolders = context.Folders.Count();
dto.AssetsByType = context.Assets
.Where(a => a.DeletedAt == null)
.GroupBy(a => a.Type)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionary(x => x.Key, x => x.Count);
dto.TotalStorageBytes = context.Assets
.Where(a => a.DeletedAt == null)
.Sum(a => (long?)a.FileSize) ?? 0;
dto.StorageByType = context.Assets
.Where(a => a.DeletedAt == null)
.GroupBy(a => a.Type)
.Select(g => new { g.Key, Size = g.Sum(a => (long?)a.FileSize) ?? 0 })
.ToDictionary(x => x.Key, x => x.Size);
dto.UsersByAccessLevel = context.Users
.Where(u => u.DeletedAt == null)
.GroupBy(u => u.AccessLevel)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionary(x => x.Key, x => x.Count);
dto.AssetsAddedLast7Days = context.Assets
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-7));
dto.AssetsAddedLast30Days = context.Assets
.Count(a => a.DeletedAt == null && a.CreatedAt >= now.AddDays(-30));
dto.UsersRegisteredLast30Days = context.Users
.Count(u => u.DeletedAt == null && u.CreatedAt >= now.AddDays(-30));
dto.OrphanAssets = context.Assets.Count(a => a.DeletedAt == null && a.FolderId == null);
dto.PublicAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Public);
dto.ProtectedAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Protected);
dto.PrivateAssets = context.Assets.Count(a => a.DeletedAt == null && a.Visibility == EVisibility.Private);
dto.AssetsMissingMetadata = context.Assets.Count(a =>
a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
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);
dto.AssetsMissingThumbnail = context.Assets.Count(a =>
(a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
var stats = await ComputeStatsAsync(cancellationToken, expectedThumbnailSize, expectedPreviewSize);
cache.Set(cacheKey, stats, CacheTtl);
return stats;
}
dto.AssetsMissingThumbnailStale = expectedThumbnailSize > 0
? context.Assets.Count(a =>
a.ThumbnailPath != null && a.ThumbnailPath != "" && a.ThumbnailSize != expectedThumbnailSize && a.DeletedAt == null)
: 0;
dto.AssetsMissingPreviews = context.Assets.Count(a =>
(a.PreviewPath == null || a.PreviewPath == "") && a.DeletedAt == null);
dto.AssetsMissingPreviewsStale = expectedPreviewSize > 0
? context.Assets.Count(a =>
a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize && a.DeletedAt == null)
: 0;
private async Task<StatsDto> ComputeStatsAsync(
CancellationToken cancellationToken,
int expectedThumbnailSize,
int expectedPreviewSize) {
var dto = new StatsDto();
var now = DateTime.UtcNow;
var emptyHash = new BitArray(64);
dto.AssetsMissingPhash = context.Assets.Count(a =>
a.Hash == emptyHash && a.DeletedAt == null);
var albumAssetSet = context.Set<Dictionary<string, object>>("AlbumAsset");
dto.AssetsWithNoAlbum = context.Assets.Count(a =>
a.DeletedAt == null && !albumAssetSet.Any(aa => EF.Property<Guid?>(aa, "AssetsId") == a.Id));
dto.AssetsWithNoPerson = context.Assets.Count(a =>
a.DeletedAt == null && !albumAssetSet.Any(aa =>
EF.Property<Guid?>(aa, "AssetsId") == a.Id
&& context.Albums.Any(al => al.Id == EF.Property<Guid?>(aa, "AlbumsId") && al.PersonOwnerId != null)));
dto.AlbumsMissingCover = context.Albums.Count(a => a.CoverAssetId == null);
dto.CosplayersMissingProfile = context.People.Count(p => p.ProfileAssetId == null);
dto.TopTags = (
from at in context.Set<Dictionary<string, object>>("AssetTag")
join a in context.Assets.Where(a => a.DeletedAt == null)
on EF.Property<Guid?>(at, "AssetsId") equals (Guid?)a.Id
join t in context.Tags
on EF.Property<Guid?>(at, "TagsId") equals (Guid?)t.Id
group t by new { t.Id, t.Name } into g
select new TagStatDto {
Id = g.Key.Id,
Name = g.Key.Name,
AssetCount = g.Count()
}
).OrderByDescending(t => t.AssetCount).Take(10).ToList();
var resolutions = context.Assets
.Where(a => a.DeletedAt == null && a.ResolutionWidth > 0 && a.ResolutionHeight > 0)
.Select(a => new { a.ResolutionWidth, a.ResolutionHeight })
.ToList();
dto.ResolutionDistribution = resolutions
.GroupBy(r => ResolveBucket(r.ResolutionWidth, r.ResolutionHeight))
.Select(g => new ResolutionBucketDto { Label = g.Key, Count = g.Count() })
.OrderBy(r => r.Count)
.ToList();
var mimeTypes = context.Assets
var assetStats = await context.Assets
.Where(a => a.DeletedAt == null)
.Select(a => a.MimeType)
.ToList();
.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);
dto.FileFormatBreakdown = mimeTypes
.GroupBy(m => m)
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 = context.Assets
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() })
.ToList();
.ToListAsync(cancellationToken);
var monthlyUsers = context.Users
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() })
.ToList();
.ToListAsync(cancellationToken);
var months = Enumerable.Range(0, 12)
.Select(i => twelveMonthsAgo.AddMonths(i + 1))
@@ -163,17 +189,11 @@ public class StatsRepository(LactoseDbContext context, ISettingsRepository setti
return dto;
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
private static string ResolveBucket(int width, int height) {
int maxDim = Math.Max(width, height);
return maxDim switch {
<= 480 => "SD (≤480p)",
<= 720 => "HD (≤720p)",
<= 1080 => "Full HD (≤1080p)",
<= 2160 => "4K (≤2160p)",
_ => "4K+"
};
}
}
private static string ResolveBucket(int maxDim) => maxDim switch {
<= 480 => "SD (≤480p)",
<= 720 => "HD (≤720p)",
<= 1080 => "Full HD (≤1080p)",
<= 2160 => "4K (≤2160p)",
_ => "4K+"
};
}
+41 -29
View File
@@ -50,7 +50,7 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
FolderRepository.FolderRemoved += OnFolderRemoved;
// defaults to 30 minutes
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
fileSystemCrawl = new Timer(QueueFileSystemCrawl, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
fileSystemCrawl = new Timer(QueueFileSystemCrawlTimer, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
logger?.LogInformation($"Periodic folder scanning is now enabled every {interval} minutes.");
var folderRepository = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
@@ -145,10 +145,11 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
}
/// <summary>
/// Queues a file system crawl job for each active folder.
/// Queues a file system crawl job for each selected (or active) folder.
/// </summary>
/// <param name="state">Unused state parameter (compatible with Timer callback).</param>
internal void QueueFileSystemCrawl(object? state) {
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
/// <param name="folderIds">Optional folders to scan. When null/empty, all active folders are scanned.</param>
internal void QueueFileSystemCrawl(bool deep, IReadOnlyList<Guid>? folderIds = null) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue file system crawl.");
return;
@@ -158,10 +159,37 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
return;
}
// queue a crawl job for each active folder
folders?.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
logger?.LogInformation("Queued file system crawl jobs.");
List<Folder>? targetFolders;
if (folderIds is { Count: > 0 }) {
using var scope = serviceProvider.CreateScope();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
targetFolders = folderRepo.GetAll().Where(f => folderIds.Contains(f.Id)).ToList();
} else {
// if the scheduled scan list isn't populated, fetch active folders directly from DB
targetFolders = folders;
if (targetFolders is not { Count: > 0 }) {
using var scope = serviceProvider.CreateScope();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
}
}
// queue a crawl job for each target folder
if (targetFolders is { Count: > 0 }) {
foreach (var f in targetFolders)
jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath, deep));
logger?.LogInformation("Queued {Count} file system crawl job(s) (deep={Deep}).", targetFolders.Count, deep);
} else {
logger?.LogWarning("No folders to scan.");
}
}
/// <summary>
/// Timer callback for periodic incremental file-system scans.
/// </summary>
/// <param name="state">Unused state parameter.</param>
void QueueFileSystemCrawlTimer(object? state) => QueueFileSystemCrawl(false);
/// <summary>
/// Queues a thumbnail generation job.
@@ -236,27 +264,11 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
}
/// <summary>
/// Queues a create persons job.
/// Queues a create persons &amp; albums (discovery) job.
/// </summary>
/// <param name="o">Unused state parameter.</param>
internal void QueueCreatePersons(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue create persons.");
return;
}
if (jobManager.Jobs.Any(j => j is CreatePersonsJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Create persons job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<CreatePersonsJob>());
logger?.LogInformation("Queued create persons job.");
}
/// <summary>
/// Queues a create albums job.
/// </summary>
/// <param name="o">Unused state parameter.</param>
internal void QueueCreateAlbums(object? o) {
/// <param name="deep">When true, ignores the per-folder discovery cursor and scans all assets.</param>
/// <param name="folderIds">Optional folders to process. When null/empty, all active folders are processed.</param>
internal void QueueCreateAlbums(bool deep, IReadOnlyList<Guid>? folderIds = null) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue create albums.");
return;
@@ -265,8 +277,8 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation("Create albums job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<CreateAlbumsJob>());
logger?.LogInformation("Queued create albums job.");
jobManager.EnqueueJob(jobManager.CreateJob<CreateAlbumsJob>(deep, folderIds!));
logger?.LogInformation("Queued create albums job (deep={Deep}).", deep);
}
/// <summary>
+2 -2
View File
@@ -13,11 +13,11 @@
"UserID": "root",
"Password": "testOnlyDb",
"Database": "TestDb",
"connString":"Server=127.0.0.1;Port=3306;Database=TestDb;User Id=root;Password=testOnlyDb;"
"connString":"Server=127.0.0.1;Port=5432;Database=TestDb;User Id=root;Password=testOnlyDb;"
},
"DatabaseAddress": {
"Host": "localhost",
"Port": 3306
"Port": 5432
},
"SignKey": {
"Key": "32_CHARACTERS_KEY_IS_REQUIRED_TO_WORK"
@@ -1,37 +0,0 @@
@* Generic collapsible section with clickable header and chevron toggle *@
<div class="@Class">
<div class="d-flex align-items-center gap-2 cursor-pointer" @onclick="Toggle" role="button">
@if (TitleContent is not null) {
@TitleContent
} else if (!string.IsNullOrEmpty(Title)) {
<span>@Title</span>
}
<span class="ms-auto small @(Expanded ? "bi-chevron-up" : "bi-chevron-down")"></span>
</div>
@if (Expanded) {
<div class="@ContentClass">
@ChildContent
</div>
}
</div>
@code {
[Parameter] public string? Title { get; set; }
[Parameter] public RenderFragment? TitleContent { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public bool InitiallyExpanded { get; set; }
[Parameter] public string? Class { get; set; }
[Parameter] public string? ContentClass { get; set; }
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
bool Expanded { get; set; }
protected override void OnInitialized() {
Expanded = InitiallyExpanded;
}
async Task Toggle() {
Expanded = !Expanded;
await ExpandedChanged.InvokeAsync(Expanded);
}
}
+44 -2
View File
@@ -1,6 +1,8 @@
@using Butter.Dtos.Folder
@using Butter.Dtos.Jobs
@using System.Text.RegularExpressions
@inject FoldersService FoldersService
@inject JobsService JobsService
<div class="card mb-3">
<div class="card-body py-2">
@@ -16,6 +18,18 @@
<i class="bi bi-regex"></i> @(string.IsNullOrEmpty(Folder.RegexPattern) ? "" : "on")
</button>
@* Scan dropdown *@
<div class="btn-group btn-group-sm @Display">
<button class="btn btn-job-scan" @onclick="() => OnScan(false)" title="Incremental scan">
<i class="bi bi-folder2-open"></i>
</button>
<button class="btn btn-job-scan dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Scan options"></button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" @onclick="() => OnScan(false)"><i class="bi bi-folder2-open me-1"></i>Scan (incremental)</a></li>
<li><a class="dropdown-item" @onclick="() => OnScan(true)"><i class="bi bi-arrow-repeat me-1"></i>Deep scan</a></li>
</ul>
</div>
<div class="ms-auto form-switch">
<input class="form-check-input m-1" type="checkbox" id="@Folder.Id" @bind="@Folder.Active" @bind:after="() => OnToggleActive()"/>
</div>
@@ -23,6 +37,13 @@
<button class="btn btn-danger btn-sm mx-1" @onclick="OnDelete"><i class="bi bi-folder-minus"></i></button>
</div>
@if (_scanError != null) {
<div class="alert alert-danger py-1 px-2 mb-0 mt-1 small">@_scanError</div>
}
@if (_scanBusy) {
<div class="small text-muted py-1"><span class="spinner-border spinner-border-sm me-1"></span>Enqueuing scan...</div>
}
@* Pattern section (collapsible) *@
@if (_patternExpanded)
{
@@ -103,7 +124,9 @@
@code {
bool _editing;
bool _patternExpanded;
bool _scanBusy;
string _localPattern = string.Empty;
string? _scanError;
List<string>? _samplePaths;
string Display => _editing ? "d-none" : "";
string Edit => _editing ? "" : "d-none";
@@ -113,8 +136,6 @@
[Parameter]
public FolderFullDto Folder { get; set; } = new FolderFullDto();
[Parameter]
public EventCallback<Guid> OnDeleted { get; set; }
[Parameter]
public EventCallback<Guid> OnDeleteRequested { get; set; }
@@ -159,6 +180,27 @@
await OnDeleteRequested.InvokeAsync(Folder.Id);
}
async Task OnScan(bool deep)
{
if (_scanBusy) return;
_scanBusy = true;
_scanError = null;
try
{
var parameters = new List<string> { $"folder={Folder.Id}" };
if (deep) parameters.Add("deep");
await JobsService.StartJob(EJobType.FileSystemScan, parameters);
}
catch (Exception ex)
{
_scanError = $"Could not start scan: {ex.Message}";
}
finally
{
_scanBusy = false;
}
}
async Task OnToggleActive()
{
await FoldersService.UpdateFolderAsync(
+7 -7
View File
@@ -106,13 +106,13 @@
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
[EJobStatus.Queued] = "secondary",
[EJobStatus.Running] = "primary",
[EJobStatus.Waiting] = "info",
[EJobStatus.Completed] = "success",
[EJobStatus.CompletedWithErrors] = "warning",
[EJobStatus.Failed] = "danger",
[EJobStatus.Canceled] = "secondary",
[EJobStatus.Queued] = "var(--status-queued)",
[EJobStatus.Running] = "var(--status-running)",
[EJobStatus.Waiting] = "var(--status-waiting)",
[EJobStatus.Completed] = "var(--status-completed)",
[EJobStatus.CompletedWithErrors] = "var(--status-completed-with-errors)",
[EJobStatus.Failed] = "var(--status-failed)",
[EJobStatus.Canceled] = "var(--status-canceled)",
};
static readonly EJobStatus[] _segmentOrder = [
@@ -1,19 +0,0 @@
@* Section wrapper for a group of jobs with title and count *@
<div class="mb-3">
<div class="d-flex align-items-center mb-1">
<h6 class="mb-0 text-muted text-uppercase small">@Title @(Count is not null ? $"({Count})" : "")</h6>
@if (Extra is not null) {
<span class="ms-auto">@Extra</span>
}
</div>
<div class="border rounded">
@ChildContent
</div>
</div>
@code {
[Parameter] public required string Title { get; set; }
[Parameter] public int? Count { get; set; }
[Parameter] public RenderFragment? Extra { get; set; }
[Parameter] public required RenderFragment ChildContent { get; set; }
}
+2 -2
View File
@@ -151,7 +151,7 @@
job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting
);
if (shouldFetch) {
var (children, since) = await FetchChildren(job.Id, null);
var (children, since) = await FetchChildren!(job.Id, null);
_childrenCache[job.Id] = children;
_sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
}
@@ -200,7 +200,7 @@
// Level 0: poll children for all active roots (segment progress update)
if (Level == 0 && FetchChildren is not null) {
foreach (var root in Roots) {
foreach (var root in Roots!) {
if (root.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting) {
hasActive = true;
visited.Add(root.Id);
@@ -4,6 +4,7 @@
@implements IDisposable
@using Butter.Dtos.User
@using Microsoft.IdentityModel.JsonWebTokens
@using System.Reflection
<NavMenu/>
<main class="container-xxl flex-grow-1">
@@ -12,7 +13,7 @@
</div>
</main>
<footer class="container-xxl footer d-none d-sm-flex justify-content-center">
<span class="text-primary m-auto">Footer (duh!)</span>
<span class="text-primary m-auto">MilkyShots - &copy; 2024 - @DateTime.Now.Year | @AppVersion</span>
</footer>
<div id="blazor-error-ui">
@@ -23,6 +24,9 @@
@code {
CancellationTokenSource? _keepaliveCts;
string AppVersion => System.Reflection.Assembly.GetExecutingAssembly()
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
protected override void OnInitialized() {
LoginService.ForceLogout += OnForceLogout;
@@ -43,15 +43,15 @@
</button>
<button class="btn btn-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
<li><a class="dropdown-item" href="@($"/User/{loginService.AuthInfo?.UserId}")"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(AdminOrCurator) {
<li><a class="dropdown-item" @onclick="OnStatsClick"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
<li><a class="dropdown-item" href="/stats"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
}
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnMaintenanceClick"><i class="bi bi-tools"></i> Maintenance</a></li>
<li><a class="dropdown-item" @onclick="OnUsersClick"><i class="bi bi-people-fill"></i> Users</a></li>
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
<li><a class="dropdown-item" href="/maintenance"><i class="bi bi-tools"></i> Maintenance</a></li>
<li><a class="dropdown-item" href="/users"><i class="bi bi-people-fill"></i> Users</a></li>
<li><a class="dropdown-item" href="/settings"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" href="/jobs"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
</ul>
@@ -78,15 +78,6 @@
void OnRegisterClick() => navigation.NavigateTo("/register");
void OnSettingsClick() => navigation.NavigateTo("/settings");
void OnJobsClick() => navigation.NavigateTo("/jobs");
void OnMaintenanceClick() => navigation.NavigateTo("/maintenance");
void OnStatsClick() => navigation.NavigateTo("/stats");
void OnUsersClick() => navigation.NavigateTo("/users");
void OnProfileClick() => navigation.NavigateTo($"/User/{loginService.AuthInfo?.UserId}", true);
async Task OnLogout() {
@@ -2,14 +2,13 @@
@using Butter.Dtos.Album
@using Butter.Dtos.Asset
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
@inject NavigationManager navigationManager
<PageTitle>@(album?.Name ?? "Album")</PageTitle>
@@ -19,7 +18,7 @@
} else if (album == null) {
<EmptyState Variant="danger" Title="Album not found">
<p>The album you are looking for does not exist or you do not have access to it.</p>
<button class="btn btn-danger" @onclick="GoBack">Back to Albums</button>
<a class="btn btn-danger" href="/albums">Back to Albums</a>
</EmptyState>
} else {
<div class="album-detail">
@@ -37,7 +36,7 @@
}
</div>
<div class="d-flex align-items-center gap-2 mt-1">
@if (!string.IsNullOrEmpty(album.PersonName) && album.Person.HasValue) {
@if (!string.IsNullOrEmpty(album!.PersonName) && album.Person.HasValue) {
<a class="album-cosplayer-link" href="/cosplayer/@album.Person.Value">@album.PersonName</a>
}
<span class="text-muted small">@album.AssetCount assets</span>
@@ -143,7 +142,7 @@
</div>
</div>
@if (album.Images.Count == 0) {
@if (album!.Images.Count == 0) {
<EmptyState Variant="info" Title="No assets in this album" />
} else {
<div class="@("album-assets-grid " + viewMode)">
@@ -157,7 +156,7 @@
style="aspect-ratio:@GetAspectRatio(preview)"
@onclick="() => HandleTileClick(index, assetId)">
@if (preview == null || preview.HasThumbnail) {
<img src="@ThumbUrl(assetId)"
<img src="@mediaService.ThumbUrl(assetId)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
@@ -249,11 +248,8 @@
HashSet<Guid> selectedImageIds = [];
Guid? selectedAssetId;
int selectedIndex = -1;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string viewMode = "masonry";
bool _masonrySetup;
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
@@ -292,21 +288,21 @@
protected override async Task OnParametersSetAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadAlbum();
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (album != null && album.Images.Count > 0 && viewMode == "masonry") {
await jsRuntime.InvokeVoidAsync("masonryLayout.apply", ".album-assets-grid.masonry");
if (viewMode == "masonry" && !_masonrySetup && album != null && album.Images.Count > 0) {
_masonrySetup = true;
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-assets-grid.masonry");
} else if (viewMode != "masonry" && _masonrySetup) {
_masonrySetup = false;
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
}
}
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
}
async Task LoadAlbum() {
@@ -499,10 +495,8 @@
return "4 / 3";
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync()
{
await jsRuntime.InvokeVoidAsync("masonryLayout.dispose", ".album-assets-grid.masonry");
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
}
}
@@ -70,6 +70,10 @@
transition: filter 0.2s;
}
.album-assets-grid.masonry .masonry-tile {
aspect-ratio: 3 / 4;
}
.album-assets-grid.grid .masonry-tile {
position: relative;
}
@@ -3,13 +3,12 @@
@using Butter.Dtos.Asset
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@using System.Globalization
@inject PersonService personService
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
@inject NavigationManager navigationManager
<PageTitle>@(person?.Name ?? "Cosplayer")</PageTitle>
@@ -40,7 +39,7 @@
var cy = person.ProfileCropY ?? 50;
var z = person.ProfileCropZoom ?? 0.6f;
<div class="cosplayer-avatar-clip">
<img src="@ThumbUrl(person.ProfileAssetId.Value)" alt=""
<img src="@mediaService.ThumbUrl(person.ProfileAssetId.Value)" alt=""
class="cosplayer-avatar-img"
style="width: calc(100% / @z.ToString(CultureInfo.InvariantCulture)); transform: translate(calc(-@cx.ToString(CultureInfo.InvariantCulture) * 1%), calc(-@cy.ToString(CultureInfo.InvariantCulture) * 1%))" />
</div>
@@ -60,7 +59,7 @@
<span class="text-muted small">@pub public, @prot protected, @priv private</span>
}
<span class="mx-1">·</span>
<span><i class="bi bi-image"></i> @person.TotalAssets assets</span>
<span><i class="bi bi-image"></i> @person!.TotalAssets assets</span>
@if (person.MaintainerUserIds?.Count > 0)
{
<span class="mx-1">·</span>
@@ -222,7 +221,8 @@
SelectedIds="@selectedAlbumIds"
OnToggleSelection="@ToggleSelectAlbum"
LoadVersion="@albumLoadVersion"
FetchAlbums="@FetchPersonAlbums" />
FetchAlbums="@FetchPersonAlbums"
OnDataLoaded="@OnGridAlbumsLoaded" />
</div>
}
@@ -316,8 +316,6 @@
Func<Task>? _pendingDeleteAction;
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string viewMode = "masonry";
bool showAlbumAssigner;
List<AlbumPreviewDto>? unassignedAlbums;
@@ -340,8 +338,6 @@
int albumLoadVersion;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
List<Guid>? allAssetIds => person?.Albums?
.SelectMany(a => Enumerable.Repeat(a.CoverAssetId ?? Guid.Empty, 1))
.Where(id => id != Guid.Empty)
@@ -349,32 +345,30 @@
.ToList();
protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
await LoadPerson();
}
protected override void OnInitialized() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
};
}
async Task LoadPerson() {
isLoading = true;
person = await personService.GetByIdAsync(Id, 0, 30);
bannerCoverUrls = person?.Albums?
.Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next())
.Take(3)
.Select(a => ThumbUrl(a.CoverAssetId!.Value))
.ToList() ?? [];
isLoading = false;
}
async Task OnGridAlbumsLoaded(List<AlbumPreviewDto> albums) {
bannerCoverUrls = albums
.Where(a => a.CoverAssetId.HasValue)
.OrderBy(_ => Random.Shared.Next())
.Take(3)
.Select(a => mediaService.ThumbUrl(a.CoverAssetId!.Value))
.ToList();
}
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
var p = await personService.GetByIdAsync(Id, page, pageSize, search, sortBy, sortAsc);
return p?.Albums;
return await albumService.GetAlbumsAsync(page, pageSize, search, sortBy, sortAsc, personOwnerId: Id);
}
void OpenEditForm() => showForm = true;
@@ -519,6 +513,4 @@
if (_pendingDeleteAction != null)
await _pendingDeleteAction();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
}
+8 -18
View File
@@ -1,13 +1,12 @@
@page "/"
@using Butter.Dtos.Asset
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IAsyncDisposable
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject IOptions<ServiceOptions> serviceOptions
@inject IJSRuntime jsRuntime
@inject MediaService mediaService
<PageTitle>Home</PageTitle>
@@ -36,7 +35,7 @@
style="aspect-ratio:@GetAspectRatio(asset)"
@onclick="() => OpenPreview(asset, capturedIndex)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)"
<img src="@mediaService.ThumbUrl(asset.Id)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
@@ -136,18 +135,10 @@
ElementReference topSentinelRef;
DotNetObjectReference<Home>? dotNetRef;
Guid _randomSeed;
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() {
loginService.LoggedUserChanged += (_, _) => StateHasChanged();
loginService.AuthInfoChanged += (_, info) => {
token = info?.Token ?? string.Empty;
StateHasChanged();
};
token = loginService.AuthInfo?.Token ?? string.Empty;
loginService.AuthInfoChanged += (_, _) => StateHasChanged();
await LoadFirstPage();
}
@@ -171,7 +162,7 @@
async Task LoadFirstPage() {
_randomSeed = Guid.NewGuid();
isLoading = true;
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30);
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30, includeCount: false);
if (items?.Count > 0) {
allAssetPages.Add(items);
RebuildFlatList();
@@ -190,7 +181,7 @@
isLoadingMore = true;
StateHasChanged();
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30);
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30, includeCount: false);
if (items?.Count > 0) {
allAssetPages.Add(items);
loadedMaxPage = currentPage;
@@ -221,7 +212,7 @@
return;
}
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30);
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30, includeCount: false);
if (items?.Count > 0) {
allAssetPages.Insert(0, items);
loadedMinPage = pageToLoad;
@@ -257,6 +248,7 @@
}
void NavigatePreview(int direction) {
if (selectedAsset == null) return;
var newIndex = selectedIndex + direction;
if (newIndex < 0 || newIndex >= flatList.Count) return;
var newAsset = flatList[newIndex];
@@ -277,8 +269,6 @@
return "4 / 3";
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
public async ValueTask DisposeAsync() {
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
if (dotNetRef != null) {
+107 -18
View File
@@ -1,8 +1,10 @@
@page "/Jobs"
@implements IDisposable
@using Butter.Dtos.Jobs
@using Butter.Dtos.Folder
@using Butter.Types
@inject JobsService JobsService
@inject FoldersService FoldersService
@inject LoginService LoginService
@inject NavigationManager NavigationManager
@@ -72,13 +74,12 @@
<div class="d-none d-sm-flex flex-wrap gap-2 ms-auto">
@* Desktop launch buttons *@
<button class="btn btn-job-scan btn-sm" @onclick="LaunchScan" title="Scan Files"><i class="bi bi-folder2-open"></i></button>
<button class="btn btn-job-scan btn-sm" @onclick="() => OpenLaunchModal(EJobType.FileSystemScan)" title="Scan Files"><i class="bi bi-folder2-open"></i></button>
<button class="btn btn-job-meta btn-sm" @onclick="LaunchMeta" title="Extract Metadata"><i class="bi bi-file-earmark-break"></i></button>
<button class="btn btn-job-thumb btn-sm" @onclick="LaunchThumb" title="Generate Thumbnails"><i class="bi bi-pip"></i></button>
<button class="btn btn-job-preview btn-sm" @onclick="LaunchPreview" title="Generate Previews"><i class="bi bi-images"></i></button>
<button class="btn btn-job-phash btn-sm" @onclick="LaunchPHash" title="Calculate pHash"><i class="bi bi-hash"></i></button>
<button class="btn btn-job-persons btn-sm" @onclick="LaunchCreatePersons" title="Create Persons"><i class="bi bi-person-plus"></i></button>
<button class="btn btn-job-create-albums btn-sm" @onclick="LaunchCreateAlbums" title="Create Albums"><i class="bi bi-collection"></i></button>
<button class="btn btn-job-create-albums btn-sm" @onclick="() => OpenLaunchModal(EJobType.CreateAlbums)" title="Create Persons & Albums"><i class="bi bi-collection"></i></button>
<button class="btn btn-job-integrity btn-sm" @onclick="LaunchIntegrityCheck" title="Integrity Check"><i class="bi bi-file-earmark-check"></i></button>
</div>
<div class="d-sm-none dropdown">
@@ -86,13 +87,12 @@
<i class="bi bi-play-circle me-1"></i>Launch
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" @onclick="LaunchScan"><i class="bi bi-folder2-open me-1" style="color: var(--job-scan)"></i>Scan Files</a></li>
<li><a class="dropdown-item" @onclick="() => OpenLaunchModal(EJobType.FileSystemScan)"><i class="bi bi-folder2-open me-1" style="color: var(--job-scan)"></i>Scan Files</a></li>
<li><a class="dropdown-item" @onclick="LaunchMeta"><i class="bi bi-file-earmark-break me-1" style="color: var(--job-meta)"></i>Extract Metadata</a></li>
<li><a class="dropdown-item" @onclick="LaunchThumb"><i class="bi bi-pip me-1" style="color: var(--job-thumb)"></i>Generate Thumbnails</a></li>
<li><a class="dropdown-item" @onclick="LaunchPreview"><i class="bi bi-images me-1" style="color: var(--job-preview)"></i>Generate Previews</a></li>
<li><a class="dropdown-item" @onclick="LaunchPHash"><i class="bi bi-hash me-1" style="color: var(--job-phash)"></i>Calculate pHash</a></li>
<li><a class="dropdown-item" @onclick="LaunchCreatePersons"><i class="bi bi-person-plus me-1" style="color: var(--job-persons)"></i>Create Persons</a></li>
<li><a class="dropdown-item" @onclick="LaunchCreateAlbums"><i class="bi bi-collection me-1" style="color: var(--job-create-albums)"></i>Create Albums</a></li>
<li><a class="dropdown-item" @onclick="() => OpenLaunchModal(EJobType.CreateAlbums)"><i class="bi bi-collection me-1" style="color: var(--job-create-albums)"></i>Create Persons & Albums</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" @onclick="LaunchIntegrityCheck"><i class="bi bi-file-earmark-check me-1" style="color: var(--job-integrity)"></i>Integrity Check</a></li>
</ul>
@@ -131,10 +131,10 @@
<JobTree Jobs="_filteredSorted" FetchChildren="FetchChildren" EmptyText="" PollInterval="pollInterval" />
@if (pastJobTotal > pastJobPageSize) {
<div class="d-flex align-items-center justify-content-center gap-2 py-1 border-top">
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 1)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage <= 0)" @onclick="() => LoadPastJobs(pastJobPage - 1)">
<i class="bi bi-chevron-left"></i> Prev
</button>
<span class="small text-muted">@pastJobPage / @pastJobTotalPages</span>
<span class="small text-muted">@(pastJobPage + 1) / @pastJobTotalPages</span>
<button class="btn btn-sm btn-secondary" disabled="@(pastJobPage >= pastJobTotalPages)" @onclick="() => LoadPastJobs(pastJobPage + 1)">
Next <i class="bi bi-chevron-right"></i>
</button>
@@ -150,12 +150,48 @@
</div>
}
<ModalFrame Show="_launchModalOpen" Title="_launchModalTitle" Size="lg" OnClose="() => _launchModalOpen = false">
<Body>
<div class="mb-3">
<label class="form-check d-flex align-items-start gap-2">
<input class="form-check-input mt-1" type="checkbox" @bind="_launchDeep" />
<span>
Deep scan
<span class="d-block small text-muted">Ignore previously recorded scan state and reprocess all assets.</span>
</span>
</label>
</div>
<div class="small text-muted mb-1">Folders (none selected = all active folders):</div>
@if (_launchFolders.Count == 0) {
<div class="text-muted small">No folders configured.</div>
} else {
<div class="job-folder-list border rounded p-2" style="max-height: 320px; overflow-y: auto;">
@foreach (var f in _launchFolders) {
<label class="form-check d-flex align-items-center gap-2 mb-1">
<input class="form-check-input" type="checkbox"
checked="@(_selectedFolderIds.Contains(f.Id))"
@onchange="(e) => ToggleFolder(f.Id, (bool)e.Value!)" />
<span class="text-truncate" style="max-width: 90%;">@f.BasePath</span>
</label>
}
</div>
}
</Body>
<Footer>
<button class="btn btn-secondary btn-sm" @onclick="() => _launchModalOpen = false">Cancel</button>
<button class="btn btn-primary btn-sm" disabled="@_launchBusy" @onclick="LaunchFromModal">
@if (_launchBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Launch
</button>
</Footer>
</ModalFrame>
@code {
CancellationTokenSource? _pollCts;
List<JobStatusDto> activeRoots = [];
List<JobStatusDto> pastJobRoots = [];
int pastJobPage = 1;
int pastJobPage = 0;
int pastJobPageSize = 15;
int pastJobTotal = 0;
int pastJobTotalPages => Math.Max(1, (int)Math.Ceiling((double)pastJobTotal / pastJobPageSize));
@@ -213,11 +249,11 @@
return;
}
LoginService.LoggedUserChanged += async (_, _) => {
await RefreshActiveRoots();
await LoadPastJobs(1);
await RefreshActiveRoots();
await LoadPastJobs(0);
};
await RefreshActiveRoots();
await LoadPastJobs(1);
await LoadPastJobs(0);
StartPolling();
}
@@ -232,7 +268,7 @@
StartPolling();
} else {
StopPolling();
await LoadPastJobs(1);
await LoadPastJobs(0);
}
}
@@ -305,12 +341,68 @@
// ── Job launch helpers ──
bool _launchModalOpen;
bool _launchDeep;
bool _launchBusy;
EJobType _launchJobType;
List<Guid> _selectedFolderIds = [];
List<FolderFullDto> _launchFolders = [];
string _launchModalTitle => _launchJobType switch {
EJobType.FileSystemScan => "Scan Files",
EJobType.CreateAlbums => "Create Persons & Albums",
_ => "Launch Job"
};
async Task OpenLaunchModal(EJobType jobType) {
_launchJobType = jobType;
_launchDeep = false;
_selectedFolderIds = [];
if (_launchFolders.Count == 0) {
try {
_launchFolders = await FoldersService.GetAllFolders() ?? new List<FolderFullDto>();
} catch {
_launchFolders = new List<FolderFullDto>();
}
}
_launchModalOpen = true;
}
void ToggleFolder(Guid id, bool selected) {
if (selected) {
if (!_selectedFolderIds.Contains(id)) _selectedFolderIds.Add(id);
} else {
_selectedFolderIds.Remove(id);
}
}
async Task LaunchFromModal() {
if (_launchBusy) return;
_launchBusy = true;
try {
var parameters = new List<string>();
if (_launchDeep) parameters.Add("deep");
parameters.AddRange(_selectedFolderIds.Select(id => $"folder={id}"));
await JobsService.StartJob(_launchJobType, parameters);
errorMessage = null;
await RefreshActiveRoots();
if (pastJobPage != 0) pastJobPage = 0;
await LoadPastJobs(pastJobPage);
_launchModalOpen = false;
} catch (HttpRequestException ex) {
errorMessage = $"Failed to start {_launchJobType} job: {ex.Message}";
} finally {
_launchBusy = false;
}
}
async Task LaunchJob(EJobType jobType) {
try {
await JobsService.StartJob(jobType);
errorMessage = null;
await RefreshActiveRoots();
if (pastJobPage != 1) pastJobPage = 1;
if (pastJobPage != 0) pastJobPage = 0;
await LoadPastJobs(pastJobPage);
} catch (HttpRequestException ex) when ((int?)ex.StatusCode == 501) {
errorMessage = $"{jobType} is not yet implemented on the server.";
@@ -320,13 +412,10 @@
StateHasChanged();
}
Task LaunchScan() => LaunchJob(EJobType.FileSystemScan);
Task LaunchMeta() => LaunchJob(EJobType.MetadataExtraction);
Task LaunchThumb() => LaunchJob(EJobType.ThumbnailGeneration);
Task LaunchPreview() => LaunchJob(EJobType.PreviewGeneration);
Task LaunchPHash() => LaunchJob(EJobType.PHashGeneration);
Task LaunchCreatePersons() => LaunchJob(EJobType.CreatePersons);
Task LaunchCreateAlbums() => LaunchJob(EJobType.CreateAlbums);
Task LaunchIntegrityCheck() => LaunchJob(EJobType.IntegrityCheck);
// ── Helpers ──
@@ -361,7 +450,7 @@
EJobType.PHashGeneration => "Calculate pHash",
EJobType.IntegrityCheck => "Integrity Check",
EJobType.CreatePersons => "Create Persons",
EJobType.CreateAlbums => "Create Albums",
EJobType.CreateAlbums => "Create Persons & Albums",
_ => t.ToString()
};
@@ -22,7 +22,7 @@
<div class="align-self-start m-2"><a class="link-primary" href="#">Forgot password?</a></div>
<div class="d-flex flex-row justify-content-around w-100">
<button class="btn btn-primary m-2" type="button" id="login-button" @onclick="Login_OnClick">Login</button>
<button class="btn btn-primary m-2" type="button" id="register-button" @onclick="Register_OnClick">Register</button>
<a class="btn btn-primary m-2" id="register-button" href="/register">Register</a>
</div>
</div>
</div>
@@ -47,8 +47,6 @@
await Login_OnClick();
}
void Register_OnClick() => navigation.NavigateTo("register");
async Task Login_OnClick() {
loginError = null;
var result = await loginService.Login(username, password);
@@ -1,3 +1,4 @@
@using System.Net
@page "/Register"
@inject LoginService loginService
@@ -5,30 +6,38 @@
<PageTitle>Register</PageTitle>
<div class="d-flex flex-column mx-sm-auto mx-3">
@if (!string.IsNullOrEmpty(_error)) {
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
<div class="text-danger-emphasis m-2">@_error</div>
</div>
}
<form class="d-flex flex-column align-items-center">
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
@bind="_username" @bind:event="oninput"/>
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
@bind="_email" @bind:event="oninput"/>
@if (_checkingStatus) {
<LoadSpinner/>
} else if (_registrationDisabled) {
<EmptyState Variant="warning" Title="Registration Closed">
<p>New user registration is currently disabled on this server.</p>
<a href="/login" class="btn btn-primary">Go to Login</a>
</EmptyState>
} else {
<div class="d-flex flex-column mx-sm-auto mx-3">
@if (!string.IsNullOrEmpty(_error)) {
<div class="m-2 p-1 border-danger border-2 rounded-3 bg-danger-subtle">
<div class="text-danger-emphasis m-2">@_error</div>
</div>
}
<form class="d-flex flex-column align-items-center">
<input type="text" class="form-control my-1" placeholder="Username" required="required" id="username"
@bind="_username" @bind:event="oninput"/>
<input type="email" class="form-control my-1" placeholder="Email" required="required" id="email"
@bind="_email" @bind:event="oninput"/>
<PasswordField @ref="_passwordField"
@bind-Password="_password"
@bind-ConfirmPassword="_confirmPassword" />
<PasswordField @ref="_passwordField"
@bind-Password="_password"
@bind-ConfirmPassword="_confirmPassword" />
<div class="d-flex flex-row justify-content-around w-100 mt-2">
<button class="btn btn-primary m-2" type="button" id="register-button"
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
<button class="btn btn-primary m-2" type="button" id="login-button"
@onclick="Login_OnClick">Login</button>
</div>
</form>
</div>
<div class="d-flex flex-row justify-content-around w-100 mt-2">
<button class="btn btn-primary m-2" type="button" id="register-button"
disabled="@(!CanSubmit)" @onclick="Register_OnClick">Register</button>
<a class="btn btn-primary m-2" id="login-button" href="/login">Login</a>
</div>
</form>
</div>
}
@code {
PasswordField? _passwordField;
@@ -37,18 +46,27 @@
string _password = string.Empty;
string _confirmPassword = string.Empty;
string _error = string.Empty;
bool _checkingStatus = true;
bool _registrationDisabled;
bool CanSubmit => _passwordField?.IsValid == true
&& !string.IsNullOrWhiteSpace(_username)
&& !string.IsNullOrWhiteSpace(_email);
void Login_OnClick() => navigation.NavigateTo("login");
protected override async Task OnInitializedAsync() {
_registrationDisabled = !await loginService.IsRegistrationEnabledAsync();
_checkingStatus = false;
}
async Task Register_OnClick() {
_error = string.Empty;
var ok = await loginService.Register(_username, _email, _password);
if (ok) {
var status = await loginService.Register(_username, _email, _password);
if (status == HttpStatusCode.OK) {
navigation.NavigateTo("login");
} else if (status == HttpStatusCode.Forbidden) {
_error = "Registration is currently disabled.";
} else if (status == HttpStatusCode.Conflict) {
_error = "Username or email already taken.";
} else {
_error = "Registration failed. Please try again.";
}
+198 -130
View File
@@ -14,162 +14,181 @@
<div class="alert alert-danger mt-3">Failed to load statistics. You may not have the required permissions.</div>
} else {
<div class="container-fluid p-3">
<h3 class="mb-4"><i class="bi bi-bar-chart-fill"></i> Statistics</h3>
<div class="d-flex justify-content-between align-items-center mb-4">
<h3 class="mb-0"><i class="bi bi-bar-chart-fill"></i> Statistics</h3>
<div class="d-flex align-items-center gap-2">
@if (_lastUpdated is not null) {
<small class="text-muted">Updated @_lastUpdated.Value.ToLocalTime().ToString("HH:mm:ss")</small>
}
<button class="btn btn-sm btn-outline-primary" disabled="@_refreshing" @onclick="Refresh">
@if (_refreshing) {
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<span>Refreshing…</span>
} else {
<i class="bi bi-arrow-clockwise me-1"></i>
<span>Refresh</span>
}
</button>
</div>
</div>
@* ---- Top-level counts ---- *@
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-7 g-3 mb-4">
<div class="col"><div class="card text-bg-primary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAssets.ToString("N0")</h5><small>Assets</small></div></div></div>
<div class="col"><div class="card text-bg-success"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalUsers.ToString("N0")</h5><small>Users</small></div></div></div>
<div class="col"><div class="card text-bg-info"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalAlbums.ToString("N0")</h5><small>Albums</small></div></div></div>
<div class="col"><div class="card text-bg-warning"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalTags.ToString("N0")</h5><small>Tags</small></div></div></div>
<div class="col"><div class="card text-bg-secondary"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalPeople.ToString("N0")</h5><small>People</small></div></div></div>
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFaces.ToString("N0")</h5><small>Faces</small></div></div></div>
<div class="col"><div class="card text-bg-dark"><div class="card-body text-center py-3"><h5 class="card-title">@_stats.TotalFolders.ToString("N0")</h5><small>Folders</small></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="primary" Value="@_stats.TotalAssets.ToString("N0")" Label="Assets"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="success" Value="@_stats.TotalUsers.ToString("N0")" Label="Users"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="info" Value="@_stats.TotalAlbums.ToString("N0")" Label="Albums"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="warning" Value="@_stats.TotalTags.ToString("N0")" Label="Tags"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="secondary" Value="@_stats.TotalPeople.ToString("N0")" Label="People"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFaces.ToString("N0")" Label="Faces"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Solid" Variant="dark" Value="@_stats.TotalFolders.ToString("N0")" Label="Folders"/></div>
</div>
@* ---- Recent activity ---- *@
<div class="row row-cols-1 row-cols-md-3 g-3 mb-4">
<div class="col"><div class="card border-primary"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-week"></i> Assets (last 7 days)</h6><h4 class="text-primary">@_stats.AssetsAddedLast7Days.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-calendar-month"></i> Assets (last 30 days)</h6><h4 class="text-success">@_stats.AssetsAddedLast30Days.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-info"><div class="card-body"><h6 class="card-title"><i class="bi bi-person-plus"></i> New Users (last 30 days)</h6><h4 class="text-info">@_stats.UsersRegisteredLast30Days.ToString("N0")</h4></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="primary" Icon="bi bi-calendar-week" Label="Assets (last 7 days)" Value="@_stats.AssetsAddedLast7Days.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-calendar-month" Label="Assets (last 30 days)" Value="@_stats.AssetsAddedLast30Days.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="info" Icon="bi bi-person-plus" Label="New Users (last 30 days)" Value="@_stats.UsersRegisteredLast30Days.ToString("N0")"/></div>
</div>
@* ---- Data completeness indicators ---- *@
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-4 g-3 mb-4">
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingMetadata.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingMetadata)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Metadata</small></div></div></div>
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingThumbnail.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingThumbnail)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Thumbnail</small><br/><small class="text-danger">@_stats.AssetsMissingThumbnailStale.ToString("N0") stale</small></div></div></div>
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPreviews.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPreviews)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing Previews</small><br/><small class="text-danger">@_stats.AssetsMissingPreviewsStale.ToString("N0") stale</small></div></div></div>
<div class="col"><div class="card border-warning h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsMissingPhash.ToString("N0")</h5><small class="text-muted">@PercentOfTotal(_stats.AssetsMissingPhash)</small><br/><small class="text-warning"><i class="bi bi-exclamation-triangle"></i> Missing pHash</small></div></div></div>
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoPerson.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Person</small></div></div></div>
<div class="col"><div class="card border-info h-100"><div class="card-body text-center py-3"><h5>@_stats.AssetsWithNoAlbum.ToString("N0")</h5><small class="text-info"><i class="bi bi-info-circle"></i> No Album</small></div></div></div>
<div class="col"><div class="card border-danger h-100"><div class="card-body text-center py-3"><h5>@_stats.AlbumsMissingCover.ToString("N0")</h5><small class="text-danger"><i class="bi bi-image"></i> Albums Missing Cover</small></div></div></div>
<div class="col"><div class="card border-secondary h-100"><div class="card-body text-center py-3"><h5>@_stats.CosplayersMissingProfile.ToString("N0")</h5><small class="text-muted"><i class="bi bi-person-badge"></i> Cosplayers Missing Profile</small></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingMetadata.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingMetadata, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Metadata"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingThumbnail.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingThumbnail, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Thumbnail" SubLabel="@($"{_stats.AssetsMissingThumbnailStale.ToString("N0")} stale")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPreviews.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPreviews, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing Previews" SubLabel="@($"{_stats.AssetsMissingPreviewsStale.ToString("N0")} stale")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="warning" EqualHeight="true" Value="@_stats.AssetsMissingPhash.ToString("N0")" Percent="@Formatting.PercentOfTotal(_stats.AssetsMissingPhash, _stats.TotalAssets)" Icon="bi bi-exclamation-triangle" Label="Missing pHash"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoPerson.ToString("N0")" Icon="bi bi-info-circle" Label="No Person"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="info" EqualHeight="true" Value="@_stats.AssetsWithNoAlbum.ToString("N0")" Icon="bi bi-info-circle" Label="No Album"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="danger" EqualHeight="true" Value="@_stats.AlbumsMissingCover.ToString("N0")" Icon="bi bi-image" Label="Albums Missing Cover"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.Outline" Variant="secondary" EqualHeight="true" Value="@_stats.CosplayersMissingProfile.ToString("N0")" Icon="bi bi-person-badge" Label="Cosplayers Missing Profile"/></div>
</div>
@* ---- Duplicate / Orphan / Visibility ---- *@
<div class="row row-cols-1 row-cols-md-4 g-3 mb-4">
<div class="col"><div class="card border-secondary"><div class="card-body"><h6 class="card-title"><i class="bi bi-folder-x"></i> Orphan Assets</h6><h4 class="text-secondary">@_stats.OrphanAssets.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-success"><div class="card-body"><h6 class="card-title"><i class="bi bi-globe2"></i> Public Assets</h6><h4 class="text-success">@_stats.PublicAssets.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-warning"><div class="card-body"><h6 class="card-title"><i class="bi bi-shield-lock"></i> Protected Assets</h6><h4 class="text-warning">@_stats.ProtectedAssets.ToString("N0")</h4></div></div></div>
<div class="col"><div class="card border-danger"><div class="card-body"><h6 class="card-title"><i class="bi bi-lock"></i> Private Assets</h6><h4 class="text-danger">@_stats.PrivateAssets.ToString("N0")</h4></div></div></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="secondary" Icon="bi bi-folder-x" Label="Orphan Assets" Value="@_stats.OrphanAssets.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="success" Icon="bi bi-globe2" Label="Public Assets" Value="@_stats.PublicAssets.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="warning" Icon="bi bi-shield-lock" Label="Protected Assets" Value="@_stats.ProtectedAssets.ToString("N0")"/></div>
<div class="col"><StatCountCard Layout="StatCountCard.StatCardLayout.IconTitle" Variant="danger" Icon="bi bi-lock" Label="Private Assets" Value="@_stats.PrivateAssets.ToString("N0")"/></div>
</div>
<div class="row g-3">
@* ---- Assets by type + Storage side by side ---- *@
<div class="col-12 col-lg-6">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-collection"></i> Assets by Type</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Type</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Type</th><th class="text-end">Size</th></tr></thead>
<tbody>
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@FormatBytes(kv.Value)</td></tr>
}
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@FormatBytes(_stats.TotalStorageBytes)</td></tr>
</tbody>
</table>
</div>
</div>
<StatTable Title="Assets by Type" Icon="bi bi-collection" MarginBottom="true">
<HeaderRow><th>Type</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var kv in _stats.AssetsByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
<StatTable Title="Storage by Type" Icon="bi bi-hdd-stack">
<HeaderRow><th>Type</th><th class="text-end">Size</th></HeaderRow>
<ChildContent>
@foreach (var kv in _stats.StorageByType.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@Formatting.FormatBytes(kv.Value)</td></tr>
}
</ChildContent>
<FooterRow>
<tr class="table-active fw-bold"><td>Total</td><td class="text-end">@Formatting.FormatBytes(_stats.TotalStorageBytes)</td></tr>
</FooterRow>
</StatTable>
</div>
@* ---- Users by role + File format breakdown ---- *@
<div class="col-12 col-lg-6">
<div class="card mb-3">
<div class="card-header"><i class="bi bi-people"></i> Users by Role</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Role</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>MIME Type</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<StatTable Title="Users by Role" Icon="bi bi-people" MarginBottom="true">
<HeaderRow><th>Role</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var kv in _stats.UsersByAccessLevel.OrderByDescending(x => x.Value)) {
<tr><td>@kv.Key</td><td class="text-end">@kv.Value.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
<StatTable Title="File Formats" Icon="bi bi-file-earmark-code">
<HeaderRow><th>MIME Type</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var fmt in _stats.FileFormatBreakdown.Take(20)) {
<tr><td><code>@fmt.MimeType</code></td><td class="text-end">@fmt.Count.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
</div>
@* ---- Resolution distribution ---- *@
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Bucket</th><th class="text-end">Count</th></tr></thead>
<tbody>
@foreach (var bucket in _stats.ResolutionDistribution) {
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
}
</tbody>
</table>
</div>
</div>
<StatTable Title="Resolution Distribution" Icon="bi bi-bounding-box-circles">
<HeaderRow><th>Bucket</th><th class="text-end">Count</th></HeaderRow>
<ChildContent>
@foreach (var bucket in _stats.ResolutionDistribution) {
<tr><td>@bucket.Label</td><td class="text-end">@bucket.Count.ToString("N0")</td></tr>
}
</ChildContent>
</StatTable>
</div>
@* ---- Top tags ---- *@
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header"><i class="bi bi-tags"></i> Top Tags</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Tag</th><th class="text-end">Assets</th></tr></thead>
<tbody>
@foreach (var tag in _stats.TopTags) {
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
}
@if (!_stats.TopTags.Any()) {
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
}
</tbody>
</table>
</div>
</div>
<StatTable Title="Top Tags" Icon="bi bi-tags">
<HeaderRow><th>Tag</th><th class="text-end">Assets</th></HeaderRow>
<ChildContent>
@foreach (var tag in _stats.TopTags) {
<tr><td>@tag.Name</td><td class="text-end">@tag.AssetCount.ToString("N0")</td></tr>
}
@if (!_stats.TopTags.Any()) {
<tr><td colspan="2" class="text-muted text-center">No tags yet.</td></tr>
}
</ChildContent>
</StatTable>
</div>
@* ---- Monthly growth ---- *@
<div class="col-12">
<div class="card">
<StatTable Title="Monthly Growth (last 12 months)" Icon="bi bi-graph-up-arrow">
<HeaderRow><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></HeaderRow>
<ChildContent>
@foreach (var m in _stats.MonthlyGrowth) {
<tr>
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
<td class="text-end">@m.NewAssets.ToString("N0")</td>
<td class="text-end">@m.NewUsers.ToString("N0")</td>
</tr>
}
</ChildContent>
</StatTable>
</div>
</div>
@* ---- Charts ---- *@
<div class="row g-3 mt-1">
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-hdd-stack"></i> Storage by Type</div>
<div class="card-body">
<StatChart Type="bar" ChartId="chart-storage" Labels="@StorageChartLabels" Datasets="@StorageChartDatasets" FormatYAsBytes="true"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-file-earmark-code"></i> File Formats</div>
<div class="card-body">
<StatChart Type="doughnut" ChartId="chart-formats" Labels="@FormatChartLabels" Datasets="@FormatChartDatasets"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-bounding-box-circles"></i> Resolution Distribution</div>
<div class="card-body">
<StatChart Type="bar" ChartId="chart-resolution" Labels="@ResolutionChartLabels" Datasets="@ResolutionChartDatasets"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header"><i class="bi bi-graph-up-arrow"></i> Monthly Growth (last 12 months)</div>
<div class="card-body p-0">
<table class="table table-striped table-sm mb-0">
<thead><tr><th>Month</th><th class="text-end">New Assets</th><th class="text-end">New Users</th></tr></thead>
<tbody>
@foreach (var m in _stats.MonthlyGrowth) {
<tr>
<td>@(new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM"))</td>
<td class="text-end">@m.NewAssets.ToString("N0")</td>
<td class="text-end">@m.NewUsers.ToString("N0")</td>
</tr>
}
</tbody>
</table>
<div class="card-body">
<StatChart Type="line" ChartId="chart-growth" Labels="@GrowthChartLabels" Datasets="@GrowthChartDatasets"/>
</div>
</div>
</div>
@@ -180,27 +199,76 @@
@code {
private StatsDto? _stats;
private bool _loading = true;
private bool _refreshing;
private DateTime? _lastUpdated;
private static readonly string[] Palette = [
"#0d6efd", "#198754", "#0dcaf0", "#ffc107", "#fd7e14", "#dc3545", "#6f42c1", "#20c997", "#6610f2", "#d63384"
];
private List<string> StorageChartLabels =>
_stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => x.Key.ToString()).ToList();
private List<StatChart.ChartDataset> StorageChartDatasets => [
new() {
Label = "Storage",
Data = _stats!.StorageByType.OrderByDescending(x => x.Value).Select(x => (double)x.Value).ToList(),
BackgroundColor = Palette.Take(_stats!.StorageByType.Count).ToList()
}
];
private List<string> FormatChartLabels =>
_stats!.FileFormatBreakdown.Take(8).Select(x => x.MimeType).ToList();
private List<StatChart.ChartDataset> FormatChartDatasets => [
new() {
Label = "Assets",
Data = _stats!.FileFormatBreakdown.Take(8).Select(x => (double)x.Count).ToList(),
BackgroundColor = Palette.Take(_stats!.FileFormatBreakdown.Take(8).Count()).ToList()
}
];
private List<string> ResolutionChartLabels =>
_stats!.ResolutionDistribution.Select(x => x.Label).ToList();
private List<StatChart.ChartDataset> ResolutionChartDatasets => [
new() {
Label = "Assets",
Data = _stats!.ResolutionDistribution.Select(x => (double)x.Count).ToList(),
BackgroundColor = Palette.Take(_stats!.ResolutionDistribution.Count).ToList()
}
];
private List<string> GrowthChartLabels =>
_stats!.MonthlyGrowth.Select(m => new DateTime(m.Year, m.Month, 1).ToString("yyyy-MM")).ToList();
private List<StatChart.ChartDataset> GrowthChartDatasets => [
new() { Label = "New Assets", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewAssets).ToList(), BorderColor = "#0d6efd", Fill = false, Tension = 0.2 },
new() { Label = "New Users", Data = _stats!.MonthlyGrowth.Select(m => (double)m.NewUsers).ToList(), BorderColor = "#198754", Fill = false, Tension = 0.2 }
];
protected override async Task OnInitializedAsync() {
await LoadStats();
}
private async Task Refresh() {
if (_refreshing)
return;
_refreshing = true;
StateHasChanged();
await LoadStats();
_refreshing = false;
StateHasChanged();
}
private async Task LoadStats() {
_loading = true;
StateHasChanged();
_stats = await StatsService.GetStatsAsync();
_lastUpdated = DateTime.UtcNow;
_loading = false;
StateHasChanged();
}
private string PercentOfTotal(int count) =>
_stats?.TotalAssets > 0 ? $"{(double)count / _stats.TotalAssets * 100:F1}%" : "0%";
private static string FormatBytes(long bytes) => bytes switch {
>= 1_073_741_824 => $"{bytes / 1_073_741_824.0:F2} GB",
>= 1_048_576 => $"{bytes / 1_048_576.0:F2} MB",
>= 1_024 => $"{bytes / 1_024.0:F2} KB",
_ => $"{bytes} B"
};
}
}
@@ -4,22 +4,19 @@
<div class="progress-stacked" style="height: @Height">
@foreach (var seg in Segments) {
<div class="progress" role="progressbar" style="width: @((seg.Width * 100).ToString(CultureInfo.InvariantCulture))%">
<div class="progress-bar @(SegmentClass(seg.Color))"></div>
<div class="progress-bar @(SegmentClass(seg.Color))" style="@(SegmentStyle(seg.Color))"></div>
</div>
}
</div>
} else {
<div class="progress" role="progressbar" style="height: @Height">
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%">
@if (ShowPercent) { @($"{Value * 100:F0}%") }
</div>
<div class="progress-bar @BgClass" style="width: @((Value * 100).ToString(CultureInfo.InvariantCulture))%"></div>
</div>
}
@code {
[Parameter] public float Value { get; set; }
[Parameter] public string? Color { get; set; }
[Parameter] public bool ShowPercent { get; set; }
[Parameter] public string Size { get; set; } = "sm";
[Parameter] public IReadOnlyList<(float Width, string Color)>? Segments { get; set; }
@@ -32,7 +29,9 @@
string BgClass => ToCssClass(Color ?? "primary");
string SegmentClass(string color) => ToCssClass(color);
string SegmentClass(string color) => color.StartsWith("var(--") ? "" : ToCssClass(color);
string SegmentStyle(string color) => color.StartsWith("var(--") ? $"background-color: {color}" : "";
static string ToCssClass(string name) => name switch {
"primary" => "text-bg-primary",
@@ -124,30 +124,8 @@ public static class RegexHighlighter
return new MarkupString(sb.ToString());
}
/// <summary>
/// Returns the set of CSS class names used by the highlighter for embedding in isolated CSS.
/// </summary>
public static string CssClasses => string.Join(" ",
ClassAnchor, ClassQuantifier, ClassCharClass, ClassEscape,
ClassNamedGroup, ClassGroupRef, ClassOperator, ClassLiteral);
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start)
{
int depth = 1;
int i = start + 1;
while (i < span.Length && depth > 0)
{
if (span[i] == '\\')
{
i += 2;
continue;
}
if (span[i] == '(') depth++;
if (span[i] == ')') depth--;
i++;
}
return Math.Min(i - 1, span.Length - 1);
}
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start) =>
FindMatchingParen(span, start);
private static int FindMatchingParen(ReadOnlySpan<char> span, int start)
{
@@ -2,14 +2,13 @@
@using Butter.Dtos.Asset
@using Butter.Dtos.Person
@using Butter.Types
@using Microsoft.Extensions.Options
@implements IDisposable
@inject AssetService assetService
@inject AlbumService albumService
@inject PersonService personService
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService
@inject MediaService mediaService
@if (Embedded) {
<div class="picker-embedded">
@@ -86,7 +85,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -137,7 +136,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -254,7 +253,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -305,7 +304,7 @@
<div class="picker-tile @(selectedIds.Contains(asset.Id) ? "selected" : "")"
@key="asset.Id" @onclick="() => ToggleSelection(asset.Id)">
@if (asset.HasThumbnail) {
<img src="@ThumbUrl(asset.Id)" loading="lazy" alt=""
<img src="@mediaService.ThumbUrl(asset.Id)" loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none"><i class="bi bi-image"></i></div>
} else {
@@ -429,7 +428,6 @@
string currentPath = "";
HashSet<Guid> selectedIds = [];
string searchQuery = string.Empty;
string token = string.Empty;
int currentPage;
bool isLoading;
bool hasMore = true;
@@ -455,15 +453,12 @@
const int PageSize = 150;
bool isAdminOrCurator => loginService.IsAdminOrCurator;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override async Task OnInitializedAsync() {
if (Embedded) await InitializeAsync();
}
protected override async Task OnParametersSetAsync() {
token = loginService.AuthInfo?.Token ?? string.Empty;
if (!Embedded && Show && !_initializing) {
_initializing = true;
await InitializeAsync();
@@ -613,8 +608,6 @@
_personSearchTimer?.Dispose();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
static string TruncateName(string name, int maxLen = 40) {
if (name.Length <= maxLen) return name;
var lastSlash = name.LastIndexOf('/');
@@ -1,15 +1,14 @@
@using Butter.Dtos.Album
@using Butter.Types
@using Microsoft.Extensions.Options
@inject IOptions<ServiceOptions> serviceOptions
@inject LoginService loginService
@inject LoginService loginService
@inject MediaService mediaService
<div class="@GetCardClass()" @key="Album.Id">
<div class="@GetCardClass()" @key="Album.Id" style="@GetCardStyle()">
<a href="/albums/@Album.Id" class="album-card-overlay" aria-label="View album @Album.Name"
@onclick="HandleOverlayClick" @onclick:preventDefault="SelectionMode"></a>
@if (Album.CoverAssetId.HasValue) {
<img src="@ThumbUrl(Album.CoverAssetId.Value)"
<img src="@mediaService.ThumbUrl(Album.CoverAssetId.Value)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="album-card-fallback" style="display:none">
@@ -51,11 +50,8 @@
[Parameter]
public EventCallback<Guid> OnToggleSelection { get; set; }
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string token => loginService.AuthInfo?.Token ?? string.Empty;
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
[Parameter]
public string ViewMode { get; set; } = "masonry";
string GetCardClass() {
var classes = new List<string> { "album-card" };
@@ -71,6 +67,15 @@
return string.Join(" ", classes);
}
string GetCardStyle() {
if (ViewMode == "masonry") {
var w = Album.CoverWidth ?? 3;
var h = Album.CoverHeight ?? 4;
return $"aspect-ratio:{w}/{h}";
}
return "";
}
async Task HandleOverlayClick() {
if (SelectionMode)
await OnToggleSelection.InvokeAsync(Album.Id);
@@ -7,7 +7,7 @@
@if (isLoading) {
<LoadSpinner/>
} else if (albums.Count == 0) {
} else if (buffer.Count == 0 && !hasMore) {
<EmptyState Variant="warning" Title="No albums available">
@if (loginService.IsLoggedIn) {
<p>There are no albums yet.</p>
@@ -16,20 +16,24 @@
}
</EmptyState>
} else {
@if (isLoadingUp) {
<LoadSpinner/>
}
<div class="@("album-grid " + ViewMode)">
@foreach (var album in albums) {
<AlbumCard Album="album"
@foreach (var album in buffer.FlatList) {
<AlbumCard @key="album.Id"
Album="album"
SelectionMode="SelectionMode"
Selected="SelectedIds.Contains(album.Id)"
OnToggleSelection="ToggleAlbumSelection"/>
OnToggleSelection="ToggleAlbumSelection"
ViewMode="@ViewMode"/>
}
</div>
@if (isLoadingMore) {
<LoadSpinner/>
}
<div @ref="sentinelRef" class="masonry-sentinel"></div>
}
@code {
@@ -42,19 +46,22 @@
[Parameter] public EventCallback<Guid> OnToggleSelection { get; set; }
[Parameter] public int LoadVersion { get; set; }
[Parameter] public Func<int, int, string?, string?, bool, Task<List<AlbumPreviewDto>?>>? FetchAlbums { get; set; }
[Parameter] public EventCallback<List<AlbumPreviewDto>> OnDataLoaded { get; set; }
List<AlbumPreviewDto> albums = [];
int currentPage = 0;
PageBuffer<AlbumPreviewDto> buffer = new(a => a.Id) { MaxPages = 10 };
bool hasMore = true;
bool hasMoreUp => buffer.MinPage > 0;
bool isLoading = true;
bool isLoadingMore;
ElementReference sentinelRef;
bool isLoadingUp;
DotNetObjectReference<AlbumGrid>? dotNetRef;
int? _prevLoadVersion;
string? _prevSearch;
string? _prevSortBy;
bool _prevSortAsc;
string? _prevViewMode;
bool _masonrySetup;
protected override async Task OnParametersSetAsync() {
bool filterChanged = _prevLoadVersion != LoadVersion
@@ -62,6 +69,14 @@
|| _prevSortBy != SortBy
|| _prevSortAsc != SortAsc;
if (_prevViewMode == "masonry" && ViewMode != "masonry") {
_masonrySetup = false;
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
} else if (_prevViewMode != "masonry" && ViewMode == "masonry") {
_masonrySetup = false;
}
_prevViewMode = ViewMode;
_prevLoadVersion = LoadVersion;
_prevSearch = SearchQuery;
_prevSortBy = SortBy;
@@ -72,18 +87,22 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (!isLoading && (albums.Count > 0 || hasMore) && dotNetRef == null) {
if (!_masonrySetup && ViewMode == "masonry" && buffer.Count > 0) {
_masonrySetup = true;
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-grid.masonry");
}
if (!isLoading && (buffer.Count > 0 || hasMore) && dotNetRef == null) {
dotNetRef = DotNetObjectReference.Create(this);
await jsRuntime.InvokeVoidAsync(
"albumObserver.observeBottom", sentinelRef, dotNetRef
"albumObserver.setup", dotNetRef
);
}
}
async Task Reload() {
await DisposeObserver();
albums.Clear();
currentPage = 0;
buffer.Clear();
hasMore = true;
isLoading = true;
StateHasChanged();
@@ -92,11 +111,13 @@
? await FetchAlbums(0, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(0, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums = items;
currentPage = 1;
buffer.AddPage(0, items);
}
hasMore = items?.Count == 30;
isLoading = false;
if (buffer.Count > 0)
await OnDataLoaded.InvokeAsync(buffer.FlatList);
}
[JSInvokable]
@@ -105,18 +126,41 @@
isLoadingMore = true;
StateHasChanged();
var nextPage = buffer.MaxPage + 1;
var items = FetchAlbums != null
? await FetchAlbums(currentPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(currentPage, 30, SearchQuery, SortBy, SortAsc);
? await FetchAlbums(nextPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(nextPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
albums.AddRange(items);
currentPage++;
buffer.AddPage(nextPage, items);
}
hasMore = items?.Count == 30;
isLoadingMore = false;
StateHasChanged();
}
[JSInvokable]
public async Task LoadPreviousAlbums() {
if (isLoadingUp || !hasMoreUp) return;
isLoadingUp = true;
StateHasChanged();
var prevPage = buffer.MinPage - 1;
if (prevPage < 0) {
isLoadingUp = false;
return;
}
var items = FetchAlbums != null
? await FetchAlbums(prevPage, 30, SearchQuery, SortBy, SortAsc)
: await albumService.GetAlbumsAsync(prevPage, 30, SearchQuery, SortBy, SortAsc);
if (items?.Count > 0) {
buffer.PrependPage(prevPage, items);
}
isLoadingUp = false;
StateHasChanged();
}
async Task ToggleAlbumSelection(Guid id) => await OnToggleSelection.InvokeAsync(id);
async Task DisposeObserver() {
@@ -127,15 +171,18 @@
}
}
public async ValueTask DisposeAsync() => await DisposeObserver();
public async ValueTask DisposeAsync() {
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-grid.masonry");
await DisposeObserver();
}
/// <summary>
/// Returns the IDs of all currently loaded albums.
/// </summary>
public List<Guid> GetCurrentAlbumIds() => albums.Select(a => a.Id).ToList();
public List<Guid> GetCurrentAlbumIds() => buffer.FlatList.Select(a => a.Id).ToList();
/// <summary>
/// Returns the previews of all currently loaded albums.
/// </summary>
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. albums];
public List<AlbumPreviewDto> GetCurrentAlbumPreviews() => [.. buffer.FlatList];
}
@@ -3,6 +3,7 @@
column-count: 4;
column-gap: 8px;
padding: 0 8px;
overflow-anchor: none;
}
/* ── Grid ── */
@@ -11,12 +12,14 @@
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
padding: 0 8px;
overflow-anchor: none;
}
/* ── Card in masonry ── */
.album-grid.masonry ::deep .album-card {
break-inside: avoid;
margin-bottom: 8px;
aspect-ratio: 3 / 4;
}
.album-grid.grid ::deep .album-card {
@@ -30,6 +33,7 @@
.masonry-sentinel {
height: 1px;
overflow-anchor: none;
}
@media (max-width: 576px) {
@@ -1,9 +1,6 @@
@using Microsoft.Extensions.Options
@using MilkStream.Client.Services
@using System.Linq
@inject LoginService loginService
@inject IOptions<ServiceOptions> serviceOptions
@inject MediaService mediaService
<ModalFrame Title="Select Cover Image" Show="Show" Size="lg" OnClose="OnCancel">
<Body>
@@ -15,7 +12,7 @@
<div class="picker-tile @(SelectedAssetId == id ? "selected" : "")"
@key="id"
@onclick="() => SelectAsset(id)">
<img src="@ThumbUrl(id)"
<img src="@mediaService.ThumbUrl(id)"
loading="lazy" alt=""
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'" />
<div class="tile-fallback" style="display:none">
@@ -47,15 +44,6 @@
[Parameter] public EventCallback<Guid?> OnSelected { get; set; }
[Parameter] public EventCallback OnClosed { get; set; }
string token = string.Empty;
string apiBase => serviceOptions.Value.BaseUrl.TrimEnd('/');
string TokenParam => string.IsNullOrEmpty(token) ? "" : $"?token={token}";
protected override void OnParametersSet() {
token = loginService.AuthInfo?.Token ?? string.Empty;
}
void SelectAsset(Guid id) {
SelectedAssetId = id;
}
@@ -68,6 +56,4 @@
async Task OnCancel() {
await OnClosed.InvokeAsync();
}
string ThumbUrl(Guid id) => $"{apiBase}/api/media/thumb/{id}{TokenParam}";
}

Some files were not shown because too many files have changed in this diff Show More