479 Commits
Author SHA1 Message Date
REDCODE 0267313306 Merge pull request 'feat: restore soft-deleted assets (#198)' (#200) from feature/restore-softdeleted-assets into develop
Reviewed-on: #200
2026-08-22 11:07:51 +00:00
REDCODE 985178b741 feat(MilkStream.Client): replace trash filter flow with searchable dropdowns 2026-08-22 02:32:54 +02:00
REDCODE 677a3816d2 feat(MilkStream.Client): add album search dropdown component 2026-08-22 02:32:48 +02:00
REDCODE 451c131711 feat(MilkStream.Client): allow restoring deleted users in admin users page 2026-08-22 00:57:45 +02:00
REDCODE 559e7f1a46 feat(MilkStream.Client): add deleted-assets tab to maintenance page 2026-08-22 00:56:25 +02:00
REDCODE 9c5bc6ccf7 feat(MilkStream.Client): pass date-range filters through asset search 2026-08-22 00:53:38 +02:00
REDCODE 4228ab6ca5 feat(MilkStream.Client): restore deleted assets from album edit mode 2026-08-22 00:51:30 +02:00
REDCODE c644717808 feat(MilkStream.Client): add deleted-only listing and asset restore to AssetService 2026-08-22 00:48:38 +02:00
REDCODE 453507a3ed test(Lactose): cover asset restore flows in REST Client suite 2026-08-22 00:47:30 +02:00
REDCODE c95728b097 feat(Lactose): add deleted-only and album/person filters to asset search 2026-08-22 00:45:25 +02:00
REDCODE a8122ebc39 feat(Lactose): support restoring soft-deleted assets via update endpoints 2026-08-22 00:43:31 +02:00
REDCODE 594fe7a977 feat(Butter): replace asset DeletedAt update field with IsDeleted flag 2026-08-22 00:41:36 +02:00
REDCODE 62dc693842 Merge pull request 'Async migration PR 5: Asset repository (final slice)' (#199) from feature/webapi/async-asset into develop
Reviewed-on: #199
2026-08-21 17:45:24 +00:00
REDCODE e00fc5e98c refactor(Lactose): await asset lookups and saves in batch jobs 2026-08-21 18:48:01 +02:00
REDCODE c773228832 refactor(Lactose): await asset repository calls in dependent controllers 2026-08-21 18:48:01 +02:00
REDCODE 225e6c4b92 feat(Lactose): convert remaining AssetController actions to async 2026-08-21 18:48:01 +02:00
REDCODE dffc909353 feat(Lactose): convert AssetRepository to async 2026-08-21 18:48:01 +02:00
REDCODE 379f640f00 Merge pull request 'Async migration PR 4: Album, Person & Media repositories' (#197) from feature/webapi/async-album-person-media into develop
Reviewed-on: #197
2026-08-21 16:36:10 +00:00
REDCODE 8c6b18382a refactor(Lactose): await person and album lookups in CreateAlbumsJob 2026-08-21 18:17:06 +02:00
REDCODE 917a144ed5 refactor(Lactose): await maintainer checks in AssetController update/delete paths 2026-08-21 18:17:06 +02:00
REDCODE 9613e7fe13 feat(Lactose): convert MediaRepository and MediaController to async 2026-08-21 18:17:06 +02:00
REDCODE 059866eb4a feat(Lactose): convert AlbumController actions to async 2026-08-21 18:16:55 +02:00
REDCODE 65ee5cfd20 feat(Lactose): convert PersonController actions to async 2026-08-21 18:16:55 +02:00
REDCODE c0164a75a0 feat(Lactose): convert AlbumRepository to async 2026-08-21 18:16:55 +02:00
REDCODE 2d1d1e0d26 feat(Lactose): convert PersonRepository to async 2026-08-21 18:16:55 +02:00
REDCODE 6f2ad6d24c Merge pull request 'Async migration PR 3: Folder repository & filesystem browse' (#196) from feature/webapi/async-folder-media into develop
Reviewed-on: #196
2026-08-21 15:58:49 +00:00
REDCODE 5727da5798 refactor(Lactose): await folder lookups in scheduler, jobs API and CreateAlbumsJob 2026-08-21 17:30:36 +02:00
REDCODE 2038bd5e20 feat(Lactose): await filesystem browse in AssetController.FsBrowse 2026-08-21 17:30:36 +02:00
REDCODE 8a4783367a feat(Lactose): make FilesystemBrowseService database access async 2026-08-21 17:30:36 +02:00
REDCODE 4bf8c5e69f feat(Lactose): convert FolderController actions to async 2026-08-21 17:30:36 +02:00
REDCODE df8b5a2cf2 feat(Lactose): convert FolderRepository to async 2026-08-21 17:30:36 +02:00
REDCODE 2d82dd7c7c Merge pull request 'Async migration PR 2: Tag, Settings & JobRecord repositories' (#195) from feature/webapi/async-tag-settings-jobs into develop
Reviewed-on: #195
2026-08-21 15:23:01 +00:00
REDCODE c3b473ff86 refactor(Lactose): await settings lookups in background jobs 2026-08-21 17:04:17 +02:00
REDCODE e279e91f0d refactor(Lactose): make JobScheduler lifecycle and settings seeding async 2026-08-21 17:04:17 +02:00
REDCODE ccbf8ebaa4 feat(Lactose): convert JobManager queries and JobsController to async 2026-08-21 17:04:17 +02:00
REDCODE ae62213b76 refactor(Lactose): await settings lookups in StatsRepository 2026-08-21 17:04:10 +02:00
REDCODE a2de4b8ac3 feat(Lactose): convert Settings and registration-check endpoints to async 2026-08-21 17:04:10 +02:00
REDCODE 1127364b15 feat(Lactose): convert TagController actions to async 2026-08-21 17:04:10 +02:00
REDCODE 64ec5d46a8 feat(Lactose): convert JobRecordRepository to async with tuple page result 2026-08-21 17:04:00 +02:00
REDCODE b1bcab67e0 feat(Lactose): convert SettingsRepository to async 2026-08-21 17:04:00 +02:00
REDCODE b43f85e7c9 feat(Lactose): convert TagRepository to async 2026-08-21 17:04:00 +02:00
REDCODE b3f2e73eef Merge pull request 'Async migration PR 1: UserRepository, auth pipeline & user controllers' (#194) from feature/webapi/async-user-auth into develop
Reviewed-on: #194
2026-08-21 14:46:47 +00:00
REDCODE 3f8b36eb37 chore(Lactose): await user existence check in AssetController.Create 2026-08-21 16:27:27 +02:00
REDCODE 152e0c2bc0 feat(Lactose): convert AuthController actions to async 2026-08-21 16:27:27 +02:00
REDCODE 9d79c15201 feat(Lactose): convert UserController actions to async 2026-08-21 16:27:05 +02:00
REDCODE ba8f7b68be refactor(Lactose): await user lookup in RefreshTokenTransformation 2026-08-21 16:27:05 +02:00
REDCODE a7d2e88993 feat(Lactose): convert UserRepository to async 2026-08-21 16:27:05 +02:00
REDCODE 79fc81fabd Merge pull request 'refactor: extract shared components (C) — action toolbar, infinite-scroll, picker, state helpers, search' (#193) from feature/shared-components-c into develop
Reviewed-on: #193
2026-08-21 03:18:51 +00:00
REDCODE a5d32d7173 refactor(frontend): consolidate person search and debounce in forms and pickers 2026-08-21 04:23:56 +02:00
REDCODE 90026a61cc refactor(shared): add PersonSearchDropdown component and Debounce helper 2026-08-21 04:23:56 +02:00
REDCODE 98c3fd5371 fix(cosplayers): unify select-all toggle with albums page 2026-08-21 04:15:27 +02:00
REDCODE 9ccdfe5512 refactor(frontend): adopt shared state helpers in selection pages 2026-08-21 04:08:06 +02:00
REDCODE abe446db6c refactor(shared): add DeleteConfirmation, VisibilityApplyState, and MultiSelect helpers 2026-08-21 04:08:01 +02:00
REDCODE fe8c1d5531 refactor(album-asset-picker): extract shared picker layout render fragment 2026-08-21 03:45:51 +02:00
REDCODE 104193a09e refactor(frontend): use InfiniteScrollController in Home, AlbumGrid, CosplayerGrid 2026-08-21 03:25:25 +02:00
REDCODE 291eb611c0 refactor(shared): extract InfiniteScrollController pagination state machine 2026-08-21 03:25:24 +02:00
REDCODE e24e6cc2f7 refactor(shared): extract responsive action toolbar and button 2026-08-21 03:13:39 +02:00
REDCODE 03ee6c555e Merge pull request 'refactor(auth): remove manual token-refresh wrapper in LoginService' (#192) from feature/remove-login-manual-refresh into develop
Reviewed-on: #192
2026-08-21 01:05:19 +00:00
REDCODE 70c432a668 refactor(auth): delegate profile fetch to UserService and drop manual token refresh 2026-08-21 02:48:39 +02:00
REDCODE d72448b34b Merge pull request 'refactor: frontend consistency pass — OnClose naming, AssetIds param, drop _ prefixes' (#191) from feature/frontend-consistency-166 into develop
Reviewed-on: #191
2026-08-21 00:43:53 +00:00
REDCODE 87b7d61f1a refactor(frontend): drop underscore prefix from service and code-behind private fields 2026-08-21 02:31:07 +02:00
REDCODE 2ea4bb7d8b refactor(frontend): drop underscore prefix from shared component private fields 2026-08-21 02:31:02 +02:00
REDCODE a893836f42 refactor(frontend): drop underscore prefix from page private fields 2026-08-21 02:30:57 +02:00
REDCODE 0a51ce676a refactor(album-form): align asset ids parameter naming to AssetIds 2026-08-21 02:27:05 +02:00
REDCODE de8589b066 refactor(shared): standardize dismiss callbacks on OnClose 2026-08-21 02:26:28 +02:00
REDCODE c2fe80cddc docs: establish no underscore prefix convention for frontend fields 2026-08-21 02:23:49 +02:00
REDCODE 1a340d23ac fix(album): previewer navigation skips hidden assets in browse mode 2026-08-21 02:15:19 +02:00
REDCODE 819be813dd feat(frontend): download previewer image via attachment link 2026-08-21 02:15:15 +02:00
REDCODE d5ee952bb0 feat(media): support attachment download via download query param 2026-08-21 02:15:11 +02:00
REDCODE b675457e3e Merge pull request 'refactor: centralize frontend GET handling in GetJsonAsync with a null-on-failure contract' (#190) from feature/getjson-error-contract into develop
Reviewed-on: #190
2026-08-21 00:14:00 +00:00
REDCODE 2dd4f8621f style(services): add trailing newline to ServiceBase 2026-08-21 02:13:01 +02:00
REDCODE 8209da44e7 refactor(jobs): route job reads through GetJsonAsync keeping the throwing boundary 2026-08-21 01:33:52 +02:00
REDCODE 2177cb1a0b refactor(settings): return null from GetAllSettings instead of throwing 2026-08-21 01:33:42 +02:00
REDCODE 79b15ed0ff refactor(asset): migrate asset reads to GetJsonAsync 2026-08-21 01:33:33 +02:00
REDCODE 4124daeec9 refactor(user): migrate user reads to GetJsonAsync 2026-08-21 01:33:23 +02:00
REDCODE 8007a2d8ce refactor(services): migrate album, person, stats, and folder reads to GetJsonAsync 2026-08-21 01:33:14 +02:00
REDCODE a38f5eb59e refactor(services): add GetJsonAsync helper to ServiceBase 2026-08-21 01:33:09 +02:00
REDCODE cd2fa3a355 fix(services): unsubscribe AuthInfoChanged on dispose to prevent singleton scoped subscription leak 2026-08-21 01:22:59 +02:00
REDCODE 59cb11e687 refactor(shared): migrate ProfileCropper onto ModalFrame component 2026-08-21 01:22:55 +02:00
REDCODE a3372881c4 refactor(settings): remove dead UpdateSettingsAsync batch method 2026-08-21 01:22:43 +02:00
REDCODE f89c8f3804 Merge pull request 'feat: mark broken assets instead of requeueing + rebuild maintenance page + GIF→mp4 conversion' (#182) from feature/broken-assets-gif-conversion into develop
Reviewed-on: #182
2026-08-20 23:02:57 +00:00
REDCODE b6386aad98 refactor(asset): rename fs-browse to directory and remove the DB-path browse endpoint
- Rename the filesystem browse route from /api/asset/fs-browse to /api/asset/directory
  (controller route, client URL, REST tests, and service log label).
- Remove the old /api/asset/browse endpoint and its BrowseAssets/GetDirectoryNames
  repository code, plus the client BrowseAsync method. Repoint AlbumAssetPicker's
  admin/curator folder browse to the /directory endpoint.
- Drop the now-unused pg_catalog.split_part DbFunction mapping.
- Refresh .env GIT_VERSION.
2026-08-21 01:00:38 +02:00
REDCODE e63b93f645 chore: added generation of default folder for conversions in Lactose dockerfile 2026-08-21 00:32:22 +02:00
REDCODE a23231fd5f perf(jobs): stream integrity check in batches and make it fully cancellable
Replace full-table Load()/ToList() with keyset pagination (1000-row batches,
AsNoTracking) for asset existence, thumbnail, preview, and converted-video checks,
using ExecuteUpdateAsync to clear missing state without tracking overhead.
Make all heavy EF operations async and token-aware (LoadAsync/ToListAsync/
CountAsync/SaveChangesAsync/MigrateAsync) and check cancellation between every
batch so the job can be cancelled at any point. Refresh .env GIT_VERSION.
2026-08-21 00:11:41 +02:00
REDCODE bfd99c578d chore: drop unused CurrentPath from browse result and refresh GIT_VERSION
AssetBrowseResultDto.CurrentPath was populated but unused by the fs-browse flow.
Remove it from the DTO and FilesystemBrowseService, and refresh the .env GIT_VERSION.
2026-08-20 23:59:26 +02:00
REDCODE 0d5089c01e feat(jobs): richer integrity check logging and live progress reporting
- Per-step [N/8] console logging with loaded counts, elapsed time, and totals
- Meaningful web-UI status messages per step instead of a hardcoded string
- Live checked/total progress (console + web UI) across folder, asset, thumbnail,
  preview, and converted-video loops
2026-08-20 23:59:11 +02:00
REDCODE 2c8dcb6255 chore(docker): persist converted videos via a named volume
ConvertedPath defaults to /app/converted on the container's ephemeral writable layer,
so mp4s were lost when the container was recreated while the DB still referenced them.
Mirror the existing thumbnails/previews mounts with a converted volume.
2026-08-20 23:59:06 +02:00
REDCODE 85357752e6 feat(frontend): render GIF previews as video and download the converted mp4
- Add MediaService.VideoUrl for the display media endpoint
- ImagePreview renders a <video> (webp poster) for video MIME types; otherwise the existing <img>
- Download and 'View full size' use the video URL for animated assets
- Home and AlbumDetail pass the asset MIME type into the shared preview
2026-08-20 23:58:56 +02:00
REDCODE 5b4e2b9afc feat(media): repurpose GIFs as video and serve converted mp4 when available
- Classify .gif as EAssetType.Video via MimeTypes (moved image/gif to the Video registry)
- Add MediaRepository.GetConvertedData, served by the existing media/{id} endpoint
  when a converted file exists on disk (with a fallback + warning log if missing)
- Expose the converted MIME type on AssetPreviewDto/AlbumAssetPreviewDto
  so the frontend can detect video assets from a single MimeType field
2026-08-20 23:58:48 +02:00
REDCODE 7fd294043e refactor(asset): convert bulk visibility to ExecuteUpdate, translate directory-name query to LINQ, and mark read-only queries AsNoTracking 2026-08-20 20:10:06 +02:00
REDCODE 025f9f6629 feat(db): map pg_catalog.split_part for LINQ translation 2026-08-20 20:09:06 +02:00
REDCODE f6b42f850c refactor(jobs): use ExecuteDeleteAsync for staging table cleanup 2026-08-20 20:08:57 +02:00
REDCODE 7596308075 feat(fs-browse): highlight folders with unassigned assets in picker
Amber-highlights subdirectory entries that contain assets without an album,
adds a warning dot and open-folder icon, and shows a toolbar badge when the
current directory holds unassigned files directly.
2026-08-20 18:37:42 +02:00
REDCODE 70247200f1 feat(asset): flag folders containing assets without an album in fs-browse
Adds HasUnassignedAssets to directory entries and HasUnassignedFilesHere to
the browse result. The service computes both in a single parameterized
VALUES + EXISTS anti-join over the unique OriginalPath index, using ESCAPE ''
so Windows separator paths survive LIKE semantics.
2026-08-20 18:37:34 +02:00
REDCODE ba66000748 merge: integrate develop (incremental scans, visibility cascade, view-mode search) 2026-08-20 18:03:58 +02:00
REDCODE ffe37fec87 Merge pull request 'refactor: extract ViewModeToggle + query-param search' (#170) from feature/view-mode-toggle into develop
Reviewed-on: #170
2026-08-20 15:10:36 +00:00
REDCODE 7563450c35 chore: refresh GIT_VERSION env 2026-08-20 16:56:55 +02:00
REDCODE 4bc35c0625 fix(albums,cosplayers): restore SupplyParameterFromQuery search binding 2026-08-20 16:56:44 +02:00
REDCODE 6fbb98a3fd chore: refresh GIT_VERSION env 2026-08-20 16:49:04 +02:00
REDCODE 796f485a84 fix(cosplayer-detail): base album selection on grid-loaded ids 2026-08-20 16:47:57 +02:00
REDCODE 95c624aaab Merge branch 'develop' into feature/view-mode-toggle 2026-08-20 16:36:45 +02:00
REDCODE 371e73a999 Added windows run config for PM deployment 2026-08-20 16:14:37 +02:00
REDCODE ee249adaf1 Merge pull request 'feat: cascade visibility skips only deleted + backend bulk cascade' (#154) from feature/cascade-visibility-skip-deleted into develop
Reviewed-on: #154
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-08-20 14:09:27 +00:00
REDCODE b5b8fb247c Merge branch 'develop' into feature/cascade-visibility-skip-deleted 2026-08-20 14:08:54 +00:00
REDCODE f9556cd5de Merge branch 'develop' into feature/view-mode-toggle 2026-08-20 14:08:47 +00:00
REDCODE 05f3c40f60 Merge pull request 'feat(jobs): incremental scans, deep mode & folder selection' (#188) from feature/incremental-scans into develop
Reviewed-on: #188
2026-08-20 14:04:33 +00:00
REDCODE 22b6e0e702 fix(jobs): retry directories whose reconciliation batch was dropped 2026-08-20 15:54:54 +02:00
REDCODE b2786b487f fix(jobs): snapshot discovery cursor before scan to avoid skipping mid-run assets 2026-08-20 15:54:51 +02:00
REDCODE 317bf12e07 fix(jobs): count unique albums in discovery scan status 2026-08-20 15:33:43 +02:00
REDCODE 7fd7a82d54 fix(migration): no-op IngestedAt backfill to avoid timeout crash on large tables 2026-08-20 15:13:24 +02:00
REDCODE f9706d26d5 chore: update local dev env, IDE config, and version tooling
Local-only changes: refreshed .env, IDE data-source metadata, a new
VersionEnvGen run config, and the generate-version script.
2026-08-20 14:41:13 +02:00
REDCODE 9c3d7effc9 fix(migration): use ordered keyset instead of MIN(uuid) for IngestedAt backfill 2026-08-20 02:32:02 +02:00
REDCODE 4d9d94b648 docs: mandate EF-tool-only migrations; move IngestedAt backfill to its own migration 2026-08-20 01:51:58 +02:00
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 887e5e0b31 Merge branch 'develop' into feature/view-mode-toggle 2026-08-19 00:37:11 +00:00
REDCODE 4e93029965 feat: shift-select a range of assets in the folder browser
Adds shift+click range selection to the maintenance file browser so a
contiguous span of images can be selected at once, matching the quick
multi-select workflows on the other pages. The first click sets an
anchor; a subsequent shift+click selects every asset between the anchor
and the clicked item. The anchor resets on navigation, pagination, and
search so it never spans stale pages.

Refs #180
2026-08-19 02:17:03 +02:00
REDCODE b26163296f fix: allow empty path in asset browse options
ASP.NET infers [Required] for non-nullable reference-type query
parameters even when initialized with "", so an empty path= query failed
binding with 'The Path field is required.' Making the property nullable
removes the implicit required constraint; both browse endpoints already
normalize with Path ?? "".

Refs #180
2026-08-18 18:37:46 +02:00
REDCODE ec6254ea73 style: move convert gifs button next to preview and thumbnail buttons
Reorders the Jobs toolbar and mobile menu so Convert GIFs sits between
Generate Previews and Calculate pHash, alongside the other processing jobs.

Refs #181
2026-08-18 18:28:32 +02:00
REDCODE 9d08f3de3e fix: reflect child batch failures in animated conversion status
The master job validated only null path settings and tracked per-asset
counters, so an unset Converted Path spawned doomed child batches and the
master still reported success. It now rejects empty output paths up front
and aggregates child batch statuses - any failed/canceled batch fails the
master, errors mark it completed-with-errors.

Refs #181
2026-08-18 18:22:02 +02:00
REDCODE 3872fb05ed feat: expose converted path and ffmpeg settings in the settings UI
Adds Converted Path and FFmpeg Path to the Paths group and Converted Format
to the Jobs group so the GIF conversion settings are editable in the UI.
Seeds a default Converted Path (cwd/converted) when missing.

Refs #181
2026-08-18 18:21:55 +02:00
REDCODE 16850bc6fd fix: map ConvertAnimatedJob in JobMapper
The job-type mapper switch was missing the ConvertAnimatedJob case, so
any request mapping active jobs (GET /api/jobs) threw
ArgumentOutOfRangeException once an animated conversion job was queued.

Refs #181
2026-08-18 18:15:41 +02:00
REDCODE 4d9b595a1d feat: launch animated conversion from the jobs page
Adds a launch button for the AnimatedConversion (GIF to mp4) job to the
Jobs page toolbar and mobile menu, a friendly type-filter label, and the
film icon for AnimatedConversion rows. Styled with a new accent-based
job color.

Refs #181
2026-08-18 18:01:07 +02:00
REDCODE a02b2f4843 style: use filled buttons on the broken assets toolbar
Matches the filled button styling used on other pages (Albums, Cosplayers)
for the maintenance page's broken-assets actions and requeue buttons.

Refs #180
2026-08-18 17:07:11 +02:00
REDCODE 68fdad13d5 Merge branch 'develop' into feature/broken-assets-gif-conversion 2026-08-18 15:05:43 +00:00
REDCODE a149b01690 feat: show broken asset count on statistics page
Adds a TotalBrokenAssets stat aggregated from the existing asset query and
surfaces it on the stats page as a danger card linked to the maintenance
page.

Refs #180
2026-08-18 16:54:35 +02:00
REDCODE 3fce3599fb fix: use page-fullness pagination for broken report and folder browse
Aligns the maintenance page with the project's existing pagination pattern
instead of relying on the X-Total-Count response header, which is not
exposed to the WASM client over CORS. The broken report now pages by
'count >= page size' like AlbumAssetPicker, and folder browse pages over
the on-disk file count carried in the response body. The pagination bar
always renders when files exist so browsing can never strand on a page
with no database matches.

Refs #180
2026-08-18 16:54:28 +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 c55ce7ac4f Merge branch 'develop' into feature/view-mode-toggle 2026-08-18 14:44:58 +00:00
REDCODE 6026c9ec43 Merge branch 'develop' into feature/regularize-services 2026-08-18 14:44:46 +00:00
REDCODE 6add667ef6 fix: reject empty base path in filesystem browse 2026-08-18 11:15:52 +02:00
REDCODE 0c09e01f3c test: add REST client tests for broken report, fs-browse, animated job 2026-08-18 11:14:59 +02:00
REDCODE f940d5603a feat: convert GIFs to mp4 via ffmpeg with first-frame thumbnail
Add ConvertAnimatedJob (EJobType.AnimatedConversion) targeting image/gif
assets: extracts a static WebP first-frame thumbnail and converts the full
animation to mp4/H.264, leaving originals untouched. New ConvertedPath/
ConvertedFormat/FfmpegPath settings, PathUtils extension parameter,
FfmpegRunner helper, and Dockerfile ffmpeg install. Thumbnail/preview
jobs now skip GIFs (handled by the conversion job) and IntegrityCheck
validates converted outputs. Failed conversions are marked broken.

Refs #181
2026-08-18 11:13:48 +02:00
REDCODE 83d30feadf feat: rebuild maintenance page with broken report and fs browsing
Add BrokenAssetList (paginated broken-assets grid with retry/delete and
requeue buttons) and FolderBrowser (filesystem browsing with folder
sidebar, breadcrumbs, bulk visibility/delete). Maintenance.razor now
switches between the two tabs. Add EJobType.AnimatedConversion.

Refs #180 #181
2026-08-18 11:09:37 +02:00
REDCODE 03e4c3e86d feat: add broken-assets report and filesystem browse endpoints
Add GET /api/asset/broken (paginated broken list), POST
/api/asset/broken/retry (clear broken state), and GET
/api/asset/fs-browse which walks the filesystem and looks up DB asset
records by path, replacing the slow DB path-string traversal for the
maintenance page. AssetPreviewDto now carries original path, broken
state, and directory entries carry relative paths.

Refs #180
2026-08-18 11:06:47 +02:00
REDCODE 61b7c10967 feat: track broken assets to stop requeueing known failures
Add ProcessFailedAt/ProcessErrorMessage to Asset plus a filtered index.
All 'missing' queries now exclude broken assets so failed images are not
re-processed indefinitely. Thumbnail/Preview/PHash/Metadata jobs mark the
asset broken on failure via a shared helper. AssetPreviewDto now carries
the broken state for the maintenance report.

Refs #180
2026-08-18 11:04:23 +02:00
REDCODE f8db265ffd perf: use semi-join raw SQL for asset cascade updates 2026-08-18 02:17:31 +02:00
REDCODE 164008bef1 fix: surface bulk visibility errors on the frontend 2026-08-18 01:48:09 +02:00
REDCODE 4d5694dca6 perf: use pure SQL cascade in person bulk update 2026-08-18 01:47:59 +02:00
REDCODE a680f6363d feat: add bulk visibility update by person IDs (ExecuteUpdate) 2026-08-18 01:47:52 +02:00
REDCODE 0098ec3a65 chore: remove unused FindByAlbumIds 2026-08-18 01:04:45 +02:00
REDCODE fbd7f5ff00 fix: run bulk cascade within a repository transaction 2026-08-18 01:04:38 +02:00
REDCODE c05082ad13 Merge branch 'develop' into feature/cascade-visibility-skip-deleted 2026-08-17 22:40:36 +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 06e59e164f feat: add shift-click range selection to multi-select pages 2026-08-17 23:02:55 +02:00
REDCODE c2f3b05903 feat: add SelectionRange helper for shift-click range selection 2026-08-17 23:02:49 +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 725a020078 feat: add saving state to PersonForm 2026-08-17 22:32:50 +02:00
REDCODE 2782a38819 feat: add applying state to VisibilityModal 2026-08-17 22:32:42 +02:00
REDCODE 84e4492aff refactor: use bulk asset update in cascade endpoints 2026-08-17 22:32:33 +02:00
REDCODE f75940cd20 feat: add bulk asset visibility update via ExecuteUpdateAsync 2026-08-17 22:32:25 +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 fe4e19a7de refactor: supply search query from URL parameter in cosplayers page 2026-08-17 21:55:59 +02:00
REDCODE a5a783e8d6 feat: use ViewModeToggle in detail pages 2026-08-17 21:55:53 +02:00
REDCODE 3afa7f2834 feat: use ViewModeToggle and query-param search in albums page 2026-08-17 21:55:47 +02:00
REDCODE 66782cacfc feat: extract ViewModeToggle shared component 2026-08-17 21:55:41 +02:00
REDCODE aacd4c8049 feat: use bulk cascade in visibility change flows 2026-08-17 21:49:06 +02:00
REDCODE fa62e75a72 feat: expose cascade option on bulk update services 2026-08-17 21:48:59 +02:00
REDCODE 1599715d06 feat: cascade visibility in person and album bulk endpoints 2026-08-17 21:48:53 +02:00
REDCODE 4189e975a1 feat: add bulk descendant queries for visibility cascade 2026-08-17 21:48:48 +02:00
REDCODE 5fbb0e75e8 fix: make PersonUpdateDto.Name nullable for patch-style bulk updates 2026-08-17 21:48:42 +02:00
REDCODE 2371011720 feat: add cascade visibility options to bulk DTOs 2026-08-17 21:48:36 +02:00
REDCODE 2d4e78f014 Merge branch 'develop' into feature/cascade-visibility-skip-deleted 2026-08-17 21:17:31 +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 25144ccd3c refactor: drop no-op deleted filter on album cascade 2026-08-17 18:40:34 +02:00
REDCODE a9b52a890a chore: update cascade labels to skip-deleted wording 2026-08-17 16:41:04 +02:00
REDCODE b344c1d625 feat: person edit-form cascade skips only deleted 2026-08-17 16:41:00 +02:00
REDCODE cff5a7969d feat: cascade cosplayer visibility to albums skips only deleted 2026-08-17 16:40:56 +02:00
REDCODE 30dca5e216 feat: cascade album visibility skips only deleted assets 2026-08-17 16:40:51 +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
REDCODE 550f195cf2 Apply deploy config override fix
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m33s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m54s
2026-07-17 03:47:47 +02:00
REDCODE 06c6cf7648 fix: remove redundant AddJsonFile calls that overrode env vars
Removed the explicit AddJsonFile calls in Program.cs — WebApplication.CreateBuilder
already adds them internally, but in the wrong order (after env vars). This caused
DatabaseAddress__Host env var from docker-compose to be silently overridden by
appsettings.json defaults, resulting in connection to 127.0.0.1:3306 instead of
database:5432.

Also updated docker-compose.yml to set DatabaseAddress__Host and DatabaseAddress__Port
explicitly, and default ASPNETCORE_ENVIRONMENT to Production.
2026-07-17 03:47:11 +02:00
REDCODE a5b6642b94 ci: auto-run on tags only, manual dispatch on branches 2026-07-17 02:19:41 +02:00
REDCODE f0b67b0404 Merge branch 'develop'
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 7m22s
Build and Push Containers / build-and-push (Lactose) (push) Has been cancelled
2026-07-17 02:13:27 +02:00
REDCODE fb2b02e71e Merge branch 'hotfix/ci-actions' into develop
Build and Push Containers / build-and-push (Lactose) (push) Has been cancelled
Build and Push Containers / build-and-push (MilkStream) (push) Has been cancelled
2026-07-17 02:13:16 +02:00
REDCODE 4658ed1a27 fix: remove gha cache config (cache server unreachable from builder)
Build and Push Containers / build-and-push (Lactose) (push) Successful in 2m34s
Build and Push Containers / build-and-push (MilkStream) (push) Successful in 2m55s
2026-07-17 02:08:20 +02:00
REDCODE 52c08089b2 fix: match matrix service names to actual directory case (Lactose/MilkStream)
Build and Push Containers / build-and-push (Lactose) (push) Failing after 3m34s
Build and Push Containers / build-and-push (MilkStream) (push) Has been cancelled
2026-07-17 02:04:21 +02:00
REDCODE 111875670b fix: lowercase repository name for Docker compatibility
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m0s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m2s
2026-07-17 02:02:16 +02:00
REDCODE 9c50f84783 fix: sanitize tag names for Docker compatibility (replace / with -)
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m13s
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m11s
2026-07-17 02:00:00 +02:00
REDCODE 593d88d3e3 perf: add BuildKit cache mounts for NuGet packages in Dockerfiles
Build and Push Containers / build-and-push (milkstream) (push) Has been cancelled
Build and Push Containers / build-and-push (lactose) (push) Has been cancelled
2026-07-17 01:58:53 +02:00
REDCODE abef50b010 fix: use explicit domain git.r3d.codes instead of gitea.server_url
Build and Push Containers / build-and-push (milkstream) (push) Has been cancelled
Build and Push Containers / build-and-push (lactose) (push) Has been cancelled
2026-07-17 01:57:29 +02:00
REDCODE ce221428f5 ci: use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m18s
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m18s
2026-07-17 01:53:29 +02:00
REDCODE 213c643263 ci: pass SixLaborsLicenseKey as build arg for lactose
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m15s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m16s
2026-07-17 01:48:56 +02:00
REDCODE ecc6e02c78 ci: also run on develop and hotfix/* branches
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m16s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m17s
2026-07-17 01:46:30 +02:00
REDCODE b78c5772b4 ci: also run on push to master 2026-07-17 01:45:55 +02:00
REDCODE 6463df51d1 CI: added sixlabors license key to the docker file for lactose 2026-07-17 01:43:37 +02:00
REDCODE dd2d703fc0 ci: add workflow_dispatch trigger with version input 2026-07-17 01:39:10 +02:00
REDCODE a388887f56 ci: add Gitea Actions workflow to build and push containers on version tags
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m30s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m32s
2026-07-17 00:42:22 +02:00
REDCODE 7d30ea2394 Version 1.0
Build and Push Containers / build-and-push (milkstream) (push) Failing after 1m10s
Build and Push Containers / build-and-push (lactose) (push) Failing after 1m11s
2026-07-17 00:24:24 +02:00
REDCODE ab420fe167 CI: added build + push to internal registry action 2026-07-17 00:21:49 +02:00
REDCODE 19e177504f Merge pull request 'feat: add ability to add assets to an album from album detail page' (#142) from feat/add-assets-to-album into develop
Reviewed-on: #142
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-16 22:08:43 +00:00
REDCODE f57e2864c5 refactor: replace individual [FromQuery] params with AssetBrowseOptionsDto 2026-07-17 00:07:47 +02:00
REDCODE d89f6f0a21 fix: address PR #142 review issues
- Fix timer leak: implement IDisposable on AlbumAssetPicker
- Remove unnecessary full-table COUNT in GetFolderGroups
- Remove dead DirectoryEntryDto.AssetCount
- Remove unused ExistingAssetIds parameter
- Remove redundant .Distinct() after .Union()
- Extract hardcoded page size 150 to named constant
- Rename AssetSearchOptionsDto.Unassigned to Unlinked for consistency
- BrowseAssets returns 404 for missing folderId instead of 200 empty
2026-07-17 00:00:37 +02:00
REDCODE ca217ddb59 fix: adjust embedded picker viewport height offset 2026-07-16 23:50:20 +02:00
REDCODE e025866357 fix: simplify maintenance page CSS to flex-fill container without vh calc 2026-07-16 23:40:31 +02:00
REDCODE 1449664678 feat: add visibility and create-album actions, fix embedded viewport sizing 2026-07-16 23:35:24 +02:00
REDCODE cf8bee555a revert: change LoginService back to Singleton (DI error was from docker fast mode) 2026-07-16 23:27:45 +02:00
REDCODE 664ac54a7c fix: change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime 2026-07-16 23:26:50 +02:00
REDCODE df48dd17f4 fix: inline all markup in .razor template to restore CSS isolation 2026-07-16 23:22:43 +02:00
REDCODE f15fe8f31a fix: add scoped CSS for maintenance page to make embedded picker fill viewport 2026-07-16 23:18:02 +02:00
REDCODE 8589a85625 refactor: rename UnlinkedAssetGroupDto to AssetGroupDto 2026-07-16 23:14:14 +02:00
REDCODE 983cd8baef refactor: add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names 2026-07-16 23:13:18 +02:00
REDCODE f52b376fca fix: restrict maintenance page and nav link to admin only 2026-07-16 23:04:55 +02:00
REDCODE 75e57353e7 feat: add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page 2026-07-16 23:03:37 +02:00
REDCODE eae6152738 fix: filter directory names to only paths containing a slash after prefix, excluding filenames 2026-07-16 22:56:17 +02:00
REDCODE c4788e1572 fix: use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error 2026-07-16 22:51:41 +02:00
REDCODE 1dfa9eaf41 perf: replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges 2026-07-16 22:44:20 +02:00
REDCODE 50b5a28841 perf: replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter 2026-07-16 22:15:11 +02:00
REDCODE 580cdee67d feat: add collapsible sidebar toggle to AlbumAssetPicker folder browser 2026-07-16 21:50:23 +02:00
REDCODE 8f0267b0f7 feat: redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX 2026-07-16 21:43:49 +02:00
REDCODE 5d9aa21987 feat: add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath 2026-07-16 21:43:44 +02:00
REDCODE ad1f9e2222 fix: use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init 2026-07-16 21:33:48 +02:00
REDCODE 90c81c2d20 fix: use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging 2026-07-16 21:06:18 +02:00
REDCODE 0cc80a084a fix: handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker 2026-07-16 21:06:14 +02:00
REDCODE ad7c38b20e feat: integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler 2026-07-16 20:55:32 +02:00
REDCODE e0a5bd0ce4 feat: add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select 2026-07-16 20:54:29 +02:00
REDCODE e41e7d774f feat: extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync 2026-07-16 20:53:02 +02:00
REDCODE 34220e93d4 feat: extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params 2026-07-16 20:52:33 +02:00
REDCODE 22706a09cd feat: extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups 2026-07-16 20:51:34 +02:00
REDCODE 664c1de5da feat: add trigram GIN index on Asset.OriginalFilename for efficient ILike search 2026-07-16 20:50:30 +02:00
REDCODE 081295af86 feat: add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters 2026-07-16 20:49:56 +02:00
REDCODE 7dd00c0a5e fix: gate registration behind UserRegistrationEnabled setting
AuthController.Register() never checked the UserRegistrationEnabled
server setting, allowing anyone to register even when disabled.

- Inject ISettingsRepository into AuthController
- Return 403 Forbidden at top of Register() if setting is not 'true'
- Add setup test #3.5 to enable registration for the main test flow

Closes #141
2026-07-16 20:37:50 +02:00
REDCODE 3a195dbc15 chore: Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage 2026-07-16 20:09:47 +02:00
REDCODE cce310ee14 Merge pull request 'feat: frontend multi-select enhancements - merge, unlink cosplayer, visibility refactor' (#140) from feat/128-frontend-merge-multi-select into develop
Reviewed-on: #140
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 17:54:32 +00:00
REDCODE ada113914d refactor: move Unlink cosplayer button from album header to album edit form 2026-07-16 19:48:55 +02:00
REDCODE a2a25673de refactor: replace inline visibility modal with shared VisibilityModal in Cosplayers.razor 2026-07-16 19:33:09 +02:00
REDCODE 963e897fe5 feat: add Unlink cosplayer button to AlbumDetail.razor 2026-07-16 19:31:39 +02:00
REDCODE f9fe91e975 feat: add Merge Albums button to CosplayerDetail.razor 2026-07-16 19:31:12 +02:00
REDCODE 01fc121bc3 feat: add Merge button to Cosplayers.razor with CosplayerGrid preview access 2026-07-16 19:30:22 +02:00
REDCODE da8a5ad539 feat: add Merge button to Albums.razor with AlbumGrid preview access 2026-07-16 19:29:41 +02:00
REDCODE ff2ed4f7cd feat: add MergeModal shared component for merge destination selection 2026-07-16 19:28:45 +02:00
REDCODE 2c292d2d81 feat: add frontend service methods for merge, bulk update, and bulk delete people 2026-07-16 19:27:48 +02:00
REDCODE f064277c31 Merge pull request 'feat: add rate limiting with configurable buckets' (#139) from feature/rate-limit into develop
Reviewed-on: #139
2026-07-16 17:17:53 +00:00
REDCODE c5ea749517 test: added rate limiting stress test 2026-07-16 19:17:37 +02:00
REDCODE ebe83fe691 chore: use record for config classes 2026-07-16 19:06:35 +02:00
REDCODE ab58ac7dd8 Merge branch 'develop' into feature/rate-limit 2026-07-16 16:56:33 +00:00
REDCODE 17d3f0f2a7 Merge pull request 'feat: backend merge endpoints, bulk visibility fix, and tests' (#138) from feat/backend-merge-endpoints into develop
Reviewed-on: #138
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-16 16:46:52 +00:00
REDCODE 1e2bc83b20 fix: guard against null PersonOwnerId in MergePeople albums query
Replace PersonOwnerId ?? Guid.Empty with explicit null check to
avoid matching albums with no owner if sourceIds contained Guid.Empty.
2026-07-16 18:30:23 +02:00
MrFastwind 58014a37bd feat: apply rate-limit buckets to controllers
- AuthController: auth bucket (10 req/60s) — anti-brute-force
- MediaController.GetImage: media_original bucket (50 req/60s)
- MediaController.GetThumb: media_thumb bucket (200 req/60s)
- MediaController.GetPreview: media_preview bucket (200 req/60s)
- JobsController: jobs bucket (30 req/10s) — quick refresh for long-polling
- All other endpoints: global bucket (100 req/60s) via default
2026-07-16 18:26:39 +02:00
MrFastwind c79ed47e41 feat: add rate limiting with configurable buckets
Add fixed-window rate limiting to Lactose with 6 configurable buckets:
- Global (100 req/60s) for all generic calls
- Auth (10 req/60s) strict anti-brute-force
- MediaOriginal (50 req/60s) for full-size media
- MediaThumb (200 req/60s) for thumbnails
- MediaPreview (200 req/60s) for previews
- Jobs (30 req/10s) quick refresh for long-polling

All buckets configurable via appsettings.json RateLimiting section.
Rejected requests return HTTP 429 with JSON body and Retry-After header.
2026-07-16 18:17:58 +02:00
REDCODE e48da562fa test: flatten test numbering to sequential 1–137
Renumber all test blocks flatly with no gaps or sub-letter suffixes.
Split the remaining multi-request block in the bulk visibility section.
2026-07-16 18:08:17 +02:00
REDCODE b943d2e595 fix: split multi-request test blocks into single-request blocks
REST Client only processes the first request per ### block.
Split 99f into 99f/99f1 and 99i2 into 99i2/99i2b so dynamic
variables set by response handlers are available in subsequent tests.
2026-07-16 18:04:40 +02:00
REDCODE 0950106609 test: add merge and bulk visibility tests to WepApiTest.http
Covers: album merge validation and e2e, person merge validation and e2e,
bulk visibility update (people + assets), authorization gating.
2026-07-16 17:58:14 +02:00
REDCODE 7ba1ab1551 feat: add merge endpoints to AlbumController and PersonController
POST /api/album/merge — Curator/Admin, validates DestinationId not in SourceIds
POST /api/person/merge — Admin/Curator, validates DestinationId not in SourceIds
2026-07-16 17:56:52 +02:00
REDCODE 656e6fa9bf feat: add MergePeople to person repository
Reassigns albums (PersonOwnerId), faces (PersonId), and maintainers
from source people to destination, then hard-deletes source people.
2026-07-16 17:56:24 +02:00
REDCODE 5e1306e466 feat: add MergeAlbums to album repository
Loads destination + source albums with assets, moves all assets
from sources to destination (deduplicated), then hard-deletes sources.
2026-07-16 17:55:46 +02:00
REDCODE 22565c4f92 feat: add AlbumMergeDto and PersonMergeDto
New DTOs for merge endpoints. Both require DestinationId and SourceIds,
with DestinationId validated to not appear in SourceIds by the controller.
2026-07-16 17:55:15 +02:00
REDCODE e62a567e4e fix: apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
The bulk update path was ignoring Visibility and MaintainerUserIds
from PersonUpdateDto, unlike the single-update path.
2026-07-16 17:55:00 +02:00
REDCODE e5ffe86077 feat: add visibility-colored borders to cosplayer cards in selection mode
Adds border-warning for non-Public cosplayers and preserves the
cosplayer-card-select accent outline, matching the AlbumCard pattern.
2026-07-16 17:26:26 +02:00
REDCODE d4a53cb2dc fix(UI): make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page 2026-07-16 16:09:34 +02:00
REDCODE 548b0d5bfd Merge pull request 'feat: add reusable ConfirmDialog component for deletion operations' (#135) from issue-57-confirm-dialog into develop
Reviewed-on: #135
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 13:59:26 +00:00
REDCODE 92fb5a0149 fix: add ConfirmDialog to Settings folder deletion flow
FolderBox now delegates deletion to parent via OnDeleteRequested
callback instead of deleting directly. Settings.razor shows a
ConfirmDialog before removing the folder.
2026-07-16 15:11:33 +02:00
REDCODE 6542dcafd6 fix: add ConfirmDialog to Cosplayers bulk delete flow
Adds confirmation before bulk-deleting selected cosplayers.
2026-07-16 15:10:37 +02:00
REDCODE b7690b5828 fix: add ConfirmDialog to Albums bulk delete flow
Adds confirmation before bulk-deleting selected albums.
2026-07-16 15:10:20 +02:00
REDCODE f3f2bdba02 fix: add ConfirmDialog to CosplayerDetail delete flows
Adds confirmation before deleting a cosplayer or bulk-deleting selected
albums. Uses a single ConfirmDialog with dynamic message and stored
callback for either action.
2026-07-16 15:10:03 +02:00
REDCODE 8bf6405c64 fix: add ConfirmDialog to AlbumDetail delete flows
Adds confirmation before deleting an album or bulk-deleting selected
assets. Uses a single ConfirmDialog with dynamic message and stored
callback for either action.
2026-07-16 15:09:36 +02:00
REDCODE c1b1feef4d fix: add ConfirmDialog to User delete user flow
Replaces browser window.confirm() with the reusable ConfirmDialog
component.
2026-07-16 15:09:06 +02:00
REDCODE 0e9820f7c3 fix: add ConfirmDialog to AdminUsers delete user flow
Replaces browser window.confirm() with the reusable ConfirmDialog
component for a consistent, styled confirmation experience.
2026-07-16 15:08:42 +02:00
REDCODE 133c697176 feat: add reusable ConfirmDialog component
A shared confirmation dialog wrapping ModalFrame for delete operations.
Supports plain text Message or custom Body RenderFragment, configurable
confirm button text and styling, and OnConfirm/OnCancel event callbacks.
2026-07-16 15:08:13 +02:00
REDCODE 8f07d7c445 Merge pull request 'feat: bulk cosplayer assignment for albums from multiselection mode' (#134) from issue-122-bulk-assign-cosplayer into develop
Reviewed-on: #134
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-16 11:25:22 +00:00
REDCODE c7e83ed97b feat: bulk cosplayer assignment for albums from multiselect mode
Adds a shared LinkToCosplayerModal component with person typeahead
search and wires it into the albums list page's multiselect topbar.

The backend already supports bulk person assignment via POST /api/album
with BulkDto<AlbumUpdateDto> { Data.Person = ... }, so no backend
changes are needed.

Also adds API tests for the bulk album person assignment flow.

Part of #128, closes #122
2026-07-16 02:59:15 +02:00
REDCODE 7db1738678 fix: soft-delete users in UserController.Delete instead of hard-delete
UserController.Delete was calling userRepository.Delete(user) which performed
a hard delete (context.Users.Remove), violating the project's soft-delete
convention. Changed to set user.DeletedAt = DateTime.UtcNow, matching the
pattern used in AssetController.Delete.

Also removed the now-unused Delete method from IUserRepository and
UserRepository for consistency with AssetRepository (which also has no
Delete method).

Extended REST tests 103-104 to verify deletedAt is set after deletion.

Closes #130
2026-07-16 02:31:52 +02:00
REDCODE 3e099987a3 fix: album detail back button uses history.back() with fallback to /albums
Replaces the hardcoded <a href="/albums"> back link with a button
that calls history.back() via JS interop, falling back to NavigateTo("/albums")
when there is no browser history. Fixes #133.
2026-07-16 02:17:24 +02:00
REDCODE ca50554108 feat: wire up SystemUploaderId setting — seed, UI, and crawl job backfill
- Seed SystemUploaderId in DefaultSettings.json (empty = no default owner)
- Read setting once per crawl and apply UploadedBy on new assets
- Backfill UploadedBy on existing assets that are missing it
- Add UserSelect display type for user-picker setting
- Create SettingUserSelect component with search dropdown
- Add setting to Scanning group in Settings UI
2026-07-16 01:18:46 +02:00
REDCODE 2bfa370989 Merge pull request 'feat: role-visible colored borders, AlbumDetail bulk actions, and cascading visibility (#78)' (#131) from issue-78-role-visible-borders into develop
Reviewed-on: #131
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-15 22:59:59 +00:00
REDCODE dbd5d15fa1 fix: label Delete button and cropper modal overlay
- CosplayerDetail: label desktop Delete button and mobile dropdown as
  'Delete Cosplayer' (was 'Delete' / 'Delete person')
- ProfileCropper: move instruction text into modal footer to prevent
  overlap with Cancel/Save buttons; darken overlay background to
  rgba(0,0,0,0.85) instead of semi-transparent rgba(0,0,0,0.6)
2026-07-16 00:57:30 +02:00
REDCODE b1f32844a9 fix: homepage preview navigation, grid observer crash, and cascade labels
- fix: @for closure bug in Home.razor causing preview to show only previous arrow
- guard NavigatePreview against flatList rebuilds during infinite scroll
- fix IntersectionObserver crash when sentinel not in DOM during reload (CosplayerGrid, AlbumGrid)
- align cascade checkbox labels with actual cascade logic (skips private/deleted)
2026-07-16 00:02:46 +02:00
REDCODE 3bf98cbfed fix: prevent private assets with null UploadedBy leaking to anonymous users
Adds userId.HasValue guard to the private-visibility condition in both
AssetRepository and MediaRepository, preventing the null==null match
when an anonymous visitor (userId=null) encounters an asset with null
UploadedBy.
2026-07-15 23:33:08 +02:00
REDCODE 80c9c8bd6f fix: Cosplayers page bugs
- Restore New Cosplayer button on desktop
- Fix ToggleSelectAll via CosplayerGrid.GetCurrentPersonIds()
- Fix ApplyVisibility wiping data — fetch person first, include all fields
- Add GetCurrentPersonIds() to CosplayerGrid for Select All support
2026-07-15 23:10:23 +02:00
REDCODE 7086fb127f feat: add multiselect editing controls to Cosplayers page
- Visibility modal with cascade to albums and assets
- Desktop inline + mobile dropdown action buttons
- Select All / Deselect All button
- Uses PersonService single-update for visibility, AlbumService cascade
2026-07-15 23:03:02 +02:00
REDCODE 19ab8c0975 refactor: centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
- Add CascadeVisibilityToAssetsAsync to AlbumService (skips private/deleted)
- Replace duplicated loop+filter in Albums, CosplayerDetail, PersonForm with single call
- Update labels to describe skip-private/deleted behavior
2026-07-15 23:01:41 +02:00
REDCODE d7f8350ae7 fix: add cascade checkbox to CosplayerDetail album visibility modal 2026-07-15 22:58:36 +02:00
REDCODE f0e3cc9c37 feat: cascade visibility to albums and assets with lower-only rule
- VisibilityModal: add optional CascadeLabel + CascadeEnabled for checkbox
- Albums page: checkbox to cascade album visibility to non-deleted assets
  (lower-only: never raise asset visibility)
- PersonForm: cascade checkboxes when editing cosplayer visibility
  - Cascade to albums (lower only)
  - Cascade to assets in every album (only if album cascade enabled)
- Inject AssetService in Albums page and PersonForm
2026-07-15 22:41:13 +02:00
REDCODE 4f56af252b refactor: extract VisibilityModal shared component, add editing controls to Albums page
- Create MilkStream.Client/Components/Shared/VisibilityModal.razor
- Replace duplicated modal markup in AlbumDetail, CosplayerDetail, Albums
- Add Select All/Deselect All to Albums via AlbumGrid ref
- Add Delete, Visibility modal to Albums select mode
- Add mobile dropdown to Albums action buttons
- Add GetCurrentAlbumIds() to AlbumGrid for Select All support
2026-07-15 22:31:41 +02:00
REDCODE 8aeb4df064 fix: wrap CosplayerDetail action buttons into mobile dropdown 2026-07-15 22:24:07 +02:00
REDCODE dc2983ae55 fix: album-card-select and cosplayer-card-select only on selected cards 2026-07-15 22:23:01 +02:00
REDCODE a50dc3aa30 fix: prevent jagged wrapping of toolbar and action buttons
SortFilterBar: wrap search+sort in flex-nowrap row, remove ms-auto
from ActionButtons so wrapped items are left-aligned and clean.
AlbumDetail: add flex-shrink: 0 to action buttons container.
2026-07-15 22:19:42 +02:00
REDCODE 3cdad1cebd feat: add album-level bulk actions to CosplayerDetail page
- Add BulkUpdateAlbumsAsync to AlbumService
- Fix AlbumCard border gate from IsAdminOrCurator to CanEdit()
- CosplayerDetail: Delete, Unlink, Visibility buttons in select mode
- CosplayerDetail: Select All / Deselect All button
- CosplayerDetail: visibility breakdown stats in header
- CosplayerDetail: visibility modal for albums
2026-07-15 22:14:18 +02:00
REDCODE 0903da4490 fix: show borders for maintainers in select mode 2026-07-15 22:07:38 +02:00
REDCODE 2fcd6467e1 feat: add Select All / Deselect All button in album select mode 2026-07-15 22:05:28 +02:00
REDCODE 10337c735c feat: show visibility/deleted breakdown in album header in select mode 2026-07-15 21:59:27 +02:00
REDCODE e3d4cf9673 fix: update border colors and browse-mode visibility in AlbumDetail
- Private assets → red border (instead of orange)
- Protected assets → orange border
- Deleted assets → greyed out (opacity + grayscale)
- When not in select mode: hide Private and Deleted assets from view
2026-07-15 21:55:54 +02:00
REDCODE a203885377 Merge branch 'issue-78-album-detail-bulk-actions' into issue-78-role-visible-borders
Adds bulk delete, visibility modal, and rename Remove→Unlink to
AlbumDetail select mode, plus bugfix for missing Save() in bulk endpoints.
2026-07-15 21:48:18 +02:00
REDCODE fff5ea65df feat: add bulk delete and visibility change to AlbumDetail select mode
- Fix AssetController.BulkUpdate/BulkDelete — add missing Save() call
- Add BulkUpdateAssetsAsync and BulkDeleteAssetsAsync to AssetService
- Rename 'Remove N' → 'Unlink N' (removes from album, original behavior)
- Add 'Delete N' button — soft-deletes selected assets via BulkDelete
- Add 'Visibility' button — opens ModalFrame to pick Public/Protected/Private
- Apply calls BulkUpdateAssetsAsync with chosen visibility level
2026-07-15 21:46:28 +02:00
REDCODE 86f95d7461 feat: add role-visible colored borders on albums and assets based on item state (#78)
Backend:
- Add DeletedAt to AssetPreviewDto, AlbumAssetPreviewDto, AlbumPreviewDto
- Remove redundant DeletedAt from AssetDto (now inherited from base)
- Add viewerId parameter to ToAssetPreviewDto, ToAlbumPreviewDto,
  ToAlbumFullDto, ToPersonDetailedDto mappers
- Conditionally send DeletedAt only when viewer is Admin or asset uploader
- Pass uid/viewerId from all controller/repository call sites

Frontend:
- AlbumCard: show orange (not public) / red (deleted) border in select
  mode for admins/curators
- AlbumDetail: same border logic in GetTileClass for asset tiles
- Borders only appear in select mode per REDCODE's feedback
2026-07-15 20:48:00 +02:00
REDCODE ec287f087c docs: add visibility rules (R1–R7) to AGENTS.md 2026-07-15 20:32:23 +02:00
REDCODE 758696f7c9 fix: add entity-level visibility gating for albums and persons
Adds R3/R4 visibility gates to AlbumRepository.FindVisible and
PersonRepository.FindVisible so that 404 is returned when the
requesting user lacks permission to see the entity itself.

Adds comprehensive HTTP tests verifying:
- R2: anonymous asset detail access
- R3: Public/Protected visible to User, Private returns 404 (Admin sees all)
- R4: Private person returns 404 for User, 200 for Admin/Curator
2026-07-15 20:30:06 +02:00
REDCODE de9cd618b6 docs: fix stale column references and tooling versions in AGENTS.md
- Update dotnet-tools.json location reference
- Fix EF Core Tools version (both now v10.0.9)
- Remove stale SharedWith.Any() EF Core gotcha (per-user sharing removed)
- Fix index example to use Visibility/UploadedBy columns
2026-07-15 18:41:37 +02:00
REDCODE e7fb01be9f Merge pull request 'feat: frontend alignment for #106 auth redesign — Maintainer role, visibility UI, user data' (#127) from feature/106-auth-redesign-frontend into develop
Reviewed-on: #127
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-15 16:23:17 +00:00
REDCODE e4a34a7dad fix: show maintained cosplayers section for all user types in user profile edit modal
- Remove _editAccessLevel == Maintainer guard so the maintainer
  assignment section appears when editing any user as admin
- OpenEditModal already loads maintained persons regardless of access level
2026-07-15 17:42:31 +02:00
REDCODE d799c3eb1f docs: clarify Curators and Admins can be maintainers for credited attribution
- Update Actors table: Curator and Admin entries mention maintainer assignment
- Update Role Design Philosophy: same clarification
- Update Curator section: note about PersonMaintainer as credit/attribution
- Update Admin section: same note
2026-07-15 17:40:49 +02:00
REDCODE c5fabf0e07 fix: show maintained cosplayers section for all user types in admin edit modal
- Remove _editAccessLevel == Maintainer guard from the maintained
  cosplayers markup so it appears for User, Curator, and Admin edits too
- Remove the same guard from OpenEditModal so maintained persons are
  loaded regardless of the user's current access level
2026-07-15 17:39:36 +02:00
REDCODE 3cdf9eb185 refactor: extract person search/assign UI into shared SearchAssignBadges component
- Create generic SearchAssignBadges component (TItem parameterized) with
  search input, dropdown results, and removable badge chips
- Replace duplicate markup in AdminUsers.razor, User.razor, PersonForm.razor
- Update RemoveAssignedPerson/RemoveMaintainer to accept the item DTO
  instead of Guid, aligning with the component's EventCallback<TItem> pattern
2026-07-15 17:33:26 +02:00
REDCODE 6efccae030 fix: address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
- Change ?? EAccessLevel.Admin fallback to ?? EAccessLevel.User in UserController.GetAll
- Add .Include(u => u.MaintainedPersons) to UserRepository.GetAll() so MaintainedPersonIds/MaintainedPersonNames populate in list endpoint
- Fix UsersMapper.cs method body indentation (was flush with class)
- Add Maintainer-role visibility tests (68.5-68.8): promote user, test viewing admin, demote back
2026-07-15 17:31:33 +02:00
REDCODE 93cbbea3c7 fix: allow maintainer assignment for all user roles in UI
Remove _editAccessLevel == Maintainer guard so admins can assign
maintainers to curators/admins/users, not just Maintainer-level users.
2026-07-15 15:12:13 +02:00
REDCODE cace8640e9 fix: replace remaining jsonPath calls in asset visibility tests
Test 75 admin assertion: allow null email for server-scanned assets
(Guid.Empty placeholder has no email). Removes all remaining jsonPath
calls that could NPE on null resolved values.
2026-07-15 15:03:50 +02:00
REDCODE a3ce98a984 fix: replace jsonPath with response.body for null-value checks in visibility tests
jsonPath throws NullPointerException in GraalVM when the resolved JSON
value is null. Use response.body[n].property directly for all null
assertions in search-list and visibility tests.
2026-07-15 14:55:27 +02:00
REDCODE 4f3e3a28cc fix: enforce data visibility per access level with test coverage
- UsersMapper.ToGetUsersDto: filter Email, BannedAt, DeletedAt, AccessLevel,
  MaintainedPersonIds by viewer access (admin/self only)
- AssetsMapper.ToFullAssetsDto: pass viewerId to restrict UploadedBy.Email;
  null-safe Uploader fallback
- AssetsMapper.ToAssetPreviewDto: gate FileName behind Curator+ (was public)
- AlbumMapper: gate FileName behind Curator+; fix AssetCount to count only
  non-deleted assets
- AssetRepository.FindVisible: include Uploader to prevent NRE (500 error)
- AssetController: restrict UploadedBy reassignment to Admin only
- UserController: pass viewer context to all ToGetUsersDto calls
- WepApiTest.http: add 18 data visibility tests (#64-#81) verifying field
  gating per role; fix 3 pre-existing stale assertions; avoid jsonPath NPE
  on null values by using response.body directly
2026-07-15 14:53:39 +02:00
REDCODE 657ffaae11 fix: show email to self-viewing users on user page 2026-07-15 14:24:41 +02:00
REDCODE 700d5a63a7 chore: fix Dockerfiles, add debug compose to solution 2026-07-15 14:21:18 +02:00
REDCODE 9531ac2e23 fix: NRE on user page when user data not yet loaded
Add loading spinner and null-state guard to prevent rendering
before user data arrives. Fix unsafe `user is not {DeletedAt: null}`
pattern that throws NRE on null user.
2026-07-15 14:19:13 +02:00
REDCODE 288da412d9 fix: remove underline from maintainer links, load assigned person names in user edit modal 2026-07-15 14:11:57 +02:00
REDCODE 1b34520a66 feat: add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
- Add docker-compose.debug.yml override for Debug build + Development env
- Add UseWebAssemblyDebugging() middleware in Development
- Production path unchanged (HSTS + HTTPS redirect preserved)
2026-07-15 14:10:25 +02:00
REDCODE 23b285cf49 fix: remove underline from user links, hide email from non-admin viewers 2026-07-15 13:18:32 +02:00
REDCODE 27707b120c feat: make usernames clickable on admin users page, linking to /User/{id} 2026-07-15 13:17:37 +02:00
REDCODE 14978087e3 feat: expand user page edit modal for admins with access level, maintainer assignment, and danger zone 2026-07-15 13:16:32 +02:00
REDCODE 3021788271 fix: null reference in User page maintainer display 2026-07-15 13:14:28 +02:00
REDCODE 358bf0918e feat: maintainer assignment UI for cosplayers, maintainer display on user page
Backend:
- Add MaintainerUserIds to PersonUpdateDto/PersonDetailedDto
- Add MaintainedPersonNames to UserInfoDto
- Add IPersonRepository.SetMaintainers + implementation
- PersonRepository.Find/FindVisible now include Maintainers
- PersonController.Update handles maintainer assignment (admin/curator)
- Fix PersonController.Update to save Visibility field
- Allow curators to list all users via UserController.GetAll

Frontend (PersonForm):
- Add maintainer search+select UI (admin/curator only in edit mode)
- Search users client-side, click to add, badge with X to remove
- Saves maintainer IDs on person update

Frontend (CosplayerDetail):
- Show 'Maintained by: user1, user2' in header with clickable links
- Pass maintainer data to PersonForm

Frontend (User page):
- Show 'Maintainer of: cosplayer1, cosplayer2' with links
- Non-admin view: strip Email, Created, Deleted fields
- Fix LoadUser to use route UserId instead of logged-in user
2026-07-15 13:11:05 +02:00
REDCODE 656784af00 feat: maintainer cosplayer assignment in admin user edit modal
Backend:
- Add MaintainedPersonIds to UserUpdateDto
- Add SetMaintainedPersons to user repository (removes old PersonMaintainer rows, inserts new)
- Wire up in UserController.Update (admin only)

Frontend:
- Add PersonService injection to AdminUsers page
- Searchable cosplayer dropdown in edit modal for Maintainer users
- Click result to add, badge with X to remove
- Zero debounce, saves only on Submit
- Pre-loads currently assigned cosplayer names on modal open
2026-07-15 12:55:03 +02:00
REDCODE c8b71f290b fix: add missing app.css link to index.html 2026-07-15 12:27:38 +02:00
REDCODE 8f109bf92f chore: commit compiled CSS for role badge classes 2026-07-15 12:19:46 +02:00
REDCODE 268b380867 refactor: move CanEdit logic to LoginService, remove local page properties 2026-07-15 12:18:53 +02:00
REDCODE 71889ddfac refactor: extract role checks to LoginService, update all consumers 2026-07-15 12:06:05 +02:00
REDCODE 0a74ff8770 refactor: generate access level dropdown from enum values 2026-07-15 12:04:35 +02:00
REDCODE f158181ccf refactor: extract role badge colors to SCSS theme layer 2026-07-15 12:04:01 +02:00
REDCODE e585abc378 feat: expand UI gating to include Maintainer with scope-aware CanEdit 2026-07-15 11:26:43 +02:00
REDCODE ccf16b6abc feat: add Protected Assets card to stats page 2026-07-15 11:25:05 +02:00
REDCODE 7dcb78db97 feat: add Visibility dropdown to album and person create/edit forms 2026-07-15 11:24:18 +02:00
REDCODE 26984f4452 feat: add Maintainer to admin user dropdowns and role badges 2026-07-15 11:23:15 +02:00
REDCODE f32d0678df feat: add MaintainedPersonIds to UserInfoDto with backend plumbing 2026-07-15 11:22:33 +02:00
REDCODE 05b99ab6c5 Merge pull request 'feat: #106 auth/permissions redesign — 5-actor model, #93 visibility levels (backend)' (#125) from feature/106-auth-redesign into develop
Reviewed-on: #125
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-14 20:23:23 +00:00
REDCODE 88a0098314 perf: add AsSplitQuery to repository queries with multiple Include calls 2026-07-14 22:00:33 +02:00
REDCODE 8b53899648 docs: add data size estimates to AGENTS.md 2026-07-14 21:46:19 +02:00
REDCODE e2b0eaba37 refactor: rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
Clarifies that this property filters albums by assets uploaded by a user,
not albums themselves being uploaded by a user.
2026-07-14 21:30:14 +02:00
REDCODE e1504dbf72 style: standardize access level comparison to switch expressions
- AlbumRepository.SearchQuery: if/else → switch expression
- PersonRepository.FindVisible: nested if → switch statement with guarded case
- AlbumController Update/BulkDelete/Delete: if/else → switch
2026-07-14 21:29:46 +02:00
REDCODE b67b2260fe docs(repo): fix stale pageSize default in IAlbumRepository XML doc
Actual default is PagedParametersDto.MaxPageSize (changed in 08c3890),
but documentation still referenced the old hardcoded 150.
2026-07-14 21:27:15 +02:00
REDCODE 06fd26e641 refactor: move all visibility filtering from mappers and controllers to repositories
- AlbumMapper: remove CountVisibleAssets(), drop userId param from ToAlbumPreviewDto()
- PersonMapper: remove IsAssetCountVisible(), drop userId param from ToPersonDetailedDto()
- AssetRepository: add FindVisible(), add userId/accessLevel params to GetAssets()
- AlbumRepository: add FindVisible(), replace .Include(a => a.Assets) with subquery count in SearchQuery()
- PersonRepository: add FindVisible() with visibility filtering + album search/sort/pagination
- AssetController.Get: replace inline switch with FindVisible()
- AssetController.GetAll: pass uid/accessLevel to GetAssets(), remove inline filter
- AlbumController.Get: replace inline asset filter with FindVisible()
- PersonController.Get: replace 50-line inline filter/search/sort/page with FindVisible()
2026-07-14 21:17:02 +02:00
REDCODE 74c74cbbdf fix(mapper): scope asset counts in album/ person previews to visible assets
- Added optional userId parameter to ToAlbumPreviewDto and ToPersonDetailedDto
- AlbumRepository.SearchQuery and PersonMapper now pass userId for visibility-scoped counts
- ToAlbumFullDto unchanged (callers pre-filter assets before mapping)
- Private helper filters out non-visible assets for non-curator users
2026-07-14 20:44:52 +02:00
REDCODE 774ee0d7c7 fix(media): allow Maintainer to access media files for cosplayers they maintain
- Injected IPersonRepository into MediaRepository
- Updated GetVisibleAsset with Maintainer-specific check via album→person→maintainer chain
2026-07-14 20:42:55 +02:00
REDCODE 45b3c56ce2 fix(controller): split Maintainer from User in AssetController.GetAll visibility filter
Maintainers now also see Private assets belonging to cosplayers they maintain,
not just their own uploads.
2026-07-14 20:42:26 +02:00
REDCODE b0fa104642 fix(mapper): replace null-forgiving operator with null-conditional in AlbumMapper
- ToAlbumFullDto.AssetPreviews and Images now use ?. instead of !.
- Falls back to empty list when Assets is null, matching ToAlbumPreviewDto pattern
2026-07-14 20:41:16 +02:00
REDCODE d4cd305c28 fix(controller): scope AssetController maintainer permissions via album→person chain
- Added IPersonRepository injection into AssetController
- Added FindWithAlbums to IAssetRepository/AssetRepository for single-entity checks
- Modified FindBulk to include Albums navigation property
- Single Update/Delete: Maintainers can act on assets in albums of maintained persons
- BulkUpdate/BulkDelete: same scope expansion for Maintainers
2026-07-14 20:35:55 +02:00
REDCODE 671783fd16 fix(repo): scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
Maintainers now only see Private albums for persons they maintain,
matching the same pattern already used in PersonRepository.SearchQuery.
2026-07-14 20:33:38 +02:00
REDCODE 674710986f fix(model): add [PrimaryKey] annotation to PersonMaintainer for clarity
Composite key (PersonId, UserId) was only defined via Fluent API;
[PrimaryKey] makes the model class self-documenting.
2026-07-14 20:33:18 +02:00
REDCODE 77a6ee9747 fix(dto): make AssetCreateDto.UploadedBy nullable for scanner-created assets
- Changed UploadedBy from Guid to Guid? to represent system/scanner uploader
- Controller Create method now handles null UploadedBy (skips user validation)
2026-07-14 20:18:53 +02:00
REDCODE 1f2bf02d02 fix(migration): default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
- Changed column default from 0 (Public) to 1 (Protected) for Assets, People, Albums
- Reordered migration to add Visibility column before dropping IsPubliclyShared
- Assets that were IsPubliclyShared = true are restored to Public (0) via SQL
- Private assets and all existing people/albums default to Protected (1)
2026-07-14 20:18:13 +02:00
REDCODE c5b8c93a39 fix(migration): remap existing EAccessLevel values to prevent data corruption
Old enum values stored in DB:
  - Curator (value 1) → Maintainer after renumbering (incorrect)
  - Admin   (value 2) → Curator after renumbering (incorrect)

Add SQL UPDATE statements to remap:
  1. Old Admin (2) → New Admin (3) — done first to free up value 2
  2. Old Curator (1) → New Curator (2) — done second
2026-07-14 20:17:32 +02:00
REDCODE b9251c3e19 style(repo): use explicit named enum cases in PersonRepository switch expressions 2026-07-14 19:22:14 +02:00
REDCODE 0d1ce9feb3 style(repo): expand switch expression arms for readability 2026-07-14 19:14:35 +02:00
REDCODE 0673017765 style(repo): convert visibility filters to switch expressions for readability 2026-07-14 19:13:20 +02:00
REDCODE 08c389026a fix(repo): use PagedParametersDto.MaxPageSize const instead of hardcoded 150
Replaced magic number with named constant in both interface and implementation.
2026-07-14 19:03:02 +02:00
REDCODE bbb2ed8f73 fix(dto): default AssetCreateDto.Visibility to Private instead of Protected 2026-07-14 19:02:34 +02:00
REDCODE fe33fe2044 fix(stats): add ProtectedAssets count, fix PrivateAssets to count only Private
Previously PrivateAssets counted everything != Public (i.e., Protected+Private combined).
Now PublicAssets=Public, ProtectedAssets=Protected, PrivateAssets=Private.
2026-07-14 19:02:07 +02:00
REDCODE a449c792c1 fix(media): replace bool parameter with EAccessLevel, add deleted asset check
- GetVisibleAsset: accept EAccessLevel enum instead of bool isCuratorOrAbove
- Admin: sees all assets including deleted
- Curator: sees own deleted + all non-deleted
- Maintainer/User: only non-deleted + visibility filtering
2026-07-14 19:01:39 +02:00
REDCODE 9323111d0d fix(mapper): allow Maintainer to see Visibility field in DTO responses
Changed threshold from >= Curator to >= Maintainer in all three mappers (AssetsMapper, PersonMapper, AlbumMapper). FileName stays Curator+ only.
2026-07-14 19:00:57 +02:00
REDCODE c2326414b4 fix(controller): add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
- BulkUpdate: Maintainer can update albums of persons they maintain
- Delete: Maintainer can delete albums of persons they maintain
- BulkDelete: same scoped filtering
2026-07-14 19:00:07 +02:00
REDCODE 747ad77f4c fix(controller): scope Maintainer asset access to only maintained cosplayers in Get
Maintainer of the album's person sees all non-deleted assets. Otherwise applies User-level visibility filter.
2026-07-14 18:59:23 +02:00
REDCODE f77a4574ac fix(controller): scope Maintainer album visibility to only maintained persons
When viewing a person they maintain, show all albums. Otherwise apply User-level visibility filter (Public + Protected only).
2026-07-14 18:58:47 +02:00
REDCODE c64c1947e6 fix(repo): scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
Previously Maintainer saw all people like Curator+. Now:
- Curator+: sees all people
- Maintainer: sees Public + Protected + persons they maintain
- User: sees Public + Protected only
2026-07-14 18:58:14 +02:00
REDCODE 1b444dab93 feat(auth): complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
- AlbumSearchParametersDto: add UploadedBy field
- IPersonRepository/PersonRepository: add IsMaintainerOf method
- AlbumController: allow Maintainer to update with scope check, pass uploadedBy to SearchQuery
- PersonController: allow Maintainer to update maintained persons
- AssetController: allow Maintainer to update/delete own assets
- TagController: allow Maintainer to CRUD all tags
- Fix Authorize(Roles) to include Maintainer where appropriate
- Update WepApiTest.http: curator accessLevel 1→2
2026-07-14 18:13:15 +02:00
REDCODE 50bf0a2a78 feat(db): add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy 2026-07-14 17:45:22 +02:00
REDCODE 2335e8e23a feat(repos,controllers): update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
- PersonRepository, AlbumRepository, AssetRepository, MediaRepository, StatsRepository: visibility-based filtering
- IAlbumRepository, IAssetRepository, IMediaRepository: updated signatures (FindByUploader, uploadedBy param, accessLevel)
- AlbumController, AssetController, PersonController, MediaController: replace IsPubliclyShared/SharedWith/UserOwnerId/Owner with Visibility/UploadedBy/Uploader
- FileSystemCrawlJob: IsPubliclyShared=false → Visibility=EVisibility.Private
- Asset model: nav property UploadedBy→Uploader to avoid FK/nav name collision
2026-07-14 17:44:46 +02:00
REDCODE d02a010083 feat(infra): update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility 2026-07-14 17:40:34 +02:00
REDCODE 6676347353 feat(models): update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer 2026-07-14 17:39:48 +02:00
REDCODE c9391d840b feat(butter): update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields 2026-07-14 17:39:01 +02:00
REDCODE 51323b4a64 feat(butter): add EVisibility enum, Maintainer access level, and SystemUploaderId setting 2026-07-14 17:37:42 +02:00
REDCODE 842ed76345 fix(jobs): use grey for queued segments in layered progress bar 2026-07-14 13:14:44 +02:00
REDCODE f3ed658dd0 chore: Updated packages, removed unused packages. ImageSharp now requires a license 2026-07-14 13:14:27 +02:00
REDCODE 6c83b8395b fix(album-form): searchable person combobox with modal-safe positioning
Replaces the static <select> with a server-side searchable combobox
in the album edit modal. Fixes issues where the person dropdown was
empty and the name field was not pre-filled on edit.

- Server-side person search (trigram-indexed ILIKE, 20 results)
- 2-character minimum before search triggers
- pre-populates input with current person name when editing
- position: fixed via JS to escape modal-body overflow clipping

Closes #123
2026-07-14 12:43:59 +02:00
REDCODE e58524ce19 Merge pull request 'refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate' (#121) from refactor/120-albumgrid-cosplayerdetail into develop
Reviewed-on: #121
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-14 10:22:09 +00:00
REDCODE 9dced21554 docs: reconcile AGENTS.md with current codebase
- Remove stale Npgsql legacy timestamp switch note (removed from code)
- Fix EF Core version mismatch description (Tools 8.0.15, not CLI)
- Expand analyzer rules to cover MS001–MS004
2026-07-14 12:09:33 +02:00
REDCODE 3fdf55b53c docs: add PR workflow section to AGENTS.md 2026-07-14 12:07:20 +02:00
REDCODE f5b28e5a21 docs: add Gitea label levels note to AGENTS.md 2026-07-14 12:04:49 +02:00
REDCODE 7a380d7df5 refactor: reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
Replace manual album state management (inline AlbumCard loop, own
infinite scroll, own sentinel/observer, _ = ReloadAlbums() discard
lambdas) with <AlbumGrid> using a new FetchAlbums delegate parameter.

- AlbumGrid.razor: add FetchAlbums Func parameter; use it in
  Reload()/LoadMoreAlbums() when set
- CosplayerDetail.razor: replace manual album rendering + state with
  <AlbumGrid FetchAlbums=@FetchPersonAlbums />; remove all album
  state management fields (allAlbumsDict, currentAlbumPage, etc.),
  ReloadAlbums/LoadMoreAlbums/OnAfterRenderAsync methods, and
  IAsyncDisposable; SortFilterBar now uses clean lambdas
- CosplayerDetail.razor.css: remove unused album-grid overrides

Closes #120
2026-07-14 12:00:38 +02:00
REDCODE f7d6348ea9 Merge pull request 'fix: make card components middle-clickable by using <a> instead of <div @onclick>' (#119) from fix/102-card-middle-click into develop
Reviewed-on: #119
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-14 09:57:32 +00:00
REDCODE 46871299ca fix: remove underline from album card person link 2026-07-14 11:51:58 +02:00
REDCODE c281746fdc fix: make card components middle-clickable
Convert <div @onclick> -> NavigationManager.NavigateTo() to real <a>
elements so cards support middle-click, Ctrl+click, and
'Open in new tab'.

- CosplayerGrid.razor: replace <div> with <a href='/cosplayer/{id}'>
- AlbumCard.razor: add full-card <a> overlay, keep inner person <a>
- CosplayerGrid.razor.css + AlbumCard.razor.css: add display:block,
  color:inherit, text-decoration:none for <a> card elements
- Remove OnCardClicked/OnNavigate callbacks and parent page
  navigation methods (nav is now native via <a href>)

Closes #102
2026-07-14 11:46:13 +02:00
REDCODE be7532dd74 fix: implement CosplayersMissingProfile stat
Replaces the hardcoded zero with a real query counting people
without a profile picture (ProfileAssetId == null).

Renamed from CosplayersMissingCover to CosplayersMissingProfile
to reflect the actual metric.

DTO, repository, and UI label updated accordingly.

fixes #97
2026-07-14 11:40:06 +02:00
REDCODE 108b53a7e2 refactor: use MaxPageSize constant as default pageSize across all frontend services
AlbumService.GetAlbumsAsync, AssetService.GetAssetsAsync, PersonService.GetAllAsync
now default to PagedParametersDto.MaxPageSize (250) instead of hardcoded 30.
Also fixed AssetService.GetAssetsAsync page default from 1 to 0 for consistency
with zero-based pagination.
2026-07-14 11:27:38 +02:00
REDCODE 38793efe6f fix(albums): use MaxPageSize constant for unassigned album fetch
Fixes #118 - hardcoded pageSize=999 exceeded server's MaxPageSize (250),
causing 400 Bad Request and the assign-album modal to hang on loading.
2026-07-14 11:24:34 +02:00
REDCODE 4205188855 docs: add use case catalog for new auth/permissions model
Documents the 5-actor model (Anonymous/User/Maintainer/Curator/Admin),
77 use cases across all roles, permission matrix, visibility integration
with #93, and database schema changes including:
- Album.UserOwnerId removal
- Asset.OwnerId → UploadedBy rename
- PersonMaintainer M:N join table
- EAccessLevel enum update
2026-07-13 18:57:54 +02:00
REDCODE d2daed27a8 Merge pull request 'Global "Search Anything" typeahead dropdown in navbar' (#91) from feature/global-search-dropdown into develop
Reviewed-on: #91
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
Reviewed-by: Fastwind <fastwind@noreply.localhost>
2026-07-13 15:18:40 +00:00
REDCODE 87b69d5ee8 revert: remove AllowAnonymous from AlbumController.Search and PersonController.GetAll 2026-07-13 17:03:53 +02:00
REDCODE e0d46aa87b refactor: replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize 2026-07-13 17:02:13 +02:00
REDCODE dbe55be63f refactor(albums): extract visibility-aware AssetCount into AlbumMapper overload
Replace inline AlbumPreviewDto construction in AlbumRepository.SearchQuery
with a call to the new ToAlbumPreviewDto(album, userId, accessLevel) mapper
overload, making the visibility logic reusable and the repository more compact.
2026-07-12 21:15:00 +02:00
REDCODE 28f9e7faae fix: replace untranslatable Tags navigation in TopTags stats query with direct join table query 2026-07-12 21:02:50 +02:00
REDCODE 820d3b1c8b fix: replace untranslatable Album navigation in stats queries with direct join table queries 2026-07-12 21:02:33 +02:00
REDCODE 278648aae6 Merge branch 'develop' into feature/global-search-dropdown 2026-07-12 21:00:42 +02:00
REDCODE 9d9491253b refactor: clean up imports, simplify checks, and add SearchDropdown component
- Remove unused using directives across C# and Razor files
- Remove unused IServiceProvider from SettingsRepository
- Simplify null/empty string checks in StatsRepository
- Add null-safe navigation for Albums/Tags in stats queries
- Initialize Asset.Hash default to prevent null refs
- Deduplicate AssetIds in AssetPicker
- Add OnStartedWaiting/OnFinishedWaiting/OnProgressChanged to Job
- Add global SearchDropdown component with keyboard nav
- Fix XML doc param mismatches
2026-07-12 20:47:24 +02:00
REDCODE 499c6375f6 Merge branch 'develop' into feature/global-search-dropdown
Resolved merge conflict in CosplayerDetail.razor:
- Kept SortFilterBar with instant search/sort (from feat/cosplayer-detail-search)
- Adapted allAlbums references to allAlbumsDict (from feature/global-search-dropdown)
- Kept updated PersonController.cs and PersonService.cs from both branches
2026-07-12 20:28:59 +02:00
REDCODE 2b150060f4 Merge pull request 'Add searchbar to cosplayer detail page for filtering albums' (#100) from feat/cosplayer-detail-search into develop
Reviewed-on: #100
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-12 18:23:51 +00:00
REDCODE b93cb73e0a fix(cosplayer-detail): remove search debounce, instant inline reload
Search now fires ReloadAlbums immediately on every keystroke, matching
the same instant behavior as Albums and Cosplayers pages. Since
ReloadAlbums only swaps the album grid content without touching
isLoading, focus is preserved and the page stays intact.
2026-07-12 20:21:27 +02:00
REDCODE d2d584d509 fix(cosplayer-detail): debounce search, keep page intact on filter changes
- Remove OnFilterChanged from SortFilterBar to prevent per-keystroke reloads
- Debounce search input with 300ms CancellationTokenSource delay
- Sort changes (dropdown/toggle) trigger immediate ReloadAlbums
- ReloadAlbums no longer sets isLoading — updates album grid in-place
  without unmounting the page DOM, preserving focus on the search box
- Dispose searchCts on component disposal
2026-07-12 20:19:30 +02:00
REDCODE 8bcb408a5b fix(cosplayer-detail): prevent banner image shuffle on album search/sort
Add updateBanner parameter to LoadPerson so ReloadAlbums (triggered
by search/sort filter changes) does not regenerate the random banner
cover URLs, keeping the header fixed during filtering.
2026-07-12 20:15:09 +02:00
REDCODE 445b030160 feat(cosplayer-detail): add SortFilterBar for album search and sort
- Backend: accept PagedSearchParametersDto on GET /api/person/{id}
  with in-memory search (case-insensitive title Contains) and sort
  (name/created/updated/assets) before pagination
- Frontend service: add search, sortBy, sortAsc params to GetByIdAsync
- Frontend page: replace inline action buttons with reusable SortFilterBar
  component, wire search/sort state into initial load and infinite scroll
2026-07-12 20:11:06 +02:00
REDCODE 8195808500 docs: update AGENTS.md with missing conventions and project info
- Add Lactose.Analyzers as 5th project (DateTime.UtcNow error MS001)
- Document zero-based pagination standard with validation rules
- Add SharedWith.Any() EF Core translation gotcha
- Document pg_trgm GIN indexes and pgvector extensions
- Fix test count (66+ → 67), add env file reference
2026-07-12 19:41:10 +02:00
REDCODE 393ad136f6 fix: restore PageSize < 1 validation with upper limit of 250 2026-07-12 19:31:23 +02:00
REDCODE 00acfef28c fix: only reject Page < 0, keep original PageSize validation
Per review feedback: only Page being negative is truly invalid.
PageSize validation reverted to original < 0 (allowing 0).
PersonController keeps minimal Page < 0 check since it had none before.
JobsController aligned for consistency.
2026-07-12 19:27:10 +02:00
REDCODE efeed2b668 test: add page=0&pageSize=5 to paginated list endpoints
Person (tests 23-26), Album (tests 45-48), and User (test 12) list
endpoints now explicitly request the first page instead of the full set.
2026-07-12 19:17:15 +02:00
REDCODE efc6379bb3 test: add cleanup of regular test user in REST Client suite 2026-07-12 19:11:11 +02:00
REDCODE 20a456591d fix: move SharedWith visibility check to client side for translatable queries
EF Core cannot translate SharedWith.Any() through the many-to-many
AlbumAsset join table inside a SQL WHERE clause. Moved the visibility
filter to client-side after loading the page's records with Include.

PersonRepository.SearchQuery and AlbumRepository.SearchQuery now:
1. Get paginated IDs without visibility filter (translatable SQL)
2. Load those records with Include chains
3. Filter visibility in C# (SharedWith.Any(), etc.)
4. Project to DTOs
2026-07-12 19:07:38 +02:00
REDCODE 1b301ff2c0 fix: standardize pagination to zero-based across all layers
- AssetRepository: all Skip() formulas changed from (page-1)*size to page*size
- JobRecordRepository: same formula change for GetPastRootJobs
- AssetController: removed Page+1 bridge conversion, passes Page directly
- JobsController: GetPast default changed from 1 to 0, added validation
- AlbumController, TagController, PersonController: consistent Page<0/PageSize<1 validation
- IAssetRepository XML doc: 1-based → zero-based
- Home.razor: shifted all internal page state from 1-based to 0-based

Closes #95
2026-07-12 19:03:09 +02:00
REDCODE 50c58e22ff fix: restore ToAlbumPreviewDto mapper method per review feedback #91 2026-07-12 18:50:03 +02:00
REDCODE ad2f08698d perf: simplify ORDER BY to total count and add trigram GIN indexes
Simplify the 'assets'/'albums' sort to use total count instead of
visibility-filtered count, eliminating the expensive correlated COUNT
subquery from ORDER BY (which evaluated 25k times for every album).

Add pg_trgm extension and GIN trigram indexes on People.Name and
Albums.Title for efficient ILike %%query%% searches.

The SELECT projection still computes visibility-filtered counts for
accuracy, but only for the paginated subset (30 rows).

Migration: 20260712162406_AddTrigramIndexes
2026-07-12 18:24:55 +02:00
REDCODE e05ab3beaf docs: document REST Client tests in AGENTS.md 2026-07-12 18:05:55 +02:00
REDCODE 48b6d4f149 perf: add partial index on Assets for visibility-aware search queries
Create IX_Assets_VisibleForSearch on (IsPubliclyShared, OwnerId)
WHERE "DeletedAt" IS NULL to accelerate the correlated subqueries
used in both AlbumRepository and PersonRepository for filtering,
sorting by asset/album count, and projecting AssetCount/TotalAlbums.

- Fluent API in LactoseDbContext.OnModelCreating
- Generated migration via dotnet ef migrations add
2026-07-12 18:04:53 +02:00
REDCODE 89edf290ff refactor: project AlbumPreviewDto directly in repository with visibility-aware asset count
Align AlbumRepository.SearchQuery with the PersonRepository pattern:
return AlbumPreviewDto directly with access-level filtering at the
query level, avoiding loading full entity graphs and filtering
in-memory in the controller.

- Add userId/accessLevel params to SearchQuery
- Push visibility filter into the query for regular users
- Compute AssetCount with same visibility logic in the projection
- Sort by 'assets' uses visible count for regular users
- Remove unused AlbumMapper.ToAlbumPreviewDto extension
- Simplify AlbumController.Search to just return repo results
2026-07-12 17:41:24 +02:00
REDCODE c77096b0f9 refactor: project PersonPreviewDto directly in repository instead of [NotMapped] entity property
Replace the TempData-like TotalVisibleAlbums [NotMapped] property on
Person with a direct DTO projection in PersonRepository.SearchQuery.
The repository now returns IEnumerable<PersonPreviewDto>, computing
the visibility-aware album count in the EF Core subquery and projecting
only the needed columns. This avoids polluting the entity model with a
context-dependent property.
2026-07-12 17:18:17 +02:00
REDCODE e333d0e9db fix: filter album count by user visibility in person search
- Add Person.TotalVisibleAlbums [NotMapped] populated by repository
- Use visibility-aware album count in PersonMapper
- Restructure PersonRepository.SearchQuery with two-step pagination
  for correct ordering and per-user album visibility filtering
2026-07-12 16:51:29 +02:00
REDCODE 324c1b1a17 fix: prevent duplicate @key errors across all list renderings
Switch accumulating list storage to Dictionary<Guid, T> to guarantee
unique Blazor @key values when paginated data overlaps or races occur
during sort/filter changes.

- CosplayerGrid: Dictionary<Guid, PersonPreviewDto> for people list
- CosplayerDetail: Dictionary<Guid, AlbumPreviewDto> for albums
- AlbumDetail: deduplicate album.Images at load time via dict
- Home: deduplicate flatList rebuild via dict (keeps first occurrence)
- AssetPicker: Distinct() on parameter list
2026-07-12 14:47:12 +02:00
REDCODE ee3511c09a fix: paginate PersonRepository.SearchQuery by ID before loading albums
Include + Skip/Take on a collection navigation can produce duplicate people when sorted by album count, because pagination operates on joined rows. Fix by selecting paged IDs first, then loading the full entities with Include for those IDs.
2026-07-11 20:47:44 +02:00
278 changed files with 22454 additions and 4565 deletions
+1
View File
@@ -0,0 +1 @@
GIT_VERSION=v1.0.3.2-123-ge63b93f
+63
View File
@@ -0,0 +1,63 @@
name: Build and Push Containers
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.2.3)'
required: true
default: 'manual'
jobs:
build-and-push:
strategy:
matrix:
service: [Lactose, MilkStream]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.r3d.codes
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Determine version tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "VERSION=$(echo '${{ github.event.inputs.version }}' | sed 's|/|-|g')" >> $GITHUB_ENV
else
echo "VERSION=$(echo '${{ gitea.ref_name }}' | sed 's|/|-|g')" >> $GITHUB_ENV
fi
- name: Lowercase repository name
run: echo "REPO_LC=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Lowercase service name
run: echo "SERVICE_LC=$(echo '${{ matrix.service }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV
- name: Prepare build args for ${{ matrix.service }}
run: |
if [ "${{ matrix.service }}" = "Lactose" ]; then
echo "BUILD_ARGS=SixLaborsLicenseKey=${{ secrets.SIXLABORS_KEY }}" >> $GITHUB_ENV
else
echo "BUILD_ARGS=APP_VERSION=$VERSION" >> $GITHUB_ENV
fi
- name: Build and push ${{ matrix.service }}
uses: docker/build-push-action@v5
with:
context: .
file: ./${{ matrix.service }}/Dockerfile
push: true
tags: |
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:${{ env.VERSION }}
git.r3d.codes/${{ env.REPO_LC }}/${{ env.SERVICE_LC }}:latest
build-args: ${{ env.BUILD_ARGS }}
-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.
+4
View File
@@ -8,3 +8,7 @@ storageImages/
dotnet-tools.json
.opencode/
*.css.map
# SixLabors license file
/Lactose/sixlabors.lic
.desloppify/
.claude/
+1 -15
View File
@@ -1,20 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="MariaDb" uuid="eab007eb-4f50-4961-9bce-5c4940a7cdff">
<driver-ref>mariadb</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.mariadb.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mariadb://localhost:3306/</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.resource.type" value="Deployment" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
<time-zone>Europe/Rome</time-zone>
</data-source>
<data-source source="LOCAL" name="TestDb@localhost" uuid="80498c22-67da-4c4a-8106-c4789470ef83">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
@@ -31,7 +17,7 @@
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://192.168.51.101:3306/TestDb</jdbc-url>
<jdbc-url>jdbc:postgresql://192.168.51.101:5432/TestDb</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
+2 -3
View File
@@ -3,9 +3,8 @@
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:eab007eb-4f50-4961-9bce-5c4940a7cdff
2:0:80498c22-67da-4c4a-8106-c4789470ef83
3:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
1:0:80498c22-67da-4c4a-8106-c4789470ef83
2:0:a0f3e480-c856-41e0-8fb4-5cac87b9faf6
.</data>
</component>
</project>
+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>
+34
View File
@@ -0,0 +1,34 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compose Deployment (PM) Win" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker-PM">
<deployment type="docker-compose.yml">
<settings>
<option name="containerName" value="" />
<option name="envFilePath" value="" />
<option name="envFilePaths">
<list>
<option value="" />
</list>
</option>
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
<option name="value" value="Docker" />
</DockerEnvVarImpl>
</list>
</option>
<option name="removeOrphansOnComposeDown" value="false" />
<option name="commandLineOptions" value="--build" />
<option name="secondarySourceFiles">
<list>
<option value="docker-compose.pm.yml" />
</list>
</option>
<option name="sourceFilePath" value="docker-compose.yml" />
<option name="upRemoveOrphans" value="true" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</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>
+17
View File
@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="VersionEnvGenWin" type="ShConfigurationType">
<option name="SCRIPT_TEXT" value="" />
<option name="INDEPENDENT_SCRIPT_PATH" value="false" />
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.ps1" />
<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="false" />
<option name="INTERPRETER_PATH" value="&quot;C:/Program Files/PowerShell/7/pwsh.exe&quot;" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="EXECUTE_IN_TERMINAL" value="false" />
<option name="EXECUTE_SCRIPT_FILE" value="true" />
<envs />
<method v="2" />
</configuration>
</component>
+101 -8
View File
@@ -7,7 +7,7 @@
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
- **PostgreSQL with pgvector extension**
- **SCSS** compiled by `AspNetCore.SassCompiler` MSBuild task (no Node.js needed; compiles during `dotnet build`)
- Run `dotnet tool restore` before first migration — installs `dotnet-ef`
- Run `dotnet tool restore` before first migration — installs `dotnet-ef` (tool manifest at `dotnet-tools.json` in repo root)
## SCSS
@@ -24,11 +24,12 @@ dotnet build # compiles SCSS automatically
- Aggressive gradient mixins in `Styles/_gradients.scss`
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
## Projects (4 in solution)
## Projects (5 in solution)
| Project | Role | Entrypoint |
|---|---|---|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — |
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
@@ -43,16 +44,14 @@ 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
- **Npgsql legacy timestamps:** `AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true)` is set in `Lactose/Program.cs`. Do not remove without understanding the timestamp implications.
- **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.
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
- **EF Core Tools v8.0.15 vs EF Core Design v10.0.9:** Version mismatch may cause `dotnet ef` CLI quirks.
- **`dotnet-ef` CLI vs Microsoft.EntityFrameworkCore.Tools:** The global `dotnet-ef` (from `dotnet-tools.json`, v10.0.9) is the correct CLI to use. The Tools package reference in `Lactose.csproj` (v10.0.9) provides the `dotnet ef` target for `dotnet build`-time validation.
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001, `DateTime.Today` as error MS002, `DateTime.Date` as warning MS003, and non-UTC `SpecifyKind` as error MS004. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
## XML docs enforced
@@ -64,12 +63,56 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
- **Soft delete only** — set `DeletedAt`, never hard-delete (models: Asset, User)
- **Repository `Save()` must be called explicitly** after insert/update/delete
- **Enum prefix `E`** (e.g. `EAccessLevel`, `EAssetType`, `EJobStatus`)
- **Frontend fields: no `_` prefix** — private fields in `MilkStream.Client` use a plain camelCase name (e.g. `isLoading`, `selectedIndex`), never a leading `_`. Do not introduce `_`-prefixed fields; rename them out when touching such code.
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1250).
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
## Modularity
## Visibility Rules (R1R7)
Defined in issue [#129](https://git.r3d.codes/MilkyShots/MilkyShots/issues/129). Each entity (Asset, Album, Person) carries **independent** `EVisibility` — no inheritance from container to child.
### R1 — Independent Visibility
No entity's visibility is overridden by its container. An asset in a Public album does NOT become Public; the asset is only visible if the viewer has permission for the asset's own visibility.
### R2 — Asset Visibility (Per-Asset Gate)
| Actor | Public | Protected | Private |
|---|---|---|---|
| Anonymous | ✅ | ❌ | ❌ |
| User | ✅ | ✅ | ❌ |
| Maintainer (not owning / not linked) | ✅ | ✅ | ❌ |
| Maintainer (uploaded by self) | ✅ | ✅ | ✅ |
| Maintainer (album → person they maintain) | ✅ | ✅ | ✅ |
| Curator (non-deleted) | ✅ | ✅ | ✅ |
| Curator (deleted, own upload) | ✅ | ✅ | ✅ |
| Admin (all, including deleted) | ✅ | ✅ | ✅ |
### R3 — Album Access Gating
Album's own visibility determines who can access it. Not visible → 404.
| Actor | Public | Protected | Private |
|---|---|---|---|
| Anonymous / User | ✅ | ✅ | ❌ |
| Maintainer (unassigned) | ✅ | ✅ | ❌ |
| Maintainer (maintains PersonOwner) | ✅ | ✅ | ✅ |
| Curator / Admin | ✅ | ✅ | ✅ |
### R4 — Person Access Gating
Same as R3. Not visible → 404.
### R5 — Media Serving
Uses per-asset rules (R2). Album provenance not checked — too complex, marginal benefit.
### R6 — Edit Mode (Frontend)
API returns Private assets to privileged users (Admin/Curator/Maintainer-on-owned-content). Frontend hides them in browse mode, shows them in edit/curator mode. No backend flag.
### R7 — Deleted Assets
- **Admin**: all deleted assets visible
- **Curator**: own deleted uploads visible
- **Everyone else**: deleted assets hidden (`DeletedAt IS NULL`)
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
When a markup pattern appears in 2+ places, extract it into a shared component.
@@ -85,13 +128,63 @@ See issue #52 for the tracking list of candidate components.
Migrations in `Lactose/Migrations/` — generated, do not hand-edit.
**All migrations MUST be created with EF Core tools** (`dotnet ef migrations add`) and **never edited manually** (including the generated `.Designer.cs` and snapshot), unless the user explicitly asks for a manual edit. Hand-written data backfills, index tweaks, or idempotency guards belong in a separate migration added via the tools — not as edits to a generated file. If a migration is already partially applied (e.g. a backfill timed out), do not make the migration re-runnable; instead reset the partial DB state and re-apply, or add a new tool-generated migration.
```bash
dotnet ef migrations add <Name> --project Lactose
dotnet ef database update --project Lactose
```
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v10.0.9 vs Design v10.0.9 mismatch). Example:
```csharp
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
```
- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search.
- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`.
## Tests
No test project found in solution.
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
- **`Lactose/WepApiTest.http`** — 100+ numbered tests covering all API endpoints across 12 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Data Visibility → Visibility Gates → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin) and include comprehensive visibility gate tests (R2R4). Self-contained — all test data is created at runtime and cleaned up.
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
- Server must be running locally (default `http://localhost:5162`).
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.
## PR workflow
- Before starting work, ask the user whether they want the fix in a PR or directly on `develop` (for quick/trivial fixes).
- Larger work always goes in a PR.
- Always mark the PR as **draft** until the user confirms they are satisfied and have tested it works.
- When making the PR ready (removing draft status), mark it ready for review and request:
- **REDCODE** when the PR contains frontend changes
- **Fastwind** when the PR contains backend changes
- **Both** when both areas are touched
## Data sizes
Approximate record counts in the database:
| Entity | Count |
|---|---|
| Assets | 1,000,000+ |
| Albums | 10,000+ |
| People | 1,000+ |
| Users | 100+ |
| Maintainers | 10+ |
| Curators | single digit |
| Admins | single digit |
## Gitea labels
Labels live at two levels:
- **Org level** (`MilkyShots` org): generic issue types (`bug`, `enhancement`, `help wanted`, `question`, `duplicate`, `invalid`, `wontfix`)
- **Repo level** (`MilkyShots/MilkyShots`): project-specific labels (`area:frontend`, `area:backend`, `page:*`, `type:refactor`, `priority:*`, etc.)
- When labelling issues, check both levels with the `label_read` tool (`list_org_labels` / `list_repo_labels`).
+21 -6
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,25 +9,38 @@ public class AlbumAssetPreviewDto {
/// <summary>
/// Gets or sets the asset ID.
/// </summary>
public Guid Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the resolution width.
/// </summary>
public int ResolutionWidth { get; set; }
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height.
/// </summary>
public int ResolutionHeight { get; set; }
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets whether the asset has a thumbnail.
/// </summary>
public bool HasThumbnail { get; set; }
public bool HasThumbnail { get; set; }
/// <summary>
/// Gets or sets whether the asset has a preview.
/// </summary>
public bool HasPreview { get; set; }
public bool HasPreview { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset's display media. For animated assets this is the
/// converted video MIME type (e.g. "video/mp4"); otherwise the original MIME type.
/// </summary>
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
public string? FileName { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
/// </summary>
public DateTime? DeletedAt { get; set; }
}
+10 -8
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,21 +9,21 @@ public class AlbumCreateDto {
/// <summary>
/// Gets or sets the name of the album.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the owner ID of the album.
/// </summary>
public Guid? Owner { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets the cover asset ID for the album.
/// </summary>
public Guid? CoverAssetId { get; set; }
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid>? Assets { get; set; }
public List<Guid>? Assets { get; set; }
/// <summary>
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
/// </summary>
public EVisibility Visibility { get; set; } = EVisibility.Protected;
}
+18
View File
@@ -0,0 +1,18 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Represents a request to merge multiple albums into a single destination album.
/// </summary>
public class AlbumMergeDto {
/// <summary>
/// Gets or sets the ID of the destination album that will receive all assets.
/// Must not be present in <see cref="SourceIds"/>.
/// </summary>
public required Guid DestinationId { get; set; }
/// <summary>
/// Gets or sets the list of source album IDs whose assets will be moved to the destination.
/// Source albums will be hard-deleted after the merge.
/// </summary>
public required List<Guid> SourceIds { get; set; } = [];
}
+24 -10
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,29 +9,41 @@ public class AlbumPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the album.
/// </summary>
public Guid Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the album.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the owner ID of the album.
/// </summary>
public Guid? Owner { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets the person name associated with the album.
/// </summary>
public string? PersonName { get; set; }
public string? PersonName { get; set; }
/// <summary>
/// Gets or sets the ID of the cover asset for this album.
/// </summary>
public Guid? CoverAssetId { get; set; }
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; }
public int AssetCount { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the soft-delete timestamp. Always null for albums (not supported).
/// </summary>
public DateTime? DeletedAt { get; set; }
}
@@ -8,4 +8,15 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
/// Gets or sets whether to return only albums with no person assigned.
/// </summary>
public bool Unassigned { get; set; }
/// <summary>
/// Gets or sets the user ID to filter albums by.
/// 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; }
}
+11 -9
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,25 +9,25 @@ public class AlbumUpdateDto {
/// <summary>
/// Gets or sets the name of the album.
/// </summary>
public string? Name { get; set; }
/// <summary>
/// Gets or sets the owner ID of the album.
/// </summary>
public Guid? Owner { get; set; }
public string? Name { get; set; }
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets whether to remove the person assignment from the album.
/// </summary>
public bool RemovePerson { get; set; }
public bool RemovePerson { get; set; }
/// <summary>
/// Gets or sets the cover asset ID for the album.
/// </summary>
public Guid? CoverAssetId { get; set; }
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid>? Assets { get; set; }
public List<Guid>? Assets { get; set; }
/// <summary>
/// Gets or sets the visibility level.
/// </summary>
public EVisibility? Visibility { get; set; }
}
@@ -0,0 +1,19 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the parameters for browsing assets within a folder at a specific directory level.
/// </summary>
public class AssetBrowseOptionsDto : PagedSearchParametersDto {
/// <summary>
/// Gets or sets the root folder ID to browse within.
/// </summary>
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets the current path relative to the folder root (empty or null for root).
/// </summary>
public string? Path { get; set; }
/// <summary>
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
/// </summary>
public bool UnlinkedOnly { get; set; } = true;
}
+24
View File
@@ -0,0 +1,24 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents the result of browsing unlinked assets at a specific directory level within a folder.
/// </summary>
public class AssetBrowseResultDto {
/// <summary>
/// Gets or sets the list of immediate subdirectories at the current path level.
/// </summary>
public List<DirectoryEntryDto> Directories { get; set; } = [];
/// <summary>
/// Gets or sets the list of assets located directly at the current path level.
/// </summary>
public List<AssetPreviewDto> Assets { get; set; } = [];
/// <summary>
/// Gets or sets the total count of assets at the current path level (for pagination).
/// </summary>
public int TotalAssetCount { get; set; }
/// <summary>
/// Gets or sets whether the current path level contains at least one non-deleted asset
/// (registered in the database) that is not assigned to any album.
/// </summary>
public bool HasUnassignedFilesHere { get; set; }
}
+13 -12
View File
@@ -9,41 +9,42 @@ public class AssetCreateDto {
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the owner ID of the asset.
/// Gets or sets the ID of the user who uploaded the asset.
/// Null for scanner-created assets (system uploader will be used).
/// </summary>
public Guid Owner { get; set; }
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets the type of the asset.
/// </summary>
public EAssetType AssetType { get; set; }
public EAssetType AssetType { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
/// </summary>
public bool IsPublic { get; set; }
public EVisibility Visibility { get; set; } = EVisibility.Private;
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public string MimeType { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the resolution width in pixels.
/// </summary>
public int ResolutionWidth { get; set; }
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
public long FileSize { get; set; }
public long FileSize { get; set; }
/// <summary>
/// Gets or sets the duration in seconds (for video/audio).
/// </summary>
public int? Duration { get; set; }
public int? Duration { get; set; }
/// <summary>
/// Gets or sets the frame rate (for video).
/// </summary>
public int? FrameRate { get; set; }
public int? FrameRate { get; set; }
}
+9 -17
View File
@@ -10,41 +10,33 @@ public class AssetDto : AssetPreviewDto {
/// <summary>
/// Gets or sets the type of the asset (e.g., Image, Video).
/// </summary>
public EAssetType AssetType { get; set; }
public EAssetType AssetType { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// Gets or sets the user who uploaded the asset.
/// </summary>
public bool IsPublic { get; set; }
/// <summary>
/// Gets or sets the owner of the asset.
/// </summary>
public required UserInfoDto Owner { get; set; }
public required UserInfoDto UploadedBy { get; set; }
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the last updated date of the asset.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the deletion date of the asset.
/// </summary>
public DateTime? DeletedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public new string MimeType { get; set; } = string.Empty;
public new string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the file size of the asset in bytes.
/// </summary>
public long FileSize { get; set; }
public long FileSize { get; set; }
/// <summary>
/// Gets or sets the duration of the asset in seconds (for video/audio).
/// </summary>
public float Duration { get; set; }
public float Duration { get; set; }
/// <summary>
/// Gets or sets the frame rate of the asset (for video).
/// </summary>
public float FrameRate { get; set; }
public float FrameRate { get; set; }
}
+20
View File
@@ -0,0 +1,20 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents a group of unlinked assets (not assigned to any album),
/// grouped by folder for drill-down browsing in the asset picker.
/// </summary>
public class AssetGroupDto {
/// <summary>
/// Gets or sets the folder ID of the group. Null for assets with no folder or for the maintainer's "My Uploads" group.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the display name of the group (folder base path, or "My Uploads" for maintainers).
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the count of unlinked assets in this group.
/// </summary>
public int AssetCount { get; set; }
}
+33 -11
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
@@ -7,45 +9,65 @@ public class AssetPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the asset.
/// </summary>
public Guid Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public string MimeType { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the resolution width of the asset in pixels.
/// </summary>
public int ResolutionWidth { get; set; }
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height of the asset in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets whether the asset has a generated thumbnail.
/// </summary>
public bool HasThumbnail { get; set; }
public bool HasThumbnail { get; set; }
/// <summary>
/// Gets or sets whether the asset has a generated preview.
/// </summary>
public bool HasPreview { get; set; }
public bool HasPreview { get; set; }
/// <summary>
/// Gets or sets the list of album titles associated with the asset.
/// </summary>
public List<string>? AlbumNames { get; set; }
public List<string>? AlbumNames { get; set; }
/// <summary>
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
/// </summary>
public List<Guid>? AlbumIds { get; set; }
public List<Guid>? AlbumIds { get; set; }
/// <summary>
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
/// </summary>
public List<string>? CosplayerNames { get; set; }
public List<string>? CosplayerNames { get; set; }
/// <summary>
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
/// </summary>
public List<Guid>? CosplayerIds { get; set; }
public List<Guid>? CosplayerIds { get; set; }
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
public string? FileName { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the soft-delete timestamp. Only populated for admins and the asset uploader.
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets the timestamp when processing of the asset last failed. Only populated for curators and admins.
/// </summary>
public DateTime? ProcessFailedAt { get; set; }
/// <summary>
/// Gets or sets the error message recorded when the asset was last marked broken. Only populated for curators and admins.
/// </summary>
public string? ProcessErrorMessage { get; set; }
/// <summary>
/// Gets or sets the original file path of the asset. Only populated for curators and admins.
/// </summary>
public string? OriginalPath { get; set; }
}
@@ -26,4 +26,34 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// Gets or sets whether to order results randomly instead of by date.
/// </summary>
public bool Random { get; set; }
/// <summary>
/// Gets or sets whether to filter for assets not assigned to any album (unlinked).
/// </summary>
public bool Unlinked { get; set; }
/// <summary>
/// Gets or sets the folder ID to filter assets by their scan folder.
/// </summary>
public Guid? FolderId { get; set; }
/// <summary>
/// Gets or sets the uploader user ID to filter assets by their uploader.
/// </summary>
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets whether to filter exclusively for soft-deleted assets.
/// Results remain scoped by the requester's access level.
/// </summary>
public bool DeletedOnly { get; set; }
/// <summary>
/// Gets or sets the album ID to filter assets belonging to that album.
/// </summary>
public Guid? AlbumId { get; set; }
/// <summary>
/// Gets or sets the person ID to filter assets in albums owned by that person.
/// </summary>
public Guid? PersonId { 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;
}
+10 -6
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
@@ -5,15 +7,17 @@ namespace Butter.Dtos.Asset;
/// </summary>
public class AssetUpdateDto {
/// <summary>
/// Gets or sets the new owner ID for the asset.
/// Gets or sets the ID of the user who uploaded the asset.
/// </summary>
public Guid? Owner { get; set; }
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets the deletion date to set on the asset.
/// Gets or sets the soft-delete state to apply to the asset.
/// <see langword="true"/> soft-deletes the asset, <see langword="false"/> restores it,
/// and <see langword="null"/> leaves the current state unchanged.
/// </summary>
public DateTime? DeletedAt { get; set; }
public bool? IsDeleted { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// Gets or sets the visibility level.
/// </summary>
public bool? IsPublic { get; set; }
public EVisibility? Visibility { get; set; }
}
+15
View File
@@ -0,0 +1,15 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Reports the outcome of a bulk restore operation on soft-deleted assets.
/// </summary>
public class BulkRestoreResultDto {
/// <summary>
/// Gets or sets the IDs of assets that were successfully restored.
/// </summary>
public List<Guid> Restored { get; set; } = [];
/// <summary>
/// Gets or sets the IDs of assets that were skipped because their source file no longer exists on disk.
/// </summary>
public List<Guid> SkippedMissingFile { get; set; } = [];
}
+20
View File
@@ -0,0 +1,20 @@
namespace Butter.Dtos.Asset;
/// <summary>
/// Represents a subdirectory entry in the asset file browser.
/// </summary>
public class DirectoryEntryDto {
/// <summary>
/// Gets or sets the directory name (a single path segment, not a full path).
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the directory path relative to the folder root (forward slashes).
/// </summary>
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether this directory contains at least one non-deleted asset
/// that is not assigned to any album (directly or in a subdirectory).
/// </summary>
public bool HasUnassignedAssets { get; set; }
}
+5
View File
@@ -13,4 +13,9 @@ public class BulkDto<TDto> {
/// Gets or sets the update data to apply to each entity.
/// </summary>
public required TDto Data { get; set; }
/// <summary>
/// Gets or sets optional cascade options for the update (e.g. cascade a visibility change to albums and assets).
/// </summary>
public CascadeOptions? Cascade { get; set; }
}
+16
View File
@@ -0,0 +1,16 @@
namespace Butter.Dtos;
/// <summary>
/// Represents cascade options for a bulk visibility update.
/// </summary>
public class CascadeOptions {
/// <summary>
/// Gets or sets whether to cascade the visibility update to albums.
/// </summary>
public bool ToAlbums { get; set; }
/// <summary>
/// Gets or sets whether to cascade the visibility update to assets.
/// </summary>
public bool ToAssets { get; set; }
}
+21
View File
@@ -0,0 +1,21 @@
namespace Butter.Dtos;
/// <summary>
/// Represents the result of a bulk visibility update with cascading.
/// </summary>
public class CascadeResultDto {
/// <summary>
/// Gets or sets the number of people updated.
/// </summary>
public int PeopleUpdated { get; set; }
/// <summary>
/// Gets or sets the number of albums updated.
/// </summary>
public int AlbumsUpdated { get; set; }
/// <summary>
/// Gets or sets the number of assets updated.
/// </summary>
public int AssetsUpdated { get; set; }
}
+3 -1
View File
@@ -67,7 +67,9 @@ public enum EJobType {
/// <summary>Creates missing Person entities extracted from asset paths via folder regex patterns.</summary>
CreatePersons,
/// <summary>Creates missing Person and Album entities extracted from asset paths via folder regex patterns.</summary>
CreateAlbums
CreateAlbums,
/// <summary>Converts animated assets (GIF) to video (mp4) and extracts a first-frame thumbnail.</summary>
AnimatedConversion
}
/// <summary>
+6
View File
@@ -4,6 +4,12 @@ namespace Butter.Dtos;
/// Represents pagination parameters for a query.
/// </summary>
public class PagedParametersDto {
/// <summary>
/// Contains the global constant for max pages.
/// </summary>
public const int MaxPageSize = 250;
/// <summary>
/// Gets or sets the page number (zero-based).
/// </summary>
+11 -5
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,21 +9,25 @@ public class PersonCreateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID to use as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
/// </summary>
public EVisibility Visibility { get; set; } = EVisibility.Protected;
}
+13
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
namespace Butter.Dtos.Person;
@@ -39,7 +40,19 @@ public class PersonDetailedDto {
/// </summary>
public int TotalAssets { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the list of album previews associated with this person.
/// </summary>
public List<AlbumPreviewDto>? Albums { get; set; }
/// <summary>
/// Gets or sets the IDs of users who maintain this person.
/// </summary>
public List<Guid>? MaintainerUserIds { get; set; }
/// <summary>
/// Gets or sets the usernames of users who maintain this person.
/// </summary>
public List<string>? MaintainerUsernames { get; set; }
}
+18
View File
@@ -0,0 +1,18 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Represents a request to merge multiple people into a single destination person.
/// </summary>
public class PersonMergeDto {
/// <summary>
/// Gets or sets the ID of the destination person that will receive all associated data.
/// Must not be present in <see cref="SourceIds"/>.
/// </summary>
public required Guid DestinationId { get; set; }
/// <summary>
/// Gets or sets the list of source person IDs whose albums, faces, and maintainers
/// will be reassigned to the destination. Source people will be hard-deleted.
/// </summary>
public required List<Guid> SourceIds { get; set; } = [];
}
+13 -7
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,29 +9,33 @@ public class PersonPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the person.
/// </summary>
public required Guid Id { get; set; }
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID used as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the number of albums associated with this person.
/// </summary>
public int TotalAlbums { get; set; }
public int TotalAlbums { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
}
+15 -5
View File
@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,21 +9,29 @@ public class PersonUpdateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
public string? Name { get; set; }
/// <summary>
/// Gets or sets the asset ID to use as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the visibility level.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
/// </summary>
public List<Guid>? MaintainerUserIds { get; set; }
}
+11 -4
View File
@@ -75,7 +75,11 @@ public class StatsDto {
/// </summary>
public int PublicAssets { get; set; }
/// <summary>
/// Gets or sets the number of non-publicly-shared assets.
/// Gets or sets the number of assets with protected visibility (authenticated only).
/// </summary>
public int ProtectedAssets { get; set; }
/// <summary>
/// Gets or sets the number of private assets.
/// </summary>
public int PrivateAssets { get; set; }
@@ -104,6 +108,10 @@ public class StatsDto {
/// </summary>
public int AssetsMissingPhash { get; set; }
/// <summary>
/// Gets or sets the number of assets whose processing failed (marked broken).
/// </summary>
public int TotalBrokenAssets { get; set; }
/// <summary>
/// Gets or sets the number of assets with no person assigned (no album with a PersonOwner).
/// </summary>
public int AssetsWithNoPerson { get; set; }
@@ -116,10 +124,9 @@ public class StatsDto {
/// </summary>
public int AlbumsMissingCover { get; set; }
/// <summary>
/// Gets or sets the number of people/cosplayers without an album that has a cover image.
/// Gets or sets the number of people/cosplayers without a profile picture.
/// </summary>
/// <remarks>Not wired yet — reserved for future use.</remarks>
public int CosplayersMissingCover { get; set; }
public int CosplayersMissingProfile { get; set; }
/// <summary>
/// Gets or sets the top tags by asset count.
+9
View File
@@ -46,4 +46,13 @@ public class UserInfoDto {
/// Gets or sets the access level of the user.
/// </summary>
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
/// <summary>
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
/// </summary>
public List<Guid>? MaintainedPersonIds { get; set; }
/// <summary>
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
/// </summary>
public List<string>? MaintainedPersonNames { get; set; }
}
+4
View File
@@ -38,4 +38,8 @@ public string? OldPassword { get; set; }
/// Gets or sets the new access level.
/// </summary>
public EAccessLevel? AccessLevel { get; set; }
/// <summary>
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
/// </summary>
public List<Guid>? MaintainedPersonIds { get; set; }
}
+2 -1
View File
@@ -26,7 +26,6 @@ public static class MimeTypes{
new("image/bmp}", [".bmp"]),
new("image/cgm", [".cgm"]),
new("image/g3fax", [".g3"]),
new("image/gif", [".gif"]),
new("image/heic", [".heif", ".heic"]),
new("image/ief", [".ief"]),
new("image/jpeg", [".jpe", ".jpeg", ".jpg", ".pjpg", ".jfif", ".jfif-tbnl", ".jif"]),
@@ -89,6 +88,8 @@ public static class MimeTypes{
public static readonly MimeTypeMap[] Video = [
new("video/3gpp", [".3gp"]),
new("video/3gpp2", [".3g2"]),
// Animated GIFs are repurposed as video so they flow through the video rendering path.
new("image/gif", [".gif"]),
new("video/h261", [".h261"]),
new("video/h263", [".h263"]),
new("video/h264", [".h264"]),
+2
View File
@@ -22,6 +22,8 @@ public enum DisplayType {
TimePicker = 7,
/// <summary>Power of 2 selection.</summary>
Po2W = 8,
/// <summary>User picker (searchable dropdown).</summary>
UserSelect = 9,
}
/// <summary>
+14 -2
View File
@@ -31,13 +31,21 @@ public enum Settings {
/// <summary>Quality setting (1100) for WebP thumbnail encoding.</summary>
ThumbnailQuality,
/// <summary>Quality setting (1100) for WebP preview encoding.</summary>
PreviewQuality
PreviewQuality,
/// <summary>Path to store converted videos for animated assets (e.g. GIF → mp4).</summary>
ConvertedPath,
/// <summary>Container format for converted videos (e.g. "mp4").</summary>
ConvertedFormat,
/// <summary>Path to the ffmpeg executable (defaults to "ffmpeg" on PATH).</summary>
FfmpegPath,
/// <summary>User ID used for scanner-created asset attribution.</summary>
SystemUploaderId
}
/// <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>
@@ -59,6 +67,10 @@ public static class SettingsExtensions {
Settings.JobBatchSize => "Job Batch Size",
Settings.ThumbnailQuality => "Thumbnail Quality",
Settings.PreviewQuality => "Preview Quality",
Settings.ConvertedPath => "Converted Path",
Settings.ConvertedFormat => "Converted Format",
Settings.FfmpegPath => "FFmpeg Path",
Settings.SystemUploaderId => "System Uploader Id",
_ => setting.ToString() // Fallback to the enum name
};
}
+5 -3
View File
@@ -5,9 +5,11 @@ namespace Butter.Types;
/// </summary>
public enum EAccessLevel {
/// <summary>Standard user with basic permissions.</summary>
User,
User = 0,
/// <summary>Maintainer — scoped cosplayer management and tag management.</summary>
Maintainer = 1,
/// <summary>Curator with elevated permissions for managing assets.</summary>
Curator,
Curator = 2,
/// <summary>Administrator with full system access.</summary>
Admin
Admin = 3
}
+13
View File
@@ -0,0 +1,13 @@
namespace Butter.Types;
/// <summary>
/// Defines visibility levels for entities in the system.
/// </summary>
public enum EVisibility {
/// <summary>Visible to everyone, including anonymous users.</summary>
Public = 0,
/// <summary>Visible to authenticated users only.</summary>
Protected = 1,
/// <summary>Visible only to authorized users (maintainers, curators, admins).</summary>
Private = 2
}
+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
+3 -3
View File
@@ -15,9 +15,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.12.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" PrivateAssets="all" />
</ItemGroup>
</Project>
+1 -1
View File
@@ -1,8 +1,8 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
namespace Lactose.Analyzers;
+4 -4
View File
@@ -1,13 +1,13 @@
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
namespace Lactose.Analyzers;
@@ -57,7 +57,7 @@ public class RefreshTokenTransformation(
/// </summary>
public const string ClaimType = "LoginValid";
/// <inheritdoc />
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
ClaimsIdentity claimsIdentity = new ClaimsIdentity();
if (principal.HasClaim(claim => claim.Type == ClaimType)) {
@@ -68,12 +68,12 @@ public class RefreshTokenTransformation(
}
}
var userData = authService.GetUserData(principal);
if (userData == null) return Task.FromResult(principal);
var user = userRepository.Find(userData.Id);
if (user == null) return Task.FromResult(principal);
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
if (userData == null) return principal;
var user = await userRepository.FindAsync(userData.Id, CancellationToken.None);
if (user == null) return principal;
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return principal;
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
principal.AddIdentity(claimsIdentity);
return Task.FromResult(principal);
return principal;
}
}
@@ -0,0 +1,61 @@
namespace Lactose.Configuration;
/// <summary>
/// Configuration for rate-limit buckets applied to different API route groups.
/// </summary>
public record RateLimitBucketOptions {
/// <summary>
/// The configuration section name for rate limiting.
/// </summary>
public const string SectionName = "RateLimiting";
/// <summary>
/// Default bucket applied to all routes that are not explicitly assigned.
/// </summary>
public BucketOptions Global { get; set; } = new();
/// <summary>
/// Strict bucket for authentication endpoints (login, register, refresh).
/// </summary>
public BucketOptions Auth { get; set; } = new();
/// <summary>
/// Bucket for serving original (full-size) media files.
/// </summary>
public BucketOptions MediaOriginal { get; set; } = new();
/// <summary>
/// Bucket for serving thumbnail media files.
/// </summary>
public BucketOptions MediaThumb { get; set; } = new();
/// <summary>
/// Bucket for serving preview media files.
/// </summary>
public BucketOptions MediaPreview { get; set; } = new();
/// <summary>
/// Bucket for the long-polling jobs endpoint. Uses a shorter window.
/// </summary>
public BucketOptions Jobs { get; set; } = new();
}
/// <summary>
/// Settings for an individual rate-limit bucket.
/// </summary>
public class BucketOptions {
/// <summary>
/// Maximum number of permits allowed within the window.
/// </summary>
public int PermitLimit { get; set; } = 100;
/// <summary>
/// Length of the fixed window in seconds.
/// </summary>
public int WindowSeconds { get; set; } = 60;
/// <summary>
/// Maximum number of queued requests waiting for a permit. Null disables queueing.
/// </summary>
public int? QueueLimit { get; set; }
}
@@ -0,0 +1,17 @@
using Microsoft.Extensions.Options;
namespace Lactose.Configuration;
/// <summary>
/// Configures <see cref="RateLimitBucketOptions"/> from the application configuration.
/// </summary>
public class RateLimitBucketOptionsProvider(IConfiguration configuration) : IConfigureOptions<RateLimitBucketOptions> {
/// <inheritdoc />
public void Configure(RateLimitBucketOptions options) {
var section = configuration.GetSection(RateLimitBucketOptions.SectionName);
if (!section.Exists())
return;
section.Bind(options);
}
}
+81 -5
View File
@@ -42,6 +42,18 @@ public class LactoseDbContext : DbContext {
/// Gets or sets the job records DbSet.
/// </summary>
public DbSet<JobRecord> JobRecords { get; set; }
/// <summary>
/// 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.
@@ -60,18 +72,66 @@ public class LactoseDbContext : DbContext {
/// <inheritdoc />
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.UserOwner).WithMany(e => e.OwnedAlbums);
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
//Asset Relationships
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets);
modelBuilder.Entity<Asset>().HasOne(e => e.Uploader).WithMany(e => e.UploadedAssets).HasForeignKey(e => e.UploadedBy);
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
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");
// Broken assets
modelBuilder.Entity<Asset>().HasIndex(e => e.ProcessFailedAt)
.HasDatabaseName("IX_Assets_ProcessFailedAt")
.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")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// Albums
modelBuilder.Entity<Album>().HasIndex(a => a.Title)
.HasDatabaseName("IX_Albums_Title_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
//Face Relationships
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
@@ -79,13 +139,29 @@ public class LactoseDbContext : DbContext {
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
modelBuilder.Entity<Person>().HasMany(e => e.Maintainers).WithMany(e => e.MaintainedPersons)
.UsingEntity<PersonMaintainer>(
j => j.HasOne(pm => pm.User).WithMany().HasForeignKey(pm => pm.UserId),
j => j.HasOne(pm => pm.Person).WithMany().HasForeignKey(pm => pm.PersonId)
);
//Tag Relationships
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
//User Relationships
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
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.");
}
+128 -106
View File
@@ -16,6 +16,7 @@ namespace Lactose.Controllers;
/// <param name="authService">Authentication service.</param>
/// <param name="albumRepository">Album repository.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="personRepository">Person repository.</param>
[ApiController]
[Authorize]
[Route("api/[controller]")]
@@ -24,87 +25,56 @@ public class AlbumController(
//ILogger<AlbumController> logger,
LactoseAuthService authService,
IAlbumRepository albumRepository,
IAssetRepository assetRepository
IAssetRepository assetRepository,
IPersonRepository personRepository
) : ControllerBase {
/// <summary>
/// Gets an album by its ID with filtered assets based on access level.
/// </summary>
/// <param name="id">The album ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The full album details with accessible assets, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<AlbumFullDto> Get([FromRoute] Guid id) {
public async Task<ActionResult<AlbumFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
Guid? uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
var album = await albumRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
// If the album does not exist, return a 404
if (album == null) return NotFound();
// If the album has no assets, return an empty list
if (album.Assets == null) return Ok(album.ToAlbumFullDto());
//Storage for assets that will be sent out;
List<Asset> assets = new();
switch (accessLevel) {
case EAccessLevel.User:
// Only publicly shared assets or owned by user
assets.AddRange(album.Assets.Where(asset => (asset.IsPubliclyShared || asset.OwnerId == uid) && asset.DeletedAt == null));
// Add shared assets
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid) && asset.DeletedAt == null));
break;
case EAccessLevel.Curator:
// all assets minus deleted
assets.AddRange(album.Assets.Where(asset => asset.DeletedAt == null));
break;
case EAccessLevel.Admin:
// All assets
assets.AddRange(album.Assets);
break;
}
// changes the retrieved album to the new filtered
album.Assets = assets.OrderBy(a => a.OriginalFilename).ToList();
return Ok(album.ToAlbumFullDto());
return Ok(album.ToAlbumFullDto(accessLevel, uid));
}
/// <summary>
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
/// </summary>
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of album previews accessible to the user.</returns>
[HttpGet]
[AllowAnonymous]
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
public async Task<ActionResult<List<AlbumPreviewDto>>> Search([FromQuery] AlbumSearchParametersDto pagingOptions, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 0) return BadRequest();
var albums = albumRepository.SearchQuery(
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
var albums = await albumRepository.SearchQueryAsync(
pagingOptions.Search ?? string.Empty,
pagingOptions.Page,
pagingOptions.PageSize,
pagingOptions.SortBy,
pagingOptions.SortAsc,
pagingOptions.Unassigned
).ToList();
pagingOptions.Unassigned,
uid ?? default,
accessLevel,
pagingOptions.AssetUploadedBy,
pagingOptions.PersonOwnerId,
cancellationToken
);
switch (accessLevel) {
default:
case EAccessLevel.User:
List<Album> filteredAlbums;
// Only publicly shared albums or owned by user
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared && asset.DeletedAt == null))).ToList();
// Add shared albums
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.DeletedAt == null && x.SharedWith!.Any(user => user.Id == uid))));
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
case EAccessLevel.Curator:
case EAccessLevel.Admin:
// All albums
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
}
return Ok(albums);
}
/// <summary>
@@ -112,40 +82,36 @@ public class AlbumController(
/// </summary>
/// <param name="id">The album ID.</param>
/// <param name="albumDto">The album update data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin, Curator")]
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto, CancellationToken cancellationToken) {
Guid? uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
var album = await albumRepository.FindWithAssetsAsync(id, cancellationToken);
// If the album does not exist, return a 404
if (album == null) return NotFound();
switch (accessLevel) {
case EAccessLevel.User:
// Users can't update albums they don't own
if(album.UserOwnerId != uid) return Forbid();
break;
// Admins and Curator can update any album
case EAccessLevel.Curator:
case EAccessLevel.Admin:
break;
}
// Maintainer scope check — must maintain the album's person
if (accessLevel == EAccessLevel.Maintainer && (album.PersonOwnerId == null || !await personRepository.IsMaintainerOfAsync(uid!.Value, album.PersonOwnerId.Value, cancellationToken)))
return Forbid();
if (accessLevel == EAccessLevel.User)
return Forbid();
// Update album properties
album.Title = string.IsNullOrEmpty(albumDto.Name) ? album.Title : albumDto.Name;
album.UpdatedAt = DateTime.UtcNow;
album.PersonOwnerId = albumDto.RemovePerson ? null : albumDto.Person ?? album.PersonOwnerId;
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
album.Visibility = albumDto.Visibility ?? album.Visibility;
album.Assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : album.Assets;
album.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
// Save changes
albumRepository.Update(album);
albumRepository.Save();
await albumRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -154,96 +120,148 @@ public class AlbumController(
/// Updates multiple albums in a bulk operation.
/// </summary>
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 403 if forbidden.</returns>
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<ActionResult> BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var albums = albumRepository.FindBulk(bulkDto.Ids).ToList();
var albums = await albumRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
switch (accessLevel) {
case EAccessLevel.User:
// Users can't update albums
return Forbid();
case EAccessLevel.Maintainer: {
// Maintainers can only update albums of persons they maintain
List<Album> maintained = [];
foreach (var a in albums) {
if (a.PersonOwnerId != null && await personRepository.IsMaintainerOfAsync(uid!.Value, a.PersonOwnerId.Value, cancellationToken))
maintained.Add(a);
}
albums = maintained;
if (albums.Count == 0) return Forbid();
break;
}
case EAccessLevel.Curator:
// Curators can only update albums they own
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
// Curators can update any album
break;
case EAccessLevel.Admin:
// Admins can update any album
break;
}
foreach (var album in albums) {
album.Title = bulkDto.Data.Name ?? album.Title;
album.UpdatedAt = DateTime.UtcNow;
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
album.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
album.Assets = bulkDto.Data.Assets != null ? assetRepository.FindBulk(bulkDto.Data.Assets).ToList() : album.Assets;
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
}
var result = await albumRepository.ExecuteInTransactionAsync(async () => {
foreach (var album in albums) {
album.Title = bulkDto.Data.Name ?? album.Title;
album.UpdatedAt = DateTime.UtcNow;
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
album.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
album.Assets = bulkDto.Data.Assets != null ? await assetRepository.FindBulkAsync(bulkDto.Data.Assets, CancellationToken.None) : album.Assets;
album.CoverAssetId = bulkDto.Data.CoverAssetId ?? album.CoverAssetId;
}
albumRepository.UpdateBulk(albums);
albumRepository.Save();
var result = new CascadeResultDto { AlbumsUpdated = albums.Count };
var cascade = bulkDto.Cascade;
var visibility = bulkDto.Data.Visibility;
return Ok();
if (cascade?.ToAssets == true && visibility.HasValue) {
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByAlbumIdsAsync(
albums.Select(a => a.Id).ToList(), visibility.Value, uid, accessLevel, CancellationToken.None);
}
await albumRepository.SaveAsync(CancellationToken.None);
return result;
});
return Ok(result);
}
/// <summary>
/// Creates a new album.
/// </summary>
/// <param name="albumDto">The album creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin, Curator")]
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
var uid = authService.GetUserData(User)?.Id;
[Authorize(Roles = "Curator,Admin")]
public async Task<ActionResult> Create([FromBody] AlbumCreateDto albumDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
// If not admin, the owner of a new album is the current user
albumDto.Owner = accessLevel == EAccessLevel.Admin ? albumDto.Owner : uid;
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
var assets = albumDto.Assets != null ? await assetRepository.FindBulkAsync(albumDto.Assets, cancellationToken) : [];
var album = new Album {
Id = Guid.NewGuid(),
Title = albumDto.Name,
CreatedAt = DateTime.UtcNow,
PersonOwnerId = albumDto.Person ?? null,
UserOwnerId = albumDto.Owner,
Visibility = albumDto.Visibility,
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
Assets = assets
};
albumRepository.Insert(album);
albumRepository.Save();
await albumRepository.SaveAsync(cancellationToken);
return Ok(album.Id);
}
/// <summary>
/// Merges multiple source albums into a destination album.
/// All assets from source albums are moved to the destination, then sources are hard-deleted.
/// </summary>
/// <param name="dto">The merge request with destination and source IDs.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
[HttpPost("merge")]
[Authorize(Roles = "Curator,Admin")]
public async Task<ActionResult> Merge([FromBody] AlbumMergeDto dto, CancellationToken cancellationToken) {
if (dto.SourceIds.Contains(dto.DestinationId))
return BadRequest("Destination album ID must not appear in source album IDs.");
if (await albumRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
return NotFound("Destination album not found.");
await albumRepository.MergeAlbumsAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
await albumRepository.SaveAsync(cancellationToken);
return Ok();
}
/// <summary>
/// Removes multiple albums (soft delete).
/// </summary>
/// <param name="albumIds">The list of album IDs to remove.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 403 if forbidden.</returns>
[HttpDelete]
[Authorize(Roles = "Admin, Curator")]
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<ActionResult> BulkDelete([FromBody] List<Guid> albumIds, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var albums = albumRepository.FindBulk(albumIds).ToList();
var albums = await albumRepository.FindBulkAsync(albumIds, cancellationToken);
// If the user is not an admin, filter out albums they do not own
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
// Filter by access level
switch (accessLevel) {
case EAccessLevel.Maintainer: {
List<Album> maintained = [];
foreach (var a in albums) {
if (a.PersonOwnerId != null && await personRepository.IsMaintainerOfAsync(uid!.Value, a.PersonOwnerId.Value, cancellationToken))
maintained.Add(a);
}
albums = maintained;
break;
}
case EAccessLevel.User:
return Forbid();
}
// If no albums are left after filtering, return a 403
if (albumIds.Count == 0) return Forbid();
if (albums.Count == 0) return Forbid();
albums.ForEach(albumRepository.Remove);
albumRepository.Save();
await albumRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -252,22 +270,26 @@ public class AlbumController(
/// Removes an album by its ID (soft delete).
/// </summary>
/// <param name="id">The album ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin, Curator")]
public ActionResult Delete(Guid id) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<ActionResult> Delete(Guid id, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
var album = await albumRepository.FindAsync(id, cancellationToken);
// If the album does not exist, return a 404
if (album == null) return NotFound();
// If the user is not the owner or an admin, return a 403
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) return Forbid();
if (accessLevel == EAccessLevel.Maintainer && (album.PersonOwnerId == null || !await personRepository.IsMaintainerOfAsync(uid!.Value, album.PersonOwnerId.Value, cancellationToken)))
return Forbid();
if (accessLevel == EAccessLevel.User)
return Forbid();
albumRepository.Remove(album);
albumRepository.Save();
await albumRepository.SaveAsync(cancellationToken);
return Ok();
}
+257 -110
View File
@@ -18,21 +18,26 @@ namespace Lactose.Controllers;
/// <param name="authService">Authentication service.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
/// <param name="filesystemBrowseService">Filesystem browsing service for the maintenance page.</param>
[ApiController]
[Route("api/[controller]")]
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository,
IUserRepository userRepository
IUserRepository userRepository,
IPersonRepository personRepository,
IFilesystemBrowseService filesystemBrowseService
) : ControllerBase {
/// <summary>
/// Gets an asset by its ID with full details, respecting access level.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The full asset details, or 404/401 based on permissions.</returns>
[HttpGet("{id}")]
public ActionResult<AssetDto> Get(Guid id) {
public async Task<ActionResult<AssetDto>> Get(Guid id, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
@@ -44,50 +49,14 @@ public class AssetController(
"""
);
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
logger.LogWarning($"Request asset {id} not found or not visible!");
return NotFound();
}
switch (accessLevel) {
case EAccessLevel.User: {
//Not publicly shared && Not shared with the user
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
return Unauthorized();
}
//Deleted asset
if (asset.DeletedAt != null) {
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
return NotFound();
}
break;
}
case EAccessLevel.Curator: {
//Deleted asset owned by the user
if (asset.DeletedAt != null)
if (asset.Owner?.Id != uid) {
logger.LogTrace(
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
);
return NotFound();
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
break;
}
case EAccessLevel.Admin: {
//Admins will see this regardless
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
break;
}
}
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
logger.LogTrace(
$"""
@@ -95,11 +64,11 @@ public class AssetController(
dto.Id: {dto.Id}
dto.FileName: {dto.FileName}
dto.AssetType: {dto.AssetType}
dto.IsPublic: {dto.IsPublic}
dto.Visibility: {dto.Visibility}
dto.CreatedAt: {dto.CreatedAt}
dto.UpdatedAt: {dto.UpdatedAt}
dto.DeletedAt: {dto.DeletedAt}
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
"""
);
@@ -107,12 +76,13 @@ public class AssetController(
}
/// <summary>
/// Searches assets with optional date range, type filter, and random ordering with pagination.
/// Searches assets with optional date range, type filter, random ordering, unlinked filter, folder filter, uploader filter, search term, and pagination.
/// </summary>
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
/// <param name="searchOptionsDto">Search options including date range, type filter, unlinked filter, folder filter, uploader filter, random ordering, search term, and pagination.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of asset previews.</returns>
[HttpGet]
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
public async Task<ActionResult<List<AssetPreviewDto>>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto, CancellationToken cancellationToken) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
@@ -124,14 +94,18 @@ public class AssetController(
return BadRequest();
}
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1) {
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
logger.LogWarning("Invalid pagination parameters provided");
return BadRequest();
}
var assets = assetRepository.GetAssets(
var (assets, total) = await assetRepository.GetAssetsAsync(
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
searchOptionsDto.Page + 1, searchOptionsDto.PageSize, out int total
searchOptionsDto.Page, searchOptionsDto.PageSize,
uid, accessLevel,
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
searchOptionsDto.IncludeCount, searchOptionsDto.DeletedOnly, searchOptionsDto.AlbumId, searchOptionsDto.PersonId,
cancellationToken
);
logger.LogTrace(
@@ -146,25 +120,94 @@ public class AssetController(
"""
);
switch (accessLevel) {
case EAccessLevel.User: {
assets = assets.Where(
a => a.DeletedAt == null && (a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null)
);
break;
}
case EAccessLevel.Curator:
case EAccessLevel.Admin: {
break;
}
}
var dtoList = assets.ToAssetPreviewDto().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>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
/// Admins and curators receive all folders with their unlinked asset counts. Maintainers receive a single
/// "My Uploads" group with their own unlinked upload count.
/// </summary>
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
[HttpGet("unlinked-groups")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<ActionResult<List<AssetGroupDto>>> GetFolderGroups([FromQuery] bool unlinkedOnly = true, CancellationToken cancellationToken = default) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
logger.LogTrace("GetFolderGroups: uid={Uid}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", uid, accessLevel, unlinkedOnly);
var groups = await assetRepository.GetFolderGroupsAsync(uid, accessLevel, unlinkedOnly, cancellationToken);
logger.LogTrace("GetFolderGroups: returned {Count} groups", groups.Count);
return Ok(groups);
}
/// <summary>
/// Browses a folder's filesystem directly, returning the database asset records for the files found on disk.
/// Used by the maintenance page; does not reconstruct the folder tree from stored paths.
/// </summary>
/// <param name="options">Browse options including folder ID, relative path, pagination, search, and unlinked-only filter.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A browse result with subdirectories and assets at the current level, or 404 if the path is invalid.</returns>
[HttpGet("directory")]
[Authorize(Roles = "Curator,Admin")]
public async Task<ActionResult<AssetBrowseResultDto>> FsBrowse([FromQuery] AssetBrowseOptionsDto options, CancellationToken cancellationToken) {
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
return BadRequest();
logger.LogTrace("FsBrowse: folderId={FolderId}, path={Path}, page={Page}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, options.UnlinkedOnly);
var result = await filesystemBrowseService.BrowseAsync(options.FolderId, options.Path ?? "", options.Page, options.PageSize, options.UnlinkedOnly, options.Search, uid, accessLevel, cancellationToken);
if (result == null) return NotFound();
return Ok(result);
}
/// <summary>
/// Returns a paginated list of assets marked broken (processing failed, not deleted).
/// </summary>
/// <param name="page">The zero-based page number.</param>
/// <param name="pageSize">The number of assets per page.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of broken assets; total count in the <c>X-Total-Count</c> header.</returns>
[HttpGet("broken")]
[Authorize(Roles = "Curator,Admin")]
public async Task<ActionResult<List<AssetPreviewDto>>> GetBroken([FromQuery] int page = 0, [FromQuery] int pageSize = 50, CancellationToken cancellationToken = default) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize)
return BadRequest();
var (assets, total) = await assetRepository.GetBrokenAssetsAsync(page, pageSize, cancellationToken);
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(assets);
}
/// <summary>
/// Clears the broken (processing failed) state for the given assets so they are picked up
/// for re-processing on the next job run.
/// </summary>
/// <param name="ids">The list of asset IDs to retry.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost("broken/retry")]
[Authorize(Roles = "Curator,Admin")]
public async Task<IStatusCodeActionResult> RetryBroken([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel < EAccessLevel.Curator) { return Unauthorized(); }
if (ids.Count == 0) return BadRequest();
await assetRepository.ClearProcessErrorAsync(ids, cancellationToken);
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
/// <summary>
@@ -172,39 +215,53 @@ public class AssetController(
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="dto">The asset update data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Curator,Admin")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !await IsMaintainedByUser(uid.Value, asset, cancellationToken))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
return Unauthorized();
}
string log = "";
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
asset.Visibility = dto.Visibility ?? asset.Visibility;
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
if (dto.IsDeleted.HasValue) {
// Restoring a soft-deleted asset requires its source file to still exist on disk;
// otherwise the crawl/integrity jobs would silently re-delete it on their next run.
if (!dto.IsDeleted.Value && asset.DeletedAt != null && !System.IO.File.Exists(asset.OriginalPath)) {
logger.LogWarning($"Cannot restore asset {id}: source file '{asset.OriginalPath}' does not exist on disk!");
return Conflict();
}
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
DateTime? newDeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null;
log += $"DeletedAt: {asset.DeletedAt} -> {newDeletedAt}\n";
asset.DeletedAt = newDeletedAt;
}
if (accesslevel == EAccessLevel.Admin) {
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
}
logger.LogTrace($"Updating asset {id}\n{log}");
assetRepository.Update(asset);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -213,13 +270,14 @@ public class AssetController(
/// Updates multiple assets in a bulk operation.
/// </summary>
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, or an error response.</returns>
[HttpPost]
[Authorize(Roles = "Curator,Admin")]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(bulkDto.Ids);
var assets = await assetRepository.FindBulkAsync(bulkDto.Ids, cancellationToken);
var enumerable = assets as List<Asset> ?? assets.ToList();
@@ -234,45 +292,93 @@ public class AssetController(
}
//If the user is not an admin, they can only update their own assets
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
: enumerable.Where(x => x.UploadedBy == uid).ToList();
if (bulkDto.Data.IsDeleted == false) {
var result = ApplyBulkRestore(enumerable, bulkDto.Data);
assetRepository.UpdateBulk(enumerable);
await assetRepository.SaveAsync(cancellationToken);
return Ok(result);
}
enumerable.ForEach(
x => {
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
x.DeletedAt = bulkDto.Data.IsDeleted == true ? DateTime.UtcNow : x.DeletedAt;
if (accesslevel == EAccessLevel.Admin) {
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
}
}
);
assetRepository.UpdateBulk(enumerable);
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
/// <summary>
/// Applies a restore request (<c>IsDeleted</c> = <see langword="false"/>) to the given assets.
/// Assets whose source file no longer exists on disk are skipped and reported instead of restored,
/// since the crawl/integrity jobs would re-delete them otherwise. All other update fields are still
/// applied to the assets that are not skipped.
/// </summary>
/// <param name="assets">The assets selected for the bulk update.</param>
/// <param name="data">The shared update values.</param>
/// <returns>The outcome listing restored IDs and IDs skipped due to missing files.</returns>
private BulkRestoreResultDto ApplyBulkRestore(List<Asset> assets, AssetUpdateDto data) {
BulkRestoreResultDto result = new();
foreach (var x in assets) {
if (x.DeletedAt != null && !System.IO.File.Exists(x.OriginalPath)) {
result.SkippedMissingFile.Add(x.Id);
continue;
}
bool wasRestored = x.DeletedAt != null;
x.Visibility = data.Visibility ?? x.Visibility;
x.DeletedAt = null;
if (wasRestored) result.Restored.Add(x.Id);
}
if (result.SkippedMissingFile.Count > 0)
logger.LogWarning($"Bulk restore: {result.SkippedMissingFile.Count} asset(s) skipped because their source files are missing on disk");
return result;
}
/// <summary>
/// Soft-deletes an asset by its ID.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Curator,Admin")]
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<IStatusCodeActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !await IsMaintainedByUser(uid.Value, asset, cancellationToken))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
return Unauthorized();
}
asset.DeletedAt = DateTime.UtcNow;
assetRepository.Update(asset);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -281,13 +387,14 @@ public class AssetController(
/// Soft-deletes multiple assets in a bulk operation.
/// </summary>
/// <param name="ids">The list of asset IDs to delete.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, or an error response.</returns>
[HttpDelete]
[Authorize(Roles = "Curator,Admin")]
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<IStatusCodeActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var assets = assetRepository.FindBulk(ids);
var assets = await assetRepository.FindBulkAsync(ids, cancellationToken);
var enumerable = assets as List<Asset> ?? assets.ToList();
@@ -302,11 +409,15 @@ public class AssetController(
}
//If the user is not an admin, they can only delete their own assets
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -314,31 +425,31 @@ public class AssetController(
/// Creates a new asset record.
/// </summary>
/// <param name="dto">The asset creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new asset ID, or an error response.</returns>
[NonAction]
[HttpPut]
[Authorize(Roles = "Curator,Admin")]
public ActionResult<Guid> Create([FromBody] AssetCreateDto dto) {
public async Task<ActionResult<Guid>> Create([FromBody] AssetCreateDto dto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
if (userRepository.Find(dto.Owner) == null) {
logger.LogWarning($"User {dto.Owner} does not exist in the database");
return BadRequest();
}
if (dto.UploadedBy.HasValue) {
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
return BadRequest();
}
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
return Unauthorized();
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
return Unauthorized();
}
}
//TODO: This makes exactly zero sense. How are we supposed to create an asset externally here?
// Does this even need to exist?
var asset = new Asset {
Id = Guid.NewGuid(),
Type = dto.AssetType,
IsPubliclyShared = dto.IsPublic,
OwnerId = dto.Owner,
Visibility = dto.Visibility,
UploadedBy = dto.UploadedBy,
CreatedAt = dto.CreatedAt,
MimeType = dto.MimeType,
ResolutionWidth = dto.ResolutionWidth,
@@ -347,12 +458,48 @@ 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);
assetRepository.Save();
await assetRepository.InsertAsync(asset, cancellationToken);
await assetRepository.SaveAsync(cancellationToken);
return Ok(asset.Id);
}
/// <summary>
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
/// </summary>
/// <param name="uid">The maintainer's user ID.</param>
/// <param name="asset">The asset to check.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns><see langword="true"/> if the maintainer is linked via album→person→maintainer chain.</returns>
private async Task<bool> IsMaintainedByUser(Guid uid, Asset asset, CancellationToken cancellationToken) {
if (asset.Albums == null) return false;
foreach (var album in asset.Albums) {
if (album.PersonOwnerId.HasValue
&& await personRepository.IsMaintainerOfAsync(uid, album.PersonOwnerId.Value, cancellationToken))
return true;
}
return false;
}
/// <summary>
/// Filters an asset list down to assets uploaded by the maintainer or linked through
/// the cosplayers they maintain.
/// </summary>
/// <param name="assets">The assets to filter.</param>
/// <param name="uid">The maintainer's user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The filtered asset list preserving input order.</returns>
private async Task<List<Asset>> FilterForMaintainerAsync(IEnumerable<Asset> assets, Guid uid, CancellationToken cancellationToken) {
List<Asset> result = [];
foreach (var asset in assets) {
if (asset.UploadedBy == uid || await IsMaintainedByUser(uid, asset, cancellationToken))
result.Add(asset);
}
return result;
}
}
+42 -18
View File
@@ -1,5 +1,6 @@
using Butter.Dtos;
using Butter.Dtos.User;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
@@ -7,6 +8,7 @@ using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Lactose.Controllers;
@@ -17,30 +19,34 @@ namespace Lactose.Controllers;
/// <param name="authService">Authentication service.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="passwordHasher">Password hasher.</param>
/// <param name="settingsRepository">Settings repository.</param>
[ApiController]
[Route("api/[controller]")]
[EnableRateLimiting("auth")]
public class AuthController(
ILogger<AuthController> logger,
LactoseAuthService authService,
IUserRepository userRepository,
IPasswordHasher<User> passwordHasher
IPasswordHasher<User> passwordHasher,
ISettingsRepository settingsRepository
) : ControllerBase {
/// <summary>
/// Authenticates a user by username/email and password, issuing JWT and refresh tokens.
/// </summary>
/// <param name="userDto">The login credentials.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>An authentication result with tokens, or an error.</returns>
[HttpPost("login")]
public ActionResult<AuthResultDto> Login([FromBody] CredentialsDto userDto) {
public async Task<ActionResult<AuthResultDto>> Login([FromBody] CredentialsDto userDto, CancellationToken cancellationToken) {
User? user;
if (userDto.Identifier.Contains('@')) {
//search for user in database (by email)
user = userRepository.FindByEmail(userDto.Identifier);
user = await userRepository.FindByEmailAsync(userDto.Identifier, cancellationToken);
} else {
//search for user in database (by username)
user = userRepository.FindByUsername(userDto.Identifier);
user = await userRepository.FindByUsernameAsync(userDto.Identifier, cancellationToken);
}
if (user == null) {
return NotFound(new AuthResultDto() {
@@ -74,16 +80,16 @@ public class AuthController(
case PasswordVerificationResult.Failed: return NotFound(new AuthResultDto() { Success = false, ErrorMessage = "User or Password was wrong"});
case PasswordVerificationResult.SuccessRehashNeeded:
user.Password = passwordHasher.HashPassword(user, userDto.Password);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
break;
case PasswordVerificationResult.Success: break;
}
user.LastLogin = DateTime.UtcNow;
var token = authService.GenerateAccessToken(user);
var refreshToken = authService.GenerateRefreshToken(user);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return new AuthResultDto() {
UserId = user.Id,
Token = token,
@@ -92,14 +98,30 @@ public class AuthController(
};
}
/// <summary>
/// Checks whether user registration is currently enabled.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 if enabled, 403 if disabled.</returns>
[HttpGet("register")]
public async Task<ActionResult> CheckRegistrationEnabled(CancellationToken cancellationToken) {
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
}
/// <summary>
/// Registers a new user account.
/// </summary>
/// <param name="dto">The registration data.</param>
/// <returns>200 with the new user ID, or 409 if the user already exists.</returns>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new user ID, 403 if registration is disabled, or 409 if the user already exists.</returns>
//TODO: Switch Guid reply with Authentication Result (giving a complete reason in case of failure or giving the authentication token)
[HttpPost("register")]
public ActionResult Register([FromBody] UserRegisterDto dto) {
public async Task<ActionResult> Register([FromBody] UserRegisterDto dto, CancellationToken cancellationToken) {
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
if (regSetting?.Value != "true")
return StatusCode(403, "Registration is currently disabled on this server");
logger.LogDebug(
$"""
Registering user:
@@ -108,7 +130,8 @@ public class AuthController(
"""
);
if (userRepository.FindByEmail(dto.Email) != null || userRepository.FindByUsername(dto.Username) != null) {
if (await userRepository.FindByEmailAsync(dto.Email, cancellationToken) != null
|| await userRepository.FindByUsernameAsync(dto.Username, cancellationToken) != null) {
return Conflict("User already exists");
}
@@ -123,7 +146,7 @@ public class AuthController(
user.Password = passwordHasher.HashPassword(user, dto.Password);
userRepository.Insert(user);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(user.Id);
}
@@ -133,17 +156,17 @@ public class AuthController(
/// <returns>200 on success, 401 if unauthorized.</returns>
[Authorize]
[HttpPost("logout")]
public ActionResult Logout() {
public async Task<ActionResult> Logout(CancellationToken cancellationToken) {
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
if (identity == null) { return Unauthorized(); }
//Reset token from the database
User? user = userRepository.Find(identity.Id);
User? user = await userRepository.FindAsync(identity.Id, cancellationToken);
if (user == null) { return Unauthorized(); }
user.RefreshToken = string.Empty;
user.RefreshTokenExpires = null;
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -153,10 +176,11 @@ public class AuthController(
/// Refreshes an expired JWT access token using a valid refresh token.
/// </summary>
/// <param name="token">The refresh token request data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A new access token, and optionally a new refresh token.</returns>
[HttpPost("refresh")]
public ActionResult<AuthResultDto> RefreshToken([FromBody] RefreshDto token) {
User? user = userRepository.Find(token.UserId);
public async Task<ActionResult<AuthResultDto>> RefreshToken([FromBody] RefreshDto token, CancellationToken cancellationToken) {
User? user = await userRepository.FindAsync(token.UserId, cancellationToken);
if (user == null) {
return NotFound(
@@ -206,7 +230,7 @@ public class AuthController(
authDto.RefreshToken = refreshToken;
user.RefreshToken = refreshToken;
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
}
return authDto;
+22 -19
View File
@@ -29,10 +29,11 @@ namespace Lactose.Controllers;
/// Creates a new folder for asset scanning.
/// </summary>
/// <param name="folderCreateDto">The folder creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Create([FromBody] FolderCreateDto folderCreateDto) {
public async Task<ActionResult> Create([FromBody] FolderCreateDto folderCreateDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
@@ -43,40 +44,39 @@ namespace Lactose.Controllers;
Active = false
};
folderRepository.Create(newFolder);
await folderRepository.InsertAsync(newFolder, cancellationToken);
return Ok();
}
/// <summary>
/// Gets all tracked folders.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of all folders.</returns>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<List<FolderFullDto>> GetAll() {
public async Task<ActionResult<List<FolderFullDto>>> GetAll(CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(
folderRepository.GetAll()
.Select(folder => folder.ToFolderDto())
.ToList()
);
List<Folder> folders = await folderRepository.GetAllAsync(cancellationToken);
return Ok(folders.Select(folder => folder.ToFolderDto()).ToList());
}
/// <summary>
/// Gets a specific folder by ID.
/// </summary>
/// <param name="id">The folder ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The folder details, or 404 if not found.</returns>
[HttpGet("{id}")]
[Authorize(Roles = "Admin")]
public ActionResult<FolderFullDto> Get([FromRoute] Guid id) {
public async Task<ActionResult<FolderFullDto>> Get([FromRoute] Guid id, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var folder = folderRepository.Get(id);
var folder = await folderRepository.GetAsync(id, cancellationToken);
if (folder == null) return NotFound();
@@ -88,22 +88,23 @@ namespace Lactose.Controllers;
/// </summary>
/// <param name="id">The folder ID.</param>
/// <param name="folderUpdateDto">The folder update data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin")]
public ActionResult Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto) {
public async Task<ActionResult> Update([FromRoute] Guid id, [FromBody] FolderUpdateDto folderUpdateDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
Folder? folder = folderRepository.Get(id);
Folder? folder = await folderRepository.GetAsync(id, cancellationToken);
if (folder == null) return NotFound();
folder.BasePath = folderUpdateDto.BasePath ?? folder.BasePath;
folder.Active = folderUpdateDto.Active ?? folder.Active;
folder.RegexPattern = folderUpdateDto.RegexPattern ?? folder.RegexPattern;
folderRepository.Update(id, folder);
await folderRepository.UpdateAsync(id, folder, cancellationToken);
return Ok();
}
@@ -112,20 +113,21 @@ namespace Lactose.Controllers;
/// </summary>
/// <param name="id">The folder ID.</param>
/// <param name="count">Number of sample paths to return (default 10, max 50).</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of up to <paramref name="count"/> random asset paths, or 404 if the folder is not found.</returns>
[HttpGet("{id}/sample-paths")]
[Authorize(Roles = "Admin")]
public ActionResult<FolderSamplePathsDto> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10) {
public async Task<ActionResult<FolderSamplePathsDto>> GetSamplePaths([FromRoute] Guid id, [FromQuery] int count = 10, CancellationToken cancellationToken = default) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var folder = folderRepository.Get(id);
var folder = await folderRepository.GetAsync(id, cancellationToken);
if (folder == null) return NotFound();
count = Math.Clamp(count, 1, 50);
var paths = assetRepository.GetRandomPathsByFolder(id, count);
var paths = await assetRepository.GetRandomPathsByFolderAsync(id, count, cancellationToken);
return Ok(new FolderSamplePathsDto {
FolderId = id,
@@ -137,15 +139,16 @@ namespace Lactose.Controllers;
/// Deletes a folder (stops tracking it).
/// </summary>
/// <param name="id">The folder ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin")]
public ActionResult Delete([FromRoute] Guid id) {
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
folderRepository.Delete(id);
await folderRepository.RemoveAsync(id, cancellationToken);
return Ok();
}
}
+63 -18
View File
@@ -1,10 +1,11 @@
using Butter.Dtos;
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Jobs;
using Lactose.Mapper;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Lactose.Controllers;
@@ -17,6 +18,7 @@ namespace Lactose.Controllers;
[ApiController]
[Authorize(Roles = "Admin")]
[Route("api/[controller]")]
[EnableRateLimiting("jobs")]
public class JobsController(JobManager jobManager, JobScheduler jobScheduler, LactoseAuthService authService) : ControllerBase {
/// <summary>
@@ -33,12 +35,17 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// <summary>
/// Returns a page of past root jobs with total count. Polled less frequently or on demand.
/// </summary>
/// <param name="page">The zero-based page number.</param>
/// <param name="pageSize">The number of records per page.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of past jobs and the total count.</returns>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
public async Task<ActionResult<PastJobsResponse>> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5, CancellationToken cancellationToken = default) {
if (page < 0 || pageSize < 1 || pageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var (jobs, total) = jobManager.GetPastRootJobs(page, pageSize);
var (jobs, total) = await jobManager.GetPastRootJobsAsync(page, pageSize, cancellationToken);
return Ok(new PastJobsResponse { Jobs = jobs, Total = total });
}
@@ -47,12 +54,16 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// Supports delta sync — pass <paramref name="since"/> to receive only children changed after that timestamp.
/// The <see cref="ChildrenResponse.Since"/> field contains the timestamp to use for the next delta poll.
/// </summary>
/// <param name="parentId">The parent job ID.</param>
/// <param name="since">Optional timestamp for delta sync.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The children of the job, plus the timestamp for the next delta poll.</returns>
[HttpGet("{parentId}/children")]
[Authorize(Roles = "Admin")]
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
public async Task<ActionResult<ChildrenResponse>> GetChildren(Guid parentId, [FromQuery] DateTime? since, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
var children = jobManager.GetChildren(parentId, since);
var children = await jobManager.GetChildrenAsync(parentId, since, cancellationToken);
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
}
@@ -93,20 +104,20 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// Enqueues a new job of the specified type.
/// </summary>
/// <param name="jobRequest">The job request containing the job type and optional parameters.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 if enqueued, 400 if invalid request, 501 if not implemented.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Enqueue([FromBody] JobRequestDto jobRequest) {
public async Task<ActionResult> Enqueue([FromBody] JobRequestDto jobRequest, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
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);
await jobScheduler.QueueFileSystemCrawlAsync(scanDeep, scanFolders, cancellationToken);
return Ok();
case EJobType.ThumbnailGeneration:
if (jobRequest.Parameters.Count != 0)
@@ -132,22 +143,56 @@ 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)
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");
jobScheduler.QueuePHashGeneration(jobRequest.Parameters.FirstOrDefault());
return Ok();
case EJobType.AnimatedConversion:
if (jobRequest.Parameters.Count != 0)
return BadRequest("AnimatedConversion job does not require any parameters.");
jobScheduler.QueueAnimatedConversion(null);
return Ok();
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;
}
}
+81 -15
View File
@@ -4,8 +4,10 @@ using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Net.Http.Headers;
using System.IdentityModel.Tokens.Jwt;
namespace Lactose.Controllers;
@@ -15,7 +17,9 @@ namespace Lactose.Controllers;
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="mediaRepository">Media repository.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="signKeyOptions">JWT signing key configuration.</param>
/// <param name="logger">Logger instance.</param>
[ApiController]
[Authorize]
[AllowAnonymous]
@@ -24,48 +28,105 @@ public class MediaController(
LactoseAuthService authService,
IMediaRepository mediaRepository,
IAssetRepository assetRepository,
IOptions<SignKeyConfiguration> signKeyOptions
IOptions<SignKeyConfiguration> signKeyOptions,
ILogger<MediaController> logger
) : ControllerBase {
/// <summary>
/// Gets the original image file for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The physical image file, or 404 if not found or access denied.</returns>
[HttpGet("{id}")]
[HttpGet("original/{id}")]
public ActionResult GetImage(Guid id) {
[EnableRateLimiting("media_original")]
public async Task<ActionResult> GetImage(Guid id, CancellationToken cancellationToken) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindAsync(id, cancellationToken);
if (asset == null) return NotFound();
// Animated assets with a converted video on disk serve the converted file (and its MIME type),
// never the original animation. A stale ConvertedPath (file missing) falls back to the original.
bool hasConverted = HasConvertedFile(asset);
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
return PhysicalFile(asset.OriginalPath, asset.MimeType);
if (hasConverted)
return ServeFile(asset.ConvertedPath, asset.ConvertedMimeType, asset);
return ServeFile(asset.OriginalPath, asset.MimeType, asset);
}
var data = mediaRepository.GetOriginalData(id, user?.Id);
var accessLevel = user?.AccessLevel ?? EAccessLevel.User;
var converted = hasConverted
? await mediaRepository.GetConvertedDataAsync(id, user?.Id, accessLevel, cancellationToken)
: null;
if (converted != null)
return ServeFile(converted.Path, converted.MimeType, asset);
var data = await mediaRepository.GetOriginalDataAsync(id, user?.Id, accessLevel, cancellationToken);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
return ServeFile(data.Path, data.MimeType, asset);
}
/// <summary>
/// Serves a physical media file, marking it as an attachment when the request asks to download.
/// The <c>download</c> query parameter forces <c>Content-Disposition: attachment</c> so a plain
/// link/navigation (not a CORS-gated fetch) saves the file to disk regardless of origin.
/// </summary>
/// <param name="path">The physical file path to serve.</param>
/// <param name="mimeType">The MIME type of the file.</param>
/// <param name="asset">The asset the file belongs to, used for the attachment filename.</param>
/// <returns>The physical file result.</returns>
private ActionResult ServeFile(string path, string mimeType, Models.Asset asset) {
if (Request.Query.ContainsKey("download"))
Response.Headers.ContentDisposition =
new ContentDispositionHeaderValue("attachment") { FileName = asset.OriginalFilename }.ToString();
return PhysicalFile(path, mimeType);
}
/// <summary>
/// Determines whether an asset has a converted video file available on disk.
/// Logs a warning when the converted path is set but the file is missing.
/// </summary>
/// <param name="asset">The asset to inspect.</param>
/// <returns><see langword="true"/> when the converted path and MIME type are set and the file exists.</returns>
private bool HasConvertedFile(Models.Asset asset) {
if (string.IsNullOrEmpty(asset.ConvertedPath) || string.IsNullOrEmpty(asset.ConvertedMimeType))
return false;
if (!System.IO.File.Exists(asset.ConvertedPath)) {
logger.LogWarning("Converted file for asset {AssetId} is missing on disk at {Path}; falling back to the original.",
asset.Id, asset.ConvertedPath);
return false;
}
return true;
}
/// <summary>
/// Gets the thumbnail image for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
[HttpGet("thumb/{id}")]
public ActionResult GetThumb(Guid id) {
[EnableRateLimiting("media_thumb")]
public async Task<ActionResult> GetThumb(Guid id, CancellationToken cancellationToken) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindAsync(id, cancellationToken);
if (asset == null) return NotFound();
// 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");
}
var data = mediaRepository.GetThumbData(id, user?.Id);
var data = await mediaRepository.GetThumbDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
@@ -75,19 +136,24 @@ public class MediaController(
/// Gets the preview image for an asset.
/// </summary>
/// <param name="id">The asset ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
[HttpGet("preview/{id}")]
public ActionResult GetPreview(Guid id) {
[EnableRateLimiting("media_preview")]
public async Task<ActionResult> GetPreview(Guid id, CancellationToken cancellationToken) {
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
var asset = await assetRepository.FindAsync(id, cancellationToken);
if (asset == null) return NotFound();
// 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");
}
var data = mediaRepository.GetPreviewData(id, user?.Id);
var data = await mediaRepository.GetPreviewDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
+107 -74
View File
@@ -18,6 +18,7 @@ namespace Lactose.Controllers;
public class PersonController(
IPersonRepository personRepository,
IAlbumRepository albumRepository,
IAssetRepository assetRepository,
LactoseAuthService authService
) : ControllerBase {
/// <summary>
@@ -27,65 +28,45 @@ public class PersonController(
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
/// </summary>
/// <param name="id">The person ID.</param>
/// <param name="albumPaging">Pagination parameters for albums.</param>
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The person details with albums, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedParametersDto albumPaging) {
public async Task<ActionResult<PersonDetailedDto>> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var person = personRepository.Find(id);
var person = await personRepository.FindVisibleAsync(id, uid, accessLevel,
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
albumPaging.Page, albumPaging.PageSize, cancellationToken);
if (person == null) return NotFound();
if (person.Albums != null && accessLevel == EAccessLevel.User) {
var owned = person.Albums.Where(a => a.UserOwnerId == uid).ToList();
var publicShared = person.Albums
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is { IsPubliclyShared: true, DeletedAt: null }))
.ToList();
var shared = person.Albums
.Where(a => a.Assets != null && a.Assets.Any(asset => asset is {
DeletedAt: null, SharedWith: { Count: > 0 }
} && asset.SharedWith!.Any(u => u.Id == uid)))
.ToList();
person.Albums = owned
.Union(publicShared)
.Union(shared)
.DistinctBy(a => a.Id)
.ToList();
}
if (person.Albums != null) {
person.Albums = person.Albums
.Skip(albumPaging.Page * albumPaging.PageSize)
.Take(albumPaging.PageSize)
.ToList();
}
return Ok(person.ToPersonDetailedDto());
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
}
/// <summary>
/// Searches people with pagination, optional search term, sort options, filtering out cosplayers with no visible content for non-admin users.
/// </summary>
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A paginated list of person previews.</returns>
[HttpGet]
[AllowAnonymous]
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
public async Task<ActionResult<List<PersonPreviewDto>>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var people = personRepository.SearchQuery(
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var people = await personRepository.SearchQueryAsync(
pagedSearch.Search ?? string.Empty,
pagedSearch.Page,
pagedSearch.PageSize,
pagedSearch.SortBy,
pagedSearch.SortAsc,
uid ?? default,
accessLevel
).Select(p => p.ToPersonPreviewDto())
.ToList();
accessLevel,
cancellationToken
);
return Ok(people);
}
@@ -94,99 +75,150 @@ public class PersonController(
/// </summary>
/// <param name="id">The person ID.</param>
/// <param name="dto">The updated person data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin, Curator")]
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
var person = personRepository.Find(id);
[Authorize(Roles = "Maintainer,Curator,Admin")]
public async Task<IActionResult> Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto, CancellationToken cancellationToken) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var person = await personRepository.FindAsync(id, cancellationToken);
if (person == null) return NotFound();
person.Name = dto.Name;
if (accessLevel == EAccessLevel.Maintainer) {
if (uid == null || !await personRepository.IsMaintainerOfAsync(uid.Value, id, cancellationToken))
return Forbid();
}
person.Name = dto.Name ?? person.Name;
person.ProfileAssetId = dto.ProfileAssetId;
person.ProfileCropX = dto.ProfileCropX;
person.ProfileCropY = dto.ProfileCropY;
person.ProfileCropZoom = dto.ProfileCropZoom;
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
person.Visibility = dto.Visibility.Value;
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
await personRepository.SetMaintainersAsync(person.Id, dto.MaintainerUserIds, cancellationToken);
person.UpdatedAt = DateTime.UtcNow;
personRepository.Save();
await personRepository.SaveAsync(cancellationToken);
return Ok();
}
/// <summary>
/// Updates multiple people in a bulk operation.
/// Updates multiple people in a bulk operation, optionally cascading a visibility change to their albums and assets.
/// </summary>
/// <param name="personBulkDto">The bulk update data.</param>
/// <returns>200 on success.</returns>
/// <returns>200 on success with an optional cascade result.</returns>
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
public async Task<IActionResult> BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
foreach (var person in people) {
person.Name = personBulkDto.Data.Name;
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId;
person.ProfileCropX = personBulkDto.Data.ProfileCropX;
person.ProfileCropY = personBulkDto.Data.ProfileCropY;
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom;
person.UpdatedAt = DateTime.UtcNow;
}
var result = await personRepository.ExecuteInTransactionAsync(async () => {
var people = await personRepository.FindBulkAsync(personBulkDto.Ids, CancellationToken.None);
personRepository.Save();
return Ok();
foreach (var person in people) {
person.Name = personBulkDto.Data.Name ?? person.Name;
person.ProfileAssetId = personBulkDto.Data.ProfileAssetId ?? person.ProfileAssetId;
person.ProfileCropX = personBulkDto.Data.ProfileCropX ?? person.ProfileCropX;
person.ProfileCropY = personBulkDto.Data.ProfileCropY ?? person.ProfileCropY;
person.ProfileCropZoom = personBulkDto.Data.ProfileCropZoom ?? person.ProfileCropZoom;
person.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
person.UpdatedAt = DateTime.UtcNow;
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
await personRepository.SetMaintainersAsync(person.Id, personBulkDto.Data.MaintainerUserIds, CancellationToken.None);
}
var result = new CascadeResultDto { PeopleUpdated = people.Count };
var cascade = personBulkDto.Cascade;
var visibility = personBulkDto.Data.Visibility;
if (cascade != null && visibility.HasValue) {
if (cascade.ToAlbums)
result.AlbumsUpdated = await albumRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
if (cascade.ToAssets)
result.AssetsUpdated = await assetRepository.BulkSetVisibilityByPersonIdsAsync(personBulkDto.Ids, visibility.Value, CancellationToken.None);
}
await personRepository.SaveAsync(CancellationToken.None);
return result;
});
return Ok(result);
}
/// <summary>
/// Deletes a person by ID (soft delete).
/// </summary>
/// <param name="id">The person ID.</param>
/// <returns>200 on success, 404 if not found.</returns>
/// <summary>
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
/// </summary>
/// <param name="id">The person ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin, Curator")]
public IActionResult Delete([FromRoute] Guid id) {
var person = personRepository.Find(id);
public async Task<IActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
var person = await personRepository.FindAsync(id, cancellationToken);
if (person == null) return NotFound();
// Unlink all albums owned by this person
var albums = albumRepository.FindByPerson(id).ToList();
var albums = await albumRepository.FindByPersonAsync(id, cancellationToken);
foreach (var album in albums) {
album.PersonOwnerId = null;
}
personRepository.Remove(person);
personRepository.Save();
await personRepository.SaveAsync(cancellationToken);
return Ok();
}
/// <summary>
/// Deletes multiple people in a bulk operation.
/// </summary>
/// <param name="ids">The list of person IDs to delete.</param>
/// <returns>200 on success.</returns>
/// <summary>
/// Deletes multiple people in a bulk operation (hard delete). Cascades to unlink all associated albums.
/// </summary>
/// <param name="ids">The list of person IDs to delete.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpDelete]
[Authorize(Roles = "Admin, Curator")]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
var people = personRepository.FindBulk(ids).ToList();
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
var people = await personRepository.FindBulkAsync(ids, cancellationToken);
foreach (var person in people) {
// Unlink all albums owned by this person
var albums = albumRepository.FindByPerson(person.Id).ToList();
var albums = await albumRepository.FindByPersonAsync(person.Id, cancellationToken);
foreach (var album in albums) {
album.PersonOwnerId = null;
}
personRepository.Remove(person);
}
personRepository.Save();
await personRepository.SaveAsync(cancellationToken);
return Ok();
}
/// <summary>
/// Merges multiple source people into a destination person.
/// Albums, faces, and maintainers are reassigned to the destination, then source people are hard-deleted.
/// </summary>
/// <param name="dto">The merge request with destination and source IDs.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 400 if destination is in sources or not found.</returns>
[HttpPost("merge")]
[Authorize(Roles = "Admin, Curator")]
public async Task<IActionResult> Merge([FromBody] PersonMergeDto dto, CancellationToken cancellationToken) {
if (dto.SourceIds.Contains(dto.DestinationId))
return BadRequest("Destination person ID must not appear in source person IDs.");
if (await personRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
return NotFound("Destination person not found.");
await personRepository.MergePeopleAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
await personRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -194,10 +226,11 @@ public class PersonController(
/// Creates a new person.
/// </summary>
/// <param name="dto">The person creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new person ID.</returns>
[HttpPut]
[Authorize(Roles = "Admin, Curator")]
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto) {
public async Task<ActionResult<Guid>> Create([FromBody] PersonCreateDto dto, CancellationToken cancellationToken) {
var person = new Models.Person {
Id = Guid.NewGuid(),
Name = dto.Name,
@@ -205,7 +238,7 @@ public class PersonController(
CreatedAt = DateTime.UtcNow
};
personRepository.Insert(person);
personRepository.Save();
await personRepository.SaveAsync(cancellationToken);
return Ok(person.Id);
}
}
+17 -11
View File
@@ -27,22 +27,24 @@ public class SettingsController(
/// Creates a new application setting.
/// </summary>
/// <param name="settingDto">The setting to create.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin")]
public ActionResult Create([FromBody] SettingDto settingDto) {
public async Task<ActionResult> Create([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
settingsRepository.Create(
await settingsRepository.CreateAsync(
new Setting {
Name = settingDto.Name,
Value = settingDto.Value,
Description = settingDto.Description,
Type = settingDto.Type,
DisplayType = settingDto.DisplayType
}
},
cancellationToken
);
return Ok();
@@ -51,28 +53,30 @@ public class SettingsController(
/// <summary>
/// Gets all application settings.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of all settings.</returns>
[HttpGet]
[Authorize(Roles = "Admin")]
public ActionResult<SettingDto> Get() {
public async Task<ActionResult<SettingDto>> Get(CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
return Ok(settingsRepository.Get().Select(s => s.ToSettingDto()).ToList());
return Ok((await settingsRepository.GetAllAsync(cancellationToken)).Select(s => s.ToSettingDto()).ToList());
}
/// <summary>
/// Updates an existing application setting.
/// </summary>
/// <param name="settingDto">The updated setting data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
public ActionResult Update([FromBody] SettingDto settingDto) {
public async Task<ActionResult> Update([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Update(settingDto.ToSetting());
await settingsRepository.UpdateAsync(settingDto.ToSetting(), cancellationToken);
return Ok();
}
@@ -80,29 +84,31 @@ public class SettingsController(
/// Deletes a specific application setting.
/// </summary>
/// <param name="settingDto">The setting to delete.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpDelete]
[Authorize(Roles = "Admin")]
public ActionResult Delete([FromBody] SettingDto settingDto) {
public async Task<ActionResult> Delete([FromBody] SettingDto settingDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Delete(settingDto.ToSetting());
await settingsRepository.DeleteAsync(settingDto.ToSetting(), cancellationToken);
return Ok();
}
/// <summary>
/// Deletes all application settings.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin")]
[Route("delete-all")]
public ActionResult DeleteAll() {
public async Task<ActionResult> DeleteAll(CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) return Unauthorized();
settingsRepository.Delete();
await settingsRepository.DeleteAllAsync(cancellationToken);
return Ok();
}
}
+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);
}
}
+41 -34
View File
@@ -29,10 +29,11 @@ public class TagController(
/// <param name="id">The tag ID.</param>
/// <param name="children">Whether to include child tags.</param>
/// <param name="ancestors">Whether to include ancestor tags.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The tag with its hierarchy, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<TagDto> Get(Guid id, bool children = false, bool ancestors = false) {
Tag? tag = tagRepository.Find(id);
public async Task<ActionResult<TagDto>> Get(Guid id, bool children = false, bool ancestors = false, CancellationToken cancellationToken = default) {
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
if (tag == null) { return NotFound(); }
var tagDto = TagsMapper.ToTagDto(tag);
@@ -40,7 +41,7 @@ public class TagController(
if (children) {
tagDto.Children = [];
tagRepository.GetChildren(tag.Id).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
(await tagRepository.GetChildrenAsync(tag.Id, cancellationToken)).ForEach(t => tagDto.Children.Add(TagsMapper.ToTagDto(t)));
}
//TODO: Protect from overflow
@@ -65,14 +66,15 @@ public class TagController(
/// Searches tags with pagination and optional search term.
/// </summary>
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of tags matching the search criteria.</returns>
[HttpGet]
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
public async Task<ActionResult<List<TagDto>>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch, CancellationToken cancellationToken) {
pagedSearch.Search ??= string.Empty;
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
if(pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
List<Tag> tags = await tagRepository.SearchAsync(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize, cancellationToken);
List<TagDto> tagsDto = [];
tags.ForEach(
@@ -91,14 +93,15 @@ public class TagController(
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="tagDto">The updated tag data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost("{id}")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
public async Task<IStatusCodeActionResult> Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto, CancellationToken cancellationToken) {
IStatusCodeActionResult result = UpdateTag(id, tagDto.Name, tagDto.Parent);
IStatusCodeActionResult result = await UpdateTag(id, tagDto.Name, tagDto.Parent, cancellationToken);
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
return result;
}
@@ -107,18 +110,19 @@ public class TagController(
/// Updates multiple tags in a bulk operation.
/// </summary>
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
public async Task<IStatusCodeActionResult> BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto, CancellationToken cancellationToken) {
foreach (Guid id in tagBulkDto.Ids) {
IStatusCodeActionResult result = UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent);
IStatusCodeActionResult result = await UpdateTag(id, tagBulkDto.Data.Name, tagBulkDto.Data.Parent, cancellationToken);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
await tagRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -126,13 +130,14 @@ public class TagController(
/// Deletes a tag by its ID (soft delete).
/// </summary>
/// <param name="id">The tag ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 404 if not found.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete("{id}")]
public ActionResult Delete([FromRoute] Guid id) {
IStatusCodeActionResult result = DeleteTag(id);
public async Task<ActionResult> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
if (result.StatusCode == StatusCodes.Status200OK) { tagRepository.Save(); }
if (result.StatusCode == StatusCodes.Status200OK) { await tagRepository.SaveAsync(cancellationToken); }
return (ActionResult)result;
}
@@ -141,17 +146,18 @@ public class TagController(
/// Deletes multiple tags in a bulk operation.
/// </summary>
/// <param name="ids">The list of tag IDs to delete.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
public async Task<IActionResult> BulkDelete([FromBody] List<Guid> ids, CancellationToken cancellationToken) {
foreach (Guid id in ids) {
IStatusCodeActionResult result = DeleteTag(id);
IStatusCodeActionResult result = await DeleteTag(id, cancellationToken);
if (result.StatusCode != StatusCodes.Status200OK) { return result; }
}
tagRepository.Save();
await tagRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -159,29 +165,30 @@ public class TagController(
/// Creates a new tag.
/// </summary>
/// <param name="tagDto">The tag creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPut]
public ActionResult Create([FromBody] TagCreateDto tagDto) {
public async Task<ActionResult> Create([FromBody] TagCreateDto tagDto, CancellationToken cancellationToken) {
if (tagRepository.FindByName(tagDto.Name) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
if (await tagRepository.FindByNameAsync(tagDto.Name, cancellationToken) != null) { return Conflict($"Tag with name {tagDto.Name} already exist"); }
var tag = new Tag() {
Name = tagDto.Name
};
if (tagDto.Parent != null && tagRepository.Find(tagDto.Parent.Value) == null) {
if (tagDto.Parent != null && await tagRepository.FindAsync(tagDto.Parent.Value, cancellationToken) == null) {
return BadRequest($"No Tag with id {tagDto.Parent} found");
}
tag.ParentId = tagDto.Parent;
tagRepository.Insert(tag);
tagRepository.Save();
await tagRepository.SaveAsync(cancellationToken);
return Ok();
}
IStatusCodeActionResult DeleteTag(Guid id) {
Tag? tag = tagRepository.Find(id);
async Task<IStatusCodeActionResult> DeleteTag(Guid id, CancellationToken cancellationToken) {
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
if (tag == null) { return NotFound($"Tag {id} not found"); }
tagRepository.Delete(tag);
@@ -189,14 +196,14 @@ public class TagController(
}
IStatusCodeActionResult UpdateTag(Guid id, string? name, Guid? parentId) {
async Task<IStatusCodeActionResult> UpdateTag(Guid id, string? name, Guid? parentId, CancellationToken cancellationToken) {
Tag? tag = tagRepository.Find(id);
Tag? tag = await tagRepository.FindAsync(id, cancellationToken);
if (tag == null) { return NotFound($"Tag {id} not found"); }
if (name != null) {
Tag? foundTag = tagRepository.FindByName(name);
Tag? foundTag = await tagRepository.FindByNameAsync(name, cancellationToken);
if (foundTag != null && foundTag.Id != tag.Id) { return Conflict($"Tag with name '{name}' already exists"); }
@@ -206,7 +213,7 @@ public class TagController(
if (parentId == null) { return Ok(); }
if (parentId.Value.Equals(Guid.Empty)) { tag.Parent = null; } else {
Tag? parent = tagRepository.Find(parentId.Value);
Tag? parent = await tagRepository.FindAsync(parentId.Value, cancellationToken);
if (parent == null) { return NotFound($"Parent tag on {id} was not found"); }
tag.Parent = parent;
+33 -20
View File
@@ -29,10 +29,11 @@ namespace Lactose.Controllers {
/// Creates a new user (admin only).
/// </summary>
/// <param name="userCreateDto">The user creation data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the new user ID, or conflict if the email/username already exists.</returns>
[Authorize(Roles = "Admin")]
[HttpPut]
public ActionResult<UserInfoDto> Create([FromBody] UserCreateDto userCreateDto) {
public async Task<ActionResult<UserInfoDto>> Create([FromBody] UserCreateDto userCreateDto, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser != null) {
@@ -50,8 +51,9 @@ namespace Lactose.Controllers {
"""
);
if (userRepository.GetAll().Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
if (userRepository.GetAll().Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
List<User> users = await userRepository.GetAllAsync(cancellationToken);
if (users.Any(x => x.Email == userCreateDto.Email)) return Conflict("Email already exists");
if (users.Any(x => x.Username == userCreateDto.Username)) return Conflict("Username already exists");
var user = new User {
Username = userCreateDto.Username,
@@ -63,7 +65,7 @@ namespace Lactose.Controllers {
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
userRepository.Insert(user);
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(user.Id);
}
@@ -73,9 +75,10 @@ namespace Lactose.Controllers {
/// Gets a user by their ID.
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The user info, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<UserInfoDto> Get(Guid id) {
public async Task<ActionResult<UserInfoDto>> Get(Guid id, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
@@ -83,26 +86,30 @@ namespace Lactose.Controllers {
return Unauthorized("Unauthorized");
}
//TODO: Lower level of access should fill less information of users
logger.LogDebug($"Sending user with id {id}");
User? user = userRepository.Find(id);
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
if (user is null) return NotFound();
return Ok(user.ToGetUsersDto());
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
/// <summary>
/// Gets all users.
/// Gets all users (admin/curator only).
/// </summary>
/// <returns>A list of all users.</returns>
[Authorize(Roles = "Admin")]
[Authorize(Roles = "Admin,Curator")]
[HttpGet]
public ActionResult<List<UserInfoDto>> GetAll() {
public async Task<ActionResult<List<UserInfoDto>>> GetAll(CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
logger.LogDebug("Sending Users list");
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
List<User> users = await userRepository.GetAllAsync(cancellationToken);
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
}
@@ -110,9 +117,10 @@ namespace Lactose.Controllers {
/// Updates an existing user's profile.
/// </summary>
/// <param name="dto">The user update data.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The updated user info, or 403/404 if unauthorized or not found.</returns>
[HttpPost("update")]
public ActionResult<UserInfoDto> Update([FromBody] UserUpdateDto dto) {
public async Task<ActionResult<UserInfoDto>> Update([FromBody] UserUpdateDto dto, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
@@ -134,7 +142,7 @@ namespace Lactose.Controllers {
if (authenticatedUser.AccessLevel != EAccessLevel.Admin && authenticatedUser.Id != dto.Id) return Forbid();
User? user = userRepository.Find(dto.Id);
User? user = await userRepository.FindAsync(dto.Id, cancellationToken);
if (user == null) return NotFound();
if (dto.Username != null) { user.Username = dto.Username; }
@@ -168,20 +176,25 @@ namespace Lactose.Controllers {
}
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
if (dto.MaintainedPersonIds != null) {
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
}
}
userRepository.Save();
return Ok(user.ToGetUsersDto());
await userRepository.SaveAsync(cancellationToken);
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
/// <summary>
/// Soft-deletes a user by their ID (admin only).
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>200 with the deleted user ID, or 404 if not found.</returns>
[Authorize(Roles = "Admin")]
[HttpDelete("{id}")]
public ActionResult<Guid> Delete([FromRoute] Guid id) {
public async Task<ActionResult<Guid>> Delete([FromRoute] Guid id, CancellationToken cancellationToken) {
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
if (authenticatedUser == null) {
@@ -191,14 +204,14 @@ namespace Lactose.Controllers {
logger.LogDebug($"Deleting user: {id}");
User? user = userRepository.Find(id);
User? user = await userRepository.FindAsync(id, cancellationToken);
if (user == null) return NotFound();
userRepository.Delete(user);
user.DeletedAt = DateTime.UtcNow;
//TODO: eventually remove/hide all of the user content if required
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(id);
}
}
+28
View File
@@ -125,11 +125,39 @@
"1"
]
},
{
"Name": "Converted Path",
"Value": "",
"Description": "Path where converted videos for animated assets (GIF -> mp4) are stored.",
"Type": 0,
"DisplayType": 0
},
{
"Name": "Converted Format",
"Value": "mp4",
"Description": "Container format for converted videos (e.g. mp4).",
"Type": 0,
"DisplayType": 0
},
{
"Name": "FFmpeg Path",
"Value": "ffmpeg",
"Description": "Path to the ffmpeg executable. Defaults to 'ffmpeg' on PATH.",
"Type": 0,
"DisplayType": 0
},
{
"Name": "Job Batch Size",
"Value": "200",
"Description": "Number of assets to process per sub-job batch.",
"Type": 1,
"DisplayType": 2
},
{
"Name": "System Uploader Id",
"Value": "",
"Description": "User ID to attribute scanner-created assets to. Leave empty for no default owner.",
"Type": 0,
"DisplayType": 9
}
]
+12 -5
View File
@@ -1,5 +1,8 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
RUN mkdir -p /app/thumbnails /app/previews && chown -R $APP_UID /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app/thumbnails /app/previews /app/converted && chown -R $APP_UID /app
USER $APP_UID
WORKDIR /app
EXPOSE 8080
@@ -8,16 +11,20 @@ EXPOSE 5162
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
ARG SixLaborsLicenseKey
WORKDIR "/src"
COPY ["Lactose/Lactose.csproj", "Lactose/"]
RUN dotnet restore "Lactose/Lactose.csproj"
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet restore "Lactose/Lactose.csproj"
COPY . .
WORKDIR "/src/Lactose"
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \
dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
+303
View File
@@ -0,0 +1,303 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
namespace Lactose.Jobs;
/// <summary>
/// Converts animated assets (GIF) to video (mp4 via ffmpeg) and extracts a first-frame WebP thumbnail.
/// Original files are never modified; outputs are stored under the Converted Path setting.
/// Master creates one sub-job per batch of assets.
/// </summary>
public sealed class ConvertAnimatedJob : Job {
ConvertAnimatedJob? ParentJob;
Asset[]? Batch;
int ThumbnailSize;
string ThumbnailPath = "";
string ConvertedPath = "";
string FfmpegPath = "ffmpeg";
string ExpectedFormat = "mp4";
string ExpectedThumbnailFormat = "webp";
int processedAssets;
int failedAssets;
int childJobFailures;
int childJobsWithErrors;
int totalChildJobs;
int totalAssets = 1;
int batchSize;
List<Job> childJobs = [];
ILogger<ConvertAnimatedJob> Logger;
ISettingsRepository SettingsRepository;
IAssetRepository AssetRepository;
JobManager? JobManager;
/// <inheritdoc />
public override string Name { get; }
/// <summary>
/// Creates a master animated-asset conversion job.
/// </summary>
public ConvertAnimatedJob(
ILogger<ConvertAnimatedJob> logger,
ISettingsRepository settingsRepository,
IAssetRepository assetRepository,
JobManager jobManager
) {
Logger = logger;
SettingsRepository = settingsRepository;
AssetRepository = assetRepository;
JobManager = jobManager;
Name = "Animated Asset Conversion Job";
}
/// <summary>
/// Creates a sub-job to convert a batch of animated assets.
/// </summary>
public ConvertAnimatedJob(
ConvertAnimatedJob parentJob,
IEnumerable<Asset> batch,
int thumbnailSize,
string thumbnailPath,
string convertedPath,
string ffmpegPath,
string expectedFormat,
int batchSize,
ILogger<ConvertAnimatedJob> logger,
ISettingsRepository settingsRepository,
IAssetRepository assetRepository
) {
ParentJob = parentJob;
ParentJobId = parentJob.Id;
Batch = batch.ToArray();
ThumbnailSize = thumbnailSize;
ThumbnailPath = thumbnailPath;
ConvertedPath = convertedPath;
FfmpegPath = ffmpegPath;
ExpectedFormat = expectedFormat;
this.batchSize = batchSize;
Logger = logger;
SettingsRepository = settingsRepository;
AssetRepository = assetRepository;
Name = $"Animated Conversion Batch ({Batch.Length} assets)";
totalAssets = Batch.Length;
}
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
if (ParentJob == null) {
await MasterJob(token);
} else {
await SlaveJob(token);
}
}
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
if (Batch == null || Batch.Length == 0) {
Logger.LogWarning("Sub-job started with an empty batch. Completing.");
JobStatus.Complete("Empty batch — nothing to process.");
return;
}
JobStatus.Start();
if (string.IsNullOrEmpty(ThumbnailPath) || string.IsNullOrEmpty(ConvertedPath)) {
Logger.LogError("Thumbnail or converted output path is not set. Cannot convert assets.");
JobStatus.Fail("Thumbnail or converted output path is not set.");
return;
}
for (int i = 0; i < Batch.Length; i++) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
var asset = Batch[i];
JobStatus.UpdateProgress((float)i / Batch.Length, $"Processing {asset.OriginalPath}");
try {
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
Logger.LogWarning(errorMsg);
JobHelpers.MarkBroken(asset, errorMsg);
failedAssets++;
continue;
}
var thumbPath = PathUtils.PathFromGuid(asset.Id, ThumbnailPath, ExpectedThumbnailFormat);
var videoPath = PathUtils.PathFromGuid(asset.Id, ConvertedPath, ExpectedFormat);
var thumbOutDir = Path.GetDirectoryName(thumbPath);
if (thumbOutDir != null && !Directory.Exists(thumbOutDir))
Directory.CreateDirectory(thumbOutDir);
var videoOutDir = Path.GetDirectoryName(videoPath);
if (videoOutDir != null && !Directory.Exists(videoOutDir))
Directory.CreateDirectory(videoOutDir);
// First frame as a static WebP thumbnail (avoids decoding the whole animation).
if (asset.ThumbnailPath != thumbPath || !File.Exists(thumbPath)) {
var thumbArgs = $"-y -i \"{asset.OriginalPath}\" -vf \"scale={ThumbnailSize}:-1\" -frames:v 1 \"{thumbPath}\"";
await FfmpegRunner.RunAsync(FfmpegPath, thumbArgs, Logger, token);
}
// Full animation → video (mp4 / H.264, no audio, faststart for streaming).
if (asset.ConvertedPath != videoPath || !File.Exists(videoPath)) {
var videoArgs = $"-y -i \"{asset.OriginalPath}\" -c:v libx264 -crf 23 -pix_fmt yuv420p -movflags +faststart -an \"{videoPath}\"";
await FfmpegRunner.RunAsync(FfmpegPath, videoArgs, Logger, token);
}
asset.ThumbnailPath = thumbPath;
asset.ThumbnailSize = ThumbnailSize;
asset.ThumbnailFormat = ExpectedThumbnailFormat;
asset.ConvertedPath = videoPath;
asset.ConvertedMimeType = MimeTypeFor(ExpectedFormat);
asset.ConvertedFormat = ExpectedFormat;
processedAssets++;
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) {
Logger.LogError(ex, "Failed to convert animated asset ID {Id} at {Path}.", asset.Id, asset.OriginalPath);
JobHelpers.MarkBroken(asset, $"Animated conversion failed: {ex.Message}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
await AssetRepository.SaveAsync(token);
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
if (failedAssets == Batch.Length)
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
else if (failedAssets > 0)
JobStatus.CompleteWithErrors(resultMsg);
else
JobStatus.Complete(resultMsg);
}
async Task MasterJob(CancellationToken token) {
if (JobManager == null) {
Logger.LogError("JobManager is not available. Cannot create sub-jobs.");
JobStatus.Fail("JobManager is not available. Cannot create sub-jobs.");
return;
}
JobStatus.Start();
Logger.LogInformation("Starting master animated conversion job.");
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
var convertedPathSetting = await SettingsRepository.GetAsync(Settings.ConvertedPath.AsString(), token);
var convertedFormatSetting = await SettingsRepository.GetAsync(Settings.ConvertedFormat.AsString(), token);
var ffmpegPathSetting = await SettingsRepository.GetAsync(Settings.FfmpegPath.AsString(), token);
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (thumbPathSetting == null || convertedPathSetting == null
|| string.IsNullOrWhiteSpace(thumbPathSetting.Value) || string.IsNullOrWhiteSpace(convertedPathSetting.Value)) {
Logger.LogError("Thumbnail path or converted path is not set. Cannot proceed.");
JobStatus.Fail("Thumbnail path or converted path is not set.");
return;
}
if (!int.TryParse(thumbSizeSetting?.Value, out var thumbnailSize)) {
thumbnailSize = 512;
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
}
var convertedFormat = string.IsNullOrWhiteSpace(convertedFormatSetting?.Value) ? "mp4" : convertedFormatSetting.Value;
var ffmpegPath = string.IsNullOrWhiteSpace(ffmpegPathSetting?.Value) ? "ffmpeg" : ffmpegPathSetting.Value;
batchSize = 200;
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = await AssetRepository.CountAssetsNeedingConversionAsync(token);
Logger.LogInformation("Found {Count} animated assets needing conversion.", totalAssets);
if (totalAssets == 0) {
JobStatus.Complete("No animated assets need conversion.");
return;
}
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = await AssetRepository.GetAssetsNeedingConversionAsync(batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
var job = JobManager.CreateJob<ConvertAnimatedJob>(
this, batchList, thumbnailSize, thumbPathSetting.Value!, convertedPathSetting.Value!, ffmpegPath, convertedFormat, batchSize
);
job.LinkParentToken(token);
lock (childJobs) { childJobs.Add(job); }
job.Done += (o, _) => {
var sub = (ConvertAnimatedJob)o!;
lock (childJobs) { childJobs.Remove(sub); }
Interlocked.Add(ref processedAssets, sub.processedAssets);
Interlocked.Add(ref failedAssets, sub.failedAssets);
switch (sub.JobStatus.Status) {
case EJobStatus.Failed:
case EJobStatus.Canceled:
Interlocked.Increment(ref childJobFailures);
break;
case EJobStatus.CompletedWithErrors:
Interlocked.Increment(ref childJobsWithErrors);
break;
}
if (Interlocked.Decrement(ref pending) == 0)
childTcs.TrySetResult();
};
Interlocked.Increment(ref pending);
Interlocked.Increment(ref totalChildJobs);
JobManager.EnqueueJob(job);
JobStatus.UpdateProgress(
(float)offset / totalAssets,
$"Created batch at {offset:N0}/{totalAssets:N0}."
);
}
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
var remain = Interlocked.CompareExchange(ref pending, 0, 0);
if (remain == 0) {
childTcs.TrySetResult();
} else {
using var ctr = token.Register(() => childTcs.TrySetCanceled(token));
try {
await childTcs.Task;
} catch (TaskCanceledException) {
JobStatus.Cancel("Cancellation requested from user.");
return;
}
}
if (childJobFailures > 0)
JobStatus.Fail($"{childJobFailures:N0} of {totalChildJobs:N0} conversion batch(es) failed.");
else if (childJobsWithErrors > 0 || failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} animated conversions failed.");
else
JobStatus.Complete("All animated assets converted successfully.");
}
static string MimeTypeFor(string format) => format switch {
"webm" => "video/webm",
"mp4" => "video/mp4",
_ => $"video/{format}"
};
}
+248 -290
View File
@@ -1,400 +1,336 @@
using System.Globalization;
using System.Text.RegularExpressions;
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 = (await folderRepo.GetAllAsync(token))
.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);
var albumKeys = 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);
DateTime scanAt = DateTime.UtcNow;
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) {
var key = $"{m.PersonName}|{m.AlbumName}";
if (albumKeys.TryAdd(key, 1)) 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, scanAt, 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 await personRepo.FindByNamesAsync(chunk, token))
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();
await personRepo.SaveAsync(token);
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();
await albumRepo.SaveAsync(token);
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 +341,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.AssetAlbumStaging.ExecuteDeleteAsync(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 System.Globalization;
using System.Text.RegularExpressions;
using Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
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());
}
+54
View File
@@ -0,0 +1,54 @@
using System.Diagnostics;
using Microsoft.Extensions.Logging;
namespace Lactose.Jobs;
/// <summary>
/// Runs ffmpeg/ffprobe invocations for media conversion jobs.
/// </summary>
internal static class FfmpegRunner {
/// <summary>
/// Executes ffmpeg with the given arguments, capturing output for logging.
/// </summary>
/// <param name="ffmpegPath">Path to the ffmpeg executable.</param>
/// <param name="arguments">Command-line arguments to pass to ffmpeg.</param>
/// <param name="logger">Logger for ffmpeg output.</param>
/// <param name="token">Cancellation token; the process is killed if cancelled.</param>
/// <exception cref="InvalidOperationException">Thrown when ffmpeg exits with a non-zero exit code.</exception>
internal static async Task RunAsync(string ffmpegPath, string arguments, ILogger logger, CancellationToken token) {
var startInfo = new ProcessStartInfo {
FileName = ffmpegPath,
Arguments = arguments,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var process = new Process { StartInfo = startInfo };
process.Start();
var stdoutTask = process.StandardOutput.ReadToEndAsync();
var stderrTask = process.StandardError.ReadToEndAsync();
try {
await process.WaitForExitAsync(token);
} catch (OperationCanceledException) {
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
throw;
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (!string.IsNullOrEmpty(stdout))
logger.LogTrace("ffmpeg stdout: {Output}", stdout);
if (!string.IsNullOrEmpty(stderr))
logger.LogTrace("ffmpeg stderr: {Output}", stderr);
if (process.ExitCode != 0) {
logger.LogError("ffmpeg exited with code {Code}. Stderr: {Error}", process.ExitCode, stderr);
throw new InvalidOperationException($"ffmpeg exited with code {process.ExitCode}.");
}
}
}
+244 -126
View File
@@ -1,185 +1,316 @@
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;
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="jobManager">The job manager.</param>
public FileSystemCrawlJob(
Guid folderId,
string workingPath,
ILogger<FileSystemCrawlJob> logger,
IAssetRepository assetRepository,
JobManager jobManager
) {
JobStatus = new(this);
this.logger = logger;
this.assetRepository = assetRepository;
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;
Guid? uploaderId = null;
var uploaderSetting = (await settingsRepo.GetAsync(Settings.SystemUploaderId.AsString(), token))?.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;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Updated asset {Asset} — file modification detected.", 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);
if (asset == null) continue;
if (changed) {
dirsProcessed++;
bool reconciled = 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);
// Only record scan state when the reconciliation batch actually committed; a dropped
// batch leaves the mtime stale so the directory is retried on the next run.
if (reconciled) {
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.");
}
Asset? AssetFromPath(string filePath) {
/// <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>
/// <returns>True when the batch committed; false when it was dropped due to a duplicate-key race.</returns>
async Task<bool> 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);
report(0, 0, 0);
return false;
} finally {
assetDb.ChangeTracker.Clear();
}
}
report(added, updated, deleted);
return true;
}
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, []));
if (mimeImg.MimeType != Empty) type = EAssetType.Image;
else if (mimeVid.MimeType != Empty) type = EAssetType.Video;
if (mimeVid.MimeType != Empty) type = EAssetType.Video;
else if (mimeImg.MimeType != Empty) type = EAssetType.Image;
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,
IsPubliclyShared = false,
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()
@@ -187,18 +318,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;
}
}
+292 -106
View File
@@ -1,7 +1,8 @@
using Butter.Settings;
using Lactose.Context;
using Lactose.Models;
using Lactose.Utils;
using Microsoft.EntityFrameworkCore;
using System.Diagnostics;
namespace Lactose.Jobs;
@@ -9,129 +10,188 @@ namespace Lactose.Jobs;
/// Performs a comprehensive integrity check of the system — database, assets, albums, people, thumbnails, and previews.
/// </summary>
public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvider serviceProvider): Job{
const float totalSteps = 7;
const float totalSteps = 8;
const int LiveProgressEvery = 50_000;
const int BatchSize = 1000;
/// <inheritdoc />
public override string Name => "Integrity Check Job";
void IncrementProgress(float f) => JobStatus.UpdateProgress(f/totalSteps, "Database connection successful.");
/// <summary>
/// Advances overall progress to the given step and surfaces a meaningful status message in the web UI.
/// </summary>
/// <param name="step">The completed step number (1-based).</param>
/// <param name="message">A human-readable status message.</param>
void ReportStep(int step, string message) => JobStatus.UpdateProgress(step / totalSteps, message);
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
try {
float progress = 0;
var stopwatch = Stopwatch.StartNew();
logger.LogInformation("Starting integrity check job...");
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
//Step 1
// Check database connection
// Step 1 — Database connection
logger.LogInformation("[1/8] Checking database connection...");
if (!dbContext.Database.CanConnect()) {
logger.LogError("Cannot connect to the database.");
JobStatus.Fail("Cannot connect to the database.");
return;
}
IncrementProgress(++progress);
logger.LogInformation("[1/8] Database connection successful.");
ReportStep(1, "Database connection successful.");
token.ThrowIfCancellationRequested();
// Step 2
// Check for pending migrations
// Step 2 — Pending migrations
logger.LogInformation("[2/8] Checking for pending migrations...");
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
if (pendingMigrations.Any()) {
logger.LogWarning("There are pending migrations:");
logger.LogWarning("Found {Count} pending migrations:", pendingMigrations.Count);
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
// Apply migrations
try {
dbContext.Database.Migrate();
logger.LogInformation("Applying {Count} pending migrations...", pendingMigrations.Count);
await dbContext.Database.MigrateAsync(token);
logger.LogInformation("Applied pending migrations successfully.");
} catch (OperationCanceledException) {
throw;
} catch (Exception ex) {
logger.LogError(ex, "Error applying migrations.");
JobStatus.Fail("Error applying migrations.");
return;
}
} else {
logger.LogInformation("[2/8] No pending migrations.");
}
IncrementProgress(++progress);
ReportStep(2, "Migrations up to date.");
token.ThrowIfCancellationRequested();
// A bit of context: Load() will load all entities from the table into the DbContext.
// Essentially it will ask the DB for all rows in the table and create entities for them in the DbContext.
// This will basically warm up the cache of the DbContext so that when we later access all the rows in the table,
// it won't lazy load them one by one, one call at the time.
// This is important because lazy loading one by one would be a huge performance hit,
// especially if there are many rows in the table.
// While for folders it might not be a big deal, this is just for consistency with all the other steps,
// especially the asset one.
// Step 3
// Check for folders existence
dbContext.Folders.Load();
foreach (Folder folder in dbContext.Folders.ToList()) {
// Step 3 — Folder existence
logger.LogInformation("[3/8] Checking folder existence...");
await dbContext.Folders.LoadAsync(token);
var folders = await dbContext.Folders.ToListAsync(token);
logger.LogInformation("[3/8] Loaded {Count} folders.", folders.Count);
int missingFolders = 0;
int missingFolderAssets = 0;
var lastFolderReport = DateTime.UtcNow;
for (int i = 0; i < folders.Count; i++) {
token.ThrowIfCancellationRequested();
if (!Directory.Exists(folder.BasePath)) {
logger.LogWarning($"Folder {folder.Id} does not exist. Deactivating.");
folder.Active = false;
dbContext.Folders.Update(folder);
var affectedAssets = dbContext.Assets.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.ToList();
foreach (var asset in affectedAssets) {
asset.DeletedAt = DateTime.UtcNow;
dbContext.Assets.Update(asset);
var folder = folders[i];
if (Directory.Exists(folder.BasePath)) {
if ((i + 1) % LiveProgressEvery == 0 || DateTime.UtcNow - lastFolderReport > TimeSpan.FromSeconds(5)) {
lastFolderReport = DateTime.UtcNow;
logger.LogInformation("[3/8] Verified {Processed}/{Total} folders ({Missing} missing so far).", i + 1, folders.Count, missingFolders);
JobStatus.UpdateProgress(
(2f + (float)(i + 1) / folders.Count) / totalSteps,
$"Checking folders: {i + 1:N0}/{folders.Count:N0} (missing {missingFolders:N0})");
}
dbContext.SaveChanges();
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
continue;
}
missingFolders++;
logger.LogWarning("Folder {FolderId} ({Path}) does not exist on disk. Deactivating.", folder.Id, folder.BasePath);
folder.Active = false;
dbContext.Folders.Update(folder);
var affectedAssets = await dbContext.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.ToListAsync(token);
foreach (var asset in affectedAssets) {
asset.DeletedAt = DateTime.UtcNow;
dbContext.Assets.Update(asset);
}
missingFolderAssets += affectedAssets.Count;
await dbContext.SaveChangesAsync(token);
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
}
logger.LogInformation("[3/8] Folders checked: {Missing} missing, {Assets} assets soft-deleted.", missingFolders, missingFolderAssets);
ReportStep(3, $"Folders checked: {missingFolders} missing.");
token.ThrowIfCancellationRequested();
// Step 4 — Asset existence (streamed via keyset pagination, never loading the whole table)
logger.LogInformation("[4/8] Checking asset existence...");
var totalLive = await dbContext.Assets.CountAsync(a => a.DeletedAt == null, token);
logger.LogInformation("[4/8] {Count} live assets to verify.", totalLive);
Guid lastId = Guid.Empty;
long processed = 0;
int deletedAssets = 0;
var lastReport = DateTime.UtcNow;
while (true) {
token.ThrowIfCancellationRequested();
var batch = await dbContext.Assets.AsNoTracking()
.Where(a => a.DeletedAt == null && a.Id > lastId)
.OrderBy(a => a.Id)
.Take(BatchSize)
.Select(a => new { a.Id, a.OriginalPath })
.ToListAsync(token);
if (batch.Count == 0) break;
var missingIds = new List<Guid>(batch.Count);
foreach (var item in batch) {
token.ThrowIfCancellationRequested();
if (!File.Exists(item.OriginalPath)) {
missingIds.Add(item.Id);
logger.LogWarning("Asset {AssetId} ({Path}) does not exist. Soft-deleting.", item.Id, item.OriginalPath);
}
}
if (missingIds.Count > 0) {
await dbContext.Assets
.Where(a => missingIds.Contains(a.Id))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.DeletedAt, DateTime.UtcNow), token);
deletedAssets += missingIds.Count;
}
lastId = batch[^1].Id;
processed += batch.Count;
if (processed % LiveProgressEvery == 0 || DateTime.UtcNow - lastReport > TimeSpan.FromSeconds(5)) {
lastReport = DateTime.UtcNow;
logger.LogInformation("[4/8] Verified {Processed}/{Total} assets ({Missing} missing so far).", processed, totalLive, deletedAssets);
JobStatus.UpdateProgress(
(3f + (float)processed / Math.Max(totalLive, 1)) / totalSteps,
$"Checking assets: {processed:N0}/{totalLive:N0} (missing {deletedAssets:N0})");
}
}
IncrementProgress(++progress);
logger.LogInformation("[4/8] Assets checked: {Deleted} soft-deleted.", deletedAssets);
ReportStep(4, $"Assets checked: {deletedAssets:N0} missing.");
token.ThrowIfCancellationRequested();
// Step 4
// Check for assets existence
dbContext.Assets.Load();
foreach (Asset asset in dbContext.Assets.Where(a => a.DeletedAt == null)) {
token.ThrowIfCancellationRequested();
if (File.Exists(asset.OriginalPath)) continue;
asset.DeletedAt = DateTime.UtcNow;
dbContext.Assets.Update(asset);
logger.LogWarning($"Asset {asset.Id} does not exist. Soft-deleting.");
}
int changes = dbContext.SaveChanges();
logger.LogInformation("Soft-deleted {Changes} non-existent assets.", changes);
IncrementProgress(++progress);
token.ThrowIfCancellationRequested();
// Step 5
// Check for empty albums
dbContext.Albums.Load();
dbContext.Albums.Include(a => a.Assets)
// Step 5 — Empty albums
logger.LogInformation("[5/8] Checking for empty albums...");
var emptyAlbums = await dbContext.Albums.Include(a => a.Assets)
.Where(a => a.Assets!.Count == 0)
.ToList().ForEach(a => {
logger.LogWarning($"Album {a.Id} is empty. Removing from database.");
dbContext.Albums.Remove(a);
});
changes = dbContext.SaveChanges();
logger.LogInformation("Removed {Changes} empty albums from the database.", changes);
IncrementProgress(++progress);
.ToListAsync(token);
foreach (var album in emptyAlbums) {
logger.LogWarning("Album {AlbumId} ({Title}) is empty. Removing from database.", album.Id, album.Title);
dbContext.Albums.Remove(album);
}
await dbContext.SaveChangesAsync(token);
logger.LogInformation("[5/8] Removed {Count} empty albums.", emptyAlbums.Count);
ReportStep(5, $"Empty albums removed: {emptyAlbums.Count:N0}.");
token.ThrowIfCancellationRequested();
// Step 6
// Check for orphaned thumbnails
dbContext.Settings.Load();
dbContext.Assets.Load();
// Step 6 — Orphaned thumbnails (streamed via keyset pagination)
logger.LogInformation("[6/8] Checking thumbnails...");
await dbContext.Settings.LoadAsync(token);
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
int missingThumbnails;
if (!Directory.Exists(thumbnailPath)) {
logger.LogWarning("Thumbnail path does not exist. Creating...");
logger.LogWarning("Thumbnail path does not exist at {Path}. Creating...", thumbnailPath);
try {
if (thumbnailPath != null)
@@ -141,31 +201,68 @@ public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvi
}
logger.LogWarning("Marking all assets as missing thumbnails.");
dbContext.Assets.ForEach(a => a.ThumbnailPath = "");
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
missingThumbnails = await dbContext.Assets
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
} else {
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.ThumbnailPath)).AsEnumerable()
.Where(a => !File.Exists(a.ThumbnailPath)).AsEnumerable()
.ForEach(a => {
logger.LogWarning($"Asset {a.Id} has a missing thumbnail. Marking as missing.");
a.ThumbnailPath = "";
dbContext.Assets.Update(a);
});
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing thumbnails.", changes);
}
var totalWithThumb = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.ThumbnailPath), token);
missingThumbnails = 0;
Guid lastThumbId = Guid.Empty;
long thumbProcessed = 0;
var lastThumbReport = DateTime.UtcNow;
IncrementProgress(++progress);
while (true) {
token.ThrowIfCancellationRequested();
var batch = await dbContext.Assets.AsNoTracking()
.Where(a => !string.IsNullOrEmpty(a.ThumbnailPath) && a.Id > lastThumbId)
.OrderBy(a => a.Id)
.Take(BatchSize)
.Select(a => new { a.Id, a.ThumbnailPath })
.ToListAsync(token);
if (batch.Count == 0) break;
var missingIds = new List<Guid>(batch.Count);
foreach (var item in batch) {
token.ThrowIfCancellationRequested();
if (!File.Exists(item.ThumbnailPath)) {
missingIds.Add(item.Id);
logger.LogWarning("Asset {AssetId} has a missing thumbnail at {Path}. Marking as missing.", item.Id, item.ThumbnailPath);
}
}
if (missingIds.Count > 0) {
await dbContext.Assets
.Where(a => missingIds.Contains(a.Id))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ThumbnailPath, ""), token);
missingThumbnails += missingIds.Count;
}
lastThumbId = batch[^1].Id;
thumbProcessed += batch.Count;
if (thumbProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastThumbReport > TimeSpan.FromSeconds(5)) {
lastThumbReport = DateTime.UtcNow;
logger.LogInformation("[6/8] Verified {Processed}/{Total} thumbnails ({Missing} missing so far).", thumbProcessed, totalWithThumb, missingThumbnails);
JobStatus.UpdateProgress(
(5f + (float)thumbProcessed / Math.Max(totalWithThumb, 1)) / totalSteps,
$"Checking thumbnails: {thumbProcessed:N0}/{totalWithThumb:N0} (missing {missingThumbnails:N0})");
}
}
}
logger.LogInformation("[6/8] Thumbnails checked: {Count} marked as missing.", missingThumbnails);
ReportStep(6, $"Thumbnails: {missingThumbnails:N0} missing.");
token.ThrowIfCancellationRequested();
// Step 7
// Check for orphaned previews
// Step 7 — Orphaned previews (streamed via keyset pagination)
logger.LogInformation("[7/8] Checking previews...");
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
int missingPreviews;
if (!Directory.Exists(previewPath)) {
logger.LogWarning("Preview path does not exist. Creating...");
logger.LogWarning("Preview path does not exist at {Path}. Creating...", previewPath);
try {
if (previewPath != null)
@@ -175,25 +272,114 @@ public class IntegrityCheckJob (ILogger<IntegrityCheckJob> logger, IServiceProvi
}
logger.LogWarning("Marking all assets as missing previews.");
dbContext.Assets.ForEach(a => a.PreviewPath = "");
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
missingPreviews = await dbContext.Assets
.Where(a => !string.IsNullOrEmpty(a.PreviewPath))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
} else {
dbContext.Assets.Where(a => !String.IsNullOrEmpty(a.PreviewPath)).AsEnumerable()
.Where(a => !File.Exists(a.PreviewPath)).AsEnumerable()
.ForEach(a => {
logger.LogWarning($"Asset {a.Id} has a missing preview. Marking as missing.");
a.PreviewPath = "";
dbContext.Assets.Update(a);
});
var totalWithPreview = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.PreviewPath), token);
missingPreviews = 0;
Guid lastPreviewId = Guid.Empty;
long previewProcessed = 0;
var lastPreviewReport = DateTime.UtcNow;
while (true) {
token.ThrowIfCancellationRequested();
var batch = await dbContext.Assets.AsNoTracking()
.Where(a => !string.IsNullOrEmpty(a.PreviewPath) && a.Id > lastPreviewId)
.OrderBy(a => a.Id)
.Take(BatchSize)
.Select(a => new { a.Id, a.PreviewPath })
.ToListAsync(token);
if (batch.Count == 0) break;
var missingIds = new List<Guid>(batch.Count);
foreach (var item in batch) {
token.ThrowIfCancellationRequested();
if (!File.Exists(item.PreviewPath)) {
missingIds.Add(item.Id);
logger.LogWarning("Asset {AssetId} has a missing preview at {Path}. Marking as missing.", item.Id, item.PreviewPath);
}
}
if (missingIds.Count > 0) {
await dbContext.Assets
.Where(a => missingIds.Contains(a.Id))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.PreviewPath, ""), token);
missingPreviews += missingIds.Count;
}
lastPreviewId = batch[^1].Id;
previewProcessed += batch.Count;
if (previewProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastPreviewReport > TimeSpan.FromSeconds(5)) {
lastPreviewReport = DateTime.UtcNow;
logger.LogInformation("[7/8] Verified {Processed}/{Total} previews ({Missing} missing so far).", previewProcessed, totalWithPreview, missingPreviews);
JobStatus.UpdateProgress(
(6f + (float)previewProcessed / Math.Max(totalWithPreview, 1)) / totalSteps,
$"Checking previews: {previewProcessed:N0}/{totalWithPreview:N0} (missing {missingPreviews:N0})");
}
}
}
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
logger.LogInformation("[7/8] Previews checked: {Count} marked as missing.", missingPreviews);
ReportStep(7, $"Previews: {missingPreviews:N0} missing.");
IncrementProgress(++progress);
token.ThrowIfCancellationRequested();
// Complete
logger.LogInformation("Integrity check completed successfully.");
// Step 8 — Orphaned converted videos (streamed via keyset pagination)
logger.LogInformation("[8/8] Checking converted videos...");
var totalWithConverted = await dbContext.Assets.CountAsync(a => !string.IsNullOrEmpty(a.ConvertedPath), token);
int missingConverted = 0;
Guid lastConvertedId = Guid.Empty;
long convertedProcessed = 0;
var lastConvertedReport = DateTime.UtcNow;
while (true) {
token.ThrowIfCancellationRequested();
var batch = await dbContext.Assets.AsNoTracking()
.Where(a => !string.IsNullOrEmpty(a.ConvertedPath) && a.Id > lastConvertedId)
.OrderBy(a => a.Id)
.Take(BatchSize)
.Select(a => new { a.Id, a.ConvertedPath })
.ToListAsync(token);
if (batch.Count == 0) break;
var missingIds = new List<Guid>(batch.Count);
foreach (var item in batch) {
token.ThrowIfCancellationRequested();
if (!File.Exists(item.ConvertedPath)) {
missingIds.Add(item.Id);
logger.LogWarning("Asset {AssetId} has a missing converted video at {Path}. Marking as missing.", item.Id, item.ConvertedPath);
}
}
if (missingIds.Count > 0) {
await dbContext.Assets
.Where(a => missingIds.Contains(a.Id))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ConvertedPath, ""), token);
missingConverted += missingIds.Count;
}
lastConvertedId = batch[^1].Id;
convertedProcessed += batch.Count;
if (convertedProcessed % LiveProgressEvery == 0 || DateTime.UtcNow - lastConvertedReport > TimeSpan.FromSeconds(5)) {
lastConvertedReport = DateTime.UtcNow;
logger.LogInformation("[8/8] Verified {Processed}/{Total} converted videos ({Missing} missing so far).", convertedProcessed, totalWithConverted, missingConverted);
JobStatus.UpdateProgress(
(7f + (float)convertedProcessed / Math.Max(totalWithConverted, 1)) / totalSteps,
$"Checking converted videos: {convertedProcessed:N0}/{totalWithConverted:N0} (missing {missingConverted:N0})");
}
}
logger.LogInformation("[8/8] Converted videos checked: {Count} marked as missing.", missingConverted);
stopwatch.Stop();
logger.LogInformation("Integrity check completed successfully in {Elapsed}.",
stopwatch.Elapsed.ToString(@"h\:mm\:ss"));
JobStatus.Complete("Integrity check completed successfully.");
} catch (OperationCanceledException) {
JobStatus.Cancel();
+18 -1
View File
@@ -1,5 +1,4 @@
using Butter.Types;
using Microsoft.Extensions.DependencyInjection;
namespace Lactose.Jobs;
@@ -147,4 +146,22 @@ public abstract class Job {
/// Raised when the job is done, regardless of the final status (Completed, Canceled, or Failed).
/// </summary>
public virtual event EventHandler<JobStatus>? Done;
/// <summary>
/// Raises the StartedWaiting event.
/// </summary>
/// <param name="e">The job status.</param>
protected virtual void OnStartedWaiting(JobStatus e) { StartedWaiting?.Invoke(this, e); }
/// <summary>
/// Raises the FinishedWaiting event.
/// </summary>
/// <param name="e">The job status.</param>
protected virtual void OnFinishedWaiting(JobStatus e) { FinishedWaiting?.Invoke(this, e); }
/// <summary>
/// Raises the ProgressChanged event.
/// </summary>
/// <param name="e">The job status.</param>
protected virtual void OnProgressChanged(JobStatus e) { ProgressChanged?.Invoke(this, e); }
}
+19
View File
@@ -0,0 +1,19 @@
using Lactose.Models;
namespace Lactose.Jobs;
/// <summary>
/// Shared helpers for background processing jobs.
/// </summary>
internal static class JobHelpers {
/// <summary>
/// Marks an asset as broken (processing failed) so it is excluded from re-processing
/// until an admin explicitly retries it.
/// </summary>
/// <param name="asset">The asset to mark broken.</param>
/// <param name="message">A human-readable error message describing the failure.</param>
internal static void MarkBroken(Asset asset, string message) {
asset.ProcessFailedAt = DateTime.UtcNow;
asset.ProcessErrorMessage = message;
}
}
+12 -12
View File
@@ -3,7 +3,6 @@ using Butter.Settings;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Repositories;
using Lactose.Utils;
using System.Collections.Concurrent;
namespace Lactose.Jobs;
@@ -36,10 +35,10 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary>
/// Returns a page of past root jobs from the database.
/// </summary>
public (List<JobStatusDto> Jobs, int TotalCount) GetPastRootJobs(int page, int pageSize) {
public async Task<(List<JobStatusDto> Jobs, int TotalCount)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = repo.GetPastRootJobs(page, pageSize, out var total);
var (records, total) = await repo.GetPastRootJobsAsync(page, pageSize, cancellationToken);
logger.LogInformation("GetPastRootJobs page={Page} pageSize={PageSize}: {Count} records out of {Total}", page, pageSize, records.Count, total);
return (records.Select(r => r.ToJobStatusDto()).ToList(), total);
}
@@ -50,7 +49,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// </summary>
/// <param name="parentId">The parent job ID.</param>
/// <param name="since">If set, only children changed after this timestamp are returned. Null returns all children.</param>
public List<JobStatusDto> GetChildren(Guid parentId, DateTime? since) {
/// <param name="cancellationToken">Token to cancel the operation.</param>
public async Task<List<JobStatusDto>> GetChildrenAsync(Guid parentId, DateTime? since, CancellationToken cancellationToken) {
var seenIds = new HashSet<Guid>();
// Active children still in memory
@@ -72,8 +72,8 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = since is null
? repo.GetChildren(parentId)
: repo.GetChildrenModifiedSince(parentId, since.Value);
? await repo.GetChildrenAsync(parentId, cancellationToken)
: await repo.GetChildrenModifiedSinceAsync(parentId, since.Value, cancellationToken);
results.AddRange(records.Where(r => !seenIds.Contains(r.Id)).Select(r => r.ToJobStatusDto()));
} catch (Exception ex) {
logger.LogWarning(ex, "Could not load children for parent {ParentId}", parentId);
@@ -179,14 +179,14 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
}
///<inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
logger.LogInformation("Preparing JobManager...");
// Load past jobs from database on startup
try {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
foreach (var record in repo.GetAll()) {
foreach (var record in await repo.GetAllAsync(stoppingToken)) {
pastJobs.TryAdd(record.Id, record.ToJobStatusDto());
}
logger.LogInformation($"Loaded {pastJobs.Count} past job(s) from database.");
@@ -198,7 +198,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
try {
using var scope = serviceProvider.CreateScope();
var settingsRepo = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
var maxJobsSetting = settingsRepo.Get(Settings.MaxConcurrentJobs.AsString());
var maxJobsSetting = await settingsRepo.GetAsync(Settings.MaxConcurrentJobs.AsString(), stoppingToken);
if (maxJobsSetting != null && int.TryParse(maxJobsSetting.Value, out var maxJobs) && maxJobs > 0) {
MaxConcurrentJobs = maxJobs;
logger.LogInformation("Loaded MaxConcurrentJobs from settings: {Value}", MaxConcurrentJobs);
@@ -228,7 +228,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
}
foreach (var job in queued) {
job.Done += (sender, status) => {
job.Done += async (sender, status) => {
var j = (Job)sender!;
var dto = j.ToJobStatusDto();
@@ -248,7 +248,7 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
Progress = dto.Progress
};
repo.Insert(record);
repo.Save();
await repo.SaveAsync(CancellationToken.None);
} catch (Exception ex) {
logger.LogError(ex, "Failed to persist job {JobId}", status.Id);
}
@@ -267,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
);
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
await service;
}
}
+10 -8
View File
@@ -1,5 +1,4 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
@@ -56,11 +55,11 @@ public sealed class MetadataJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -85,7 +84,9 @@ public sealed class MetadataJob : Job {
try {
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
Logger.LogWarning($"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}");
var errorMsg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
Logger.LogWarning(errorMsg);
JobHelpers.MarkBroken(asset, errorMsg);
failedAssets++;
continue;
}
@@ -96,12 +97,13 @@ public sealed class MetadataJob : Job {
processedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to extract metadata for asset ID {asset.Id}");
JobHelpers.MarkBroken(asset, $"Metadata extraction failed: {ex.Message}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
@@ -124,11 +126,11 @@ public sealed class MetadataJob : Job {
JobStatus.Start();
batchSize = 200;
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingMetadata();
totalAssets = await AssetRepository.CountAssetsMissingMetadataAsync(token);
Logger.LogInformation("Found {Count} image assets missing metadata.", totalAssets);
if (totalAssets == 0) {
@@ -140,7 +142,7 @@ public sealed class MetadataJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingMetadata(batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingMetadataAsync(batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+12 -9
View File
@@ -1,10 +1,9 @@
using Butter.Settings;
using Butter.Types;
using CoenM.ImageHash.HashAlgorithms;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
using CoenM.ImageHash.HashAlgorithms;
using Lactose.Utils;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Collections;
@@ -59,11 +58,11 @@ public sealed class PHashJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -94,24 +93,28 @@ public sealed class PHashJob : Job {
} catch (ArgumentNullException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} could not be found. Setting hash to 0.");
asset.Hash = new BitArray(64);
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
failedAssets++;
} catch (UnknownImageFormatException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is in an unknown or unsupported format.");
asset.Hash = new BitArray(64);
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
failedAssets++;
} catch (InvalidImageContentException ex) {
Logger.LogError(ex, $"Image at path {asset.OriginalPath} is corrupted or unreadable.");
asset.Hash = new BitArray(64);
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
failedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to calculate pHash for asset {asset.Id} | {asset.OriginalPath}.");
asset.Hash = new BitArray(64);
JobHelpers.MarkBroken(asset, $"pHash failed: {ex.Message}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
@@ -134,11 +137,11 @@ public sealed class PHashJob : Job {
JobStatus.Start();
batchSize = 200;
var batchSizeSetting = SettingsRepository?.Get(Settings.JobBatchSize.AsString());
var batchSizeSetting = SettingsRepository is null ? null : await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingPHash();
totalAssets = await AssetRepository.CountAssetsMissingPHashAsync(token);
Logger.LogInformation("Found {Count} assets missing pHash.", totalAssets);
if (totalAssets == 0) {
@@ -150,7 +153,7 @@ public sealed class PHashJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingPHash(batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingPHashAsync(batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
+15
View File
@@ -0,0 +1,15 @@
namespace Lactose.Jobs;
static class PathUtils {
internal static string PathFromGuid(Guid id, string root, string extension = "webp") {
var s = id.ToString("N");
return Path.Combine(
root,
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.{extension}"
);
}
}
+12 -24
View File
@@ -1,6 +1,4 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
@@ -84,11 +82,11 @@ public class PreviewJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -121,6 +119,7 @@ public class PreviewJob : Job {
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
var msg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
Logger.LogWarning(msg);
JobHelpers.MarkBroken(asset, msg);
failedAssets++;
continue;
}
@@ -142,7 +141,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)!);
@@ -161,12 +160,13 @@ public class PreviewJob : Job {
processedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to generate preview for asset ID {asset.Id}");
JobHelpers.MarkBroken(asset, $"Preview generation failed: {ex.Message}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
@@ -189,10 +189,10 @@ public class PreviewJob : Job {
JobStatus.Start();
Logger.LogInformation("Starting master Preview job.");
var previewPathSetting = SettingsRepository.Get(Settings.PreviewPath.AsString());
var previewSizeSetting = SettingsRepository.Get(Settings.PreviewSize.AsString());
var previewQualitySetting = SettingsRepository.Get(Settings.PreviewQuality.AsString());
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
var previewPathSetting = await SettingsRepository.GetAsync(Settings.PreviewPath.AsString(), token);
var previewSizeSetting = await SettingsRepository.GetAsync(Settings.PreviewSize.AsString(), token);
var previewQualitySetting = await SettingsRepository.GetAsync(Settings.PreviewQuality.AsString(), token);
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (previewPathSetting == null) {
Logger.LogError("Preview path not found. Cannot proceed.");
@@ -216,7 +216,7 @@ public class PreviewJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingOrWrongPreview(previewSize, ExpectedFormat);
totalAssets = await AssetRepository.CountAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, token);
Logger.LogInformation("Found {Count} assets needing previews.", totalAssets);
if (totalAssets == 0) {
@@ -228,7 +228,7 @@ public class PreviewJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingOrWrongPreview(previewSize, ExpectedFormat, batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingOrWrongPreviewAsync(previewSize, ExpectedFormat, batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
@@ -281,16 +281,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"
);
}
}
+12 -24
View File
@@ -1,6 +1,4 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
@@ -84,11 +82,11 @@ public class ThumbnailJob : Job {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
await SlaveJob(token);
}
}
void SlaveJob(CancellationToken token) {
async Task SlaveJob(CancellationToken token) {
if (token.IsCancellationRequested) {
JobStatus.Cancel("Cancellation requested from user.");
return;
@@ -121,6 +119,7 @@ public class ThumbnailJob : Job {
if (string.IsNullOrEmpty(asset.OriginalPath) || !File.Exists(asset.OriginalPath)) {
var msg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
Logger.LogWarning(msg);
JobHelpers.MarkBroken(asset, msg);
failedAssets++;
continue;
}
@@ -142,7 +141,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)!);
@@ -161,12 +160,13 @@ public class ThumbnailJob : Job {
processedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to generate thumbnail for asset ID {asset.Id}");
JobHelpers.MarkBroken(asset, $"Thumbnail generation failed: {ex.Message}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
await AssetRepository.SaveAsync(token);
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
@@ -189,10 +189,10 @@ public class ThumbnailJob : Job {
JobStatus.Start();
Logger.LogInformation("Starting master Thumbnail job.");
var thumbPathSetting = SettingsRepository.Get(Settings.ThumbnailPath.AsString());
var thumbSizeSetting = SettingsRepository.Get(Settings.ThumbnailSize.AsString());
var thumbQualitySetting = SettingsRepository.Get(Settings.ThumbnailQuality.AsString());
var batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
var thumbPathSetting = await SettingsRepository.GetAsync(Settings.ThumbnailPath.AsString(), token);
var thumbSizeSetting = await SettingsRepository.GetAsync(Settings.ThumbnailSize.AsString(), token);
var thumbQualitySetting = await SettingsRepository.GetAsync(Settings.ThumbnailQuality.AsString(), token);
var batchSizeSetting = await SettingsRepository.GetAsync(Settings.JobBatchSize.AsString(), token);
if (thumbPathSetting == null) {
Logger.LogError("Thumbnail path not found. Cannot proceed.");
@@ -216,7 +216,7 @@ public class ThumbnailJob : Job {
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat);
totalAssets = await AssetRepository.CountAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, token);
Logger.LogInformation("Found {Count} assets needing thumbnails.", totalAssets);
if (totalAssets == 0) {
@@ -228,7 +228,7 @@ public class ThumbnailJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat, batchSize, offset);
var batch = await AssetRepository.GetAssetsMissingOrWrongThumbnailAsync(thumbnailSize, ExpectedFormat, batchSize, offset, token);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
@@ -281,16 +281,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"
);
}
}
+3 -5
View File
@@ -14,19 +14,17 @@
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.15">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
+48 -31
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -8,44 +9,60 @@ namespace Lactose.Mapper;
/// </summary>
public static class AlbumMapper {
/// <summary>
/// Maps an Album object to an AlbumFullDto object.
/// </summary>
/// <param name="album">The album to map.</param>
/// <returns>An AlbumFullDto object.</returns>
public static AlbumFullDto ToAlbumFullDto(this Album album) => new AlbumFullDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count ?? 0,
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 = asset.OriginalFilename
}).ToList()
};
/// <summary>
/// Maps an Album object to an AlbumPreviewDto object.
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
/// </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 AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel, Guid? viewerId = null) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count ?? 0
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
};
/// <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) {
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),
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
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
};
}
}
+29 -17
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.Asset;
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
@@ -9,41 +10,45 @@ namespace Lactose.Mapper;
/// </summary>
public static class AssetsMapper {
/// <summary>
/// Maps an Asset object to a GetFullAssetDto object.
/// Maps an Asset object to a AssetDto object.
/// </summary>
/// <param name="asset">The asset to map.</param>
/// <param name="accessLevel">The access level of the user.</param>
/// <returns>A GetFullAssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A AssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
Id = asset.Id,
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
AssetType = asset.Type,
IsPublic = asset.IsPubliclyShared,
CreatedAt = asset.CreatedAt,
UpdatedAt = asset.UpdatedAt,
DeletedAt = asset.DeletedAt,
Owner = asset.Owner!.ToGetUsersDto(),
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
MimeType = asset.MimeType,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
FileSize = asset.FileSize,
Duration = asset.Duration ?? 0,
FrameRate = asset.FrameRate ?? 0
FrameRate = asset.FrameRate ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
};
/// <summary>
/// Maps an Asset object to a GetAssetPreviewDto object.
/// Maps an Asset object to a AssetPreviewDto object.
/// </summary>
/// <param name="asset">The asset to map.</param>
/// <returns>A GetAssetPreviewDto object.</returns>
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
/// <param name="accessLevel">The access level of the user.</param>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A AssetPreviewDto object.</returns>
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetPreviewDto {
Id = asset.Id,
MimeType = asset.MimeType,
MimeType = string.IsNullOrEmpty(asset.ConvertedMimeType) ? asset.MimeType : asset.ConvertedMimeType,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = asset.OriginalFilename,
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
CosplayerNames = asset.Albums?
@@ -55,14 +60,21 @@ public static class AssetsMapper {
.Where(a => a.PersonOwner != null)
.Select(a => a.PersonOwner!.Id)
.Distinct()
.ToList()
.ToList(),
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null,
ProcessFailedAt = accessLevel >= EAccessLevel.Curator ? asset.ProcessFailedAt : null,
ProcessErrorMessage = accessLevel >= EAccessLevel.Curator ? asset.ProcessErrorMessage : null,
OriginalPath = accessLevel >= EAccessLevel.Curator ? asset.OriginalPath : null
};
/// <summary>
/// Maps a collection of Asset objects to a collection of GetAssetPreviewDto objects.
/// Maps a collection of Asset objects to a collection of AssetPreviewDto objects.
/// </summary>
/// <param name="assets">The collection of assets to map.</param>
/// <returns>A collection of GetAssetPreviewDto objects.</returns>
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets)
=> assets.Select(ToAssetPreviewDto);
/// <param name="accessLevel">The access level of the user.</param>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A collection of AssetPreviewDto objects.</returns>
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel, Guid? viewerId)
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel, viewerId));
}
+1 -1
View File
@@ -30,8 +30,8 @@ public static class JobMapper {
IntegrityCheckJob => EJobType.IntegrityCheck,
PHashJob => EJobType.PHashGeneration,
MetadataJob => EJobType.MetadataExtraction,
CreatePersonsJob => EJobType.CreatePersons,
CreateAlbumsJob => EJobType.CreateAlbums,
ConvertAnimatedJob => EJobType.AnimatedConversion,
_ => throw new ArgumentOutOfRangeException(nameof(job))
}
};
+19 -16
View File
@@ -1,5 +1,6 @@
using Butter.Dtos.Album;
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -12,9 +13,12 @@ public static class PersonMapper {
/// Maps a Person to a PersonDetailedDto with album previews and stats.
/// </summary>
/// <param name="person">The person to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A PersonDetailedDto.</returns>
public static PersonDetailedDto ToPersonDetailedDto(this Person person) {
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel, Guid? viewerId = null) {
var albums = person.Albums ?? [];
var maintainers = person.Maintainers ?? [];
return new PersonDetailedDto {
Id = person.Id,
@@ -23,9 +27,16 @@ 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),
Albums = albums.Select(ToAlbumPreviewDto).ToList()
TotalAlbums = person.AlbumTotalCount,
TotalAssets = person.TotalAssetCount,
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
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()
};
}
@@ -33,24 +44,16 @@ public static class PersonMapper {
/// Maps a Person to a PersonPreviewDto.
/// </summary>
/// <param name="person">The person to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>A PersonPreviewDto.</returns>
public static PersonPreviewDto ToPersonPreviewDto(this Person person) => new() {
public static PersonPreviewDto ToPersonPreviewDto(this Person person, EAccessLevel accessLevel) => new() {
Id = person.Id,
Name = person.Name,
ProfileAssetId = person.ProfileAssetId,
ProfileCropX = person.ProfileCropX,
ProfileCropY = person.ProfileCropY,
ProfileCropZoom = person.ProfileCropZoom,
TotalAlbums = person.Albums?.Count ?? 0
};
private static AlbumPreviewDto ToAlbumPreviewDto(Album album) => new() {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count(asset => asset.DeletedAt == null) ?? 0
TotalAlbums = person.Albums?.Count ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null
};
}
+24 -12
View File
@@ -1,4 +1,5 @@
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -8,22 +9,33 @@ namespace Lactose.Mapper;
/// </summary>
public static class UsersMapper {
/// <summary>
/// Maps a User object to a UserInfoDto object.
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
/// when the viewer is an admin or viewing their own profile.
/// </summary>
/// <param name="user">The User object to map.</param>
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
/// <returns>A UserInfoDto object containing the mapped data.</returns>
public static UserInfoDto ToGetUsersDto(this User user) {
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
bool isSelf = viewerId == user.Id;
bool showSensitive = isAdmin || isSelf;
return new UserInfoDto {
Id = user.Id,
Email = user.Email,
Username = user.Username,
CreatedAt = user.CreatedAt,
IsBanned = user.BannedAt != null,
BannedAt = user.BannedAt,
LastLogin = user.LastLogin,
UpdatedAt = user.UpdatedAt,
DeletedAt = user.DeletedAt,
AccessLevel = user.AccessLevel,
Id = user.Id,
Username = user.Username,
Email = showSensitive ? user.Email : null,
CreatedAt = user.CreatedAt,
UpdatedAt = showSensitive ? user.UpdatedAt : null,
LastLogin = showSensitive ? user.LastLogin : null,
BannedAt = showSensitive ? user.BannedAt : null,
DeletedAt = isAdmin ? user.DeletedAt : null,
IsBanned = showSensitive && user.BannedAt != null,
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
};
}
}
-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

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