439 Commits

Author SHA1 Message Date
ec287f087c docs: add visibility rules (R1–R7) to AGENTS.md 2026-07-15 20:32:23 +02:00
758696f7c9 fix: add entity-level visibility gating for albums and persons
Adds R3/R4 visibility gates to AlbumRepository.FindVisible and
PersonRepository.FindVisible so that 404 is returned when the
requesting user lacks permission to see the entity itself.

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

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

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

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

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

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

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

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

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

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

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

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

Renamed from CosplayersMissingCover to CosplayersMissingProfile
to reflect the actual metric.

DTO, repository, and UI label updated accordingly.

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

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

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

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

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

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

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

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

- CosplayerGrid: Dictionary<Guid, PersonPreviewDto> for people list
- CosplayerDetail: Dictionary<Guid, AlbumPreviewDto> for albums
- AlbumDetail: deduplicate album.Images at load time via dict
- Home: deduplicate flatList rebuild via dict (keeps first occurrence)
- AssetPicker: Distinct() on parameter list
2026-07-12 14:47:12 +02:00
REDCODE
ee3511c09a fix: paginate PersonRepository.SearchQuery by ID before loading albums
Include + Skip/Take on a collection navigation can produce duplicate people when sorted by album count, because pagination operates on joined rows. Fix by selecting paged IDs first, then loading the full entities with Include for those IDs.
2026-07-11 20:47:44 +02:00
REDCODE
41c262d157 fix: restore Include(p => p.Albums) in PersonRepository queries
The visibility subquery doesn't load the Albums navigation, so TotalAlbums in the mapper was always 0. Added back a lightweight Include(p => p.Albums) (without Assets/SharedWith chain) to both SearchQuery and GetAllVisible.
2026-07-11 20:39:14 +02:00
REDCODE
837491fa4a fix: guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query 2026-07-11 20:36:33 +02:00
REDCODE
5a2f61d261 perf: push visibility filter into SQL for PersonRepository queries
Replace client-side visibility filtering with EF Core Any() subqueries in SearchQuery and GetAllVisible. This eliminates the cartesian product from the Include chain (Person -> Albums -> Assets -> SharedWith) and lets the database short-circuit the auth check with indexes. Adds null-forgiving operators (!) on navigation access in the query predicates.
2026-07-11 20:35:56 +02:00
851cdfa924 test: restructure WepApiTest.http with three clearly separated users
The test file now creates three distinct users at the start:
  - admin (seeded, admin/admin)
  - curator (created by admin, accessLevel=1)
  - user (registered via /api/auth/register, accessLevel=0)

Each user has its own token variable (admin_token, curator_token,
user_token) and ID variable (admin_id, curator_id, user_id).

Sections are organized: Setup → Auth → User CRUD → Person →
Album → Tag → Asset → Stats → Settings → Cleanup.

All endpoints are tested at each appropriate authorization level:
admin (full access), curator (elevated access), user (own data only),
and anonymous (public data only).
2026-07-11 19:02:58 +02:00
8dd5e7158f test: fix curator user access level from Admin (2) to Curator (1)
EAccessLevel enum: User=0, Curator=1, Admin=2. The test created the
curator user with accessLevel=2 (Admin), making curator_token an
admin token, which broke the 'Create user as curator should 403' test.
2026-07-11 19:00:53 +02:00
9e52cc2351 fix: use zero-based page numbers in AssetController to match API convention
AssetController.GetAll validated page >= 1 (one-based), inconsistent
with PersonController and AlbumController which use zero-based pages.
Adjust validation to page >= 0 and pass page + 1 to the repository
which internally uses one-based.
2026-07-11 18:55:15 +02:00
ca8119f356 fix: add [FromQuery] to TagController.GetAll parameter
Complex type PagedSearchParametersDto needs [FromQuery] for GET
requests, otherwise ASP.NET Core tries body binding and returns 415.
2026-07-11 18:54:05 +02:00
bef29f5b22 test: add user id to profile update request body
UserUpdateDto.Id is required. The test was sending only username,
causing 400 Bad Request from model binding failure.
2026-07-11 18:52:54 +02:00
e27d63cf32 fix: restrict user list to admin only
UserController.GetAll was missing [Authorize(Roles = Admin)],
allowing any authenticated user to list all users.
2026-07-11 18:51:26 +02:00
fd46faec21 test: set personId from admin search to ensure it's always populated
Rng user may see no public people, leaving personId unset. Admin
sees all people including test-created ones, so setting personId from
the admin response ensures downstream tests have a valid reference.
2026-07-11 18:50:17 +02:00
648531fcc4 test: handle empty person search results gracefully
The person search may return an empty array when the database has no
publicly visible people. Only set personId when results exist.
2026-07-11 18:48:50 +02:00
dda062d959 fix: materialize person search query before client-side visibility filter
The nested Any() through SharedWith navigation cannot be translated
to SQL by EF Core. Apply the same pattern as GetAllVisible and
AlbumController.Search: materialize server-side (search, sort,
pagination), then filter by visibility in memory.
2026-07-11 18:46:10 +02:00
02c62d0dd8 test: add anonymous search tests for persons and albums 2026-07-11 18:42:13 +02:00
2249e0fb2e fix: allow anonymous search for persons and albums
GET /api/person and GET /api/album should not require authentication.
Anonymous users are treated as EAccessLevel.User, so they only see
publicly shared content. PersonController.GetAll uses uid ?? default
instead of uid!.Value to handle null uid safely.
2026-07-11 18:42:10 +02:00
f626228c2f fix: return 401 Unauthorized when user data is null in PersonController
PersonController.GetAll used uid!.Value which throws NRE if
authService.GetUserData returns null (e.g. claims present but
user not found). Return 401 Unauthorized instead of crashing.
2026-07-11 18:39:52 +02:00
b9fd5e172c test: add endpoint tests for refresh token, albums, curator auth levels
Add HTTP endpoint tests covering:
- Refresh token endpoint (empty body, valid, auth failures)
- Album GET by ID (rng user, admin, non-existent)
- Album search (rng user, admin)
- User get all and profile update (rng user, admin)
- Stats endpoint (admin, curator, rng user 403)
- Tag search and create (admin, rng user 403)
- Asset search (rng user, anonymous)
- Curator-level CRUD permissions (person, album, stats succeed;
  user creation and settings access 403)
