191 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 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 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 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
167 changed files with 10649 additions and 4236 deletions
+1 -1
View File
@@ -1 +1 @@
GIT_VERSION=v1.0.3.2-1-gff2e7ff
GIT_VERSION=v1.0.3.2-123-ge63b93f
+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>
+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>
+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>
+3
View File
@@ -63,6 +63,7 @@ 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`
@@ -127,6 +128,8 @@ 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
@@ -27,6 +27,11 @@ public class AlbumAssetPreviewDto {
/// </summary>
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; }
+2 -2
View File
@@ -9,9 +9,9 @@ public class AssetBrowseOptionsDto : PagedSearchParametersDto {
/// </summary>
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets the current path relative to the folder root (empty for root).
/// Gets or sets the current path relative to the folder root (empty or null for root).
/// </summary>
public string Path { get; set; } = "";
public string? Path { get; set; }
/// <summary>
/// Gets or sets whether to only include assets not assigned to any album. Defaults to true.
/// </summary>
+5 -4
View File
@@ -13,11 +13,12 @@ public class AssetBrowseResultDto {
/// </summary>
public List<AssetPreviewDto> Assets { get; set; } = [];
/// <summary>
/// Gets or sets the current browsing path relative to the folder root.
/// </summary>
public string CurrentPath { 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; }
}
+12
View File
@@ -58,4 +58,16 @@ public class AssetPreviewDto {
/// 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; }
}
@@ -39,6 +39,19 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// </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>
+4 -2
View File
@@ -11,9 +11,11 @@ public class AssetUpdateDto {
/// </summary>
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 the visibility level.
/// </summary>
+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; } = [];
}
+9
View File
@@ -8,4 +8,13 @@ public class DirectoryEntryDto {
/// 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>
+1 -1
View File
@@ -9,7 +9,7 @@ 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>
+4
View File
@@ -108,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; }
+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"]),
+9
View File
@@ -32,6 +32,12 @@ public enum Settings {
ThumbnailQuality,
/// <summary>Quality setting (1100) for WebP preview encoding.</summary>
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
}
@@ -61,6 +67,9 @@ public static class SettingsExtension {
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
};
@@ -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;
}
}
+29
View File
@@ -46,6 +46,14 @@ public class LactoseDbContext : DbContext {
/// Gets or sets the person-maintainer join table.
/// </summary>
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
/// <summary>
/// Gets or sets the per-directory scan-state table used by incremental file-system scans.
/// </summary>
public DbSet<DirectoryScanState> DirectoryScanStates { get; set; }
/// <summary>
/// Gets or sets the temporary staging table used by the person/album discovery job.
/// </summary>
public DbSet<AssetAlbumStaging> AssetAlbumStaging { get; set; }
/// <summary>
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
@@ -105,6 +113,15 @@ public class LactoseDbContext : DbContext {
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")
@@ -134,5 +151,17 @@ public class LactoseDbContext : DbContext {
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
//Folder Relationships
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
//Scan state
modelBuilder.Entity<DirectoryScanState>().HasOne(e => e.Folder).WithMany().HasForeignKey(e => e.FolderId);
modelBuilder.Entity<DirectoryScanState>()
.HasIndex(e => new { e.FolderId, e.Path })
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path")
.IsUnique();
//Discovery staging
modelBuilder.Entity<AssetAlbumStaging>().HasOne(e => e.Asset).WithMany().HasForeignKey(e => e.AssetId);
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.AssetId)
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
modelBuilder.Entity<AssetAlbumStaging>().HasIndex(e => e.PersonName)
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
}
}
+80 -52
View File
@@ -32,13 +32,14 @@ public class AlbumController(
/// 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.FindVisible(id, uid, accessLevel);
var album = await albumRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
// If the album does not exist, return a 404
if (album == null) return NotFound();
@@ -50,15 +51,16 @@ public class AlbumController(
/// 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]
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 < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
var albums = albumRepository.SearchQuery(
var albums = await albumRepository.SearchQueryAsync(
pagingOptions.Search ?? string.Empty,
pagingOptions.Page,
pagingOptions.PageSize,
@@ -68,8 +70,9 @@ public class AlbumController(
uid ?? default,
accessLevel,
pagingOptions.AssetUploadedBy,
pagingOptions.PersonOwnerId
).ToList();
pagingOptions.PersonOwnerId,
cancellationToken
);
return Ok(albums);
}
@@ -79,37 +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 = "Maintainer,Curator,Admin")]
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
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.FindWithAssets(id);
var album = await albumRepository.FindWithAssetsAsync(id, cancellationToken);
// If the album does not exist, return a 404
if (album == null) return NotFound();
// Maintainer scope check — must maintain the album's person
switch (accessLevel) {
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
return Forbid();
case EAccessLevel.User:
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();
// 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.Visibility = albumDto.Visibility ?? album.Visibility;
album.Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : album.Assets;
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();
}
@@ -118,24 +120,31 @@ 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 = "Maintainer,Curator,Admin")]
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
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:
case EAccessLevel.Maintainer: {
// Maintainers can only update albums of persons they maintain
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
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 update any album
break;
@@ -144,32 +153,44 @@ public class AlbumController(
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.Visibility = bulkDto.Data.Visibility ?? album.Visibility;
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 = "Curator,Admin")]
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
public async Task<ActionResult> Create([FromBody] AlbumCreateDto albumDto, CancellationToken cancellationToken) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
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(),
@@ -182,7 +203,7 @@ public class AlbumController(
};
albumRepository.Insert(album);
albumRepository.Save();
await albumRepository.SaveAsync(cancellationToken);
return Ok(album.Id);
}
@@ -192,18 +213,19 @@ public class AlbumController(
/// 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 ActionResult Merge([FromBody] AlbumMergeDto dto) {
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 (albumRepository.Find(dto.DestinationId) == null)
if (await albumRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
return NotFound("Destination album not found.");
albumRepository.MergeAlbums(dto.DestinationId, dto.SourceIds);
albumRepository.Save();
await albumRepository.MergeAlbumsAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
await albumRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -211,20 +233,27 @@ public class AlbumController(
/// 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 = "Maintainer,Curator,Admin")]
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
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);
// Filter by access level
switch (accessLevel) {
case EAccessLevel.Maintainer:
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
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();
}
@@ -232,7 +261,7 @@ public class AlbumController(
if (albums.Count == 0) return Forbid();
albums.ForEach(albumRepository.Remove);
albumRepository.Save();
await albumRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -241,27 +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 = "Maintainer,Curator,Admin")]
public ActionResult Delete(Guid id) {
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();
switch (accessLevel) {
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
return Forbid();
case EAccessLevel.User:
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();
}
+172 -45
View File
@@ -19,6 +19,7 @@ namespace Lactose.Controllers;
/// <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(
@@ -26,15 +27,17 @@ public class AssetController(
LactoseAuthService authService,
IAssetRepository assetRepository,
IUserRepository userRepository,
IPersonRepository personRepository
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;
@@ -46,7 +49,7 @@ public class AssetController(
"""
);
var asset = assetRepository.FindVisible(id, uid, accessLevel);
var asset = await assetRepository.FindVisibleAsync(id, uid, accessLevel, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found or not visible!");
@@ -76,9 +79,10 @@ public class AssetController(
/// 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, 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;
@@ -95,12 +99,13 @@ public class AssetController(
return BadRequest();
}
var assets = assetRepository.GetAssets(
var (assets, total) = await assetRepository.GetAssetsAsync(
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
searchOptionsDto.Page, searchOptionsDto.PageSize,
uid, accessLevel,
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
searchOptionsDto.IncludeCount
searchOptionsDto.IncludeCount, searchOptionsDto.DeletedOnly, searchOptionsDto.AlbumId, searchOptionsDto.PersonId,
cancellationToken
);
logger.LogTrace(
@@ -115,7 +120,7 @@ public class AssetController(
"""
);
logger.LogTrace($"Returning {assets.Count()} assets (total: {total})");
logger.LogTrace($"Returning {assets.Count} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(assets);
}
@@ -128,27 +133,28 @@ public class AssetController(
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
[HttpGet("unlinked-groups")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult<List<AssetGroupDto>> GetFolderGroups([FromQuery] bool unlinkedOnly = true) {
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 = assetRepository.GetFolderGroups(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 assets at a specific directory level within a folder, returning immediate subdirectories
/// and a paginated list of assets at that level.
/// 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, path, pagination, search, and unlinked-only filter.</param>
/// <returns>A browse result with subdirectories and assets at the current level.</returns>
[HttpGet("browse")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult<AssetBrowseResultDto> Browse([FromQuery] AssetBrowseOptionsDto options) {
/// <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;
@@ -156,25 +162,67 @@ public class AssetController(
if (options.Page < 0 || options.PageSize < 1 || options.PageSize > PagedParametersDto.MaxPageSize)
return BadRequest();
logger.LogTrace("Browse: folderId={FolderId}, path={Path}, page={Page}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, accessLevel, options.UnlinkedOnly);
logger.LogTrace("FsBrowse: folderId={FolderId}, path={Path}, page={Page}, unlinkedOnly={UnlinkedOnly}", options.FolderId, options.Path, options.Page, options.UnlinkedOnly);
var result = assetRepository.BrowseAssets(options.FolderId, options.Path ?? "", options.Page, options.PageSize, uid, accessLevel, options.Search, 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>
/// Updates an asset's metadata.
/// </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 = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
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.FindWithAlbums(id);
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
@@ -182,7 +230,7 @@ public class AssetController(
}
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
&& (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();
}
@@ -192,8 +240,18 @@ public class AssetController(
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();
}
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;
@@ -203,7 +261,7 @@ public class AssetController(
logger.LogTrace($"Updating asset {id}\n{log}");
assetRepository.Update(asset);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -212,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 = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
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();
@@ -235,13 +294,20 @@ public class AssetController(
//If the user is not an admin, they can only update their own assets
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
? 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.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
x.DeletedAt = bulkDto.Data.IsDeleted == true ? DateTime.UtcNow : x.DeletedAt;
if (accesslevel == EAccessLevel.Admin) {
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
@@ -250,21 +316,54 @@ public class AssetController(
);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
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 = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
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.FindWithAlbums(id);
var asset = await assetRepository.FindWithAlbumsAsync(id, cancellationToken);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
@@ -272,14 +371,14 @@ public class AssetController(
}
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
&& (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();
}
@@ -288,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 = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
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();
@@ -311,13 +411,13 @@ public class AssetController(
//If the user is not an admin, they can only delete their own assets
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
? await FilterForMaintainerAsync(enumerable, uid.Value, cancellationToken)
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
assetRepository.Save();
await assetRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -325,16 +425,17 @@ 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 (dto.UploadedBy.HasValue) {
if (userRepository.Find(dto.UploadedBy.Value) == null) {
if (await userRepository.FindAsync(dto.UploadedBy.Value, cancellationToken) == null) {
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
return BadRequest();
}
@@ -357,11 +458,12 @@ 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);
}
@@ -372,7 +474,32 @@ public class AssetController(
/// </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 bool IsMaintainedByUser(Guid uid, Asset asset) =>
asset.Albums?.Any(a => a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(uid, a.PersonOwnerId.Value)) == true;
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;
}
}
+24 -19
View File
@@ -35,17 +35,18 @@ public class AuthController(
/// 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() {
@@ -79,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,
@@ -100,10 +101,11 @@ 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 ActionResult CheckRegistrationEnabled() {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
public async Task<ActionResult> CheckRegistrationEnabled(CancellationToken cancellationToken) {
var regSetting = await settingsRepository.GetAsync(Settings.UserRegistrationEnabled.AsString(), cancellationToken);
return regSetting?.Value == "true" ? Ok() : StatusCode(403);
}
@@ -111,11 +113,12 @@ public class AuthController(
/// Registers a new user account.
/// </summary>
/// <param name="dto">The registration data.</param>
/// <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) {
var regSetting = settingsRepository.Get(Settings.UserRegistrationEnabled.AsString());
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");
@@ -127,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");
}
@@ -142,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);
}
@@ -152,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();
}
@@ -172,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(
@@ -225,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.Insert(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.Remove(id);
await folderRepository.RemoveAsync(id, cancellationToken);
return Ok();
}
}
+59 -17
View File
@@ -35,13 +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 = 0, [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 });
}
@@ -50,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 });
}
@@ -96,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)
@@ -135,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;
}
}
+70 -15
View File
@@ -7,6 +7,7 @@ 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;
@@ -18,6 +19,7 @@ namespace Lactose.Controllers;
/// <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]
@@ -26,43 +28,95 @@ 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}")]
[EnableRateLimiting("media_original")]
public ActionResult GetImage(Guid id) {
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, user?.AccessLevel ?? EAccessLevel.User);
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}")]
[EnableRateLimiting("media_thumb")]
public ActionResult GetThumb(Guid id) {
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
@@ -72,7 +126,7 @@ public class MediaController(
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
return PhysicalFile(asset.ThumbnailPath, "image/webp");
}
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
var data = await mediaRepository.GetThumbDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
@@ -82,13 +136,14 @@ 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}")]
[EnableRateLimiting("media_preview")]
public ActionResult GetPreview(Guid id) {
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
@@ -98,7 +153,7 @@ public class MediaController(
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
return PhysicalFile(asset.PreviewPath, "image/webp");
}
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
var data = await mediaRepository.GetPreviewDataAsync(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User, cancellationToken);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
+72 -54
View File
@@ -18,6 +18,7 @@ namespace Lactose.Controllers;
public class PersonController(
IPersonRepository personRepository,
IAlbumRepository albumRepository,
IAssetRepository assetRepository,
LactoseAuthService authService
) : ControllerBase {
/// <summary>
@@ -28,15 +29,16 @@ public class PersonController(
/// </summary>
/// <param name="id">The person ID.</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] PagedSearchParametersDto 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.FindVisible(id, uid, accessLevel,
var person = await personRepository.FindVisibleAsync(id, uid, accessLevel,
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
albumPaging.Page, albumPaging.PageSize);
albumPaging.Page, albumPaging.PageSize, cancellationToken);
if (person == null) return NotFound();
return Ok(person.ToPersonDetailedDto(accessLevel, uid));
@@ -46,23 +48,25 @@ public class PersonController(
/// 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]
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;
if (pagedSearch.Page < 0 || pagedSearch.PageSize < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest(); }
var people = personRepository.SearchQuery(
var people = await personRepository.SearchQueryAsync(
pagedSearch.Search ?? string.Empty,
pagedSearch.Page,
pagedSearch.PageSize,
pagedSearch.SortBy,
pagedSearch.SortAsc,
uid ?? default,
accessLevel
).ToList();
accessLevel,
cancellationToken
);
return Ok(people);
}
@@ -71,22 +75,23 @@ 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 = "Maintainer,Curator,Admin")]
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
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 = personRepository.Find(id);
var person = await personRepository.FindAsync(id, cancellationToken);
if (person == null) return NotFound();
if (accessLevel == EAccessLevel.Maintainer) {
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
if (uid == null || !await personRepository.IsMaintainerOfAsync(uid.Value, id, cancellationToken))
return Forbid();
}
person.Name = dto.Name;
person.Name = dto.Name ?? person.Name;
person.ProfileAssetId = dto.ProfileAssetId;
person.ProfileCropX = dto.ProfileCropX;
person.ProfileCropY = dto.ProfileCropY;
@@ -96,92 +101,103 @@ public class PersonController(
person.Visibility = dto.Visibility.Value;
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
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) {
public async Task<IActionResult> BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var people = personRepository.FindBulk(personBulkDto.Ids).ToList();
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.Visibility = personBulkDto.Data.Visibility ?? person.Visibility;
person.UpdatedAt = DateTime.UtcNow;
var result = await personRepository.ExecuteInTransactionAsync(async () => {
var people = await personRepository.FindBulkAsync(personBulkDto.Ids, CancellationToken.None);
if (personBulkDto.Data.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
personRepository.SetMaintainers(person.Id, personBulkDto.Data.MaintainerUserIds);
}
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;
personRepository.Save();
return Ok();
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();
}
@@ -190,18 +206,19 @@ public class PersonController(
/// 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 IActionResult Merge([FromBody] PersonMergeDto dto) {
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 (personRepository.Find(dto.DestinationId) == null)
if (await personRepository.FindAsync(dto.DestinationId, cancellationToken) == null)
return NotFound("Destination person not found.");
personRepository.MergePeople(dto.DestinationId, dto.SourceIds);
personRepository.Save();
await personRepository.MergePeopleAsync(dto.DestinationId, dto.SourceIds, cancellationToken);
await personRepository.SaveAsync(cancellationToken);
return Ok();
}
@@ -209,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,
@@ -220,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();
}
}
+35 -28
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 < 1 || pagedSearch.PageSize > PagedParametersDto.MaxPageSize) { return BadRequest($"PageSize must be between 1 and {PagedParametersDto.MaxPageSize}"); }
List<Tag> tags = tagRepository.Search(pagedSearch.Search, pagedSearch.Page, pagedSearch.PageSize);
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 = "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 = "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 = "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 = "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 = "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;
+22 -15
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) {
@@ -85,7 +88,7 @@ namespace Lactose.Controllers {
logger.LogDebug($"Sending user with id {id}");
User? user = userRepository.FindWithMaintainedPersons(id);
User? user = await userRepository.FindWithMaintainedPersonsAsync(id, cancellationToken);
if (user is null) return NotFound();
@@ -98,13 +101,15 @@ namespace Lactose.Controllers {
/// <returns>A list of all users.</returns>
[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(viewerAccessLevel, authenticatedUser?.Id)).ToList());
List<User> users = await userRepository.GetAllAsync(cancellationToken);
return Ok(users.Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
}
@@ -112,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) {
@@ -136,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; }
@@ -172,11 +178,11 @@ namespace Lactose.Controllers {
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
if (dto.MaintainedPersonIds != null) {
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
await userRepository.SetMaintainedPersonsAsync(user.Id, dto.MaintainedPersonIds, cancellationToken);
}
}
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
@@ -184,10 +190,11 @@ namespace Lactose.Controllers {
/// 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) {
@@ -197,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();
user.DeletedAt = DateTime.UtcNow;
//TODO: eventually remove/hide all of the user content if required
userRepository.Save();
await userRepository.SaveAsync(cancellationToken);
return Ok(id);
}
}
+21
View File
@@ -125,6 +125,27 @@
"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",
+4 -1
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
+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}"
};
}
+246 -288
View File
@@ -2,399 +2,335 @@ 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 Lactose.Context;
using Lactose.Models;
using Lactose.Repositories;
using System.Globalization;
using System.Text.RegularExpressions;
namespace Lactose.Jobs;
/// <summary>
/// Scans active folders with regex patterns in a streaming fashion (suitable for millions of assets),
/// extracts person names from asset paths, and creates any missing Person entities.
/// </summary>
public class CreatePersonsJob(ILogger<CreatePersonsJob> logger, IServiceProvider serviceProvider) : Job {
const int ScanBatchSize = 5000;
/// <inheritdoc />
public override string Name => "Create Persons Job";
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
try {
logger.LogInformation("Starting create persons job...");
using var scope = serviceProvider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
var personRepo = scope.ServiceProvider.GetRequiredService<IPersonRepository>();
var folders = folderRepo.GetAll()
.Where(f => f.Active && !string.IsNullOrWhiteSpace(f.RegexPattern))
.ToList();
if (folders.Count == 0) {
logger.LogInformation("No active folders with a regex pattern found.");
JobStatus.Complete("No active folders with a regex pattern found.");
return;
}
logger.LogInformation("Scanning {FolderCount} active folders for person names...", folders.Count);
// ── Phase 1: Streaming scan → collect unique person names ──
var personNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
float folderIndex = 0;
int totalAssetsMatched = 0;
foreach (var folder in folders) {
token.ThrowIfCancellationRequested();
var regex = new Regex(folder.RegexPattern!, RegexOptions.IgnoreCase | RegexOptions.Compiled);
if (!regex.GetGroupNames().Contains("person", StringComparer.OrdinalIgnoreCase)) {
logger.LogWarning("Folder {FolderId} ({BasePath}) regex is missing the 'person' named group. Skipping.", folder.Id, folder.BasePath);
continue;
}
var totalInFolder = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.CountAsync(token);
int folderScanned = 0;
int folderMatched = 0;
for (int offset = 0; offset < totalInFolder; offset += ScanBatchSize) {
token.ThrowIfCancellationRequested();
var paths = await db.Assets
.Where(a => a.FolderId == folder.Id && a.DeletedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(ScanBatchSize)
.Select(a => a.OriginalPath)
.ToListAsync(token);
int batchMatched = 0;
foreach (var path in paths) {
token.ThrowIfCancellationRequested();
var match = regex.Match(path);
if (!match.Success) continue;
var group = match.Groups["person"];
if (!group.Success) continue;
var name = ToTitleCase(group.Value.Replace('_', ' ').Trim());
if (!string.IsNullOrWhiteSpace(name)) {
personNames.Add(name);
batchMatched++;
}
}
folderScanned += paths.Count;
folderMatched += batchMatched;
// Per-batch message update while keeping progress at per-folder level
float progress = folderIndex / folders.Count * 0.5f;
JobStatus.UpdateProgress(progress,
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {personNames.Count:N0} persons found so far");
}
totalAssetsMatched += folderMatched;
folderIndex++;
float folderProgress = folderIndex / folders.Count * 0.5f;
JobStatus.UpdateProgress(folderProgress,
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched:N0} matched — {personNames.Count:N0} unique persons");
logger.LogInformation("Scanned folder {BasePath}: {Scanned:N0} assets, {Matched} matched. Cumulative: {Persons} unique persons",
folder.BasePath, folderScanned, folderMatched, personNames.Count);
}
if (personNames.Count == 0) {
logger.LogInformation("No person names extracted from asset paths.");
JobStatus.Complete("No person names extracted from asset paths.");
return;
}
logger.LogInformation("Extracted {Count} unique person names from {Matched:N0} matched assets. Checking existing...",
personNames.Count, totalAssetsMatched);
JobStatus.UpdateProgress(0.5f, $"Extracted {personNames.Count:N0} unique person names from {totalAssetsMatched:N0} matched assets.");
token.ThrowIfCancellationRequested();
// ── Phase 2: Create missing persons ──
var existingNames = personRepo.FindByNames(personNames)
.Select(p => p.Name)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var missingNames = personNames
.Where(n => !existingNames.Contains(n))
.ToList();
if (missingNames.Count == 0) {
logger.LogInformation("All {Count} persons already exist.", personNames.Count);
JobStatus.Complete($"All {personNames.Count:N0} persons already exist.");
return;
}
logger.LogInformation("Creating {Count} missing persons ({Existing} already exist).",
missingNames.Count, existingNames.Count);
float created = 0;
foreach (var name in missingNames) {
token.ThrowIfCancellationRequested();
personRepo.Insert(new Person {
Id = Guid.NewGuid(),
Name = name,
CreatedAt = DateTime.UtcNow
});
created++;
float progress = 0.5f + created / missingNames.Count * 0.5f;
JobStatus.UpdateProgress(progress, $"Creating persons: {created:N0}/{missingNames.Count:N0} — {name}");
}
personRepo.Save();
logger.LogInformation("Created {Count} new persons.", missingNames.Count);
JobStatus.Complete($"Created {missingNames.Count:N0} new persons from {totalAssetsMatched:N0} matched assets.");
} catch (OperationCanceledException) {
JobStatus.Cancel();
}
}
static string ToTitleCase(string input) =>
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
}
+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}.");
}
}
}
+240 -141
View File
@@ -1,204 +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;
ISettingsRepository settingsRepository;
string workingPath;
Guid folderId;
ILogger<FileSystemCrawlJob> logger;
JobManager jobManager;
List<Job> childJobs = [];
int pendingChildren;
readonly Guid folderId;
readonly string workingPath;
readonly bool deep;
readonly ILogger<FileSystemCrawlJob> logger;
const int ProgressUpdateEveryDirs = 200;
/// <summary>
/// Initialises a new file system crawl job for the specified folder.
/// Initialises a new file-system crawl job for the specified folder.
/// </summary>
/// <param name="folderId">The folder ID.</param>
/// <param name="workingPath">The base path to crawl.</param>
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
/// <param name="logger">Logger instance.</param>
/// <param name="assetRepository">The asset repository.</param>
/// <param name="settingsRepository">The settings repository.</param>
/// <param name="jobManager">The job manager.</param>
public FileSystemCrawlJob(
Guid folderId,
string workingPath,
ILogger<FileSystemCrawlJob> logger,
IAssetRepository assetRepository,
ISettingsRepository settingsRepository,
JobManager jobManager
) {
JobStatus = new(this);
this.logger = logger;
this.assetRepository = assetRepository;
this.settingsRepository = settingsRepository;
this.workingPath = workingPath;
public FileSystemCrawlJob(Guid folderId, string workingPath, bool deep, ILogger<FileSystemCrawlJob> logger) {
JobStatus = new(this);
this.folderId = folderId;
this.jobManager = jobManager;
Name += $"{workingPath}";
this.workingPath = workingPath;
this.deep = deep;
this.logger = logger;
Name = $"Directory Scan: {workingPath}";
}
///<inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
logger.LogInformation($"Started crawling directory {workingPath}");
logger.LogInformation("Started crawling directory {Path} (deep={Deep})", workingPath, deep);
DirectoryInfo dir = new(workingPath);
if (!dir.Exists) {
var root = new DirectoryInfo(workingPath);
if (!root.Exists) {
logger.LogWarning("Directory {Dir} does not exist. Skipping crawl.", workingPath);
JobStatus.Complete("Directory does not exist.");
return;
}
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var db = Scope!.ServiceProvider.GetRequiredService<LactoseDbContext>();
var settingsRepo = Scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
int steps,
totalSteps;
// Resolve system uploader from settings once
Guid? uploaderId = null;
var uploaderSetting = settingsRepository.Get(Settings.SystemUploaderId.AsString())?.Value;
if (Guid.TryParse(uploaderSetting, out var parsed))
uploaderId = parsed;
var uploaderSetting = (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;
if (existing.UploadedBy == null && uploaderId.HasValue)
existing.UploadedBy = uploaderId;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
} else if (existing.UploadedBy == null && uploaderId.HasValue) {
existing.UploadedBy = uploaderId;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Backfilled UploadedBy for asset {Asset}.", existing.OriginalPath);
// A directory's mtime changes only when its direct children change; descendant changes bump
// only their own parent. So we must always descend, but skip file reconciliation for clean dirs.
bool changed = deep;
if (!changed) {
if (!existingStates.TryGetValue(currentPath, out var state)) {
changed = true; // new directory
} else {
changed = state.LastMtime != currentDir.LastWriteTimeUtc;
}
}
List<DirectoryInfo> subdirs;
FileInfo[]? files = null;
try {
subdirs = currentDir.GetDirectories().ToList();
if (changed) files = currentDir.GetFiles();
} catch (Exception ex) {
logger.LogWarning(ex, "Could not enumerate directory {Path}.", currentPath);
continue;
}
var asset = AssetFromPath(file.FullName, uploaderId);
if (asset == null) continue;
if (changed) {
dirsProcessed++;
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.");
}
/// <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,
Visibility = EVisibility.Private,
Visibility = EVisibility.Private,
UploadedBy = uploaderId,
CreatedAt = finfo.CreationTimeUtc,
UpdatedAt = finfo.LastWriteTimeUtc,
MimeType = type.Value switch {
IngestedAt = DateTime.UtcNow,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,
_ => throw new ArgumentOutOfRangeException()
@@ -206,18 +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();
+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 -11
View File
@@ -35,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);
}
@@ -49,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
@@ -71,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);
@@ -178,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.");
@@ -197,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);
@@ -227,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();
@@ -247,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);
}
@@ -266,6 +267,6 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
);
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
await service;
}
}
+10 -7
View File
@@ -55,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;
@@ -84,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;
}
@@ -95,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);
@@ -123,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) {
@@ -139,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;
+10 -6
View File
@@ -58,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;
@@ -93,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);
@@ -133,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) {
@@ -149,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;
+2 -2
View File
@@ -1,7 +1,7 @@
namespace Lactose.Jobs;
static class PathUtils {
internal static string PathFromGuid(Guid id, string root) {
internal static string PathFromGuid(Guid id, string root, string extension = "webp") {
var s = id.ToString("N");
return Path.Combine(
@@ -9,7 +9,7 @@ static class PathUtils {
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.webp"
$"{s}.{extension}"
);
}
}
+11 -9
View File
@@ -82,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;
@@ -119,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;
}
@@ -159,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);
@@ -187,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.");
@@ -214,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) {
@@ -226,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;
+11 -9
View File
@@ -82,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;
@@ -119,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;
}
@@ -159,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);
@@ -187,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.");
@@ -214,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) {
@@ -226,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;
+1
View File
@@ -44,6 +44,7 @@ public static class AlbumMapper {
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
+5 -2
View File
@@ -43,7 +43,7 @@ public static class AssetsMapper {
/// <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),
@@ -62,7 +62,10 @@ public static class AssetsMapper {
.Distinct()
.ToList(),
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : 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>
+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))
}
};
@@ -0,0 +1,647 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260818090402_AddAssetProcessFailureAndConverted")]
partial class AddAssetProcessFailureAndConverted
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConvertedFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ConvertedMimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("ConvertedPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<string>("ProcessErrorMessage")
.HasColumnType("VARCHAR(1024)");
b.Property<DateTime?>("ProcessFailedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ProcessFailedAt")
.HasDatabaseName("IX_Assets_ProcessFailedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetProcessFailureAndConverted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ConvertedFormat",
table: "Assets",
type: "VARCHAR(16)",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ConvertedMimeType",
table: "Assets",
type: "VARCHAR(255)",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ConvertedPath",
table: "Assets",
type: "VARCHAR(2048)",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "ProcessErrorMessage",
table: "Assets",
type: "VARCHAR(1024)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "ProcessFailedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Assets_ProcessFailedAt",
table: "Assets",
column: "ProcessFailedAt",
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_ProcessFailedAt",
table: "Assets");
migrationBuilder.DropColumn(
name: "ConvertedFormat",
table: "Assets");
migrationBuilder.DropColumn(
name: "ConvertedMimeType",
table: "Assets");
migrationBuilder.DropColumn(
name: "ConvertedPath",
table: "Assets");
migrationBuilder.DropColumn(
name: "ProcessErrorMessage",
table: "Assets");
migrationBuilder.DropColumn(
name: "ProcessFailedAt",
table: "Assets");
}
}
}
@@ -0,0 +1,710 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260819004150_AddIncrementalScanState")]
partial class AddIncrementalScanState
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("FolderId", "Id")
.HasDatabaseName("IX_Assets_FolderId_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,130 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddIncrementalScanState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId",
table: "Assets");
migrationBuilder.AddColumn<DateTime>(
name: "LastDiscoveryScanAt",
table: "Folders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "LastDiscoveryScannedRegex",
table: "Folders",
type: "VARCHAR(2048)",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "LastFileScanAt",
table: "Folders",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "AssetAlbumStaging",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AssetId = table.Column<Guid>(type: "uuid", nullable: false),
PersonName = table.Column<string>(type: "VARCHAR(255)", nullable: false),
AlbumName = table.Column<string>(type: "VARCHAR(255)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AssetAlbumStaging", x => x.Id);
table.ForeignKey(
name: "FK_AssetAlbumStaging_Assets_AssetId",
column: x => x.AssetId,
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "DirectoryScanStates",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FolderId = table.Column<Guid>(type: "uuid", nullable: false),
Path = table.Column<string>(type: "VARCHAR(2048)", nullable: false),
LastMtime = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DirectoryScanStates", x => x.Id);
table.ForeignKey(
name: "FK_DirectoryScanStates_Folders_FolderId",
column: x => x.FolderId,
principalTable: "Folders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets",
columns: new[] { "FolderId", "Id" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_AssetAlbumStaging_AssetId",
table: "AssetAlbumStaging",
column: "AssetId");
migrationBuilder.CreateIndex(
name: "IX_AssetAlbumStaging_PersonName",
table: "AssetAlbumStaging",
column: "PersonName");
migrationBuilder.CreateIndex(
name: "IX_DirectoryScanStates_FolderId_Path",
table: "DirectoryScanStates",
columns: new[] { "FolderId", "Path" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AssetAlbumStaging");
migrationBuilder.DropTable(
name: "DirectoryScanStates");
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets");
migrationBuilder.DropColumn(
name: "LastDiscoveryScanAt",
table: "Folders");
migrationBuilder.DropColumn(
name: "LastDiscoveryScannedRegex",
table: "Folders");
migrationBuilder.DropColumn(
name: "LastFileScanAt",
table: "Folders");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId",
table: "Assets",
column: "FolderId");
}
}
}
@@ -0,0 +1,713 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260819224929_AddAssetIngestedAt")]
partial class AddAssetIngestedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetIngestedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets");
migrationBuilder.AddColumn<DateTime>(
name: "IngestedAt",
table: "Assets",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_IngestedAt_Id",
table: "Assets",
columns: new[] { "FolderId", "IngestedAt", "Id" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_FolderId_IngestedAt_Id",
table: "Assets");
migrationBuilder.DropColumn(
name: "IngestedAt",
table: "Assets");
migrationBuilder.CreateIndex(
name: "IX_Assets_FolderId_Id",
table: "Assets",
columns: new[] { "FolderId", "Id" },
filter: "\"DeletedAt\" IS NULL");
}
}
}
@@ -0,0 +1,713 @@
// <auto-generated />
using System;
using System.Collections;
using Lactose.Context;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Lactose.Migrations
{
[DbContext(typeof(LactoseDbContext))]
[Migration("20260819235054_BackfillAssetIngestedAt")]
partial class BackfillAssetIngestedAt
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CreatedAt")
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
b.HasIndex("OriginalFilename")
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("PreviewPath")
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("Type")
.HasDatabaseName("IX_Assets_Stats_Type")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "CreatedAt")
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class BackfillAssetIngestedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Intentionally a no-op. The IngestedAt backfill was removed because a single UPDATE on the
// large Assets table exceeds the connection command timeout and crashes the app on startup.
// IngestedAt remains populated (set to now() when the column was added); the next discovery
// run will process the library once, after which incremental scans resume normally.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -101,6 +101,18 @@ namespace Lactose.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConvertedFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ConvertedMimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("ConvertedPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
@@ -123,6 +135,9 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("bit(64)");
b.Property<DateTime>("IngestedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
@@ -146,6 +161,12 @@ namespace Lactose.Migrations
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<string>("ProcessErrorMessage")
.HasColumnType("VARCHAR(1024)");
b.Property<DateTime?>("ProcessFailedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
@@ -181,8 +202,6 @@ namespace Lactose.Migrations
.HasDatabaseName("IX_Assets_Stats_CreatedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId");
b.HasIndex("Hash")
.HasDatabaseName("IX_Assets_Stats_MissingPhash")
.HasFilter("\"Hash\" = B'0000000000000000000000000000000000000000000000000000000000000000'::bit AND \"DeletedAt\" IS NULL");
@@ -200,6 +219,10 @@ namespace Lactose.Migrations
.HasDatabaseName("IX_Assets_Stats_MissingPreview")
.HasFilter("\"PreviewPath\" = '' AND \"DeletedAt\" IS NULL");
b.HasIndex("ProcessFailedAt")
.HasDatabaseName("IX_Assets_ProcessFailedAt")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ThumbnailPath")
.HasDatabaseName("IX_Assets_Stats_MissingThumbnail")
.HasFilter("\"ThumbnailPath\" = '' AND \"DeletedAt\" IS NULL");
@@ -218,6 +241,10 @@ namespace Lactose.Migrations
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("FolderId", "IngestedAt", "Id")
.HasDatabaseName("IX_Assets_FolderId_IngestedAt_Id")
.HasFilter("\"DeletedAt\" IS NULL");
b.HasIndex("ResolutionWidth", "ResolutionHeight", "Type")
.HasDatabaseName("IX_Assets_Stats_Resolution")
.HasFilter("\"DeletedAt\" IS NULL");
@@ -225,6 +252,58 @@ namespace Lactose.Migrations
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AlbumName")
.HasColumnType("VARCHAR(255)");
b.Property<Guid>("AssetId")
.HasColumnType("uuid");
b.Property<string>("PersonName")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.HasKey("Id");
b.HasIndex("AssetId")
.HasDatabaseName("IX_AssetAlbumStaging_AssetId");
b.HasIndex("PersonName")
.HasDatabaseName("IX_AssetAlbumStaging_PersonName");
b.ToTable("AssetAlbumStaging");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FolderId")
.HasColumnType("uuid");
b.Property<DateTime>("LastMtime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.HasKey("Id");
b.HasIndex("FolderId", "Path")
.IsUnique()
.HasDatabaseName("IX_DirectoryScanStates_FolderId_Path");
b.ToTable("DirectoryScanStates");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
@@ -277,6 +356,15 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastDiscoveryScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("LastDiscoveryScannedRegex")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("LastFileScanAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
@@ -543,6 +631,28 @@ namespace Lactose.Migrations
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.AssetAlbumStaging", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany()
.HasForeignKey("AssetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Asset");
});
modelBuilder.Entity("Lactose.Models.DirectoryScanState", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Folder");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
+40
View File
@@ -69,6 +69,26 @@ public class Asset {
[Column(TypeName = "VARCHAR(16)")]
public string PreviewFormat { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to the converted video for animated assets (e.g. GIF → mp4).
/// Empty means no converted video has been generated.
/// </summary>
[Column(TypeName = "VARCHAR(2048)")]
public string ConvertedPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the MIME type of the converted video (e.g. "video/mp4").
/// </summary>
[Column(TypeName = "VARCHAR(255)")]
public string ConvertedMimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the container format of the converted video (e.g. "mp4").
/// Used to detect when conversion needs to be redone due to format changes.
/// </summary>
[Column(TypeName = "VARCHAR(16)")]
public string ConvertedFormat { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the type of the asset.
/// </summary>
@@ -101,6 +121,26 @@ public class Asset {
/// </summary>
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets the timestamp when processing of this asset last failed.
/// Non-null means the asset is marked broken and excluded from re-processing until manually retried.
/// </summary>
public DateTime? ProcessFailedAt { get; set; }
/// <summary>
/// Gets or sets the error message recorded when the asset was last marked broken.
/// </summary>
[Column(TypeName = "VARCHAR(1024)")]
public string? ProcessErrorMessage { get; set; }
/// <summary>
/// Gets or sets the timestamp when this asset was ingested into the system.
/// Unlike <see cref="CreatedAt"/> (the file's on-disk creation time), this reflects when the
/// asset was first inserted into the database, which is what incremental discovery scans use as their cursor.
/// </summary>
[Required]
public required DateTime IngestedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
+41
View File
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lactose.Models;
/// <summary>
/// Temporary staging row produced by the person/album discovery job.
/// Records a matched asset together with the person and (optional) album name extracted from its path.
/// The table is truncated at the start and end of each discovery run.
/// </summary>
public class AssetAlbumStaging {
/// <summary>
/// Gets or sets the unique identifier for the staging row.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the matched asset.
/// </summary>
[ForeignKey(nameof(Asset))]
public Guid AssetId { get; set; }
/// <summary>
/// Gets or sets the normalized person name extracted from the asset path.
/// </summary>
[Column(TypeName = "VARCHAR(255)")]
public string PersonName { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the normalized album name extracted from the asset path.
/// Null when the folder's regex has no <c>group</c> group or the group did not match.
/// </summary>
[Column(TypeName = "VARCHAR(255)")]
public string? AlbumName { get; set; }
/// <summary>
/// Gets or sets the matched asset.
/// </summary>
public Asset? Asset { get; set; }
}
+38
View File
@@ -0,0 +1,38 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Lactose.Models;
/// <summary>
/// Stores the last-known filesystem modification timestamp of a scanned directory.
/// Used by the file-system crawl job to skip directories whose mtime is unchanged since the last scan.
/// </summary>
public class DirectoryScanState {
/// <summary>
/// Gets or sets the unique identifier for the scan-state entry.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the identifier of the folder this directory belongs to.
/// </summary>
[ForeignKey(nameof(Folder))]
public Guid FolderId { get; set; }
/// <summary>
/// Gets or sets the full filesystem path of the scanned directory.
/// </summary>
[Column(TypeName = "VARCHAR(2048)")]
public string Path { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the last-known <c>LastWriteTimeUtc</c> of the directory.
/// </summary>
public DateTime LastMtime { get; set; }
/// <summary>
/// Gets or sets the folder this directory belongs to.
/// </summary>
public Folder? Folder { get; set; }
}
+19
View File
@@ -32,6 +32,25 @@ public class Folder {
[MaxLength(2048)]
public string? RegexPattern { get; set; }
/// <summary>
/// Gets or sets the timestamp of the last file-system scan of this folder.
/// Used by incremental scans to skip directories whose mtime is unchanged.
/// </summary>
public DateTime? LastFileScanAt { get; set; }
/// <summary>
/// Gets or sets the timestamp of the last person/album discovery scan of this folder.
/// Incremental discovery only processes assets created at or after this timestamp.
/// </summary>
public DateTime? LastDiscoveryScanAt { get; set; }
/// <summary>
/// Gets or sets the regex pattern used during the last discovery scan.
/// When this differs from <see cref="RegexPattern"/>, discovery automatically re-runs as a full (deep) scan.
/// </summary>
[Column(TypeName = "VARCHAR(2048)")]
public string? LastDiscoveryScannedRegex { get; set; }
#region Navigation Properties
/// <summary>
+7 -5
View File
@@ -117,11 +117,12 @@ string host = builder.Configuration["DatabaseAddress:Host"] ?? "localhost";
string port = builder.Configuration["DatabaseAddress:Port"] ?? "3306";
var strBuilder = new NpgsqlConnectionStringBuilder() {
Host = host,
Port = int.Parse(port),
Username = user,
Password = password,
Database = database
Host = host,
Port = int.Parse(port),
Username = user,
Password = password,
Database = database,
CommandTimeout = 120
};
builder.Services.AddDbContext<LactoseDbContext>(
@@ -161,6 +162,7 @@ builder.Services.AddTransient<IPersonRepository, PersonRepository>();
builder.Services.AddTransient<IMediaRepository, MediaRepository>();
builder.Services.AddTransient<IJobRecordRepository, JobRecordRepository>();
builder.Services.AddTransient<IStatsRepository, StatsRepository>();
builder.Services.AddTransient<IFilesystemBrowseService, FilesystemBrowseService>();
builder.Services.AddTransient<ITokenService, TokenService>();
builder.Services.AddTransient<LactoseAuthService>();
builder.Services.AddSingleton<JobManager>();
+60 -35
View File
@@ -4,6 +4,7 @@ using Butter.Types;
using Lactose.Context;
using Lactose.Mapper;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -27,7 +28,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
}
/// <inheritdoc />
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null) {
public async Task<List<AlbumPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, bool unassigned, Guid userId, EAccessLevel accessLevel, Guid? uploadedBy, Guid? personOwnerId, CancellationToken cancellationToken) {
IQueryable<Album> albumsQuery = context.Albums.AsNoTracking();
if (!string.IsNullOrEmpty(query))
@@ -62,31 +63,31 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
};
var pageOffset = page * pageSize;
var albumIds = ordered
var albumIds = await ordered
.Skip(pageOffset)
.Take(pageSize)
.Select(a => a.Id)
.ToList();
.ToListAsync(cancellationToken);
if (albumIds.Count == 0)
return [];
var pagedAlbums = context.Albums
var pagedAlbums = await context.Albums
.AsNoTracking()
.Include(a => a.PersonOwner)
.Include(a => a.CoverAsset)
.Where(a => albumIds.Contains(a.Id))
.ToList();
.ToListAsync(cancellationToken);
// Compute visible asset count per album at the DB level
Dictionary<Guid, int> assetCounts;
if (accessLevel >= EAccessLevel.Curator) {
assetCounts = context.Albums
assetCounts = await context.Albums
.Where(a => albumIds.Contains(a.Id))
.Select(a => new { a.Id, Count = a.Assets!.Count() })
.ToDictionary(x => x.Id, x => x.Count);
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
} else {
assetCounts = context.Albums
assetCounts = await context.Albums
.Where(a => albumIds.Contains(a.Id))
.Select(a => new {
a.Id,
@@ -96,7 +97,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
))
})
.ToDictionary(x => x.Id, x => x.Count);
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
}
var dtos = pagedAlbums.Select(a => {
@@ -108,25 +109,34 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
}
/// <inheritdoc />
public void Save() => context.SaveChanges();
/// <inheritdoc />
public Album? Find(Guid id) => context.Albums.FirstOrDefault(a => a.Id == id);
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public Album? FindWithAssets(Guid id) => context.Albums
public async Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation) {
await using var tx = await context.Database.BeginTransactionAsync();
var result = await operation();
await tx.CommitAsync();
return result;
}
/// <inheritdoc />
public Task<Album?> FindAsync(Guid id, CancellationToken cancellationToken) =>
context.Albums.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
/// <inheritdoc />
public Task<Album?> FindWithAssetsAsync(Guid id, CancellationToken cancellationToken) => context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.FirstOrDefault(a => a.Id == id);
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
/// <inheritdoc />
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
var album = context.Albums
public async Task<Album?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var album = await context.Albums
.AsNoTracking()
.Include(a => a.PersonOwner)
.FirstOrDefault(a => a.Id == id);
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
if (album == null) return null;
@@ -134,8 +144,8 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
bool canSeeAlbum = accessLevel switch {
< EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
EAccessLevel.Maintainer when userId.HasValue => album.Visibility <= EVisibility.Protected
|| (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)),
|| (album.PersonOwnerId.HasValue && await context.PersonMaintainers.AnyAsync(pm =>
pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value, cancellationToken)),
EAccessLevel.Maintainer => album.Visibility <= EVisibility.Protected,
_ => true
};
@@ -151,7 +161,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
EAccessLevel.Admin => assetsQuery,
EAccessLevel.Curator => assetsQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer when userId.HasValue && album.PersonOwnerId.HasValue
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)
&& await context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value, cancellationToken)
=> assetsQuery.Where(a => a.DeletedAt == null),
_ => assetsQuery.Where(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
@@ -160,7 +170,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
))
};
album.VisibleAssetPreviews = assetsQuery
album.VisibleAssetPreviews = await assetsQuery
.OrderBy(a => a.OriginalFilename)
.Select(a => new AlbumAssetPreviewDto {
Id = a.Id,
@@ -168,38 +178,53 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
ResolutionHeight = a.ResolutionHeight,
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
MimeType = a.ConvertedMimeType != null && a.ConvertedMimeType != "" ? a.ConvertedMimeType : a.MimeType,
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
})
.ToList();
.ToListAsync(cancellationToken);
return album;
}
/// <inheritdoc />
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
/// <inheritdoc />
public IEnumerable<Album> FindBulk(IEnumerable<Guid> ids) => context.Albums.Where(a => ids.Contains(a.Id));
public Task<List<Album>> FindByPersonAsync(Guid personId, CancellationToken cancellationToken) =>
context.Albums.Where(a => a.PersonOwnerId == personId).ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to)
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
public Task<List<Album>> FindByPersonIdsAsync(IEnumerable<Guid> personIds, CancellationToken cancellationToken) =>
context.Albums.Where(a => a.PersonOwnerId != null && personIds.Contains(a.PersonOwnerId.Value)).ToListAsync(cancellationToken);
/// <inheritdoc />
public void MergeAlbums(Guid destId, List<Guid> sourceIds) {
var dest = context.Albums
public Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken) =>
context.Albums
.Where(a => a.PersonOwnerId != null && personIds.Contains(a.PersonOwnerId.Value))
.ExecuteUpdateAsync(s => s
.SetProperty(a => a.Visibility, visibility)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
/// <inheritdoc />
public Task<List<Album>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) =>
context.Albums.Where(a => ids.Contains(a.Id)).ToListAsync(cancellationToken);
/// <inheritdoc />
public Task<List<Album>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken)
=> context.Albums.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to).ToListAsync(cancellationToken);
/// <inheritdoc />
public async Task MergeAlbumsAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken) {
var dest = await context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.FirstOrDefault(a => a.Id == destId);
.FirstOrDefaultAsync(a => a.Id == destId, cancellationToken);
if (dest == null) return;
var sources = context.Albums
var sources = await context.Albums
.AsSplitQuery()
.Include(a => a.Assets)
.Where(a => sourceIds.Contains(a.Id))
.ToList();
.ToListAsync(cancellationToken);
var existingAssetIds = dest.Assets?.Select(a => a.Id).ToHashSet() ?? [];
@@ -216,7 +241,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
/// <inheritdoc />
public void Remove(Album album) => context.Albums.Remove(album);
/// <inheritdoc />
public void Dispose() => context.Dispose();
}
+190 -188
View File
@@ -8,68 +8,70 @@ using Microsoft.Extensions.Logging;
using Pgvector.EntityFrameworkCore;
using System.Collections;
using System.Data;
using System.Text;
namespace Lactose.Repositories;
/// <inheritdoc />
public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository> logger) : IAssetRepository {
/// <inheritdoc />
public Asset? Find(Guid id) => context.Assets.Find(id);
/// <inheritdoc />
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
public async Task<Asset?> FindAsync(Guid id, CancellationToken cancellationToken) =>
await context.Assets.FindAsync([id], cancellationToken);
/// <inheritdoc />
public Task<Asset?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
IQueryable<Asset> query = context.Assets
.AsNoTracking()
.AsSplitQuery()
.Include(a => a.Uploader)
.Where(a => a.Id == id);
return accessLevel switch {
>= EAccessLevel.Admin => query.FirstOrDefault(),
EAccessLevel.Curator => query.FirstOrDefault(a =>
a.DeletedAt == null || a.UploadedBy == userId),
>= EAccessLevel.Admin => query.FirstOrDefaultAsync(cancellationToken),
EAccessLevel.Curator => query.FirstOrDefaultAsync(a =>
a.DeletedAt == null || a.UploadedBy == userId, cancellationToken),
EAccessLevel.Maintainer when userId.HasValue => query
.Include(a => a.Albums)
.FirstOrDefault(a => a.DeletedAt == null && (
.FirstOrDefaultAsync(a => a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId ||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
context.PersonMaintainers.Any(pm =>
pm.UserId == userId.Value && pm.PersonId == al.PersonOwnerId.Value))
)),
_ => query.FirstOrDefault(a =>
), cancellationToken),
_ => query.FirstOrDefaultAsync(a =>
a.DeletedAt == null && (
a.Visibility == EVisibility.Public ||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
(a.Visibility == EVisibility.Private && userId.HasValue && a.UploadedBy == userId)
))
), cancellationToken)
};
}
/// <inheritdoc />
public Asset? FindWithAlbums(Guid id) => context.Assets
public Task<Asset?> FindWithAlbumsAsync(Guid id, CancellationToken cancellationToken) => context.Assets
.AsSplitQuery()
.Include(a => a.Albums)
.FirstOrDefault(a => a.Id == id);
.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to) =>
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to);
public Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to).ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize) =>
context.Assets.Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
public Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, int pageNumber, int pageSize, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().Where(x => x.CreatedAt >= from && x.UpdatedAt <= to)
.Skip(pageNumber * pageSize)
.Take(pageSize);
/// <inheritdoc />
public void Save() => context.SaveChanges();
.Take(pageSize)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public void Insert(Asset asset) {
if(!context.Assets.Any(x => x.OriginalPath == asset.OriginalPath))
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public async Task InsertAsync(Asset asset, CancellationToken cancellationToken) {
if(!await context.Assets.AnyAsync(x => x.OriginalPath == asset.OriginalPath, cancellationToken))
context.Assets.Add(asset);
else
throw new DuplicateNameException("The required asset already exists in the database.");
@@ -90,95 +92,186 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
}
/// <inheritdoc />
public IEnumerable<Asset> FindByUploader(Guid uploaderId) => context.Assets.Where(a => a.UploadedBy == uploaderId);
public Task<List<Asset>> FindByUploaderAsync(Guid uploaderId, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().Where(a => a.UploadedBy == uploaderId).ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids) => context.Assets
public Task<int> BulkSetVisibilityByAlbumIdsAsync(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
// Scope the update to assets the caller is permitted to modify (R2). Curators/admins
// see all non-deleted assets; maintainers additionally respect their uploads and
// the albums of people they maintain.
var query = context.Assets
.Where(a => a.DeletedAt == null && a.Albums!.Any(al => albumIds.Contains(al.Id)));
query = accessLevel switch {
EAccessLevel.Admin or EAccessLevel.Curator => query,
EAccessLevel.Maintainer when userId.HasValue => query.Where(a =>
a.Visibility == EVisibility.Public ||
a.Visibility == EVisibility.Protected ||
a.UploadedBy == userId ||
a.Albums!.Any(al => al.PersonOwnerId.HasValue &&
context.PersonMaintainers.Any(pm =>
pm.UserId == userId && pm.PersonId == al.PersonOwnerId.Value))),
_ => query.Where(a => false)
};
return query.ExecuteUpdateAsync(s => s
.SetProperty(a => a.Visibility, visibility)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
}
/// <inheritdoc />
public Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken) =>
context.Assets
.Where(a => a.DeletedAt == null && a.Albums!.Any(al =>
al.PersonOwnerId.HasValue && personIds.Contains(al.PersonOwnerId.Value)))
.ExecuteUpdateAsync(s => s
.SetProperty(a => a.Visibility, visibility)
.SetProperty(a => a.UpdatedAt, DateTime.UtcNow), cancellationToken);
/// <inheritdoc />
public Task<List<Asset>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) => context.Assets
.AsSplitQuery()
.Include(a => a.Albums)
.Where(a => ids.Contains(a.Id));
/// <inheritdoc />
public Asset? FindByPath(string path) => context.Assets.FirstOrDefault(a => a.OriginalPath == path);
.Where(a => ids.Contains(a.Id))
.ToListAsync(cancellationToken);
/// <inheritdoc />
public int CountAssetsMissingPHash() {
public Task<Asset?> FindByPathAsync(string path, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking().FirstOrDefaultAsync(a => a.OriginalPath == path, cancellationToken);
/// <inheritdoc />
public Task<int> CountAssetsMissingPHashAsync(CancellationToken cancellationToken) {
var empty = new BitArray(64);
return context.Assets.Count(a => a.Hash == empty && a.DeletedAt == null);
return context.Assets.CountAsync(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null, cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset) {
public Task<List<Asset>> GetAssetsMissingPHashAsync(int limit, int offset, CancellationToken cancellationToken) {
var empty = new BitArray(64);
return context.Assets.Where(a => a.Hash == empty && a.DeletedAt == null)
return context.Assets.Where(a => a.Hash == empty && a.DeletedAt == null && a.ProcessFailedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<List<Asset>> GetDuplicates() => context.Assets
public Task<List<List<Asset>>> GetDuplicatesAsync(CancellationToken cancellationToken) => context.Assets
.GroupBy(a => new { a.Hash })
.Where(g => g.Count() > 1)
.Select(g => g.ToList());
.Select(g => g.ToList())
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, int distance) {
if(distance < 0 || distance > 63)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 64.");
return context.Assets
public async Task<List<Asset>> GetWithinHammingDistanceAsync(ulong phash, float distance, CancellationToken cancellationToken) {
if(distance < 0 || distance > 1)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 1.");
int intDistance = (int)(distance * 64);
if(intDistance < 0 || intDistance > 63)
throw new ArgumentOutOfRangeException(nameof(distance), "Distance must be between 0 and 1.");
return await context.Assets
.OrderBy(a => a.HammingDistance(phash))
.Where(a => a.Hash.HammingDistance(phash) <= distance);
.Where(a => a.Hash.HammingDistance(phash) <= intDistance)
.ToListAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance)
=> GetWithinHammingDistance(phash, (int)(distance * 64));
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingThumbnail(out int count) {
count = context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
return context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null);
public async Task<(List<Asset> Assets, int Total)> GetAssetsMissingThumbnailAsync(CancellationToken cancellationToken) {
var total = await context.Assets.CountAsync(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
var assets = await context.Assets.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "") && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif").ToListAsync(cancellationToken);
return (assets, total);
}
/// <inheritdoc />
public int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat) =>
context.Assets.Count(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null);
public Task<int> CountAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset) =>
public Task<List<Asset>> GetAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken) =>
context.Assets
.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null)
.Where(a => (a.ThumbnailPath == null || a.ThumbnailPath == "" || a.ThumbnailSize != thumbnailSize || a.ThumbnailFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif")
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat) =>
context.Assets.Count(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null);
public Task<int> CountAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif", cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset) =>
public Task<List<Asset>> GetAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken) =>
context.Assets
.Where(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null)
.Where(a => (a.PreviewPath == null || a.PreviewPath == "" || a.PreviewSize != previewSize || a.PreviewFormat != expectedFormat) && a.DeletedAt == null && a.ProcessFailedAt == null && a.MimeType != "image/gif")
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
/// <inheritdoc />
public int CountAssetsMissingMetadata() =>
context.Assets.Count(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset) =>
context.Assets.Where(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null)
public Task<int> CountAssetsMissingMetadataAsync(CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null, cancellationToken);
/// <inheritdoc />
public Task<List<Asset>> GetAssetsMissingMetadataAsync(int limit, int offset, CancellationToken cancellationToken) =>
context.Assets.Where(a => a.Type == EAssetType.Image && a.ResolutionWidth == 0 && a.DeletedAt == null && a.ProcessFailedAt == null)
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit);
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true) {
public Task<int> CountAssetsNeedingConversionAsync(CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
a.ConvertedPath == null || a.ConvertedPath == "" ||
a.ThumbnailPath == null || a.ThumbnailPath == ""), cancellationToken);
/// <inheritdoc />
public Task<List<Asset>> GetAssetsNeedingConversionAsync(int limit, int offset, CancellationToken cancellationToken) =>
context.Assets
.Where(a => a.MimeType == "image/gif" && a.DeletedAt == null && a.ProcessFailedAt == null && (
a.ConvertedPath == null || a.ConvertedPath == "" ||
a.ThumbnailPath == null || a.ThumbnailPath == ""))
.OrderBy(a => a.Id)
.Skip(offset)
.Take(limit)
.ToListAsync(cancellationToken);
/// <inheritdoc />
public Task<int> CountBrokenAssetsAsync(CancellationToken cancellationToken) =>
context.Assets.CountAsync(a => a.ProcessFailedAt != null && a.DeletedAt == null, cancellationToken);
/// <inheritdoc />
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetBrokenAssetsAsync(int page, int pageSize, CancellationToken cancellationToken) {
var total = await CountBrokenAssetsAsync(cancellationToken);
var assets = await context.Assets
.AsNoTracking()
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
.Where(a => a.ProcessFailedAt != null && a.DeletedAt == null)
.OrderBy(a => a.ProcessFailedAt)
.Skip(page * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (assets.Select(a => a.ToAssetPreviewDto(EAccessLevel.Admin, null)).ToList(), total);
}
/// <inheritdoc />
public async Task ClearProcessErrorAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) {
var assets = await context.Assets.Where(a => ids.Contains(a.Id)).ToListAsync(cancellationToken);
foreach (var asset in assets) {
asset.ProcessFailedAt = null;
asset.ProcessErrorMessage = null;
}
}
/// <inheritdoc />
public async Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, bool deletedOnly, Guid? albumId, Guid? personId, CancellationToken cancellationToken) {
var query = context.Assets.AsNoTracking().AsQueryable();
if (type.HasValue)
query = query.Where(a => a.Type == type.Value);
@@ -188,6 +281,12 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
query = query.Where(a => a.UpdatedAt <= to.Value);
if (unlinked)
query = query.Where(a => a.Albums!.Count == 0);
if (deletedOnly)
query = query.Where(a => a.DeletedAt != null);
if (albumId.HasValue)
query = query.Where(a => a.Albums!.Any(al => al.Id == albumId.Value));
if (personId.HasValue)
query = query.Where(a => a.Albums!.Any(al => al.PersonOwnerId == personId.Value));
if (folderId.HasValue)
query = query.Where(a => a.FolderId == folderId.Value);
if (uploadedBy.HasValue)
@@ -214,7 +313,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
))
};
total = includeCount ? query.Count() : 0;
var total = includeCount ? await query.CountAsync(cancellationToken) : 0;
// Deterministic page of IDs first: random order is a seeded md5 shuffle on the server,
// so the full ID set is never materialized in memory.
@@ -224,20 +323,20 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
false => query.OrderByDescending(a => a.CreatedAt)
};
var pageIds = ordered
var pageIds = await ordered
.Skip(pageNumber * pageSize)
.Take(pageSize)
.Select(a => a.Id)
.ToList();
.ToListAsync(cancellationToken);
if (pageIds.Count == 0)
return [];
return ([], total);
var assets = query
var assets = await query
.Where(a => pageIds.Contains(a.Id))
.Select(a => new AssetPreviewDto {
Id = a.Id,
MimeType = a.MimeType,
MimeType = a.ConvertedMimeType != null && a.ConvertedMimeType != "" ? a.ConvertedMimeType : a.MimeType,
ResolutionWidth = a.ResolutionWidth,
ResolutionHeight = a.ResolutionHeight,
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
@@ -246,14 +345,14 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
})
.ToList();
.ToListAsync(cancellationToken);
// Album and cosplayer names per asset via a single grouped query
var albumLinks = (from a in context.Assets
var albumLinks = await (from a in context.Assets
from al in a.Albums!
where pageIds.Contains(a.Id)
select new { AssetId = a.Id, al.Id, al.Title, al.PersonOwnerId, PersonName = al.PersonOwner!.Name })
.ToList();
.ToListAsync(cancellationToken);
var linksByAsset = albumLinks
.GroupBy(x => x.AssetId)
@@ -268,26 +367,26 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
}
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
return ([.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))], total);
}
/// <inheritdoc />
public void Dispose() => context.Dispose();
/// <inheritdoc />
public List<string> GetRandomPathsByFolder(Guid folderId, int count) =>
context.Assets
public Task<List<string>> GetRandomPathsByFolderAsync(Guid folderId, int count, CancellationToken cancellationToken) =>
context.Assets.AsNoTracking()
.Where(a => a.FolderId == folderId && a.DeletedAt == null)
.OrderBy(_ => Guid.NewGuid())
.Take(count)
.Select(a => a.OriginalPath)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true) {
public async Task<List<Butter.Dtos.Asset.AssetGroupDto>> GetFolderGroupsAsync(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly, CancellationToken cancellationToken) {
logger.LogTrace("GetFolderGroups: userId={UserId}, accessLevel={AccessLevel}, unlinkedOnly={UnlinkedOnly}", userId, accessLevel, unlinkedOnly);
var query = context.Assets
var query = context.Assets.AsNoTracking()
.Where(a => a.DeletedAt == null);
if (unlinkedOnly)
@@ -295,7 +394,7 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
if (accessLevel < EAccessLevel.Curator) {
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
var count = query.Count(a => a.UploadedBy == userId);
var count = await query.CountAsync(a => a.UploadedBy == userId, cancellationToken);
return [new Butter.Dtos.Asset.AssetGroupDto {
FolderId = null,
Name = "My Uploads",
@@ -305,17 +404,17 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
return [];
}
var groups = query
var groups = await query
.GroupBy(a => a.FolderId)
.Select(g => new {
FolderId = g.Key,
AssetCount = g.Count()
})
.ToList();
.ToListAsync(cancellationToken);
var folderNames = context.Folders
var folderNames = await context.Folders
.Where(f => groups.Select(g => g.FolderId).Contains(f.Id))
.ToDictionary(f => f.Id, f => f.BasePath);
.ToDictionaryAsync(f => f.Id, f => f.BasePath, cancellationToken);
var result = groups
.Where(g => g.AssetCount > 0)
@@ -330,101 +429,4 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
return result;
}
/// <inheritdoc />
public Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true) {
var folder = context.Folders.Find(folderId);
if (folder == null)
return null;
var basePath = folder.BasePath.TrimEnd('/') + "/";
var prefix = string.IsNullOrEmpty(currentPath) ? basePath : basePath + currentPath.TrimEnd('/') + "/";
var likePattern = prefix + "%";
var baseQuery = context.Assets
.Where(a => a.FolderId == folderId)
.Where(a => a.DeletedAt == null);
if (unlinkedOnly)
baseQuery = baseQuery.Where(a => a.Albums!.Count == 0);
baseQuery = accessLevel switch {
EAccessLevel.Admin => baseQuery,
EAccessLevel.Curator => baseQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
EAccessLevel.Maintainer => baseQuery.Where(a => a.UploadedBy == userId),
_ => baseQuery.Where(a => false)
};
if (!string.IsNullOrEmpty(search))
baseQuery = baseQuery.Where(a => EF.Functions.ILike(a.OriginalFilename, $"%{search}%"));
var dirs = GetDirectoryNames(prefix, prefix.Length, folderId, userId, accessLevel, search, unlinkedOnly);
var levelQuery = baseQuery
.Where(a => EF.Functions.Like(a.OriginalPath, likePattern))
.Where(a => !EF.Functions.Like(a.OriginalPath, prefix + "%/%"));
var totalAtLevel = levelQuery.Count();
var assetEntities = levelQuery
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
.OrderBy(a => a.OriginalFilename)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
var assetDtos = assetEntities.Select(a => a.ToAssetPreviewDto(accessLevel, userId)).ToList();
return new Butter.Dtos.Asset.AssetBrowseResultDto {
Directories = dirs,
Assets = assetDtos,
CurrentPath = currentPath,
TotalAssetCount = totalAtLevel
};
}
private List<Butter.Dtos.Asset.DirectoryEntryDto> GetDirectoryNames(string prefix, int prefixLen, Guid folderId, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly) {
var sql = new System.Text.StringBuilder();
sql.Append("SELECT DISTINCT SPLIT_PART(SUBSTRING(a.\"OriginalPath\", LENGTH({0}) + 1), '/', 1) AS \"Name\" ");
sql.Append("FROM \"Assets\" a ");
if (unlinkedOnly)
sql.Append("LEFT JOIN \"AlbumAsset\" aa ON a.\"Id\" = aa.\"AssetsId\" ");
sql.Append("WHERE a.\"FolderId\" = {1} ");
sql.Append("AND a.\"DeletedAt\" IS NULL ");
if (unlinkedOnly)
sql.Append("AND aa.\"AssetsId\" IS NULL ");
sql.Append("AND a.\"OriginalPath\" LIKE {2} ");
sql.Append("AND a.\"OriginalPath\" LIKE {3} ");
sql.Append("AND LENGTH(a.\"OriginalPath\") > LENGTH({0}) ");
var parms = new List<object> { prefix, folderId, prefix + "%", prefix + "%/%" };
int p = 4;
switch (accessLevel) {
case EAccessLevel.Curator:
sql.Append($"AND (a.\"DeletedAt\" IS NULL OR a.\"UploadedBy\" = {{{p}}}) ");
parms.Add(userId ?? Guid.Empty);
p++;
break;
case EAccessLevel.Maintainer:
sql.Append($"AND a.\"UploadedBy\" = {{{p}}} ");
parms.Add(userId ?? Guid.Empty);
p++;
break;
case < EAccessLevel.Curator:
sql.Append("AND FALSE ");
break;
}
if (!string.IsNullOrEmpty(search)) {
sql.Append($"AND a.\"OriginalFilename\" ILIKE '%' || {{{p}}} || '%' ");
parms.Add(search!);
}
sql.Append("ORDER BY \"Name\"");
return context.Database
.SqlQueryRaw<Butter.Dtos.Asset.DirectoryEntryDto>(sql.ToString(), parms.ToArray())
.ToList();
}
}
}
+15 -16
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -17,15 +18,15 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
static void OnFolderRemoved(Folder e) => FolderRemoved?.Invoke(null, e);
/// <inheritdoc />
public void Insert(Folder folder) {
public async Task InsertAsync(Folder folder, CancellationToken cancellationToken) {
context.Folders.Add(folder);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
if (folder.Active) OnFolderAdded(folder);
}
/// <inheritdoc />
public void Update(Guid id, Folder folder) {
var origFolder = context.Folders.Find(id);
public async Task UpdateAsync(Guid id, Folder folder, CancellationToken cancellationToken) {
var origFolder = await context.Folders.FindAsync([id], cancellationToken);
if (origFolder == null) { return; }
@@ -33,12 +34,12 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
var hasChanged = origFolder.BasePath != folder.BasePath;
var wasActive = origFolder.Active;
origFolder.BasePath = folder.BasePath;
origFolder.Active = folder.Active;
context.Folders.Update(origFolder);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
if (hasChanged && wasActive) OnFolderRemoved(oldPath);
if (hasChanged && folder.Active) {}
@@ -48,24 +49,22 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
}
/// <inheritdoc />
public void Remove(Guid id) {
var folder = context.Folders.Find(id);
public async Task RemoveAsync(Guid id, CancellationToken cancellationToken) {
var folder = await context.Folders.FindAsync([id], cancellationToken);
if (folder != null) {
context.Folders.Remove(folder);
OnFolderRemoved(folder);
}
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Folder> GetAllUntracked() => context.Folders.AsNoTracking();
/// <inheritdoc />
public Folder? Get(Guid id) => context.Folders.Find(id);
public async Task<Folder?> GetAsync(Guid id, CancellationToken cancellationToken) =>
await context.Folders.FindAsync([id], cancellationToken);
/// <inheritdoc />
public IEnumerable<Folder> GetAll() => context.Folders;
public Task<List<Folder>> GetAllAsync(CancellationToken cancellationToken) => context.Folders.ToListAsync(cancellationToken);
/// <inheritdoc />
public void Dispose() => context.Dispose();
+52 -18
View File
@@ -10,41 +10,44 @@ namespace Lactose.Repositories;
/// </summary>
public interface IAlbumRepository : IDisposable {
/// <summary>
/// Inserts a new album into the database
/// Inserts a new album into the database.
/// </summary>
/// <param name="album"></param>
public void Insert(Album album);
/// <param name="album">The album to insert.</param>
void Insert(Album album);
/// <summary>
/// Updates an album in the database
/// Updates an album in the database.
/// </summary>
/// <param name="album"></param>
public void Update(Album album);
/// <param name="album">The album to update.</param>
void Update(Album album);
/// <summary>
/// Updates multiple albums in the database.
/// </summary>
/// <param name="albums">The albums to update.</param>
public void UpdateBulk(IEnumerable<Album> albums);
void UpdateBulk(IEnumerable<Album> albums);
/// <summary>
/// Saves changes to the database.
/// </summary>
public void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds an album by its ID.
/// </summary>
/// <param name="id">The ID of the album.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The album if found; otherwise, null.</returns>
public Album? Find(Guid id);
Task<Album?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds an album by its ID with its assets loaded for collection replacement.
/// </summary>
/// <param name="id">The ID of the album.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The album with its asset collection loaded if found; otherwise, null.</returns>
public Album? FindWithAssets(Guid id);
Task<Album?> FindWithAssetsAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds an album by its ID with assets filtered by the requesting user's access level.
@@ -52,30 +55,51 @@ public interface IAlbumRepository : IDisposable {
/// <param name="id">The ID of the album.</param>
/// <param name="userId">The requesting user's ID for visibility-scoped asset filtering.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The album if found; otherwise, null.</returns>
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
Task<Album?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Finds albums by the person's ID.
/// </summary>
/// <param name="personId">The ID of the person.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of albums associated with the specified person.</returns>
public IEnumerable<Album> FindByPerson(Guid personId);
Task<List<Album>> FindByPersonAsync(Guid personId, CancellationToken cancellationToken);
/// <summary>
/// Finds albums owned by any of the specified people.
/// </summary>
/// <param name="personIds">The IDs of the people.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of albums associated with the specified people.</returns>
Task<List<Album>> FindByPersonIdsAsync(IEnumerable<Guid> personIds, CancellationToken cancellationToken);
/// <summary>
/// Bulk-updates the visibility of all albums owned by the specified people via a single SQL UPDATE.
/// </summary>
/// <param name="personIds">The IDs of the people.</param>
/// <param name="visibility">The target visibility level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of albums updated.</returns>
Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken);
/// <summary>
/// Finds multiple albums by their IDs.
/// </summary>
/// <param name="ids">The IDs of the albums.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of albums with the specified IDs.</returns>
public IEnumerable<Album> FindBulk(IEnumerable<Guid> ids);
Task<List<Album>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
/// <summary>
/// Finds albums within a specified date range.
/// </summary>
/// <param name="from">The start date of the range.</param>
/// <param name="to">The end date of the range.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of albums created or updated within the specified date range.</returns>
public IEnumerable<Album> FindByDateRange(DateTime from, DateTime to);
Task<List<Album>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
/// <summary>
/// Merges multiple source albums into a destination album.
@@ -83,13 +107,14 @@ public interface IAlbumRepository : IDisposable {
/// </summary>
/// <param name="destId">The ID of the destination album.</param>
/// <param name="sourceIds">The IDs of the source albums to merge.</param>
public void MergeAlbums(Guid destId, List<Guid> sourceIds);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task MergeAlbumsAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken);
/// <summary>
/// Removes an album from the database.
/// </summary>
/// <param name="album">The album to remove.</param>
public void Remove(Album album);
void Remove(Album album);
/// <summary>
/// Searches for albums based on a query string with optional sorting, filtering, and access-level scoping.
@@ -104,7 +129,16 @@ public interface IAlbumRepository : IDisposable {
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
/// <param name="personOwnerId">Optional filter for albums owned by a specific person.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of album previews matching the search query.</returns>
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null);
Task<List<AlbumPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, bool unassigned, Guid userId, EAccessLevel accessLevel, Guid? uploadedBy, Guid? personOwnerId, CancellationToken cancellationToken);
}
/// <summary>
/// Executes the given operation inside an explicit transaction on the shared database context,
/// committing on success and rolling back on failure.
/// </summary>
/// <typeparam name="TResult">The type returned by the operation.</typeparam>
/// <param name="operation">The work to run inside the transaction.</param>
/// <returns>The operation's result, or null if the operation returned null.</returns>
Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation);
}
+129 -52
View File
@@ -12,8 +12,9 @@ public interface IAssetRepository : IDisposable {
/// Finds an asset by its ID.
/// </summary>
/// <param name="id">The ID of the asset.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns>
public Asset? Find(Guid id);
Task<Asset?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds an asset by its ID, respecting the requesting user's access level.
@@ -22,37 +23,64 @@ public interface IAssetRepository : IDisposable {
/// <param name="id">The ID of the asset.</param>
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The asset if found and visible; otherwise null.</returns>
public Asset? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel);
Task<Asset?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Finds an asset by its ID with its album navigation properties loaded.
/// </summary>
/// <param name="id">The ID of the asset.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Null if not found, otherwise the requested asset with albums.</returns>
public Asset? FindWithAlbums(Guid id);
Task<Asset?> FindWithAlbumsAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds a set of assets by their IDs.
/// </summary>
/// <param name="ids">The IDs of the assets.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets with the specified IDs.</returns>
public IEnumerable<Asset> FindBulk(IEnumerable<Guid> ids);
Task<List<Asset>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets uploaded by the user.
/// </summary>
/// <param name="uploaderId">The ID of the uploader.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets uploaded by the specified user.</returns>
public IEnumerable<Asset> FindByUploader(Guid uploaderId);
Task<List<Asset>> FindByUploaderAsync(Guid uploaderId, CancellationToken cancellationToken);
/// <summary>
/// Bulk-updates the visibility of non-deleted assets in the specified albums via a single SQL UPDATE,
/// respecting the requesting user's access level.
/// </summary>
/// <param name="albumIds">The IDs of the albums.</param>
/// <param name="visibility">The target visibility level.</param>
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of assets updated.</returns>
Task<int> BulkSetVisibilityByAlbumIdsAsync(IEnumerable<Guid> albumIds, EVisibility visibility, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Bulk-updates the visibility of non-deleted assets in any album owned by the specified people
/// via a single SQL UPDATE.
/// </summary>
/// <param name="personIds">The IDs of the people whose album assets to update.</param>
/// <param name="visibility">The target visibility level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of assets updated.</returns>
Task<int> BulkSetVisibilityByPersonIdsAsync(IEnumerable<Guid> personIds, EVisibility visibility, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets created or updated between the given dates.
/// </summary>
/// <param name="from">The start date of the range.</param>
/// <param name="to">The end date of the range.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets created or updated within the specified date range.</returns>
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to);
Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets created or updated between the given dates with pagination.
@@ -61,73 +89,88 @@ public interface IAssetRepository : IDisposable {
/// <param name="to">The end date of the range.</param>
/// <param name="pageNumber">The page number for pagination.</param>
/// <param name="pageSize">The number of items per page.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of assets created or updated within the specified date range.</returns>
public IEnumerable<Asset> FindByDateRange(DateTime from, DateTime to, int pageNumber, int pageSize);
Task<List<Asset>> FindByDateRangeAsync(DateTime from, DateTime to, int pageNumber, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Saves all the changes to the model.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Inserts a new asset.
/// Inserts a new asset. Throws when an asset with the same original path already exists.
/// </summary>
/// <param name="asset">The asset to insert.</param>
public void Insert(Asset asset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task InsertAsync(Asset asset, CancellationToken cancellationToken);
/// <summary>
/// Updates a given asset.
/// </summary>
/// <param name="asset">The asset to update.</param>
public void Update(Asset asset);
void Update(Asset asset);
/// <summary>
/// Updates a list of assets.
/// </summary>
/// <param name="assets">The list of assets to update.</param>
public void UpdateBulk(IEnumerable<Asset> assets);
void UpdateBulk(IEnumerable<Asset> assets);
/// <summary>
/// Gets an asset by its file path.
/// </summary>
/// <param name="filePath">The file path of the asset.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns>
Asset? FindByPath(string filePath);
/// Gets an asset by its file path.
/// </summary>
/// <param name="filePath">The file path of the asset.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Null if not found, otherwise the requested asset.</returns>
Task<Asset?> FindByPathAsync(string filePath, CancellationToken cancellationToken);
/// <summary>
/// Counts all assets that are missing a perceptual hash (pHash).
/// </summary>
int CountAssetsMissingPHash();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountAssetsMissingPHashAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets that are missing a perceptual hash (pHash).
/// </summary>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
IEnumerable<Asset> GetAssetsMissingPHash(int limit, int offset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Asset>> GetAssetsMissingPHashAsync(int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Groups assets sharing an identical perceptual hash (pHash).
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Groups of duplicate assets (only groups with more than one asset).</returns>
Task<List<List<Asset>>> GetDuplicatesAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds assets within a normalized Hamming distance (0 to 1).
/// </summary>
/// <param name="phash">The perceptual hash to compare against.</param>
/// <param name="distance">The maximum distance as a fraction (0 to 1).</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Assets whose hash is within the specified distance.</returns>
public IEnumerable<Asset> GetWithinHammingDistance(ulong phash, float distance);
Task<List<Asset>> GetWithinHammingDistanceAsync(ulong phash, float distance, CancellationToken cancellationToken);
/// <summary>
/// Finds all assets that are missing a thumbnail.
/// Finds all assets that are missing a thumbnail, together with the total count.
/// </summary>
/// <param name="totalAssets">The total number of assets missing thumbnails.</param>
/// <returns>Assets that do not have a thumbnail path set.</returns>
IEnumerable<Asset> GetAssetsMissingThumbnail(out int totalAssets);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Assets that do not have a thumbnail path set, and the total count of such assets.</returns>
Task<(List<Asset> Assets, int Total)> GetAssetsMissingThumbnailAsync(CancellationToken cancellationToken);
/// <summary>
/// Counts all assets that need thumbnails — either missing entirely, with wrong dimensions, or wrong format.
/// </summary>
/// <param name="thumbnailSize">The expected longest-side size in pixels.</param>
/// <param name="expectedFormat">The expected thumbnail format (e.g. "webp").</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The total count of assets needing thumbnails.</returns>
int CountAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat);
Task<int> CountAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets that need thumbnails — either missing entirely, with wrong dimensions, or wrong format.
@@ -136,16 +179,18 @@ public interface IAssetRepository : IDisposable {
/// <param name="expectedFormat">The expected thumbnail format (e.g. "webp").</param>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of assets that need thumbnails regenerated.</returns>
IEnumerable<Asset> GetAssetsMissingOrWrongThumbnail(int thumbnailSize, string expectedFormat, int limit, int offset);
Task<List<Asset>> GetAssetsMissingOrWrongThumbnailAsync(int thumbnailSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts all assets that need previews — either missing entirely, with wrong dimensions, or wrong format.
/// </summary>
/// <param name="previewSize">The expected longest-side size in pixels.</param>
/// <param name="expectedFormat">The expected preview format (e.g. "webp").</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The total count of assets needing previews.</returns>
int CountAssetsMissingOrWrongPreview(int previewSize, string expectedFormat);
Task<int> CountAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets that need previews — either missing entirely, with wrong dimensions, or wrong format.
@@ -154,28 +199,71 @@ public interface IAssetRepository : IDisposable {
/// <param name="expectedFormat">The expected preview format (e.g. "webp").</param>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A page of assets that need previews regenerated.</returns>
IEnumerable<Asset> GetAssetsMissingOrWrongPreview(int previewSize, string expectedFormat, int limit, int offset);
Task<List<Asset>> GetAssetsMissingOrWrongPreviewAsync(int previewSize, string expectedFormat, int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts image assets that are missing resolution metadata.
/// </summary>
int CountAssetsMissingMetadata();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountAssetsMissingMetadataAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of image assets missing resolution metadata.
/// </summary>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
IEnumerable<Asset> GetAssetsMissingMetadata(int limit, int offset);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Asset>> GetAssetsMissingMetadataAsync(int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts animated assets (GIF) that need a converted video and/or first-frame thumbnail.
/// Excludes deleted and broken assets.
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountAssetsNeedingConversionAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of animated assets (GIF) that need a converted video and/or first-frame thumbnail.
/// </summary>
/// <param name="limit">Maximum number of assets to return.</param>
/// <param name="offset">Number of assets to skip.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Asset>> GetAssetsNeedingConversionAsync(int limit, int offset, CancellationToken cancellationToken);
/// <summary>
/// Counts assets currently marked broken (processing failed, not deleted).
/// </summary>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<int> CountBrokenAssetsAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a page of assets marked broken (processing failed, not deleted), most recently broken first,
/// together with the total count.
/// </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 as preview DTOs and the total count.</returns>
Task<(List<AssetPreviewDto> Assets, int Total)> GetBrokenAssetsAsync(int page, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Clears the broken (processing failed) state for the given assets so they are picked up for
/// re-processing on the next job run. Caller must invoke <see cref="SaveAsync"/> afterwards.
/// </summary>
/// <param name="ids">The IDs of the assets to retry.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task ClearProcessErrorAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
/// <summary>
/// Gets a random sample of asset paths from the specified folder.
/// </summary>
/// <param name="folderId">The folder ID.</param>
/// <param name="count">The number of random paths to return.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of asset original paths.</returns>
List<string> GetRandomPathsByFolder(Guid folderId, int count);
Task<List<string>> GetRandomPathsByFolderAsync(Guid folderId, int count, CancellationToken cancellationToken);
/// <summary>
/// Queries assets with optional type filter, date range, random ordering, and visibility scoping.
@@ -187,16 +275,19 @@ public interface IAssetRepository : IDisposable {
/// <param name="seed">An optional seed for deterministic random ordering across paginated requests.</param>
/// <param name="pageNumber">The zero-based page number.</param>
/// <param name="pageSize">The number of items per page.</param>
/// <param name="total">The total count of matching assets (after visibility filtering).</param>
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
/// <param name="accessLevel">The requesting user's access level for visibility filtering.</param>
/// <param name="unlinked">If true, filters for assets not assigned to any album.</param>
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
/// <param name="includeCount">If true (default), computes the total matching count. When false, <paramref name="total"/> is set to zero and the count query is skipped.</param>
/// <returns>A paginated collection of asset previews matching the filters.</returns>
IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true);
/// <param name="includeCount">If true (default), computes the total matching count. When false, the returned total is zero and the count query is skipped.</param>
/// <param name="deletedOnly">If true, filters exclusively for soft-deleted assets. Results remain scoped by the requester's access level.</param>
/// <param name="albumId">Optional album ID to filter assets belonging to that album.</param>
/// <param name="personId">Optional person ID to filter assets in albums owned by that person.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A paginated collection of asset previews matching the filters, and the total matching count.</returns>
Task<(List<AssetPreviewDto> Assets, int Total)> GetAssetsAsync(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, Guid? userId, EAccessLevel accessLevel, bool unlinked, Guid? folderId, Guid? uploadedBy, string? search, bool includeCount, bool deletedOnly, Guid? albumId, Guid? personId, CancellationToken cancellationToken);
/// <summary>
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
@@ -206,21 +297,7 @@ public interface IAssetRepository : IDisposable {
/// <param name="userId">The requesting user's ID.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="unlinkedOnly">If true (default), only counts assets not assigned to any album.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A list of unlinked asset groups with folder ID, name, and asset count.</returns>
List<Butter.Dtos.Asset.AssetGroupDto> GetFolderGroups(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly = true);
/// <summary>
/// Browses assets at a specific directory level within a folder, returning immediate subdirectories
/// and the assets located directly at that level with pagination.
/// </summary>
/// <param name="folderId">The root folder ID to browse within.</param>
/// <param name="currentPath">The current path relative to the folder root (empty string for root).</param>
/// <param name="page">The zero-based page number for asset pagination.</param>
/// <param name="pageSize">The number of assets per page.</param>
/// <param name="userId">The requesting user's ID for visibility filtering.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
/// <param name="unlinkedOnly">If true (default), only includes assets not assigned to any album.</param>
/// <returns>A browse result containing subdirectories and a page of assets at the current level.</returns>
Butter.Dtos.Asset.AssetBrowseResultDto? BrowseAssets(Guid folderId, string currentPath, int page, int pageSize, Guid? userId, EAccessLevel accessLevel, string? search, bool unlinkedOnly = true);
Task<List<Butter.Dtos.Asset.AssetGroupDto>> GetFolderGroupsAsync(Guid? userId, EAccessLevel accessLevel, bool unlinkedOnly, CancellationToken cancellationToken);
}
+11 -10
View File
@@ -10,35 +10,36 @@ public interface IFolderRepository : IDisposable {
/// Creates a new folder.
/// </summary>
/// <param name="folder">The folder to create.</param>
void Insert(Folder folder);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task InsertAsync(Folder folder, CancellationToken cancellationToken);
/// <summary>
/// Updates an existing folder.
/// </summary>
/// <param name="id">The ID of the folder to update.</param>
/// <param name="folder">The updated folder data.</param>
void Update(Guid id, Folder folder);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task UpdateAsync(Guid id, Folder folder, CancellationToken cancellationToken);
/// <summary>
/// Removes a folder by ID.
/// </summary>
/// <param name="id">The ID of the folder to remove.</param>
void Remove(Guid id);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task RemoveAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Retrieves a folder by ID.
/// </summary>
/// <param name="id">The ID of the folder to retrieve.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The folder with the specified ID.</returns>
Folder? Get(Guid id);
Task<Folder?> GetAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Retrieves all folders.
/// </summary>
IEnumerable<Folder> GetAll();
/// <summary>
/// Retrieves all folders without tracking them in the change tracker.
/// </summary>
IEnumerable<Folder> GetAllUntracked();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all folders.</returns>
Task<List<Folder>> GetAllAsync(CancellationToken cancellationToken);
}
+16 -9
View File
@@ -19,38 +19,45 @@ public interface IJobRecordRepository : IDisposable {
/// <summary>
/// Retrieves all job records, ordered by most recent.
/// </summary>
List<JobRecord> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<JobRecord>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Retrieves a page of past (completed/failed/canceled) root job records.
/// Retrieves a page of past (completed/failed/canceled) root job records with the total count.
/// </summary>
/// <param name="page">The page number.</param>
/// <param name="pageSize">The number of records per page.</param>
/// <param name="total">The total number of past root jobs.</param>
List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The page of records and the total number of past root jobs.</returns>
Task<(List<JobRecord> Jobs, int Total)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Retrieves child job records for a given parent job.
/// </summary>
/// <param name="parentId">The parent job ID.</param>
List<JobRecord> GetChildren(Guid parentId);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<JobRecord>> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken);
/// <summary>
/// Retrieves child job records for a given parent job that have been modified since the specified timestamp.
/// </summary>
/// <param name="parentId">The parent job ID.</param>
/// <param name="since">Only return records modified after this timestamp.</param>
List<JobRecord> GetChildrenModifiedSince(Guid parentId, DateTime since);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<JobRecord>> GetChildrenModifiedSinceAsync(Guid parentId, DateTime since, CancellationToken cancellationToken);
/// <summary>
/// Retrieves a job record by its ID.
/// </summary>
/// <param name="id">The job record ID.</param>
JobRecord? GetById(Guid id);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<JobRecord?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Deletes job records that finished before the specified cutoff date.
/// </summary>
/// <param name="cutoff">The cutoff date.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The number of deleted records.</returns>
int DeleteOldJobs(DateTime cutoff);
Task<int> DeleteOldJobsAsync(DateTime cutoff, CancellationToken cancellationToken);
/// <summary>
/// Persists changes to the database.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
}
+17 -4
View File
@@ -13,8 +13,9 @@ public interface IMediaRepository : IDisposable {
/// <param name="id">The ID of the media.</param>
/// <param name="userId">The ID of the user requesting the media.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
Task<MediaDto?> GetOriginalDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the thumbnail media data.
@@ -22,8 +23,9 @@ public interface IMediaRepository : IDisposable {
/// <param name="id">The ID of the media.</param>
/// <param name="userId">The ID of the user requesting the media.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
Task<MediaDto?> GetThumbDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the preview media data.
@@ -31,6 +33,17 @@ public interface IMediaRepository : IDisposable {
/// <param name="id">The ID of the media.</param>
/// <param name="userId">The ID of the user requesting the media.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A <see cref="MediaDto"/> containing the media data, or null if not found or unauthorized.</returns>
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User);
}
Task<MediaDto?> GetPreviewDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the converted media data (e.g. the mp4 for an animated GIF).
/// </summary>
/// <param name="id">The ID of the media.</param>
/// <param name="userId">The ID of the user requesting the media.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A <see cref="MediaDto"/> containing the converted media data, or null if the asset has no converted file or is not visible.</returns>
Task<MediaDto?> GetConvertedDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
}
+36 -14
View File
@@ -18,14 +18,16 @@ public interface IPersonRepository : IDisposable {
/// <summary>
/// Saves all pending changes to the database.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Finds a person by their ID.
/// </summary>
/// <param name="id">The ID of the person.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The person if found; otherwise, null.</returns>
Person? Find(Guid id);
Task<Person?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Finds a person by their ID with albums and assets filtered by the requesting user's access level.
@@ -38,29 +40,34 @@ public interface IPersonRepository : IDisposable {
/// <param name="albumSortAsc">Whether to sort albums ascending.</param>
/// <param name="albumPage">Zero-based page number for album pagination.</param>
/// <param name="albumPageSize">Number of albums per page (default 150, max 250).</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The person if found; otherwise, null.</returns>
Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize);
Task<Person?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel,
string? albumSearch, string? albumSortBy, bool albumSortAsc,
int albumPage, int albumPageSize, CancellationToken cancellationToken);
/// <summary>
/// Finds a person by their name (case-sensitive).
/// </summary>
/// <param name="name">The name of the person.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The person if found; otherwise, null.</returns>
Person? FindByName(string name);
Task<Person?> FindByNameAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Finds all people whose names are in the given set.
/// </summary>
/// <param name="names">The names to search for.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of people with matching names (non-deleted only).</returns>
IEnumerable<Person> FindByNames(IEnumerable<string> names);
Task<List<Person>> FindByNamesAsync(IEnumerable<string> names, CancellationToken cancellationToken);
/// <summary>
/// Retrieves all non-deleted people.
/// </summary>
IEnumerable<Person> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all people.</returns>
Task<List<Person>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Retrieves all non-deleted people that have at least one album with visible assets for the given user.
@@ -68,7 +75,8 @@ public interface IPersonRepository : IDisposable {
/// </summary>
/// <param name="userId">The current user's ID.</param>
/// <param name="accessLevel">The current user's access level.</param>
IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task<List<Person>> GetAllVisibleAsync(Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Searches for people based on a query string with optional sorting and pagination.
@@ -81,15 +89,17 @@ public interface IPersonRepository : IDisposable {
/// <param name="sortAsc">Whether to sort ascending. Default is <c>true</c>.</param>
/// <param name="userId">The current user's ID for access-level filtering.</param>
/// <param name="accessLevel">The current user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A paginated list of person previews matching the query.</returns>
IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User);
Task<List<PersonPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
/// <summary>
/// Finds multiple people by their IDs.
/// </summary>
/// <param name="ids">The IDs to find.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A collection of matching people.</returns>
IEnumerable<Person> FindBulk(IEnumerable<Guid> ids);
Task<List<Person>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken);
/// <summary>
/// Merges multiple source people into a destination person.
@@ -97,7 +107,8 @@ public interface IPersonRepository : IDisposable {
/// </summary>
/// <param name="destId">The ID of the destination person.</param>
/// <param name="sourceIds">The IDs of the source people to merge.</param>
void MergePeople(Guid destId, List<Guid> sourceIds);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task MergePeopleAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken);
/// <summary>
/// Removes a person from the database.
@@ -110,8 +121,9 @@ public interface IPersonRepository : IDisposable {
/// </summary>
/// <param name="userId">The user ID to check.</param>
/// <param name="personId">The person ID to check.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns><see langword="true"/> if the user is a maintainer of the person.</returns>
bool IsMaintainerOf(Guid userId, Guid personId);
Task<bool> IsMaintainerOfAsync(Guid userId, Guid personId, CancellationToken cancellationToken);
/// <summary>
/// Sets the list of user IDs that maintain this person.
@@ -119,5 +131,15 @@ public interface IPersonRepository : IDisposable {
/// </summary>
/// <param name="personId">The person ID.</param>
/// <param name="userIds">The user IDs to assign as maintainers.</param>
void SetMaintainers(Guid personId, IEnumerable<Guid> userIds);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SetMaintainersAsync(Guid personId, IEnumerable<Guid> userIds, CancellationToken cancellationToken);
/// <summary>
/// Executes the given operation inside an explicit transaction on the shared database context,
/// committing on success and rolling back on failure.
/// </summary>
/// <typeparam name="TResult">The type returned by the operation.</typeparam>
/// <param name="operation">The work to run inside the transaction.</param>
/// <returns>The operation's result, or null if the operation returned null.</returns>
Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation);
}
+20 -13
View File
@@ -10,41 +10,48 @@ public interface ISettingsRepository : IDisposable {
/// Creates a settings entry in the repository. If a settings entry already exists, it will be overwritten.
/// </summary>
/// <param name="setting">The settings to create.</param>
public void Create(Setting setting);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task CreateAsync(Setting setting, CancellationToken cancellationToken);
/// <summary>
/// Retrieves the settings from the repository.
/// Retrieves all settings from the repository.
/// </summary>
/// <returns>The settings if found; otherwise, null.</returns>
public IEnumerable<Setting> Get();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all settings.</returns>
Task<List<Setting>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Try to get a specific setting by its name.
/// </summary>
/// <param name="name">name key of the setting</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns> The setting if found; otherwise, null.</returns>
public Setting? Get(string name);
Task<Setting?> GetAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Updates the settings in the repository.
/// </summary>
/// <param name="setting">The settings to update.</param>
public void Update(Setting setting);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task UpdateAsync(Setting setting, CancellationToken cancellationToken);
/// <summary>
/// Deletes the settings from the repository.
/// Deletes all settings from the repository.
/// </summary>
public void Delete();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task DeleteAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Delete a specific setting by its name.
/// </summary>
/// <param name="name">name key of the setting to remove</param>
public void Delete(string name);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task DeleteAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Deletes a specific setting from the repository.
/// </summary>
/// <param name="setting">Setting instance to delete</param>
public void Delete(Setting setting);
}
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task DeleteAsync(Setting setting, CancellationToken cancellationToken);
}
+22 -17
View File
@@ -12,51 +12,56 @@ public interface ITagRepository : IDisposable {
/// <param name="searchQuery">the search query</param>
/// <param name="page"> the number of the page for the given query</param>
/// <param name="pageSize">the size of the page</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>list of tags of type <see cref="Tag"/> with the size <paramref name="pageSize"/></returns>
List<Tag> Search(string searchQuery, int page, int pageSize);
Task<List<Tag>> SearchAsync(string searchQuery, int page, int pageSize, CancellationToken cancellationToken);
/// <summary>
/// Returns all the tags
/// Returns all the tags.
/// </summary>
/// <returns></returns>
IEnumerable<Tag> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all tags.</returns>
Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Find a specific Tag given the id
/// Find a specific Tag given the id.
/// </summary>
/// <param name="id">identifier of the tag</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>the <see cref="Tag"/> or null</returns>
Tag? Find(Guid id);
Task<Tag?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Find a specific Tag given the name
/// Find a specific Tag given the name.
/// </summary>
/// <param name="name">the exact name of the tag</param>
/// <returns></returns>
Tag? FindByName(string name);
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>the <see cref="Tag"/> or null</returns>
Task<Tag?> FindByNameAsync(string name, CancellationToken cancellationToken);
/// <summary>
/// Return the list of the children of the given tag
/// Return the list of the children of the given tag.
/// </summary>
/// <param name="id">identifier of the tag</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>the list of <see cref="Tag"/>, could be empty</returns>
List<Tag> GetChildren(Guid id);
Task<List<Tag>> GetChildrenAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Saves all changes to the repository.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Deletes the given tag from the repository.
/// </summary>
/// <param name="tag"></param>
/// <param name="tag">The tag to delete.</param>
void Delete(Tag tag);
/// <summary>
/// Insert a new Tag
/// Insert a new Tag.
/// </summary>
/// <param name="tag"></param>
/// <param name="tag">The tag to insert.</param>
void Insert(Tag tag);
}
+28 -21
View File
@@ -7,49 +7,55 @@ namespace Lactose.Repositories;
/// </summary>
public interface IUserRepository : IDisposable {
/// <summary>
/// Return the list of all users
/// Return the list of all users.
/// </summary>
/// <returns></returns>
IEnumerable<User> GetAll();
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The list of all users.</returns>
Task<List<User>> GetAllAsync(CancellationToken cancellationToken);
/// <summary>
/// Insert a new uer
/// Insert a new user.
/// </summary>
/// <param name="user"></param>
/// <param name="user">The user to insert.</param>
void Insert(User user);
/// <summary>
/// Saves all the changes to the Model
/// Saves all the changes to the Model.
/// </summary>
void Save();
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SaveAsync(CancellationToken cancellationToken);
/// <summary>
/// Get the user from the database.
/// Get the user from the database.
/// </summary>
/// <param name="id"></param>
/// <returns>Returns the user if exist</returns>
User? Find(Guid id);
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user if exist.</returns>
Task<User?> FindAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Find a user by email, expects that the email is unique
/// Find a user by email, expects that the email is unique.
/// </summary>
/// <param name="email"></param>
/// <returns>Returns the user if found </returns>
User? FindByEmail(string email);
/// <param name="email">The email address.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user if found.</returns>
Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken);
/// <summary>
/// Find a user by username, expects that the username is unique
/// Find a user by username, expects that the username is unique.
/// </summary>
/// <param name="username"></param>
/// <returns>Returns the user if found </returns>
User? FindByUsername(string username);
/// <param name="username">The username.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user if found.</returns>
Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken);
/// <summary>
/// Get the user with their maintained persons eagerly loaded.
/// </summary>
/// <param name="id">The user ID.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>Returns the user with maintained persons if found.</returns>
User? FindWithMaintainedPersons(Guid id);
Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken);
/// <summary>
/// Sets the list of person (cosplayer) IDs that a user maintains.
@@ -57,5 +63,6 @@ public interface IUserRepository : IDisposable {
/// </summary>
/// <param name="userId">The user ID.</param>
/// <param name="personIds">The person IDs to assign.</param>
void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds);
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken);
}
+19 -16
View File
@@ -1,6 +1,7 @@
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -19,50 +20,52 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
}
/// <inheritdoc />
public List<JobRecord> GetAll() =>
public Task<List<JobRecord>> GetAllAsync(CancellationToken cancellationToken) =>
context.JobRecords
.OrderByDescending(j => j.Finished ?? j.Created)
.ToList();
.ToListAsync(cancellationToken);
static readonly EJobStatus[] PastStatuses = [EJobStatus.Completed, EJobStatus.CompletedWithErrors, EJobStatus.Failed, EJobStatus.Canceled];
/// <inheritdoc />
public List<JobRecord> GetPastRootJobs(int page, int pageSize, out int total) {
public async Task<(List<JobRecord> Jobs, int Total)> GetPastRootJobsAsync(int page, int pageSize, CancellationToken cancellationToken) {
var query = context.JobRecords
.Where(j => j.ParentJobId == null && PastStatuses.Contains(j.Status));
total = query.Count();
var total = await query.CountAsync(cancellationToken);
return query
var jobs = await query
.OrderByDescending(j => j.Finished ?? j.Created)
.Skip(page * pageSize)
.Take(pageSize)
.ToList();
.ToListAsync(cancellationToken);
return (jobs, total);
}
/// <inheritdoc />
public List<JobRecord> GetChildren(Guid parentId) =>
public Task<List<JobRecord>> GetChildrenAsync(Guid parentId, CancellationToken cancellationToken) =>
context.JobRecords
.Where(j => j.ParentJobId == parentId)
.OrderBy(j => j.Created)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public List<JobRecord> GetChildrenModifiedSince(Guid parentId, DateTime since) =>
public Task<List<JobRecord>> GetChildrenModifiedSinceAsync(Guid parentId, DateTime since, CancellationToken cancellationToken) =>
context.JobRecords
.Where(j => j.ParentJobId == parentId && j.ModifiedAt != null && j.ModifiedAt > since)
.OrderBy(j => j.Created)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public JobRecord? GetById(Guid id) =>
context.JobRecords.Find(id);
public async Task<JobRecord?> GetByIdAsync(Guid id, CancellationToken cancellationToken) =>
await context.JobRecords.FindAsync([id], cancellationToken);
/// <inheritdoc />
public int DeleteOldJobs(DateTime cutoff) {
var old = context.JobRecords
public async Task<int> DeleteOldJobsAsync(DateTime cutoff, CancellationToken cancellationToken) {
var old = await context.JobRecords
.Where(j => j.Finished != null && j.Finished < cutoff)
.ToList();
.ToListAsync(cancellationToken);
var count = old.Count;
if (count > 0) {
context.JobRecords.RemoveRange(old);
@@ -71,7 +74,7 @@ public class JobRecordRepository(LactoseDbContext context) : IJobRecordRepositor
}
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public void Dispose() => context.Dispose();
+45 -26
View File
@@ -2,6 +2,7 @@ using Butter.Dtos;
using Butter.Types;
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -16,47 +17,51 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
/// <param name="id">Asset ID</param>
/// <param name="userId">UserID we are trying to get this as</param>
/// <param name="accessLevel">The requesting user's access level</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns></returns>
Asset? GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel) {
async Task<Asset?> GetVisibleAsset(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var query = context.Assets.Where(asset => asset.Id == id);
if (accessLevel >= EAccessLevel.Admin)
return query.FirstOrDefault();
return await query.FirstOrDefaultAsync(cancellationToken);
if (accessLevel >= EAccessLevel.Curator)
return query.FirstOrDefault(asset =>
asset.DeletedAt == null || asset.UploadedBy == userId);
return await query.FirstOrDefaultAsync(asset =>
asset.DeletedAt == null || asset.UploadedBy == userId, cancellationToken);
if (accessLevel == EAccessLevel.Maintainer && userId.HasValue) {
var asset = query
var asset = await query
.AsSplitQuery()
.Include(a => a.Albums)
.FirstOrDefault();
.FirstOrDefaultAsync(cancellationToken);
if (asset == null || asset.DeletedAt != null) return null;
if (asset.Visibility == EVisibility.Public) return asset;
if (asset.Visibility == EVisibility.Protected) return asset;
if (asset.UploadedBy == userId) return asset;
if (asset.Albums != null && asset.Albums.Any(a =>
a.PersonOwnerId.HasValue && personRepository.IsMaintainerOf(userId.Value, a.PersonOwnerId.Value)))
return asset;
if (asset.Albums != null) {
foreach (var album in asset.Albums) {
if (album.PersonOwnerId.HasValue
&& await personRepository.IsMaintainerOfAsync(userId.Value, album.PersonOwnerId.Value, cancellationToken))
return asset;
}
}
return null;
}
return query.FirstOrDefault(asset =>
return await query.FirstOrDefaultAsync(asset =>
asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && userId.HasValue && asset.UploadedBy == userId)
)
);
), cancellationToken);
}
/// <inheritdoc />
public MediaDto? GetOriginalData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
var media = GetVisibleAsset(id, userId, accessLevel);
public async Task<MediaDto?> GetOriginalDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
if (media == null) return null;
return new MediaDto {
MimeType = media.MimeType,
Path = media.OriginalPath
@@ -65,13 +70,13 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
//TODO: for Thumbnails the MimeType should be the correct one (depending on which format is used for thumbnails)
/// <inheritdoc />
public MediaDto? GetThumbData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
var media = GetVisibleAsset(id, userId, accessLevel);
public async Task<MediaDto?> GetThumbDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
if(media == null) return null;
if (string.IsNullOrEmpty(media.ThumbnailPath)) return null;
return new MediaDto {
MimeType = "image/webp",
Path = media.ThumbnailPath
@@ -80,22 +85,36 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
//TODO: for Previews the MimeType should be the correct one (depending on which format is used for previews and if the asset is a video)
/// <inheritdoc />
public MediaDto? GetPreviewData(Guid id, Guid? userId, EAccessLevel accessLevel = EAccessLevel.User) {
var media = GetVisibleAsset(id, userId, accessLevel);
public async Task<MediaDto?> GetPreviewDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
if(media == null) return null;
if (string.IsNullOrEmpty(media.PreviewPath)) return null;
return new MediaDto {
MimeType = "image/webp",
Path = media.PreviewPath
};
}
/// <inheritdoc />
public async Task<MediaDto?> GetConvertedDataAsync(Guid id, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var media = await GetVisibleAsset(id, userId, accessLevel, cancellationToken);
if (media == null) return null;
if (string.IsNullOrEmpty(media.ConvertedPath) || string.IsNullOrEmpty(media.ConvertedMimeType)) return null;
return new MediaDto {
MimeType = media.ConvertedMimeType,
Path = media.ConvertedPath
};
}
/// <inheritdoc />
public void Dispose() {
context.Dispose();
personRepository.Dispose();
}
}
}
+71 -59
View File
@@ -4,6 +4,7 @@ using Butter.Types;
using Lactose.Context;
using Lactose.Mapper;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -13,19 +14,28 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
public void Insert(Person person) => context.People.Add(person);
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public Person? Find(Guid id) => context.People.FirstOrDefault(p => p.Id == id);
public async Task<TResult?> ExecuteInTransactionAsync<TResult>(Func<Task<TResult?>> operation) {
await using var tx = await context.Database.BeginTransactionAsync();
var result = await operation();
await tx.CommitAsync();
return result;
}
/// <inheritdoc />
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
var person = context.People
public Task<Person?> FindAsync(Guid id, CancellationToken cancellationToken) =>
context.People.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
/// <inheritdoc />
public async Task<Person?> FindVisibleAsync(Guid id, Guid? userId, EAccessLevel accessLevel,
string? albumSearch, string? albumSortBy, bool albumSortAsc,
int albumPage, int albumPageSize, CancellationToken cancellationToken) {
var person = await context.People
.AsNoTracking()
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == id);
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
if (person == null) return null;
@@ -33,7 +43,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
bool canSeePerson = accessLevel switch {
< EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
EAccessLevel.Maintainer when userId.HasValue => person.Visibility <= EVisibility.Protected
|| context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id),
|| await context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId.Value && pm.PersonId == id, cancellationToken),
EAccessLevel.Maintainer => person.Visibility <= EVisibility.Protected,
_ => true
};
@@ -41,7 +51,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
if (!canSeePerson) return null;
bool isMaintainerOfPerson = accessLevel == EAccessLevel.Maintainer && userId.HasValue
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id);
&& await context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId.Value && pm.PersonId == id, cancellationToken);
IQueryable<Album> albumsQuery = context.Albums
.AsNoTracking()
@@ -97,47 +107,47 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
}
// Total album count (after search and visibility filters, before pagination)
person.AlbumTotalCount = ordered.Count();
person.AlbumTotalCount = await ordered.CountAsync(cancellationToken);
// Total visible assets across all visible albums of the person (before search and pagination)
if (seesAllAlbums) {
person.TotalAssetCount = context.Assets
person.TotalAssetCount = await context.Assets
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
.Count(asset => asset.DeletedAt == null);
.CountAsync(asset => asset.DeletedAt == null, cancellationToken);
} else {
person.TotalAssetCount = context.Assets
person.TotalAssetCount = await context.Assets
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
.Count(asset => asset.DeletedAt == null && (
.CountAsync(asset => asset.DeletedAt == null && (
asset.Visibility == EVisibility.Public ||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)));
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)), cancellationToken);
}
var albumIds = ordered
var albumIds = await ordered
.Skip(albumPage * albumPageSize)
.Take(albumPageSize)
.Select(a => a.Id)
.ToList();
.ToListAsync(cancellationToken);
if (albumIds.Count == 0)
return person;
person.Albums = context.Albums
person.Albums = await context.Albums
.AsNoTracking()
.AsSplitQuery()
.Include(a => a.PersonOwner)
.Include(a => a.CoverAsset)
.Where(a => albumIds.Contains(a.Id))
.ToList();
.ToListAsync(cancellationToken);
// Per-album visible asset counts at the database level
if (seesAllAlbums) {
person.AlbumAssetCounts = context.Albums
person.AlbumAssetCounts = await context.Albums
.Where(a => albumIds.Contains(a.Id))
.Select(a => new { a.Id, Count = a.Assets!.Count(asset => asset.DeletedAt == null) })
.ToDictionary(x => x.Id, x => x.Count);
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
} else {
person.AlbumAssetCounts = context.Albums
person.AlbumAssetCounts = await context.Albums
.Where(a => albumIds.Contains(a.Id))
.Select(a => new {
a.Id,
@@ -146,7 +156,7 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)))
})
.ToDictionary(x => x.Id, x => x.Count);
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
}
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
@@ -156,46 +166,46 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
}
/// <inheritdoc />
public IEnumerable<Person> GetAll() =>
context.People.Include(p => p.Albums);
public Task<List<Person>> GetAllAsync(CancellationToken cancellationToken) =>
context.People.Include(p => p.Albums).ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Person> GetAllVisible(Guid userId, EAccessLevel accessLevel) =>
public async Task<List<Person>> GetAllVisibleAsync(Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken) =>
accessLevel switch {
// Admin and curator.
>= EAccessLevel.Curator => GetAll(),
>= EAccessLevel.Curator => await GetAllAsync(cancellationToken),
EAccessLevel.Maintainer => context.People
EAccessLevel.Maintainer => await context.People
.Include(p => p.Albums)
.Where(p => p.Visibility <= EVisibility.Protected
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id))
.ToList(),
.ToListAsync(cancellationToken),
EAccessLevel.User => context.People
EAccessLevel.User => await context.People
.Include(p => p.Albums)
.Where(p => p.Visibility <= EVisibility.Protected)
.ToList(),
.ToListAsync(cancellationToken),
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
};
/// <inheritdoc />
public Person? FindByName(string name) =>
context.People.FirstOrDefault(p => p.Name == name);
public Task<Person?> FindByNameAsync(string name, CancellationToken cancellationToken) =>
context.People.FirstOrDefaultAsync(p => p.Name == name, cancellationToken);
/// <inheritdoc />
public IEnumerable<Person> FindByNames(IEnumerable<string> names) =>
context.People.Where(p => names.Contains(p.Name));
public Task<List<Person>> FindByNamesAsync(IEnumerable<string> names, CancellationToken cancellationToken) =>
context.People.Where(p => names.Contains(p.Name)).ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Person> FindBulk(IEnumerable<Guid> ids) =>
context.People.Where(p => ids.Contains(p.Id));
public Task<List<Person>> FindBulkAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken) =>
context.People.Where(p => ids.Contains(p.Id)).ToListAsync(cancellationToken);
/// <inheritdoc />
public void Remove(Person person) => context.People.Remove(person);
/// <inheritdoc />
public IEnumerable<PersonPreviewDto> SearchQuery(string query, int page = 0, int pageSize = 30, string? sortBy = null, bool sortAsc = true, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User) {
public async Task<List<PersonPreviewDto>> SearchQueryAsync(string query, int page, int pageSize, string? sortBy, bool sortAsc, Guid userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
IQueryable<Person> peopleQuery = context.People;
if (!string.IsNullOrEmpty(query))
@@ -204,12 +214,12 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
// Apply visibility filter
peopleQuery = accessLevel switch {
EAccessLevel.Admin or EAccessLevel.Curator => peopleQuery,
EAccessLevel.Maintainer => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected
|| context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == p.Id)),
EAccessLevel.User => peopleQuery.Where(p => p.Visibility <= EVisibility.Protected),
_ => throw new ArgumentOutOfRangeException(nameof(accessLevel), accessLevel, null)
};
@@ -224,25 +234,25 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
};
var pageOffset = page * pageSize;
var personIds = ordered
var personIds = await ordered
.Skip(pageOffset)
.Take(pageSize)
.Select(p => p.Id)
.ToList();
.ToListAsync(cancellationToken);
if (personIds.Count == 0)
return [];
var pagedPeople = context.People
var pagedPeople = await context.People
.AsNoTracking()
.Where(p => personIds.Contains(p.Id))
.ToList();
.ToListAsync(cancellationToken);
// Album counts via a single grouped query instead of loading every album row
var albumCounts = context.People
var albumCounts = await context.People
.Where(p => personIds.Contains(p.Id))
.Select(p => new { p.Id, Count = p.Albums!.Count })
.ToDictionary(x => x.Id, x => x.Count);
.ToDictionaryAsync(x => x.Id, x => x.Count, cancellationToken);
var dtos = pagedPeople.Select(p => {
var dto = p.ToPersonPreviewDto(accessLevel);
@@ -255,12 +265,14 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
}
/// <inheritdoc />
public bool IsMaintainerOf(Guid userId, Guid personId) =>
context.PersonMaintainers.Any(pm => pm.UserId == userId && pm.PersonId == personId);
public Task<bool> IsMaintainerOfAsync(Guid userId, Guid personId, CancellationToken cancellationToken) =>
context.PersonMaintainers.AnyAsync(pm => pm.UserId == userId && pm.PersonId == personId, cancellationToken);
/// <inheritdoc />
public void SetMaintainers(Guid personId, IEnumerable<Guid> userIds) {
var existing = context.PersonMaintainers.Where(pm => pm.PersonId == personId);
public async Task SetMaintainersAsync(Guid personId, IEnumerable<Guid> userIds, CancellationToken cancellationToken) {
var existing = await context.PersonMaintainers
.Where(pm => pm.PersonId == personId)
.ToListAsync(cancellationToken);
context.PersonMaintainers.RemoveRange(existing);
foreach (var userId in userIds) {
@@ -269,35 +281,35 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
}
/// <inheritdoc />
public void MergePeople(Guid destId, List<Guid> sourceIds) {
var dest = context.People
public async Task MergePeopleAsync(Guid destId, List<Guid> sourceIds, CancellationToken cancellationToken) {
var dest = await context.People
.AsSplitQuery()
.Include(p => p.Maintainers)
.FirstOrDefault(p => p.Id == destId);
.FirstOrDefaultAsync(p => p.Id == destId, cancellationToken);
if (dest == null) return;
// Reassign albums
var albums = context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToList();
var albums = await context.Albums.Where(a => a.PersonOwnerId != null && sourceIds.Contains(a.PersonOwnerId.Value)).ToListAsync(cancellationToken);
foreach (var album in albums)
album.PersonOwnerId = destId;
// Reassign faces
var faces = context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToList();
var faces = await context.Faces.Where(f => f.PersonId.HasValue && sourceIds.Contains(f.PersonId.Value)).ToListAsync(cancellationToken);
foreach (var face in faces)
face.PersonId = destId;
// Reassign maintainers
var existingMaintainerIds = dest.Maintainers?.Select(m => m.Id).ToHashSet() ?? [];
var sourceMaintainers = context.PersonMaintainers
var sourceMaintainers = await context.PersonMaintainers
.Where(pm => sourceIds.Contains(pm.PersonId))
.ToList();
.ToListAsync(cancellationToken);
foreach (var pm in sourceMaintainers) {
if (!existingMaintainerIds.Contains(pm.UserId))
context.PersonMaintainers.Add(new PersonMaintainer { PersonId = destId, UserId = pm.UserId });
}
// Hard-delete source people
var people = context.People.Where(p => sourceIds.Contains(p.Id)).ToList();
var people = await context.People.Where(p => sourceIds.Contains(p.Id)).ToListAsync(cancellationToken);
context.People.RemoveRange(people);
}
+25 -22
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
using System.Data;
namespace Lactose.Repositories;
@@ -7,32 +8,33 @@ namespace Lactose.Repositories;
/// <inheritdoc />
public class SettingsRepository(LactoseDbContext context) : ISettingsRepository {
/// <inheritdoc />
public void Create(Setting setting) {
public async Task CreateAsync(Setting setting, CancellationToken cancellationToken) {
//Check if the settings already exist
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
if (existingSettings != null)
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
if (existingSettings != null)
throw new DuplicateNameException("A setting with the same key already exists: " + setting.Name);
SettingChange(setting);
//Adds the new settings
context.Settings.Add(setting);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public IEnumerable<Setting> Get() => context.Settings.AsEnumerable();
public Task<List<Setting>> GetAllAsync(CancellationToken cancellationToken) => context.Settings.ToListAsync(cancellationToken);
/// <inheritdoc />
public Setting? Get(string name) => context.Settings.FirstOrDefault(s => s.Name == name);
public Task<Setting?> GetAsync(string name, CancellationToken cancellationToken) =>
context.Settings.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
/// <inheritdoc />
public void Update(Setting setting) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
public async Task UpdateAsync(Setting setting, CancellationToken cancellationToken) {
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
if (existingSettings != null) {
if (existingSettings.Value != setting.Value) SettingChange(setting);
existingSettings.Value = setting.Value;
context.Settings.Update(existingSettings);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
} else {
// If the settings entry does not exist
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
@@ -40,37 +42,38 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
}
/// <inheritdoc />
public void Delete() {
public async Task DeleteAllAsync(CancellationToken cancellationToken) {
context.Settings.RemoveRange(context.Settings);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
}
/// <inheritdoc />
public void Delete(Setting setting) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == setting.Name);
public async Task DeleteAsync(Setting setting, CancellationToken cancellationToken) {
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == setting.Name, cancellationToken);
if (existingSettings != null) {
context.Settings.Remove(existingSettings);
context.SaveChanges();
await context.SaveChangesAsync(cancellationToken);
} else {
// If the settings entry does not exist
throw new KeyNotFoundException("The setting with the specified name does not exist: " + setting.Name);
}
}
/// <inheritdoc />
public void Delete(string name) {
var existingSettings = context.Settings.FirstOrDefault(s => s.Name == name);
public async Task DeleteAsync(string name, CancellationToken cancellationToken) {
var existingSettings = await context.Settings.FirstOrDefaultAsync(s => s.Name == name, cancellationToken);
if (existingSettings == null) throw new KeyNotFoundException("The setting with the specified name does not exist: " + name);
Delete(existingSettings);
context.SaveChanges();
context.Settings.Remove(existingSettings);
await context.SaveChangesAsync(cancellationToken);
}
/// <summary>
/// Occurs when a setting value changes.
/// </summary>
public static event EventHandler<Setting>? SettingChanged;
static void OnSettingChanged(Setting e) => SettingChanged?.Invoke(null, e);
//TODO: May need to be change on a Event based system, because adding here all the Setting Actions could be too much ramification
void SettingChange(Setting setting) {
OnSettingChanged(setting);
@@ -91,4 +94,4 @@ public class SettingsRepository(LactoseDbContext context) : ISettingsRepository
/// <inheritdoc />
public void Dispose() => context.Dispose();
}
}
+24 -12
View File
@@ -18,8 +18,8 @@ public class StatsRepository(
/// <inheritdoc />
public async Task<StatsDto> GetStatsAsync(CancellationToken cancellationToken) {
var thumbnailSizeSetting = settingsRepo.Get(Settings.ThumbnailSize.AsString());
var previewSizeSetting = settingsRepo.Get(Settings.PreviewSize.AsString());
var thumbnailSizeSetting = await settingsRepo.GetAsync(Settings.ThumbnailSize.AsString(), cancellationToken);
var previewSizeSetting = await settingsRepo.GetAsync(Settings.PreviewSize.AsString(), cancellationToken);
var cacheKey = $"stats:{thumbnailSizeSetting?.Value}:{previewSizeSetting?.Value}";
if (cache.TryGetValue(cacheKey, out StatsDto? cached) && cached is not null)
@@ -63,8 +63,10 @@ public class StatsRepository(
MissingPreviewStale = expectedPreviewSize > 0
? g.Count(a => a.PreviewPath != null && a.PreviewPath != "" && a.PreviewSize != expectedPreviewSize)
: 0,
MissingPhash = g.Count(a => a.Hash == emptyHash)
MissingPhash = g.Count(a => a.Hash == emptyHash),
Broken = g.Count(a => a.ProcessFailedAt != null)
})
.OrderBy(x => 1)
.FirstOrDefaultAsync(cancellationToken);
if (assetStats is not null) {
@@ -82,6 +84,7 @@ public class StatsRepository(
dto.AssetsMissingPreviews = assetStats.MissingPreview;
dto.AssetsMissingPreviewsStale = assetStats.MissingPreviewStale;
dto.AssetsMissingPhash = assetStats.MissingPhash;
dto.TotalBrokenAssets = assetStats.Broken;
}
var byType = await context.Assets
@@ -109,17 +112,26 @@ public class StatsRepository(
dto.AlbumsMissingCover = await context.Albums.CountAsync(a => a.CoverAssetId == null, cancellationToken);
dto.CosplayersMissingProfile = await context.People.CountAsync(p => p.ProfileAssetId == null, cancellationToken);
var albumAssetIds = context.Albums.SelectMany(al => al.Assets!).Select(a => a.Id).Distinct();
dto.AssetsWithNoAlbum = await context.Assets.CountAsync(
a => a.DeletedAt == null && !albumAssetIds.Contains(a.Id), cancellationToken);
var albumAsset = context.Set<Dictionary<string, object>>("AlbumAsset");
var liveAssetIds = context.Assets.Where(a => a.DeletedAt == null).Select(a => a.Id);
var personAlbumAssetIds = context.Albums
var assetsInAnyAlbum = await albumAsset
.Select(aa => EF.Property<Guid>(aa, "AssetsId"))
.Where(id => liveAssetIds.Contains(id))
.Distinct()
.CountAsync(cancellationToken);
dto.AssetsWithNoAlbum = Math.Max(0, dto.TotalAssets - assetsInAnyAlbum);
var personAlbumIds = context.Albums
.Where(al => al.PersonOwnerId != null)
.SelectMany(al => al.Assets!)
.Select(a => a.Id)
.Distinct();
dto.AssetsWithNoPerson = await context.Assets.CountAsync(
a => a.DeletedAt == null && !personAlbumAssetIds.Contains(a.Id), cancellationToken);
.Select(al => al.Id);
var assetsInPersonAlbum = await albumAsset
.Where(aa => personAlbumIds.Contains(EF.Property<Guid>(aa, "AlbumsId")))
.Select(aa => EF.Property<Guid>(aa, "AssetsId"))
.Where(id => liveAssetIds.Contains(id))
.Distinct()
.CountAsync(cancellationToken);
dto.AssetsWithNoPerson = Math.Max(0, dto.TotalAssets - assetsInPersonAlbum);
dto.TopTags = await context.Tags
.Select(t => new TagStatDto {
+11 -8
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -9,25 +10,27 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
public void Dispose() => context.Dispose();
/// <inheritdoc />
public List<Tag> Search(string searchQuery, int page = 0, int pageSize = 200) =>
public Task<List<Tag>> SearchAsync(string searchQuery, int page, int pageSize, CancellationToken cancellationToken) =>
context.Tags.Where(t => EF.Functions.Like(t.Name, $"%{searchQuery}%"))
.OrderByDescending(t => t.Name)
.Skip((page) * pageSize)
.Take(pageSize)
.ToList();
.ToListAsync(cancellationToken);
/// <inheritdoc />
public IEnumerable<Tag> GetAll() => context.Tags;
public Task<List<Tag>> GetAllAsync(CancellationToken cancellationToken) => context.Tags.ToListAsync(cancellationToken);
/// <inheritdoc />
public Tag? Find(Guid id) => context.Tags.Find(id);
public async Task<Tag?> FindAsync(Guid id, CancellationToken cancellationToken) =>
await context.Tags.FindAsync([id], cancellationToken);
/// <inheritdoc />
public Tag? FindByName(string name) => context.Tags.FirstOrDefault(t => t.Name == name);
public Task<Tag?> FindByNameAsync(string name, CancellationToken cancellationToken) =>
context.Tags.FirstOrDefaultAsync(t => t.Name == name, cancellationToken);
/// <inheritdoc />
public List<Tag> GetChildren(Guid id) => context.Tags.Where(t=>t.ParentId==id).ToList();
public Task<List<Tag>> GetChildrenAsync(Guid id, CancellationToken cancellationToken) =>
context.Tags.Where(t => t.ParentId == id).ToListAsync(cancellationToken);
/// <inheritdoc />
public void Delete(Tag tag) => context.Tags.Remove(tag);
@@ -36,5 +39,5 @@ public class TagRepository(LactoseDbContext context) : ITagRepository {
public void Insert(Tag tag) => context.Tags.Add(tag);
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
}
+16 -9
View File
@@ -1,5 +1,6 @@
using Lactose.Context;
using Lactose.Models;
using Microsoft.EntityFrameworkCore;
namespace Lactose.Repositories;
@@ -9,30 +10,36 @@ public class UserRepository(LactoseDbContext context) : IUserRepository {
public void Dispose() => context.Dispose();
/// <inheritdoc />
public IEnumerable<User> GetAll() => context.Users.Include(u => u.MaintainedPersons).AsEnumerable();
public Task<List<User>> GetAllAsync(CancellationToken cancellationToken) =>
context.Users.Include(u => u.MaintainedPersons).ToListAsync(cancellationToken);
/// <inheritdoc />
public void Insert(User user) => context.Users.Add(user);
/// <inheritdoc />
public void Save() => context.SaveChanges();
public Task SaveAsync(CancellationToken cancellationToken) => context.SaveChangesAsync(cancellationToken);
/// <inheritdoc />
public User? Find(Guid id) => context.Users.Find(id);
public async Task<User?> FindAsync(Guid id, CancellationToken cancellationToken) =>
await context.Users.FindAsync([id], cancellationToken);
/// <inheritdoc />
public User? FindByEmail(string email) => context.Users.FirstOrDefault(u => u.Email == email);
public Task<User?> FindByEmailAsync(string email, CancellationToken cancellationToken) =>
context.Users.FirstOrDefaultAsync(u => u.Email == email, cancellationToken);
/// <inheritdoc />
public User? FindByUsername(string username) => context.Users.FirstOrDefault(u => u.Username == username);
public Task<User?> FindByUsernameAsync(string username, CancellationToken cancellationToken) =>
context.Users.FirstOrDefaultAsync(u => u.Username == username, cancellationToken);
/// <inheritdoc />
public User? FindWithMaintainedPersons(Guid id) =>
context.Users.Include(u => u.MaintainedPersons).FirstOrDefault(u => u.Id == id);
public Task<User?> FindWithMaintainedPersonsAsync(Guid id, CancellationToken cancellationToken) =>
context.Users.Include(u => u.MaintainedPersons).FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
/// <inheritdoc />
public void SetMaintainedPersons(Guid userId, IEnumerable<Guid> personIds) {
var existing = context.PersonMaintainers.Where(pm => pm.UserId == userId);
public async Task SetMaintainedPersonsAsync(Guid userId, IEnumerable<Guid> personIds, CancellationToken cancellationToken) {
var existing = await context.PersonMaintainers
.Where(pm => pm.UserId == userId)
.ToListAsync(cancellationToken);
context.PersonMaintainers.RemoveRange(existing);
foreach (var personId in personIds) {
+3
View File
@@ -70,6 +70,9 @@ internal class DbInitializer(LactoseDbContext dbContext, IPasswordHasher<User> p
settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value
= Path.Combine(Environment.CurrentDirectory, "previews");
settings.First(s => s.Name == Settings.ConvertedPath.AsString()).Value
= Path.Combine(Environment.CurrentDirectory, "converted");
// set max concurrent jobs to number of processors - 2, at least one. (who the fuck is using a single core cpu?)
settings.First(s => s.Name == Settings.MaxConcurrentJobs.AsString()).Value
= (Math.Max(Environment.ProcessorCount - 2, 1)).ToString();
+170
View File
@@ -0,0 +1,170 @@
using Butter.Dtos.Asset;
using Butter.Types;
using Lactose.Context;
using Lactose.Mapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Lactose.Services;
/// <inheritdoc />
public class FilesystemBrowseService(
LactoseDbContext context,
ILogger<FilesystemBrowseService> logger
) : IFilesystemBrowseService {
const int LookupChunkSize = 200;
/// <inheritdoc />
public async Task<Butter.Dtos.Asset.AssetBrowseResultDto?> BrowseAsync(Guid folderId, string relativePath, int page, int pageSize, bool unlinkedOnly, string? search, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken) {
var folder = await context.Folders.FindAsync([folderId], cancellationToken);
if (folder == null || string.IsNullOrWhiteSpace(folder.BasePath))
return null;
string root = Path.GetFullPath(folder.BasePath);
string normalizedRelative = (relativePath ?? "").Replace('/', Path.DirectorySeparatorChar);
string full = Path.GetFullPath(Path.Combine(root, normalizedRelative));
if (!IsWithinRoot(root, full) || !Directory.Exists(full))
return null;
var directories = Directory.EnumerateDirectories(full)
.Select(d => Path.GetFileName(d) ?? "")
.Where(n => string.IsNullOrEmpty(search) || n.Contains(search, StringComparison.OrdinalIgnoreCase))
.OrderBy(n => n, StringComparer.OrdinalIgnoreCase)
.Select(n => new DirectoryEntryDto {
Name = n,
Path = CombineRelative(relativePath ?? "", n)
})
.ToList();
var files = Directory.EnumerateFiles(full)
.Where(f => string.IsNullOrEmpty(search) || Path.GetFileName(f).Contains(search, StringComparison.OrdinalIgnoreCase))
.OrderBy(f => Path.GetFileName(f), StringComparer.OrdinalIgnoreCase)
.ToList();
int total = files.Count;
var pageFiles = files.Skip(page * pageSize).Take(pageSize).ToList();
var found = await LookupAssetsAsync(pageFiles, unlinkedOnly, accessLevel, userId, cancellationToken);
var byPath = found.ToDictionary(a => a.Path, a => a.Dto);
// Preserve the on-disk ordering and only surface files with a database record.
var assets = pageFiles
.Where(byPath.ContainsKey)
.Select(a => byPath[a])
.ToList();
var (unassignedSubdirs, hasUnassignedFilesHere) = await FindUnassignedDirectoriesAsync(full, directories, cancellationToken);
foreach (var dir in directories)
dir.HasUnassignedAssets = unassignedSubdirs.Contains(dir.Name);
logger.LogTrace(
"directory: folderId={FolderId}, path={Path}, dirs={DirCount}, files={FileCount}, page={Page}, size={PageSize}, matched={Matched}, unassignedDirs={UnassignedDirs}, unassignedHere={UnassignedHere}",
folderId, relativePath, directories.Count, files.Count, page, pageSize, assets.Count, unassignedSubdirs.Count, hasUnassignedFilesHere
);
return new Butter.Dtos.Asset.AssetBrowseResultDto {
Directories = directories,
Assets = assets,
TotalAssetCount = total,
HasUnassignedFilesHere = hasUnassignedFilesHere
};
}
static bool IsWithinRoot(string root, string candidate) {
var prefix = root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
return candidate == root || candidate.StartsWith(prefix, StringComparison.OrdinalIgnoreCase);
}
static string CombineRelative(string current, string name)
=> string.IsNullOrEmpty(current) ? name : $"{current}/{name}";
/// <summary>
/// Determines which of the given immediate subdirectories contain at least one non-deleted asset that is
/// not assigned to any album, and whether the current directory itself contains such assets directly.
/// Executed as a single parameterized query so each prefix check is an index range scan that short-circuits
/// at the first unassigned asset.
/// </summary>
/// <param name="full">The full on-disk path of the current directory.</param>
/// <param name="directories">The immediate subdirectory entries at the current level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>The set of subdirectory names containing unassigned assets, and whether the current level has unassigned files directly.</returns>
async Task<(HashSet<string>, bool)> FindUnassignedDirectoriesAsync(string full, List<DirectoryEntryDto> directories, CancellationToken cancellationToken) {
HashSet<string> unassignedSubdirs = [];
bool hasUnassignedFilesHere = false;
char sep = Path.DirectorySeparatorChar;
string curPrefix = full.TrimEnd(sep) + sep;
var items = new List<(string Kind, string Path)>();
foreach (var dir in directories)
items.Add(("dir", curPrefix + dir.Name + sep));
items.Add(("here", ""));
var sql = new System.Text.StringBuilder();
sql.Append("WITH items(kind, path) AS (VALUES ");
var parms = new List<object>();
for (int i = 0; i < items.Count; i++) {
if (i > 0) sql.Append(", ");
sql.Append($"({{{i * 2}}}, {{{i * 2 + 1}}})");
parms.Add(items[i].Kind);
parms.Add(items[i].Path);
}
int k = items.Count * 2;
sql.Append(") SELECT i.kind AS \"Kind\", i.path AS \"Path\" FROM items i ");
sql.Append("WHERE EXISTS (");
sql.Append("SELECT 1 FROM \"Assets\" a ");
sql.Append("WHERE a.\"DeletedAt\" IS NULL ");
sql.Append("AND NOT EXISTS (SELECT 1 FROM \"AlbumAsset\" aa WHERE aa.\"AssetsId\" = a.\"Id\") ");
sql.Append("AND CASE WHEN i.kind = 'dir' THEN a.\"OriginalPath\" LIKE i.path || '%' ESCAPE '' ");
sql.Append($"ELSE a.\"OriginalPath\" LIKE {{{k}}} || '%' ESCAPE '' AND a.\"OriginalPath\" NOT LIKE {{{k}}} || '%' || {{{k + 1}}} || '%' ESCAPE '' END)");
parms.Add(curPrefix);
parms.Add(sep.ToString());
var rows = await context.Database
.SqlQueryRaw<UnassignedFlagRow>(sql.ToString(), parms.ToArray())
.ToListAsync(cancellationToken);
foreach (var row in rows) {
if (row.Kind == "here") {
hasUnassignedFilesHere = true;
} else {
string name = row.Path.Substring(curPrefix.Length).TrimEnd(sep);
unassignedSubdirs.Add(name);
}
}
return (unassignedSubdirs, hasUnassignedFilesHere);
}
/// <summary>
/// Row shape for the unassigned-asset highlight query.
/// </summary>
public sealed class UnassignedFlagRow {
/// <summary>
/// Gets or sets the item kind: <c>dir</c> for a subdirectory prefix, or <c>here</c> for the current level.
/// </summary>
public string Kind { get; set; } = "";
/// <summary>
/// Gets or sets the full on-disk path prefix for a <c>dir</c> item (unused for <c>here</c>).
/// </summary>
public string Path { get; set; } = "";
}
async Task<List<(string Path, AssetPreviewDto Dto)>> LookupAssetsAsync(List<string> paths, bool unlinkedOnly, EAccessLevel accessLevel, Guid? userId, CancellationToken cancellationToken) {
var result = new List<(string, AssetPreviewDto)>();
foreach (var chunk in paths.Chunk(LookupChunkSize)) {
var found = await context.Assets
.AsSplitQuery()
.Include(a => a.Albums!).ThenInclude(al => al.PersonOwner)
.Where(a => chunk.Contains(a.OriginalPath) && a.DeletedAt == null)
.ToListAsync(cancellationToken);
if (unlinkedOnly)
found = found.Where(a => a.Albums == null || a.Albums.Count == 0).ToList();
result.AddRange(found.Select(a => (a.OriginalPath, a.ToAssetPreviewDto(accessLevel, userId))));
}
return result;
}
}
@@ -0,0 +1,26 @@
using Butter.Dtos.Asset;
using Butter.Types;
namespace Lactose.Services;
/// <summary>
/// Browses the filesystem directly for maintenance purposes, looking up database
/// asset records for the files found on disk.
/// </summary>
public interface IFilesystemBrowseService {
/// <summary>
/// Browses a folder's filesystem at the given relative path, returning immediate
/// subdirectories and the assets recorded in the database for the files at that level.
/// </summary>
/// <param name="folderId">The ID of the scan folder to browse within.</param>
/// <param name="relativePath">The path relative to the folder root (empty for root, forward slashes).</param>
/// <param name="page">The zero-based page number for asset pagination.</param>
/// <param name="pageSize">The number of assets per page.</param>
/// <param name="unlinkedOnly">If true, only assets not assigned to any album are returned.</param>
/// <param name="search">Optional filename search term.</param>
/// <param name="userId">The requesting user's ID (unused for admins/curators).</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
/// <returns>A browse result, or null if the folder does not exist or the path escapes the folder root.</returns>
Task<Butter.Dtos.Asset.AssetBrowseResultDto?> BrowseAsync(Guid folderId, string relativePath, int page, int pageSize, bool unlinkedOnly, string? search, Guid? userId, EAccessLevel accessLevel, CancellationToken cancellationToken);
}
+150 -80
View File
@@ -17,44 +17,47 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
JobManager? jobManager;
/// <inheritdoc />
protected override Task ExecuteAsync(CancellationToken stoppingToken) {
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
var service = new Task(() => {
logger?.LogInformation("Preparing JobScheduler...");
jobManager = serviceProvider.GetRequiredService<JobManager>();
logger?.LogInformation("JobScheduler started!");
// fetch settings on startup
FetchSettings();
// listen for settings changes
SettingsRepository.SettingChanged += OnSettingsChanged;
// keep the task alive until the service is stopping
stoppingToken.WaitHandle.WaitOne();
}, stoppingToken
);
lifetime.ApplicationStarted.Register(() => service.Start());
return service;
// wait until the application has started before preparing the scheduler
var startedTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
lifetime.ApplicationStarted.Register(() => startedTcs.SetResult());
try {
await startedTcs.Task;
logger?.LogInformation("Preparing JobScheduler...");
jobManager = serviceProvider.GetRequiredService<JobManager>();
logger?.LogInformation("JobScheduler started!");
// fetch settings on startup
await FetchSettingsAsync(stoppingToken);
// listen for settings changes
SettingsRepository.SettingChanged += OnSettingsChanged;
// keep the service alive until the host is stopping
await Task.Delay(Timeout.Infinite, stoppingToken);
} catch (OperationCanceledException) {
// normal shutdown
}
}
void FetchSettings() {
async Task FetchSettingsAsync(CancellationToken cancellationToken = default) {
logger?.LogInformation("Fetching settings...");
using var scope = serviceProvider.CreateScope();
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
var prevFolderScanEnabled = folderScanEnabled;
// check if folder scanning is enabled
folderScanEnabled = bool.Parse(settingsRepository.Get(Settings.FolderScanEnabled.AsString())?.Value ?? "false");
folderScanEnabled = bool.Parse((await settingsRepository.GetAsync(Settings.FolderScanEnabled.AsString(), cancellationToken))?.Value ?? "false");
if (folderScanEnabled && !prevFolderScanEnabled) {
FolderRepository.FolderAdded += OnFolderAdded;
FolderRepository.FolderRemoved += OnFolderRemoved;
// defaults to 30 minutes
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
fileSystemCrawl = new Timer(QueueFileSystemCrawl, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
int.TryParse((await settingsRepository.GetAsync(Settings.FolderScanInterval.AsString(), cancellationToken))?.Value ?? "30", out var interval);
fileSystemCrawl = new Timer(QueueFileSystemCrawlTimer, null, TimeSpan.Zero, TimeSpan.FromMinutes(interval));
logger?.LogInformation($"Periodic folder scanning is now enabled every {interval} minutes.");
var folderRepository = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
folders = folderRepository.GetAll().Where(f => f.Active).ToList();
folders = (await folderRepository.GetAllAsync(cancellationToken)).Where(f => f.Active).ToList();
} else if(!folderScanEnabled && prevFolderScanEnabled) {
FolderRepository.FolderAdded -= OnFolderAdded;
FolderRepository.FolderRemoved -= OnFolderRemoved;
@@ -63,13 +66,13 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation("Periodic folder scanning is now disabled.");
} else if (folderScanEnabled && prevFolderScanEnabled) {
// update interval
int.TryParse(settingsRepository.Get(Settings.FolderScanInterval.AsString())?.Value ?? "30", out var interval);
int.TryParse((await settingsRepository.GetAsync(Settings.FolderScanInterval.AsString(), cancellationToken))?.Value ?? "30", out var interval);
fileSystemCrawl?.Change(TimeSpan.Zero, TimeSpan.FromMinutes(interval));
logger?.LogInformation($"Periodic folder scanning interval updated to {interval} minutes.");
}
// Job cleanup timer
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
int.TryParse((await settingsRepository.GetAsync(Settings.JobRetentionDays.AsString(), cancellationToken))?.Value, out var retentionDays);
if (retentionDays > 0) {
if (jobCleanup == null) {
jobCleanup = new Timer(RunJobCleanup, null, TimeSpan.Zero, TimeSpan.FromHours(1));
@@ -81,34 +84,70 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation("Job cleanup is now disabled (retention set to 0 or invalid).");
}
EnsureJobSettings(settingsRepository);
await EnsureJobSettingsAsync(settingsRepository, cancellationToken);
}
void EnsureJobSettings(ISettingsRepository settingsRepository) {
async Task EnsureJobSettingsAsync(ISettingsRepository settingsRepository, CancellationToken cancellationToken) {
var batchSizeName = Settings.JobBatchSize.AsString();
if (settingsRepository.Get(batchSizeName) == null) {
settingsRepository.Create(new Setting {
if (await settingsRepository.GetAsync(batchSizeName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = batchSizeName,
Value = "200",
Description = "Number of assets to process per sub-job batch.",
Type = EType.Integer,
DisplayType = DisplayType.IntNumber
});
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = 200.", batchSizeName);
}
var convertedPathName = Settings.ConvertedPath.AsString();
if (await settingsRepository.GetAsync(convertedPathName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = convertedPathName,
Value = Path.Combine(Environment.CurrentDirectory, "converted"),
Description = "Path where converted videos for animated assets (GIF -> mp4) are stored.",
Type = EType.String,
DisplayType = DisplayType.Text
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = {Value}.", convertedPathName, Path.Combine(Environment.CurrentDirectory, "converted"));
}
var convertedFormatName = Settings.ConvertedFormat.AsString();
if (await settingsRepository.GetAsync(convertedFormatName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = convertedFormatName,
Value = "mp4",
Description = "Container format for converted videos (e.g. mp4).",
Type = EType.String,
DisplayType = DisplayType.Text
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = mp4.", convertedFormatName);
}
var ffmpegPathName = Settings.FfmpegPath.AsString();
if (await settingsRepository.GetAsync(ffmpegPathName, cancellationToken) == null) {
await settingsRepository.CreateAsync(new Setting {
Name = ffmpegPathName,
Value = "ffmpeg",
Description = "Path to the ffmpeg executable. Defaults to 'ffmpeg' on PATH.",
Type = EType.String,
DisplayType = DisplayType.Text
}, cancellationToken);
logger?.LogInformation("Seeded default setting: {Name} = ffmpeg.", ffmpegPathName);
}
}
void RunJobCleanup(object? state) {
async void RunJobCleanup(object? state) {
try {
using var scope = serviceProvider.CreateScope();
var settingsRepository = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
int.TryParse(settingsRepository.Get(Settings.JobRetentionDays.AsString())?.Value, out var retentionDays);
int.TryParse((await settingsRepository.GetAsync(Settings.JobRetentionDays.AsString(), CancellationToken.None))?.Value, out var retentionDays);
if (retentionDays <= 0) return;
var cutoff = DateTime.UtcNow.AddDays(-retentionDays);
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var deleted = repo.DeleteOldJobs(cutoff);
repo.Save();
var deleted = await repo.DeleteOldJobsAsync(cutoff, CancellationToken.None);
await repo.SaveAsync(CancellationToken.None);
if (jobManager != null) {
jobManager.CleanupPastJobs(cutoff);
@@ -131,24 +170,30 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation($"Folder removed: {e.Id}");
}
void OnSettingsChanged(object? sender, Setting e) {
var name = e.Name;
if (name == Settings.FolderScanEnabled.AsString()
|| name == Settings.FolderScanInterval.AsString()
|| name == Settings.JobRetentionDays.AsString()) {
FetchSettings();
}
if (name == Settings.MaxConcurrentJobs.AsString() && jobManager != null && int.TryParse(e.Value, out var maxJobs) && maxJobs > 0) {
jobManager.MaxConcurrentJobs = maxJobs;
logger?.LogInformation("MaxConcurrentJobs updated to {Value}.", maxJobs);
async void OnSettingsChanged(object? sender, Setting e) {
try {
var name = e.Name;
if (name == Settings.FolderScanEnabled.AsString()
|| name == Settings.FolderScanInterval.AsString()
|| name == Settings.JobRetentionDays.AsString()) {
await FetchSettingsAsync();
}
if (name == Settings.MaxConcurrentJobs.AsString() && jobManager != null && int.TryParse(e.Value, out var maxJobs) && maxJobs > 0) {
jobManager.MaxConcurrentJobs = maxJobs;
logger?.LogInformation("MaxConcurrentJobs updated to {Value}.", maxJobs);
}
} catch (Exception ex) {
logger?.LogWarning(ex, "Failed to apply settings change for {SettingName}.", e.Name);
}
}
/// <summary>
/// Queues a file system crawl job for each active folder.
/// Queues a file system crawl job for each selected (or active) folder.
/// </summary>
/// <param name="state">Unused state parameter (compatible with Timer callback).</param>
internal void QueueFileSystemCrawl(object? state) {
/// <param name="deep">When true, ignores recorded scan state and reconciles every asset.</param>
/// <param name="folderIds">Optional folders to scan. When null/empty, all active folders are scanned.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
internal async Task QueueFileSystemCrawlAsync(bool deep, IReadOnlyList<Guid>? folderIds = null, CancellationToken cancellationToken = default) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue file system crawl.");
return;
@@ -158,19 +203,42 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogWarning("A file system crawl job is already in progress, cannot queue another at this moment.");
return;
}
// if the scheduled scan list isn't populated, fetch active folders directly from DB
var targetFolders = folders;
if (targetFolders == null || targetFolders.Count == 0) {
List<Folder>? targetFolders;
if (folderIds is { Count: > 0 }) {
using var scope = serviceProvider.CreateScope();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
targetFolders = folderRepo.GetAll().Where(f => f.Active).ToList();
targetFolders = (await folderRepo.GetAllAsync(cancellationToken)).Where(f => folderIds.Contains(f.Id)).ToList();
} else {
// if the scheduled scan list isn't populated, fetch active folders directly from DB
targetFolders = folders;
if (targetFolders is not { Count: > 0 }) {
using var scope = serviceProvider.CreateScope();
var folderRepo = scope.ServiceProvider.GetRequiredService<IFolderRepository>();
targetFolders = (await folderRepo.GetAllAsync(cancellationToken)).Where(f => f.Active).ToList();
}
}
// queue a crawl job for each target folder
if (targetFolders is { Count: > 0 }) {
foreach (var f in targetFolders)
jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath, deep));
logger?.LogInformation("Queued {Count} file system crawl job(s) (deep={Deep}).", targetFolders.Count, deep);
} else {
logger?.LogWarning("No folders to scan.");
}
}
/// <summary>
/// Timer callback for periodic incremental file-system scans.
/// </summary>
/// <param name="state">Unused state parameter.</param>
async void QueueFileSystemCrawlTimer(object? state) {
try {
await QueueFileSystemCrawlAsync(false);
} catch (Exception ex) {
logger?.LogWarning(ex, "Scheduled file system crawl failed to queue.");
}
// queue a crawl job for each active folder
targetFolders.ForEach(f => { jobManager.EnqueueJob(jobManager.CreateJob<FileSystemCrawlJob>(f.Id, f.BasePath)); });
if (targetFolders.Count > 0)
logger?.LogInformation("Queued {Count} file system crawl job(s).", targetFolders.Count);
else
logger?.LogWarning("No active folders to scan.");
}
/// <summary>
@@ -246,27 +314,11 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
}
/// <summary>
/// Queues a create persons job.
/// Queues a create persons &amp; albums (discovery) job.
/// </summary>
/// <param name="o">Unused state parameter.</param>
internal void QueueCreatePersons(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue create persons.");
return;
}
if (jobManager.Jobs.Any(j => j is CreatePersonsJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Create persons job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<CreatePersonsJob>());
logger?.LogInformation("Queued create persons job.");
}
/// <summary>
/// Queues a create albums job.
/// </summary>
/// <param name="o">Unused state parameter.</param>
internal void QueueCreateAlbums(object? o) {
/// <param name="deep">When true, ignores the per-folder discovery cursor and scans all assets.</param>
/// <param name="folderIds">Optional folders to process. When null/empty, all active folders are processed.</param>
internal void QueueCreateAlbums(bool deep, IReadOnlyList<Guid>? folderIds = null) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue create albums.");
return;
@@ -275,8 +327,8 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
logger?.LogInformation("Create albums job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<CreateAlbumsJob>());
logger?.LogInformation("Queued create albums job.");
jobManager.EnqueueJob(jobManager.CreateJob<CreateAlbumsJob>(deep, folderIds!));
logger?.LogInformation("Queued create albums job (deep={Deep}).", deep);
}
/// <summary>
@@ -296,4 +348,22 @@ public class JobScheduler(IServiceProvider serviceProvider, ILogger<JobScheduler
jobManager.EnqueueJob(jobManager.CreateJob<PHashJob>());
logger?.LogInformation("Queued pHash job.");
}
/// <summary>
/// Queues an animated asset conversion job (GIF → video + first-frame thumbnail).
/// </summary>
/// <param name="o">Unused state parameter.</param>
internal void QueueAnimatedConversion(object? o) {
if (jobManager == null) {
logger?.LogWarning("JobManager is not available, cannot queue animated conversion.");
return;
}
// don't queue if there's already an animated conversion job in progress
if (jobManager.Jobs.Any(j => j is ConvertAnimatedJob && j.JobStatus.Status is EJobStatus.Running or EJobStatus.Queued)) {
logger?.LogInformation("Animated conversion job is already in progress, cannot queue another at this moment.");
return;
}
jobManager.EnqueueJob(jobManager.CreateJob<ConvertAnimatedJob>());
logger?.LogInformation("Queued animated conversion job.");
}
}
+261
View File
@@ -939,6 +939,123 @@ GET {{WepApiTest_HostAddress}}/api/asset?page=0&pageSize=5
});
%}
# =============================================================================
# MAINTENANCE TESTS — broken report, retry, filesystem browse, animated job
# =============================================================================
### 64.1 Get broken assets as admin
GET {{WepApiTest_HostAddress}}/api/asset/broken?page=0&pageSize=5
Authorization: Bearer {{admin_token}}
> {%
client.test("Get broken assets as admin", function () {
client.assert(response.status === 200);
client.assert(response.headers.get("X-Total-Count") !== null, "X-Total-Count header should be present");
});
%}
### 64.2 Get broken assets as regular user (should 403)
GET {{WepApiTest_HostAddress}}/api/asset/broken?page=0&pageSize=5
Authorization: Bearer {{user_token}}
> {%
client.test("Get broken assets as regular user (should 403)", function () {
client.assert(response.status === 403)
});
%}
### 64.3 Get broken assets with invalid pagination (should 400)
GET {{WepApiTest_HostAddress}}/api/asset/broken?page=-1&pageSize=5
Authorization: Bearer {{admin_token}}
> {%
client.test("Get broken assets with invalid pagination (should 400)", function () {
client.assert(response.status === 400)
});
%}
### 64.4 Retry broken assets with empty list (should 400)
POST {{WepApiTest_HostAddress}}/api/asset/broken/retry
Authorization: Bearer {{admin_token}}
Content-Type: application/json
[]
> {%
client.test("Retry broken assets with empty list (should 400)", function () {
client.assert(response.status === 400)
});
%}
### 64.5 Retry broken assets as regular user (should 403)
POST {{WepApiTest_HostAddress}}/api/asset/broken/retry
Authorization: Bearer {{user_token}}
Content-Type: application/json
["00000000-0000-0000-0000-000000000001"]
> {%
client.test("Retry broken assets as regular user (should 403)", function () {
client.assert(response.status === 403)
});
%}
### 64.6 Filesystem browse non-existent folder (should 404)
GET {{WepApiTest_HostAddress}}/api/asset/directory?folderId=00000000-0000-0000-0000-000000000001&path=&page=0&pageSize=5
Authorization: Bearer {{admin_token}}
> {%
client.test("Filesystem browse non-existent folder (should 404)", function () {
client.assert(response.status === 404)
});
%}
### 64.7 Filesystem browse invalid pagination (should 400)
GET {{WepApiTest_HostAddress}}/api/asset/directory?folderId=00000000-0000-0000-0000-000000000001&path=&page=0&pageSize=0
Authorization: Bearer {{admin_token}}
> {%
client.test("Filesystem browse invalid pagination (should 400)", function () {
client.assert(response.status === 400)
});
%}
### 64.8 Filesystem browse as regular user (should 403)
GET {{WepApiTest_HostAddress}}/api/asset/directory?folderId=00000000-0000-0000-0000-000000000001&path=&page=0&pageSize=5
Authorization: Bearer {{user_token}}
> {%
client.test("Filesystem browse as regular user (should 403)", function () {
client.assert(response.status === 403)
});
%}
### 64.9 Enqueue animated conversion job as admin
PUT {{WepApiTest_HostAddress}}/api/jobs
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{ "jobType": "AnimatedConversion", "parameters": [] }
> {%
client.test("Enqueue animated conversion job as admin", function () {
client.assert(response.status === 200)
});
%}
### 64.10 Enqueue animated conversion job as regular user (should 403)
PUT {{WepApiTest_HostAddress}}/api/jobs
Authorization: Bearer {{user_token}}
Content-Type: application/json
{ "jobType": "AnimatedConversion", "parameters": [] }
> {%
client.test("Enqueue animated conversion job as regular user (should 403)", function () {
client.assert(response.status === 403)
});
%}
# =============================================================================
# STATS TESTS
# =============================================================================
@@ -2061,6 +2178,150 @@ GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
}
%}
# ---------------------------------------------------------------------------
# Restore soft-deleted assets (issue #198)
# ---------------------------------------------------------------------------
### 131.1 Admin soft-deletes the test asset (IsDeleted = true)
POST {{WepApiTest_HostAddress}}/api/asset
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"ids": ["{{testAssetId}}"],
"data": { "isDeleted": true }
}
> {%
var hasAssets = client.global.get("hasAssets");
if (hasAssets === "true") {
client.test("Soft-delete asset via IsDeleted flag", function () {
client.assert(response.status === 200)
});
} else {
client.test("No assets — skipping soft-delete test", function () {
client.assert(true)
});
}
%}
### 131.2 Admin: deletedOnly search lists the soft-deleted asset
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
Authorization: Bearer {{admin_token}}
> {%
var hasAssets = client.global.get("hasAssets");
if (hasAssets === "true") {
client.test("Admin sees deleted assets in deletedOnly search", function () {
client.assert(response.status === 200);
var total = parseInt(response.headers.valueOf("X-Total-Count"));
client.assert(total >= 1, "Expected at least one deleted asset but got total " + total);
});
} else {
client.test("No assets — skipping deletedOnly search test", function () {
client.assert(true)
});
}
%}
### 131.3 Regular user: deletedOnly search returns nothing (R7 ? users never see deleted)
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
Authorization: Bearer {{user_token}}
> {%
client.test("Regular user gets empty deletedOnly results", function () {
client.assert(response.status === 200);
client.assert(response.body.length === 0, "Expected no deleted assets for regular user");
});
%}
### 131.4 Curator: deletedOnly search succeeds (scoped to own uploads by R7)
GET {{WepApiTest_HostAddress}}/api/asset?deletedOnly=true&page=0&pageSize=50&includeCount=true
Authorization: Bearer {{curator_token}}
> {%
client.test("Curator can query deletedOnly search", function () {
client.assert(response.status === 200)
});
%}
### 131.5 Anonymous: restore attempt should 401
POST {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
Content-Type: application/json
{
"isDeleted": false
}
> {%
client.test("Anonymous restore attempt is rejected", function () {
client.assert([401, 403].indexOf(response.status) !== -1, "Expected 401/403 but got " + response.status);
});
%}
### 131.6 Regular user: restore attempt should 403
POST {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
Authorization: Bearer {{user_token}}
Content-Type: application/json
{
"isDeleted": false
}
> {%
client.test("Regular user restore attempt is rejected", function () {
client.assert(response.status === 403, "Expected 403 but got " + response.status);
});
%}
### 131.7 Admin: bulk restore via update endpoint (IsDeleted = false)
# Assets whose source files are missing on disk are reported in skippedMissingFile
# instead of being restored; a bare 200 therefore always indicates a valid outcome.
POST {{WepApiTest_HostAddress}}/api/asset
Authorization: Bearer {{admin_token}}
Content-Type: application/json
{
"ids": ["{{testAssetId}}"],
"data": { "isDeleted": false }
}
> {%
var hasAssets = client.global.get("hasAssets");
if (hasAssets === "true") {
client.test("Bulk restore reports restored/skipped lists", function () {
client.assert(response.status === 200);
client.assert(jsonPath(response.body, "$.restored") != null, "Missing 'restored' list");
client.assert(jsonPath(response.body, "$.skippedMissingFile") != null, "Missing 'skippedMissingFile' list");
});
var restored = response.body.restored;
client.global.set("assetRestored", restored != null && restored.indexOf(client.global.get("testAssetId")) !== -1 ? "true" : "false");
} else {
client.global.set("assetRestored", "false");
client.test("No assets — skipping bulk restore test", function () {
client.assert(true)
});
}
%}
### 131.8 Verify restored asset is live again (skipped when its file was missing on disk)
GET {{WepApiTest_HostAddress}}/api/asset/{{testAssetId}}
Authorization: Bearer {{admin_token}}
> {%
var assetRestored = client.global.get("assetRestored");
if (assetRestored === "true") {
client.test("Restored asset no longer reports DeletedAt", function () {
client.assert(response.status === 200);
client.assert(response.body.deletedAt === null, "deletedAt should be cleared after restore");
});
} else {
client.test("Asset not restored (missing file) — skipping verification", function () {
client.assert(true)
});
}
%}
# =============================================================================
# CLEANUP
# =============================================================================
+3 -3
View File
@@ -1,7 +1,7 @@
@inject LoginService loginService
@inject NavigationManager NavigationManager
@if (_initialized) {
@if (initialized) {
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
@@ -16,11 +16,11 @@
}
@code {
bool _initialized;
bool initialized;
protected async override Task OnInitializedAsync() {
await loginService.InitializeAsync();
_initialized = true;
initialized = true;
if (loginService.ForceLogoutReason != null)
NavigationManager.NavigateToLoginForce();
+71 -27
View File
@@ -1,6 +1,8 @@
@using Butter.Dtos.Folder
@using Butter.Dtos.Jobs
@using System.Text.RegularExpressions
@inject FoldersService FoldersService
@inject JobsService JobsService
<div class="card mb-3">
<div class="card-body py-2">
@@ -16,6 +18,18 @@
<i class="bi bi-regex"></i> @(string.IsNullOrEmpty(Folder.RegexPattern) ? "" : "on")
</button>
@* Scan dropdown *@
<div class="btn-group btn-group-sm @Display">
<button class="btn btn-job-scan" @onclick="() => OnScan(false)" title="Incremental scan">
<i class="bi bi-folder2-open"></i>
</button>
<button class="btn btn-job-scan dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false" title="Scan options"></button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" @onclick="() => OnScan(false)"><i class="bi bi-folder2-open me-1"></i>Scan (incremental)</a></li>
<li><a class="dropdown-item" @onclick="() => OnScan(true)"><i class="bi bi-arrow-repeat me-1"></i>Deep scan</a></li>
</ul>
</div>
<div class="ms-auto form-switch">
<input class="form-check-input m-1" type="checkbox" id="@Folder.Id" @bind="@Folder.Active" @bind:after="() => OnToggleActive()"/>
</div>
@@ -23,41 +37,48 @@
<button class="btn btn-danger btn-sm mx-1" @onclick="OnDelete"><i class="bi bi-folder-minus"></i></button>
</div>
@if (scanError != null) {
<div class="alert alert-danger py-1 px-2 mb-0 mt-1 small">@scanError</div>
}
@if (scanBusy) {
<div class="small text-muted py-1"><span class="spinner-border spinner-border-sm me-1"></span>Enqueuing scan...</div>
}
@* Pattern section (collapsible) *@
@if (_patternExpanded)
@if (patternExpanded)
{
<hr class="my-2"/>
<div class="regex-section">
<div class="mb-1 small text-muted">Regex Pattern</div>
@* Syntax-highlighted preview *@
<div class="regex-highlight mb-1">@_highlightedPattern</div>
<div class="regex-highlight mb-1">@highlightedPattern</div>
@* Regex input *@
<div class="input-group input-group-sm mb-2">
<input class="form-control form-control-sm font-monospace"
type="text"
placeholder="e.g. (?&lt;person&gt;\w+)/(?&lt;group&gt;\d{4})"
@bind="_localPattern"
@bind="localPattern"
@bind:event="oninput"/>
<button class="btn btn-sm btn-success"
@onclick="OnSavePattern"
disabled="@(_localPattern == Folder.RegexPattern)">
disabled="@(localPattern == Folder.RegexPattern)">
<i class="bi bi-check"></i>
</button>
</div>
@* Sample paths *@
@if (_samplePaths != null && _samplePaths.Count > 0)
@if (samplePaths != null && samplePaths.Count > 0)
{
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="small text-muted">Sample paths (@_samplePaths.Count)</span>
<span class="small text-muted">Sample paths (@samplePaths.Count)</span>
<button class="btn btn-sm btn-secondary py-0 px-1" @onclick="LoadSamplePaths" title="Refresh samples">
<i class="bi bi-arrow-repeat small"></i>
</button>
</div>
<div class="sample-paths">
@foreach (var path in _samplePaths)
@foreach (var path in samplePaths)
{
var matchResult = TryMatch(path);
<div class="sample-path-row mb-1 @(matchResult == null ? "no-match" : "")">
@@ -91,7 +112,7 @@
}
</div>
}
else if (_samplePaths != null)
else if (samplePaths != null)
{
<div class="small text-muted mb-1">No assets in this folder yet. Sample paths appear after scanning.</div>
}
@@ -101,15 +122,17 @@
</div>
@code {
bool _editing;
bool _patternExpanded;
string _localPattern = string.Empty;
List<string>? _samplePaths;
string Display => _editing ? "d-none" : "";
string Edit => _editing ? "" : "d-none";
bool editing;
bool patternExpanded;
bool scanBusy;
string localPattern = string.Empty;
string? scanError;
List<string>? samplePaths;
string Display => editing ? "d-none" : "";
string Edit => editing ? "" : "d-none";
string PatternBadgeClass => !string.IsNullOrEmpty(Folder.RegexPattern) ? "pattern-active" : "";
MarkupString _highlightedPattern => RegexHighlighter.Highlight(_localPattern);
MarkupString highlightedPattern => RegexHighlighter.Highlight(localPattern);
[Parameter]
public FolderFullDto Folder { get; set; } = new FolderFullDto();
@@ -119,24 +142,24 @@
protected override void OnParametersSet()
{
if (!_patternExpanded)
_localPattern = Folder.RegexPattern ?? string.Empty;
if (!patternExpanded)
localPattern = Folder.RegexPattern ?? string.Empty;
}
void TogglePatternSection()
{
_patternExpanded = !_patternExpanded;
if (_patternExpanded)
patternExpanded = !patternExpanded;
if (patternExpanded)
{
_localPattern = Folder.RegexPattern ?? string.Empty;
if (_samplePaths == null)
localPattern = Folder.RegexPattern ?? string.Empty;
if (samplePaths == null)
_ = LoadSamplePaths();
}
}
void OnEdit()
{
_editing = true;
editing = true;
}
async Task OnConfirmEdit()
@@ -149,7 +172,7 @@
RegexPattern = Folder.RegexPattern
}
);
_editing = false;
editing = false;
}
async Task OnDelete()
@@ -157,6 +180,27 @@
await OnDeleteRequested.InvokeAsync(Folder.Id);
}
async Task OnScan(bool deep)
{
if (scanBusy) return;
scanBusy = true;
scanError = null;
try
{
var parameters = new List<string> { $"folder={Folder.Id}" };
if (deep) parameters.Add("deep");
await JobsService.StartJob(EJobType.FileSystemScan, parameters);
}
catch (Exception ex)
{
scanError = $"Could not start scan: {ex.Message}";
}
finally
{
scanBusy = false;
}
}
async Task OnToggleActive()
{
await FoldersService.UpdateFolderAsync(
@@ -171,7 +215,7 @@
async Task OnSavePattern()
{
Folder.RegexPattern = _localPattern;
Folder.RegexPattern = localPattern;
await FoldersService.UpdateFolderAsync(
Folder.Id,
new FolderUpdateDto() {
@@ -185,17 +229,17 @@
async Task LoadSamplePaths()
{
var result = await FoldersService.GetSamplePathsAsync(Folder.Id);
_samplePaths = result?.Paths ?? new List<string>();
samplePaths = result?.Paths ?? new List<string>();
}
MatchResult? TryMatch(string path)
{
if (string.IsNullOrEmpty(_localPattern))
if (string.IsNullOrEmpty(localPattern))
return null;
try
{
var regex = new Regex(_localPattern, RegexOptions.None, TimeSpan.FromMilliseconds(500));
var regex = new Regex(localPattern, RegexOptions.None, TimeSpan.FromMilliseconds(500));
var match = regex.Match(path);
if (!match.Success) return null;
+11 -10
View File
@@ -18,8 +18,8 @@
<span class="text-truncate text-muted small text-end d-none d-sm-inline">@(Job.Started?.ToLocalTime().ToString("g")) &mdash; @(Job.Finished?.ToLocalTime().ToString("g"))</span>
} else {
<span class="flex-shrink-0" style="width: 90px;">
@if (_segments is not null) {
<ProgressBar Segments="@_segments" />
@if (segments is not null) {
<ProgressBar Segments="@segments" />
} else {
<ProgressBar Value="@Job.Progress" Color="@StatusColor" />
}
@@ -65,8 +65,8 @@
<span class="d-inline-block rounded-circle flex-shrink-0" style="width: 6px; height: 6px; background: @DotColor"></span>
<span class="small text-muted">@StatusLabel</span>
<span class="flex-shrink-0" style="width: 70px;">
@if (_segments is not null) {
<ProgressBar Segments="@_segments" />
@if (segments is not null) {
<ProgressBar Segments="@segments" />
} else {
<ProgressBar Value="@Job.Progress" Color="@StatusColor" />
}
@@ -105,7 +105,7 @@
[Parameter] public EventCallback<bool> ExpandedChanged { get; set; }
[Parameter] public IReadOnlyList<JobStatusDto>? Children { get; set; }
static readonly Dictionary<EJobStatus, string> _statusColors = new() {
static readonly Dictionary<EJobStatus, string> statusColors = new() {
[EJobStatus.Queued] = "var(--status-queued)",
[EJobStatus.Running] = "var(--status-running)",
[EJobStatus.Waiting] = "var(--status-waiting)",
@@ -115,7 +115,7 @@
[EJobStatus.Canceled] = "var(--status-canceled)",
};
static readonly EJobStatus[] _segmentOrder = [
static readonly EJobStatus[] segmentOrder = [
EJobStatus.Completed,
EJobStatus.CompletedWithErrors,
EJobStatus.Failed,
@@ -125,20 +125,20 @@
EJobStatus.Queued,
];
List<(float Width, string Color)>? _segments;
List<(float Width, string Color)>? segments;
protected override void OnParametersSet() {
_segments = ComputeSegments();
segments = ComputeSegments();
}
List<(float Width, string Color)>? ComputeSegments() {
if (Children is not { Count: > 0 }) return null;
var total = (float)Children.Count;
var ordered = _segmentOrder
var ordered = segmentOrder
.Select(s => (Status: s, Children.Where(c => c.Status == s).ToList()))
.Where(g => g.Item2.Count > 0)
.Select(g => (g.Item2.Count / total, _statusColors[g.Status]))
.Select(g => (g.Item2.Count / total, statusColors[g.Status]))
.ToList();
return ordered;
@@ -153,6 +153,7 @@
EJobType.PHashGeneration => "<i class=\"bi bi-hash\" style=\"color: var(--job-phash)\"></i>",
EJobType.CreatePersons => "<i class=\"bi bi-person-plus\" style=\"color: var(--job-persons)\"></i>",
EJobType.CreateAlbums => "<i class=\"bi bi-collection\" style=\"color: var(--job-create-albums)\"></i>",
EJobType.AnimatedConversion => "<i class=\"bi bi-film\" style=\"color: var(--job-animated)\"></i>",
_ => "<i class=\"bi bi-question-circle text-muted\"></i>"
};
+33 -33
View File
@@ -6,7 +6,7 @@
<div class="text-muted small p-2 text-center">@EmptyText</div>
} else {
@foreach (var root in Roots) {
var hasFetched = _childrenCache.TryGetValue(root.Id, out var children);
var hasFetched = childrenCache.TryGetValue(root.Id, out var children);
var hasChildren = hasFetched ? children!.Count > 0 : true;
<JobRow @key="root.Id"
@@ -14,14 +14,14 @@
Level="@Level"
ChildSummaryText="@ChildSummary(root, hasFetched, children)"
Children="@(hasFetched ? children : null)"
IsExpanded="@(_expandedParents.Contains(root.Id) && hasFetched)"
IsExpanded="@(expandedParents.Contains(root.Id) && hasFetched)"
ExpandedChanged="(bool e) => OnExpanded(root, e)" />
@if (_expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
@if (expandedParents.Contains(root.Id) && hasFetched && hasChildren) {
var groups = children!.GroupBy(j => j.Status).OrderBy(g => (int)g.Key);
@foreach (var group in groups) {
var groupKey = $"{root.Id}-{(int)group.Key}";
var isGroupExpanded = _expandedGroups.Contains(groupKey);
var isGroupExpanded = expandedGroups.Contains(groupKey);
<div @key="groupKey" class="job-tree-group ms-@((Level + 1) * 2)">
<div class="small fw-semibold py-0 d-flex align-items-center gap-2 user-select-none"
@onclick="() => ToggleGroup(groupKey)" role="button">
@@ -56,11 +56,11 @@
? Jobs?.Where(j => j.ParentJobId is null).OrderByDescending(j => j.Finished ?? j.Created).ToList() ?? []
: Jobs?.OrderBy(j => j.Created).ToList() ?? [];
readonly HashSet<Guid> _expandedParents = [];
readonly HashSet<string> _expandedGroups = [];
readonly Dictionary<Guid, List<JobStatusDto>?> _childrenCache = [];
readonly Dictionary<Guid, DateTime> _sinceTimestamps = [];
CancellationTokenSource? _childRefreshCts;
readonly HashSet<Guid> expandedParents = [];
readonly HashSet<string> expandedGroups = [];
readonly Dictionary<Guid, List<JobStatusDto>?> childrenCache = [];
readonly Dictionary<Guid, DateTime> sinceTimestamps = [];
CancellationTokenSource? childRefreshCts;
protected override async Task OnParametersSetAsync() {
if (Level == 0 && FetchChildren is not null) {
@@ -71,11 +71,11 @@
async Task EagerFetchMissing() {
foreach (var root in Roots) {
if (_childrenCache.ContainsKey(root.Id) && _sinceTimestamps.ContainsKey(root.Id)) continue;
if (childrenCache.ContainsKey(root.Id) && sinceTimestamps.ContainsKey(root.Id)) continue;
try {
var (children, since) = await FetchChildren!(root.Id, null);
_childrenCache[root.Id] = children;
_sinceTimestamps[root.Id] = since ?? DateTime.UtcNow;
childrenCache[root.Id] = children;
sinceTimestamps[root.Id] = since ?? DateTime.UtcNow;
} catch {
// refresh loop will retry
}
@@ -145,20 +145,20 @@
async Task OnExpanded(JobStatusDto job, bool expanded) {
if (expanded) {
_expandedParents.Add(job.Id);
expandedParents.Add(job.Id);
bool shouldFetch = FetchChildren is not null && (
!_childrenCache.ContainsKey(job.Id) ||
!childrenCache.ContainsKey(job.Id) ||
job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting
);
if (shouldFetch) {
var (children, since) = await FetchChildren!(job.Id, null);
_childrenCache[job.Id] = children;
_sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
childrenCache[job.Id] = children;
sinceTimestamps[job.Id] = since ?? DateTime.UtcNow;
}
StartChildRefresh();
} else {
_expandedParents.Remove(job.Id);
if (_expandedParents.Count == 0 && !HasActiveRoots) {
expandedParents.Remove(job.Id);
if (expandedParents.Count == 0 && !HasActiveRoots) {
StopChildRefresh();
}
}
@@ -167,24 +167,24 @@
bool HasActiveRoots => Level == 0 && Roots.Any(j => j.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting);
void StartChildRefresh() {
if (_childRefreshCts is not null) return;
if (childRefreshCts is not null) return;
bool hasActive = HasActiveRoots;
if (!hasActive) {
hasActive = _expandedParents.Any(id => {
hasActive = expandedParents.Any(id => {
var job = Roots?.FirstOrDefault(j => j.Id == id);
return job is not null && job.Status is EJobStatus.Queued or EJobStatus.Running or EJobStatus.Waiting;
});
}
if (!hasActive) return;
_childRefreshCts = new CancellationTokenSource();
_ = ChildRefreshLoop(_childRefreshCts.Token);
childRefreshCts = new CancellationTokenSource();
_ = ChildRefreshLoop(childRefreshCts.Token);
}
void StopChildRefresh() {
_childRefreshCts?.Cancel();
_childRefreshCts?.Dispose();
_childRefreshCts = null;
childRefreshCts?.Cancel();
childRefreshCts?.Dispose();
childRefreshCts = null;
}
async Task ChildRefreshLoop(CancellationToken ct) {
@@ -210,7 +210,7 @@
}
// Expanded parents (any level): poll children for tree display
foreach (var id in _expandedParents.ToArray()) {
foreach (var id in expandedParents.ToArray()) {
if (visited.Contains(id)) continue;
var job = Roots?.FirstOrDefault(j => j.Id == id);
if (job is null) continue;
@@ -232,9 +232,9 @@
async void PollChildren(Guid parentId) {
if (FetchChildren is null) return;
try {
DateTime? since = _sinceTimestamps.TryGetValue(parentId, out var ts) ? ts : null;
DateTime? since = sinceTimestamps.TryGetValue(parentId, out var ts) ? ts : null;
var (children, newSince) = await FetchChildren(parentId, since);
_sinceTimestamps[parentId] = newSince ?? DateTime.UtcNow;
sinceTimestamps[parentId] = newSince ?? DateTime.UtcNow;
if (children.Count > 0) {
MergeChildren(parentId, children);
}
@@ -244,21 +244,21 @@
}
void MergeChildren(Guid parentId, List<JobStatusDto> incoming) {
if (!_childrenCache.TryGetValue(parentId, out var existing) || existing is null) {
_childrenCache[parentId] = incoming;
if (!childrenCache.TryGetValue(parentId, out var existing) || existing is null) {
childrenCache[parentId] = incoming;
return;
}
var dict = existing.ToDictionary(c => c.Id);
foreach (var child in incoming) {
dict[child.Id] = child;
}
_childrenCache[parentId] = dict.Values.ToList();
childrenCache[parentId] = dict.Values.ToList();
}
public void Dispose() => StopChildRefresh();
void ToggleGroup(string groupKey) {
if (!_expandedGroups.Remove(groupKey))
_expandedGroups.Add(groupKey);
if (!expandedGroups.Remove(groupKey))
expandedGroups.Add(groupKey);
}
}
@@ -23,7 +23,7 @@
</div>
@code {
CancellationTokenSource? _keepaliveCts;
CancellationTokenSource? keepaliveCts;
string AppVersion => System.Reflection.Assembly.GetExecutingAssembly()
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>()
?.InformationalVersion ?? "unknown";
@@ -44,14 +44,14 @@
}
void StartKeepalive() {
_keepaliveCts = new CancellationTokenSource();
_ = KeepAliveLoop(_keepaliveCts.Token);
keepaliveCts = new CancellationTokenSource();
_ = KeepAliveLoop(keepaliveCts.Token);
}
void StopKeepalive() {
_keepaliveCts?.Cancel();
_keepaliveCts?.Dispose();
_keepaliveCts = null;
keepaliveCts?.Cancel();
keepaliveCts?.Dispose();
keepaliveCts = null;
}
async Task KeepAliveLoop(CancellationToken ct) {
@@ -43,15 +43,15 @@
</button>
<button class="btn btn-light dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" @onclick="OnProfileClick"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
<li><a class="dropdown-item" href="@($"/User/{loginService.AuthInfo?.UserId}")"><i class="bi bi-person-lines-fill"></i> Profile</a></li>
@if(AdminOrCurator) {
<li><a class="dropdown-item" @onclick="OnStatsClick"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
<li><a class="dropdown-item" href="/stats"><i class="bi bi-bar-chart-fill"></i> Statistics</a></li>
}
@if(Admin) {
<li><a class="dropdown-item" @onclick="OnMaintenanceClick"><i class="bi bi-tools"></i> Maintenance</a></li>
<li><a class="dropdown-item" @onclick="OnUsersClick"><i class="bi bi-people-fill"></i> Users</a></li>
<li><a class="dropdown-item" @onclick="OnSettingsClick"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" @onclick="OnJobsClick"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
<li><a class="dropdown-item" href="/maintenance"><i class="bi bi-tools"></i> Maintenance</a></li>
<li><a class="dropdown-item" href="/users"><i class="bi bi-people-fill"></i> Users</a></li>
<li><a class="dropdown-item" href="/settings"><i class="bi bi-gear-wide-connected"></i> Settings</a></li>
<li><a class="dropdown-item" href="/jobs"><i class="bi bi-kanban-fill"></i> Jobs</a></li>
}
<li><a class="dropdown-item" @onclick="OnLogout"><i class="bi bi-door-closed"></i> Logout</a></li>
</ul>
@@ -78,15 +78,6 @@
void OnRegisterClick() => navigation.NavigateTo("/register");
void OnSettingsClick() => navigation.NavigateTo("/settings");
void OnJobsClick() => navigation.NavigateTo("/jobs");
void OnMaintenanceClick() => navigation.NavigateTo("/maintenance");
void OnStatsClick() => navigation.NavigateTo("/stats");
void OnUsersClick() => navigation.NavigateTo("/users");
void OnProfileClick() => navigation.NavigateTo($"/User/{loginService.AuthInfo?.UserId}", true);
async Task OnLogout() {
@@ -12,9 +12,9 @@
<PageTitle>Users</PageTitle>
@if (_isLoading) {
@if (isLoading) {
<LoadSpinner />
} else if (_users == null) {
} else if (users == null) {
<div class="alert alert-danger">Failed to load users.</div>
} else {
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
@@ -24,16 +24,16 @@
</button>
</div>
@if (!string.IsNullOrEmpty(_error)) {
@if (!string.IsNullOrEmpty(error)) {
<div class="alert alert-danger py-1 alert-dismissible fade show">
@_error
<button type="button" class="btn-close" @onclick="() => _error = null"></button>
@error
<button type="button" class="btn-close" @onclick="() => error = null"></button>
</div>
}
@if (!string.IsNullOrEmpty(_successMessage)) {
@if (!string.IsNullOrEmpty(successMessage)) {
<div class="alert alert-success alert-dismissible fade show py-1">
@_successMessage
<button type="button" class="btn-close" @onclick="() => _successMessage = null"></button>
@successMessage
<button type="button" class="btn-close" @onclick="() => successMessage = null"></button>
</div>
}
@@ -41,12 +41,12 @@
<div class="input-group input-group-sm" style="max-width: 280px;">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" placeholder="Search by name or email..."
@bind="_searchQuery" @bind:event="oninput" />
@bind="searchQuery" @bind:event="oninput" />
</div>
<small class="text-muted">@_users.Count user@(_users.Count != 1 ? "s" : "") —
<span class="text-success">@_users.Count(u => u.DeletedAt == null && !u.IsBanned) active</span>,
<span class="text-warning">@_users.Count(u => u.IsBanned) banned</span>,
<span class="text-secondary">@_users.Count(u => u.DeletedAt != null) deleted</span>
<small class="text-muted">@users.Count user@(users.Count != 1 ? "s" : "") —
<span class="text-success">@users.Count(u => u.DeletedAt == null && !u.IsBanned) active</span>,
<span class="text-warning">@users.Count(u => u.IsBanned) banned</span>,
<span class="text-secondary">@users.Count(u => u.DeletedAt != null) deleted</span>
</small>
</div>
@@ -77,7 +77,7 @@
</tr>
</thead>
<tbody>
@foreach (var user in _filteredSorted) {
@foreach (var user in filteredSorted) {
var isSelf = user.Id == LoginService.LoggedUser?.Id;
<tr class="@(user.DeletedAt != null ? "table-secondary" : user.IsBanned ? "table-warning" : "")">
<td>
@@ -109,7 +109,7 @@
</td>
</tr>
}
@if (!_filteredSorted.Any()) {
@if (!filteredSorted.Any()) {
<tr><td colspan="7" class="text-center text-muted py-3">No users match your search.</td></tr>
}
</tbody>
@@ -118,7 +118,7 @@
@* Mobile cards *@
<div class="d-md-none">
@foreach (var user in _filteredSorted) {
@foreach (var user in filteredSorted) {
var isSelf = user.Id == LoginService.LoggedUser?.Id;
<div class="card mb-2 @(user.DeletedAt != null ? "border-secondary" : user.IsBanned ? "border-warning" : "")">
<div class="card-body py-2">
@@ -146,38 +146,38 @@
</div>
</div>
}
@if (!_filteredSorted.Any()) {
@if (!filteredSorted.Any()) {
<div class="text-center text-muted py-3">No users match your search.</div>
}
</div>
}
<ModalFrame Title="Create User" Show="_showCreateModal" OnClose="CloseCreateModal">
<ModalFrame Title="Create User" Show="showCreateModal" OnClose="CloseCreateModal">
<HeaderActions><i class="bi bi-person-plus me-1"></i></HeaderActions>
<Body>
@if (!string.IsNullOrEmpty(_createError)) {
<div class="alert alert-danger py-1">@_createError</div>
@if (!string.IsNullOrEmpty(createError)) {
<div class="alert alert-danger py-1">@createError</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="_createModel.Username" @bind:event="oninput" />
<input type="text" class="form-control" @bind="createModel.Username" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_createModel.Email" @bind:event="oninput" />
<input type="email" class="form-control" @bind="createModel.Email" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="@(_showCreatePassword ? "text" : "password")" class="form-control border-end-0" @bind="_createModel.Password" @bind:event="oninput" />
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showCreatePassword = !_showCreatePassword" tabindex="-1">
<i class="bi @(_showCreatePassword ? "bi-eye-slash" : "bi-eye")"></i>
<input type="@(showCreatePassword ? "text" : "password")" class="form-control border-end-0" @bind="createModel.Password" @bind:event="oninput" />
<button class="btn btn-outline-secondary" type="button" @onclick="() => showCreatePassword = !showCreatePassword" tabindex="-1">
<i class="bi @(showCreatePassword ? "bi-eye-slash" : "bi-eye")"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Access Level</label>
<select class="form-select" @bind="_createModel.AccessLevel">
<select class="form-select" @bind="createModel.AccessLevel">
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
<option value="@level">@level</option>
}
@@ -190,27 +190,27 @@
</Footer>
</ModalFrame>
<ModalFrame Title="" Show="_editUser != null" OnClose="CloseEditModal">
<ModalFrame Title="" Show="editUser != null" OnClose="CloseEditModal">
<HeaderActions><i class="bi bi-pencil me-1"></i></HeaderActions>
<Body>
@if (_editUser != null)
@if (editUser != null)
{
<h5>Edit @_editUser.Username</h5>
@if (!string.IsNullOrEmpty(_editError)) {
<div class="alert alert-danger py-1">@_editError</div>
<h5>Edit @editUser.Username</h5>
@if (!string.IsNullOrEmpty(editError)) {
<div class="alert alert-danger py-1">@editError</div>
}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" @bind="_editUsername" @bind:event="oninput" />
<input type="text" class="form-control" @bind="editUsername" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" class="form-control" @bind="_editEmail" @bind:event="oninput" />
<input type="email" class="form-control" @bind="editEmail" @bind:event="oninput" />
</div>
<div class="mb-3">
<label class="form-label">Access Level</label>
<select class="form-select" @bind="_editAccessLevel">
<select class="form-select" @bind="editAccessLevel">
@foreach (var level in Enum.GetValues<EAccessLevel>()) {
<option value="@level">@level</option>
}
@@ -222,10 +222,10 @@
<div class="mb-3">
<SearchAssignBadges TItem="PersonPreviewDto"
Placeholder="Search cosplayers..."
SearchQuery="@_personSearchQuery"
SearchQuery="@personSearchQuery"
OnSearch="HandlePersonSearchInput"
SearchResults="@_personSearchResults"
SelectedItems="@_assignedPersons"
SearchResults="@personSearchResults"
SelectedItems="@assignedPersons"
OnSelect="SelectPerson"
OnRemove="RemoveAssignedPerson">
<ItemDisplay Context="person">
@@ -233,10 +233,10 @@
</ItemDisplay>
</SearchAssignBadges>
@if (_maintainedPersonIds.Count > 0)
@if (maintainedPersonIds.Count > 0)
{
<div class="mt-1">
<small class="text-muted">@_maintainedPersonIds.Count cosplayer@(_maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
<small class="text-muted">@maintainedPersonIds.Count cosplayer@(maintainedPersonIds.Count != 1 ? "s" : "") selected</small>
</div>
}
</div>
@@ -244,19 +244,24 @@
<hr />
<h6>Danger Zone</h6>
<div class="d-flex flex-wrap gap-2">
@if (_editUser.IsBanned) {
<button class="btn btn-success btn-sm" @onclick="() => ToggleBan(_editUser, false)">
@if (editUser.IsBanned) {
<button class="btn btn-success btn-sm" @onclick="() => ToggleBan(editUser, false)">
<i class="bi bi-unlock"></i> Unban
</button>
} else {
<button class="btn btn-warning btn-sm" @onclick="() => ToggleBan(_editUser, true)">
<button class="btn btn-warning btn-sm" @onclick="() => ToggleBan(editUser, true)">
<i class="bi bi-lock"></i> Ban
</button>
}
<button class="btn btn-primary btn-sm" @onclick="() => _passwordUser = _editUser">
<button class="btn btn-primary btn-sm" @onclick="() => passwordUser = editUser">
<i class="bi bi-key"></i> Change Password
</button>
<button class="btn btn-danger btn-sm" disabled="@(_editUser.DeletedAt != null)" @onclick="ConfirmDelete">
@if (editUser.DeletedAt != null) {
<button class="btn btn-success btn-sm" @onclick="() => ToggleDeleted(editUser, false)">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
}
<button class="btn btn-danger btn-sm" disabled="@(editUser.DeletedAt != null)" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete
</button>
</div>
@@ -264,39 +269,39 @@
</Body>
<Footer>
<button class="btn btn-secondary" @onclick="CloseEditModal">Cancel</button>
<button class="btn btn-primary" @onclick="SubmitEditUser" disabled="@(string.IsNullOrWhiteSpace(_editUsername) || string.IsNullOrWhiteSpace(_editEmail))">
<button class="btn btn-primary" @onclick="SubmitEditUser" disabled="@(string.IsNullOrWhiteSpace(editUsername) || string.IsNullOrWhiteSpace(editEmail))">
Save
</button>
</Footer>
</ModalFrame>
<ModalFrame Title="Change Password" Show="_passwordUser != null" OnClose="ClosePasswordModal">
<ModalFrame Title="Change Password" Show="passwordUser != null" OnClose="ClosePasswordModal">
<HeaderActions><i class="bi bi-key me-1"></i></HeaderActions>
<Body>
@if (_passwordUser != null)
@if (passwordUser != null)
{
<p class="text-muted small">Changing password for <strong>@_passwordUser.Username</strong></p>
@if (!string.IsNullOrEmpty(_passwordError)) {
<div class="alert alert-danger py-1">@_passwordError</div>
<p class="text-muted small">Changing password for <strong>@passwordUser.Username</strong></p>
@if (!string.IsNullOrEmpty(passwordError)) {
<div class="alert alert-danger py-1">@passwordError</div>
}
<div class="mb-3">
<label class="form-label">New Password</label>
<div class="input-group">
<input type="@(_showNewPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminNewPassword" @bind:event="oninput" />
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showNewPassword = !_showNewPassword" tabindex="-1">
<i class="bi @(_showNewPassword ? "bi-eye-slash" : "bi-eye")"></i>
<input type="@(showNewPassword ? "text" : "password")" class="form-control border-end-0" @bind="adminNewPassword" @bind:event="oninput" />
<button class="btn btn-outline-secondary" type="button" @onclick="() => showNewPassword = !showNewPassword" tabindex="-1">
<i class="bi @(showNewPassword ? "bi-eye-slash" : "bi-eye")"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Confirm New Password</label>
<div class="input-group">
<input type="@(_showConfirmPassword ? "text" : "password")" class="form-control border-end-0" @bind="_adminConfirmPassword" @bind:event="oninput" />
<button class="btn btn-outline-secondary" type="button" @onclick="() => _showConfirmPassword = !_showConfirmPassword" tabindex="-1">
<i class="bi @(_showConfirmPassword ? "bi-eye-slash" : "bi-eye")"></i>
<input type="@(showConfirmPassword ? "text" : "password")" class="form-control border-end-0" @bind="adminConfirmPassword" @bind:event="oninput" />
<button class="btn btn-outline-secondary" type="button" @onclick="() => showConfirmPassword = !showConfirmPassword" tabindex="-1">
<i class="bi @(showConfirmPassword ? "bi-eye-slash" : "bi-eye")"></i>
</button>
</div>
@if (!string.IsNullOrEmpty(_adminConfirmPassword) && _adminNewPassword != _adminConfirmPassword) {
@if (!string.IsNullOrEmpty(adminConfirmPassword) && adminNewPassword != adminConfirmPassword) {
<small class="text-danger">Passwords do not match</small>
}
</div>
@@ -304,72 +309,72 @@
</Body>
<Footer>
<button class="btn btn-secondary" @onclick="ClosePasswordModal">Cancel</button>
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(_adminNewPassword) || _adminNewPassword != _adminConfirmPassword)"
<button class="btn btn-primary" disabled="@(string.IsNullOrWhiteSpace(adminNewPassword) || adminNewPassword != adminConfirmPassword)"
@onclick="SubmitAdminPasswordChange">
Update Password
</button>
</Footer>
</ModalFrame>
<ConfirmDialog Show="_showDeleteConfirm"
<ConfirmDialog Show="showDeleteConfirm"
Title="Confirm Deletion"
Message="@($"Delete user '{_editUser?.Username}'? This can be undone.")"
Message="@($"Delete user '{editUser?.Username}'? This can be undone.")"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecuteDelete"
OnCancel="() => _showDeleteConfirm = false" />
OnCancel="() => showDeleteConfirm = false" />
@code {
private List<UserInfoDto>? _users;
private bool _isLoading = true;
private string? _error;
private string? _successMessage;
private string _searchQuery = "";
private List<UserInfoDto>? users;
private bool isLoading = true;
private string? error;
private string? successMessage;
private string searchQuery = "";
private string? _sortColumn = nameof(UserInfoDto.AccessLevel);
private bool _sortAsc = false;
private string? sortColumn = nameof(UserInfoDto.AccessLevel);
private bool sortAsc = false;
private bool _showCreateModal;
private readonly UserCreateDto _createModel = new() {
private bool showCreateModal;
private readonly UserCreateDto createModel = new() {
Username = "",
Email = "",
Password = "",
AccessLevel = EAccessLevel.User
};
private string? _createError;
private string? createError;
private UserInfoDto? _editUser;
bool _showDeleteConfirm;
private string _editUsername = "";
private string _editEmail = "";
private EAccessLevel _editAccessLevel;
private string? _editError;
private UserInfoDto? editUser;
bool showDeleteConfirm;
private string editUsername = "";
private string editEmail = "";
private EAccessLevel editAccessLevel;
private string? editError;
private UserInfoDto? _passwordUser;
private string _adminNewPassword = "";
private string _adminConfirmPassword = "";
private string? _passwordError;
private bool _showCreatePassword;
private bool _showNewPassword;
private bool _showConfirmPassword;
private UserInfoDto? passwordUser;
private string adminNewPassword = "";
private string adminConfirmPassword = "";
private string? passwordError;
private bool showCreatePassword;
private bool showNewPassword;
private bool showConfirmPassword;
private HashSet<Guid> _maintainedPersonIds = [];
private List<PersonPreviewDto> _assignedPersons = [];
private string _personSearchQuery = "";
private List<PersonPreviewDto>? _personSearchResults;
private CancellationTokenSource? _searchCts;
private HashSet<Guid> maintainedPersonIds = [];
private List<PersonPreviewDto> assignedPersons = [];
private string personSearchQuery = "";
private List<PersonPreviewDto>? personSearchResults;
private CancellationTokenSource? searchCts;
private IEnumerable<UserInfoDto> _filteredSorted {
private IEnumerable<UserInfoDto> filteredSorted {
get {
if (_users == null) return [];
if (users == null) return [];
var query = _searchQuery?.Trim().ToLowerInvariant() ?? "";
var filtered = _users
var query = searchQuery?.Trim().ToLowerInvariant() ?? "";
var filtered = users
.Where(u => string.IsNullOrEmpty(query)
|| (u.Username?.ToLowerInvariant().Contains(query) ?? false)
|| (u.Email?.ToLowerInvariant().Contains(query) ?? false));
return (_sortColumn, _sortAsc) switch {
return (sortColumn, sortAsc) switch {
(nameof(UserInfoDto.Username), true) => filtered.OrderBy(u => u.Username),
(nameof(UserInfoDto.Username), false) => filtered.OrderByDescending(u => u.Username),
(nameof(UserInfoDto.Email), true) => filtered.OrderBy(u => u.Email),
@@ -400,107 +405,107 @@
}
private async Task LoadUsers() {
_isLoading = true;
_error = null;
isLoading = true;
error = null;
StateHasChanged();
_users = await UserService.GetAllUsersAsync();
_isLoading = false;
users = await UserService.GetAllUsersAsync();
isLoading = false;
}
private void ToggleSort(string column) {
if (_sortColumn == column) {
_sortAsc = !_sortAsc;
if (sortColumn == column) {
sortAsc = !sortAsc;
} else {
_sortColumn = column;
_sortAsc = true;
sortColumn = column;
sortAsc = true;
}
}
private MarkupString SortArrow(string column) {
if (_sortColumn != column) return (MarkupString)"";
return (MarkupString)(_sortAsc
if (sortColumn != column) return (MarkupString)"";
return (MarkupString)(sortAsc
? "<i class=\"bi bi-sort-up\"></i>"
: "<i class=\"bi bi-sort-down\"></i>");
}
private void OpenCreateModal() {
_createModel.Username = "";
_createModel.Email = "";
_createModel.Password = "";
_createModel.AccessLevel = EAccessLevel.User;
_createError = null;
_showCreateModal = true;
createModel.Username = "";
createModel.Email = "";
createModel.Password = "";
createModel.AccessLevel = EAccessLevel.User;
createError = null;
showCreateModal = true;
}
private async Task CloseCreateModal() {
_showCreateModal = false;
showCreateModal = false;
}
private async Task CreateUser() {
_createError = null;
var result = await UserService.CreateUserAsync(_createModel);
createError = null;
var result = await UserService.CreateUserAsync(createModel);
if (result != null) {
_showCreateModal = false;
_successMessage = $"User '{_createModel.Username}' created.";
showCreateModal = false;
successMessage = $"User '{createModel.Username}' created.";
await LoadUsers();
} else {
_createError = "Failed to create user. Email or username may already exist.";
createError = "Failed to create user. Email or username may already exist.";
}
}
private async Task OpenEditModal(UserInfoDto user) {
_editUser = user;
_editUsername = user.Username ?? "";
_editEmail = user.Email ?? "";
_editAccessLevel = user.AccessLevel;
_editError = null;
_maintainedPersonIds = [];
_assignedPersons = [];
_personSearchResults = null;
_personSearchQuery = "";
editUser = user;
editUsername = user.Username ?? "";
editEmail = user.Email ?? "";
editAccessLevel = user.AccessLevel;
editError = null;
maintainedPersonIds = [];
assignedPersons = [];
personSearchResults = null;
personSearchQuery = "";
var fullUser = await UserService.GetUserByIdAsync(user.Id);
if (fullUser?.MaintainedPersonIds != null) {
_maintainedPersonIds = [.. fullUser.MaintainedPersonIds];
foreach (var pid in _maintainedPersonIds) {
maintainedPersonIds = [.. fullUser.MaintainedPersonIds];
foreach (var pid in maintainedPersonIds) {
var person = await PersonService.GetByIdAsync(pid);
if (person != null) {
_assignedPersons.Add(new PersonPreviewDto { Id = person.Id, Name = person.Name });
assignedPersons.Add(new PersonPreviewDto { Id = person.Id, Name = person.Name });
}
}
}
}
private async Task CloseEditModal() {
_editUser = null;
_editError = null;
editUser = null;
editError = null;
}
private async Task SubmitEditUser() {
if (_editUser == null) return;
_editError = null;
if (editUser == null) return;
editError = null;
var isSelf = _editUser.Id == LoginService.LoggedUser?.Id;
if (isSelf && _editAccessLevel != EAccessLevel.Admin) {
_editError = "Cannot demote yourself from Admin.";
var isSelf = editUser.Id == LoginService.LoggedUser?.Id;
if (isSelf && editAccessLevel != EAccessLevel.Admin) {
editError = "Cannot demote yourself from Admin.";
return;
}
var dto = new UserUpdateDto {
Id = _editUser.Id,
Username = _editUsername,
Email = _editEmail,
AccessLevel = _editAccessLevel,
MaintainedPersonIds = _maintainedPersonIds.ToList()
Id = editUser.Id,
Username = editUsername,
Email = editEmail,
AccessLevel = editAccessLevel,
MaintainedPersonIds = maintainedPersonIds.ToList()
};
var (result, error) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_successMessage = $"'{result.Username}' updated.";
successMessage = $"'{result.Username}' updated.";
await CloseEditModal();
await LoadUsers();
} else {
_editError = error ?? "Failed to update user.";
editError = error ?? "Failed to update user.";
}
}
@@ -508,78 +513,93 @@
var dto = new UserUpdateDto { Id = user.Id, IsBanned = ban };
var (result, _) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_successMessage = ban
successMessage = ban
? $"'{user.Username}' has been banned."
: $"'{user.Username}' has been unbanned.";
var idx = _users?.FindIndex(u => u.Id == user.Id) ?? -1;
if (idx >= 0) _users![idx] = result;
if (_editUser?.Id == user.Id) _editUser = result;
var idx = users?.FindIndex(u => u.Id == user.Id) ?? -1;
if (idx >= 0) users![idx] = result;
if (editUser?.Id == user.Id) editUser = result;
} else {
_error = $"Failed to {(ban ? "ban" : "unban")} user.";
error = $"Failed to {(ban ? "ban" : "unban")} user.";
}
}
private async Task ToggleDeleted(UserInfoDto user, bool deleted) {
var dto = new UserUpdateDto { Id = user.Id, IsDeleted = deleted };
var (result, _) = await UserService.UpdateUserAsync(dto);
if (result != null) {
successMessage = deleted
? $"'{user.Username}' has been deleted."
: $"'{user.Username}' has been restored.";
var idx = users?.FindIndex(u => u.Id == user.Id) ?? -1;
if (idx >= 0) users![idx] = result;
if (editUser?.Id == user.Id) editUser = result;
} else {
error = $"Failed to {(deleted ? "delete" : "restore")} user.";
}
}
private async Task ConfirmDelete() {
if (_editUser == null) return;
_showDeleteConfirm = true;
if (editUser == null) return;
showDeleteConfirm = true;
}
async Task ExecuteDelete() {
if (_editUser == null) return;
_showDeleteConfirm = false;
var success = await UserService.DeleteUserAsync(_editUser.Id);
if (editUser == null) return;
showDeleteConfirm = false;
var success = await UserService.DeleteUserAsync(editUser.Id);
if (success) {
_successMessage = $"User '{_editUser.Username}' deleted.";
successMessage = $"User '{editUser.Username}' deleted.";
await CloseEditModal();
await LoadUsers();
} else {
_editError = "Failed to delete user.";
editError = "Failed to delete user.";
}
}
private async Task ClosePasswordModal() {
_passwordUser = null;
_adminNewPassword = "";
_adminConfirmPassword = "";
_passwordError = null;
passwordUser = null;
adminNewPassword = "";
adminConfirmPassword = "";
passwordError = null;
}
private async Task SubmitAdminPasswordChange() {
if (_passwordUser == null) return;
_passwordError = null;
if (passwordUser == null) return;
passwordError = null;
var dto = new UserUpdateDto {
Id = _passwordUser.Id,
Password = _adminNewPassword
Id = passwordUser.Id,
Password = adminNewPassword
};
var (result, error) = await UserService.UpdateUserAsync(dto);
if (result != null) {
_successMessage = $"Password changed for '{_passwordUser.Username}'.";
successMessage = $"Password changed for '{passwordUser.Username}'.";
await ClosePasswordModal();
} else {
_passwordError = error ?? "Failed to change password.";
passwordError = error ?? "Failed to change password.";
}
}
private async Task HandlePersonSearchInput(ChangeEventArgs e) {
_personSearchQuery = e.Value?.ToString() ?? "";
await SearchPersons(_personSearchQuery);
personSearchQuery = e.Value?.ToString() ?? "";
await SearchPersons(personSearchQuery);
}
private async Task SearchPersons(string query) {
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
var token = _searchCts.Token;
searchCts?.Cancel();
searchCts = new CancellationTokenSource();
var token = searchCts.Token;
if (string.IsNullOrWhiteSpace(query) || query.Length < 2) {
_personSearchResults = null;
personSearchResults = null;
return;
}
try {
var results = await PersonService.GetAllAsync(search: query, pageSize: 250);
if (!token.IsCancellationRequested) {
_personSearchResults = results;
personSearchResults = results;
}
} catch (OperationCanceledException) {
// ignore
@@ -587,21 +607,21 @@
}
private void SelectPerson(PersonPreviewDto person) {
if (_maintainedPersonIds.Add(person.Id)) {
_assignedPersons.Add(person);
if (maintainedPersonIds.Add(person.Id)) {
assignedPersons.Add(person);
}
_personSearchQuery = "";
_personSearchResults = null;
personSearchQuery = "";
personSearchResults = null;
}
private void RemoveAssignedPerson(PersonPreviewDto person) {
_maintainedPersonIds.Remove(person.Id);
_assignedPersons.RemoveAll(p => p.Id == person.Id);
maintainedPersonIds.Remove(person.Id);
assignedPersons.RemoveAll(p => p.Id == person.Id);
}
void IDisposable.Dispose() {
_searchCts?.Cancel();
_searchCts?.Dispose();
searchCts?.Cancel();
searchCts?.Dispose();
}
private static string AccessLevelBadge(EAccessLevel level) => level switch {
@@ -18,7 +18,7 @@
} else if (album == null) {
<EmptyState Variant="danger" Title="Album not found">
<p>The album you are looking for does not exist or you do not have access to it.</p>
<button class="btn btn-danger" @onclick="GoBack">Back to Albums</button>
<a class="btn btn-danger" href="/albums">Back to Albums</a>
</EmptyState>
} else {
<div class="album-detail">
@@ -44,100 +44,32 @@
</div>
</div>
<div class="album-detail-actions">
<div class="btn-group btn-group-sm">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
<i class="bi bi-grid-3x2-gap"></i>
</button>
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
@onclick="SetGrid" title="Grid">
<i class="bi bi-grid-3x3"></i>
</button>
</div>
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" />
@if (loginService.CanEdit(album?.Person)) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<ResponsiveActionBar>
@if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
</button>
<button class="btn btn-sm btn-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAll">
@(allSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
} else {
<button class="btn btn-sm btn-success" @onclick="OpenAssetPicker">
<i class="bi bi-plus-circle"></i> Add Assets
</button>
<button class="btn btn-sm btn-secondary" @onclick="EnterSelectMode">
<i class="bi bi-check2-square"></i> Select
</button>
}
<button class="btn btn-sm btn-danger" @onclick="ConfirmDelete" title="Delete album">
<i class="bi bi-trash"></i>
</button>
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedImageIds.Count
</button>
</li>
<li>
<button class="dropdown-item text-warning" @onclick="UnlinkSelectedImages" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedImageIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedImageIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAll">
<i class="bi @(allSelected ? "bi-x-square" : "bi-check2-square")"></i> @(allSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="OpenAssetPicker">
<i class="bi bi-plus-circle"></i> Add Assets
</button>
</li>
<li>
<button class="dropdown-item" @onclick="EnterSelectMode">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedImages.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedImages.Count == 0)" OnClick="DeleteSelectedImages" />
@if (selectedDeletedCount > 0) {
<ActionButton Icon="bi-arrow-counterclockwise" Label="@($"Restore {selectedDeletedCount}")" Class="btn-success" MobileClass="text-success"
OnClick="RestoreSelectedImages" />
}
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete album
</button>
</li>
</ul>
</div>
<ActionButton Icon="bi-link-45deg" Label="@($"Unlink {selectedImages.Count}")" Class="btn-warning" MobileClass="text-warning"
Disabled="@(selectedImages.Count == 0)" OnClick="UnlinkSelectedImages" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
Disabled="@(selectedImages.Count == 0)" OnClick="OpenVisibilityModal" />
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="@(allSelected ? "bi-x-square" : "bi-check2-square")" Label="@(allSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAll" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<ActionButton Icon="bi-plus-circle" Label="Add Assets" Class="btn-success" OnClick="OpenAssetPicker" />
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="EnterSelectMode" />
}
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="bi-trash" Label="" MobileLabel="Delete album" Class="btn-danger" MobileClass="text-danger"
Title="Delete album" OnClick="ConfirmDelete" />
</ResponsiveActionBar>
}
</div>
</div>
@@ -151,10 +83,10 @@
var preview = album.AssetPreviews.Count > i ? album.AssetPreviews[i] : null;
if (!selectMode && preview != null && (preview.DeletedAt != null || (preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private))) continue;
var index = i;
var isSelected = selectedImageIds.Contains(assetId);
var isSelected = selectedImages.Contains(assetId);
<div class="@GetTileClass(isSelected, preview)" @key="assetId"
style="aspect-ratio:@GetAspectRatio(preview)"
@onclick="() => HandleTileClick(index, assetId)">
@onclick="(e) => HandleTileClick(index, assetId, e.ShiftKey)">
@if (preview == null || preview.HasThumbnail) {
<img src="@mediaService.ThumbUrl(assetId)"
loading="lazy" alt=""
@@ -186,17 +118,18 @@
@if (showForm && album != null) {
<AlbumForm Show="true"
EditAlbum="new AlbumPreviewDto { Id = album.Id, Name = album.Name, Person = album.Person, PersonName = album.PersonName, CoverAssetId = album.CoverAssetId }"
AlbumAssetIds="album.Images"
AssetIds="album.Images"
OnSaved="HandleFormSaved"
OnClosed="CloseForm" />
OnClose="CloseForm" />
}
@if (selectedAssetId != null && album != null) {
<ImagePreview Show="true"
AssetId="selectedAssetId.Value"
HasPreview="currentPreview?.HasPreview ?? false"
HasPrevious="selectedIndex > 0"
HasNext="selectedIndex < album.Images.Count - 1"
MimeType="currentPreview?.MimeType"
HasPrevious="CanGoPrev"
HasNext="CanGoNext"
Filename="@(currentPreview?.FileName)"
OnNavigate="NavigatePreview"
OnClose="ClosePreview">
@@ -207,28 +140,37 @@
</ImagePreview>
}
@if (showVisibilityModal) {
@if (visibility.Show) {
<VisibilityModal Show="true"
ItemCount="@selectedImageIds.Count"
ItemCount="@selectedImages.Count"
ItemLabel="asset(s)"
Visibility="@selectedVisibility"
VisibilityChanged="@((EVisibility v) => selectedVisibility = v)"
Visibility="visibility.Selection"
VisibilityChanged="@((EVisibility v) => visibility.Selection = v)"
Error="@visibility.Error"
OnApply="ApplyVisibility"
OnClose="CloseVisibilityModal" />
OnClose="visibility.Close" />
}
<ConfirmDialog Show="_showDeleteConfirm"
<ConfirmDialog Show="deleteConfirm.Show"
Title="Confirm Deletion"
Message="@_deleteConfirmMessage"
Message="@deleteConfirm.Message"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecutePendingDelete"
OnCancel="() => _showDeleteConfirm = false" />
OnConfirm="ExecuteDeletePending"
OnCancel="deleteConfirm.Cancel" />
<ConfirmDialog Show="restoreConfirm.Show"
Title="Confirm Restore"
Message="@restoreConfirm.Message"
ConfirmText="Restore"
ConfirmButtonClass="btn-success"
OnConfirm="ExecuteRestorePending"
OnCancel="restoreConfirm.Cancel" />
@if (showAssetPicker && album != null) {
<AlbumAssetPicker Show="true"
OnSelected="OnAssetsAdded"
OnClosed="() => showAssetPicker = false" />
OnClose="() => showAssetPicker = false" />
}
@code {
@@ -239,33 +181,39 @@
bool isLoading = true;
bool showForm;
bool selectMode;
bool showVisibilityModal;
bool showAssetPicker;
bool _showDeleteConfirm;
string _deleteConfirmMessage = "";
Func<Task>? _pendingDeleteAction;
EVisibility selectedVisibility = EVisibility.Public;
HashSet<Guid> selectedImageIds = [];
DeleteConfirmation deleteConfirm = new();
DeleteConfirmation restoreConfirm = new();
VisibilityApplyState visibility = new();
MultiSelect<Guid> selectedImages = new();
SelectionRange assetSelectionRange = new();
Guid? selectedAssetId;
int selectedIndex = -1;
string viewMode = "masonry";
bool _masonrySetup;
void SetMasonry() { viewMode = "masonry"; StateHasChanged(); }
void SetGrid() { viewMode = "grid"; StateHasChanged(); }
bool masonrySetup;
AlbumAssetPreviewDto? currentPreview => album?.AssetPreviews != null && selectedIndex >= 0 && selectedIndex < album.AssetPreviews.Count
? album.AssetPreviews[selectedIndex]
: null;
bool allSelected => album != null && album.Images.Count > 0 && selectedImageIds.Count == album.Images.Count;
bool allSelected => album != null && selectedImages.IsAllSelected(album.Images);
// Deleted tiles are only selectable in edit mode; this counts how many of the
// currently selected assets are soft-deleted and thus eligible for restore.
int selectedDeletedCount {
get {
if (album == null || selectedImages.Count == 0) return 0;
var count = 0;
for (var i = 0; i < album.Images.Count && i < album.AssetPreviews.Count; i++)
if (selectedImages.Contains(album.Images[i]) && album.AssetPreviews[i].DeletedAt != null)
count++;
return count;
}
}
void ToggleSelectAll() {
if (album == null) return;
if (allSelected)
selectedImageIds.Clear();
else
selectedImageIds.UnionWith(album.Images);
selectedImages.ToggleAll(album.Images);
}
string AssetStatsText {
@@ -292,11 +240,11 @@
}
protected override async Task OnAfterRenderAsync(bool firstRender) {
if (viewMode == "masonry" && !_masonrySetup && album != null && album.Images.Count > 0) {
_masonrySetup = true;
if (viewMode == "masonry" && !masonrySetup && album != null && album.Images.Count > 0) {
masonrySetup = true;
await jsRuntime.InvokeVoidAsync("masonryLayout.setup", ".album-assets-grid.masonry");
} else if (viewMode != "masonry" && _masonrySetup) {
_masonrySetup = false;
} else if (viewMode != "masonry" && masonrySetup) {
masonrySetup = false;
await jsRuntime.InvokeVoidAsync("masonryLayout.teardown", ".album-assets-grid.masonry");
}
}
@@ -310,7 +258,7 @@
selectedAssetId = null;
selectedIndex = -1;
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
album = await albumService.GetAlbumAsync(Id);
if (album != null) {
var seen = new Dictionary<Guid, int>();
@@ -349,24 +297,18 @@
navigationManager.NavigateTo("/albums");
}
async Task ConfirmDelete() {
void ConfirmDelete() {
if (album == null) return;
_deleteConfirmMessage = $"Delete album '{album.Name}'? This cannot be undone.";
_pendingDeleteAction = ExecuteDeleteAlbum;
_showDeleteConfirm = true;
deleteConfirm.Confirm($"Delete album '{album.Name}'? This cannot be undone.", ExecuteDeleteAlbum);
}
async Task ExecuteDeleteAlbum() {
if (album == null) return;
_showDeleteConfirm = false;
await albumService.DeleteAlbumAsync(album.Id);
navigationManager.NavigateTo("/albums");
}
async Task ExecutePendingDelete() {
if (_pendingDeleteAction != null)
await _pendingDeleteAction();
}
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
void OpenAssetPicker() {
showAssetPicker = true;
@@ -384,68 +326,73 @@
void EnterSelectMode() {
selectMode = true;
selectedImageIds.Clear();
selectedImages.Clear();
}
void CancelSelectMode() {
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
}
void HandleTileClick(int index, Guid assetId) {
void HandleTileClick(int index, Guid assetId, bool shift) {
if (selectMode)
ToggleImageSelection(assetId);
assetSelectionRange.Toggle(selectedImages, album?.Images ?? [], assetId, shift);
else
OpenPreview(index);
}
void ToggleImageSelection(Guid id) {
if (selectedImageIds.Contains(id))
selectedImageIds.Remove(id);
else
selectedImageIds.Add(id);
}
async Task UnlinkSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
var remaining = album.Images.Where(id => !selectedImageIds.Contains(id)).ToList();
if (album == null || selectedImages.Count == 0) return;
var remaining = album.Images.Where(id => !selectedImages.Contains(id)).ToList();
await albumService.UpdateAlbumAsync(album.Id, new AlbumUpdateDto { Assets = remaining });
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
await LoadAlbum();
}
async Task DeleteSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
_deleteConfirmMessage = $"Delete {selectedImageIds.Count} selected asset{(selectedImageIds.Count == 1 ? "" : "s")}? This cannot be undone.";
_pendingDeleteAction = ExecuteDeleteSelectedImages;
_showDeleteConfirm = true;
void DeleteSelectedImages() {
if (album == null || selectedImages.Count == 0) return;
deleteConfirm.Confirm($"Delete {selectedImages.Count} selected asset{(selectedImages.Count == 1 ? "" : "s")}? This cannot be undone.", ExecuteDeleteSelectedImages);
}
async Task ExecuteDeleteSelectedImages() {
if (album == null || selectedImageIds.Count == 0) return;
_showDeleteConfirm = false;
await assetService.BulkDeleteAssetsAsync([.. selectedImageIds]);
if (album == null || selectedImages.Count == 0) return;
await assetService.BulkDeleteAssetsAsync(selectedImages.ToList());
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
await LoadAlbum();
}
void OpenVisibilityModal() {
showVisibilityModal = true;
selectedVisibility = EVisibility.Public;
void RestoreSelectedImages() {
var ids = album?.AssetPreviews
.Where(p => p.DeletedAt != null && selectedImages.Contains(p.Id))
.Select(p => p.Id)
.ToList();
if (ids == null || ids.Count == 0) return;
restoreConfirm.Confirm(
$"Restore {ids.Count} selected asset{(ids.Count == 1 ? "" : "s")}?",
() => ExecuteRestoreSelectedImages(ids)
);
}
void CloseVisibilityModal() {
showVisibilityModal = false;
async Task ExecuteRestoreSelectedImages(List<Guid> ids) {
if (album == null) return;
await assetService.RestoreAssetsAsync(ids);
selectMode = false;
selectedImages.Clear();
await LoadAlbum();
}
async Task ExecuteRestorePending() => await restoreConfirm.ExecuteAsync();
void OpenVisibilityModal() => visibility.Open();
async Task ApplyVisibility() {
if (album == null || selectedImageIds.Count == 0) return;
await assetService.BulkUpdateAssetsAsync([.. selectedImageIds], new AssetUpdateDto { Visibility = selectedVisibility });
showVisibilityModal = false;
if (album == null || selectedImages.Count == 0) return;
await assetService.BulkUpdateAssetsAsync(selectedImages.ToList(), new AssetUpdateDto { Visibility = visibility.Selection });
visibility.MarkApplied();
selectMode = false;
selectedImageIds.Clear();
selectedImages.Clear();
await LoadAlbum();
}
@@ -481,10 +428,42 @@
selectedIndex = -1;
}
// Previewer navigation must mirror the grid: in browse mode, private/deleted assets are hidden
// (R6), so prev/next scan past them instead of landing on tiles the user cannot see.
bool CanGoPrev => HasVisibleAssetBefore(selectedIndex);
bool CanGoNext => HasVisibleAssetAfter(selectedIndex);
bool HasVisibleAssetBefore(int rawIndex) {
if (album == null) return false;
for (var i = rawIndex - 1; i >= 0; i--)
if (!IsHiddenFromBrowse(i)) return true;
return false;
}
bool HasVisibleAssetAfter(int rawIndex) {
if (album == null) return false;
for (var i = rawIndex + 1; i < album.Images.Count; i++)
if (!IsHiddenFromBrowse(i)) return true;
return false;
}
// Matches the grid's browse-mode filter (skip deleted or private previews).
bool IsHiddenFromBrowse(int rawIndex) {
var preview = album != null && rawIndex >= 0 && rawIndex < album.AssetPreviews.Count
? album.AssetPreviews[rawIndex]
: null;
return preview != null && (preview.DeletedAt != null ||
(preview.Visibility.HasValue && preview.Visibility.Value == EVisibility.Private));
}
void NavigatePreview(int direction) {
if (album == null) return;
var newIndex = selectedIndex + direction;
if (newIndex < 0 || newIndex >= album.Images.Count) return;
var newIndex = selectedIndex;
while (true) {
newIndex += direction;
if (newIndex < 0 || newIndex >= album.Images.Count) return;
if (!IsHiddenFromBrowse(newIndex)) break;
}
selectedAssetId = album.Images[newIndex];
selectedIndex = newIndex;
}
+66 -156
View File
@@ -4,12 +4,9 @@
@using Butter.Types
@implements IDisposable
@using Butter.Dtos.Asset
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject IJSRuntime jsRuntime
@inject NavigationManager navigationManager
<PageTitle>Albums</PageTitle>
@@ -24,131 +21,58 @@
SortOptions="@sortOptions">
<ActionButtons>
@if (loginService.CanEditAll) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<ResponsiveActionBar>
@if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Link to Cosplayer
</button>
<button class="btn btn-sm btn-info" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
@(albumsAllSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedAlbums.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedAlbums.Count == 0)" OnClick="DeleteSelectedAlbums" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenVisibilityModal" />
<ActionButton Icon="bi-link-45deg" Label="Link to Cosplayer" Class="btn-warning" MobileClass="btn-warning"
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenLinkCosplayerModal" />
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
Disabled="@(selectedAlbums.Count < 2)" OnClick="OpenMergeModal" />
<ActionButton Icon="@(albumsAllSelected ? "bi-x-square" : "bi-check2-square")" Label="@(albumsAllSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAllAlbums" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => selectMode = true" />
}
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
<button class="dropdown-item btn-warning" @onclick="OpenLinkCosplayerModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Link to Cosplayer
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
</li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
<li>
<button class="dropdown-item" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> New Album
</button>
</li>
</ul>
</div>
}
<div class="btn-group btn-group-sm">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
<i class="bi bi-grid-3x2-gap"></i>
</button>
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
@onclick="SetGrid" title="Grid">
<i class="bi bi-grid-3x3"></i>
</button>
</div>
@if (loginService.CanEditAll) {
<button class="btn btn-primary btn-sm d-none d-md-inline-block" @onclick="OpenCreateForm">
<i class="bi bi-plus-circle"></i> <span class="d-none d-sm-inline">New Album</span>
</button>
<li class="dropdown-divider d-md-none"></li>
<ActionButton Icon="bi-plus-circle" Label="New Album" Class="btn-primary" OnClick="OpenCreateForm" />
</ResponsiveActionBar>
}
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" />
</ActionButtons>
</SortFilterBar>
@if (showVisibilityModal) {
<VisibilityModal Show="true"
ItemCount="@selectedAlbumIds.Count"
ItemCount="@selectedAlbums.Count"
ItemLabel="album(s)"
Visibility="@selectedAlbumVisibility"
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
CascadeLabel="Also change visibility of all assets in these albums (skips deleted)"
CascadeEnabled="@cascadeToAssets"
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
Error="@visibilityError"
OnApply="ApplyAlbumVisibility"
OnClose="CloseVisibilityModal" />
}
@if (showLinkCosplayerModal) {
<LinkToCosplayerModal Show="true"
AlbumCount="@selectedAlbumIds.Count"
AlbumCount="@selectedAlbums.Count"
OnLink="@LinkSelectedAlbumsToCosplayer"
OnClose="() => showLinkCosplayerModal = false" />
}
<ConfirmDialog Show="_showDeleteConfirm"
<ConfirmDialog Show="showDeleteConfirm"
Title="Confirm Deletion"
Message="@($"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.")"
Message="@($"Delete {selectedAlbums.Count} selected album{(selectedAlbums.Count == 1 ? "" : "s")}? This cannot be undone.")"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecuteDeleteSelectedAlbums"
OnCancel="() => _showDeleteConfirm = false" />
OnCancel="() => showDeleteConfirm = false" />
@if (showMergeModal) {
<MergeModal Show="true"
@@ -164,7 +88,7 @@
SortAsc="@sortAsc"
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
SelectedIds="@selectedAlbums.Selected"
OnToggleSelection="@ToggleAlbumSelection"
LoadVersion="@loadVersion" />
@@ -172,29 +96,31 @@
<AlbumForm Show="true"
EditAlbum="editingAlbum"
OnSaved="HandleFormSaved"
OnClosed="CloseForm" />
OnClose="CloseForm" />
}
@code {
AlbumGrid? albumGridRef;
SelectionRange albumSelectionRange = new();
bool showForm;
AlbumPreviewDto? editingAlbum;
string viewMode = "masonry";
bool selectMode;
bool showVisibilityModal;
bool _showDeleteConfirm;
bool showDeleteConfirm;
bool showLinkCosplayerModal;
bool showMergeModal;
List<MergeModal.MergeItem> mergeItems = [];
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
HashSet<Guid> selectedAlbumIds = [];
bool albumsAllSelected => albumGridRef != null && selectedAlbumIds.Count == albumGridRef.GetCurrentAlbumIds().Count && selectedAlbumIds.Count > 0;
string? searchQuery;
string? visibilityError;
MultiSelect<Guid> selectedAlbums = new();
bool albumsAllSelected => albumGridRef != null && selectedAlbums.IsAllSelected(albumGridRef.GetCurrentAlbumIds());
[SupplyParameterFromQuery(Name = "search")] public string? searchQuery { get; set; }
string? sortBy;
bool sortAsc;
int loadVersion;
EventHandler<UserInfoDto?>? _loggedUserChangedHandler;
EventHandler<UserInfoDto?>? loggedUserChangedHandler;
Dictionary<string, string> sortOptions = new()
{
["Created"] = "created",
@@ -205,50 +131,29 @@
};
protected override async Task OnInitializedAsync() {
ParseSearchQuery();
await jsRuntime.InvokeVoidAsync("masonryObserver.unlockBodyScroll");
_loggedUserChangedHandler = (_, _) => StateHasChanged();
loginService.LoggedUserChanged += _loggedUserChangedHandler;
loggedUserChangedHandler = (_, _) => StateHasChanged();
loginService.LoggedUserChanged += loggedUserChangedHandler;
}
protected override void OnParametersSet() {
ParseSearchQuery();
}
void ParseSearchQuery() {
var uri = navigationManager.Uri;
var qIdx = uri.IndexOf('?');
if (qIdx < 0) return;
foreach (var part in uri[(qIdx + 1)..].Split('&'))
{
var kv = part.Split('=', 2);
if (kv.Length == 2 && kv[0] == "search")
searchQuery = Uri.UnescapeDataString(kv[1].Replace("+", " "));
}
}
void SetMasonry() => viewMode = "masonry";
void SetGrid() => viewMode = "grid";
void ToggleAlbumSelection(Guid id) {
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
else selectedAlbumIds.Add(id);
void ToggleAlbumSelection((Guid Id, bool Shift) args) {
albumSelectionRange.Toggle(selectedAlbums, albumGridRef?.GetCurrentAlbumIds() ?? [], args.Id, args.Shift);
}
void CancelSelectMode() {
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
}
async Task DeleteSelectedAlbums() {
if (selectedAlbumIds.Count == 0) return;
_showDeleteConfirm = true;
if (selectedAlbums.Count == 0) return;
showDeleteConfirm = true;
}
async Task ExecuteDeleteSelectedAlbums() {
_showDeleteConfirm = false;
await albumService.BulkDeleteAlbumsAsync(selectedAlbumIds.ToList());
selectedAlbumIds.Clear();
showDeleteConfirm = false;
await albumService.BulkDeleteAlbumsAsync(selectedAlbums.ToList());
selectedAlbums.Clear();
selectMode = false;
loadVersion++;
}
@@ -256,15 +161,13 @@
void ToggleSelectAllAlbums() {
if (albumGridRef == null) return;
var allIds = albumGridRef.GetCurrentAlbumIds();
if (albumsAllSelected)
selectedAlbumIds.Clear();
else
selectedAlbumIds.UnionWith(allIds);
selectedAlbums.ToggleAll(allIds);
}
void OpenVisibilityModal() {
showVisibilityModal = true;
selectedAlbumVisibility = EVisibility.Public;
visibilityError = null;
}
void CloseVisibilityModal() {
@@ -279,7 +182,7 @@
void OpenMergeModal() {
if (albumGridRef is null) return;
mergeItems = albumGridRef.GetCurrentAlbumPreviews()
.Where(a => selectedAlbumIds.Contains(a.Id))
.Where(a => selectedAlbums.Contains(a.Id))
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
.ToList();
showMergeModal = true;
@@ -291,29 +194,36 @@
await albumService.MergeAlbumsAsync(dto);
showMergeModal = false;
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
loadVersion++;
}
async Task LinkSelectedAlbumsToCosplayer(Guid personId) {
if (selectedAlbumIds.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Person = personId });
if (selectedAlbums.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync(selectedAlbums.ToList(), new AlbumUpdateDto { Person = personId });
showLinkCosplayerModal = false;
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
loadVersion++;
}
async Task ApplyAlbumVisibility() {
if (selectedAlbumIds.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
if (cascadeToAssets)
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
showVisibilityModal = false;
cascadeToAssets = false;
selectMode = false;
selectedAlbumIds.Clear();
loadVersion++;
if (selectedAlbums.Count == 0) return;
visibilityError = null;
try {
await albumService.BulkUpdateAlbumsAsync(
selectedAlbums.ToList(),
new AlbumUpdateDto { Visibility = selectedAlbumVisibility },
new CascadeOptions { ToAssets = cascadeToAssets }
);
showVisibilityModal = false;
cascadeToAssets = false;
selectMode = false;
selectedAlbums.Clear();
loadVersion++;
} catch (Exception) {
visibilityError = "The visibility update failed. Please try again.";
}
}
void OpenCreateForm() {
@@ -1,12 +1,10 @@
@page "/cosplayer/{Id:guid}"
@using Butter.Dtos.Album
@using Butter.Dtos.Asset
@using Butter.Dtos.Person
@using Butter.Types
@using System.Globalization
@inject PersonService personService
@inject AlbumService albumService
@inject AssetService assetService
@inject LoginService loginService
@inject MediaService mediaService
@inject NavigationManager navigationManager
@@ -102,112 +100,32 @@
SortOptions="@albumSortOptions">
<ActionButtons>
@if (person.TotalAlbums > 0) {
<div class="btn-group btn-group-sm d-none d-md-inline-flex">
<button class="btn @(viewMode == "masonry" ? "btn-primary" : "btn-secondary")"
@onclick="SetMasonry" title="Masonry">
<i class="bi bi-grid-3x2-gap"></i>
</button>
<button class="btn @(viewMode == "grid" ? "btn-primary" : "btn-secondary")"
@onclick="SetGrid" title="Grid">
<i class="bi bi-grid-3x3"></i>
</button>
</div>
<ViewModeToggle ViewMode="@viewMode" ViewModeChanged="@((string v) => viewMode = v)" Class="d-none d-md-inline-flex" />
}
@if (loginService.CanEdit(Id)) {
@* Desktop: inline buttons *@
<div class="d-none d-md-flex gap-1 align-items-center">
<button class="btn btn-success btn-sm" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
<ResponsiveActionBar>
<ActionButton Icon="bi-plus-circle" Label="Add Albums" Class="btn-success" OnClick="OpenAlbumAssigner" />
@if (person.TotalAlbums > 0) {
if (selectMode) {
<button class="btn btn-sm btn-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
</button>
<button class="btn btn-sm btn-secondary" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
<button class="btn btn-sm btn-info" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="ToggleSelectAllAlbums">
@(albumsAllSelected ? "Deselect All" : "Select All")
</button>
<button class="btn btn-sm btn-primary" @onclick="CancelSelectMode">
Done
</button>
@if (selectMode) {
<ActionButton Icon="bi-trash" Label="@($"Delete {selectedAlbums.Count}")" Class="btn-danger" MobileClass="text-danger"
Disabled="@(selectedAlbums.Count == 0)" OnClick="DeleteSelectedAlbums" />
<ActionButton Icon="bi-link-45deg" Label="@($"Unlink {selectedAlbums.Count}")" Class="btn-warning" MobileClass="text-warning"
Disabled="@(selectedAlbums.Count == 0)" OnClick="RemoveSelectedAlbums" />
<ActionButton Icon="bi-eye" Label="Visibility" Class="btn-secondary"
Disabled="@(selectedAlbums.Count == 0)" OnClick="OpenVisibilityModal" />
<ActionButton Icon="bi-arrow-right-circle" Label="Merge" Class="btn-info"
Disabled="@(selectedAlbums.Count < 2)" OnClick="OpenAlbumMergeModal" />
<ActionButton Icon="@(albumsAllSelected ? "bi-x-square" : "bi-check2-square")" Label="@(albumsAllSelected ? "Deselect All" : "Select All")"
Class="btn-outline-secondary" OnClick="ToggleSelectAllAlbums" />
<ActionButton Label="Done" Class="btn-primary" MobileIcon="bi-x-circle" OnClick="CancelSelectMode" />
} else {
<button class="btn btn-secondary btn-sm" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
<ActionButton Icon="bi-check2-square" Label="Select" Class="btn-secondary" OnClick="() => selectMode = true" />
}
<li class="dropdown-divider d-md-none"></li>
}
<button class="btn btn-danger btn-sm" @onclick="ConfirmDelete" title="Delete cosplayer">
<i class="bi bi-trash"></i>
</button>
</div>
@* Mobile: dropdown *@
<div class="btn-group btn-group-sm d-md-none">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button class="dropdown-item" @onclick="OpenAlbumAssigner">
<i class="bi bi-plus-circle"></i> Add Albums
</button>
</li>
@if (person.TotalAlbums > 0) {
@if (selectMode) {
<li>
<button class="dropdown-item text-danger" @onclick="DeleteSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-trash"></i> Delete @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item text-warning" @onclick="RemoveSelectedAlbums" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-link-45deg"></i> Unlink @selectedAlbumIds.Count
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenVisibilityModal" disabled="@(selectedAlbumIds.Count == 0)">
<i class="bi bi-eye"></i> Visibility
</button>
</li>
<li>
<button class="dropdown-item" @onclick="OpenAlbumMergeModal" disabled="@(selectedAlbumIds.Count < 2)">
<i class="bi bi-arrow-right-circle"></i> Merge
</button>
</li>
<li>
<button class="dropdown-item" @onclick="ToggleSelectAllAlbums">
<i class="bi @(albumsAllSelected ? "bi-x-square" : "bi-check2-square")"></i> @(albumsAllSelected ? "Deselect All" : "Select All")
</button>
</li>
<li>
<button class="dropdown-item" @onclick="CancelSelectMode">
<i class="bi bi-x-circle"></i> Done
</button>
</li>
} else {
<li>
<button class="dropdown-item" @onclick="() => selectMode = true">
<i class="bi bi-check2-square"></i> Select
</button>
</li>
}
<li><hr class="dropdown-divider"></li>
}
<li>
<button class="dropdown-item text-danger" @onclick="ConfirmDelete">
<i class="bi bi-trash"></i> Delete Cosplayer
</button>
</li>
</ul>
</div>
<ActionButton Icon="bi-trash" Label="" MobileLabel="Delete Cosplayer" Class="btn-danger" MobileClass="text-danger"
Title="Delete cosplayer" OnClick="ConfirmDelete" />
</ResponsiveActionBar>
}
</ActionButtons>
</SortFilterBar>
@@ -218,7 +136,7 @@
SortAsc="@albumSortAsc"
ViewMode="@viewMode"
SelectionMode="@selectMode"
SelectedIds="@selectedAlbumIds"
SelectedIds="@selectedAlbums.Selected"
OnToggleSelection="@ToggleSelectAlbum"
LoadVersion="@albumLoadVersion"
FetchAlbums="@FetchPersonAlbums"
@@ -232,7 +150,7 @@
AssetIds="allAssetIds"
MaintainerUserIds="person.MaintainerUserIds"
OnSaved="HandleFormSaved"
OnClosed="CloseForm" />
OnClose="CloseForm" />
}
<ModalFrame Title="" Show="showAlbumAssigner && person != null" OnClose="CloseAlbumAssigner">
@@ -272,13 +190,14 @@
@if (showAlbumVisibilityModal) {
<VisibilityModal Show="true"
ItemCount="@selectedAlbumIds.Count"
ItemCount="@selectedAlbums.Count"
ItemLabel="album(s)"
Visibility="@selectedAlbumVisibility"
VisibilityChanged="@((EVisibility v) => selectedAlbumVisibility = v)"
CascadeLabel="Also change visibility of all assets in these albums (skips private/deleted)"
CascadeLabel="Also change visibility of all assets in these albums (skips deleted)"
CascadeEnabled="@cascadeToAssets"
CascadeEnabledChanged="@((bool v) => cascadeToAssets = v)"
Error="@visibilityError"
OnApply="ApplyAlbumVisibility"
OnClose="CloseVisibilityModal" />
}
@@ -291,13 +210,13 @@
OnClose="() => showAlbumMergeModal = false" />
}
<ConfirmDialog Show="_showDeleteConfirm"
<ConfirmDialog Show="deleteConfirm.Show"
Title="Confirm Deletion"
Message="@_deleteConfirmMessage"
Message="@deleteConfirm.Message"
ConfirmText="Delete"
ConfirmButtonClass="btn-danger"
OnConfirm="ExecutePendingDelete"
OnCancel="() => _showDeleteConfirm = false" />
OnConfirm="ExecuteDeletePending"
OnCancel="deleteConfirm.Cancel" />
@code {
[Parameter]
@@ -311,16 +230,16 @@
bool showAlbumVisibilityModal;
bool showAlbumMergeModal;
List<MergeModal.MergeItem> albumMergeItems = [];
bool _showDeleteConfirm;
string _deleteConfirmMessage = "";
Func<Task>? _pendingDeleteAction;
DeleteConfirmation deleteConfirm = new();
bool cascadeToAssets;
EVisibility selectedAlbumVisibility = EVisibility.Public;
string? visibilityError;
string viewMode = "masonry";
bool showAlbumAssigner;
List<AlbumPreviewDto>? unassignedAlbums;
HashSet<Guid> assignAlbumIds = [];
HashSet<Guid> selectedAlbumIds = [];
MultiSelect<Guid> selectedAlbums = new();
SelectionRange albumSelectionRange = new();
List<string> bannerCoverUrls = [];
// Search & sort
@@ -378,9 +297,6 @@
await LoadPerson();
}
void SetMasonry() => viewMode = "masonry";
void SetGrid() => viewMode = "grid";
void CloseForm() => showForm = false;
async Task OpenAlbumAssigner() {
@@ -399,17 +315,17 @@
else assignAlbumIds.Remove(id);
}
bool albumsAllSelected => person?.Albums != null && person.Albums.Count > 0 && selectedAlbumIds.Count == person.Albums.Count;
bool albumsAllSelected => personAlbumGridRef != null && selectedAlbums.IsAllSelected(personAlbumGridRef.GetCurrentAlbumIds());
void CancelSelectMode() {
selectMode = false;
selectedAlbumIds.Clear();
selectedAlbums.Clear();
}
void OpenAlbumMergeModal() {
if (personAlbumGridRef is null) return;
albumMergeItems = personAlbumGridRef.GetCurrentAlbumPreviews()
.Where(a => selectedAlbumIds.Contains(a.Id))
.Where(a => selectedAlbums.Contains(a.Id))
.Select(a => new MergeModal.MergeItem { Id = a.Id, Name = a.Name, Subtitle = $"{a.AssetCount} asset{(a.AssetCount == 1 ? "" : "s")}" })
.ToList();
showAlbumMergeModal = true;
@@ -426,16 +342,14 @@
}
void ToggleSelectAllAlbums() {
if (person?.Albums == null) return;
if (albumsAllSelected)
selectedAlbumIds.Clear();
else
selectedAlbumIds.UnionWith(person.Albums.Select(a => a.Id));
if (personAlbumGridRef == null) return;
var allIds = personAlbumGridRef.GetCurrentAlbumIds();
selectedAlbums.ToggleAll(allIds);
}
void ToggleSelectAlbum(Guid id) {
if (selectedAlbumIds.Contains(id)) selectedAlbumIds.Remove(id);
else selectedAlbumIds.Add(id);
void ToggleSelectAlbum((Guid Id, bool Shift) args) {
var ordered = personAlbumGridRef?.GetCurrentAlbumIds() ?? [];
albumSelectionRange.Toggle(selectedAlbums, ordered, args.Id, args.Shift);
}
async Task AssignSelectedAlbums() {
@@ -450,24 +364,21 @@
async Task RemoveSelectedAlbums() {
if (person == null) return;
foreach (var albumId in selectedAlbumIds)
foreach (var albumId in selectedAlbums.Selected)
await albumService.UnlinkPersonAsync(albumId);
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
}
async Task DeleteSelectedAlbums() {
if (person == null || selectedAlbumIds.Count == 0) return;
_deleteConfirmMessage = $"Delete {selectedAlbumIds.Count} selected album{(selectedAlbumIds.Count == 1 ? "" : "s")}? This cannot be undone.";
_pendingDeleteAction = ExecuteDeleteSelectedAlbums;
_showDeleteConfirm = true;
void DeleteSelectedAlbums() {
if (person == null || selectedAlbums.Count == 0) return;
deleteConfirm.Confirm($"Delete {selectedAlbums.Count} selected album{(selectedAlbums.Count == 1 ? "" : "s")}? This cannot be undone.", ExecuteDeleteSelectedAlbums);
}
async Task ExecuteDeleteSelectedAlbums() {
if (person == null || selectedAlbumIds.Count == 0) return;
_showDeleteConfirm = false;
await albumService.BulkDeleteAlbumsAsync([.. selectedAlbumIds]);
if (person == null || selectedAlbums.Count == 0) return;
await albumService.BulkDeleteAlbumsAsync(selectedAlbums.ToList());
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
@@ -476,6 +387,7 @@
void OpenVisibilityModal() {
showAlbumVisibilityModal = true;
selectedAlbumVisibility = EVisibility.Public;
visibilityError = null;
}
void CloseVisibilityModal() {
@@ -484,33 +396,34 @@
}
async Task ApplyAlbumVisibility() {
if (person == null || selectedAlbumIds.Count == 0) return;
await albumService.BulkUpdateAlbumsAsync([.. selectedAlbumIds], new AlbumUpdateDto { Visibility = selectedAlbumVisibility });
if (cascadeToAssets)
await albumService.CascadeVisibilityToAssetsAsync([.. selectedAlbumIds], selectedAlbumVisibility);
showAlbumVisibilityModal = false;
cascadeToAssets = false;
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
if (person == null || selectedAlbums.Count == 0) return;
visibilityError = null;
try {
await albumService.BulkUpdateAlbumsAsync(
selectedAlbums.ToList(),
new AlbumUpdateDto { Visibility = selectedAlbumVisibility },
new CascadeOptions { ToAssets = cascadeToAssets }
);
showAlbumVisibilityModal = false;
cascadeToAssets = false;
CancelSelectMode();
await LoadPerson();
albumLoadVersion++;
} catch (Exception) {
visibilityError = "The visibility update failed. Please try again.";
}
}
async Task ConfirmDelete() {
void ConfirmDelete() {
if (person == null) return;
_deleteConfirmMessage = $"Delete cosplayer '{person.Name}'? This cannot be undone.";
_pendingDeleteAction = ExecuteDeletePerson;
_showDeleteConfirm = true;
deleteConfirm.Confirm($"Delete cosplayer '{person.Name}'? This cannot be undone.", ExecuteDeletePerson);
}
async Task ExecuteDeletePerson() {
if (person == null) return;
_showDeleteConfirm = false;
await personService.DeletePersonAsync(person.Id);
navigationManager.NavigateTo("/cosplayers");
}
async Task ExecutePendingDelete() {
if (_pendingDeleteAction != null)
await _pendingDeleteAction();
}
async Task ExecuteDeletePending() => await deleteConfirm.ExecuteAsync();
}

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