- Cleanup of curator test user
2026-07-11 18:33:51 +02:00
3b05061a31 fix: override Bootstrap dropdown-menu display:none
Bootstrap's .dropdown-menu sets display:none. Add display:block to
.search-dropdown-menu to make the search dropdown visible.
2026-07-11 18:15:04 +02:00
7ea72e13ac style: use Bootstrap dropdown-menu and dropdown-item classes
Match styling of the user profile dropdown in the navbar by using
Bootstrap's native .dropdown-menu and .dropdown-item classes instead
of custom classes. Keep only custom positioning, thumbnail sizing,
and the clickable header styles.
2026-07-11 18:09:57 +02:00
faa8e93d55 fix: use case-insensitive ILike search for persons and albums
Replace EF Core Contains() with EF.Functions.ILike() which
translates to PostgreSQL ILIKE for case-insensitive matching.
Searching 'alice', 'Alice', or 'ALICE' now returns the same results.
2026-07-11 18:03:17 +02:00
c5ba90202d style: add chevron-right indicator to search dropdown headers
Shows a right-pointing arrow on section headers (Cosplayers/Albums)
to visually signal they are clickable and navigate to the list page.
The arrow subtly moves right on hover for extra affordance.
2026-07-11 17:44:13 +02:00
5fba560bec fix: parse search query before await in Albums OnInitializedAsync
Blazor triggers an early render when OnInitializedAsync has a true
await (masonryObserver.unlockBodyScroll). By that point searchQuery
was still null, so the grid briefly loaded unfiltered results and a
stale response could overwrite the correct filtered results.
ParseSearchQuery must run before the await to ensure the first render
passes the correct search query to AlbumGrid.
2026-07-11 17:38:51 +02:00
f2059e0dd7 fix: move query param parsing to OnParametersSet
OnInitializedAsync only fires once per component lifetime, so
re-navigating to the same page with a different ?search= value
wouldn't re-parse. OnParametersSet fires on every parameter change,
covering both initial load and subsequent navigations.
2026-07-11 17:34:04 +02:00
e02466771a fix: pressing enter with nothing selected does nothing 2026-07-11 17:30:17 +02:00
0b8c435f7c feat: parse ?search= query param on Cosplayers and Albums pages
Headers in the search dropdown navigate to the respective list page
with the search query pre-filled via ?search= parameter. Both pages
now parse this parameter on initialization.
2026-07-11 17:28:51 +02:00
fc50aec996 feat: make search dropdown headers selectable with keyboard and mouse
Refactor SearchDropdown to use a flat item list for unified keyboard
navigation. Section headers (Cosplayers/Albums) are now clickable
links that navigate to the respective list page with the search query
as a ?search= parameter.
2026-07-11 17:28:48 +02:00
bc72243c3e style: use rounded-square thumbnails for album results
Album covers now show as rounded squares (border-radius: 0.375rem)
while cosplayer profile pics remain circular, improving visual
differentiation between result types.
2026-07-11 17:26:13 +02:00
c723a1f265 fix: close outer div and remove invalid @{} blocks in SearchDropdown
- Add missing closing </div> for search-dropdown-container
- Replace @{} variable blocks with IndexOf() calls (invalid inside @if)
- Remove leftover pi++ and ai++ incrementers
2026-07-11 17:22:08 +02:00
e772e6d2b9 feat: wire SearchDropdown into NavMenu
Replace static search input markup in NavMenu with the new
SearchDropdown typeahead component.
2026-07-11 17:20:47 +02:00
48ed84b7bd feat: add SearchDropdown typeahead component
New SearchDropdown component that provides a live typeahead dropdown
in the navbar searching both cosplayers (persons) and albums.
- Fires API calls on every keystroke (no debounce) to existing /api/person and /api/album
- Version counter discards stale responses
- Minimum 2 characters before searching
- Keyboard navigation (arrows, enter, escape)
- Blur delay for click-to-navigate
- Thumbnail support with fallback avatars/icons
- Loading and empty states
2026-07-11 17:20:34 +02:00
9cfab29e6c Merge pull request 'feat(cosplayers): add sorting, pagination, and infinite scroll' (#88) from feature/cosplayers-sorting-pagination into develop
Reviewed-on: #88
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-11 15:06:55 +00:00
c177afe078 Merge branch 'develop' into feature/cosplayers-sorting-pagination 2026-07-11 15:05:49 +00:00
69cdc30ae9 Merge pull request 'feat(albums): add sorting, filtering, and search' (#87) from feature/albums-sorting into develop
Reviewed-on: #87
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-11 15:04:45 +00:00
0d33be8637 fix(ui): move cosplayer actions into SortFilterBar, fix sortAsc always sending
- Move select/delete/new buttons into SortFilterBar's ActionButtons slot
- Fix PersonService sortAsc: always send param instead of conditional
2026-07-11 16:56:55 +02:00
9f709e5a2e fix(ui): move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation 2026-07-11 16:56:54 +02:00
12cb8aa15b feat(ui): extract CosplayerGrid to isolate grid re-renders from SortFilterBar
- Create CosplayerGrid.razor child component that manages its own data loading,
  pagination, infinite scroll via cosplayerObserver, and empty state
- Cosplayers.razor now only holds header/SortFilterBar/form state
- Filter param changes trigger reload via OnParametersSetAsync
- LoadVersion parameter allows parent to trigger grid reload
- Remove OnFilterChanged no-op handler - grid handles reloads via params
2026-07-11 16:56:54 +02:00
c96488b1eb fix(ui): keep header and toolbar visible when cosplayers search returns empty 2026-07-11 16:56:54 +02:00
51bb14862d feat(ui): convert Cosplayers page to infinite scroll with sort/search toolbar
- PersonService.GetAllAsync accepts page, pageSize, search, sortBy, sortAsc
- Add cosplayerObserver JS intersection observer for infinite scroll
- Integrate SortFilterBar component with search, sort field, direction toggle
- Default sort: name ASC (alphabetical)
- Disposes and re-attaches observer on filter changes
2026-07-11 16:56:54 +02:00
4cffc9b424 feat(api): paginate PersonController.GetAll with sort/search params
Accept PersonSearchParametersDto, forward sort/search/pagination to repository.
Returns paginated results instead of loading all people into memory.
2026-07-11 16:56:54 +02:00
4ea0afe9f6 feat(api): add SearchQuery to PersonRepository with sort/search/pagination
SQL-level search by name, sorting (name, created, albums), and pagination.
Access-level filtering for regular users pushed to SQL via subquery.
Replaces the in-memory GetAllVisible pattern.
2026-07-11 16:56:54 +02:00
de049d9d33 fix(ui): push ActionButtons to right with ms-auto wrapper 2026-07-11 16:56:51 +02:00
26a59b4ac2 fix(ui): add ActionButtons slot to SortFilterBar, move album actions into bar
- Add ActionButtons RenderFragment parameter to SortFilterBar
- Move view mode toggle, select/delete, and new album buttons into
  SortFilterBar's ActionButtons slot in Albums.razor
- Fix AlbumService sortAsc: always send param instead of conditional
2026-07-11 16:40:07 +02:00
6a3daa397b fix(ui): add margin-bottom to SortFilterBar for spacing from grid 2026-07-11 16:28:18 +02:00
7f0c51edc4 fix(ui): add height:100% to album-card-fallback so icon centers vertically in grid mode 2026-07-11 16:25:44 +02:00
46a68d9dd4 fix(ui): move album grid CSS to AlbumGrid.razor.css for scoped isolation 2026-07-11 16:22:46 +02:00
ff76673ee1 feat(ui): extract AlbumGrid to isolate grid re-renders from SortFilterBar
- Create AlbumGrid.razor child component that manages its own data loading,
  pagination, infinite scroll, and empty state independently
- Albums.razor now only holds header/SortFilterBar/form state — grid is a
  child component that re-renders separately when data loads
- Filter parameter changes (search, sort) trigger reload via OnParametersSetAsync
- ViewMode and SelectionMode changes only affect grid rendering, no API call
- LoadVersion parameter allows parent to trigger grid reload (e.g. after delete)
- Remove debounce from SortFilterBar — instant search restored
- Keep backspace prevention on empty search input
- Fix event handler unsubscription with stored delegate reference
2026-07-11 16:15:50 +02:00
b0b1856c01 fix(ui): refocus search input after debounce triggers reload
- Add ElementReference _searchInput bound to the input element
- Call FocusAsync() after OnFilterChanged completes via Task.Yield()
  to ensure the render completes before focus is restored
- Catch OperationCanceledException instead of TaskCanceledException
  for wider cancellation compatibility
2026-07-11 16:12:01 +02:00
9e40add0d5 fix(ui): debounce search input and prevent backspace navigation
- Debounce OnFilterChanged on search input (300ms) to prevent focus loss
- Add CancellationTokenSource to cancel stale debounce on new keystroke
- Prevent browser back navigation on Backspace when search input is empty
  (via plain HTML onkeydown attribute, runs at DOM level before Blazor)
- Implement IDisposable to clean up debounce timer
2026-07-11 16:07:12 +02:00
2886c0d629 fix(ui): keep header and toolbar visible when albums search returns empty 2026-07-11 14:52:50 +02:00
ed2ce83b58 feat(ui): add SortFilterBar component and update Albums page with search/sort
- Extract reusable SortFilterBar.razor with search input, sort dropdown, direction toggle
- Add search, sort field, and sort direction to Albums page toolbar
- AlbumService.GetAlbumsAsync accepts sortBy, sortAsc, unassigned params
- Albums reset and reload on filter/sort changes
2026-07-11 13:11:16 +02:00
334451238b feat(api): pass sort and filter params through AlbumController.Search
Forward SortBy, SortAsc, and Unassigned from AlbumSearchParametersDto to repository.
Remove in-memory unassigned filter (now handled in SQL).
2026-07-11 13:11:12 +02:00
48ebbebee1 feat(api): add sorting and unassigned filter to AlbumRepository.SearchQuery
Extend SearchQuery with sortBy, sortAsc, and unassigned parameters.
Sorting applied via switch expression: name, created, updated, assets, person.
Unassigned filter pushed to SQL instead of in-memory.
2026-07-11 13:11:07 +02:00
bd924f9e23 feat(api): add sort parameters to pagination DTOs
Add SortBy (string?) and SortAsc (bool) to PagedParametersDto base class.
Create PersonSearchParametersDto extending PagedSearchParametersDto.

Closes #84
2026-07-11 13:07:41 +02:00
REDCODE
3633b418ea feat(ui): add eye toggle button to show/hide password on all password fields 2026-07-11 04:08:19 +02:00
REDCODE
46382e2aed fix(ui): prevent infinite loading loop on cosplayers page with no data
The AuthInfoChanged handler was calling StateHasChanged() during
OnInitializedAsync (fired by JwtTokenRefresher when refreshing the
token during LoadPeople). This conflicted with Blazor's initial
render cycle, leaving the component stuck in a loading state when
the result was empty.

Changes:
- Remove StateHasChanged() from AuthInfoChanged handler (token
  update only, consistent with CosplayerDetail pattern)
- Extract named event handler methods for proper unsubscribe
- Implement IDisposable to unsubscribe from singleton events
- Always assign GetAllAsync result (including empty) to people
2026-07-11 00:32:24 +02:00
REDCODE
be7dc9bb91 feat(ui): apply metallic gradient to all buttons and surfaces
- Add gradient-button mixin for color-agnostic metallic button style
- Apply to all .btn-* variants via theme-color loop
- Reset hover lift inside .btn-group / .navbar to prevent button breakout
- Add gradient-surface-deep/shallow mixins for cards, modals, dropdowns
- Apply metallic surface gradients to .card, .card-header, .modal-content,
  .dropdown-menu, .dropdown-item, .list-group-item
- Update gradient-accent to brighter stops (lighten 5% / darken 5%)
- Fix danger color contrast (#f85149 -> #d41111)
- Add --text CSS variable
- Clean up Dockerfile (remove unnecessary libc6/libstdc++6)
- Fix LactoseBaseUrl IP in docker-compose.yml
- Minor UI tweaks: badge spacing in AdminUsers, badge style in SettingRange
2026-07-11 00:23:18 +02:00
REDCODE
d010b51687 fix(ui): fix remaining float culture issues in CSS style attributes
- CosplayerDetail.razor: add InvariantCulture to @z (missed in prior fix)
- ProgressBar.razor: add InvariantCulture to seg.Width and Value in width% styles
2026-07-10 23:58:42 +02:00
REDCODE
07c0a15369 fix(ui): fix float values in the cosplayer thumbnail and cosplayer card 2026-07-10 23:36:23 +02:00
6e091299e4 Merge pull request 'Frontend restyle: SCSS foundation, ModalFrame, EmptyState, dark navy theme, gradients, polish' (#82) from feat/frontend-restyle into develop
Reviewed-on: #82
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 19:05:49 +00:00
74882904a0 style: soften accent gradient stops (5%/10% instead of 15%/30%) 2026-07-10 21:01:31 +02:00
7639c6909a feat(gradients): strengthen all gradients for more aggressive effect
- gradient-accent: 3 stops (accent -> darken 15% -> darken 30%)
- gradient-surface: opacity doubled (0.03→0.08, 0.08→0.18)
- gradient-overlay: starts earlier (transparent 20%) + darker (0.92)
- gradient-banner-empty: 3 stops with intermediate dark accent
- Body bg: accent opacity doubled (0.06→0.12) + mid stop
- btn-primary hover: 3-stop gradient, stronger lift (-2px)
- btn-primary active: full gradient instead of flat
- Nav-link hover: 3-stop radial glow, stronger opacity
- ImagePreview nav: stronger bg + shadow
- CosplayerDetail banner overlay: darker (0.5→0.85)
2026-07-10 20:57:44 +02:00
b58066d563 feat(cropper): circle handles with accent color, black border, drop shadow 2026-07-10 20:53:07 +02:00
14df8f1e8d fix(cropper): add pointer-events:auto on handles, fix cursors
- .cropper-square has pointer-events:none, so handles need
  pointer-events:auto to be interactive
- Restore per-corner cursor variants (nw-resize, ne-resize, etc.)
2026-07-10 20:52:02 +02:00
188e4568da fix(cropper): restore missing cropper CSS styles
- Cropper-viewport, overlay, square, circle, and handle CSS were in the
  old MilkyShot.css which was deleted during SCSS migration
- Restore all cropper styles in _custom.scss
2026-07-10 20:49:47 +02:00
5006d81d00 fix(cropper): revert ProfileCropper to inline modal
- ProfileCropper needs direct DOM access for JS cropper init, but
  ModalFrame's render cycle delays child content rendering
- Revert to inline modal markup with @if (Show && AssetId.HasValue)
- Add global modal styles to _custom.scss for inline modal support
- ModalFrame continues using its own scoped styles in ModalFrame.razor.css
2026-07-10 20:46:26 +02:00
97e5404069 fix(ui): page bg radial gradient, navbar radial glow, favicon cache
- Body: radial gradient (ellipse top center) instead of linear — spreads
  naturally across the viewport instead of pooling at the top
- Navbar: radial-gradient on hover instead of rounded box — blends into
  the navbar background
- Favicon: add ?v=2 cache-busting query param to force re-download
2026-07-10 20:40:23 +02:00
aa9aca8f6f fix(favicon): render MilkyShot logo as PNG favicon
- Replace default Blazor favicon.png with rendered logo via rsvg-convert
- White logo on transparent background, 32x32
2026-07-10 20:34:55 +02:00
dddc4baa5d feat(ui): replace default favicon with MilkyShot logo SVG 2026-07-10 20:33:46 +02:00
3d2c14ef41 fix(ui): modal icon order, fill all outline buttons
- ModalFrame: move HeaderActions before title so icons appear left of text
- Replace all btn-outline-* with btn-* (secondary/primary/danger/success/
  warning/light/info) across all .razor components for filled buttons
2026-07-10 20:33:08 +02:00
628cfea773 feat(ui): page background gradient, status/strength CSS vars, nav/image polish
- Page body: subtle accent-to-dark gradient (180deg, rgba accent
  4% -> body-bg 30%)
- Status dot colors: extracted to --status-* CSS vars using theme
  colors (accent for running, green for completed, etc.)
- PasswordField: strength colors to --strength-* vars, track to
  --strength-track
- JobRow: inline hex colors replaced with var(--status-*)
- NavMenu: gradient background on nav-link hover + rounded pills
- ImagePreview: gradient nav background + shadow on hover
2026-07-10 20:31:05 +02:00
d2d1fc7e50 feat(ui): aggressive gradients on navbar, buttons, modal headers
- btn-primary: gradient-accent background with hover elevation
- Navbar: bg-gradient-surface for subtle shimmer overlay
- Modal header: bg-gradient-surface + accent bottom border
- _gradients.scss preset classes compiled into output
2026-07-10 20:25:18 +02:00
27f89f3820 feat(ui): CosplayerDetail banner empty gradient uses accent theme
- Add --gradient-banner-empty CSS var (accent → dark navy)
- Replace hardcoded #1a1a2e/#16213e/#0f3460 gradient
2026-07-10 20:23:21 +02:00
ecac0cdc2e refactor(jobs): use darken() for phash and create-albums hover/active too
All 8 job types now derive hover/active via darken() — consistent behavior
2026-07-10 20:19:09 +02:00
5fd338906c fix(jobs): add hover/active variants for all job types via darken()
- -*-hover and -*-active now derive from base color using
  darken(, 15%) / darken(25%) for consistent button behavior
- Manual values kept for phash and create-albums (they were already correct)
2026-07-10 20:18:20 +02:00
88476e0409 feat(jobs): extract all job-type colors to configurable SCSS variables
- Add -scan, -meta, -thumb, -preview, -persons,
  -integrity to _variables.scss (alongside existing -phash
  and -create-albums)
- Emit all as --job-* CSS custom properties in :root
- Replace Bootstrap btn-info/primary/success/warning/light classes
  with dedicated .btn-job-* classes in Jobs.razor.css
- Update Jobs.razor desktop buttons + dropdown icons to use --job-* vars
- Update JobRow.razor MarkupString icons from Bootstrap text-* classes
  to --job-* CSS vars
- All job colors are now adjustable from _variables.scss in one place
2026-07-10 20:16:32 +02:00
7848923445 feat(ui): CSS variables for .razor.css, job type colors, interactive states, gradients
- Add :root custom properties (--surface-bg, --accent, --shadow-*,
  --gradient-overlay, --selection-outline, --job-*) to _custom.scss
- Replace hardcoded #1a1a1a/var(--bs-dark) fallbacks with var(--surface-bg)
- Replace var(--bs-secondary, #666/555) with var(--text-muted)
- Replace linear-gradient overlays with var(--gradient-overlay)
- Replace rgba selection outlines with var(--accent)
- Extract job-type colors (-phash, -create-albums) to _variables.scss
  and emit as --job-* CSS vars for Jobs.razor.css consumption
- NavMenu: accent hover underline animation, accent avatar bg
- Card hover: translateY(-2px) + box-shadow elevation
- ImagePreview: accent color on nav hover
2026-07-10 20:06:09 +02:00
3b51a35e2c refactor(ui): ModalFrame + EmptyState components, remove duplicate CSS, remove main border
- ModalFrame shared component replaces 8+ inline modal implementations
  (AdminUsers ×3, User ×2, CosplayerDetail, AlbumForm, PersonForm,
   AssetPicker, ProfileCropper) — all now use <ModalFrame> with
   Title/Body/Footer parameters
- ModalFrame.razor.css houses modal styles once — deleted from
  AdminUsers.razor.css, User.razor.css, AssetPicker.razor.css
- ModalFrame handles body scroll lock/unlock via JS interop
- EmptyState component replaces 7 empty-state/not-found patterns
- Removed duplicate .sortable class from AdminUsers.razor.css and Jobs.razor.css
  (now in _custom.scss as shared utility)
- AGENTS.md: fix orphaned SCSS ref, add SCSS docs
- .gitignore: ignore *.css.map
2026-07-10 19:58:03 +02:00
7686f04023 feat(css): SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
- Add AspNetCore.SassCompiler v1.101.0 (MSBuild-only, no hosted service)
- Vendor Bootstrap 5.3.7 SCSS source into Styles/bootstrap/
- Create _variables.scss with adjustable accent, dark navy palette
- Create _gradients.scss with aggressive gradient mixins
- Create _custom.scss (scrollbar, validation, shared sortable, fonts)
- Wire up sasscompiler.json for Debug expanded / Release compressed
- Replace Helvetica with Inter (Google Fonts CDN)
- Delete all precompiled Bootstrap CSS (lib/css/*) - keep JS
- Delete MilkyShot.css, Milky.styles.css, app.css (now SCSS-compiled)
- Update MilkStream.csproj - remove CSS None entries, keep JS entries
- 0 errors, 0 warnings build
2026-07-10 19:38:29 +02:00
31d917c56e fix: remove soft-delete from Person model
Person had a DeletedAt property and all queries filtered by it, but the
controller was hard-deleting via context.People.Remove(). This removes
the DeletedAt property, drops the column via migration, and cleans up
all stale DeletedAt filters in queries.
2026-07-10 18:49:59 +02:00
bc71887726 Merge pull request 'fix: batch-processing subjob status reporting + redesign jobs page UI (#76)' (#77) from fix/preview-subjob-status-reporting into develop
Reviewed-on: #77
Reviewed-by: Fastwind <fastwind@noreply.localhost>
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 15:48:54 +00:00
89c91b2291 refactor: replace tuple + custom header with ChildrenResponse DTO
- Add ChildrenResponse DTO (Children + Since) in Butter.Dtos.Jobs
- JobManager.GetChildren returns List<JobStatusDto> only (no tuple),
  active children filtered via LINQ over activeJobs.Values
- JobsController generates Since timestamp, returns ChildrenResponse
  instead of setting x-delta-timestamp header
- JobsService deserializes ChildrenResponse instead of parsing headers
2026-07-10 17:40:28 +02:00
4a6bc2f8aa Merge branch 'develop' into fix/preview-subjob-status-reporting 2026-07-10 17:37:34 +02:00
ac5b75c0d6 fix: suppress nullable warnings on Include chain in GetAllVisible 2026-07-10 17:36:52 +02:00
7e1a2a7ed6 Merge branch 'develop' into fix/preview-subjob-status-reporting 2026-07-10 15:36:11 +00:00
e98aa6167e fix: translate GetAllVisible to client-side eval for SharedWith navigation
EF Core cannot translate three-level nested Any through many-to-many
navigation (Person→Albums→Assets→SharedWith). Load data with Include
chain and filter visible persons in-memory instead.
2026-07-10 17:28:13 +02:00
17ddd1c332 feat: hide cosplayers with no visible content from regular users
- IPersonRepository.GetAllVisible filters persons by asset visibility
- For User level: only returns persons that have at least one album
  with a non-deleted asset that is publicly shared, owned by the user,
  or explicitly shared with the user
- For Admin/Curator: returns all persons (unchanged behavior)
- PersonController.GetAll extracts user data and uses GetAllVisible
2026-07-10 17:21:47 +02:00
b0b65e808d refactor: extract password strength UI into shared PasswordField component
Create PasswordField.razor in Components/Shared with:
- Two-way binding for Password, ConfirmPassword, OldPassword
- Strength bar with color-coded segments
- Requirements checklist (min length, upper/lower, number/special)
- Passwords-do-not-match validation
- Public IsValid property for parent submit-button checks
- ShowOldPassword toggle for change-password vs register flows

Register.razor and User.razor now use the shared component,
eliminating duplicated password validation logic.
2026-07-10 15:26:26 +02:00
0a99178416 feat: add password strength validation to registration page
Copy password validation UI from User.razor — strength bar,
requirements checklist, passsword match check, submit disabled
until all requirements are met.
2026-07-10 15:23:02 +02:00
91cb420743 fix: update albums logged-out message to match home/cosplayers 2026-07-10 15:21:01 +02:00
f4a62eafc2 fix: update home logged-out message to match cosplayers wording 2026-07-10 15:19:39 +02:00
dc5ac98c68 feat: return-url login redirects + keepalive + cosplayers fix
- NavigationExtensions: centralized NavigateToLogin/NavigateToLoginForce
  helpers that include returnUrl query parameter
- LoginService: SemaphoreSlim guard on Reauthenticate to prevent
  concurrent refresh races (timer + API handler)
- MainLayout: expiry-driven keepalive loop — parses JWT, schedules
  reauthentication 2 min before token expiry; starts on login,
  stops on logout/force-logout
- Login page: reads returnUrl from query via SupplyParameterFromQuery,
  navigates there (or /) on success
- All NavigateTo(/login) call sites updated to use returnUrl:
  App, MainLayout, NavMenu, Jobs, Settings, AdminUsers
- Cosplayers: fix eternal spinner when logged out (set isLoading=false),
  update message to 'No media available' with hint about public content
2026-07-10 15:16:59 +02:00
b741f331e9 revert: restore docker-compose.yml LactoseBaseUrl to 192.168.50.100 2026-07-10 13:23:00 +02:00
04a528ce55 fix: eager-fetch children for all roots including past jobs
Remove status filter in EagerFetchMissing so past (completed) roots
also get their child status icons on load.
2026-07-10 13:17:17 +02:00
ac7833a069 fix: re-render after eager child fetch so icons appear immediately 2026-07-10 13:15:02 +02:00
27eec196a0 fix: eagerly fetch children on load and hide sub-jobs placeholder
- EagerFetchMissing fetches children for active roots on parameter set
  so the summary never shows the placeholder
- ChildSummary returns null instead of "sub-jobs..." when not fetched
2026-07-10 13:12:37 +02:00
c6d8d2c746 feat: poll children for all active roots and fix segment progress bar colors
- JobTree polls children for all Level 0 active roots (not just expanded),
  so segment progress bars update on collapsed rows too
- Add PollChildren helper to deduplicate fetch logic
- OnExpanded only stops refresh when no active roots remain
- Store UtcNow as fallback when server x-delta-timestamp is missing
- Fix segment colors: share ToCssClass between single and stacked bars,
  use text-bg-* classes for proper Bootstrap 5.3 progress-bar override
- Replace status words with Bootstrap icons in child summary
  (e.g. "15 Running" → "15 🔃") for compact display
2026-07-10 13:09:56 +02:00
71ac41086c fix: pass null since on first child fetch instead of DateTime.MinValue
GetValueOrDefault on missing key returns DateTime.MinValue (year 0001),
causing every poll to send since=0001-01-01 instead of omitting it.
2026-07-10 12:56:00 +02:00
da3a2a0a4b feat: add delta merge logic to job tree components
- JobsService.GetChildren returns (Children, Since) tuple
  with x-delta-timestamp parsed from response headers
- JobTree stores per-parent _sinceTimestamps
- ChildRefreshLoop fetches deltas with since param
- MergeChildren replaces/inserts entries by ID
- Jobs.razor FetchChildren signature updated for delta
2026-07-10 12:49:47 +02:00
0203644b3e feat: implement delta-based children endpoint with ?since parameter
- JobManager.GetChildren now accepts optional 'since' parameter
- Returns only children with LastChange/ModifiedAt > since
- Includes seenIds dedup between active and past children
- Returns response timestamp for next poll
- Controller sets x-delta-timestamp header
2026-07-10 12:49:43 +02:00
dd43d069d5 feat: add ModifiedAt to JobRecord with delta query support
- Add ModifiedAt (DateTime?) to JobRecord
- Auto-set on Insert/Update in repository
- Add GetChildrenModifiedSince to interface + implementation
- EF Core migration AddModifiedAtToJobRecord
2026-07-10 12:49:40 +02:00
7dc5eff14b feat: add LastChange tracking to JobStatus
Set LastChange = UtcNow on every status/progress transition
for delta-based children sync.
2026-07-10 12:49:36 +02:00
314ac766e4 fix: metadata icon visible in job list rows 2026-07-10 12:30:36 +02:00
7456850dec fix: metadata icon in dropdown uses text-light for visibility 2026-07-10 12:29:53 +02:00
30de7d0784 style: mobile launch button cyan for better contrast 2026-07-10 12:29:36 +02:00
3a892f6f7a style: custom job color palette based on purpose, avoid red
Scan: cyan (discovery)
Metadata: gray (technical data)
Thumbnails: blue (core functionality)
Previews: green (final visual output)
PHash: purple (identity/fingerprinting)
Integrity: white (clean health check)
CreatePersons: amber (warm, people)
CreateAlbums: teal (collection, organization)

Launch buttons use filled variants (btn-*) instead of outline.
Custom btn-job-purple and btn-job-teal CSS classes added to
Jobs.razor.css with proper hover/active states.
2026-07-10 12:26:51 +02:00
b8be4f7314 style: replace badge backgrounds with colored icons + matching launch buttons
- Removed job type badge spans from desktop and mobile JobRow
- Colored job-type icons instead (text-info/secondary/primary/etc)
- Desktop launch buttons use matching btn-outline-* colors per job type
- Mobile launch dropdown changed from dropup to dropdown to avoid
  clipping into navbar
- Dropdown item icons colored per job type
2026-07-10 12:22:45 +02:00
5514bf89d9 fix: remove double-count of failed assets in master Done handler
The Interlocked.Increment(ref failedAssets) for Failed subjobs
caused double-counting: a subjob with N/12 failed assets would
add 1 (failed subjob) + 12 (individual assets) = 13, exceeding
the total asset count. Removed the Increment since subjob's
failedAssets is already the accurate per-asset count.
2026-07-10 12:17:50 +02:00
326966b774 feat(ui): redesign jobs page with tabs, search, filters, and mobile cards
- Unified Active/Past tabbed view replacing separate sections
- Search by name/message, filter by job type and status
- Status summary strip with colored counts
- Job type badges with distinct colors per EJobType
- Status labels next to status dots
- Compact mobile card layout (d-md-none)
- Tighter tree spacing with lighter indentation
- Scoped Jobs.razor.css extracted from inline styles

Refs #76
2026-07-10 12:15:55 +02:00
8ad378b649 fix: batch-processing subjobs report correct status on asset failures
Subjobs (SlaveJob) now check failedAssets after processing:
- All failed -> JobStatus.Fail()
- Some failed -> JobStatus.CompleteWithErrors()
- None failed -> JobStatus.Complete()

Also fixed race condition in master Done handler:
- Replaced non-atomic failedAssets += sub.failedAssets
- with Interlocked.Add(ref failedAssets, sub.failedAssets)

Affects: PreviewJob, ThumbnailJob, MetadataJob, PHashJob

Refs #76
2026-07-10 12:05:45 +02:00
41f977d262 Merge pull request 'feat: add album select/edit mode with bulk deletion' (#75) from feature/albums-edit-mode into develop
Reviewed-on: #75
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 09:56:56 +00:00
5b04f39bfe feat(ui): add album select/edit mode with bulk deletion 2026-07-10 11:50:39 +02:00
71dba46565 feat(api): add BulkDeleteAlbumsAsync for bulk album deletion 2026-07-10 11:50:36 +02:00
c16749bdf6 Merge pull request 'Album detail page — multi-select/edit mode with cosplayer link (#63)' (#74) from feature/album-detail-edit-mode into develop
Reviewed-on: #74
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 09:43:29 +00:00
1d107d2de6 chore: revert docker-compose.yml to match develop 2026-07-10 11:42:50 +02:00
5b445cbe89 fix(mobile): keep back button inline with title, collapse admin actions into dropdown 2026-07-10 11:40:18 +02:00
3482f5adde style: cosplayer link dims on hover instead of blue/underline 2026-07-10 11:33:46 +02:00
3dd73d3a63 style: borderless edit pencil with hover reveal on header 2026-07-10 11:31:41 +02:00
3b4a06709a refactor: center-align header, album name on top, cosplayer smaller below 2026-07-10 11:29:37 +02:00
5ffb114532 fix: use bold (700) instead of semi-bold for reliable font rendering 2026-07-10 11:22:33 +02:00
590aef2ece style: consistent inline typography for album title and cosplayer name with dash separator 2026-07-10 11:22:08 +02:00
e3a2a78b21 style: match cosplayer link font size to h3, remove icon, keep regular weight 2026-07-10 11:19:23 +02:00
d0bf3b7ec7 refactor: move cosplayer link to same line as album name 2026-07-10 11:15:46 +02:00
2af3995ae7 style: style cosplayer link with inherited color and person icon 2026-07-10 11:15:24 +02:00
02bdba3891 feat(ui): album detail multi-select edit mode, cosplayer link, move edit button (#63)
- Replace non-functional cosplayer badge with clickable link to cosplayer page
- Move Edit button next to album title in info section
- Add select mode: select/deselect image tiles with check overlays
- Add bulk removal of selected assets (unlink from album)
- Add Select / Done / Remove N action buttons replacing Edit in actions area
- Add scoped CSS for cosplayer link style and tile selection overlays
2026-07-10 11:13:15 +02:00
8ba1c9b6b2 Merge pull request 'User profile & admin users page redesign (#72)' (#73) from feature/user-pages-redesign into develop
Reviewed-on: #73
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 09:03:47 +00:00
e188cc35e3 feat(ui): show avatar initial in navbar instead of generic icon 2026-07-10 10:53:54 +02:00
75cbe2717f fix: default sort users table by role descending (admin on top) 2026-07-10 10:52:56 +02:00
4c63e80b6c feat(ui): rewrite admin users page with sortable table, search, and modal-based editing 2026-07-10 10:50:34 +02:00
354198c413 feat(ui): redesign user profile page with avatar initials and edit profile modal 2026-07-10 10:50:30 +02:00
56c3f9c8e1 Merge pull request 'Add password change field on user's own page (#46)' (#71) from feature/password-change-self-service into develop
Reviewed-on: #71
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-10 08:42:59 +00:00
b59c3b68ec fix: wire profile icon in navbar to user profile page 2026-07-10 10:37:35 +02:00
47eef8aad0 fix: center profile card on page 2026-07-10 10:33:55 +02:00
d4e0af2c29 refactor(ui): complete user profile page redesign with card layout 2026-07-10 10:32:18 +02:00
9e4b0ecb27 fix: auto-close modal on password change success, add page-level success message 2026-07-10 10:29:07 +02:00
64220d0818 fix: propagate server-side error messages from UpdateUserAsync to UI 2026-07-10 10:23:27 +02:00
55af7dccec refactor(ui): replace inline password form with modal 2026-07-10 10:22:39 +02:00
3bddd4385d feat: add server-side password policy validation (admin bypass) 2026-07-10 10:22:36 +02:00
d8927d90c2 docs: add granular commit convention to AGENTS.md 2026-07-10 10:15:25 +02:00
afad073d0c feat: add password change UI with strength bar and validation on user profile page 2026-07-10 10:15:05 +02:00
15b3fcedf1 feat: enforce old-password verification on own password change, allow admin override 2026-07-10 10:15:03 +02:00
3f56f9afdc feat: add OldPassword field to UserUpdateDto for self-service password change 2026-07-10 10:15:01 +02:00
c541c45e9b docs: add Gitea repo owner note to AGENTS.md 2026-07-10 10:14:58 +02:00
459079d708 Merge pull request 'refactor(auth): remove redundant SendWithRefreshAsync, enforce JwtTokenRefresher as sole handler' (#69) from feature/remove-sendwithrefreshasync into develop
Reviewed-on: #69
Reviewed-by: Fastwind <fastwind@noreply.localhost>
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 23:47:08 +00:00
REDCODE
220581579b refactor(auth): remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
- Remove SendWithRefreshAsync method from AuthServiceBase (now dead code)
- Replace all 15 SendWithRefreshAsync call sites in 7 services with
  direct Client.XxxAsync calls
- Remove unused using System.Net from AuthServiceBase
- Update AGENTS.md to document JwtTokenRefresher as the sole token
  refresh mechanism
2026-07-10 01:12:31 +02:00
REDCODE
254e98b37b refactor(auth): extract AuthServiceBase, replace retry with re-auth on 401
- Move AuthServiceBase from ServiceBase.cs into its own file
- Replace FetchLoggedUserAsync retry loop with re-authentication on
  failure, then single recursive retry
- Fix override keyword order in App.razor OnInitializedAsync
- Clean up MainLayout.razor inject (unqualified type name)
2026-07-10 01:01:17 +02:00
REDCODE
652143d738 feat: allow users to update own albums and curators to update any album
- Users: changed from blanket Forbid to ownership check (can update own albums)
- Curators: changed from ownership check to full access (can update any album)
2026-07-09 23:46:02 +02:00
5a74edd29f Merge pull request 'CSS/UI fixes for cosplayers page and image previewer' (#67) from feature/CSS-UI-Fixes into develop
Reviewed-on: #67
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 21:31:52 +00:00
REDCODE
7f447077b0 feat: make album name in image previewer an anchor element for native navigation 2026-07-09 23:27:18 +02:00
REDCODE
b813c32756 fix(#62): lock body scroll when modal forms open to prevent position:fixed breakage on mobile
- PersonForm and AlbumForm now call lockBodyScroll/unlockBodyScroll via JS
  when Show state changes, preventing the body scroll context from
  interfering with position:fixed on mobile browsers
- added minimal mobile CSS: align-items:flex-start, padding-top, and
  reduced max-height:85vh on modal-content for small viewports
- safe disposal: unlocks body scroll on DisposeAsync if modal was open
2026-07-09 22:54:57 +02:00
REDCODE
cdd2e9a1ef fix(#62): use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile 2026-07-09 22:49:24 +02:00
REDCODE
64bee6c92a revert: restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident 2026-07-09 22:45:21 +02:00
REDCODE
870ebb06f8 fix(#62): move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
fix(AlbumCard): add null check for Album.Person before rendering link

The ::deep combinator in CosplayerDetail.razor.css cannot target
.modal-overlay rendered by the PersonForm child component because
Blazor's CSS isolation applies the child's scope attribute to its
root, and ::deep only strips the parent's scope — it doesn't match
elements bearing only the child's scope.
2026-07-09 22:43:27 +02:00
REDCODE
2f7cd3d7da fix: resolve UI issues #61, #62, #66
- #61: wrap standalone fallback div in cosplayer-card-img-wrap on
  Cosplayers page so placeholder icon gets proper sizing/positioning
- #62: add mobile breakpoint rule for modal-overlay so edit form
  appears centered on small screens instead of at page bottom
- #66: make cosplayer name in ImagePreview, AlbumDetail, AlbumCard,
  and Home asset tiles a clickable link to /cosplayer/{id}
- add CosplayerIds to AssetPreviewDto and populate via mapper for
  linked navigation from the home page image previewer
2026-07-09 22:36:07 +02:00
18ddcf681f Merge pull request 'feat: migrate to UTC timestamps — remove legacy Npgsql behavior' (#65) from feature/utctimezone into develop
Reviewed-on: #65
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 19:57:43 +00:00
MrFastwind
ede759b50b feat: migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
- Remove EnableLegacyTimestampBehavior switch from Program.cs
- Add ConfigureConventions to DbContext pinning DateTime → timestamptz
- Add migration to convert all 17 DateTime columns across 5 tables
  (Albums, Assets, Users, People, JobRecords) with safe AT TIME ZONE 'UTC'
- Add Roslyn analyzer (MS001-MS004) enforcing UTC-only DateTime at compile time
  with code fix provider for auto-replacement
- Fix pre-existing DateTime.Now in AuthController.cs:119
- Add AllowMissingPrunePackageData to work around .NET 10 SDK issue
2026-07-09 21:43:46 +02:00
9f74bc91a9 chore: Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts 2026-07-09 18:46:16 +02:00
8922dbcc57 Merge branch 'fix/mobile-ui-responsive-fixes' into develop
Closes #49

Integrates responsive mobile UI fixes on top of the cosplayers pages
(#55) without conflicts. All 19 files from PR #49 merged cleanly; the
two overlapping files (NavMenu.razor, Albums.razor) were auto-merged
correctly with the cosplayers features preserved.
2026-07-09 18:34:02 +02:00
095a2bdce0 Merge pull request 'feat(ui): cosplayers pages — browse and discover cosplayers' (#55) from feature/cosplayers-pages into develop
Reviewed-on: #55
Reviewed-by: Samuele Lorefice <aironenerowork@gmail.com>
2026-07-09 16:00:02 +00:00
b807e4b636 feat: infinite scroll for albums on cosplayer detail page 2026-07-09 17:53:42 +02:00
9c17843dec fix: cache random banner covers in field, not computed on every render 2026-07-09 17:48:12 +02:00
c1aafc4223 refactor: replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint 2026-07-09 17:42:37 +02:00
a8d06959b5 feat: person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select 2026-07-09 17:07:43 +02:00
dbbb25da32 fix: create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param 2026-07-09 16:46:18 +02:00
9f180742cd fix(test): update name assertion to match renamed person 2026-07-09 16:40:32 +02:00
e8c1a80264 test: all tests create their own data — create album returns ID, all mutations use self-created resources 2026-07-09 16:38:43 +02:00
7e16c77cb6 test: use real album from search for update test, not zero-GUID fallback 2026-07-09 16:36:01 +02:00
8f6a4396db test: add album update test without removePerson field 2026-07-09 16:34:47 +02:00
34fbbc91a0 chore(test): remove Swagger test — disabled in container environments 2026-07-09 16:32:47 +02:00
9be49ac9c6 fix(test): use response.body directly instead of JSON.parse (already parsed object) 2026-07-09 16:32:00 +02:00
c96b22fe62 fix(tests): fix pre-existing auth route tests and handle empty unassigned albums gracefully 2026-07-09 16:30:53 +02:00
7f50d3c3e2 test(api): add comprehensive http tests for all person and album endpoints 2026-07-09 16:26:11 +02:00
191cba4946 fix(pr): implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery] 2026-07-09 16:22:51 +02:00
a38f627ca4 feat(dto): add RemovePerson flag to AlbumUpdateDto for explicit person unlinking 2026-07-09 15:56:20 +02:00
f31b753182 refactor(dto): move Unassigned filter into PagedSearchParametersDto 2026-07-09 15:54:02 +02:00
1377d02520 fix: prevent album title from being overwritten when only setting Person in update DTO 2026-07-09 15:19:03 +02:00
bac1ad0afc fix(build): remove stray semicolon in onclick lambda 2026-07-09 15:16:01 +02:00
fa434b180d fix(api): allow unlinking person from album by directly assigning null PersonOwnerId 2026-07-09 15:12:34 +02:00
4d169e6303 docs: add API reuse convention to AGENTS.md — prefer optional query params over new endpoints 2026-07-09 15:10:05 +02:00
e1d7822b7c refactor(api): fold unassigned album filter into search endpoint via query param 2026-07-09 15:09:09 +02:00
cc611828b9 feat(ui): unassigned album selector in PersonForm, Add Albums button in CosplayerDetail 2026-07-09 15:07:19 +02:00
9aead51481 feat(ui): add New Cosplayer button on list page with create endpoint and form 2026-07-09 15:00:52 +02:00
67d00efb10 fix(js): resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images 2026-07-09 14:56:47 +02:00
b71fef147a fix(js): skip resize on first mousemove if mouse hasn't moved from mousedown position 2026-07-09 14:50:58 +02:00
8004dc2b43 fix(js): use correct opposite corners for TL/TR/BL resize handles 2026-07-09 14:44:35 +02:00
c9f08ea48e fix(ui): use pixel-exact transform positioning for avatar display instead of background-size formula 2026-07-09 14:43:52 +02:00
52b88a3ba4 fix(js): store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save 2026-07-09 14:32:09 +02:00
e39d753a85 fix(js): adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle 2026-07-09 14:15:17 +02:00
83025065b3 fix(ui): correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position 2026-07-09 14:08:59 +02:00
0c65e9de1b fix(css): show full image in cropper viewport (contain instead of cover) 2026-07-09 14:02:18 +02:00
1559bf081e fix(js): corner-drag resize with fixed opposite corner, proper visual rate matching 2026-07-09 13:58:07 +02:00
79305646dd fix(js): handle positions relative to square, not viewport 2026-07-09 13:53:48 +02:00
b9ca9f7b45 fix(ui): 4 corner handles on square boundary for resizing instead of single circular handle 2026-07-09 13:29:05 +02:00
5aed999671 fix(ui): random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static 2026-07-09 13:25:37 +02:00
56b80038a3 fix(ui): always render ProfileCropper in tree, use OnParametersSet to trigger JS init 2026-07-09 13:20:54 +02:00
3d3c59d58b chore: remove unused banner-cell-empty CSS class 2026-07-09 13:17:32 +02:00
76bbdbd2f4 fix(ui): remove empty filler cells from banner mosaic, let overlay cover gaps naturally 2026-07-09 13:17:22 +02:00
22b15c387b feat(ui): redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support 2026-07-09 13:15:19 +02:00
bcfb838b5e fix(js): set onload before src to handle cached images 2026-07-09 13:07:23 +02:00
63a16392d7 feat(ui): add circular profile picture cropper with drag-to-pan 2026-07-09 13:03:08 +02:00
d3ef5ff4c1 feat(backend): add ProfileCropX/Y to Person model for profile picture cropping 2026-07-09 13:01:01 +02:00
f45ef7c8b2 fix(ui): cosplayer cards now match album card style — image fills square, text overlay at bottom 2026-07-09 12:54:57 +02:00
8228cc2b1a refactor(css): remove SCSS, switch to plain CSS with Bootstrap custom properties 2026-07-09 12:51:58 +02:00
6b9234dfac refactor(css): move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS 2026-07-09 12:49:06 +02:00
f150f6d863 fix(ui): add scoped modal CSS to PersonForm for proper overlay rendering 2026-07-09 12:47:44 +02:00
7521f15b63 fix(auth): force logout on any refresh failure, redirect to login 2026-07-09 12:45:37 +02:00
88eb22d454 fix(ui): larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle 2026-07-09 12:42:25 +02:00
c1c6a4cead feat(css): add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles 2026-07-09 12:31:28 +02:00
845751fca1 fix(css): move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors 2026-07-09 12:20:28 +02:00
2dc0153b10 fix(backend): add [FromQuery] to person list endpoint to fix 415 error 2026-07-09 12:16:24 +02:00
7143af14f0 feat(client): add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs 2026-07-09 12:13:12 +02:00
51a3d0d19b feat(client): add PersonService client methods and PersonForm component 2026-07-09 12:11:43 +02:00
53b1d0fbf3 feat(client): extract AlbumCard reusable component, update Albums.razor 2026-07-09 12:10:44 +02:00
20fd6a930b test(api): add person endpoint tests to WepApiTest.http 2026-07-09 12:09:40 +02:00
8f0af8d544 feat(backend): implement person detail, update, and delete endpoints 2026-07-09 12:03:33 +02:00
16161ebfb8 feat(backend): add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto 2026-07-09 12:02:36 +02:00
ca224feafb feat(backend): eager-load ProfileAsset and Albums in PersonRepository 2026-07-09 12:01:46 +02:00
0909842c37 feat(dtos): update Person DTOs with ProfileAssetId and album previews 2026-07-09 12:01:22 +02:00
9c785423a9 feat(backend): add ProfileAssetId to Person model + migration 2026-07-09 12:00:56 +02:00
REDCODE
d1f545bb59 fix(ui): restore progress bar visibility on job rows
d-none d-sm-flex hides the progress bar but also changes its
display from inline to flex on desktop, breaking visibility.
Remove the hide-class — the progress bar is always shown.
2026-07-09 03:31:49 +02:00
REDCODE
584e24d751 fix(ui): replace inline save indicator with Bootstrap toast notification
SettingBox base now uses JS interop to call showToast() which
renders a Bootstrap Toast at the bottom-right corner. Removed
all SaveIndicator RenderFragments and IsSaving/IsSaved state
from the four child components. Toast auto-hides after 2s.
2026-07-09 02:58:01 +02:00
REDCODE
ea25eca9fa fix(ui): right-align number inputs, let them grow to fill card width
Replace w-auto with text-end+flex-grow-1 so the number input
right-aligns its value text and adapts its width on narrow
setting cards.
2026-07-09 02:55:03 +02:00
REDCODE
b8f0eea848 fix(ui): stack setting card body vertically — description above, control + indicator below
Remove d-flex justify-content-between from all card bodies.
Description always sits above the input/control row, and the save
indicator sits inline on the same row as the control. Removed
mobile-specific stacking workaround and save-indicator CSS class
since layout shift is impossible with this structure.
2026-07-09 02:44:10 +02:00
REDCODE
b417f37780 fix(ui): prevent save indicator from shifting adjacent controls
Always render the save indicator container with a fixed 5rem min-width,
only toggle the inner text/color. Previously the conditional Rendering
caused the element to appear-disappear-resize, shifting nearby inputs.
2026-07-09 02:39:39 +02:00
REDCODE
f52b684722 fix(ui): prevent layout shift from save indicator width change
Add .save-indicator CSS class with fixed min-width (4rem) and
text-align:right. Apply to all four setting box types so
'Saving...'→'Saved' text change doesn't shift adjacent
controls.
2026-07-09 02:27:00 +02:00
REDCODE
5c15f89f59 fix(ui): stack setting card description and control vertically on mobile
On ≤576px viewports, switch card body from side-by-side to
stacked layout: description text on top, input/slider
control below. Prevents text clipping and control overflow.
2026-07-09 02:26:24 +02:00
REDCODE
f2e2997988 fix(ui): reduce password input width on AdminUsers to prevent cell overflow
Tighten password inline-edit input from 80-120px to 60-100px
so it fits alongside confirm/cancel buttons in narrow
mobile table cells.
2026-07-09 02:26:04 +02:00
REDCODE
1589c7fe29 fix(ui): change AdminUsers mobile actions dropdown from dropup to dropdown
dropup opens upward and clips off-screen for the first table rows.
Use standard dropdown (downward) which works correctly for
all row positions.
2026-07-09 02:25:42 +02:00
REDCODE
5e23180c7c fix(ui): prevent JobRow horizontal overflow on mobile portrait
Hide non-essential columns (child summary, dates/progress bar,
elapsed time) on mobile using d-none d-sm-inline/d-sm-flex.
Add flex-wrap to main row for overflow safety on very
narrow viewports.
2026-07-09 02:25:24 +02:00
MrFastwind
f8eafed093 fix(milkystream): Fix sorted masonry for albums 2026-07-09 02:08:35 +02:00
MrFastwind
debd0dd4af Revert "refactor: replace custom masonry JS with Bootstrap grid + Masonry.js approach"
This reverts commit 04d425b88d.
2026-07-09 02:07:16 +02:00
REDCODE
04d425b88d refactor: replace custom masonry JS with Bootstrap grid + Masonry.js approach
Use Bootstrap's recommended pattern: .row.g-2 container with col-* grid
classes for item widths, Masonry.js with percentPosition:true for
vertical positioning. Extracted infinite scroll observers into separate
infiniteScroll.js. Removed custom column-width JS, imagesLoaded CDN,
and the old masonryObserver.js.
2026-07-09 01:51:38 +02:00
REDCODE
ca21dc3dd9 fix(frontend): removed faulty d-flex tag on main object 2026-07-09 00:57:58 +02:00
f4d4b6a75a Merge branch 'develop' into fix/mobile-ui-responsive-fixes 2026-07-08 21:33:43 +00:00
REDCODE
4ff3b24df8 Merge remote-tracking branch 'gitea/develop' into develop 2026-07-08 23:33:05 +02:00
REDCODE
1da7236d5d fix(frontend): sorts setting by alphabetical 2026-07-08 23:32:46 +02:00
MrFastwind
df14b8b227 fix (lactose): remove check from authentication endpoint
ref: #36
2026-07-08 23:04:51 +02:00
MrFastwind
15508013a3 Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	Lactose/Controllers/UserController.cs
2026-07-08 22:39:20 +02:00
MrFastwind
cb47336230 fix(Lactose): Change to use UtcNow, even if alredy working without timeline 2026-07-08 22:38:05 +02:00
76cabe058b fix(ui): collapse AdminUsers table columns and actions on mobile (#48)
- Hide Email and Created columns below md breakpoint (d-none d-md-table-cell)
- Hide Last Login below lg breakpoint (d-none d-lg-table-cell)
- Replace action btn-group with dropdown menu on mobile (d-sm-none)
- Reduces table from 7 visible columns to 4 on mobile
2026-07-08 19:51:48 +02:00
33175ffdcb fix(ui): responsive layout fixes for mobile portrait mode (closes #48)
- Settings: stack sidebar vertically on mobile, fix grid minmax 380->280px, use dvh
- AlbumDetail: add mobile breakpoints for grid, restructure header actions
- ImagePreview: switch info bar to column layout on mobile
- User profile: fix col-2/col-2 to responsive col-5/col-7 breakpoints
- AdminUsers: wrap inline editing controls with flex-wrap on mobile
- Jobs: collapse 8 action buttons into dropdown on mobile
- Albums: add flex-wrap to header, hide 'New Album' text on mobile
- NavMenu: responsive authenticated area (column on mobile, btn-group-sm)
- Empty-state messages: m-5 -> m-2 m-sm-5
- Footer: hide on mobile (d-none d-sm-flex)
- Login: remove orphaned 'Remember me' checkbox
2026-07-08 19:49:11 +02:00
54cf5eff2b feat(stats): add percentage and stale counters to completeness cards
- Show percentage of total assets on missing metadata/thumbnail/preview/phash cards
- Add stale counts for thumbnails and previews (generated at wrong resolution vs current settings)
- Add h-100 to all completeness cards for consistent row heights
2026-07-08 19:45:09 +02:00
94bba45f45 Merge branch 'feature/admin-users-page' into develop
Admin user management page (#45)
2026-07-08 19:18:29 +02:00
23ef7e3ded fix(auth): detect banned/deleted user on page load via InitializeAsync
- LoginService.InitializeAsync: after fetching LoggedUser, check IsBanned
  and DeletedAt. If banned/disabled, set ForceLogoutReason, call Logout
  to clear state, and return false
- App.razor: after InitializeAsync completes, check ForceLogoutReason
  and navigate to /login if set. This handles the timing issue where
  MainLayout isnt created yet when the event fires on page load

Refs #45
2026-07-08 19:15:49 +02:00
a84d93882d fix(auth): show ban/disabled message on login and force-logout on refresh
- AuthController: fix HTTP 100 Continue → 403 Forbidden for banned/disabled
  users in both Login and RefreshToken endpoints
- LoginService.Login: return error message from AuthResultDto on failure
  instead of a generic message
- LoginService.Reauthenticate: parse AuthResultDto on refresh failure;
  if "User is banned" or "User is disabled", trigger force logout via
  new ForceLogout event, setting ForceLogoutReason property
- MainLayout: subscribe to ForceLogout event, redirect to /login
- Login.razor: display specific error messages (ban reason, force-logout
  reason) instead of generic "Login failed"

Refs #45
2026-07-08 19:10:53 +02:00
0ec6888809 fix(admin): show inline errors instead of replacing entire page
- Moved error/success alerts inside the main content block (else branch)
  so validation errors (empty email, username, etc.) do not hide the table
- Added dismissible error alert with ClearError method
- Added clearError method to code-behind
- Fixed broken @if(_showCreateForm) guard for create form

Refs #45
2026-07-08 19:00:08 +02:00
977d2325fa feat(admin): add inline editing for username, email, and password
- Username and email columns now have pencil-to-edit with confirm/cancel
- Actions column gains a key button to change password inline
- Validation: empty username/email/password rejected with error message
- Unified CancelFieldEdit clears all edit modes at once

Refs #45
2026-07-08 18:56:52 +02:00
20975a2872 fix(ui): remove broken onfocusout handler from role edit dropdown
- onfocusout on the wrapper span was firing when focus moved to child buttons,
  cancelling the edit before the button onclick handlers ran
- Confirm/cancel now works via explicit button clicks only
- Starting a new edit on a different row implicitly cancels the previous one

Refs #45
2026-07-08 18:54:49 +02:00
61cf886174 fix(user): hash password in Create endpoint instead of storing raw
- UserController.Create was assigning userCreateDto.Password directly without
  hashing via IPasswordHasher, unlike the Update method and AuthController
- Moved duplicate email/username checks before User object instantiation

Refs #45
2026-07-08 18:53:46 +02:00
bf638ca995 fix(admin): add confirm/cancel for role change and prevent admin self-demotion
- Role dropdown now uses explicit confirm (checkmark) / cancel (X) buttons
  instead of auto-committing on selection
- Clicking outside the dropdown cancels the edit (onfocusout)
- Selecting the same role as current is a no-op
- Admins cannot demote themselves below Admin level

Refs #45
2026-07-08 18:52:27 +02:00
a599648d87 feat(admin): add user management page for admins
- Add AdminUsers.razor page at /Users with user listing, ban/unban toggle,
  access level change, delete, and create user functionality
- Extend UserService with GetAllUsersAsync, GetUserByIdAsync,
  UpdateUserAsync, DeleteUserAsync, CreateUserAsync methods
- Add Users link to NavMenu dropdown for admin users
- Fix ban/unban bug in UserController where BannedAt was only updated
  if the user had been previously banned

Closes #45
2026-07-08 18:47:01 +02:00
e5fc1a017e chore: remove debug auth logging middleware from Program.cs 2026-07-08 18:08:48 +02:00
44394daebf fix(AlbumController): exclude deleted assets from search visibility for #11
Album search for User role used asset.IsPubliclyShared and
SharedWith checks without filtering soft-deleted assets. A deleted
public asset would cause its album to appear in search results.

Added asset.DeletedAt == null checks to both filter paths.

Refs: #11
2026-07-08 17:49:08 +02:00
ac76aab168 fix(AssetController): correct search visibility filter for #11
The User-role asset search filter had two bugs:
1. DeletedAt != null was negated — shared non-deleted assets were
   excluded, while shared deleted assets passed the filter.
2. DeletedAt check was applied only to the shared leg — public
   deleted assets leaked through the filter.

Corrected to: not-deleted AND (public OR shared), matching the
logic used by GET /api/asset/{id}.

Refs: #11
2026-07-08 17:47:43 +02:00
4f56acb320 fix(controllers): tighten asset visibility and media access checks
- AlbumController GET {id}: exclude soft-deleted assets for User role
  (public/owned and shared paths) and Curator role (viewed all assets)
- AssetController GET: add explicit parentheses to DeletedAt operator
  grouping in search filter to match intent
- MediaController: deny Curators direct file access to deleted assets;
  extract CanAccessAssetDirectly helper method

Refs: #11
2026-07-08 17:44:03 +02:00
70fe586fc6 fix: force square aspect-ratio in grid mode over inline style
- Inline style aspect-ratio:W/H overrides CSS aspect-ratio:1
- !important needed to force square crop in grid tiles
2026-07-08 16:44:40 +02:00
58bd4ee766 fix: override masonry inline styles with !important for grid mode
- JS masonry sets position/width/left/top/height as inline styles on tiles
- Grid CSS uses !important to override inline styles for proper layout
- Force StateHasChanged on mode switch to ensure re-render
2026-07-08 16:36:46 +02:00
3d789d68e7 refactor: remove list view, add grid/masonry toggle to album detail
- Albums page: drop list mode, keep masonry and grid
- AlbumDetail page: add masonry/grid toggle in header
- Masonry mode uses JS masonryLayout, grid mode uses CSS grid with square tiles
2026-07-08 16:32:30 +02:00
9327a739f8 feat: view mode selector for albums page (masonry/grid/list)
- Button group in header: masonry (columns), grid (square tiles), list (rows)
- CSS-driven mode switching via album-grid.masonry/.grid/.list classes
- List mode: horizontal card with thumbnail left, info right
2026-07-08 16:19:47 +02:00
de731095cc fix: use CSS columns for albums page, keep JS masonry only for album detail
- Albums page: remove masonryLayout JS call, use column-count CSS (reliable, no ordering needed)
- AlbumDetail: keeps JS masonryLayout for left-to-right alphabetical ordering with varying heights
2026-07-08 16:18:03 +02:00
696ea613de revert: switch album detail back to masonry layout 2026-07-08 14:52:38 +02:00
9b17b6b231 fix: faulty migration that could cause lactose crash on migration apply 2026-07-08 14:47:54 +02:00
b1d5356220 feat: justified gallery layout for album detail page
- New justifiedLayout.apply JS function: uniform row heights, automatic widths
- Reads aspect-ratio from tile inline style, fills each row to container width
- AlbumDetail calls justifiedLayout in OnAfterRenderAsync
- Albums page stays on masonryLayout
2026-07-08 14:19:19 +02:00
37dad0d1d7 fix: JS-based masonry for left-to-right item flow with varying heights
- Add masonryLayout.apply JS function using shortest-column-next algorithm
- Both Albums and AlbumDetail call masonryLayout on each render
- Items flow left-to-right in alphabetical order, each keeping its own aspect-ratio height
- Remove CSS column-count in favor of absolute positioning by JS
2026-07-08 14:10:51 +02:00
c5e41f2c05 fix: unlock body scroll on navigation, reorder masonry for left-to-right flow
- Home.DisposeAsync now unlocks body scroll so navigation to other pages works
- Albums and AlbumDetail unlock body scroll on init as defense
- Reorder items with column-major index mapping so CSS columns display left-to-right
- Switch both pages back to column-count CSS for reliable masonry
- Add pageLayout.columnCount JS helper matching media query breakpoints
2026-07-08 14:05:31 +02:00
d45a915ba9 fix: use flexbox with align-items:flex-start for album detail grid
- CSS Grid forces uniform row heights, cropped landscape images next to portraits
- Flexbox with align-items:flex-start preserves each tile's own aspect-ratio height
- align-content:flex-start ensures container height grows for scrolling
2026-07-08 13:58:37 +02:00
b7d1912fc6 fix: switch album detail grid from flexbox to CSS Grid
- Flexbox was collapsing with aspect-ratio tiles, preventing scroll
- CSS Grid with repeat(N, 1fr) fills left-to-right with proper row heights
2026-07-08 13:54:20 +02:00
d51efe8b3c fix: remove content-visibility and contain-intrinsic-size from album detail tiles
- These properties overrode aspect-ratio in flexbox layout, causing all tiles to render at 200px regardless of actual dimensions
2026-07-08 13:52:56 +02:00
48a97beb74 fix: remove preventDefault from album links so they actually navigate
- @onclick:preventDefault was canceling the browser's link navigation
- @onclick:stopPropagation alone suffices to prevent tile click
2026-07-08 13:51:33 +02:00
e94406932b fix: switch album detail page to row-based flexbox masonry
- Replace column-count CSS masonry with flex-wrap layout
- Tiles flow left-to-right first, then wrap to next row
- Responsive column counts: 2/3/4/5 per row
2026-07-08 13:48:36 +02:00
4fe1fb0da2 feat: make album names clickable links in home tile hover and preview
- Add AlbumIds parallel list to AssetPreviewDto
- Populate AlbumIds in AssetsMapper
- Home tile hover: album names link to /albums/{id} with onclick:stopPropagation
- ImagePreview ChildContent: album names link to /albums/{id}
- CSS: tile-info-album and preview-info-album support <a> tags with hover underline
2026-07-08 13:48:17 +02:00
dcc8eba931 fix: scoped CSS deep selectors for preview info text and filename binding
- Use ::deep in ImagePreview.razor.css so preview-info-cosplayer/album styles pierce CSS isolation boundary into ChildContent
- Add missing @ prefix to Filename parameters so they evaluate as C# expressions instead of literal strings
2026-07-08 13:36:55 +02:00
4c90e553af feat: auto-assign album cover from first alphabetically-sorted asset
- Add one-time migration SQL to set CoverAssetId for existing albums
- AlbumController.Create auto-sets cover from first asset if not specified
- CreateAlbumsJob assigns cover images after asset assignment phase
- Stats: add AlbumsMissingCover and CosplayersMissingCover counters
2026-07-08 13:20:11 +02:00
b8f75f9529 refactor: extract ImagePreview shared component from Home and AlbumDetail
- Create ImagePreview.razor component with self-contained url generation, keyboard handling, and download
- Add Filename to AssetPreviewDto and AlbumAssetPreviewDto DTOs
- Update AssetsMapper and AlbumMapper to populate FileName
- Remove duplicate preview overlay HTML/CSS/code from Home.razor and AlbumDetail.razor
- Fix AssetDto.FileName hiding inherited member from AssetPreviewDto
2026-07-08 13:18:23 +02:00
d10e062373 feat: add albums list page and album detail page with inline preview
- Albums.razor: masonry card grid with infinite scroll, create button for Admin/Curator
- AlbumDetail.razor: asset masonry grid with inline preview overlay (prev/next, download)
- AlbumForm.razor: create/edit modal with title, person dropdown, cover image picker
- AssetPicker.razor: modal showing album assets for cover selection
- Fix NavMenu Albums link to /albums
- Add albumObserver JS for independent infinite scroll
2026-07-08 13:11:40 +02:00
3c1d2ef14b feat: add AlbumService and PersonService to WASM client
- AlbumService: CRUD operations for albums via API
- PersonService: GetAllAsync for person dropdown
- Register both as Scoped in DI
2026-07-08 13:11:31 +02:00
b58c8ebce8 fix: album controller Save calls, person list endpoint, media empty path guard
- Add missing Save() calls to AlbumController Update and Delete
- Wire CoverAssetId in AlbumController Create/Update/BulkUpdate
- Sort album assets by OriginalFilename in Get endpoint
- Implement PersonController.GetAll returning non-deleted people
- Guard MediaController GetThumb/GetPreview against empty paths (return 404)
2026-07-08 13:11:27 +02:00
d602d2d34b feat: add CoverAssetId to albums with resolution data in full DTO
- Add CoverAssetId FK and CoverAsset navigation to Album model
- Add AlbumAssetPreviewDto with resolution/thumbnail info
- Extend AlbumPreviewDto/FullDto with PersonName, CoverAssetId, AssetCount
- Add CoverAssetId to AlbumCreateDto and AlbumUpdateDto
- Update AlbumMapper to populate new fields from eager-loaded relations
- Add EF migration for CoverAssetId column
2026-07-08 13:11:20 +02:00
de938e6d1e docs: update AGENTS.md with verified infrastructure gotchas 2026-07-08 12:28:39 +02:00
949dec9bdc feat: add data completeness indicators to statistics page 2026-07-08 12:21:25 +02:00
89c4bfe13f feat: implement seed-based deterministic random ordering for home page
Replace the fixed GUID-based ordering (OrderBy(a => a.Id)) with a
client-generated seed that produces a truly different random order on
each page refresh. The seed is passed to the server and used to
deterministically shuffle all asset IDs via a hash function, ensuring
consistent pagination with no duplicates across pages within a session.

Also fix LoadPreviousPage showing a spinner when already at page 1 by
adding loadedMinPage <= 1 to the guard clause.
2026-07-08 12:10:37 +02:00
7a0b72584c fix: propagate cancellation tokens through linked child job CTS
Replace manual childJobs.ForEach(j => j.Cancel()) with linked
CancellationTokenSources. Master jobs now call LinkParentToken(token)
on each child, so when the master's CTS fires, all children (running
and queued) receive cancellation automatically via their linked token.

Cancelling a master job now:
- Immediately signals all Running children via their linked CTS
- Ensures queued children, when started by JobManager, receive an
  already-cancelled token and bail without processing
- Eliminates the collection-modification-during-ForEach race condition

Affects: PHashJob, ThumbnailJob, PreviewJob, MetadataJob,
FileSystemCrawlJob.
2026-07-08 12:00:27 +02:00
ebfab19df4 Add autorestart for services 2026-07-08 09:56:45 +02:00
77630f4e83 fix: remove scroll position correction from LoadPreviousPage for smoother scroll-up
The snapshotFirstTileTop/restoreScrollAfterPrepend dance caused a double
visual correction (content shifts down then scroll snaps back) making
scroll-up feel janky compared to scroll-down. Removing it lets new content
naturally push existing tiles down when prepended, matching the expected
scroll-up behavior where content above fills in seamlessly.
2026-07-08 03:40:45 +02:00
16619f9631 feat: add bidirectional infinite scroll (up and down)
- Replace single bottom observer with observeBottom/observeTop dual observers
- Track loadedMinPage/loadedMaxPage to know which pages are in memory
- hasMoreUp flag controls whether scrolling up should load pages
- LoadPreviousPage prepends pages at index 0 with scroll position preservation
  using snapshotFirstTileTop/restoreScrollAfterPrepend JS helpers
- Eviction balanced: scroll down evicts from top, scroll up evicts from bottom
- Both observers registered on first render, methods return early when idle
2026-07-08 03:33:51 +02:00
9829e02a38 fix: download button now fetches blob and triggers actual file download
Previously the download link just navigated cross-origin to the media URL.
Now uses JS interop to fetch the file as a blob, create an object URL,
and trigger a browser download via a hidden anchor element.
2026-07-08 03:27:18 +02:00
632ca0d3e4 feat: add full-size view and download buttons in preview overlay
- Add 'View full size' (opens original in new tab) and 'Download' buttons
- Positioned opposite the cosplayer/album names in the preview info bar
- Info bar always visible so buttons are accessible even without metadata
- Uses original media endpoint with JWT token for authenticated access
2026-07-08 03:24:37 +02:00
4d436620a5 fix: move preview info text below image instead of overlaying on bottom border 2026-07-08 03:16:57 +02:00
78da2b7207 feat: add cosplayer and album name overlays on home page
- Add hover overlay on masonry tiles showing cosplayer and album names
- Add info bar in full-screen preview overlay with same metadata
- Hide overlays entirely when no data is available (jobs not yet run)
- CSS: gradient overlay at tile bottom with smooth fade-in on hover
- CSS: matching info bar at preview bottom
2026-07-08 03:07:06 +02:00
d4a8b272f2 feat: extend AssetPreviewDto with album and cosplayer names
- Add AlbumNames and CosplayerNames fields to AssetPreviewDto
- Include Albums and PersonOwner in AssetRepository.GetAssets() query
- Populate new fields in AssetsMapper.ToAssetPreviewDto()
2026-07-08 03:06:03 +02:00
bb01f6526e fix: remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats 2026-07-08 02:58:00 +02:00
866d01c4e2 fix: rewrite TopTags query to avoid EF Core collection navigation translation error 2026-07-08 02:36:38 +02:00
2159eaec08 fix: populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
UsersMapper.ToGetUsersDto() was not copying LastLogin, BannedAt, or
UpdatedAt from the User entity to the UserInfoDto, causing the client
to always receive null for these fields. Added the missing mappings.

Also added null fallback ('Never') for LastLogin display on the User
page to prevent NullReferenceException when the value has not been set.
2026-07-08 02:32:12 +02:00
bf3e5e619b fix: add JobBatchSize to DefaultSettings.json to prevent reset on restart 2026-07-08 02:25:18 +02:00
REDCODE
458cf70081 feat: add thousands separators to job status numbers 2026-07-08 02:03:40 +02:00
REDCODE
3d81b7a97e fix: skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run 2026-07-08 01:50:39 +02:00
REDCODE
03f52cfb5b feat: add statistics page with aggregate asset/user/system stats via fast DB queries 2026-07-08 01:49:50 +02:00
REDCODE
4b0a6df5dd fix: per-batch progress messages and counting bug in album/person jobs 2026-07-08 01:30:33 +02:00
REDCODE
744bc9e4d0 feat: improve logging and progress reporting for album/person jobs 2026-07-08 01:23:25 +02:00
REDCODE
76ba4fd74a configuration changes 2026-07-08 01:08:00 +02:00
REDCODE
132e3d2930 fix: make job cancellation work for all job states and types
Job.Cancel() now handles queued jobs (cts null) by setting Canceled status and firing events directly. ContinueWith checks t.IsCanceled to catch ThrowIfCancellationRequested patterns. cts is now disposed after job completion. CreatePersonsJob, CreateAlbumsJob, and IntegrityCheckJob now catch OperationCanceledException and set proper Canceled state. IntegrityCheckJob also gained cancellation checks between each step and within iteration loops.
2026-07-08 01:07:25 +02:00
REDCODE
7554dbac9c fix: cancel past refresh loop when poll rate is set to Off 2026-07-08 01:06:38 +02:00
REDCODE
defd97e7f0 feat: add stacked progress bar for parent jobs with sub-job breakdown
Parent jobs with fetched sub-jobs now show a multi-color stacked
progress bar instead of the single parent-progress bar. Segments
represent sub-job status distribution: completed (success),
running (primary), waiting (info), failed (danger), with-errors
(warning), and queued (primary for visibility in dark theme).

ProgressBar gets an optional Segments parameter for stacked mode.
JobRow accepts Children and computes status-weighted segments.
JobTree passes cached children to JobRow.
2026-07-08 00:20:46 +02:00
REDCODE
6c9a608ce6 fix: propagate cancellation to sub-jobs in batch processing jobs
MetadataJob, ThumbnailJob, PreviewJob, and PHashJob previously only
canceled the master job's own token, leaving already-enqueued sub-jobs
running indefinitely. Added child-job tracking with proper cancellation
propagation at both master cancellation exit points, matching the
existing pattern in FileSystemCrawlJob.
2026-07-08 00:20:40 +02:00
MrFastwind
dc1e44f2be chore(dev): fix ovveride PM Compose deploy 2026-07-07 23:27:57 +02:00
923aaa6865 feat: add CreatePersons and CreateAlbums jobs 2026-07-07 20:33:30 +02:00
53fe2cc3d5 removed unused run configs 2026-07-07 20:05:45 +02:00
970f99db54 fix: wire MaxConcurrentJobs to database setting on startup and runtime changes 2026-07-07 19:56:41 +02:00
96f431fb41 feat: add PreviewJob and upgrade thumbnails to WebP
- Add PreviewJob (WebP) — same master/sub-job pattern as ThumbnailJob
- Convert ThumbnailJob from JPEG to WebP with configurable quality
- Add ThumbnailQuality/PreviewQuality settings (configurable in settings UI)
- Add PreviewSize, ThumbnailFormat, PreviewFormat fields to Asset model
- Add format-aware repo queries for auto-regeneration of stale JPEGs
- Fix MIME types in MediaRepository/MediaController to return image/webp
- Wire PreviewJob into JobScheduler, JobMapper, JobsController
- Expose quality settings on Settings page (Jobs group, range sliders)
- Add 'Make Previews' button on Jobs page, reorder to scan/metadata/thumb/preview/phash/integrity
2026-07-07 19:41:51 +02:00
604f0e33a0 refactor: make LoginService singleton, centralize auth state and persistence
- Register LoginService as singleton in WASM (was scoped)
- Add InitializeAsync() with retry logic for user profile fetch on boot
- LoginService now owns localStorage persistence (not components)
- Login() auto-persists auth and fetches LoggedUser atomically
- Logout() clears localStorage internally
- Reauthenticate() re-fetches LoggedUser if null after token refresh
- Simplify App.razor, Login.razor, NavMenu.razor by removing direct localStorage calls
2026-07-07 19:18:50 +02:00
6d75843dcd feat: make navbar sticky during infinite scroll 2026-07-07 18:43:54 +02:00
0cb5d097d6 update gitignore 2026-07-07 18:38:48 +02:00
0549be6d42 Merge branch 'feature/masonryLayoutInHome' into develop 2026-07-07 18:37:45 +02:00
5cd16a7416 build: upgrade to .NET 10 — packages, Docker images, and Swagger fix
- Bump all NuGet packages to 10.0.x (ASP.NET, EF Core, Npgsql, Swashbuckle)
- Bump Pgvector.EntityFrameworkCore to 0.3.0, Microsoft.IdentityModel.JsonWebTokens to 8.19.1
- Pin Microsoft.OpenApi to 2.7.5, strip security scheme from SwaggerGen (API removed in v2)
- Docker images already at 10.0
2026-07-07 18:25:18 +02:00
5573be1502 Merge remote-tracking branch 'origin/feature/masonryLayoutInHome' into feature/masonryLayoutInHome 2026-07-07 18:09:04 +02:00
99ef2db822 chore: add dotnet-tools.json for ef tool 2026-07-07 18:08:08 +02:00
0502cbbd76 chore: upgrade target framework to net10.0
Update all projects from net8.0 to net10.0 and global.json SDK to 10.0.0.
Gitignore dotnet-tools.json.
2026-07-07 18:03:42 +02:00
6d29f053f4 fix: proper bidirectional SharedWith many-to-many migration
Replace broken one-to-many User.AssetId shadow FK with proper
AssetUser join table using bidirectional navigation:
  HasMany(e => e.SharedWith).WithMany(e => e.SharedAssets)

Join table columns: SharedAssetsId (FK to Assets) + SharedWithId (FK to Users).
Adds SharedAssets navigation to User model.

Generated via dotnet ef migrations add.
2026-07-07 18:03:22 +02:00
MrFastwind
75c667d1c2 Merge remote-tracking branch 'origin/feature/masonryLayoutInHome' into feature/masonryLayoutInHome 2026-07-07 18:02:51 +02:00
MrFastwind
740941790b fix(Lactose): Admin and Curators media visibility 2026-07-07 18:02:29 +02:00
5511bc56f2 fix: remove AspNetCore.SassCompiler to fix Docker startup crash
The native Dart binary at runtimes/linux-x64/src/dart is not
published into the runtime container, causing a crash when the
SassCompilerHostedService tries to start at runtime.

SCSS compilation now must be done manually with the  CLI
or by temporarily re-adding the package. Instructions added
to AGENTS.md.
2026-07-07 17:25:13 +02:00
d90a9421e9 dotnet tools 2026-07-07 17:16:04 +02:00
ba1071ba6c fix: add AssetUser many-to-many join table for SharedWith
Replace broken one-to-many User.AssetId shadow FK with proper
AssetUser join table (AssetsId + SharedWithId composite PK).
Configure explicit UsingEntity in OnModelCreating to match
database column names and prevent convention mismatches.

Migration: 20260707160000_FixSharedWithManyToMany
2026-07-07 17:13:50 +02:00
29b021bee5 feat: pass JWT token in media URLs for authenticated thumbnail/preview requests
- MediaController: accept ?token=<jwt> query param as alt auth for browser img requests,
  validating against signing key via JwtSecurityTokenHandler
- Home.razor: append current JWT token to all thumbnail/preview URLs,
  update on AuthInfoChanged, empty when not logged in
2026-07-07 15:58:56 +02:00
07fcd05031 feat: masonry gallery on homepage with infinite scroll
- Enrich AssetPreviewDto with ResolutionWidth/Height, HasThumbnail, HasPreview
- Extend GET /api/asset with ?type=&random=true for gallery queries
- Add general GetAssets repository method (type, dates, random, pagination)
- Fix LactoseAuthService.GetUserData to handle unmapped JWT claim names
- Add request-level auth debug logging middleware
- New AssetService (MilkStream.Client) for gallery API calls
- Rewrite Home.razor: CSS column-count masonry, infinite scroll via
  IntersectionObserver JS interop, inline preview overlay with
  progressive loading (preview -> thumbnail fallback), arrow/Esc nav
- Home.razor.css: responsive 2-5 column masonry, overlay styles,
  content-visibility:auto for memory optimization
- Sliding window of 10 pages (300 tiles) with loading=lazy images
- Broken thumbnail fallback to Bootstrap bi-image icon
2026-07-07 15:44:03 +02:00
490 changed files with 24582 additions and 117766 deletions

View File

@@ -22,4 +22,5 @@
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
README.md
/storageImages

7
.gitignore vendored
View File

@@ -4,4 +4,9 @@ obj/
riderModule.iml
/_ReSharper.Caches/
.idea/.idea.MilkyShots/Docker/docker-compose.generated.override.yml
storageImages/
storageImages/
dotnet-tools.json
.opencode/
*.css.map
# SixLabors license file
/Lactose/sixlabors.lic

View File

@@ -1,384 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AnalysisUIOptions">
<option name="SCOPE_TYPE" value="3" />
</component>
<component name="AutoGeneratedRunConfigurationManager">
<projectFile profileName="IIS Express">BlazorTest/BlazorTest.csproj</projectFile>
<projectFile profileName="http">BlazorTest/BlazorTest.csproj</projectFile>
<projectFile profileName="https">BlazorTest/BlazorTest.csproj</projectFile>
<projectFile profileName="IIS Express">WepApiTest/WepApiTest.csproj</projectFile>
<projectFile profileName="http">WepApiTest/WepApiTest.csproj</projectFile>
<projectFile profileName="https">WepApiTest/WepApiTest.csproj</projectFile>
</component>
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="DatabaseLocalColorSettings">
<colors>
<entry key="eab007eb-4f50-4961-9bce-5c4940a7cdff" value="Blue" />
</colors>
</component>
<component name="DpaMonitoringSettings">
<option name="autoShow" value="false" />
</component>
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="HTTP Public Environment File" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" root0="FORCE_HIGHLIGHTING" />
<setting file="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/1013dc174d8e4a313727d545767084e3df6affa8d4723b41a5f7881e96b1d/ControllerContext.cs" root0="SKIP_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/36f346b6c0454bc8a6afa7aed38119fe5bbffcc983298d9bfa4dbd5f49461f/Monitor.CoreCLR.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/3bd4df5aff92cabbc4d630be64227073db1b8539b3a1e47786b4b189d7cdb7/DbContext.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/449b441523c469ed34ff5a5e14f0bafcd8f097aa463655303dc19048fa44ac3/EntityFrameworkServiceCollectionExtensions.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/7b3b6985aad11c5d633d75b545f5f5f4fc7e50918ad1cb9f6b615682f5bfb76d/SingleQueryingEnumerable.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/96a4cc66aa48dda8a9d8be8febec4a769fc143f7a86dd53ecdebfb9c3177d/ControllerBase.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/9e6ecf3790a24c43487ae724bf9ea8185bdedaf6c28da376d6ea862e66453/WebApplicationBuilder.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/a42ba4db448ed38dcb9fb39194c637a317a8281856b88e139e8a905ee6d12b/Guid.Windows.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$APPLICATION_CONFIG_DIR$/resharper-host/SourcesCache/bd1d5c50194fea68ff3559c160230b0ab50f5acf4ce3061bffd6d62958e2182/ExceptionDispatchInfo.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/WebApiTest/GlobalUsings.cs" root0="FORCE_HIGHLIGHTING" />
<setting file="file://$PROJECT_DIR$/WebApiTest/Program.cs" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="HttpClientSelectedEnvironments">
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/post-requests.http" environment="test" />
<file url="file://$USER_HOME$/AppData/Local/Temp/whats-new-dir/whats-new.http" environment="test" />
<file url="jar://$APPLICATION_HOME_DIR$/plugins/restClient/lib/restClient.jar!/com/intellij/ws/rest/client/requests/collection/requests-with-authorization.http" environment="test" />
</component>
<component name="KubernetesApiPersistence">{}</component>
<component name="KubernetesApiProvider">{
&quot;isMigrated&quot;: true
}</component>
<component name="MetaFilesCheckinStateConfiguration" checkMetaFiles="true" />
<component name="ProblemsViewState">
<option name="selectedTabId" value="CurrentFile" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 4
}</component>
<component name="ProjectId" id="2mIMBmlQ4hFJsyahkv7dCj0zoOT" />
<component name="ProjectLevelVcsManager" settingsEditedManually="true">
<ConfirmationsSetting value="2" id="Add" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;.NET Launch Settings Profile.BlazorTest: http.executor&quot;: &quot;Debug&quot;,
&quot;.NET Launch Settings Profile.BlazorTest: https.executor&quot;: &quot;Debug&quot;,
&quot;.NET Launch Settings Profile.WepApiTest: http.executor&quot;: &quot;Debug&quot;,
&quot;Docker.MariaDbDev.executor&quot;: &quot;Run&quot;,
&quot;Docker.WebApiDocker.executor&quot;: &quot;Run&quot;,
&quot;Docker.WebApiTest/Dockerfile.executor&quot;: &quot;Debug&quot;,
&quot;HTTP Request.WepApiTest | #1 (1).executor&quot;: &quot;Run&quot;,
&quot;HTTP Request.WepApiTest | #1.executor&quot;: &quot;Run&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;preferences.lookFeel&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;mariadb&quot;
]
}
}</component>
<component name="RunManager" selected=".NET Launch Settings Profile.WepApiTest: http">
<configuration name="WepApiTest | #1 (1)" type="HttpClient.HttpRequestRunConfigurationType" factoryName="HTTP Request" temporary="true" nameIsGenerated="true" path="$PROJECT_DIR$/WebApiTest/WepApiTest.http" requestIdentifier="#1" runType="Run single request">
<method v="2" />
</configuration>
<configuration name="BlazorTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="BlazorTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="BlazorTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/BlazorTest/BlazorTest.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="WepApiTest: IIS Express" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="IIS Express" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="WepApiTest: http" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="http" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="WepApiTest: https" type="LaunchSettings" factoryName=".NET Launch Settings Profile">
<option name="LAUNCH_PROFILE_PROJECT_FILE_PATH" value="$PROJECT_DIR$/Lactose/Lactose.csproj" />
<option name="LAUNCH_PROFILE_TFM" value="net8.0" />
<option name="LAUNCH_PROFILE_NAME" value="https" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="GENERATE_APPLICATIONHOST_CONFIG" value="1" />
<option name="SHOW_IIS_EXPRESS_OUTPUT" value="0" />
<option name="SEND_DEBUG_REQUEST" value="1" />
<option name="ADDITIONAL_IIS_EXPRESS_ARGUMENTS" value="" />
<method v="2">
<option name="Build" />
</method>
</configuration>
<configuration name="BlazorTest/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="blazortest" />
<option name="containerName" value="blazortest" />
<option name="contextFolderPath" value="." />
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="BlazorTest/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration name="MariaDbDev" type="docker-deploy" factoryName="docker-image" temporary="true" server-name="Docker">
<deployment type="docker-image">
<settings>
<option name="imageTag" value="mariadb:latest" />
<option name="containerName" value="MariaDbDev" />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="MYSQL_ROOT_PASSWORD" />
<option name="value" value="testOnlyDb" />
</DockerEnvVarImpl>
</list>
</option>
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="3306" />
<option name="hostPort" value="3306" />
</DockerPortBindingImpl>
</list>
</option>
</settings>
</deployment>
<method v="2" />
</configuration>
<configuration name="WebApiDocker" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="wepapitest" />
<option name="containerName" value="wepapitest" />
<option name="contextFolderPath" value="." />
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
<DockerPortBindingImpl>
<option name="containerPort" value="5162" />
<option name="hostPort" value="5162" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="WebApiTest/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="docker-compose.yml" temporary="true">
<deployment type="docker-compose.yml">
<settings />
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="docker-image" temporary="true">
<deployment type="docker-image">
<settings />
</deployment>
<method v="2" />
</configuration>
<configuration default="true" type="docker-deploy" factoryName="dockerfile" temporary="true">
<deployment type="dockerfile">
<settings />
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<configuration name="docker-compose.yml: Compose Deployment" type="docker-deploy" factoryName="docker-compose.yml" server-name="Docker">
<deployment type="docker-compose.yml">
<settings>
<option name="envFilePath" value="" />
<option name="sourceFilePath" value="docker-compose.yml" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
<list>
<item itemvalue=".NET Launch Settings Profile.BlazorTest: http" />
<item itemvalue=".NET Launch Settings Profile.BlazorTest: https" />
<item itemvalue=".NET Launch Settings Profile.BlazorTest: IIS Express" />
<item itemvalue=".NET Launch Settings Profile.WepApiTest: http" />
<item itemvalue=".NET Launch Settings Profile.WepApiTest: https" />
<item itemvalue=".NET Launch Settings Profile.WepApiTest: IIS Express" />
<item itemvalue="Docker.BlazorTest/Dockerfile" />
<item itemvalue="Docker.docker-compose.yml: Compose Deployment" />
<item itemvalue="Docker.WebApiDocker" />
<item itemvalue="Docker.MariaDbDev" />
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
</list>
<recent_temporary>
<list>
<item itemvalue="HTTP Request.WepApiTest | #1 (1)" />
<item itemvalue="Docker.MariaDbDev" />
</list>
</recent_temporary>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="8146fea4-834d-47b3-9aaa-32befb744c64" name="Changes" comment="" />
<created>1726759505353</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1726759505353</updated>
<workItem from="1726759506537" duration="8762000" />
<workItem from="1726775138143" duration="20326000" />
<workItem from="1726877247262" duration="2104000" />
<workItem from="1726879467881" duration="2586000" />
<workItem from="1726882140090" duration="5969000" />
<workItem from="1726888243937" duration="249000" />
<workItem from="1726888519041" duration="1373000" />
<workItem from="1727188917179" duration="1894000" />
</task>
<task id="LOCAL-00001" summary="BaseFiles">
<option name="closed" value="true" />
<created>1726786753746</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1726786753746</updated>
</task>
<task id="LOCAL-00002" summary="BaseFiles">
<option name="closed" value="true" />
<created>1726786763975</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1726786763975</updated>
</task>
<option name="localTasksCounter" value="3" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="UnityCheckinConfiguration" checkUnsavedScenes="true" />
<component name="UnityProjectConfiguration" hasMinimizedUI="false" />
<component name="Vcs.Log.Tabs.Properties">
<option name="TAB_STATES">
<map>
<entry key="MAIN">
<value>
<State />
</value>
</entry>
</map>
</option>
</component>
<component name="VcsManagerConfiguration">
<option name="CLEAR_INITIAL_COMMIT_MESSAGE" value="true" />
<MESSAGE value="BaseFiles" />
<option name="LAST_COMMIT_MESSAGE" value="BaseFiles" />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />
<select />
</component>
</project>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="show" value="PROJECT_FILES" />
<option name="description" value="" />
<option name="applicationTheme" value="default" />
<option name="iconsTheme" value="default" />

View File

@@ -19,11 +19,16 @@
</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="true" isSslEnabled="false" />
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,35 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Lactose (NoCache)" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="lactose" />
<option name="buildCliOptions" value="--no-cache " />
<option name="containerName" value="lactose" />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
<option name="value" value="Docker" />
</DockerEnvVarImpl>
</list>
</option>
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
<DockerPortBindingImpl>
<option name="containerPort" value="5162" />
<option name="hostPort" value="5162" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="Lactose/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,36 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Lactose" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="lactose" />
<option name="containerName" value="lactose" />
<option name="contextFolderPath" value="." />
<option name="envVars">
<list>
<DockerEnvVarImpl>
<option name="name" value="ASPNETCORE_ENVIRONMENT" />
<option name="value" value="Docker" />
</DockerEnvVarImpl>
</list>
</option>
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
<DockerPortBindingImpl>
<option name="containerPort" value="5162" />
<option name="hostPort" value="5162" />
</DockerPortBindingImpl>
</list>
</option>
<option name="showCommandPreview" value="true" />
<option name="sourceFilePath" value="Lactose/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

View File

@@ -1,23 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="MilkStream/Dockerfile" type="docker-deploy" factoryName="dockerfile" server-name="Docker">
<deployment type="dockerfile">
<settings>
<option name="imageTag" value="milkstream" />
<option name="containerName" value="milkstream" />
<option name="contextFolderPath" value="." />
<option name="portBindings">
<list>
<DockerPortBindingImpl>
<option name="containerPort" value="8080" />
<option name="hostIp" value="127.0.0.1" />
<option name="hostPort" value="8080" />
</DockerPortBindingImpl>
</list>
</option>
<option name="sourceFilePath" value="MilkStream/Dockerfile" />
</settings>
</deployment>
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="true" isSslEnabled="false" />
<method v="2" />
</configuration>
</component>

153
AGENTS.md
View File

@@ -1,23 +1,59 @@
# MilkyShots Agent Guide
## Projects (4 in solution)
**Gitea repo owner:** MilkyShots
## Prerequisites
- **.NET 10 SDK** (pinned in `global.json`; `allowPrerelease: true`)
- **PostgreSQL with pgvector extension**
- **SCSS** compiled by `AspNetCore.SassCompiler` MSBuild task (no Node.js needed; compiles during `dotnet build`)
- Run `dotnet tool restore` before first migration — installs `dotnet-ef` (tool manifest at `dotnet-tools.json` in repo root)
## SCSS
SCSS source is in `MilkStream.Client/Styles/`. Compiled via `AspNetCore.SassCompiler` MSBuild target:
```bash
dotnet build # compiles SCSS automatically
```
- Config: `MilkStream.Client/Styles/sasscompiler.json` (expanded in Debug, compressed in Release)
- Source maps disabled
- Bootstrap 5.3.7 SCSS vendored in `Styles/bootstrap/`
- Theme variables adjustable in `Styles/_variables.scss` (`$accent` for primary color)
- Aggressive gradient mixins in `Styles/_gradients.scss`
- Works in Docker — MSBuild task runs during `dotnet build`/`dotnet publish`, no hosted service
## Projects (5 in solution)
| Project | Role | Entrypoint |
|---|---|---|
| **Butter** | Shared class library (DTOs, enums, MIME types) | — |
| **Lactose.Analyzers** | Roslyn analyzer — enforces `DateTime.UtcNow` (not `DateTime.Now`) as error MS001 | — |
| **Lactose** | ASP.NET Core Web API — controllers, EF Core, repos, background jobs | `Lactose/Program.cs` |
| **MilkStream** | Blazor WASM host — serves WASM files + dynamic `/appsettings.json` | `MilkStream/Program.cs` |
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services | `MilkStream.Client/Program.cs` |
| **MilkStream.Client** | Blazor WASM client — Razor components, SCSS, frontend services, shared UI components (ModalFrame, EmptyState) | `MilkStream.Client/Program.cs` |
## Build & run
```bash
dotnet build MilkyShots.sln # .NET 8, C# 12
dotnet build MilkyShots.sln # .NET 10, C# 12
dotnet run --project Lactose # API on :5162 (host) / :8080 (container)
dotnet run --project MilkStream # WASM host on :8080
dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (container)
```
**Prerequisite:** PostgreSQL with pgvector extension. Default DB credentials in `appsettings.json`. Docker compose spins up all services.
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
## Infrastructure gotchas
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
- **Auth requires claims transformation:** `RefreshTokenTransformation` (registered as `IClaimsTransformation`) must succeed on every authenticated request, or all endpoints return 403.
- **`dotnet-ef` CLI vs Microsoft.EntityFrameworkCore.Tools:** The global `dotnet-ef` (from `dotnet-tools.json`, v10.0.9) is the correct CLI to use. The Tools package reference in `Lactose.csproj` (v10.0.9) provides the `dotnet ef` target for `dotnet build`-time validation.
- **WASM client DI:** `LoginService` is **Singleton**; all other services are `Scoped`. Two `HttpClient` registrations: one unauthenticated (default) for login, and one named `"MilkstreamClient"` which has `JwtTokenRefresher` as a `DelegatingHandler`. The handler transparently refreshes expired tokens pre-flight and retries once on 401 — authenticated services must **never** handle token refreshes manually. Do not add `SendWithRefreshAsync` or similar wrappers; rely solely on the named client.
- **HTTPS redirection is commented out** in Lactose — API does not enforce HTTPS.
- **DateTime analyzer:** `Lactose.Analyzers` treats `DateTime.Now` as error MS001, `DateTime.Today` as error MS002, `DateTime.Date` as warning MS003, and non-UTC `SpecifyKind` as error MS004. Always use `DateTime.UtcNow`. All timestamp columns use `timestamp with time zone`.
## XML docs enforced
@@ -26,11 +62,62 @@ dotnet run --project MilkStream # WASM host on :8080
## Conventions
- **`HttpPut` = create, `HttpPost("{id}")` = update** — not REST-idiomatic; do not "fix" without team buy-in
- **Soft delete only** — set `DeletedAt`, never hard-delete
- **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`)
- **JWT access token**: 10 min expiry, refresh token: 60 min
- **JWT access token**: 10 min, refresh token: 60 min (constants in `LactoseAuthService.cs`)
- **Pagination is zero-based** everywhere — `Page = 0` is the first page. All repositories use `Skip(page * pageSize)`. All controllers validate `Page < 0` (reject negative) and `PageSize < 1 || PageSize > 250` (clamp 1250).
- **Conventional Commits** enforced via `cliff.toml`; changelog generated with `git-cliff`
- **Granular commits** — commit each logical change separately (e.g., DTO change, controller logic, UI component) with a descriptive commit message
- **Reuse API endpoints** — prefer adding optional query parameters to existing endpoints over creating new routes. New endpoints are a last resort when the existing ones fundamentally cannot serve the need.
## Visibility Rules (R1R7)
Defined in issue [#129](https://git.r3d.codes/MilkyShots/MilkyShots/issues/129). Each entity (Asset, Album, Person) carries **independent** `EVisibility` — no inheritance from container to child.
### R1 — Independent Visibility
No entity's visibility is overridden by its container. An asset in a Public album does NOT become Public; the asset is only visible if the viewer has permission for the asset's own visibility.
### R2 — Asset Visibility (Per-Asset Gate)
| Actor | Public | Protected | Private |
|---|---|---|---|
| Anonymous | ✅ | ❌ | ❌ |
| User | ✅ | ✅ | ❌ |
| Maintainer (not owning / not linked) | ✅ | ✅ | ❌ |
| Maintainer (uploaded by self) | ✅ | ✅ | ✅ |
| Maintainer (album → person they maintain) | ✅ | ✅ | ✅ |
| Curator (non-deleted) | ✅ | ✅ | ✅ |
| Curator (deleted, own upload) | ✅ | ✅ | ✅ |
| Admin (all, including deleted) | ✅ | ✅ | ✅ |
### R3 — Album Access Gating
Album's own visibility determines who can access it. Not visible → 404.
| Actor | Public | Protected | Private |
|---|---|---|---|
| Anonymous / User | ✅ | ✅ | ❌ |
| Maintainer (unassigned) | ✅ | ✅ | ❌ |
| Maintainer (maintains PersonOwner) | ✅ | ✅ | ✅ |
| Curator / Admin | ✅ | ✅ | ✅ |
### R4 — Person Access Gating
Same as R3. Not visible → 404.
### R5 — Media Serving
Uses per-asset rules (R2). Album provenance not checked — too complex, marginal benefit.
### R6 — Edit Mode (Frontend)
API returns Private assets to privileged users (Admin/Curator/Maintainer-on-owned-content). Frontend hides them in browse mode, shows them in edit/curator mode. No backend flag.
### R7 — Deleted Assets
- **Admin**: all deleted assets visible
- **Curator**: own deleted uploads visible
- **Everyone else**: deleted assets hidden (`DeletedAt IS NULL`)
Reusable UI components live in `MilkStream.Client/Components/Shared/`.
When a markup pattern appears in 2+ places, extract it into a shared component.
See issue #52 for the tracking list of candidate components.
## CORS gotchas
@@ -47,6 +134,56 @@ dotnet ef migrations add <Name> --project Lactose
dotnet ef database update --project Lactose
```
**Database indexes for performance** should be defined using the **fluent API** in `LactoseDbContext.OnModelCreating` via `HasIndex().HasFilter()`. Do not use the `[Index]` data annotation — the `Filter` parameter is not available in the current package configuration (EF Core Tools v10.0.9 vs Design v10.0.9 mismatch). Example:
```csharp
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
```
- **Trigram GIN indexes** on `People.Name` and `Albums.Title` use `HasMethod("gin")` with `HasOperators("gin_trgm_ops")` for case-insensitive `ILike` search.
- **pgvector and pg_trgm** extensions are enabled in `OnModelCreating` via `HasPostgresExtension`.
## Tests
No test project found in solution.
No unit/integration test project exists. API endpoint testing is done via **REST Client `.http` files**:
- **`Lactose/WepApiTest.http`** — 100+ numbered tests covering all API endpoints across 12 sections (Auth → User CRUD → Person → Album → Tag → Asset → Stats → Settings → Data Visibility → Visibility Gates → Cleanup). Tests verify authorization at every level (anonymous, user, curator, admin) and include comprehensive visibility gate tests (R2R4). Self-contained — all test data is created at runtime and cleaned up.
- **`Lactose/http-client.env.json`** — provides pre-filled variable values (`userName`, `curatorName`, etc.) for the REST Client extension.
- Open in VS Code/Rider with the REST Client extension and run individual tests or the full suite.
- Server must be running locally (default `http://localhost:5162`).
- Paginated list tests (people, albums, users) use `?page=0&pageSize=5` — change these carefully.
## PR workflow
- Before starting work, ask the user whether they want the fix in a PR or directly on `develop` (for quick/trivial fixes).
- Larger work always goes in a PR.
- Always mark the PR as **draft** until the user confirms they are satisfied and have tested it works.
- When making the PR ready (removing draft status), mark it ready for review and request:
- **REDCODE** when the PR contains frontend changes
- **Fastwind** when the PR contains backend changes
- **Both** when both areas are touched
## Data sizes
Approximate record counts in the database:
| Entity | Count |
|---|---|
| Assets | 1,000,000+ |
| Albums | 10,000+ |
| People | 1,000+ |
| Users | 100+ |
| Maintainers | 10+ |
| Curators | single digit |
| Admins | single digit |
## Gitea labels
Labels live at two levels:
- **Org level** (`MilkyShots` org): generic issue types (`bug`, `enhancement`, `help wanted`, `question`, `duplicate`, `invalid`, `wontfix`)
- **Repo level** (`MilkyShots/MilkyShots`): project-specific labels (`area:frontend`, `area:backend`, `page:*`, `type:refactor`, `priority:*`, etc.)
- When labelling issues, check both levels with the `label_read` tool (`list_org_labels` / `list_repo_labels`).

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

View File

@@ -0,0 +1,37 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
/// Minimal asset info needed for displaying album assets.
/// </summary>
public class AlbumAssetPreviewDto {
/// <summary>
/// Gets or sets the asset ID.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the resolution width.
/// </summary>
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height.
/// </summary>
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets whether the asset has a thumbnail.
/// </summary>
public bool HasThumbnail { get; set; }
/// <summary>
/// Gets or sets whether the asset has a preview.
/// </summary>
public bool HasPreview { get; set; }
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,17 +9,21 @@ public class AlbumCreateDto {
/// <summary>
/// Gets or sets the name of the album.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the owner ID of the album.
/// </summary>
public Guid? Owner { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets the cover asset ID for the album.
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid>? Assets { get; set; }
public List<Guid>? Assets { get; set; }
/// <summary>
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
/// </summary>
public EVisibility Visibility { get; set; } = EVisibility.Protected;
}

View File

@@ -8,4 +8,8 @@ public class AlbumFullDto : AlbumPreviewDto {
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid> Images { get; set; } = [];
/// <summary>
/// Gets or sets the asset preview information for rendering.
/// </summary>
public List<AlbumAssetPreviewDto> AssetPreviews { get; set; } = [];
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,17 +9,29 @@ public class AlbumPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the album.
/// </summary>
public Guid Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the album.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the owner ID of the album.
/// </summary>
public Guid? Owner { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets the person name associated with the album.
/// </summary>
public string? PersonName { get; set; }
/// <summary>
/// Gets or sets the ID of the cover asset for this album.
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the number of assets in the album.
/// </summary>
public int AssetCount { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
}

View File

@@ -0,0 +1,17 @@
namespace Butter.Dtos.Album;
/// <summary>
/// Search and filter parameters for album queries.
/// </summary>
public class AlbumSearchParametersDto : PagedSearchParametersDto {
/// <summary>
/// Gets or sets whether to return only albums with no person assigned.
/// </summary>
public bool Unassigned { get; set; }
/// <summary>
/// Gets or sets the user ID to filter albums by.
/// Only returns albums that contain at least one asset uploaded by the specified user.
/// </summary>
public Guid? AssetUploadedBy { get; set; }
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Album;
/// <summary>
@@ -7,17 +9,25 @@ public class AlbumUpdateDto {
/// <summary>
/// Gets or sets the name of the album.
/// </summary>
public string? Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the owner ID of the album.
/// </summary>
public Guid? Owner { get; set; }
public string? Name { get; set; }
/// <summary>
/// Gets or sets the person ID associated with the album.
/// </summary>
public Guid? Person { get; set; }
public Guid? Person { get; set; }
/// <summary>
/// Gets or sets whether to remove the person assignment from the album.
/// </summary>
public bool RemovePerson { get; set; }
/// <summary>
/// Gets or sets the cover asset ID for the album.
/// </summary>
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs in the album.
/// </summary>
public List<Guid>? Assets { get; set; }
public List<Guid>? Assets { get; set; }
/// <summary>
/// Gets or sets the visibility level.
/// </summary>
public EVisibility? Visibility { get; set; }
}

View File

@@ -9,41 +9,42 @@ public class AssetCreateDto {
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the owner ID of the asset.
/// Gets or sets the ID of the user who uploaded the asset.
/// Null for scanner-created assets (system uploader will be used).
/// </summary>
public Guid Owner { get; set; }
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets the type of the asset.
/// </summary>
public EAssetType AssetType { get; set; }
public EAssetType AssetType { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
/// </summary>
public bool IsPublic { get; set; }
public EVisibility Visibility { get; set; } = EVisibility.Private;
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public string MimeType { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the resolution width in pixels.
/// </summary>
public int ResolutionWidth { get; set; }
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
public long FileSize { get; set; }
public long FileSize { get; set; }
/// <summary>
/// Gets or sets the duration in seconds (for video/audio).
/// </summary>
public int? Duration { get; set; }
public int? Duration { get; set; }
/// <summary>
/// Gets or sets the frame rate (for video).
/// </summary>
public int? FrameRate { get; set; }
public int? FrameRate { get; set; }
}

View File

@@ -7,56 +7,40 @@ namespace Butter.Dtos.Asset;
/// Represents the full information needed to display an asset on a detail page.
/// </summary>
public class AssetDto : AssetPreviewDto {
/// <summary>
/// Gets or sets the original filename of the asset. May be null if the user lacks permission.
/// </summary>
public string? FileName { get; set; }
/// <summary>
/// Gets or sets the type of the asset (e.g., Image, Video).
/// </summary>
public EAssetType AssetType { get; set; }
public EAssetType AssetType { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// Gets or sets the user who uploaded the asset.
/// </summary>
public bool IsPublic { get; set; }
/// <summary>
/// Gets or sets the owner of the asset.
/// </summary>
public required UserInfoDto Owner { get; set; }
public required UserInfoDto UploadedBy { get; set; }
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
public DateTime CreatedAt { get; set; }
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets or sets the last updated date of the asset.
/// </summary>
public DateTime? UpdatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the deletion date of the asset.
/// </summary>
public DateTime? DeletedAt { get; set; }
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public new string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the resolution width of the asset in pixels.
/// </summary>
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height of the asset in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
public new string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the file size of the asset in bytes.
/// </summary>
public long FileSize { get; set; }
public long FileSize { get; set; }
/// <summary>
/// Gets or sets the duration of the asset in seconds (for video/audio).
/// </summary>
public float Duration { get; set; }
public float Duration { get; set; }
/// <summary>
/// Gets or sets the frame rate of the asset (for video).
/// </summary>
public float FrameRate { get; set; }
public float FrameRate { get; set; }
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
@@ -7,9 +9,49 @@ public class AssetPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the asset.
/// </summary>
public Guid Id { get; set; }
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the MIME type of the asset.
/// </summary>
public string MimeType { get; set; } = string.Empty;
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the resolution width of the asset in pixels.
/// </summary>
public int ResolutionWidth { get; set; }
/// <summary>
/// Gets or sets the resolution height of the asset in pixels.
/// </summary>
public int ResolutionHeight { get; set; }
/// <summary>
/// Gets or sets whether the asset has a generated thumbnail.
/// </summary>
public bool HasThumbnail { get; set; }
/// <summary>
/// Gets or sets whether the asset has a generated preview.
/// </summary>
public bool HasPreview { get; set; }
/// <summary>
/// Gets or sets the list of album titles associated with the asset.
/// </summary>
public List<string>? AlbumNames { get; set; }
/// <summary>
/// Gets or sets the list of album IDs associated with the asset, parallel to <see cref="AlbumNames"/>.
/// </summary>
public List<Guid>? AlbumIds { get; set; }
/// <summary>
/// Gets or sets the list of cosplayer names associated with the asset, derived from album person owners.
/// </summary>
public List<string>? CosplayerNames { get; set; }
/// <summary>
/// Gets or sets the list of cosplayer IDs associated with the asset, parallel to <see cref="CosplayerNames"/>.
/// </summary>
public List<Guid>? CosplayerIds { get; set; }
/// <summary>
/// Gets or sets the original filename of the asset.
/// </summary>
public string? FileName { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
@@ -12,4 +14,16 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
/// Gets or sets the end date for filtering assets by date range.
/// </summary>
public string? EndDate { get; set; }
/// <summary>
/// Gets or sets the optional asset type filter.
/// </summary>
public EAssetType? Type { get; set; }
/// <summary>
/// Gets or sets the seed for deterministic random ordering.
/// </summary>
public Guid? Seed { get; set; }
/// <summary>
/// Gets or sets whether to order results randomly instead of by date.
/// </summary>
public bool Random { get; set; }
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Asset;
/// <summary>
@@ -5,15 +7,15 @@ namespace Butter.Dtos.Asset;
/// </summary>
public class AssetUpdateDto {
/// <summary>
/// Gets or sets the new owner ID for the asset.
/// Gets or sets the ID of the user who uploaded the asset.
/// </summary>
public Guid? Owner { get; set; }
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets the deletion date to set on the asset.
/// </summary>
public DateTime? DeletedAt { get; set; }
public DateTime? DeletedAt { get; set; }
/// <summary>
/// Gets or sets whether the asset is publicly shared.
/// Gets or sets the visibility level.
/// </summary>
public bool? IsPublic { get; set; }
public EVisibility? Visibility { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Jobs;
/// <summary>
/// Represents a response containing child jobs and a timestamp for delta-based polling.
/// </summary>
public class ChildrenResponse {
/// <summary>
/// Gets the list of child job statuses.
/// </summary>
public List<JobStatusDto> Children { get; init; } = [];
/// <summary>
/// Gets the server timestamp to use as <c>since</c> for the next delta poll.
/// </summary>
public DateTime Since { get; init; }
}

View File

@@ -63,7 +63,11 @@ public enum EJobType {
/// <summary>Performs an integrity check on the system.</summary>
IntegrityCheck,
/// <summary>Computes perceptual hashes for assets.</summary>
PHashGeneration
PHashGeneration,
/// <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
}
/// <summary>

View File

@@ -4,6 +4,12 @@ namespace Butter.Dtos;
/// Represents pagination parameters for a query.
/// </summary>
public class PagedParametersDto {
/// <summary>
/// Contains the global constant for max pages.
/// </summary>
public const int MaxPageSize = 250;
/// <summary>
/// Gets or sets the page number (zero-based).
/// </summary>
@@ -12,6 +18,16 @@ public class PagedParametersDto {
/// Gets or sets the number of items per page.
/// </summary>
public int PageSize { get; set; } = 150;
/// <summary>
/// Gets or sets the field to sort by (e.g. "name", "created").
/// When <c>null</c>, the default sort order is applied.
/// </summary>
public string? SortBy { get; set; }
/// <summary>
/// Gets or sets the sort direction.
/// <c>false</c> = descending (default), <c>true</c> = ascending.
/// </summary>
public bool SortAsc { get; set; }
}
/// <summary>

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,5 +9,25 @@ public class PersonCreateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name {get; set;}
}
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID to use as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the visibility level. Defaults to <see cref="EVisibility.Protected"/>.
/// </summary>
public EVisibility Visibility { get; set; } = EVisibility.Protected;
}

View File

@@ -1,23 +1,58 @@
using Butter.Dtos.Album;
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
/// Represents the full details of a person, including associated assets and albums.
/// Represents the full details of a person, including associated albums.
/// </summary>
public class PersonDetailedDto {
/// <summary>
/// Gets or sets the unique identifier of the person.
/// </summary>
public required Guid Id { get; set; }
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
public required string Name { get; set; }
/// <summary>
/// Gets or sets the list of asset IDs associated with this person.
/// Gets or sets the asset ID used as the profile picture.
/// </summary>
public List<Guid>? Assets { get; set; }
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the list of album IDs associated with this person.
/// Gets or sets the horizontal crop offset for the profile picture (0100).
/// </summary>
public List<Guid>? Albums { get; set; }
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture (0100).
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the total number of non-deleted albums.
/// </summary>
public int TotalAlbums { get; set; }
/// <summary>
/// Gets or sets the total number of non-deleted assets across all albums.
/// </summary>
public int TotalAssets { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the list of album previews associated with this person.
/// </summary>
public List<AlbumPreviewDto>? Albums { get; set; }
/// <summary>
/// Gets or sets the IDs of users who maintain this person.
/// </summary>
public List<Guid>? MaintainerUserIds { get; set; }
/// <summary>
/// Gets or sets the usernames of users who maintain this person.
/// </summary>
public List<string>? MaintainerUsernames { get; set; }
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,9 +9,33 @@ public class PersonPreviewDto {
/// <summary>
/// Gets or sets the unique identifier of the person.
/// </summary>
public required Guid Id { get; set; }
public required Guid Id { get; set; }
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID used as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the number of albums associated with this person.
/// </summary>
public int TotalAlbums { get; set; }
/// <summary>
/// Gets or sets the visibility level. Only populated for curators and admins.
/// </summary>
public EVisibility? Visibility { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace Butter.Dtos.Person;
/// <summary>
/// Search and filter parameters for person (cosplayer) queries.
/// </summary>
public class PersonSearchParametersDto : PagedSearchParametersDto {
}

View File

@@ -1,3 +1,5 @@
using Butter.Types;
namespace Butter.Dtos.Person;
/// <summary>
@@ -7,6 +9,29 @@ public class PersonUpdateDto {
/// <summary>
/// Gets or sets the name of the person.
/// </summary>
public required string Name { get; set; }
//TODO: add the missing properties here
public required string Name { get; set; }
/// <summary>
/// Gets or sets the asset ID to use as the profile picture.
/// </summary>
public Guid? ProfileAssetId { get; set; }
/// <summary>
/// Gets or sets the horizontal crop offset for the profile picture.
/// </summary>
public float? ProfileCropX { get; set; }
/// <summary>
/// Gets or sets the vertical crop offset for the profile picture.
/// </summary>
public float? ProfileCropY { get; set; }
/// <summary>
/// Gets or sets the zoom level for the profile picture.
/// </summary>
public float? ProfileCropZoom { get; set; }
/// <summary>
/// Gets or sets the visibility level.
/// </summary>
public EVisibility? Visibility { get; set; }
/// <summary>
/// Gets or sets the list of user IDs that should be maintainers of this person (admin/curator only).
/// </summary>
public List<Guid>? MaintainerUserIds { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// A MIME type and its associated asset count for statistics.
/// </summary>
public class MimeTypeStatDto {
/// <summary>
/// Gets or sets the MIME type string.
/// </summary>
public string MimeType { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of assets with this MIME type.
/// </summary>
public int Count { get; set; }
}

View File

@@ -0,0 +1,23 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// Monthly growth statistics for assets and users.
/// </summary>
public class MonthlyStatDto {
/// <summary>
/// Gets or sets the year.
/// </summary>
public int Year { get; set; }
/// <summary>
/// Gets or sets the month (1-12).
/// </summary>
public int Month { get; set; }
/// <summary>
/// Gets or sets the number of new assets added in this month.
/// </summary>
public int NewAssets { get; set; }
/// <summary>
/// Gets or sets the number of new users registered in this month.
/// </summary>
public int NewUsers { get; set; }
}

View File

@@ -0,0 +1,15 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// A resolution bucket with a label and the count of assets that fall into it.
/// </summary>
public class ResolutionBucketDto {
/// <summary>
/// Gets or sets the display label for the resolution bucket.
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of assets in this resolution bucket.
/// </summary>
public int Count { get; set; }
}

View File

@@ -0,0 +1,146 @@
using Butter.Types;
namespace Butter.Dtos.Stats;
/// <summary>
/// Comprehensive statistics about the MilkyShots instance, gatherable from fast aggregate queries.
/// </summary>
public class StatsDto {
/// <summary>
/// Gets or sets the total number of assets (excluding soft-deleted).
/// </summary>
public int TotalAssets { get; set; }
/// <summary>
/// Gets or sets the total number of registered users (excluding soft-deleted).
/// </summary>
public int TotalUsers { get; set; }
/// <summary>
/// Gets or sets the total number of albums.
/// </summary>
public int TotalAlbums { get; set; }
/// <summary>
/// Gets or sets the total number of tags.
/// </summary>
public int TotalTags { get; set; }
/// <summary>
/// Gets or sets the total number of people.
/// </summary>
public int TotalPeople { get; set; }
/// <summary>
/// Gets or sets the total number of detected faces.
/// </summary>
public int TotalFaces { get; set; }
/// <summary>
/// Gets or sets the total number of watched folders.
/// </summary>
public int TotalFolders { get; set; }
/// <summary>
/// Gets or sets the asset count broken down by type (Image, Video, Animation, PhotoSphere).
/// </summary>
public Dictionary<EAssetType, int> AssetsByType { get; set; } = [];
/// <summary>
/// Gets or sets the total storage used by all assets in bytes.
/// </summary>
public long TotalStorageBytes { get; set; }
/// <summary>
/// Gets or sets the storage used broken down by asset type.
/// </summary>
public Dictionary<EAssetType, long> StorageByType { get; set; } = [];
/// <summary>
/// Gets or sets the user count broken down by access level.
/// </summary>
public Dictionary<EAccessLevel, int> UsersByAccessLevel { get; set; } = [];
/// <summary>
/// Gets or sets the number of assets added in the last 7 days.
/// </summary>
public int AssetsAddedLast7Days { get; set; }
/// <summary>
/// Gets or sets the number of assets added in the last 30 days.
/// </summary>
public int AssetsAddedLast30Days { get; set; }
/// <summary>
/// Gets or sets the number of users registered in the last 30 days.
/// </summary>
public int UsersRegisteredLast30Days { get; set; }
/// <summary>
/// Gets or sets the number of assets with no associated folder.
/// </summary>
public int OrphanAssets { get; set; }
/// <summary>
/// Gets or sets the number of publicly shared assets.
/// </summary>
public int PublicAssets { get; set; }
/// <summary>
/// Gets or sets the number of assets with protected visibility (authenticated only).
/// </summary>
public int ProtectedAssets { get; set; }
/// <summary>
/// Gets or sets the number of private assets.
/// </summary>
public int PrivateAssets { get; set; }
/// <summary>
/// Gets or sets the number of image assets missing resolution metadata.
/// </summary>
public int AssetsMissingMetadata { get; set; }
/// <summary>
/// Gets or sets the number of assets missing a generated thumbnail.
/// </summary>
public int AssetsMissingThumbnail { get; set; }
/// <summary>
/// Gets or sets the number of assets whose thumbnail was generated at a different size than the current setting.
/// </summary>
public int AssetsMissingThumbnailStale { get; set; }
/// <summary>
/// Gets or sets the number of assets missing a generated preview.
/// </summary>
public int AssetsMissingPreviews { get; set; }
/// <summary>
/// Gets or sets the number of assets whose preview was generated at a different size than the current setting.
/// </summary>
public int AssetsMissingPreviewsStale { get; set; }
/// <summary>
/// Gets or sets the number of assets missing a perceptual hash.
/// </summary>
public int AssetsMissingPhash { 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; }
/// <summary>
/// Gets or sets the number of assets with no album assigned.
/// </summary>
public int AssetsWithNoAlbum { get; set; }
/// <summary>
/// Gets or sets the number of albums without a cover image.
/// </summary>
public int AlbumsMissingCover { get; set; }
/// <summary>
/// Gets or sets the number of people/cosplayers without a profile picture.
/// </summary>
public int CosplayersMissingProfile { get; set; }
/// <summary>
/// Gets or sets the top tags by asset count.
/// </summary>
public List<TagStatDto> TopTags { get; set; } = [];
/// <summary>
/// Gets or sets the resolution distribution of assets.
/// </summary>
public List<ResolutionBucketDto> ResolutionDistribution { get; set; } = [];
/// <summary>
/// Gets or sets the file format breakdown by MIME type.
/// </summary>
public List<MimeTypeStatDto> FileFormatBreakdown { get; set; } = [];
/// <summary>
/// Gets or sets the monthly new-asset and new-user counts for the last 12 months.
/// </summary>
public List<MonthlyStatDto> MonthlyGrowth { get; set; } = [];
}

View File

@@ -0,0 +1,19 @@
namespace Butter.Dtos.Stats;
/// <summary>
/// A tag and its associated asset count for statistics.
/// </summary>
public class TagStatDto {
/// <summary>
/// Gets or sets the tag identifier.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the tag name.
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the number of assets associated with the tag.
/// </summary>
public int AssetCount { get; set; }
}

View File

@@ -46,4 +46,13 @@ public class UserInfoDto {
/// Gets or sets the access level of the user.
/// </summary>
public required EAccessLevel AccessLevel { get; set; } = EAccessLevel.User;
/// <summary>
/// Gets or sets the IDs of persons (cosplayers) this user is a maintainer of.
/// </summary>
public List<Guid>? MaintainedPersonIds { get; set; }
/// <summary>
/// Gets or sets the names of persons (cosplayers) this user is a maintainer of.
/// </summary>
public List<string>? MaintainedPersonNames { get; set; }
}

View File

@@ -18,12 +18,16 @@ public class UserUpdateDto {
/// Gets or sets the new email address.
/// </summary>
public string? Email { get; set; }
/// <summary>
/// Gets or sets the new password.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets whether the user is banned.
/// <summary>
/// Gets or sets the new password.
/// </summary>
public string? Password { get; set; }
/// <summary>
/// Gets or sets the current password (required for self-service password changes).
/// </summary>
public string? OldPassword { get; set; }
/// <summary>
/// Gets or sets whether the user is banned.
/// </summary>
public bool? IsBanned { get; set; }
/// <summary>
@@ -34,4 +38,8 @@ public class UserUpdateDto {
/// Gets or sets the new access level.
/// </summary>
public EAccessLevel? AccessLevel { get; set; }
/// <summary>
/// Gets or sets the list of person (cosplayer) IDs this user should maintain (admin only).
/// </summary>
public List<Guid>? MaintainedPersonIds { get; set; }
}

View File

@@ -27,7 +27,13 @@ public enum Settings {
/// <summary>Number of days to keep completed/failed/canceled jobs.</summary>
JobRetentionDays,
/// <summary>Number of assets to process per sub-job batch.</summary>
JobBatchSize
JobBatchSize,
/// <summary>Quality setting (1100) for WebP thumbnail encoding.</summary>
ThumbnailQuality,
/// <summary>Quality setting (1100) for WebP preview encoding.</summary>
PreviewQuality,
/// <summary>User ID used for scanner-created asset attribution.</summary>
SystemUploaderId
}
/// <summary>
@@ -53,6 +59,9 @@ public static class SettingsExtensions {
Settings.MaxConcurrentJobs => "Max Concurrent Jobs",
Settings.JobRetentionDays => "Job Retention Days",
Settings.JobBatchSize => "Job Batch Size",
Settings.ThumbnailQuality => "Thumbnail Quality",
Settings.PreviewQuality => "Preview Quality",
Settings.SystemUploaderId => "System Uploader Id",
_ => setting.ToString() // Fallback to the enum name
};
}

View File

@@ -5,9 +5,11 @@ namespace Butter.Types;
/// </summary>
public enum EAccessLevel {
/// <summary>Standard user with basic permissions.</summary>
User,
User = 0,
/// <summary>Maintainer — scoped cosplayer management and tag management.</summary>
Maintainer = 1,
/// <summary>Curator with elevated permissions for managing assets.</summary>
Curator,
Curator = 2,
/// <summary>Administrator with full system access.</summary>
Admin
Admin = 3
}

View File

@@ -0,0 +1,13 @@
namespace Butter.Types;
/// <summary>
/// Defines visibility levels for entities in the system.
/// </summary>
public enum EVisibility {
/// <summary>Visible to everyone, including anonymous users.</summary>
Public = 0,
/// <summary>Visible to authenticated users only.</summary>
Protected = 1,
/// <summary>Visible only to authorized users (maintainers, curators, admins).</summary>
Private = 2
}

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<IncludeBuildOutput>false</IncludeBuildOutput>
<DevelopmentDependency>true</DevelopmentDependency>
<SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
<NoWarn>$(NoWarn);RS1038;RS2008</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,132 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
namespace Lactose.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OnlyUtcDateTimeAnalyzer : DiagnosticAnalyzer
{
const string Category = "Correctness";
static readonly DiagnosticDescriptor DateTimeNowRule = new(
id: "MS001",
title: "Use DateTime.UtcNow instead of DateTime.Now",
messageFormat: "Use DateTime.UtcNow instead of DateTime.Now to ensure UTC semantics",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
static readonly DiagnosticDescriptor DateTimeTodayRule = new(
id: "MS002",
title: "Use DateTime.UtcNow.Date instead of DateTime.Today",
messageFormat: "Use DateTime.UtcNow.Date instead of DateTime.Today to ensure UTC semantics",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
static readonly DiagnosticDescriptor DateTimeDateRule = new(
id: "MS003",
title: "DateTime.Date may return non-UTC value",
messageFormat: "DateTime.Date produces a DateTime with Kind=Local; ensure this is intentional or use DateTime.UtcNow.Date",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
static readonly DiagnosticDescriptor SpecifyKindRule = new(
id: "MS004",
title: "DateTime.SpecifyKind must use DateTimeKind.Utc",
messageFormat: "DateTime.SpecifyKind with {0} is not allowed; use DateTimeKind.Utc",
category: Category,
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(DateTimeNowRule, DateTimeTodayRule, DateTimeDateRule, SpecifyKindRule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression);
context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression);
}
void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
{
var memberAccess = (MemberAccessExpressionSyntax)context.Node;
if (memberAccess.Name is not IdentifierNameSyntax memberName)
return;
switch (memberName.Identifier.Text)
{
case "Now":
if (IsSystemDateTime(context, memberAccess.Expression))
context.ReportDiagnostic(Diagnostic.Create(DateTimeNowRule, memberAccess.GetLocation()));
break;
case "Today":
if (IsSystemDateTime(context, memberAccess.Expression))
context.ReportDiagnostic(Diagnostic.Create(DateTimeTodayRule, memberAccess.GetLocation()));
break;
case "Date":
{
var typeInfo = context.SemanticModel.GetTypeInfo(memberAccess.Expression, context.CancellationToken);
if (typeInfo.Type is INamedTypeSymbol namedType &&
namedType.SpecialType == SpecialType.System_DateTime)
{
context.ReportDiagnostic(Diagnostic.Create(DateTimeDateRule, memberAccess.GetLocation()));
}
break;
}
}
}
void AnalyzeInvocation(SyntaxNodeAnalysisContext context)
{
var invocation = (InvocationExpressionSyntax)context.Node;
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
return;
if (memberAccess.Name is not IdentifierNameSyntax { Identifier.Text: "SpecifyKind" })
return;
if (!IsSystemDateTime(context, memberAccess.Expression))
return;
if (invocation.ArgumentList.Arguments.Count < 2)
return;
var kindArg = invocation.ArgumentList.Arguments[1].Expression;
var kindValue = context.SemanticModel.GetConstantValue(kindArg, context.CancellationToken);
if (!kindValue.HasValue || kindValue.Value is not int intKind)
return;
var kindName = intKind switch
{
0 => "DateTimeKind.Unspecified",
1 => "DateTimeKind.Utc",
2 => "DateTimeKind.Local",
_ => $"DateTimeKind value {intKind}"
};
if (intKind != 1)
{
context.ReportDiagnostic(Diagnostic.Create(
SpecifyKindRule,
kindArg.GetLocation(),
kindName));
}
}
static bool IsSystemDateTime(SyntaxNodeAnalysisContext context, ExpressionSyntax expression)
{
var typeInfo = context.SemanticModel.GetTypeInfo(expression, context.CancellationToken);
return typeInfo.Type is INamedTypeSymbol named &&
named.SpecialType == SpecialType.System_DateTime;
}
}

View File

@@ -0,0 +1,109 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using System.Collections.Immutable;
using System.Composition;
using System.Threading;
using System.Threading.Tasks;
namespace Lactose.Analyzers;
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(OnlyUtcDateTimeCodeFix))]
[Shared]
public class OnlyUtcDateTimeCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create("MS001", "MS002", "MS004");
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null)
return;
var diagnostic = context.Diagnostics[0];
var diagnosticSpan = diagnostic.Location.SourceSpan;
var node = root.FindNode(diagnosticSpan);
switch (diagnostic.Id)
{
case "MS001":
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with DateTime.UtcNow",
createChangedDocument: ct => ReplaceNowWithUtcNow(context.Document, node, ct),
equivalenceKey: "MS001"),
diagnostic);
break;
case "MS002":
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with DateTime.UtcNow.Date",
createChangedDocument: ct => ReplaceTodayWithUtcNowDate(context.Document, node, ct),
equivalenceKey: "MS002"),
diagnostic);
break;
case "MS004":
context.RegisterCodeFix(
CodeAction.Create(
title: "Replace with DateTimeKind.Utc",
createChangedDocument: ct => ReplaceWithUtcKind(context.Document, node, ct),
equivalenceKey: "MS004"),
diagnostic);
break;
}
}
static async Task<Document> ReplaceNowWithUtcNow(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
if (node is MemberAccessExpressionSyntax memberAccess)
{
var utcNow = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("DateTime"),
SyntaxFactory.IdentifierName("UtcNow"));
editor.ReplaceNode(memberAccess, utcNow.WithTriviaFrom(memberAccess));
}
return editor.GetChangedDocument();
}
static async Task<Document> ReplaceTodayWithUtcNowDate(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
if (node is MemberAccessExpressionSyntax memberAccess)
{
var utcNow = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("DateTime"),
SyntaxFactory.IdentifierName("UtcNow"));
var utcNowDotDate = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
utcNow,
SyntaxFactory.IdentifierName("Date"));
editor.ReplaceNode(memberAccess, utcNowDotDate.WithTriviaFrom(memberAccess));
}
return editor.GetChangedDocument();
}
static async Task<Document> ReplaceWithUtcKind(Document document, SyntaxNode node, CancellationToken ct)
{
var editor = await DocumentEditor.CreateAsync(document, ct).ConfigureAwait(false);
if (node is ArgumentSyntax argument)
{
var utcKind = SyntaxFactory.MemberAccessExpression(
SyntaxKind.SimpleMemberAccessExpression,
SyntaxFactory.IdentifierName("DateTimeKind"),
SyntaxFactory.IdentifierName("Utc"));
editor.ReplaceNode(argument.Expression, utcKind.WithTriviaFrom(argument.Expression));
}
return editor.GetChangedDocument();
}
}

View File

@@ -71,7 +71,7 @@ public class RefreshTokenTransformation(
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.Now) return Task.FromResult(principal);
if (string.IsNullOrEmpty(user.RefreshToken) || user.RefreshTokenExpires == null || user.RefreshTokenExpires < DateTime.UtcNow) return Task.FromResult(principal);
claimsIdentity.AddClaim(new Claim(ClaimType, "true", ClaimValueTypes.Boolean));
principal.AddIdentity(claimsIdentity);
return Task.FromResult(principal);

View File

@@ -42,6 +42,10 @@ public class LactoseDbContext : DbContext {
/// Gets or sets the job records DbSet.
/// </summary>
public DbSet<JobRecord> JobRecords { get; set; }
/// <summary>
/// Gets or sets the person-maintainer join table.
/// </summary>
public DbSet<PersonMaintainer> PersonMaintainers { get; set; }
/// <summary>
/// Initialises a new instance of the <see cref="LactoseDbContext"/> class.
@@ -49,32 +53,58 @@ public class LactoseDbContext : DbContext {
/// <param name="options">The database context options.</param>
public LactoseDbContext(DbContextOptions options) : base(options) {}
/// <inheritdoc />
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) {
configurationBuilder.Properties<DateTime>()
.HaveColumnType("timestamp with time zone");
configurationBuilder.Properties<DateTime?>()
.HaveColumnType("timestamp with time zone");
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder) {
modelBuilder.HasPostgresExtension("vector");
modelBuilder.HasPostgresExtension("pg_trgm");
//Album Relationships
modelBuilder.Entity<Album>().HasOne(e => e.UserOwner).WithMany(e => e.OwnedAlbums);
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
modelBuilder.Entity<Album>().HasMany(e => e.Assets).WithMany(e => e.Albums);
//Asset Relationships
modelBuilder.Entity<Asset>().HasOne(e => e.Owner).WithMany(e => e.OwnedAssets);
modelBuilder.Entity<Asset>().HasMany(e => e.SharedWith);
modelBuilder.Entity<Asset>().HasOne(e => e.Uploader).WithMany(e => e.UploadedAssets).HasForeignKey(e => e.UploadedBy);
modelBuilder.Entity<Asset>().HasMany(e => e.Albums).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasMany(e => e.Tags).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasMany(e => e.Faces).WithOne(e => e.Asset);
modelBuilder.Entity<Asset>().HasOne(e => e.Folder).WithMany(e => e.Assets);
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
// People
modelBuilder.Entity<Person>().HasIndex(p => p.Name)
.HasDatabaseName("IX_People_Name_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
// Albums
modelBuilder.Entity<Album>().HasIndex(a => a.Title)
.HasDatabaseName("IX_Albums_Title_Trgm")
.HasMethod("gin")
.HasOperators("gin_trgm_ops");
//Face Relationships
modelBuilder.Entity<Face>().HasOne(e => e.Asset).WithMany(e => e.Faces);
modelBuilder.Entity<Face>().HasOne(e => e.Person).WithMany(e => e.Faces);
//Person Relationships
modelBuilder.Entity<Person>().HasOne(e => e.ProfileAsset).WithMany().HasForeignKey(e => e.ProfileAssetId);
modelBuilder.Entity<Person>().HasMany(e => e.Faces).WithOne(e => e.Person);
modelBuilder.Entity<Person>().HasMany(e => e.Albums).WithOne(e => e.PersonOwner);
modelBuilder.Entity<Person>().HasMany(e => e.Maintainers).WithMany(e => e.MaintainedPersons)
.UsingEntity<PersonMaintainer>(
j => j.HasOne(pm => pm.User).WithMany().HasForeignKey(pm => pm.UserId),
j => j.HasOne(pm => pm.Person).WithMany().HasForeignKey(pm => pm.PersonId)
);
//Tag Relationships
modelBuilder.Entity<Tag>().HasOne(e => e.Parent);
modelBuilder.Entity<Tag>().HasMany(e => e.Assets).WithMany(e => e.Tags);
//User Relationships
modelBuilder.Entity<User>().HasMany(e => e.OwnedAssets).WithOne(e => e.Owner);
modelBuilder.Entity<User>().HasMany(e => e.OwnedAlbums).WithOne(e => e.UserOwner);
modelBuilder.Entity<User>().HasMany(e => e.UploadedAssets).WithOne(e => e.Uploader).HasForeignKey(e => e.UploadedBy);
//Folder Relationships
modelBuilder.Entity<Folder>().HasMany(e => e.Assets).WithOne(e => e.Folder);
}

View File

@@ -16,6 +16,7 @@ namespace Lactose.Controllers;
/// <param name="authService">Authentication service.</param>
/// <param name="albumRepository">Album repository.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="personRepository">Person repository.</param>
[ApiController]
[Authorize]
[Route("api/[controller]")]
@@ -24,7 +25,8 @@ public class AlbumController(
//ILogger<AlbumController> logger,
LactoseAuthService authService,
IAlbumRepository albumRepository,
IAssetRepository assetRepository
IAssetRepository assetRepository,
IPersonRepository personRepository
) : ControllerBase {
/// <summary>
/// Gets an album by its ID with filtered assets based on access level.
@@ -36,64 +38,39 @@ public class AlbumController(
Guid? uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var album = albumRepository.Find(id);
var album = albumRepository.FindVisible(id, uid, accessLevel);
// If the album does not exist, return a 404
if (album == null) return NotFound();
// If the album has no assets, return an empty list
if (album.Assets == null) return Ok(album.ToAlbumFullDto());
//Storage for assets that will be sent out;
List<Asset> assets = new();
switch (accessLevel) {
case EAccessLevel.User:
// Only publicly shared assets or owned by user
assets.AddRange(album.Assets.Where(asset => asset.IsPubliclyShared || asset.OwnerId == uid));
// Add shared assets
assets.AddRange(album.Assets.Where(asset => asset.SharedWith!.Any(user => user.Id == uid)));
break;
case EAccessLevel.Curator:
case EAccessLevel.Admin:
// All assets
assets.AddRange(album.Assets);
break;
}
// changes the retrieved album to the new filtered
album.Assets = assets;
return Ok(album.ToAlbumFullDto());
return Ok(album.ToAlbumFullDto(accessLevel));
}
/// <summary>
/// Searches albums with pagination and optional search term.
/// Searches albums with pagination, optional search term, sort options, and optional unassigned filter.
/// </summary>
/// <param name="pagingOptions">Pagination and search parameters.</param>
/// <param name="pagingOptions">Pagination, search, sort, and filter parameters.</param>
/// <returns>A list of album previews accessible to the user.</returns>
[HttpGet]
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] PagedSearchParametersDto pagingOptions) {
public ActionResult<List<AlbumPreviewDto>> Search([FromQuery] AlbumSearchParametersDto pagingOptions) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 0) return BadRequest();
var albums = albumRepository.SearchQuery(pagingOptions.Search ?? string.Empty, pagingOptions.Page, pagingOptions.PageSize).ToList();
switch (accessLevel) {
default:
case EAccessLevel.User:
List<Album> filteredAlbums;
// Only publicly shared albums or owned by user
filteredAlbums = albums.Where(album => album.Assets != null && (album.UserOwnerId == uid || album.Assets.Any(asset => asset.IsPubliclyShared))).ToList();
// Add shared albums
filteredAlbums.AddRange(albums.Where(album => album.Assets != null && album.Assets.Any(x => x.SharedWith!.Any(user => user.Id == uid))));
return Ok(filteredAlbums.Select(x => x.ToAlbumPreviewDto()).ToList());
case EAccessLevel.Curator:
case EAccessLevel.Admin:
// All albums
return Ok(albums.Select(x => x.ToAlbumPreviewDto()).ToList());
}
if(pagingOptions.Page < 0 || pagingOptions.PageSize < 1 || pagingOptions.PageSize > PagedParametersDto.MaxPageSize) return BadRequest();
var albums = albumRepository.SearchQuery(
pagingOptions.Search ?? string.Empty,
pagingOptions.Page,
pagingOptions.PageSize,
pagingOptions.SortBy,
pagingOptions.SortAsc,
pagingOptions.Unassigned,
uid ?? default,
accessLevel,
pagingOptions.AssetUploadedBy
).ToList();
return Ok(albums);
}
/// <summary>
@@ -103,7 +80,7 @@ public class AlbumController(
/// <param name="albumDto">The album update data.</param>
/// <returns>200 on success, 403 if forbidden, 404 if not found.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult Update([FromRoute] Guid id, [FromBody] AlbumUpdateDto albumDto) {
Guid? uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
@@ -113,28 +90,25 @@ public class AlbumController(
// 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:
// Users can't update albums
return Forbid();
case EAccessLevel.Curator:
// Curators can only update albums they own
if (album.UserOwnerId != uid) return Forbid();
break;
case EAccessLevel.Admin:
// Admins can update any album
break;
}
// Update album properties
album.Title = albumDto.Name ?? album.Title;
album.UpdatedAt = DateTime.Now;
album.PersonOwnerId = albumDto.Person ?? album.PersonOwnerId;
album.UserOwnerId = albumDto.Owner ?? album.UserOwnerId;
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.CoverAssetId = albumDto.CoverAssetId ?? album.CoverAssetId;
// Save changes
albumRepository.Update(album);
albumRepository.Save();
return Ok();
}
@@ -145,7 +119,7 @@ public class AlbumController(
/// <param name="bulkDto">The bulk update data with album IDs and shared values.</param>
/// <returns>200 on success, 403 if forbidden.</returns>
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult BulkUpdate([FromBody] BulkDto<AlbumUpdateDto> bulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
@@ -156,9 +130,13 @@ public class AlbumController(
case EAccessLevel.User:
// Users can't update albums
return Forbid();
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();
if (albums.Count == 0) return Forbid();
break;
case EAccessLevel.Curator:
// Curators can only update albums they own
albums = albums.Where(x => x.UserOwnerId == uid).ToList();
// Curators can update any album
break;
case EAccessLevel.Admin:
// Admins can update any album
@@ -167,10 +145,11 @@ public class AlbumController(
foreach (var album in albums) {
album.Title = bulkDto.Data.Name ?? album.Title;
album.UpdatedAt = DateTime.Now;
album.UpdatedAt = DateTime.UtcNow;
album.PersonOwnerId = bulkDto.Data.Person ?? album.PersonOwnerId;
album.UserOwnerId = bulkDto.Data.Owner ?? album.UserOwnerId;
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;
}
albumRepository.UpdateBulk(albums);
@@ -185,27 +164,26 @@ public class AlbumController(
/// <param name="albumDto">The album creation data.</param>
/// <returns>200 on success.</returns>
[HttpPut]
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Curator,Admin")]
public ActionResult Create([FromBody] AlbumCreateDto albumDto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
// If not admin, the owner of a new album is the current user
albumDto.Owner = accessLevel == EAccessLevel.Admin ? albumDto.Owner : uid;
var assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>();
var album = new Album {
Id = Guid.NewGuid(),
Title = albumDto.Name,
CreatedAt = DateTime.Now,
CreatedAt = DateTime.UtcNow,
PersonOwnerId = albumDto.Person ?? null,
UserOwnerId = albumDto.Owner,
Assets = albumDto.Assets != null ? assetRepository.FindBulk(albumDto.Assets).ToList() : new List<Asset>()
Visibility = albumDto.Visibility,
CoverAssetId = albumDto.CoverAssetId ?? assets.OrderBy(a => a.OriginalFilename).FirstOrDefault()?.Id,
Assets = assets
};
albumRepository.Insert(album);
albumRepository.Save();
return Ok();
return Ok(album.Id);
}
/// <summary>
@@ -214,18 +192,23 @@ public class AlbumController(
/// <param name="albumIds">The list of album IDs to remove.</param>
/// <returns>200 on success, 403 if forbidden.</returns>
[HttpDelete]
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult BulkDelete([FromBody] List<Guid> albumIds) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var albums = albumRepository.FindBulk(albumIds).ToList();
// If the user is not an admin, filter out albums they do not own
if (accessLevel != EAccessLevel.Admin) { albums = albums.Where(x => x.UserOwnerId == uid).ToList(); }
// Filter by access level
switch (accessLevel) {
case EAccessLevel.Maintainer:
albums = albums.Where(a => a.PersonOwnerId != null && personRepository.IsMaintainerOf(uid!.Value, a.PersonOwnerId.Value)).ToList();
break;
case EAccessLevel.User:
return Forbid();
}
// If no albums are left after filtering, return a 403
if (albumIds.Count == 0) return Forbid();
if (albums.Count == 0) return Forbid();
albums.ForEach(albumRepository.Remove);
albumRepository.Save();
@@ -239,7 +222,7 @@ public class AlbumController(
/// <param name="id">The album ID.</param>
/// <returns>200 on success, 404 if not found, 403 if forbidden.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public ActionResult Delete(Guid id) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
@@ -248,10 +231,16 @@ public class AlbumController(
// If the album does not exist, return a 404
if (album == null) return NotFound();
// If the user is not the owner or an admin, return a 403
if (accessLevel != EAccessLevel.Admin && album.UserOwnerId != uid) return Forbid();
switch (accessLevel) {
case EAccessLevel.Maintainer when album.PersonOwnerId == null || !personRepository.IsMaintainerOf(uid!.Value, album.PersonOwnerId.Value):
return Forbid();
case EAccessLevel.User:
return Forbid();
}
albumRepository.Remove(album);
albumRepository.Save();
return Ok();
}

View File

@@ -18,13 +18,15 @@ namespace Lactose.Controllers;
/// <param name="authService">Authentication service.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="userRepository">User repository.</param>
/// <param name="personRepository">Person repository (for maintainer scope checks).</param>
[ApiController]
[Route("api/[controller]")]
public class AssetController(
ILogger<AssetController> logger,
LactoseAuthService authService,
IAssetRepository assetRepository,
IUserRepository userRepository
IUserRepository userRepository,
IPersonRepository personRepository
) : ControllerBase {
/// <summary>
/// Gets an asset by its ID with full details, respecting access level.
@@ -44,50 +46,14 @@ public class AssetController(
"""
);
var asset = assetRepository.Find(id);
var asset = assetRepository.FindVisible(id, uid, accessLevel);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
logger.LogWarning($"Request asset {id} not found or not visible!");
return NotFound();
}
switch (accessLevel) {
case EAccessLevel.User: {
//Not publicly shared && Not shared with the user
if (!asset.IsPubliclyShared && asset.SharedWith?.Find(x => x.Id == uid) == null) {
logger.LogTrace($"{asset.Id} is not shared with user {uid}");
return Unauthorized();
}
//Deleted asset
if (asset.DeletedAt != null) {
logger.LogTrace($"{asset.Id} is deleted and thus not exists for this user");
return NotFound();
}
break;
}
case EAccessLevel.Curator: {
//Deleted asset owned by the user
if (asset.DeletedAt != null)
if (asset.Owner?.Id != uid) {
logger.LogTrace(
$"{asset.Id} is deleted and it's not owned by this user so it does not exists for this user"
);
return NotFound();
} else { logger.LogTrace($"{asset.Id} is deleted but it's owned by this user so it exists for this user"); }
break;
}
case EAccessLevel.Admin: {
//Admins will see this regardless
logger.LogTrace($"{asset.Id} is being requested by an admin, sending it regardless of the visibility state");
break;
}
}
AssetDto dto = asset.ToFullAssetsDto(accessLevel);
AssetDto dto = asset.ToFullAssetsDto(accessLevel, uid);
logger.LogTrace(
$"""
@@ -95,11 +61,11 @@ public class AssetController(
dto.Id: {dto.Id}
dto.FileName: {dto.FileName}
dto.AssetType: {dto.AssetType}
dto.IsPublic: {dto.IsPublic}
dto.Visibility: {dto.Visibility}
dto.CreatedAt: {dto.CreatedAt}
dto.UpdatedAt: {dto.UpdatedAt}
dto.DeletedAt: {dto.DeletedAt}
dto.Owner: {dto.Owner.Id} {dto.Owner.Username}
dto.UploadedBy: {dto.UploadedBy.Id} {dto.UploadedBy.Username}
"""
);
@@ -107,55 +73,51 @@ public class AssetController(
}
/// <summary>
/// Searches assets by date range with pagination.
/// Searches assets with optional date range, type filter, and random ordering with pagination.
/// </summary>
/// <param name="searchOptionsDto">Search options including date range and pagination.</param>
/// <param name="searchOptionsDto">Search options including date range, type filter, random ordering, and pagination.</param>
/// <returns>A list of asset previews.</returns>
[HttpGet]
public ActionResult<List<AssetPreviewDto>> GetAll([FromQuery] AssetSearchOptionsDto searchOptionsDto) {
var uid = authService.GetUserData(User)?.Id;
EAccessLevel accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
DateTime from = DateTime.Parse(searchOptionsDto.StartDate ?? string.Empty);
DateTime to = DateTime.Parse(searchOptionsDto.EndDate ?? string.Empty);
var userData = authService.GetUserData(User);
var uid = userData?.Id;
EAccessLevel accessLevel = userData?.AccessLevel ?? EAccessLevel.User;
DateTime? from = string.IsNullOrEmpty(searchOptionsDto.StartDate) ? null : DateTime.Parse(searchOptionsDto.StartDate);
DateTime? to = string.IsNullOrEmpty(searchOptionsDto.EndDate) ? null : DateTime.Parse(searchOptionsDto.EndDate);
if (from > to) {
if (from.HasValue && to.HasValue && from > to) {
logger.LogWarning("Invalid date range provided");
return BadRequest();
}
if (searchOptionsDto.Page < 1 || searchOptionsDto.PageSize < 1) {
if (searchOptionsDto.Page < 0 || searchOptionsDto.PageSize < 1 || searchOptionsDto.PageSize > PagedParametersDto.MaxPageSize) {
logger.LogWarning("Invalid pagination parameters provided");
return BadRequest();
}
var assets = assetRepository.FindByDateRange(from, to, searchOptionsDto.Page, searchOptionsDto.PageSize);
var assets = assetRepository.GetAssets(
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
uid, accessLevel
);
logger.LogTrace(
$"""
Requested assets
by user: {uid}
by user: {uid}
access level: {accessLevel}
type: {searchOptionsDto.Type}
random: {searchOptionsDto.Random}
date range: {from} - {to}
"""
page: {searchOptionsDto.Page}
"""
);
switch (accessLevel) {
case EAccessLevel.User: {
//Only publicly shared assets and assets shared with the user that are not deleted
assets = assets.Where(
a => a.IsPubliclyShared || a.SharedWith?.Find(x => x.Id == uid) != null && a.DeletedAt != null
);
var dtoList = assets.ToAssetPreviewDto(accessLevel).ToList();
break;
}
case EAccessLevel.Curator:
case EAccessLevel.Admin: {
//Admins will see this regardless
break;
}
}
return Ok(assets.ToAssetPreviewDto());
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
Response.Headers["X-Total-Count"] = total.ToString();
return Ok(dtoList);
}
/// <summary>
@@ -165,32 +127,35 @@ public class AssetController(
/// <param name="dto">The asset update data.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Curator,Admin")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] AssetUpdateDto dto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.Find(id);
var asset = assetRepository.FindWithAlbums(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to update asset {id} that they do not own");
return Unauthorized();
}
string log = "";
asset.IsPubliclyShared = dto.IsPublic ?? asset.IsPubliclyShared;
log += $"PubliclyShared: {asset.IsPubliclyShared} -> {dto.IsPublic ?? asset.IsPubliclyShared}\n";
asset.Visibility = dto.Visibility ?? asset.Visibility;
log += $"Visibility: {asset.Visibility} -> {dto.Visibility ?? asset.Visibility}\n";
asset.DeletedAt = dto.DeletedAt ?? asset.DeletedAt;
log += $"DeletedAt: {asset.DeletedAt} -> {dto.DeletedAt ?? asset.DeletedAt}\n";
asset.Owner = dto.Owner != null ? userRepository.Find(dto.Owner.Value) : asset.Owner;
log += $"Owner: {asset.Owner?.Id} -> {dto.Owner ?? asset.Owner?.Id}\n";
if (accesslevel == EAccessLevel.Admin) {
asset.UploadedBy = dto.UploadedBy ?? asset.UploadedBy;
log += $"UploadedBy: {asset.UploadedBy} -> {dto.UploadedBy ?? asset.UploadedBy}\n";
}
logger.LogTrace($"Updating asset {id}\n{log}");
@@ -206,7 +171,7 @@ public class AssetController(
/// <param name="bulkDto">The bulk update data with asset IDs and shared values.</param>
/// <returns>200 on success, or an error response.</returns>
[HttpPost]
[Authorize(Roles = "Curator,Admin")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<AssetUpdateDto> bulkDto) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
@@ -225,13 +190,19 @@ public class AssetController(
}
//If the user is not an admin, they can only update their own assets
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(
x => {
x.IsPubliclyShared = bulkDto.Data.IsPublic ?? x.IsPubliclyShared;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
x.Owner = bulkDto.Data.Owner != null ? userRepository.Find(bulkDto.Data.Owner.Value) : x.Owner;
x.Visibility = bulkDto.Data.Visibility ?? x.Visibility;
x.DeletedAt = bulkDto.Data.DeletedAt ?? x.DeletedAt;
if (accesslevel == EAccessLevel.Admin) {
x.UploadedBy = bulkDto.Data.UploadedBy ?? x.UploadedBy;
}
}
);
@@ -245,23 +216,24 @@ public class AssetController(
/// <param name="id">The asset ID.</param>
/// <returns>200 on success, 404 if not found, 401 if unauthorized.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Curator,Admin")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult Delete([FromRoute] Guid id) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
var asset = assetRepository.Find(id);
var asset = assetRepository.FindWithAlbums(id);
if (asset == null) {
logger.LogWarning($"Request asset {id} not found in the database!");
return NotFound();
}
if (asset.Owner?.Id != uid && accesslevel != EAccessLevel.Admin) {
if (asset.UploadedBy != uid && accesslevel != EAccessLevel.Admin && accesslevel != EAccessLevel.Curator
&& (accesslevel != EAccessLevel.Maintainer || !uid.HasValue || !IsMaintainedByUser(uid.Value, asset))) {
logger.LogWarning($"User {authService.GetUserData(User)?.Id} is trying to delete asset {id} that they do not own");
return Unauthorized();
}
asset.DeletedAt = DateTime.Now;
asset.DeletedAt = DateTime.UtcNow;
assetRepository.Update(asset);
assetRepository.Save();
@@ -274,7 +246,7 @@ public class AssetController(
/// <param name="ids">The list of asset IDs to delete.</param>
/// <returns>200 on success, or an error response.</returns>
[HttpDelete]
[Authorize(Roles = "Curator,Admin")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids) {
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
@@ -293,9 +265,12 @@ public class AssetController(
}
//If the user is not an admin, they can only delete their own assets
if (accesslevel != EAccessLevel.Admin) enumerable = enumerable.Where(x => x.OwnerId == uid).ToList();
if (accesslevel != EAccessLevel.Admin && uid.HasValue)
enumerable = accesslevel == EAccessLevel.Maintainer
? enumerable.Where(x => x.UploadedBy == uid || IsMaintainedByUser(uid.Value, x)).ToList()
: enumerable.Where(x => x.UploadedBy == uid).ToList();
enumerable.ForEach(x => x.DeletedAt = DateTime.Now);
enumerable.ForEach(x => x.DeletedAt = DateTime.UtcNow);
assetRepository.UpdateBulk(enumerable);
return Ok();
@@ -313,23 +288,22 @@ public class AssetController(
var uid = authService.GetUserData(User)?.Id;
var accesslevel = authService.GetUserData(User)!.AccessLevel;
if (userRepository.Find(dto.Owner) == null) {
logger.LogWarning($"User {dto.Owner} does not exist in the database");
return BadRequest();
}
if (dto.UploadedBy.HasValue) {
if (userRepository.Find(dto.UploadedBy.Value) == null) {
logger.LogWarning($"User {dto.UploadedBy} does not exist in the database");
return BadRequest();
}
if (accesslevel != EAccessLevel.Admin && dto.Owner != uid) {
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.Owner}");
return Unauthorized();
if (accesslevel != EAccessLevel.Admin && dto.UploadedBy != uid) {
logger.LogWarning($"User {uid} is trying to create an asset for user {dto.UploadedBy}");
return Unauthorized();
}
}
//TODO: This makes exactly zero sense. How are we supposed to create an asset externally here?
// Does this even need to exist?
var asset = new Asset {
Id = Guid.NewGuid(),
Type = dto.AssetType,
IsPubliclyShared = dto.IsPublic,
OwnerId = dto.Owner,
Visibility = dto.Visibility,
UploadedBy = dto.UploadedBy,
CreatedAt = dto.CreatedAt,
MimeType = dto.MimeType,
ResolutionWidth = dto.ResolutionWidth,
@@ -346,4 +320,14 @@ public class AssetController(
return Ok(asset.Id);
}
/// <summary>
/// Checks whether a Maintainer has access to an asset through the cosplayers they maintain.
/// A Maintainer can access assets that are in any album belonging to a person they maintain.
/// </summary>
/// <param name="uid">The maintainer's user ID.</param>
/// <param name="asset">The asset to check.</param>
/// <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;
}

View File

@@ -50,19 +50,23 @@ public class AuthController(
);
}
if (user.BannedAt != null) {
return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() {
return StatusCode(
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is banned"
}
);
);
}
if (user.DeletedAt != null) {
return StatusCode(StatusCodes.Status100Continue,new AuthResultDto() {
return StatusCode(
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is disabled"
}
);
);
}
PasswordVerificationResult result = passwordHasher.VerifyHashedPassword(user, user.Password, userDto.Password);
@@ -75,7 +79,7 @@ public class AuthController(
case PasswordVerificationResult.Success: break;
}
user.LastLogin = DateTime.Now;
user.LastLogin = DateTime.UtcNow;
var token = authService.GenerateAccessToken(user);
var refreshToken = authService.GenerateRefreshToken(user);
@@ -112,8 +116,8 @@ public class AuthController(
Username = dto.Username,
Email = dto.Email,
AccessLevel = EAccessLevel.User,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
user.Password = passwordHasher.HashPassword(user, dto.Password);
@@ -123,19 +127,6 @@ public class AuthController(
return Ok(user.Id);
}
/// <summary>
/// Checks the current authentication status and returns the authenticated user data.
/// </summary>
/// <returns>The authenticated user's data.</returns>
[Authorize]
[HttpPost()]
public ActionResult<LactoseAuthenticatedUser> Check() {
// this.User;
//ClaimsPrincipal principal = User;
LactoseAuthenticatedUser? identity = authService.GetUserData(User);
return Ok(identity);
}
/// <summary>
/// Logs out the user by invalidating their refresh token.
/// </summary>
@@ -178,7 +169,7 @@ public class AuthController(
if (user.BannedAt != null) {
return StatusCode(
StatusCodes.Status100Continue,
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is banned"
@@ -188,7 +179,7 @@ public class AuthController(
if (user.DeletedAt != null) {
return StatusCode(
StatusCodes.Status100Continue,
StatusCodes.Status403Forbidden,
new AuthResultDto() {
Success = false,
ErrorMessage = "User is disabled"
@@ -210,11 +201,11 @@ public class AuthController(
Token = authService.GenerateAccessToken(user)
};
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.Now) {
if ((user.RefreshTokenExpires - TimeSpan.FromMinutes(LactoseAuthService.BaseExpirationTime)) < DateTime.UtcNow) {
var refreshToken = authService.GenerateRefreshToken(user);
authDto.RefreshToken = refreshToken;
user.RefreshToken = refreshToken;
user.RefreshTokenExpires = DateTime.Now.AddMinutes(LactoseAuthService.LongExpirationTime);
user.RefreshTokenExpires = DateTime.UtcNow.AddMinutes(LactoseAuthService.LongExpirationTime);
userRepository.Save();
}

View File

@@ -1,7 +1,7 @@
using Butter.Dtos;
using Butter.Dtos.Jobs;
using Butter.Types;
using Lactose.Jobs;
using Lactose.Mapper;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -35,7 +35,8 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// </summary>
[HttpGet("past")]
[Authorize(Roles = "Admin")]
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 1, [FromQuery] int pageSize = 5) {
public ActionResult<PastJobsResponse> GetPast([FromQuery] int page = 0, [FromQuery] int pageSize = 5) {
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);
@@ -44,13 +45,16 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
/// <summary>
/// Returns children of a specific parent job (active + past).
/// 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>
[HttpGet("{parentId}/children")]
[Authorize(Roles = "Admin")]
public ActionResult<List<JobStatusDto>> GetChildren(Guid parentId) {
public ActionResult<ChildrenResponse> GetChildren(Guid parentId, [FromQuery] DateTime? since) {
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
return Ok(jobManager.GetChildren(parentId));
var children = jobManager.GetChildren(parentId, since);
return Ok(new ChildrenResponse { Children = children, Since = DateTime.UtcNow });
}
/// <summary>
@@ -115,8 +119,7 @@ public class JobsController(JobManager jobManager, JobScheduler jobScheduler, La
if (jobRequest.Parameters.Count != 0)
return BadRequest("PreviewGeneration job does not require any parameters.");
try { jobScheduler.QueuePreviewGeneration(null); }
catch (NotImplementedException) { return StatusCode(501, "PreviewGeneration is not yet implemented."); }
jobScheduler.QueuePreviewGeneration(null);
return Ok();
case EJobType.MetadataExtraction:
if (jobRequest.Parameters.Count != 0)
@@ -130,6 +133,16 @@ 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);
return Ok();
case EJobType.PHashGeneration:
if (jobRequest.Parameters.Count > 1)
return BadRequest("PHashGeneration job takes zero or one parameter (the asset ID).");

View File

@@ -1,7 +1,12 @@
using Butter.Types;
using Lactose.Configuration;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
namespace Lactose.Controllers;
@@ -10,17 +15,19 @@ namespace Lactose.Controllers;
/// </summary>
/// <param name="authService">Authentication service.</param>
/// <param name="mediaRepository">Media repository.</param>
/// <param name="assetRepository">Asset repository.</param>
/// <param name="signKeyOptions">JWT signing key configuration.</param>
[ApiController]
[Authorize]
[AllowAnonymous]
[Route("api/[controller]")]
public class MediaController(
//TODO: Implement logging
//ILogger<MediaController> logger
LactoseAuthService authService,
IMediaRepository mediaRepository
IMediaRepository mediaRepository,
IAssetRepository assetRepository,
IOptions<SignKeyConfiguration> signKeyOptions
) : ControllerBase {
/// <summary>
/// Gets the original image file for an asset.
/// </summary>
@@ -29,9 +36,15 @@ public class MediaController(
[HttpGet("{id}")]
[HttpGet("original/{id}")]
public ActionResult GetImage(Guid id) {
var user = authService.GetUserData(User);
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
var data = mediaRepository.GetOriginalData(id, user?.Id);
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
return PhysicalFile(asset.OriginalPath, asset.MimeType);
}
var data = mediaRepository.GetOriginalData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
@@ -44,9 +57,16 @@ public class MediaController(
/// <returns>The physical thumbnail file, or 404 if not found or access denied.</returns>
[HttpGet("thumb/{id}")]
public ActionResult GetThumb(Guid id) {
var user = authService.GetUserData(User);
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
var data = mediaRepository.GetThumbData(id, user?.Id);
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
return PhysicalFile(asset.ThumbnailPath, "image/webp");
}
var data = mediaRepository.GetThumbData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
@@ -59,11 +79,58 @@ public class MediaController(
/// <returns>The physical preview file, or 404 if not found or access denied.</returns>
[HttpGet("preview/{id}")]
public ActionResult GetPreview(Guid id) {
var user = authService.GetUserData(User);
var user = GetUserFromRequest();
var asset = assetRepository.Find(id);
if (asset == null) return NotFound();
var data = mediaRepository.GetPreviewData(id, user?.Id);
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
return PhysicalFile(asset.PreviewPath, "image/webp");
}
var data = mediaRepository.GetPreviewData(id, user?.Id, user?.AccessLevel ?? EAccessLevel.User);
if (data == null) return NotFound();
return PhysicalFile(data.Path, data.MimeType);
}
}
/// <summary>
/// Extracts the authenticated user from the request, first trying the Authorization header,
/// then falling back to the <c>token</c> query parameter for browser image requests.
/// </summary>
private LactoseAuthenticatedUser? GetUserFromRequest() {
var user = authService.GetUserData(User);
if (user != null) return user;
var token = Request.Query["token"].FirstOrDefault();
if (string.IsNullOrEmpty(token)) return null;
try {
var handler = new JwtSecurityTokenHandler();
var key = signKeyOptions.Value.GetSecurityKey();
var principal = handler.ValidateToken(token, new TokenValidationParameters {
IssuerSigningKey = key,
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true
}, out _);
return authService.GetUserData(principal);
} catch {
return null;
}
}
/// <summary>
/// Determines whether the requester can access asset files directly without repository-level filtering.
/// </summary>
/// <param name="user">The authenticated user, if any.</param>
/// <param name="deletedAt">The asset deletion timestamp.</param>
/// <returns><see langword="true"/> for admins, or for curators when the asset is not deleted; otherwise <see langword="false"/>.</returns>
private static bool CanAccessAssetDirectly(LactoseAuthenticatedUser? user, DateTime? deletedAt) {
return user?.AccessLevel switch {
EAccessLevel.Admin => true,
EAccessLevel.Curator => deletedAt == null,
_ => false
};
}
}

View File

@@ -1,36 +1,71 @@
using Butter.Dtos;
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Repositories;
using Lactose.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
namespace Lactose.Controllers;
/// <summary>
/// Manages people (faces/individuals identified in assets). Not yet implemented.
/// Manages people (faces/individuals identified in assets).
/// </summary>
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class PersonController : ControllerBase {
public class PersonController(
IPersonRepository personRepository,
IAlbumRepository albumRepository,
LactoseAuthService authService
) : ControllerBase {
/// <summary>
/// Gets a person by ID with optional associated assets and albums.
/// Gets a person by ID with their associated albums, filtered by access level.
/// </summary>
/// <summary>
/// Gets a person by ID with their associated albums, filtered by access level and paginated.
/// </summary>
/// <param name="id">The person ID.</param>
/// <param name="assets">Maximum number of associated assets to include.</param>
/// <param name="albums">Maximum number of associated albums to include.</param>
/// <returns>The person details.</returns>
/// <param name="albumPaging">Pagination, search, and sort parameters for albums.</param>
/// <returns>The person details with albums, or 404 if not found.</returns>
[HttpGet("{id}")]
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromRoute] uint? assets=100, [FromRoute] uint? albums=100) => throw new NotImplementedException();
public ActionResult<PersonDetailedDto> Get([FromRoute] Guid id, [FromQuery] PagedSearchParametersDto albumPaging) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var person = personRepository.FindVisible(id, uid, accessLevel,
albumPaging.Search, albumPaging.SortBy, albumPaging.SortAsc,
albumPaging.Page, albumPaging.PageSize);
if (person == null) return NotFound();
return Ok(person.ToPersonDetailedDto(accessLevel));
}
/// <summary>
/// Searches people with pagination.
/// 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 and search parameters.</param>
/// <returns>A list of people previews.</returns>
/// <param name="pagedSearch">Pagination, search, and sort parameters.</param>
/// <returns>A paginated list of person previews.</returns>
[HttpGet]
public ActionResult<List<PersonPreviewDto>> GetAll(PagedSearchParametersDto pagedSearch) => throw new NotImplementedException();
public ActionResult<List<PersonPreviewDto>> GetAll([FromQuery] PersonSearchParametersDto pagedSearch) {
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(
pagedSearch.Search ?? string.Empty,
pagedSearch.Page,
pagedSearch.PageSize,
pagedSearch.SortBy,
pagedSearch.SortAsc,
uid ?? default,
accessLevel
).ToList();
return Ok(people);
}
/// <summary>
/// Updates a person.
/// </summary>
@@ -38,8 +73,37 @@ public class PersonController : ControllerBase {
/// <param name="dto">The updated person data.</param>
/// <returns>200 on success.</returns>
[HttpPost("{id}")]
[Authorize(Roles = "Admin, Curator")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) =>throw new NotImplementedException();
[Authorize(Roles = "Maintainer,Curator,Admin")]
public IActionResult Update([FromRoute] Guid id, [FromBody] PersonUpdateDto dto) {
var uid = authService.GetUserData(User)?.Id;
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
var person = personRepository.Find(id);
if (person == null) return NotFound();
if (accessLevel == EAccessLevel.Maintainer) {
if (uid == null || !personRepository.IsMaintainerOf(uid.Value, id))
return Forbid();
}
person.Name = dto.Name;
person.ProfileAssetId = dto.ProfileAssetId;
person.ProfileCropX = dto.ProfileCropX;
person.ProfileCropY = dto.ProfileCropY;
person.ProfileCropZoom = dto.ProfileCropZoom;
if (dto.Visibility != null && accessLevel >= EAccessLevel.Maintainer)
person.Visibility = dto.Visibility.Value;
if (dto.MaintainerUserIds != null && accessLevel >= EAccessLevel.Curator)
personRepository.SetMaintainers(person.Id, dto.MaintainerUserIds);
person.UpdatedAt = DateTime.UtcNow;
personRepository.Save();
return Ok();
}
/// <summary>
/// Updates multiple people in a bulk operation.
/// </summary>
@@ -47,25 +111,75 @@ public class PersonController : ControllerBase {
/// <returns>200 on success.</returns>
[HttpPost]
[Authorize(Roles = "Admin, Curator")]
public IStatusCodeActionResult BulkUpdate([FromBody]BulkDto<PersonUpdateDto> personBulkDto)=>throw new NotImplementedException();
public IActionResult BulkUpdate([FromBody] BulkDto<PersonUpdateDto> personBulkDto) {
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.UpdatedAt = DateTime.UtcNow;
}
personRepository.Save();
return Ok();
}
/// <summary>
/// Deletes a person by ID.
/// Deletes a person by ID (soft delete).
/// </summary>
/// <param name="id">The person ID.</param>
/// <returns>200 on success.</returns>
/// <returns>200 on success, 404 if not found.</returns>
/// <summary>
/// Deletes a person by ID (hard delete). Cascades to unlink all associated albums.
/// </summary>
/// <returns>200 on success, 404 if not found.</returns>
[HttpDelete("{id}")]
[Authorize(Roles = "Admin, Curator")]
public IStatusCodeActionResult Delete([FromRoute] Guid id)=>throw new NotImplementedException();
public IActionResult Delete([FromRoute] Guid id) {
var person = personRepository.Find(id);
if (person == null) return NotFound();
// Unlink all albums owned by this person
var albums = albumRepository.FindByPerson(id).ToList();
foreach (var album in albums) {
album.PersonOwnerId = null;
}
personRepository.Remove(person);
personRepository.Save();
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>
/// <returns>200 on success.</returns>
[HttpDelete]
[Authorize(Roles = "Admin, Curator")]
public IStatusCodeActionResult BulkDelete([FromBody] List<Guid> ids)=>throw new NotImplementedException();
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
var people = personRepository.FindBulk(ids).ToList();
foreach (var person in people) {
// Unlink all albums owned by this person
var albums = albumRepository.FindByPerson(person.Id).ToList();
foreach (var album in albums) {
album.PersonOwnerId = null;
}
personRepository.Remove(person);
}
personRepository.Save();
return Ok();
}
/// <summary>
/// Creates a new person.
/// </summary>
@@ -73,5 +187,15 @@ public class PersonController : ControllerBase {
/// <returns>200 with the new person ID.</returns>
[HttpPut]
[Authorize(Roles = "Admin, Curator")]
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto)=>throw new NotImplementedException();
public ActionResult<Guid> Create([FromBody] PersonCreateDto dto) {
var person = new Models.Person {
Id = Guid.NewGuid(),
Name = dto.Name,
ProfileAssetId = dto.ProfileAssetId,
CreatedAt = DateTime.UtcNow
};
personRepository.Insert(person);
personRepository.Save();
return Ok(person.Id);
}
}

View File

@@ -0,0 +1,30 @@
using Butter.Dtos.Stats;
using Lactose.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Lactose.Controllers;
/// <summary>
/// Provides aggregate statistics about the MilkyShots instance.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="statsRepository">Stats repository.</param>
[ApiController]
[Route("api/[controller]")]
[Authorize(Roles = "Curator,Admin")]
public class StatsController(
ILogger<StatsController> logger,
IStatsRepository statsRepository
) : ControllerBase {
/// <summary>
/// Returns comprehensive aggregate statistics about the instance.
/// </summary>
/// <returns>A <see cref="StatsDto"/> with all gathered statistics.</returns>
[HttpGet]
public ActionResult<StatsDto> Get() {
logger.LogTrace("Stats requested");
var stats = statsRepository.GetStats();
return Ok(stats);
}
}

View File

@@ -67,10 +67,10 @@ public class TagController(
/// <param name="pagedSearch">Pagination and search parameters.</param>
/// <returns>A list of tags matching the search criteria.</returns>
[HttpGet]
public ActionResult<List<TagDto>> GetAll(PagedSearchParametersDto pagedSearch) {
public ActionResult<List<TagDto>> GetAll([FromQuery] PagedSearchParametersDto pagedSearch) {
pagedSearch.Search ??= string.Empty;
if(pagedSearch.Page < 0) { return BadRequest("Page must be greater than 0"); }
if(pagedSearch.PageSize < 0) { return BadRequest("PageSize must be greater than 0"); }
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<TagDto> tagsDto = [];
@@ -92,7 +92,7 @@ public class TagController(
/// <param name="id">The tag ID.</param>
/// <param name="tagDto">The updated tag data.</param>
/// <returns>200 on success, 404 if not found, 409 if name conflict.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost("{id}")]
public IStatusCodeActionResult Update([FromRoute] Guid id, [FromBody] TagUpdateDto tagDto) {
@@ -108,7 +108,7 @@ public class TagController(
/// </summary>
/// <param name="tagBulkDto">The bulk update data with IDs and shared update values.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPost]
public IStatusCodeActionResult BulkUpdate([FromBody] BulkDto<TagUpdateDto> tagBulkDto) {
@@ -127,7 +127,7 @@ public class TagController(
/// </summary>
/// <param name="id">The tag ID.</param>
/// <returns>200 on success, 404 if not found.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete("{id}")]
public ActionResult Delete([FromRoute] Guid id) {
IStatusCodeActionResult result = DeleteTag(id);
@@ -142,7 +142,7 @@ public class TagController(
/// </summary>
/// <param name="ids">The list of tag IDs to delete.</param>
/// <returns>200 on success, or the first error response.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpDelete]
public IActionResult BulkDelete([FromBody] List<Guid> ids) {
foreach (Guid id in ids) {
@@ -160,7 +160,7 @@ public class TagController(
/// </summary>
/// <param name="tagDto">The tag creation data.</param>
/// <returns>200 on success, 409 if a tag with the same name already exists, 400 if parent not found.</returns>
[Authorize(Roles = "Admin, Curator")]
[Authorize(Roles = "Maintainer,Curator,Admin")]
[HttpPut]
public ActionResult Create([FromBody] TagCreateDto tagDto) {

View File

@@ -50,6 +50,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");
var user = new User {
Username = userCreateDto.Username,
Email = userCreateDto.Email,
@@ -57,8 +60,7 @@ namespace Lactose.Controllers {
AccessLevel = userCreateDto.AccessLevel ?? EAccessLevel.User
};
if (userRepository.GetAll().Any(x => x.Email == user.Email)) return Conflict("Email already exists");
if (userRepository.GetAll().Any(x => x.Username == user.Username)) return Conflict("Username already exists");
user.Password = passwordHasher.HashPassword(user, userCreateDto.Password);
userRepository.Insert(user);
userRepository.Save();
@@ -81,34 +83,28 @@ namespace Lactose.Controllers {
return Unauthorized("Unauthorized");
}
//TODO: Lower level of access should fill less information of users
logger.LogDebug($"Sending user with id {id}");
User? user = userRepository.Find(id);
User? user = userRepository.FindWithMaintainedPersons(id);
if (user is null) return NotFound();
return Ok(user.ToGetUsersDto());
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
/// <summary>
/// Gets all users.
/// Gets all users (admin/curator only).
/// </summary>
/// <returns>A list of all users.</returns>
[Authorize(Roles = "Admin,Curator")]
[HttpGet]
public ActionResult<List<UserInfoDto>> GetAll() {
logger.LogDebug("Sending Users list");
LactoseAuthenticatedUser? authenticatedUser = authService.GetUserData(User);
EAccessLevel viewerAccessLevel = authenticatedUser?.AccessLevel ?? EAccessLevel.User;
if (authenticatedUser == null) {
logger.LogWarning(@"Anonymous User hasn't enough permissions to create a User. it shouldn't be here");
return Unauthorized("Unauthorized");
}
logger.LogDebug("Sending Users list");
//TODO: Lower level of access should fill less information of users
//if (false) return Unauthorized();
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto()).ToList());
return Ok(userRepository.GetAll().Select(u => u.ToGetUsersDto(viewerAccessLevel, authenticatedUser?.Id)).ToList());
}
@@ -147,20 +143,41 @@ namespace Lactose.Controllers {
if (dto.Email != null) { user.Email = dto.Email; }
if (dto.Password != null) { user.Password = passwordHasher.HashPassword(user, dto.Password); }
if (dto.Password != null) {
if (dto.OldPassword != null) {
if (passwordHasher.VerifyHashedPassword(user, user.Password, dto.OldPassword) == PasswordVerificationResult.Failed) {
return BadRequest("Old password is incorrect");
}
} else if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
return BadRequest("Old password is required to change your own password");
}
if (dto.IsDeleted != null) { user.DeletedAt = dto.IsDeleted.Value ? DateTime.Now : null; }
if (authenticatedUser.AccessLevel != EAccessLevel.Admin) {
if (dto.Password.Length < 8) return BadRequest("Password must be at least 8 characters long");
if (!dto.Password.Any(char.IsUpper)) return BadRequest("Password must contain at least one uppercase letter");
if (!dto.Password.Any(char.IsLower)) return BadRequest("Password must contain at least one lowercase letter");
if (!dto.Password.Any(c => char.IsDigit(c) || char.IsPunctuation(c) || char.IsSymbol(c))) return BadRequest("Password must contain at least one number or special character");
}
user.Password = passwordHasher.HashPassword(user, dto.Password);
}
if (dto.IsDeleted != null) { user.DeletedAt = dto.IsDeleted.Value ? DateTime.UtcNow : null; }
if (authenticatedUser.AccessLevel == EAccessLevel.Admin) {
if (dto.IsBanned != null) {
if (user.BannedAt != null) user.BannedAt = dto.IsBanned.Value ? DateTime.Now : null;
if (user.BannedAt != null) user.BannedAt = dto.IsBanned.Value ? DateTime.UtcNow : null;
}
if (dto.AccessLevel != null) { user.AccessLevel = dto.AccessLevel.Value; }
if (dto.MaintainedPersonIds != null) {
userRepository.SetMaintainedPersons(user.Id, dto.MaintainedPersonIds);
}
}
userRepository.Save();
return Ok(user.ToGetUsersDto());
return Ok(user.ToGetUsersDto(authenticatedUser.AccessLevel, authenticatedUser.Id));
}
/// <summary>

View File

@@ -100,5 +100,36 @@
"365",
"1"
]
},
{
"Name": "Thumbnail Quality",
"Value": "75",
"Description": "WebP encoding quality for thumbnails (1-100).",
"Type": 1,
"DisplayType": 7,
"Options": [
"1",
"100",
"1"
]
},
{
"Name": "Preview Quality",
"Value": "85",
"Description": "WebP encoding quality for previews (1-100).",
"Type": 1,
"DisplayType": 7,
"Options": [
"1",
"100",
"1"
]
},
{
"Name": "Job Batch Size",
"Value": "200",
"Description": "Number of assets to process per sub-job batch.",
"Type": 1,
"DisplayType": 2
}
]

View File

@@ -1,11 +1,12 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
RUN mkdir -p /app/thumbnails /app/previews && chown -R $APP_UID /app
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
EXPOSE 5162
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR "/src"
COPY ["Lactose/Lactose.csproj", "Lactose/"]
@@ -15,11 +16,10 @@ WORKDIR "/src/Lactose"
RUN dotnet build "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Lactose.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
RUN mkdir -p thumbnails previews
ENTRYPOINT ["dotnet", "Lactose.dll"]

View File

@@ -0,0 +1,410 @@
using Butter.Types;
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),
/// creates missing Person and Album entities, and assigns matching assets to their albums.
/// </summary>
public class CreateAlbumsJob(ILogger<CreateAlbumsJob> logger, IServiceProvider serviceProvider) : Job {
const int ScanBatchSize = 5000;
const int SaveBatchSize = 1000;
/// <inheritdoc />
public override string Name => "Create Albums Job";
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
try {
logger.LogInformation("Starting create albums 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 albumRepo = scope.ServiceProvider.GetRequiredService<IAlbumRepository>();
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/album patterns...", folders.Count);
// ── 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();
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);
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 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 (!personAlbumNames.TryGetValue(personName, out var albums)) {
albums = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
personAlbumNames[personName] = albums;
uniquePersons++;
}
if (albums.Add(albumName))
uniqueAlbums++;
batchMatched++;
}
folderScanned += paths.Count;
folderMatched += batchMatched;
totalAssetsScanned += paths.Count;
totalAssetsMatched += batchMatched;
// 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;
JobStatus.UpdateProgress(progress,
$"Scanning {folder.BasePath}: {folderScanned:N0}/{totalInFolder:N0} — {uniquePersons} persons, {uniqueAlbums} albums found so far");
}
folderIndex++;
float folderProgress = folderIndex / folders.Count * 0.15f;
JobStatus.UpdateProgress(folderProgress,
$"Scanned folder {folder.BasePath}: {folderScanned:N0} assets, {folderMatched} 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) {
logger.LogInformation("No person/album pairs extracted from asset paths.");
JobStatus.Complete("No person/album pairs extracted from asset paths.");
return;
}
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) {
if (existingPersons.ContainsKey(name)) continue;
token.ThrowIfCancellationRequested();
var person = new Person {
Id = Guid.NewGuid(),
Name = name,
CreatedAt = DateTime.UtcNow
};
personRepo.Insert(person);
existingPersons[name] = person.Id;
personCreated++;
if (personCreated % 10 == 0) {
JobStatus.UpdateProgress(0.15f + (float)personCreated / missingPersonCount * 0.10f,
$"Creating persons: {personCreated}/{missingPersonCount} — {name}");
}
}
if (personCreated > 0) {
personRepo.Save();
logger.LogInformation("Created {Count} missing persons.", personCreated);
}
JobStatus.UpdateProgress(0.25f, $"Persons resolved (created {personCreated} new). Checking existing albums...");
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}");
}
logger.LogInformation("Found {Count} existing album-person pairings in DB.", existingAlbumKeys.Count);
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;
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}");
}
}
}
if (albumCreated > 0) {
albumRepo.Save();
logger.LogInformation("Created {Count} new albums.", albumCreated);
}
// Build lookup for the assignment phase (covers both new and pre-existing albums)
var albumLookup = new Dictionary<(string Person, string Album), Guid>();
foreach (var (personName, albumNames) in personAlbumNames) {
if (!existingPersons.TryGetValue(personName, out var personId)) continue;
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 ──
int assetAssignments = 0;
int pendingBeforeSave = 0;
folderIndex = 0;
foreach (var folder in folders) {
token.ThrowIfCancellationRequested();
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"))
continue;
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 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);
}
if (pendingBeforeSave > 0)
await db.SaveChangesAsync(token);
db.ChangeTracker.Clear();
logger.LogInformation("Setting cover images for albums without one...");
int coversAssigned = 0;
var albumsNeedingCover = await db.Albums
.Where(a => a.CoverAssetId == null)
.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;
coversAssigned++;
}
}
if (coversAssigned > 0) {
await db.SaveChangesAsync(token);
logger.LogInformation("Assigned cover images to {Count} albums.", coversAssigned);
}
logger.LogInformation("Completed: {Assigned} assets assigned across {PersonsCreated} persons, {AlbumsCreated} albums.",
assetAssignments, personCreated, albumCreated);
JobStatus.Complete(
$"Created {personCreated} persons, {albumCreated} albums, assigned {assetAssignments:N0} assets.");
} catch (OperationCanceledException) {
JobStatus.Cancel();
}
}
static string ToTitleCase(string input) =>
CultureInfo.CurrentCulture.TextInfo.ToTitleCase(input.ToLowerInvariant());
}

View File

@@ -0,0 +1,160 @@
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());
}

View File

@@ -75,7 +75,6 @@ public sealed class FileSystemCrawlJob : Job {
foreach (var folder in folders) {
if (token.IsCancellationRequested) {
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
JobStatus.Cancel();
return;
}
@@ -83,6 +82,7 @@ public sealed class FileSystemCrawlJob : Job {
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)
@@ -97,7 +97,6 @@ public sealed class FileSystemCrawlJob : Job {
foreach (var file in files) {
if (token.IsCancellationRequested) {
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
JobStatus.Cancel();
return;
}
@@ -113,9 +112,9 @@ public sealed class FileSystemCrawlJob : Job {
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Soft-deleted asset {Asset} — file no longer exists.", existing.OriginalPath);
} else if (file.LastWriteTime > (existing.UpdatedAt ?? existing.CreatedAt)) {
} else if (file.LastWriteTimeUtc > (existing.UpdatedAt ?? existing.CreatedAt)) {
existing.FileSize = file.Length;
existing.UpdatedAt = file.LastWriteTime;
existing.UpdatedAt = file.LastWriteTimeUtc;
assetRepository.Update(existing);
assetRepository.Save();
logger.LogInformation("Updated asset {Asset} — file modification detected.", existing.OriginalPath);
@@ -138,7 +137,7 @@ public sealed class FileSystemCrawlJob : Job {
}
var childCount = Interlocked.CompareExchange(ref pendingChildren, 0, 0);
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childCount} child jobs to complete.");
JobStatus.UpdateProgress(Math.Clamp(steps/(float)totalSteps, 0, 1), $"Waiting for {childCount:N0} child jobs to complete.");
JobStatus.Wait();
if (childCount > 0) {
@@ -146,7 +145,6 @@ public sealed class FileSystemCrawlJob : Job {
try {
await childTcs.Task.ConfigureAwait(false);
} catch (TaskCanceledException) {
lock (childJobs) { childJobs.ForEach(j => j.Cancel()); }
JobStatus.Cancel();
return;
}
@@ -178,9 +176,9 @@ public sealed class FileSystemCrawlJob : Job {
OriginalPath = finfo.FullName,
OriginalFilename = finfo.Name,
Type = type.Value,
IsPubliclyShared = false,
CreatedAt = finfo.CreationTime,
UpdatedAt = finfo.LastWriteTime,
Visibility = EVisibility.Private,
CreatedAt = finfo.CreationTimeUtc,
UpdatedAt = finfo.LastWriteTimeUtc,
MimeType = type.Value switch {
EAssetType.Image => mimeImg.MimeType,
EAssetType.Video => mimeVid.MimeType,

View File

@@ -9,192 +9,194 @@ 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 = 8;
const float totalSteps = 7;
/// <inheritdoc />
public override string Name => "Integrity Check Job";
void IncrementProgress(float f) => JobStatus.UpdateProgress(f/totalSteps, "Database connection successful.");
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
try {
float progress = 0;
logger.LogInformation("Starting integrity check job...");
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
//Step 1
// Check database connection
if (!dbContext.Database.CanConnect()) {
logger.LogError("Cannot connect to the database.");
JobStatus.Fail("Cannot connect to the database.");
return;
}
IncrementProgress(++progress);
// Step 2
// Check for pending migrations
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
float progress = 0;
logger.LogInformation("Starting integrity check job...");
using var scope = serviceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LactoseDbContext>();
if (pendingMigrations.Any()) {
logger.LogWarning("There are pending migrations:");
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
// Apply migrations
try {
dbContext.Database.Migrate();
logger.LogInformation("Applied pending migrations successfully.");
} catch (Exception ex) {
logger.LogError(ex, "Error applying migrations.");
JobStatus.Fail("Error applying migrations.");
//Step 1
// Check database connection
if (!dbContext.Database.CanConnect()) {
logger.LogError("Cannot connect to the database.");
JobStatus.Fail("Cannot connect to the database.");
return;
}
}
IncrementProgress(++progress);
// 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()) {
if (!Directory.Exists(folder.BasePath)) {
logger.LogWarning($"Folder {folder.Id} does not exist. Deactivating.");
folder.Active = false;
dbContext.Folders.Update(folder);
IncrementProgress(++progress);
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);
token.ThrowIfCancellationRequested();
// Step 2
// Check for pending migrations
var pendingMigrations = dbContext.Database.GetPendingMigrations().ToList();
if (pendingMigrations.Any()) {
logger.LogWarning("There are pending migrations:");
foreach (var migration in pendingMigrations) logger.LogWarning($"- {migration}");
// Apply migrations
try {
dbContext.Database.Migrate();
logger.LogInformation("Applied pending migrations successfully.");
} catch (Exception ex) {
logger.LogError(ex, "Error applying migrations.");
JobStatus.Fail("Error applying migrations.");
return;
}
dbContext.SaveChanges();
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
}
}
IncrementProgress(++progress);
// Step 4
// Check for assets existence
dbContext.Assets.Load();
foreach (Asset asset in dbContext.Assets.Where(a => a.DeletedAt == null)) {
if (File.Exists(asset.OriginalPath)) continue;
asset.DeletedAt = DateTime.UtcNow;
dbContext.Assets.Update(asset);
logger.LogWarning($"Asset {asset.Id} does not exist. Soft-deleting.");
}
IncrementProgress(++progress);
int changes = dbContext.SaveChanges();
logger.LogInformation("Soft-deleted {Changes} non-existent assets.", changes);
IncrementProgress(++progress);
// Step 5
// Check for empty albums
dbContext.Albums.Load();
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);
// Step 6
// Check for Persons without faces
dbContext.People.Include(p => p.Faces)
.Where(p => p.Faces != null && p.Faces.Count == 0 && p.DeletedAt == null)
.AsEnumerable()
.ForEach(p => {
logger.LogWarning($"Person {p.Id} has no faces. Soft-deleting.");
p.DeletedAt = DateTime.UtcNow;
dbContext.People.Update(p);
});
token.ThrowIfCancellationRequested();
changes = dbContext.SaveChanges();
logger.LogInformation("Removed {Changes} people without faces from the database.", changes);
IncrementProgress(++progress);
// Step 7
// Check for orphaned thumbnails
dbContext.Settings.Load();
dbContext.Assets.Load();
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
// 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.
if (!Directory.Exists(thumbnailPath)) {
logger.LogWarning("Thumbnail path does not exist. Creating...");
// Step 3
// Check for folders existence
dbContext.Folders.Load();
foreach (Folder folder in dbContext.Folders.ToList()) {
token.ThrowIfCancellationRequested();
if (!Directory.Exists(folder.BasePath)) {
logger.LogWarning($"Folder {folder.Id} does not exist. Deactivating.");
folder.Active = false;
dbContext.Folders.Update(folder);
try {
if (thumbnailPath != null)
Directory.CreateDirectory(thumbnailPath);
} catch (Exception e) {
logger.LogError(e, "Could not create thumbnail directory.");
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);
}
dbContext.SaveChanges();
logger.LogWarning("Soft-deleted {Count} assets associated with missing folder {FolderId}.", affectedAssets.Count, folder.Id);
}
}
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);
} 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);
IncrementProgress(++progress);
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)
.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("Marked {Changes} assets as missing thumbnails.", changes);
}
IncrementProgress(++progress);
// Step 8
// Check for orphaned previews
logger.LogInformation("Removed {Changes} empty albums from the database.", changes);
IncrementProgress(++progress);
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
if (!Directory.Exists(previewPath)) {
logger.LogWarning("Preview path does not exist. Creating...");
try {
if (previewPath != null)
Directory.CreateDirectory(previewPath);
} catch (Exception e) {
logger.LogError(e, "Could not create preview directory.");
token.ThrowIfCancellationRequested();
// Step 6
// Check for orphaned thumbnails
dbContext.Settings.Load();
dbContext.Assets.Load();
var thumbnailPath = dbContext.Settings.First(s => s.Name == Settings.ThumbnailPath.AsString()).Value;
if (!Directory.Exists(thumbnailPath)) {
logger.LogWarning("Thumbnail path does not exist. Creating...");
try {
if (thumbnailPath != null)
Directory.CreateDirectory(thumbnailPath);
} catch (Exception e) {
logger.LogError(e, "Could not create thumbnail directory.");
}
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);
} 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);
}
IncrementProgress(++progress);
token.ThrowIfCancellationRequested();
// Step 7
// Check for orphaned previews
var previewPath = dbContext.Settings.First(s => s.Name == Settings.PreviewPath.AsString()).Value;
if (!Directory.Exists(previewPath)) {
logger.LogWarning("Preview path does not exist. Creating...");
try {
if (previewPath != null)
Directory.CreateDirectory(previewPath);
} catch (Exception e) {
logger.LogError(e, "Could not create preview directory.");
}
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);
} 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);
});
}
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);
} 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);
});
IncrementProgress(++progress);
// Complete
logger.LogInformation("Integrity check completed successfully.");
JobStatus.Complete("Integrity check completed successfully.");
} catch (OperationCanceledException) {
JobStatus.Cancel();
}
changes = dbContext.SaveChanges();
logger.LogInformation("Marked {Changes} assets as missing previews.", changes);
IncrementProgress(++progress);
// Complete
logger.LogInformation("Integrity check completed successfully.");
JobStatus.Complete("Integrity check completed successfully.");
}
}

View File

@@ -1,5 +1,4 @@
using Butter.Types;
using Microsoft.Extensions.DependencyInjection;
namespace Lactose.Jobs;
@@ -32,22 +31,38 @@ public abstract class Job {
public IServiceScope? Scope { get; set; }
Task? task;
CancellationTokenSource? cts;
CancellationToken? _parentToken;
/// <summary>
/// Initialises a new job instance.
/// </summary>
protected Job() => JobStatus = new JobStatus(this);
/// <summary>
/// Links this job's cancellation token to a parent token. When the parent token is cancelled,
/// this job's token is also cancelled automatically — even before <see cref="Start"/> is called.
/// </summary>
/// <param name="parentToken">The parent job's cancellation token.</param>
public void LinkParentToken(CancellationToken parentToken) {
_parentToken = parentToken;
}
/// <summary>
/// Sets up the task and the cancellation token, then starts the task (triggering the Started event)
/// Also sets up a continuation to handle completion, failure, or cancellation of the task.
/// </summary>
public virtual void Start() {
JobStatus.Start();
cts = new CancellationTokenSource();
cts = _parentToken.HasValue
? CancellationTokenSource.CreateLinkedTokenSource(_parentToken.Value)
: new CancellationTokenSource();
task = Task.Run(() => TaskJob(cts.Token));
task.ContinueWith(t => {
if (t.IsCanceled && JobStatus.Status != EJobStatus.Canceled) {
JobStatus.Cancel();
}
if (t.IsFaulted && JobStatus.Status != EJobStatus.Canceled) {
JobStatus.Fail($"Unhandled exception: {t.Exception?.InnerException?.Message}");
}
@@ -70,6 +85,7 @@ public abstract class Job {
Completed?.Invoke(this, JobStatus);
break;
}
cts?.Dispose();
Done?.Invoke(this, JobStatus);
}
);
@@ -85,9 +101,18 @@ public abstract class Job {
protected abstract Task TaskJob(CancellationToken token);
/// <summary>
/// Cancels the job if it's running. Canceled event will be called by the callbacks on the task.
/// Cancels the job. For running jobs, signals the cancellation token. For queued jobs,
/// marks the status as Canceled and fires completion events directly.
/// </summary>
public virtual void Cancel() => cts?.Cancel();
public virtual void Cancel() {
if (cts is not null) {
cts.Cancel();
} else {
JobStatus.Cancel();
Canceled?.Invoke(this, JobStatus);
Done?.Invoke(this, JobStatus);
}
}
/// <summary>
/// Raised when the job starts running.
@@ -121,4 +146,22 @@ public abstract class Job {
/// Raised when the job is done, regardless of the final status (Completed, Canceled, or Failed).
/// </summary>
public virtual event EventHandler<JobStatus>? Done;
/// <summary>
/// Raises the StartedWaiting event.
/// </summary>
/// <param name="e">The job status.</param>
protected virtual void OnStartedWaiting(JobStatus e) { StartedWaiting?.Invoke(this, e); }
/// <summary>
/// Raises the FinishedWaiting event.
/// </summary>
/// <param name="e">The job status.</param>
protected virtual void OnFinishedWaiting(JobStatus e) { FinishedWaiting?.Invoke(this, e); }
/// <summary>
/// Raises the ProgressChanged event.
/// </summary>
/// <param name="e">The job status.</param>
protected virtual void OnProgressChanged(JobStatus e) { ProgressChanged?.Invoke(this, e); }
}

View File

@@ -1,8 +1,8 @@
using Butter.Dtos.Jobs;
using Butter.Settings;
using Butter.Types;
using Lactose.Mapper;
using Lactose.Repositories;
using Lactose.Utils;
using System.Collections.Concurrent;
namespace Lactose.Jobs;
@@ -45,25 +45,35 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
/// <summary>
/// Returns children of a given parent job — checks active jobs first, then the database.
/// Supports delta sync via the <paramref name="since"/> parameter.
/// </summary>
public List<JobStatusDto> GetChildren(Guid parentId) {
var results = new List<JobStatusDto>();
/// <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) {
var seenIds = new HashSet<Guid>();
// Active children still in memory
foreach (var job in activeJobs.Values) {
if (job.ParentJobId == parentId) {
results.Add(job.ToJobStatusDto());
}
var active = activeJobs.Values
.Where(j => j.ParentJobId == parentId)
.ToList();
foreach (var job in active) {
seenIds.Add(job.Id);
}
var results = active
.Where(j => since is null || j.JobStatus.LastChange > since!.Value)
.Select(j => j.ToJobStatusDto())
.ToList();
// Past children in the database
try {
using var scope = serviceProvider.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<IJobRecordRepository>();
var records = repo.GetChildren(parentId);
foreach (var record in records) {
results.Add(record.ToJobStatusDto());
}
var records = since is null
? repo.GetChildren(parentId)
: repo.GetChildrenModifiedSince(parentId, since.Value);
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);
}
@@ -183,6 +193,19 @@ public class JobManager(IServiceProvider serviceProvider, ILogger<JobManager> lo
logger.LogWarning(ex, "Could not load past jobs from database.");
}
// Load MaxConcurrentJobs from the database setting
try {
using var scope = serviceProvider.CreateScope();
var settingsRepo = scope.ServiceProvider.GetRequiredService<ISettingsRepository>();
var maxJobsSetting = settingsRepo.Get(Settings.MaxConcurrentJobs.AsString());
if (maxJobsSetting != null && int.TryParse(maxJobsSetting.Value, out var maxJobs) && maxJobs > 0) {
MaxConcurrentJobs = maxJobs;
logger.LogInformation("Loaded MaxConcurrentJobs from settings: {Value}", MaxConcurrentJobs);
}
} catch (Exception ex) {
logger.LogWarning(ex, "Could not load MaxConcurrentJobs setting; using default {Default}.", MaxConcurrentJobs);
}
var lifetime = serviceProvider.GetRequiredService<IHostApplicationLifetime>();
var service = new Task(() => {

View File

@@ -38,6 +38,11 @@ public class JobStatus(Job job) {
/// Gets the progress of the job as a value between 0 and 1.
/// </summary>
public float Progress { get; private set; } = 0;
/// <summary>
/// Gets the timestamp of the last change to this job's status or progress.
/// Used for delta-based children sync.
/// </summary>
public DateTime LastChange { get; private set; } = DateTime.UtcNow;
/// <summary>
/// Returns a string representation of the job status.
@@ -49,10 +54,11 @@ public class JobStatus(Job job) {
/// </summary>
/// <param name="message">Optional start message.</param>
public void Start(string message = "Job started") {
Status = EJobStatus.Running;
Started = DateTime.UtcNow;
Progress = 0;
Message = message;
Status = EJobStatus.Running;
Started = DateTime.UtcNow;
Progress = 0;
Message = message;
LastChange = DateTime.UtcNow;
}
/// <summary>
@@ -60,8 +66,9 @@ public class JobStatus(Job job) {
/// </summary>
/// <param name="message">Optional waiting message.</param>
public void Wait(string message = "Job waiting") {
Status = EJobStatus.Waiting;
Message = message;
Status = EJobStatus.Waiting;
Message = message;
LastChange = DateTime.UtcNow;
}
/// <summary>
@@ -73,7 +80,8 @@ public class JobStatus(Job job) {
if (progress < 0 || progress > 1)
throw new ArgumentOutOfRangeException(nameof(progress), "Progress must be between 0 and 1");
Progress = progress;
Progress = progress;
LastChange = DateTime.UtcNow;
if (message != null) Message = message;
}
@@ -82,9 +90,10 @@ public class JobStatus(Job job) {
/// </summary>
/// <param name="message">Optional completion message.</param>
public void Complete(string? message = null) {
Status = EJobStatus.Completed;
Finished = DateTime.UtcNow;
Progress = 1;
Status = EJobStatus.Completed;
Finished = DateTime.UtcNow;
Progress = 1;
LastChange = DateTime.UtcNow;
if (message != null) Message = message;
}
@@ -93,9 +102,10 @@ public class JobStatus(Job job) {
/// </summary>
/// <param name="message">Optional completion message.</param>
public void CompleteWithErrors(string? message = null) {
Status = EJobStatus.CompletedWithErrors;
Finished = DateTime.UtcNow;
Progress = 1;
Status = EJobStatus.CompletedWithErrors;
Finished = DateTime.UtcNow;
Progress = 1;
LastChange = DateTime.UtcNow;
if (message != null) Message = message;
}
@@ -104,8 +114,9 @@ public class JobStatus(Job job) {
/// </summary>
/// <param name="message">Optional failure message.</param>
public void Fail(string? message = null) {
Status = EJobStatus.Failed;
Finished = DateTime.UtcNow;
Status = EJobStatus.Failed;
Finished = DateTime.UtcNow;
LastChange = DateTime.UtcNow;
if (message != null) Message = message;
}
@@ -114,8 +125,9 @@ public class JobStatus(Job job) {
/// </summary>
/// <param name="message">Optional cancellation message.</param>
public void Cancel(string? message = null) {
Status = EJobStatus.Canceled;
Finished = DateTime.UtcNow;
Status = EJobStatus.Canceled;
Finished = DateTime.UtcNow;
LastChange = DateTime.UtcNow;
if (message != null) Message = message;
}
}

View File

@@ -1,5 +1,4 @@
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
@@ -21,6 +20,7 @@ public sealed class MetadataJob : Job {
int failedAssets;
int totalAssets = 1;
int batchSize;
List<Job> childJobs = [];
/// <inheritdoc />
public override string Name { get; }
@@ -102,9 +102,15 @@ public sealed class MetadataJob : Job {
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
var msg = $"Processed {processedAssets}/{Batch.Length} assets, {failedAssets} failed.";
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
JobStatus.Complete(msg);
if (failedAssets == Batch.Length)
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
else if (failedAssets > 0)
JobStatus.CompleteWithErrors(msg);
else
JobStatus.Complete(msg);
}
async Task MasterJob(CancellationToken token) {
@@ -139,13 +145,14 @@ public sealed class MetadataJob : Job {
if (batchList.Count == 0) continue;
var job = JobManager.CreateJob<MetadataJob>(this, batchList, batchSize);
job.LinkParentToken(token);
lock (childJobs) { childJobs.Add(job); }
job.Done += (o, _) => {
var sub = (MetadataJob)o!;
if (sub.JobStatus.Status == EJobStatus.Failed)
Interlocked.Increment(ref failedAssets);
lock (childJobs) { childJobs.Remove(sub); }
Interlocked.Add(ref processedAssets, sub.processedAssets);
failedAssets += sub.failedAssets;
Interlocked.Add(ref failedAssets, sub.failedAssets);
if (Interlocked.Decrement(ref pending) == 0)
childTcs.TrySetResult();
};
@@ -155,7 +162,7 @@ public sealed class MetadataJob : Job {
JobStatus.UpdateProgress(
(float)offset / totalAssets,
$"Created batch at {offset}/{totalAssets}."
$"Created batch at {offset:N0}/{totalAssets:N0}."
);
}
@@ -178,7 +185,7 @@ public sealed class MetadataJob : Job {
}
if (failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} metadata extractions failed.");
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} metadata extractions failed.");
else
JobStatus.Complete("All metadata extracted successfully.");
}

View File

@@ -1,10 +1,9 @@
using Butter.Settings;
using Butter.Types;
using CoenM.ImageHash.HashAlgorithms;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
using CoenM.ImageHash.HashAlgorithms;
using Lactose.Utils;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using System.Collections;
@@ -24,6 +23,7 @@ public sealed class PHashJob : Job {
int failedAssets;
int totalAssets = 1;
int batchSize;
List<Job> childJobs = [];
ILogger<PHashJob> Logger { get; }
/// <inheritdoc />
public override string Name { get; }
@@ -112,9 +112,15 @@ public sealed class PHashJob : Job {
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
var msg = $"Processed {processedAssets}/{Batch.Length} assets, {failedAssets} failed.";
var msg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(msg);
JobStatus.Complete(msg);
if (failedAssets == Batch.Length)
JobStatus.Fail($"All {failedAssets:N0} assets in batch failed.");
else if (failedAssets > 0)
JobStatus.CompleteWithErrors(msg);
else
JobStatus.Complete(msg);
}
async Task MasterJob(CancellationToken token) {
@@ -149,13 +155,14 @@ public sealed class PHashJob : Job {
if (batchList.Count == 0) continue;
var job = JobManager.CreateJob<PHashJob>(this, batchList, batchSize);
job.LinkParentToken(token);
lock (childJobs) { childJobs.Add(job); }
job.Done += (o, _) => {
var sub = (PHashJob)o!;
if (sub.JobStatus.Status == EJobStatus.Failed)
Interlocked.Increment(ref failedAssets);
lock (childJobs) { childJobs.Remove(sub); }
Interlocked.Add(ref processedAssets, sub.processedAssets);
failedAssets += sub.failedAssets;
Interlocked.Add(ref failedAssets, sub.failedAssets);
if (Interlocked.Decrement(ref pending) == 0)
childTcs.TrySetResult();
};
@@ -165,7 +172,7 @@ public sealed class PHashJob : Job {
JobStatus.UpdateProgress(
(float)offset / totalAssets,
$"Created batch at {offset}/{totalAssets}."
$"Created batch at {offset:N0}/{totalAssets:N0}."
);
}
@@ -188,7 +195,7 @@ public sealed class PHashJob : Job {
}
if (failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} pHash calculations failed.");
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} pHash calculations failed.");
else
JobStatus.Complete("All pHash calculations completed.");
}

294
Lactose/Jobs/PreviewJob.cs Normal file
View File

@@ -0,0 +1,294 @@
using Butter.Settings;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
namespace Lactose.Jobs;
/// <summary>
/// Generates WebP preview images for assets missing them, with wrong dimensions, or wrong format.
/// Master creates one sub-job per batch of assets.
/// </summary>
public class PreviewJob : Job {
PreviewJob? ParentJob;
Asset[]? Batch;
int PreviewSize;
int PreviewQuality;
string? PreviewPath;
string ExpectedFormat = "webp";
int processedAssets;
int failedAssets;
int totalAssets = 1;
int batchSize;
List<Job> childJobs = [];
ILogger<PreviewJob> Logger;
ISettingsRepository SettingsRepository;
IAssetRepository AssetRepository;
JobManager? JobManager;
/// <inheritdoc />
public override string Name { get; }
/// <summary>
/// Creates a master preview generation job.
/// </summary>
public PreviewJob(
ILogger<PreviewJob> logger,
ISettingsRepository settingsRepository,
IAssetRepository assetRepository,
JobManager jobManager
) {
Logger = logger;
SettingsRepository = settingsRepository;
AssetRepository = assetRepository;
JobManager = jobManager;
Name = "Preview Generation Job";
}
/// <summary>
/// Creates a sub-job to generate previews for a batch of assets.
/// </summary>
public PreviewJob(
PreviewJob parentJob,
IEnumerable<Asset> batch,
int previewSize,
string previewPath,
int previewQuality,
string expectedFormat,
int batchSize,
ILogger<PreviewJob> logger,
ISettingsRepository settingsRepository,
IAssetRepository assetRepository
) {
Logger = logger;
SettingsRepository = settingsRepository;
AssetRepository = assetRepository;
Batch = batch.ToArray();
Name = $"Preview Batch ({Batch.Length} assets)";
ParentJob = parentJob;
ParentJobId = parentJob.Id;
PreviewSize = previewSize;
PreviewPath = previewPath;
PreviewQuality = previewQuality;
ExpectedFormat = expectedFormat;
this.batchSize = batchSize;
totalAssets = Batch.Length;
}
/// <inheritdoc />
protected override async Task TaskJob(CancellationToken token) {
if (ParentJob == null) {
await MasterJob(token);
} else {
SlaveJob(token);
}
}
void 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 (PreviewPath == null) {
Logger.LogError("Preview path is not set. Cannot save previews.");
JobStatus.Fail("Preview 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 msg = $"Original file for asset ID {asset.Id} not found at path {asset.OriginalPath}";
Logger.LogWarning(msg);
failedAssets++;
continue;
}
if (!string.IsNullOrEmpty(asset.PreviewPath) && File.Exists(asset.PreviewPath)) {
Logger.LogInformation("Removing stale preview for asset ID {Id} at {Path}.", asset.Id, asset.PreviewPath);
File.Delete(asset.PreviewPath);
asset.PreviewPath = "";
}
using var image = Image.Load(asset.OriginalPath);
image.Mutate(x => x.Resize(
new ResizeOptions {
Size = new Size(PreviewSize),
Mode = ResizeMode.Max,
Sampler = KnownResamplers.CatmullRom
}
)
);
var path = PathFromGuid(asset.Id, PreviewPath!);
if (!Directory.Exists(Path.GetDirectoryName(path)))
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
image.SaveAsWebp(
path,
new WebpEncoder() {
Quality = PreviewQuality,
SkipMetadata = true
}
);
asset.PreviewPath = path;
asset.PreviewSize = PreviewSize;
asset.PreviewFormat = ExpectedFormat;
processedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to generate preview for asset ID {asset.Id}");
failedAssets++;
}
}
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
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 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());
if (previewPathSetting == null) {
Logger.LogError("Preview path not found. Cannot proceed.");
JobStatus.Fail("Preview path not found.");
return;
}
if (!int.TryParse(previewSizeSetting?.Value, out var previewSize)) {
previewSize = 2048;
Logger.LogWarning("Preview Size setting not found or invalid. Defaulting to {Size}.", previewSize);
}
if (!int.TryParse(previewQualitySetting?.Value, out var previewQuality)) {
previewQuality = 85;
Logger.LogWarning("Preview Quality setting not found or invalid. Defaulting to {Quality}.", previewQuality);
}
previewQuality = Math.Clamp(previewQuality, 1, 100);
batchSize = 200;
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingOrWrongPreview(previewSize, ExpectedFormat);
Logger.LogInformation("Found {Count} assets needing previews.", totalAssets);
if (totalAssets == 0) {
JobStatus.Complete("No assets need previews.");
return;
}
var childTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingOrWrongPreview(previewSize, ExpectedFormat, batchSize, offset);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
var job = JobManager.CreateJob<PreviewJob>(
this, batchList, previewSize, previewPathSetting.Value!, previewQuality, ExpectedFormat, batchSize
);
job.LinkParentToken(token);
lock (childJobs) { childJobs.Add(job); }
int capturedOffset = offset;
job.Done += (o, _) => {
var sub = (PreviewJob)o!;
lock (childJobs) { childJobs.Remove(sub); }
Interlocked.Add(ref processedAssets, sub.processedAssets);
Interlocked.Add(ref failedAssets, sub.failedAssets);
if (Interlocked.Decrement(ref pending) == 0)
childTcs.TrySetResult();
};
Interlocked.Increment(ref pending);
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 (failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} previews failed.");
else
JobStatus.Complete("All previews generated successfully.");
}
static string PathFromGuid(Guid id, string root) {
var s = id.ToString("N");
return Path.Combine(
root,
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.webp"
);
}
}

View File

@@ -1,27 +1,28 @@
using Butter.Dtos.Settings;
using Butter.Settings;
using Butter.Types;
using Lactose.Models;
using Lactose.Repositories;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
namespace Lactose.Jobs;
/// <summary>
/// Generates JPEG thumbnails for assets missing them or with wrong dimensions.
/// Generates WebP thumbnails for assets missing them, with wrong dimensions, or wrong format.
/// Master creates one sub-job per batch of assets.
/// </summary>
public class ThumbnailJob : Job {
ThumbnailJob? ParentJob;
Asset[]? Batch;
int ThumbnailSize;
int ThumbnailQuality;
string? ThumbnailPath;
string ExpectedFormat = "webp";
int processedAssets;
int failedAssets;
int totalAssets = 1;
int batchSize;
List<Job> childJobs = [];
ILogger<ThumbnailJob> Logger;
ISettingsRepository SettingsRepository;
IAssetRepository AssetRepository;
@@ -54,6 +55,8 @@ public class ThumbnailJob : Job {
IEnumerable<Asset> batch,
int thumbnailSize,
string thumbnailPath,
int thumbnailQuality,
string expectedFormat,
int batchSize,
ILogger<ThumbnailJob> logger,
ISettingsRepository settingsRepository,
@@ -68,6 +71,8 @@ public class ThumbnailJob : Job {
ParentJobId = parentJob.Id;
ThumbnailSize = thumbnailSize;
ThumbnailPath = thumbnailPath;
ThumbnailQuality = thumbnailQuality;
ExpectedFormat = expectedFormat;
this.batchSize = batchSize;
totalAssets = Batch.Length;
}
@@ -140,17 +145,17 @@ public class ThumbnailJob : Job {
if (!Directory.Exists(Path.GetDirectoryName(path)))
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
image.SaveAsJpeg(
image.SaveAsWebp(
path,
new JpegEncoder() {
ColorType = JpegEncodingColor.Rgb,
Quality = 75,
new WebpEncoder() {
Quality = ThumbnailQuality,
SkipMetadata = true
}
);
asset.ThumbnailPath = path;
asset.ThumbnailSize = ThumbnailSize;
asset.ThumbnailPath = path;
asset.ThumbnailSize = ThumbnailSize;
asset.ThumbnailFormat = ExpectedFormat;
processedAssets++;
} catch (Exception ex) {
Logger.LogError(ex, $"Failed to generate thumbnail for asset ID {asset.Id}");
@@ -161,9 +166,15 @@ public class ThumbnailJob : Job {
AssetRepository.UpdateBulk(Batch);
AssetRepository.Save();
var resultMsg = $"Processed {processedAssets}/{Batch.Length} assets, {failedAssets} failed.";
var resultMsg = $"Processed {processedAssets:N0}/{Batch.Length:N0} assets, {failedAssets:N0} failed.";
Logger.LogInformation(resultMsg);
JobStatus.Complete(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) {
@@ -176,9 +187,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 batchSizeSetting = SettingsRepository.Get(Settings.JobBatchSize.AsString());
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());
if (thumbPathSetting == null) {
Logger.LogError("Thumbnail path not found. Cannot proceed.");
@@ -191,11 +203,18 @@ public class ThumbnailJob : Job {
Logger.LogWarning("Thumbnail Size setting not found or invalid. Defaulting to {Size}.", thumbnailSize);
}
if (!int.TryParse(thumbQualitySetting?.Value, out var thumbnailQuality)) {
thumbnailQuality = 75;
Logger.LogWarning("Thumbnail Quality setting not found or invalid. Defaulting to {Quality}.", thumbnailQuality);
}
thumbnailQuality = Math.Clamp(thumbnailQuality, 1, 100);
batchSize = 200;
if (batchSizeSetting != null && int.TryParse(batchSizeSetting.Value, out var parsed))
batchSize = Math.Max(1, parsed);
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize);
totalAssets = AssetRepository.CountAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat);
Logger.LogInformation("Found {Count} assets needing thumbnails.", totalAssets);
if (totalAssets == 0) {
@@ -207,22 +226,23 @@ public class ThumbnailJob : Job {
int pending = 0;
for (int offset = 0; offset < totalAssets && !token.IsCancellationRequested; offset += batchSize) {
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, batchSize, offset);
var batch = AssetRepository.GetAssetsMissingOrWrongThumbnail(thumbnailSize, ExpectedFormat, batchSize, offset);
var batchList = batch.ToList();
if (batchList.Count == 0) continue;
var job = JobManager.CreateJob<ThumbnailJob>(
this, batchList, thumbnailSize, thumbPathSetting.Value!, batchSize
this, batchList, thumbnailSize, thumbPathSetting.Value!, thumbnailQuality, ExpectedFormat, batchSize
);
job.LinkParentToken(token);
lock (childJobs) { childJobs.Add(job); }
int capturedOffset = offset;
job.Done += (o, _) => {
var sub = (ThumbnailJob)o!;
if (sub.JobStatus.Status == EJobStatus.Failed)
Interlocked.Increment(ref failedAssets);
lock (childJobs) { childJobs.Remove(sub); }
Interlocked.Add(ref processedAssets, sub.processedAssets);
failedAssets += sub.failedAssets;
Interlocked.Add(ref failedAssets, sub.failedAssets);
if (Interlocked.Decrement(ref pending) == 0)
childTcs.TrySetResult();
};
@@ -232,7 +252,7 @@ public class ThumbnailJob : Job {
JobStatus.UpdateProgress(
(float)offset / totalAssets,
$"Created batch at {offset}/{totalAssets}."
$"Created batch at {offset:N0}/{totalAssets:N0}."
);
}
@@ -255,7 +275,7 @@ public class ThumbnailJob : Job {
}
if (failedAssets > 0)
JobStatus.CompleteWithErrors($"{failedAssets} of {totalAssets} thumbnails failed.");
JobStatus.CompleteWithErrors($"{failedAssets:N0} of {totalAssets:N0} thumbnails failed.");
else
JobStatus.Complete("All thumbnails generated successfully.");
}
@@ -268,7 +288,7 @@ public class ThumbnailJob : Job {
s[..2],
s.Substring(2, 2),
s.Substring(4, 2),
$"{s}.jpg"
$"{s}.webp"
);
}
}

View File

@@ -1,31 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<UserSecretsId>00b763fc-028f-4464-9478-fc1006aeffa8</UserSecretsId>
<LangVersion>12</LangVersion>
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.15" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.15">
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.15">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.11" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.2.2" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="4.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.2.3" />
</ItemGroup>
<ItemGroup>
@@ -35,13 +35,19 @@
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\"/>
<Folder Include="Migrations\" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Butter\Butter.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Lactose.Analyzers\Lactose.Analyzers.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<None Include="DefaultSettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>

View File

@@ -1,4 +1,5 @@
using Butter.Dtos.Album;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -7,31 +8,46 @@ namespace Lactose.Mapper;
/// provides mapping methods for converting Album objects to DTOs.
/// </summary>
public static class AlbumMapper {
/// <summary>
/// Maps an Album object to an AlbumPreviewDto with visibility-aware fields.
/// </summary>
/// <param name="album">The album to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>An AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album, EAccessLevel accessLevel) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null
};
/// <summary>
/// Maps an Album object to an AlbumFullDto object.
/// </summary>
/// <param name="album">The album to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>An AlbumFullDto object.</returns>
public static AlbumFullDto ToAlbumFullDto(this Album album) => new AlbumFullDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId,
Images = album.Assets!.Select(asset => asset.Id).ToList()
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel) => new AlbumFullDto {
Id = album.Id,
Name = album.Title,
Person = album.PersonOwnerId,
PersonName = album.PersonOwner?.Name,
CoverAssetId = album.CoverAssetId,
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
Id = asset.Id,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
}).ToList() ?? []
};
/// <summary>
/// Maps an Album object to an AlbumPreviewDto object.
/// </summary>
/// <param name="album">The album to map.</param>
/// <returns>An AlbumPreviewDto object.</returns>
public static AlbumPreviewDto ToAlbumPreviewDto(this Album album) => new AlbumPreviewDto {
Id = album.Id,
Name = album.Title,
Owner = album.UserOwnerId,
Person = album.PersonOwnerId
};
}

View File

@@ -1,4 +1,5 @@
using Butter.Dtos.Asset;
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
@@ -9,43 +10,65 @@ namespace Lactose.Mapper;
/// </summary>
public static class AssetsMapper {
/// <summary>
/// Maps an Asset object to a GetFullAssetDto object.
/// Maps an Asset object to a AssetDto object.
/// </summary>
/// <param name="asset">The asset to map.</param>
/// <param name="accessLevel">The access level of the user.</param>
/// <returns>A GetFullAssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel) => new AssetDto {
/// <param name="viewerId">The ID of the requesting user.</param>
/// <returns>A AssetDto object.</returns>
public static AssetDto ToFullAssetsDto(this Asset asset, EAccessLevel accessLevel, Guid? viewerId) => new AssetDto {
Id = asset.Id,
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
AssetType = asset.Type,
IsPublic = asset.IsPubliclyShared,
CreatedAt = asset.CreatedAt,
UpdatedAt = asset.UpdatedAt,
DeletedAt = asset.DeletedAt,
Owner = asset.Owner!.ToGetUsersDto(),
UploadedBy = asset.Uploader?.ToGetUsersDto(accessLevel, viewerId)
?? new UserInfoDto { Id = Guid.Empty, Username = "deleted", AccessLevel = EAccessLevel.User },
MimeType = asset.MimeType,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
FileSize = asset.FileSize,
Duration = asset.Duration ?? 0,
FrameRate = asset.FrameRate ?? 0
FrameRate = asset.FrameRate ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
};
/// <summary>
/// Maps an Asset object to a GetAssetPreviewDto object.
/// Maps an Asset object to a AssetPreviewDto object.
/// </summary>
/// <param name="asset">The asset to map.</param>
/// <returns>A GetAssetPreviewDto object.</returns>
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset) => new AssetPreviewDto {
Id = asset.Id,
MimeType = asset.MimeType
/// <param name="accessLevel">The access level of the user.</param>
/// <returns>A AssetPreviewDto object.</returns>
public static AssetPreviewDto ToAssetPreviewDto(this Asset asset, EAccessLevel accessLevel) => new AssetPreviewDto {
Id = asset.Id,
MimeType = asset.MimeType,
ResolutionWidth = asset.ResolutionWidth,
ResolutionHeight = asset.ResolutionHeight,
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
AlbumNames = asset.Albums?.Select(a => a.Title).Distinct().ToList(),
AlbumIds = asset.Albums?.Select(a => a.Id).Distinct().ToList(),
CosplayerNames = asset.Albums?
.Where(a => a.PersonOwner != null)
.Select(a => a.PersonOwner!.Name)
.Distinct()
.ToList(),
CosplayerIds = asset.Albums?
.Where(a => a.PersonOwner != null)
.Select(a => a.PersonOwner!.Id)
.Distinct()
.ToList(),
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null
};
/// <summary>
/// Maps a collection of Asset objects to a collection of GetAssetPreviewDto objects.
/// Maps a collection of Asset objects to a collection of AssetPreviewDto objects.
/// </summary>
/// <param name="assets">The collection of assets to map.</param>
/// <returns>A collection of GetAssetPreviewDto objects.</returns>
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets)
=> assets.Select(ToAssetPreviewDto);
/// <param name="accessLevel">The access level of the user.</param>
/// <returns>A collection of AssetPreviewDto objects.</returns>
public static IEnumerable<AssetPreviewDto> ToAssetPreviewDto(this IEnumerable<Asset> assets, EAccessLevel accessLevel)
=> assets.Select(a => a.ToAssetPreviewDto(accessLevel));
}

View File

@@ -26,9 +26,12 @@ public static class JobMapper {
JobType = job switch {
FileSystemCrawlJob => EJobType.FileSystemScan,
ThumbnailJob => EJobType.ThumbnailGeneration,
PreviewJob => EJobType.PreviewGeneration,
IntegrityCheckJob => EJobType.IntegrityCheck,
PHashJob => EJobType.PHashGeneration,
MetadataJob => EJobType.MetadataExtraction,
CreatePersonsJob => EJobType.CreatePersons,
CreateAlbumsJob => EJobType.CreateAlbums,
_ => throw new ArgumentOutOfRangeException(nameof(job))
}
};

View File

@@ -0,0 +1,54 @@
using Butter.Dtos.Album;
using Butter.Dtos.Person;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
/// <summary>
/// Provides mapping methods for converting Person objects to DTOs.
/// </summary>
public static class PersonMapper {
/// <summary>
/// Maps a Person to a PersonDetailedDto with album previews and stats.
/// </summary>
/// <param name="person">The person to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>A PersonDetailedDto.</returns>
public static PersonDetailedDto ToPersonDetailedDto(this Person person, EAccessLevel accessLevel) {
var albums = person.Albums ?? [];
var maintainers = person.Maintainers ?? [];
return new PersonDetailedDto {
Id = person.Id,
Name = person.Name,
ProfileAssetId = person.ProfileAssetId,
ProfileCropX = person.ProfileCropX,
ProfileCropY = person.ProfileCropY,
ProfileCropZoom = person.ProfileCropZoom,
TotalAlbums = albums.Count,
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel)).ToList(),
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
};
}
/// <summary>
/// Maps a Person to a PersonPreviewDto.
/// </summary>
/// <param name="person">The person to map.</param>
/// <param name="accessLevel">The requesting user's access level.</param>
/// <returns>A PersonPreviewDto.</returns>
public static PersonPreviewDto ToPersonPreviewDto(this Person person, EAccessLevel accessLevel) => new() {
Id = person.Id,
Name = person.Name,
ProfileAssetId = person.ProfileAssetId,
ProfileCropX = person.ProfileCropX,
ProfileCropY = person.ProfileCropY,
ProfileCropZoom = person.ProfileCropZoom,
TotalAlbums = person.Albums?.Count ?? 0,
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null
};
}

View File

@@ -1,4 +1,5 @@
using Butter.Dtos.User;
using Butter.Types;
using Lactose.Models;
namespace Lactose.Mapper;
@@ -8,19 +9,33 @@ namespace Lactose.Mapper;
/// </summary>
public static class UsersMapper {
/// <summary>
/// Maps a User object to a UserInfoDto object.
/// Maps a User object to a UserInfoDto object, filtering sensitive fields based on the viewer's access.
/// Sensitive fields (Email, Ban/Delete status, AccessLevel, MaintainedPersons) are only populated
/// when the viewer is an admin or viewing their own profile.
/// </summary>
/// <param name="user">The User object to map.</param>
/// <param name="viewerAccessLevel">The access level of the requesting user.</param>
/// <param name="viewerId">The ID of the requesting user (null if anonymous).</param>
/// <returns>A UserInfoDto object containing the mapped data.</returns>
public static UserInfoDto ToGetUsersDto(this User user) {
public static UserInfoDto ToGetUsersDto(this User user, EAccessLevel viewerAccessLevel, Guid? viewerId) {
bool isAdmin = viewerAccessLevel >= EAccessLevel.Admin;
bool isSelf = viewerId == user.Id;
bool showSensitive = isAdmin || isSelf;
return new UserInfoDto {
Id = user.Id,
Email = user.Email,
Username = user.Username,
CreatedAt = user.CreatedAt,
IsBanned = user.BannedAt != null,
DeletedAt = user.DeletedAt,
AccessLevel = user.AccessLevel,
Id = user.Id,
Username = user.Username,
Email = showSensitive ? user.Email : null,
CreatedAt = user.CreatedAt,
UpdatedAt = showSensitive ? user.UpdatedAt : null,
LastLogin = showSensitive ? user.LastLogin : null,
BannedAt = showSensitive ? user.BannedAt : null,
DeletedAt = isAdmin ? user.DeletedAt : null,
IsBanned = showSensitive && user.BannedAt != null,
AccessLevel = showSensitive ? user.AccessLevel : EAccessLevel.User,
MaintainedPersonIds = showSensitive ? user.MaintainedPersons?.Select(p => p.Id).ToList() : null,
MaintainedPersonNames = showSensitive ? user.MaintainedPersons?.Select(p => p.Name).ToList() : null
};
}
}

View File

@@ -0,0 +1,533 @@
// <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("20260707160046_FixSharedWithManyToMany")]
partial class FixSharedWithManyToMany
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
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 without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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 without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
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 without 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 without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
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.Property<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 without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without 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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,119 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class FixSharedWithManyToMany : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Users_Assets_AssetId",
table: "Users");
migrationBuilder.DropIndex(
name: "IX_Users_AssetId",
table: "Users");
migrationBuilder.DropColumn(
name: "AssetId",
table: "Users");
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "JobRecords",
type: "VARCHAR(2048)",
nullable: false,
oldClrType: typeof(string),
oldType: "VARCHAR(256)");
migrationBuilder.AlterColumn<string>(
name: "RegexPattern",
table: "Folders",
type: "character varying(2048)",
maxLength: 2048,
nullable: true,
oldClrType: typeof(string),
oldType: "VARCHAR(2048)",
oldMaxLength: 2048,
oldNullable: true);
migrationBuilder.CreateTable(
name: "AssetUser",
columns: table => new
{
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
table.ForeignKey(
name: "FK_AssetUser_Assets_SharedAssetsId",
column: x => x.SharedAssetsId,
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AssetUser_Users_SharedWithId",
column: x => x.SharedWithId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AssetUser_SharedWithId",
table: "AssetUser",
column: "SharedWithId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AssetUser");
migrationBuilder.AddColumn<Guid>(
name: "AssetId",
table: "Users",
type: "uuid",
nullable: true);
migrationBuilder.AlterColumn<string>(
name: "Name",
table: "JobRecords",
type: "VARCHAR(256)",
nullable: false,
oldClrType: typeof(string),
oldType: "VARCHAR(2048)");
migrationBuilder.AlterColumn<string>(
name: "RegexPattern",
table: "Folders",
type: "VARCHAR(2048)",
maxLength: 2048,
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(2048)",
oldMaxLength: 2048,
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Users_AssetId",
table: "Users",
column: "AssetId");
migrationBuilder.AddForeignKey(
name: "FK_Users_Assets_AssetId",
table: "Users",
column: "AssetId",
principalTable: "Assets",
principalColumn: "Id");
}
}
}

View File

@@ -0,0 +1,543 @@
// <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("20260707172403_AddPreviewSizeAndFormatFields")]
partial class AddPreviewSizeAndFormatFields
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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 without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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 without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
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 without 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 without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
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 without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without 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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddPreviewSizeAndFormatFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PreviewFormat",
table: "Assets",
type: "VARCHAR(16)",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
name: "PreviewSize",
table: "Assets",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "ThumbnailFormat",
table: "Assets",
type: "VARCHAR(16)",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PreviewFormat",
table: "Assets");
migrationBuilder.DropColumn(
name: "PreviewSize",
table: "Assets");
migrationBuilder.DropColumn(
name: "ThumbnailFormat",
table: "Assets");
}
}
}

View File

@@ -0,0 +1,554 @@
// <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("20260708104055_AddAlbumCoverAsset")]
partial class AddAlbumCoverAsset
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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 without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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 without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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 without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
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 without 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 without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.ToTable("People");
});
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 without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without 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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,66 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAlbumCoverAsset : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "CoverAssetId",
table: "Albums",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Albums_CoverAssetId",
table: "Albums",
column: "CoverAssetId");
migrationBuilder.AddForeignKey(
name: "FK_Albums_Assets_CoverAssetId",
table: "Albums",
column: "CoverAssetId",
principalTable: "Assets",
principalColumn: "Id");
migrationBuilder.Sql("""
UPDATE "Albums"
SET "CoverAssetId" = sub."AssetId"
FROM (
SELECT DISTINCT ON ("Albums"."Id")
"Albums"."Id" AS "AlbumId",
"Assets"."Id" AS "AssetId"
FROM "Albums"
JOIN "AlbumAsset" ON "AlbumAsset"."AlbumsId" = "Albums"."Id"
JOIN "Assets" ON "Assets"."Id" = "AlbumAsset"."AssetsId"
WHERE "Albums"."CoverAssetId" IS NULL
AND "Assets"."DeletedAt" IS NULL
ORDER BY "Albums"."Id", "Assets"."OriginalFilename"
) AS sub
WHERE "Albums"."Id" = sub."AlbumId"
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Albums_Assets_CoverAssetId",
table: "Albums");
migrationBuilder.DropIndex(
name: "IX_Albums_CoverAssetId",
table: "Albums");
migrationBuilder.DropColumn(
name: "CoverAssetId",
table: "Albums");
}
}
}

View File

@@ -0,0 +1,568 @@
// <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("20260709100044_AddPersonProfileAsset")]
partial class AddPersonProfileAsset
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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 without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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 without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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 without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
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 without 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 without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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 without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without 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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddPersonProfileAsset : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ProfileAssetId",
table: "People",
type: "uuid",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_People_ProfileAssetId",
table: "People",
column: "ProfileAssetId");
migrationBuilder.AddForeignKey(
name: "FK_People_Assets_ProfileAssetId",
table: "People",
column: "ProfileAssetId",
principalTable: "Assets",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_People_Assets_ProfileAssetId",
table: "People");
migrationBuilder.DropIndex(
name: "IX_People_ProfileAssetId",
table: "People");
migrationBuilder.DropColumn(
name: "ProfileAssetId",
table: "People");
}
}
}

View File

@@ -0,0 +1,574 @@
// <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("20260709105956_AddPersonProfileCrop")]
partial class AddPersonProfileCrop
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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 without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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 without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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 without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
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 without 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 without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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 without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without 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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddPersonProfileCrop : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<float>(
name: "ProfileCropX",
table: "People",
type: "real",
nullable: true);
migrationBuilder.AddColumn<float>(
name: "ProfileCropY",
table: "People",
type: "real",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProfileCropX",
table: "People");
migrationBuilder.DropColumn(
name: "ProfileCropY",
table: "People");
}
}
}

View File

@@ -0,0 +1,577 @@
// <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("20260709111214_AddPersonProfileCropZoom")]
partial class AddPersonProfileCropZoom
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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 without time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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 without time zone");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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 without time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
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 without 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 without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without 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 without time zone");
b.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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 without time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without 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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddPersonProfileCropZoom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<float>(
name: "ProfileCropZoom",
table: "People",
type: "real",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ProfileCropZoom",
table: "People");
}
}
}

View File

@@ -0,0 +1,577 @@
// <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("20260709170106_MigrateToTimestamptz")]
partial class MigrateToTimestamptz
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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<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<DateTime?>("DeletedAt")
.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.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,144 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class MigrateToTimestamptz : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("SET TimeZone='UTC';");
// Albums
migrationBuilder.Sql(@"
ALTER TABLE ""Albums"" ALTER COLUMN ""CreatedAt"" TYPE timestamp with time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Albums"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp with time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
// Assets
migrationBuilder.Sql(@"
ALTER TABLE ""Assets"" ALTER COLUMN ""CreatedAt"" TYPE timestamp with time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Assets"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp with time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Assets"" ALTER COLUMN ""DeletedAt"" TYPE timestamp with time zone
USING ""DeletedAt"" AT TIME ZONE 'UTC';");
// Users
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""CreatedAt"" TYPE timestamp with time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp with time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""LastLogin"" TYPE timestamp with time zone
USING ""LastLogin"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""BannedAt"" TYPE timestamp with time zone
USING ""BannedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""DeletedAt"" TYPE timestamp with time zone
USING ""DeletedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""RefreshTokenExpires"" TYPE timestamp with time zone
USING ""RefreshTokenExpires"" AT TIME ZONE 'UTC';");
// People
migrationBuilder.Sql(@"
ALTER TABLE ""People"" ALTER COLUMN ""CreatedAt"" TYPE timestamp with time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""People"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp with time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""People"" ALTER COLUMN ""DeletedAt"" TYPE timestamp with time zone
USING ""DeletedAt"" AT TIME ZONE 'UTC';");
// JobRecords
migrationBuilder.Sql(@"
ALTER TABLE ""JobRecords"" ALTER COLUMN ""Created"" TYPE timestamp with time zone
USING ""Created"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""JobRecords"" ALTER COLUMN ""Started"" TYPE timestamp with time zone
USING ""Started"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""JobRecords"" ALTER COLUMN ""Finished"" TYPE timestamp with time zone
USING ""Finished"" AT TIME ZONE 'UTC';");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("SET TimeZone='UTC';");
// JobRecords
migrationBuilder.Sql(@"
ALTER TABLE ""JobRecords"" ALTER COLUMN ""Finished"" TYPE timestamp without time zone
USING ""Finished"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""JobRecords"" ALTER COLUMN ""Started"" TYPE timestamp without time zone
USING ""Started"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""JobRecords"" ALTER COLUMN ""Created"" TYPE timestamp without time zone
USING ""Created"" AT TIME ZONE 'UTC';");
// People
migrationBuilder.Sql(@"
ALTER TABLE ""People"" ALTER COLUMN ""DeletedAt"" TYPE timestamp without time zone
USING ""DeletedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""People"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp without time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""People"" ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
// Users
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""RefreshTokenExpires"" TYPE timestamp without time zone
USING ""RefreshTokenExpires"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""DeletedAt"" TYPE timestamp without time zone
USING ""DeletedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""BannedAt"" TYPE timestamp without time zone
USING ""BannedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""LastLogin"" TYPE timestamp without time zone
USING ""LastLogin"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp without time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Users"" ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
// Assets
migrationBuilder.Sql(@"
ALTER TABLE ""Assets"" ALTER COLUMN ""DeletedAt"" TYPE timestamp without time zone
USING ""DeletedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Assets"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp without time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Assets"" ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
// Albums
migrationBuilder.Sql(@"
ALTER TABLE ""Albums"" ALTER COLUMN ""UpdatedAt"" TYPE timestamp without time zone
USING ""UpdatedAt"" AT TIME ZONE 'UTC';");
migrationBuilder.Sql(@"
ALTER TABLE ""Albums"" ALTER COLUMN ""CreatedAt"" TYPE timestamp without time zone
USING ""CreatedAt"" AT TIME ZONE 'UTC';");
}
}
}

View File

@@ -0,0 +1,580 @@
// <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("20260710104820_AddModifiedAtToJobRecord")]
partial class AddModifiedAtToJobRecord
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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<DateTime?>("DeletedAt")
.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.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddModifiedAtToJobRecord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ModifiedAt",
table: "JobRecords",
type: "timestamp with time zone",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ModifiedAt",
table: "JobRecords");
}
}
}

View File

@@ -0,0 +1,577 @@
// <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("20260710164823_RemovePersonDeletedAt")]
partial class RemovePersonDeletedAt
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
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.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class RemovePersonDeletedAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "People");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "DeletedAt",
table: "People",
type: "timestamp with time zone",
nullable: true);
}
}
}

View File

@@ -0,0 +1,581 @@
// <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("20260712160403_AddAssetsSearchVisibilityIndex")]
partial class AddAssetsSearchVisibilityIndex
{
/// <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, "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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("IsPubliclyShared", "OwnerId")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddAssetsSearchVisibilityIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "IsPubliclyShared", "OwnerId" },
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
}
}
}

View File

@@ -0,0 +1,594 @@
// <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("20260712162406_AddTrigramIndexes")]
partial class AddTrigramIndexes
{
/// <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("AssetUser", b =>
{
b.Property<Guid>("SharedAssetsId")
.HasColumnType("uuid");
b.Property<Guid>("SharedWithId")
.HasColumnType("uuid");
b.HasKey("SharedAssetsId", "SharedWithId");
b.HasIndex("SharedWithId");
b.ToTable("AssetUser");
});
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<Guid?>("UserOwnerId")
.HasColumnType("uuid");
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.HasIndex("UserOwnerId");
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<bool>("IsPubliclyShared")
.HasColumnType("boolean");
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<Guid?>("OwnerId")
.HasColumnType("uuid");
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.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("IsPubliclyShared", "OwnerId")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.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.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("AssetUser", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("SharedAssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", null)
.WithMany()
.HasForeignKey("SharedWithId")
.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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.Navigation("Folder");
b.Navigation("Owner");
});
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.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("OwnedAlbums");
b.Navigation("OwnedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AddTrigramIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.Annotation("Npgsql:PostgresExtension:vector", ",,")
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
migrationBuilder.CreateIndex(
name: "IX_People_Name_Trgm",
table: "People",
column: "Name")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
migrationBuilder.CreateIndex(
name: "IX_Albums_Title_Trgm",
table: "Albums",
column: "Title")
.Annotation("Npgsql:IndexMethod", "gin")
.Annotation("Npgsql:IndexOperators", new[] { "gin_trgm_ops" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_People_Name_Trgm",
table: "People");
migrationBuilder.DropIndex(
name: "IX_Albums_Title_Trgm",
table: "Albums");
migrationBuilder.AlterDatabase()
.Annotation("Npgsql:PostgresExtension:vector", ",,")
.OldAnnotation("Npgsql:PostgresExtension:pg_trgm", ",,")
.OldAnnotation("Npgsql:PostgresExtension:vector", ",,");
}
}
}

View File

@@ -0,0 +1,591 @@
// <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("20260714154509_AuthRedesign_VisibilityAccessLevel")]
partial class AuthRedesign_VisibilityAccessLevel
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("AlbumAsset", b =>
{
b.Property<Guid>("AlbumsId")
.HasColumnType("uuid");
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.HasKey("AlbumsId", "AssetsId");
b.HasIndex("AssetsId");
b.ToTable("AlbumAsset");
});
modelBuilder.Entity("AssetTag", b =>
{
b.Property<Guid>("AssetsId")
.HasColumnType("uuid");
b.Property<Guid>("TagsId")
.HasColumnType("uuid");
b.HasKey("AssetsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("AssetTag");
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("Title")
.HasDatabaseName("IX_Albums_Title_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
b.ToTable("Albums");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
b.Property<long>("FileSize")
.HasColumnType("bigint");
b.Property<Guid?>("FolderId")
.HasColumnType("uuid");
b.Property<float?>("FrameRate")
.HasColumnType("real");
b.Property<BitArray>("Hash")
.IsRequired()
.HasColumnType("bit(64)");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalFilename")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("PreviewFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("PreviewPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("PreviewSize")
.HasColumnType("integer");
b.Property<int>("ResolutionHeight")
.HasColumnType("integer");
b.Property<int>("ResolutionWidth")
.HasColumnType("integer");
b.Property<string>("ThumbnailFormat")
.IsRequired()
.HasColumnType("VARCHAR(16)");
b.Property<string>("ThumbnailPath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<int>("ThumbnailSize")
.HasColumnType("integer");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FolderId");
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<int>("BoundingBoxX1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxX2")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY1")
.HasColumnType("integer");
b.Property<int>("BoundingBoxY2")
.HasColumnType("integer");
b.Property<int>("ImageHeight")
.HasColumnType("integer");
b.Property<int>("ImageWidth")
.HasColumnType("integer");
b.Property<Guid?>("PersonId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("AssetId");
b.HasIndex("PersonId");
b.ToTable("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("Active")
.HasColumnType("boolean");
b.Property<string>("BasePath")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.HasKey("Id");
b.ToTable("Folders");
});
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
b.Property<float>("Progress")
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("JobRecords");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ProfileAssetId")
.HasColumnType("uuid");
b.Property<float?>("ProfileCropX")
.HasColumnType("real");
b.Property<float?>("ProfileCropY")
.HasColumnType("real");
b.Property<float?>("ProfileCropZoom")
.HasColumnType("real");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.HasDatabaseName("IX_People_Name_Trgm");
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
b.HasIndex("ProfileAssetId");
b.ToTable("People");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.Property<Guid>("PersonId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("PersonId", "UserId");
b.HasIndex("UserId");
b.ToTable("PersonMaintainers");
});
modelBuilder.Entity("Lactose.Models.Setting", b =>
{
b.Property<string>("Name")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
.HasColumnType("integer");
b.Property<string>("Value")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.HasKey("Name");
b.ToTable("Settings");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Tags");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("VARCHAR(64)");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("AlbumAsset", b =>
{
b.HasOne("Lactose.Models.Album", null)
.WithMany()
.HasForeignKey("AlbumsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("AssetTag", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany()
.HasForeignKey("AssetsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Lactose.Models.Album", b =>
{
b.HasOne("Lactose.Models.Asset", "CoverAsset")
.WithMany()
.HasForeignKey("CoverAssetId");
b.HasOne("Lactose.Models.Person", "PersonOwner")
.WithMany("Albums")
.HasForeignKey("PersonOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.HasOne("Lactose.Models.Folder", "Folder")
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
{
b.HasOne("Lactose.Models.Asset", "Asset")
.WithMany("Faces")
.HasForeignKey("AssetId");
b.HasOne("Lactose.Models.Person", "Person")
.WithMany("Faces")
.HasForeignKey("PersonId");
b.Navigation("Asset");
b.Navigation("Person");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
.WithMany()
.HasForeignKey("ProfileAssetId");
b.Navigation("ProfileAsset");
});
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
{
b.HasOne("Lactose.Models.Person", "Person")
.WithMany()
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Lactose.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("User");
});
modelBuilder.Entity("Lactose.Models.Tag", b =>
{
b.HasOne("Lactose.Models.Tag", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
{
b.Navigation("Assets");
});
modelBuilder.Entity("Lactose.Models.Person", b =>
{
b.Navigation("Albums");
b.Navigation("Faces");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,229 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Lactose.Migrations
{
/// <inheritdoc />
public partial class AuthRedesign_VisibilityAccessLevel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Albums_Users_UserOwnerId",
table: "Albums");
migrationBuilder.DropForeignKey(
name: "FK_Assets_Users_OwnerId",
table: "Assets");
// Remap old EAccessLevel values: old Admin (2) → new Admin (3) first, then old Curator (1) → new Curator (2)
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 3 WHERE \"AccessLevel\" = 2");
migrationBuilder.Sql("UPDATE \"Users\" SET \"AccessLevel\" = 2 WHERE \"AccessLevel\" = 1");
migrationBuilder.DropTable(
name: "AssetUser");
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
migrationBuilder.DropIndex(
name: "IX_Albums_UserOwnerId",
table: "Albums");
// Add Visibility columns with default Protected (1) before dropping IsPubliclyShared,
// so we can map old values via SQL
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "People",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "Assets",
type: "integer",
nullable: false,
defaultValue: 1);
migrationBuilder.AddColumn<int>(
name: "Visibility",
table: "Albums",
type: "integer",
nullable: false,
defaultValue: 1);
// Map old IsPubliclyShared = true assets back to Public (0)
// Assets that were private default to Protected (1) from the column default
migrationBuilder.Sql("UPDATE \"Assets\" SET \"Visibility\" = 0 WHERE \"IsPubliclyShared\" = true");
migrationBuilder.DropColumn(
name: "IsPubliclyShared",
table: "Assets");
migrationBuilder.DropColumn(
name: "UserOwnerId",
table: "Albums");
migrationBuilder.RenameColumn(
name: "OwnerId",
table: "Assets",
newName: "UploadedBy");
migrationBuilder.RenameIndex(
name: "IX_Assets_OwnerId",
table: "Assets",
newName: "IX_Assets_UploadedBy");
migrationBuilder.CreateTable(
name: "PersonMaintainers",
columns: table => new
{
PersonId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PersonMaintainers", x => new { x.PersonId, x.UserId });
table.ForeignKey(
name: "FK_PersonMaintainers_People_PersonId",
column: x => x.PersonId,
principalTable: "People",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PersonMaintainers_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "Visibility", "UploadedBy" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_PersonMaintainers_UserId",
table: "PersonMaintainers",
column: "UserId");
migrationBuilder.AddForeignKey(
name: "FK_Assets_Users_UploadedBy",
table: "Assets",
column: "UploadedBy",
principalTable: "Users",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Assets_Users_UploadedBy",
table: "Assets");
migrationBuilder.DropTable(
name: "PersonMaintainers");
migrationBuilder.DropIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets");
migrationBuilder.DropColumn(
name: "Visibility",
table: "People");
migrationBuilder.DropColumn(
name: "Visibility",
table: "Assets");
migrationBuilder.DropColumn(
name: "Visibility",
table: "Albums");
migrationBuilder.RenameColumn(
name: "UploadedBy",
table: "Assets",
newName: "OwnerId");
migrationBuilder.RenameIndex(
name: "IX_Assets_UploadedBy",
table: "Assets",
newName: "IX_Assets_OwnerId");
migrationBuilder.AddColumn<bool>(
name: "IsPubliclyShared",
table: "Assets",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Guid>(
name: "UserOwnerId",
table: "Albums",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "AssetUser",
columns: table => new
{
SharedAssetsId = table.Column<Guid>(type: "uuid", nullable: false),
SharedWithId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AssetUser", x => new { x.SharedAssetsId, x.SharedWithId });
table.ForeignKey(
name: "FK_AssetUser_Assets_SharedAssetsId",
column: x => x.SharedAssetsId,
principalTable: "Assets",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AssetUser_Users_SharedWithId",
column: x => x.SharedWithId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Assets_VisibleForSearch",
table: "Assets",
columns: new[] { "IsPubliclyShared", "OwnerId" },
filter: "\"DeletedAt\" IS NULL");
migrationBuilder.CreateIndex(
name: "IX_Albums_UserOwnerId",
table: "Albums",
column: "UserOwnerId");
migrationBuilder.CreateIndex(
name: "IX_AssetUser_SharedWithId",
table: "AssetUser",
column: "SharedWithId");
migrationBuilder.AddForeignKey(
name: "FK_Albums_Users_UserOwnerId",
table: "Albums",
column: "UserOwnerId",
principalTable: "Users",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Assets_Users_OwnerId",
table: "Assets",
column: "OwnerId",
principalTable: "Users",
principalColumn: "Id");
}
}
}

View File

@@ -18,9 +18,10 @@ namespace Lactose.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.15")
.HasAnnotation("ProductVersion", "10.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -60,9 +61,11 @@ namespace Lactose.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime?>("CreatedAt")
.IsRequired()
.HasColumnType("timestamp without time zone");
b.Property<Guid?>("CoverAssetId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("PersonOwnerId")
.HasColumnType("uuid");
@@ -72,16 +75,22 @@ namespace Lactose.Migrations
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UserOwnerId")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CoverAssetId");
b.HasIndex("PersonOwnerId");
b.HasIndex("UserOwnerId");
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");
});
@@ -93,10 +102,10 @@ namespace Lactose.Migrations
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<float?>("Duration")
.HasColumnType("real");
@@ -114,9 +123,6 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("bit(64)");
b.Property<bool>("IsPubliclyShared")
.HasColumnType("boolean");
b.Property<string>("MimeType")
.IsRequired()
.HasColumnType("VARCHAR(255)");
@@ -129,19 +135,27 @@ namespace Lactose.Migrations
.IsRequired()
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("OwnerId")
.HasColumnType("uuid");
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)");
@@ -153,7 +167,13 @@ namespace Lactose.Migrations
.HasColumnType("integer");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("UploadedBy")
.HasColumnType("uuid");
b.Property<int>("Visibility")
.HasColumnType("integer");
b.HasKey("Id");
@@ -162,7 +182,11 @@ namespace Lactose.Migrations
b.HasIndex("OriginalPath")
.IsUnique();
b.HasIndex("OwnerId");
b.HasIndex("UploadedBy");
b.HasIndex("Visibility", "UploadedBy")
.HasDatabaseName("IX_Assets_VisibleForSearch")
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Assets");
});
@@ -221,7 +245,7 @@ namespace Lactose.Migrations
b.Property<string>("RegexPattern")
.HasMaxLength(2048)
.HasColumnType("VARCHAR(2048)");
.HasColumnType("character varying(2048)");
b.HasKey("Id");
@@ -235,10 +259,10 @@ namespace Lactose.Migrations
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("Finished")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<int>("JobType")
.HasColumnType("integer");
@@ -246,9 +270,12 @@ namespace Lactose.Migrations
b.Property<string>("Message")
.HasColumnType("VARCHAR(2048)");
b.Property<DateTime?>("ModifiedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("VARCHAR(256)");
.HasColumnType("VARCHAR(2048)");
b.Property<Guid?>("ParentJobId")
.HasColumnType("uuid");
@@ -257,7 +284,7 @@ namespace Lactose.Migrations
.HasColumnType("real");
b.Property<DateTime?>("Started")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<int>("Status")
.HasColumnType("integer");
@@ -274,23 +301,58 @@ namespace Lactose.Migrations
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
.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 without time zone");
.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")
@@ -304,7 +366,7 @@ namespace Lactose.Migrations
b.Property<int>("DisplayType")
.HasColumnType("integer");
b.Property<string[]>("Options")
b.PrimitiveCollection<string[]>("Options")
.HasColumnType("text[]");
b.Property<int>("Type")
@@ -348,24 +410,21 @@ namespace Lactose.Migrations
b.Property<int>("AccessLevel")
.HasColumnType("integer");
b.Property<Guid?>("AssetId")
.HasColumnType("uuid");
b.Property<DateTime?>("BannedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("VARCHAR(128)");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.IsRequired()
@@ -376,10 +435,10 @@ namespace Lactose.Migrations
.HasColumnType("VARCHAR(255)");
b.Property<DateTime?>("RefreshTokenExpires")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp without time zone");
.HasColumnType("timestamp with time zone");
b.Property<string>("Username")
.IsRequired()
@@ -387,8 +446,6 @@ namespace Lactose.Migrations
b.HasKey("Id");
b.HasIndex("AssetId");
b.ToTable("Users");
});
@@ -424,17 +481,17 @@ namespace Lactose.Migrations
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.HasOne("Lactose.Models.User", "UserOwner")
.WithMany("OwnedAlbums")
.HasForeignKey("UserOwnerId");
b.Navigation("CoverAsset");
b.Navigation("PersonOwner");
b.Navigation("UserOwner");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
@@ -443,13 +500,13 @@ namespace Lactose.Migrations
.WithMany("Assets")
.HasForeignKey("FolderId");
b.HasOne("Lactose.Models.User", "Owner")
.WithMany("OwnedAssets")
.HasForeignKey("OwnerId");
b.HasOne("Lactose.Models.User", "Uploader")
.WithMany("UploadedAssets")
.HasForeignKey("UploadedBy");
b.Navigation("Folder");
b.Navigation("Owner");
b.Navigation("Uploader");
});
modelBuilder.Entity("Lactose.Models.Face", b =>
@@ -467,6 +524,34 @@ namespace Lactose.Migrations
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")
@@ -476,18 +561,9 @@ namespace Lactose.Migrations
b.Navigation("Parent");
});
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.HasOne("Lactose.Models.Asset", null)
.WithMany("SharedWith")
.HasForeignKey("AssetId");
});
modelBuilder.Entity("Lactose.Models.Asset", b =>
{
b.Navigation("Faces");
b.Navigation("SharedWith");
});
modelBuilder.Entity("Lactose.Models.Folder", b =>
@@ -504,9 +580,7 @@ namespace Lactose.Migrations
modelBuilder.Entity("Lactose.Models.User", b =>
{
b.Navigation("OwnedAlbums");
b.Navigation("OwnedAssets");
b.Navigation("UploadedAssets");
});
#pragma warning restore 612, 618
}

View File

@@ -1,3 +1,4 @@
using Butter.Types;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
@@ -23,32 +24,32 @@ public class Album {
/// Gets or sets the creation date of the album.
/// </summary>
[Required]
public DateTime? CreatedAt { get; set; } = DateTime.Now;
public DateTime? CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the last updated date of the album.
/// </summary>
public DateTime? UpdatedAt { get; set; }
/// <summary>
/// Gets or sets the user owner identifier.
/// </summary>
[ForeignKey(nameof(UserOwner))]
public Guid? UserOwnerId { get; set; }
/// <summary>
/// Gets or sets the person owner identifier.
/// </summary>
[ForeignKey(nameof(PersonOwner))]
public Guid? PersonOwnerId { get; set; }
#region Navigation Properties
/// <summary>
/// Gets or sets the cover asset identifier.
/// </summary>
[ForeignKey(nameof(CoverAsset))]
public Guid? CoverAssetId { get; set; }
/// <summary>
/// Gets or sets the user owner of the album. This is the user account who created/owns the album.
/// Gets or sets the visibility level of the album.
/// </summary>
public User? UserOwner { get; set; }
public EVisibility Visibility { get; set; }
#region Navigation Properties
/// <summary>
/// Gets or sets the person owner of the album. This is the person/model who appears in the album.
/// </summary>
@@ -59,5 +60,10 @@ public class Album {
/// </summary>
public List<Asset>? Assets { get; set; }
/// <summary>
/// Gets or sets the cover asset for this album.
/// </summary>
public Asset? CoverAsset { get; set; }
#endregion
}

View File

@@ -43,33 +43,53 @@ public class Asset {
/// </summary>
public int ThumbnailSize { get; set; }
/// <summary>
/// Gets or sets the format of the generated thumbnail (e.g. "webp").
/// Used to detect when thumbnails need regeneration due to format changes.
/// </summary>
[Column(TypeName = "VARCHAR(16)")]
public string ThumbnailFormat { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the preview path of the asset.
/// </summary>
[Column(TypeName = "VARCHAR(2048)")]
public string PreviewPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the longest-side size in pixels of the generated preview.
/// Zero means no preview has been generated.
/// </summary>
public int PreviewSize { get; set; }
/// <summary>
/// Gets or sets the format of the generated preview (e.g. "webp").
/// Used to detect when previews need regeneration due to format changes.
/// </summary>
[Column(TypeName = "VARCHAR(16)")]
public string PreviewFormat { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the type of the asset.
/// </summary>
public required EAssetType Type { get; set; }
/// <summary>
/// Gets or sets the owner ID of the asset.
/// Gets or sets the ID of the user who uploaded the asset.
/// </summary>
[ForeignKey(nameof(Owner))]
public Guid? OwnerId { get; set; }
[ForeignKey(nameof(UploadedBy))]
public Guid? UploadedBy { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the asset is publicly shared.
/// Gets or sets the visibility level of the asset.
/// </summary>
public bool IsPubliclyShared { get; set; }
public EVisibility Visibility { get; set; }
/// <summary>
/// Gets or sets the creation date of the asset.
/// </summary>
[Required]
public required DateTime CreatedAt { get; set; } = DateTime.Now;
public required DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets the last updated date of the asset.
@@ -116,7 +136,7 @@ public class Asset {
/// Computed hash of the asset.
/// </summary>
[Required][Column(TypeName = "bit(64)")]
public BitArray Hash { get; set; }
public BitArray Hash { get; set; } = new BitArray(64);
/// <summary>
/// Gets or Sets which folder the asset is in.
@@ -127,14 +147,9 @@ public class Asset {
#region Navigation Properties
/// <summary>
/// Gets or sets the owner of the asset.
/// Gets or sets the user who uploaded the asset.
/// </summary>
public User? Owner { get; set; }
/// <summary>
/// Gets or sets the list of users with whom the asset is shared.
/// </summary>
public List<User>? SharedWith { get; set; }
public User? Uploader { get; set; }
/// <summary>
/// Gets or sets the list of albums associated with the asset.

View File

@@ -54,4 +54,9 @@ public class JobRecord {
/// Gets or sets the job progress (0 to 1).
/// </summary>
public float Progress { get; set; }
/// <summary>
/// Gets or sets the timestamp of the last modification to this record.
/// Used for delta-based children sync.
/// </summary>
public DateTime? ModifiedAt { get; set; }
}

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