Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd1e5841cf | ||
|
|
d318400504 | ||
|
|
9d70516da2 | ||
|
|
52e026d7e1 | ||
|
|
a811221228 | ||
|
|
3ffee8df78 | ||
|
|
63e3413141 | ||
|
|
b83b97934c | ||
|
|
6bf627e429 | ||
|
|
7ef8d8155f | ||
|
|
1aeb644e30 | ||
|
|
8913fa7c43 | ||
|
|
f42c2e4d04 | ||
|
|
0480b00766 | ||
|
|
26674e05e5 | ||
|
|
29eb39e545 | ||
|
|
fbcc7a317d |
+3
-1
@@ -9,4 +9,6 @@ dotnet-tools.json
|
||||
.opencode/
|
||||
*.css.map
|
||||
# SixLabors license file
|
||||
/Lactose/sixlabors.lic
|
||||
/Lactose/sixlabors.lic
|
||||
.desloppify/
|
||||
.claude/
|
||||
@@ -30,7 +30,7 @@
|
||||
</deployment>
|
||||
<EXTENSION ID="com.jetbrains.rider.docker.debug" isFastModeEnabled="false" isSslEnabled="false" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Unnamed" run_configuration_type="ShConfigurationType" />
|
||||
<option name="RunConfigurationTask" enabled="false" run_configuration_name="VersionEnvGen" run_configuration_type="ShConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="VersionEnvGen" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/generate-version.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@@ -44,8 +44,6 @@ dotnet run --project MilkStream # WASM host on :5269 (host) / :8080 (conta
|
||||
|
||||
**Database:** Default credentials in `appsettings.json`. Docker compose spins up all services.
|
||||
|
||||
**Port gotcha:** `appsettings.json` defaults `DatabaseAddress:Port` to **3306** (legacy MySQL port). PostgreSQL runs on 5432. The raw connection string must be overridden, or use `docker-compose.yml` which maps host 3306 → container 5432 automatically.
|
||||
|
||||
## Infrastructure gotchas
|
||||
|
||||
- **MilkStream `/appsettings.json`:** Dynamically generated from server config — maps **before** `UseStaticFiles`, so it shadows the static file in `MilkStream.Client/wwwroot/`. This endpoint provides `LactoseBaseUrl` to the WASM client.
|
||||
|
||||
@@ -14,4 +14,9 @@ public class AlbumSearchParametersDto : PagedSearchParametersDto {
|
||||
/// Only returns albums that contain at least one asset uploaded by the specified user.
|
||||
/// </summary>
|
||||
public Guid? AssetUploadedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the person ID to filter albums by their owner.
|
||||
/// </summary>
|
||||
public Guid? PersonOwnerId { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,4 +38,9 @@ public class AssetSearchOptionsDto: PagedSearchParametersDto {
|
||||
/// Gets or sets the uploader user ID to filter assets by their uploader.
|
||||
/// </summary>
|
||||
public Guid? UploadedBy { get; set; }
|
||||
/// <summary>
|
||||
/// Gets or sets whether the total matching count should be computed and returned in the <c>X-Total-Count</c> header.
|
||||
/// Skipping it avoids a full count query over the filtered result set.
|
||||
/// </summary>
|
||||
public bool IncludeCount { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public enum Settings {
|
||||
/// <summary>
|
||||
/// Provides extension methods for the <see cref="Settings"/> enum.
|
||||
/// </summary>
|
||||
public static class SettingsExtensions {
|
||||
public static class SettingsExtension {
|
||||
/// <summary>
|
||||
/// Converts a <see cref="Settings"/> value to its human-readable display string.
|
||||
/// </summary>
|
||||
|
||||
+859
@@ -0,0 +1,859 @@
|
||||
# Changelog
|
||||
|
||||
## [1.0.3.2] - 2026-08-17
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add personOwnerId album filter and opt-in asset IncludeCount
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Page cosplayer albums via the album endpoint and skip count queries
|
||||
- Cache thumbnails and previews in the browser for 24h
|
||||
- Add filtered IX_Assets_VisibleCreatedAt index for the default listing
|
||||
- Server-side seeded shuffle, DTO projection, and opt-in count
|
||||
- Project album assets to DTO in SQL and slim the Find paths
|
||||
- Push album filter/search/sort/pagination to SQL in person FindVisible
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Add auto-save middleware as a safety net for the Save() pattern
|
||||
- Desloppify review fixes: PathFromGuid extraction, RegexHighlighter dedup, port default fix
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Quality improvements from the desloppify scan
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Update desloppify scorecard and add it to the README
|
||||
|
||||
## [1.0.3.1] - 2026-07-22
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add sliding-window page buffer to AlbumGrid and CosplayerGrid with scroll-based observer reattach
|
||||
- Embed git version in WASM footer via AssemblyInformationalVersion
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use status CSS variables for progress bar segment colors
|
||||
- Eliminate masonry layout race on infinite scroll and banner double-fetch (#146)
|
||||
- Masonry items stacking on infinite scroll — missing @ on ViewMode binding (#146)
|
||||
- Prevent infinite scroll cascade by debouncing observer reattach and disabling scroll anchoring
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- Replace IntersectionObserver with input-event-based infinite scroll
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Scripts and run configs to automate version injection
|
||||
- Removed outdated agent.md
|
||||
|
||||
## [1.0.3] - 2026-07-21
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Cropper handle size constant mismatch (14→18 px)
|
||||
- Jobs past tab zero-based pagination
|
||||
- Show registration disabled message on 403
|
||||
- Hide register form when registration is disabled
|
||||
- Allow manual folder scan when scheduled scan is off
|
||||
- Prevent masonry layout shift on infinite scroll
|
||||
- Order album assets by CreatedAt in album detail (#144)
|
||||
- Replace overflow-y with JS masonry for stable album layout (#146)
|
||||
- Add aspect-ratio to album cards for stable masonry height (#146)
|
||||
- Use per-card aspect-ratio from cover dimensions for stable masonry layout (#146)
|
||||
- Restore masonryLayout.dispose that was lost in reset edit
|
||||
- Use pre-pagination album count (#143)
|
||||
- Sort album assets by OriginalFilename instead of CreatedAt (#144)
|
||||
|
||||
## [1.0.2] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Remove redundant AddJsonFile calls that overrode env vars
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Auto-run on tags only, manual dispatch on branches
|
||||
|
||||
## [1.0.1] - 2026-07-17
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Use explicit domain git.r3d.codes instead of gitea.server_url
|
||||
- Sanitize tag names for Docker compatibility (replace / with -)
|
||||
- Lowercase repository name for Docker compatibility
|
||||
- Match matrix service names to actual directory case (Lactose/MilkStream)
|
||||
- Remove gha cache config (cache server unreachable from builder)
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Add BuildKit cache mounts for NuGet packages in Dockerfiles
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Add workflow_dispatch trigger with version input
|
||||
- Also run on push to master
|
||||
- Also run on develop and hotfix/* branches
|
||||
- Pass SixLaborsLicenseKey as build arg for lactose
|
||||
- Use REGISTRY_TOKEN (PAT) instead of GITEA_TOKEN for registry push
|
||||
|
||||
### CI
|
||||
|
||||
- Added sixlabors license key to the docker file for lactose
|
||||
|
||||
## [1.0] - 2026-07-16
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- *(API)* Adds Authentication System (Needs Testing)
|
||||
- *(API)* Adds UserRepository.cs
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Adds Password hashing
|
||||
- *(API)* Implements user delete endpoint
|
||||
- *(API)* Adds stubs for AssetController.cs
|
||||
- *(API)* Adds Album endpoint Stubs
|
||||
- *(API)* Adds TagController class
|
||||
- *(API)* Adds Tag endpoint implementation
|
||||
- *(API)* Adds Repository implementation (not tested yet)
|
||||
- *(API)* Implements TagController With Bulk Requests
|
||||
- *(API)* Adds person stub endpoint
|
||||
- *(API)* Added Resolution, mimetype and extra media data on asset table
|
||||
- *(API)* Adds computed file hash field in Asset
|
||||
- *(API)* Adds Folder and Asset entities in the DB
|
||||
- *(API)* Implemented FolderController and their respective Mappers/DTO/Repository interface and it's own implementation
|
||||
- [**breaking**] Upgraded project to .Net 9.0
|
||||
- *(API)* Implemented Settings Endpoint
|
||||
- *(API)* Implemented Media endpoint and relative Repository and DTO
|
||||
- *(DB)* Switched to PostgreSQL connector
|
||||
- *(DB)* [**breaking**] Change to use database pgvecto-rs
|
||||
- *(DB)* [**breaking**] Regenerated migrations for PostgreSQL switch
|
||||
- Switched to on the fly connection string generation
|
||||
- *(DbContext)* Adds Initialization for Development environment
|
||||
- *(API)* Adds Authentication Result as DTO for authentication
|
||||
- *(Lactose)* Adds RefreshToken
|
||||
- *(Lactose)* Adds DB Context Snapshot
|
||||
- *(Lactose)* Adds RefreshToken Endpoint
|
||||
- *(DB)* Changed Asset table to have OriginalPath as Unique Index
|
||||
- Added a settings enum + it's own extension class to retrieve interpretation type and string name in DB
|
||||
- *(db)* Adds Migration for Settings in LactoseDbContext
|
||||
- Added disposability to some repositories, changed the FileSystemScanner service to be a singleton.
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Upgraded login to new API changes
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- *(API)* Added get-all endpoint for settings controller.
|
||||
- *(frontend)* Added settings service
|
||||
- *(API)* Added Access level to the data returned from authentication actions
|
||||
- *(frontend)* Added login and JWT token auto-refresh
|
||||
- *(frontend)* Adds ServiceBase and AuthServiceBase abstract classes
|
||||
- *(frontend)* Login and logout now properly work
|
||||
- *(frontend)* Added folders service
|
||||
- *(frontend)* Nav menu restyle
|
||||
- *(API)* Adds method to modify the FileWatcherService: folders, time
|
||||
- *(API)* Edits SettingsRepository to manage FileWatcher Service
|
||||
- *(frontend)* Editing of settings and addition/editing of folder paths
|
||||
- *(frontend)* Added folder removal in settings
|
||||
- Migrate MilkStream frontend from Blazor SSR to Blazor WASM
|
||||
- *(cors)* Support semicolon-separated CorsAllowedOrigins env var
|
||||
- Add ParentJobId to JobStatusDto for job hierarchy tracking
|
||||
- Add ParentJobId to Job base class
|
||||
- Set ParentJobId on child jobs for ThumbnailJob, FileSystemCrawlJob, PHashJob
|
||||
- Map ParentJobId in JobMapper
|
||||
- Add generic reusable components ProgressBar, CollapsibleSection, PollRateSelector
|
||||
- Add JobRow, JobChildrenSummary, JobSection, JobTree components
|
||||
- Redesign Jobs page with compact hierarchical layout, poll rate selector, and PHash button; remove JobCard
|
||||
- Optimize jobs API with pagination, DB persistence, and cleanup
|
||||
- Add GetAssetsMissingMetadata repository method for metadata job
|
||||
- Implement MetadataJob for image resolution extraction
|
||||
- *(docker)* Add named volumes for thumbnails and previews
|
||||
- Unify settings pages into sidebar + detail panel with auto-save
|
||||
- *(assets)* Add ThumbnailSize column to track generated thumbnail dimensions
|
||||
- *(repository)* Add GetAssetsMissingOrWrongThumbnail to interface
|
||||
- *(repository)* Implement GetAssetsMissingOrWrongThumbnail query
|
||||
- *(thumbs)* Read ThumbnailSize setting, invalidate wrong-sized thumbnails, set size after gen
|
||||
- *(db)* Add migration for ThumbnailSize column on Assets
|
||||
- *(jobs)* Add CompletedWithErrors status for parents with failed sub-jobs
|
||||
- *(ui)* Show CompletedWithErrors as orange and group sub-jobs by status
|
||||
- *(jobs)* Apply batching to PHashJob and MetadataJob
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add JobBatchSize setting for batch job sizing
|
||||
- *(settings)* Add runtime-configurable Po2W slider limits + split thumbnail/preview ranges
|
||||
- *(folders)* Add regex pattern field with named groups, syntax highlighting, and sample path preview
|
||||
- Masonry gallery on homepage with infinite scroll
|
||||
- Pass JWT token in media URLs for authenticated thumbnail/preview requests
|
||||
- Make navbar sticky during infinite scroll
|
||||
- Add PreviewJob and upgrade thumbnails to WebP
|
||||
- Add CreatePersons and CreateAlbums jobs
|
||||
- Add stacked progress bar for parent jobs with sub-job breakdown
|
||||
- Improve logging and progress reporting for album/person jobs
|
||||
- Add statistics page with aggregate asset/user/system stats via fast DB queries
|
||||
- Add thousands separators to job status numbers
|
||||
- Extend AssetPreviewDto with album and cosplayer names
|
||||
- Add cosplayer and album name overlays on home page
|
||||
- Add full-size view and download buttons in preview overlay
|
||||
- Add bidirectional infinite scroll (up and down)
|
||||
- Implement seed-based deterministic random ordering for home page
|
||||
- Add data completeness indicators to statistics page
|
||||
- Add CoverAssetId to albums with resolution data in full DTO
|
||||
- Add AlbumService and PersonService to WASM client
|
||||
- Add albums list page and album detail page with inline preview
|
||||
- Auto-assign album cover from first alphabetically-sorted asset
|
||||
- Make album names clickable links in home tile hover and preview
|
||||
- Justified gallery layout for album detail page
|
||||
- View mode selector for albums page (masonry/grid/list)
|
||||
- *(admin)* Add user management page for admins
|
||||
- *(admin)* Add inline editing for username, email, and password
|
||||
- *(stats)* Add percentage and stale counters to completeness cards
|
||||
- *(backend)* Add ProfileAssetId to Person model + migration
|
||||
- *(dtos)* Update Person DTOs with ProfileAssetId and album previews
|
||||
- *(backend)* Eager-load ProfileAsset and Albums in PersonRepository
|
||||
- *(backend)* Add PersonMapper with ToPersonPreviewDto and ToPersonDetailedDto
|
||||
- *(backend)* Implement person detail, update, and delete endpoints
|
||||
- *(client)* Extract AlbumCard reusable component, update Albums.razor
|
||||
- *(client)* Add PersonService client methods and PersonForm component
|
||||
- *(client)* Add Cosplayers and CosplayerDetail pages, fix NavMenu, add modularity docs
|
||||
- *(css)* Add Cosplayers and CosplayerDetail scoped CSS with banner, grid, and card styles
|
||||
- *(backend)* Add ProfileCropX/Y to Person model for profile picture cropping
|
||||
- *(ui)* Add circular profile picture cropper with drag-to-pan
|
||||
- *(ui)* Redesigned profile cropper with movable circle, dimmed exterior, square boundary, zoom controls; added ProfileCropZoom to model; switched avatars to background-image for zoom support
|
||||
- *(ui)* Add New Cosplayer button on list page with create endpoint and form
|
||||
- *(ui)* Unassigned album selector in PersonForm, Add Albums button in CosplayerDetail
|
||||
- *(dto)* Add RemovePerson flag to AlbumUpdateDto for explicit person unlinking
|
||||
- Person hard delete with album cascade, AlbumCard select mode, cosplayer list multi-select
|
||||
- Infinite scroll for albums on cosplayer detail page
|
||||
- Migrate to UTC timestamps — remove legacy Npgsql timestamp behavior
|
||||
- Make album name in image previewer an anchor element for native navigation
|
||||
- Allow users to update own albums and curators to update any album
|
||||
- Add OldPassword field to UserUpdateDto for self-service password change
|
||||
- Enforce old-password verification on own password change, allow admin override
|
||||
- Add password change UI with strength bar and validation on user profile page
|
||||
- Add server-side password policy validation (admin bypass)
|
||||
- *(ui)* Redesign user profile page with avatar initials and edit profile modal
|
||||
- *(ui)* Rewrite admin users page with sortable table, search, and modal-based editing
|
||||
- *(ui)* Show avatar initial in navbar instead of generic icon
|
||||
- *(ui)* Album detail multi-select edit mode, cosplayer link, move edit button (#63)
|
||||
- *(api)* Add BulkDeleteAlbumsAsync for bulk album deletion
|
||||
- *(ui)* Add album select/edit mode with bulk deletion
|
||||
- *(ui)* Redesign jobs page with tabs, search, filters, and mobile cards
|
||||
- Add LastChange tracking to JobStatus
|
||||
- Add ModifiedAt to JobRecord with delta query support
|
||||
- Implement delta-based children endpoint with ?since parameter
|
||||
- Add delta merge logic to job tree components
|
||||
- Poll children for all active roots and fix segment progress bar colors
|
||||
- Return-url login redirects + keepalive + cosplayers fix
|
||||
- Add password strength validation to registration page
|
||||
- Hide cosplayers with no visible content from regular users
|
||||
- *(css)* SCSS foundation with Bootstrap 5.3.7 source, new dark navy theme, Inter font
|
||||
- *(ui)* CSS variables for .razor.css, job type colors, interactive states, gradients
|
||||
- *(jobs)* Extract all job-type colors to configurable SCSS variables
|
||||
- *(ui)* CosplayerDetail banner empty gradient uses accent theme
|
||||
- *(ui)* Aggressive gradients on navbar, buttons, modal headers
|
||||
- *(ui)* Page background gradient, status/strength CSS vars, nav/image polish
|
||||
- *(ui)* Replace default favicon with MilkyShot logo SVG
|
||||
- *(cropper)* Circle handles with accent color, black border, drop shadow
|
||||
- *(gradients)* Strengthen all gradients for more aggressive effect
|
||||
- *(ui)* Apply metallic gradient to all buttons and surfaces
|
||||
- *(ui)* Add eye toggle button to show/hide password on all password fields
|
||||
- *(api)* Add sort parameters to pagination DTOs
|
||||
- *(api)* Add sorting and unassigned filter to AlbumRepository.SearchQuery
|
||||
- *(api)* Pass sort and filter params through AlbumController.Search
|
||||
- *(ui)* Add SortFilterBar component and update Albums page with search/sort
|
||||
- *(ui)* Extract AlbumGrid to isolate grid re-renders from SortFilterBar
|
||||
- *(api)* Add SearchQuery to PersonRepository with sort/search/pagination
|
||||
- *(api)* Paginate PersonController.GetAll with sort/search params
|
||||
- *(ui)* Convert Cosplayers page to infinite scroll with sort/search toolbar
|
||||
- *(ui)* Extract CosplayerGrid to isolate grid re-renders from SortFilterBar
|
||||
- Add SearchDropdown typeahead component
|
||||
- Wire SearchDropdown into NavMenu
|
||||
- Make search dropdown headers selectable with keyboard and mouse
|
||||
- Parse ?search= query param on Cosplayers and Albums pages
|
||||
- *(cosplayer-detail)* Add SortFilterBar for album search and sort
|
||||
- *(butter)* Add EVisibility enum, Maintainer access level, and SystemUploaderId setting
|
||||
- *(butter)* Update DTOs for visibility, renamed Owner→UploadedBy, removed IsPublic/Owner fields
|
||||
- *(models)* Update models for auth redesign — rename Owner→UploadedBy, drop UserOwnerId/IsPubliclyShared, add EVisibility, PersonMaintainer
|
||||
- *(infra)* Update DbContext and mappers for auth redesign — new relationships, PersonMaintainer, conditional Visibility
|
||||
- *(repos,controllers)* Update repositories, interfaces, controllers for auth redesign — visibility filtering, UploadedBy/Uploader rename, Maintainer support
|
||||
- *(db)* Add migration for auth redesign — drop IsPubliclyShared/UserOwnerId/SharedWith, add Visibility, PersonMaintainer, rename OwnerId→UploadedBy
|
||||
- *(auth)* Complete Maintainer role — add PersonMaintainer checks, update controller authz, pass uploadedBy search param
|
||||
- Add MaintainedPersonIds to UserInfoDto with backend plumbing
|
||||
- Add Maintainer to admin user dropdowns and role badges
|
||||
- Add Visibility dropdown to album and person create/edit forms
|
||||
- Add Protected Assets card to stats page
|
||||
- Expand UI gating to include Maintainer with scope-aware CanEdit
|
||||
- Maintainer cosplayer assignment in admin user edit modal
|
||||
- Maintainer assignment UI for cosplayers, maintainer display on user page
|
||||
- Expand user page edit modal for admins with access level, maintainer assignment, and danger zone
|
||||
- Make usernames clickable on admin users page, linking to /User/{id}
|
||||
- Add docker-compose.debug.yml and UseWebAssemblyDebugging for WASM debugging
|
||||
- Add role-visible colored borders on albums and assets based on item state (#78)
|
||||
- Add bulk delete and visibility change to AlbumDetail select mode
|
||||
- Show visibility/deleted breakdown in album header in select mode
|
||||
- Add Select All / Deselect All button in album select mode
|
||||
- Add album-level bulk actions to CosplayerDetail page
|
||||
- Cascade visibility to albums and assets with lower-only rule
|
||||
- Add multiselect editing controls to Cosplayers page
|
||||
- Wire up SystemUploaderId setting — seed, UI, and crawl job backfill
|
||||
- Bulk cosplayer assignment for albums from multiselect mode
|
||||
- Add reusable ConfirmDialog component
|
||||
- Add visibility-colored borders to cosplayer cards in selection mode
|
||||
- Add rate limiting with configurable buckets
|
||||
- Apply rate-limit buckets to controllers
|
||||
- Add AlbumMergeDto and PersonMergeDto
|
||||
- Add MergeAlbums to album repository
|
||||
- Add MergePeople to person repository
|
||||
- Add merge endpoints to AlbumController and PersonController
|
||||
- Add frontend service methods for merge, bulk update, and bulk delete people
|
||||
- Add MergeModal shared component for merge destination selection
|
||||
- Add Merge button to Albums.razor with AlbumGrid preview access
|
||||
- Add Merge button to Cosplayers.razor with CosplayerGrid preview access
|
||||
- Add Merge Albums button to CosplayerDetail.razor
|
||||
- Add Unlink cosplayer button to AlbumDetail.razor
|
||||
- Add UnlinkedAssetGroupDto and extend AssetSearchOptionsDto with unlinked/folder/uploader filters
|
||||
- Add trigram GIN index on Asset.OriginalFilename for efficient ILike search
|
||||
- Extend AssetRepository with unassigned, folderId, uploadedBy, search filters and GetUnlinkedGroups
|
||||
- Extend AssetController with unlinked-groups endpoint and pass new search/unassigned/folder/uploader params
|
||||
- Extend AssetService with unassigned/folder/uploader/search params and GetUnlinkedGroupsAsync
|
||||
- Add AlbumAssetPicker modal component with drillable folder sidebar, search, and multi-select
|
||||
- Integrate AlbumAssetPicker into AlbumDetail with Add Assets button and merge handler
|
||||
- Add file-browser BrowseUnlinkedAssets endpoint with directory tree from OriginalPath
|
||||
- Redesign AlbumAssetPicker with breadcrumb navigation, directory drill-down, and file browser UX
|
||||
- Add collapsible sidebar toggle to AlbumAssetPicker folder browser
|
||||
- Add list/grid view toggle, bulk delete in maintenance mode, and /maintenance page
|
||||
- Add visibility and create-album actions, fix embedded viewport sizing
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fix retrieve of user authentication data
|
||||
- *(API)* Split authentication from user endpoint
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Fixes null exception against anonymous users requests
|
||||
- *(API)* Fix exception for UserRepository.cs
|
||||
- *(API)* Fix Password hasher dependency resolver
|
||||
- *(API)* Adds last login update on login
|
||||
- *(API)* Remove login permission to Banned or Disabled Users
|
||||
- *(API)* User endpoints to be in line with new structure
|
||||
- *(API)* Switches tagSearch method from uint to int
|
||||
- *(docker)* Fixed Dockerfile stuck on old project name and build configs
|
||||
- *(API)* Added support for pagination on Asset GetAll Query
|
||||
- *(API)* Added deleted at field in UserInfo mappers
|
||||
- *(API)* Implements GetAll tags in tag Repository
|
||||
- *(API)* Wrong logger in album controller
|
||||
- *(API)* Standardized DTO namespaces
|
||||
- *(API)* Added required instead of null initialized strings where it didn't make sense to have a null string in Users DTOs
|
||||
- *(API)* Added missing Email field in UserInfoDto
|
||||
- *(API)* Marked as required both fields of BulkDto
|
||||
- *(API)* Marked as required both fields of credentials DTOs
|
||||
- *(API)* Marked as required the tag name in TagCreateDto
|
||||
- *(API)* Marked as required all the various required fields in person related DTOs
|
||||
- *(API)* Added null checking to folderRepository get operations in FolderController
|
||||
- *(API)* Added GetAll method to Folder Repository and controller
|
||||
- *(API)* [**breaking**] Marked required properties in Asset as required
|
||||
- *(API)* Added FindByPath in AssetRepository
|
||||
- *(API)* Allow Anonymous to use MediaEndpoint
|
||||
- *(docker)* Dockerfiles updated to use Net 8 as base images
|
||||
- *(docker)* Solved mismatch with base folders in both dockerfile and compose file
|
||||
- *(docker)* Applied "Docker" as environment when building the application in a container thus avoiding not getting the correct configuration overloads at run time.
|
||||
- *(Docker)* Removes Env var from where is not needed and add it to teh compose
|
||||
- *(Docker)* Add internal db port in appsettings.Development.json
|
||||
- *(docker)* [**breaking**] Postgres volume data was not named properly
|
||||
- *(db)* Added postgres switch for Legacy Timestamp behaviour
|
||||
- *(API)* Correct reference to the return type of result
|
||||
- *(API)* Fixes the Db Initializer on Production Environment
|
||||
- *(DB)* Added check for already existing assets on DB entry.
|
||||
- *(Services)* Added FileSystem scan service to the startup of the webserver.
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- *(frontend)* Adds the new services to the app startup configuration
|
||||
- *(frontend)* Added correct headers and response format to LoginService
|
||||
- *(frontend)* Adjusted SettingsService to work with List<SettingDto> instead of SettingsDto object.
|
||||
- *(API)* Added SaveChanges to Settings repository, reflecting changes to he database
|
||||
- *(API)* Solved filesystem scanner not scanning
|
||||
- Address code review findings (blocking .Result, async void handlers, email logging)
|
||||
- Handle stale ProtectedLocalStorage value crashing NavMenu deserialization
|
||||
- Serve appsettings.json dynamically from MilkStream to fix WASM BaseUrl in Docker
|
||||
- Support wildcard CORS origin for self-hosted Docker deployments
|
||||
- Replace CORS wildcard with explicit safe origins per environment
|
||||
- Add DB healthcheck and service_healthy condition to prevent lactose startup race condition
|
||||
- Catch OperationCanceledException from Task.Delay to prevent incorrect Completed status on cancellation
|
||||
- Notify auth info changed after token refresh and handle polling errors
|
||||
- Change ParentJobId to get/set for WASM compat, remove @key from JobTree, add debug panel
|
||||
- Handle 401 responses in JwtTokenRefresher by refreshing token and retrying
|
||||
- Redirect to login when token refresh fails in JwtTokenRefresher
|
||||
- Handle 401 in JobsService directly with refresh+retry
|
||||
- Add missing Butter.Types import in AssetRepository for EAssetType
|
||||
- Remove completed sub-jobs from list so master job can finish
|
||||
- Prevent master PHashJob from spinning indefinitely on sub-job completion
|
||||
- Use oninput for live slider value update and widen range layout
|
||||
- Add minutes unit to Folder Scan Interval description in default settings
|
||||
- Handle 401 transparently at AuthServiceBase level for all services
|
||||
- Redirect unauthenticated users from settings page to /login
|
||||
- Redirect unauthenticated users from jobs page to /login
|
||||
- *(ui)* Render child jobs in JobTree by skipping root filter at deeper levels
|
||||
- *(db)* Always override ThumbnailPath/PreviewPath values with local paths on init
|
||||
- *(docker)* Create thumbnails/previews dirs as app user so subdirs can be created at runtime
|
||||
- *(ui)* Restore auth in App.razor before rendering to prevent race redirect to /login
|
||||
- *(phash)* Display child jobs grouped by status
|
||||
- *(jobs)* Prevent silent crash on large thumbnail jobs and auto-refresh child jobs
|
||||
- *(ui)* Apply poll interval to child jobs, add @key for DOM reuse, guard admin pages
|
||||
- *(ui)* Submit login form on Enter key press
|
||||
- *(jobs)* Fix IntegrityCheckJob conditional + use soft-delete
|
||||
- *(jobs)* Add delay to JobManager main loop to prevent CPU spin
|
||||
- Add DeletedAt filter to asset batch queries to exclude soft-deleted assets
|
||||
- Add AssetUser many-to-many join table for SharedWith
|
||||
- Remove AspNetCore.SassCompiler to fix Docker startup crash
|
||||
- Proper bidirectional SharedWith many-to-many migration
|
||||
- *(Lactose)* Admin and Curators media visibility
|
||||
- Wire MaxConcurrentJobs to database setting on startup and runtime changes
|
||||
- Propagate cancellation to sub-jobs in batch processing jobs
|
||||
- Cancel past refresh loop when poll rate is set to Off
|
||||
- Make job cancellation work for all job states and types
|
||||
- Per-batch progress messages and counting bug in album/person jobs
|
||||
- Skip existing AlbumAsset relationships in CreateAlbumsJob to prevent duplicate key violations on re-run
|
||||
- Add JobBatchSize to DefaultSettings.json to prevent reset on restart
|
||||
- Populate LastLogin/BannedAt/UpdatedAt in UserInfoDto mapping
|
||||
- Rewrite TopTags query to avoid EF Core collection navigation translation error
|
||||
- Remove destructive People soft-delete in IntegrityCheckJob and drop duplicate hash stats
|
||||
- Move preview info text below image instead of overlaying on bottom border
|
||||
- Download button now fetches blob and triggers actual file download
|
||||
- Remove scroll position correction from LoadPreviousPage for smoother scroll-up
|
||||
- Propagate cancellation tokens through linked child job CTS
|
||||
- Album controller Save calls, person list endpoint, media empty path guard
|
||||
- Scoped CSS deep selectors for preview info text and filename binding
|
||||
- Switch album detail page to row-based flexbox masonry
|
||||
- Remove preventDefault from album links so they actually navigate
|
||||
- Remove content-visibility and contain-intrinsic-size from album detail tiles
|
||||
- Switch album detail grid from flexbox to CSS Grid
|
||||
- Use flexbox with align-items:flex-start for album detail grid
|
||||
- Unlock body scroll on navigation, reorder masonry for left-to-right flow
|
||||
- JS-based masonry for left-to-right item flow with varying heights
|
||||
- Faulty migration that could cause lactose crash on migration apply
|
||||
- Use CSS columns for albums page, keep JS masonry only for album detail
|
||||
- Override masonry inline styles with !important for grid mode
|
||||
- Force square aspect-ratio in grid mode over inline style
|
||||
- *(controllers)* Tighten asset visibility and media access checks
|
||||
- *(AssetController)* Correct search visibility filter for #11
|
||||
- *(AlbumController)* Exclude deleted assets from search visibility for #11
|
||||
- *(admin)* Add confirm/cancel for role change and prevent admin self-demotion
|
||||
- *(user)* Hash password in Create endpoint instead of storing raw
|
||||
- *(ui)* Remove broken onfocusout handler from role edit dropdown
|
||||
- *(admin)* Show inline errors instead of replacing entire page
|
||||
- *(auth)* Show ban/disabled message on login and force-logout on refresh
|
||||
- *(auth)* Detect banned/deleted user on page load via InitializeAsync
|
||||
- *(frontend)* Sorts setting by alphabetical
|
||||
- *(Lactose)* Change to use UtcNow, even if alredy working without timeline
|
||||
- *(backend)* Add [FromQuery] to person list endpoint to fix 415 error
|
||||
- *(css)* Move card styles to AlbumCard.razor.css, use ::deep for grid-to-card selectors
|
||||
- *(ui)* Larger cosplayer cards with profile pics, move edit buttons top-right, add masonry/grid toggle
|
||||
- *(auth)* Force logout on any refresh failure, redirect to login
|
||||
- *(ui)* Add scoped modal CSS to PersonForm for proper overlay rendering
|
||||
- *(ui)* Cosplayer cards now match album card style — image fills square, text overlay at bottom
|
||||
- *(js)* Set onload before src to handle cached images
|
||||
- *(ui)* Remove empty filler cells from banner mosaic, let overlay cover gaps naturally
|
||||
- *(ui)* Always render ProfileCropper in tree, use OnParametersSet to trigger JS init
|
||||
- *(ui)* Random 3 album covers max in banner, redesigned cropper with drag-to-move and drag-handle resize, image stays static
|
||||
- *(ui)* 4 corner handles on square boundary for resizing instead of single circular handle
|
||||
- *(js)* Handle positions relative to square, not viewport
|
||||
- *(js)* Corner-drag resize with fixed opposite corner, proper visual rate matching
|
||||
- *(css)* Show full image in cropper viewport (contain instead of cover)
|
||||
- *(ui)* Correct geometry — square outer, circle inscribed inside; image-relative crop coords with background-image viewport; correct display formula for background-position
|
||||
- *(js)* Adjust stored cropZoom by image aspect ratio factor so background-size formula matches circle
|
||||
- *(js)* Store cropZoom as image-relative fraction (square_side / image_displayed_width), no adjustment needed on save
|
||||
- *(ui)* Use pixel-exact transform positioning for avatar display instead of background-size formula
|
||||
- *(js)* Use correct opposite corners for TL/TR/BL resize handles
|
||||
- *(js)* Skip resize on first mousemove if mouse hasn't moved from mousedown position
|
||||
- *(js)* Resize calculations now in viewport pixels to avoid image-relative X/Y scale mismatch on non-square images
|
||||
- *(api)* Allow unlinking person from album by directly assigning null PersonOwnerId
|
||||
- *(build)* Remove stray semicolon in onclick lambda
|
||||
- Prevent album title from being overwritten when only setting Person in update DTO
|
||||
- *(pr)* Implement BulkUpdate, BulkDelete, revert docker-compose, explain [FromQuery]
|
||||
- *(tests)* Fix pre-existing auth route tests and handle empty unassigned albums gracefully
|
||||
- *(test)* Use response.body directly instead of JSON.parse (already parsed object)
|
||||
- *(test)* Update name assertion to match renamed person
|
||||
- Create AlbumSearchParametersDto, add album pagination to person detail, fix duplicate XML param
|
||||
- Cache random banner covers in field, not computed on every render
|
||||
- *(ui)* Responsive layout fixes for mobile portrait mode (closes #48)
|
||||
- *(ui)* Collapse AdminUsers table columns and actions on mobile (#48)
|
||||
- *(frontend)* Removed faulty d-flex tag on main object
|
||||
- *(milkystream)* Fix sorted masonry for albums
|
||||
- *(ui)* Prevent JobRow horizontal overflow on mobile portrait
|
||||
- *(ui)* Change AdminUsers mobile actions dropdown from dropup to dropdown
|
||||
- *(ui)* Reduce password input width on AdminUsers to prevent cell overflow
|
||||
- *(ui)* Stack setting card description and control vertically on mobile
|
||||
- *(ui)* Prevent layout shift from save indicator width change
|
||||
- *(ui)* Prevent save indicator from shifting adjacent controls
|
||||
- *(ui)* Stack setting card body vertically — description above, control + indicator below
|
||||
- *(ui)* Right-align number inputs, let them grow to fill card width
|
||||
- *(ui)* Replace inline save indicator with Bootstrap toast notification
|
||||
- *(ui)* Restore progress bar visibility on job rows
|
||||
- Resolve UI issues #61, #62, #66
|
||||
- *(#62)* Move mobile modal fix to global MilkyShot.css — scoped ::deep cannot pierce PersonForm root element
|
||||
- *(#62)* Use explicit top/left/right/bottom instead of inset, add !important to ensure position:fixed takes effect on mobile
|
||||
- *(#62)* Lock body scroll when modal forms open to prevent position:fixed breakage on mobile
|
||||
- Propagate server-side error messages from UpdateUserAsync to UI
|
||||
- Auto-close modal on password change success, add page-level success message
|
||||
- Center profile card on page
|
||||
- Wire profile icon in navbar to user profile page
|
||||
- Default sort users table by role descending (admin on top)
|
||||
- Use bold (700) instead of semi-bold for reliable font rendering
|
||||
- *(mobile)* Keep back button inline with title, collapse admin actions into dropdown
|
||||
- Batch-processing subjobs report correct status on asset failures
|
||||
- Remove double-count of failed assets in master Done handler
|
||||
- Metadata icon in dropdown uses text-light for visibility
|
||||
- Metadata icon visible in job list rows
|
||||
- Pass null since on first child fetch instead of DateTime.MinValue
|
||||
- Eagerly fetch children on load and hide sub-jobs placeholder
|
||||
- Re-render after eager child fetch so icons appear immediately
|
||||
- Eager-fetch children for all roots including past jobs
|
||||
- Update home logged-out message to match cosplayers wording
|
||||
- Update albums logged-out message to match home/cosplayers
|
||||
- Translate GetAllVisible to client-side eval for SharedWith navigation
|
||||
- Suppress nullable warnings on Include chain in GetAllVisible
|
||||
- Remove soft-delete from Person model
|
||||
- *(jobs)* Add hover/active variants for all job types via darken()
|
||||
- *(ui)* Modal icon order, fill all outline buttons
|
||||
- *(favicon)* Render MilkyShot logo as PNG favicon
|
||||
- *(ui)* Page bg radial gradient, navbar radial glow, favicon cache
|
||||
- *(cropper)* Revert ProfileCropper to inline modal
|
||||
- *(cropper)* Restore missing cropper CSS styles
|
||||
- *(cropper)* Add pointer-events:auto on handles, fix cursors
|
||||
- *(ui)* Fix float values in the cosplayer thumbnail and cosplayer card
|
||||
- *(ui)* Fix remaining float culture issues in CSS style attributes
|
||||
- *(ui)* Prevent infinite loading loop on cosplayers page with no data
|
||||
- *(ui)* Keep header and toolbar visible when albums search returns empty
|
||||
- *(ui)* Debounce search input and prevent backspace navigation
|
||||
- *(ui)* Refocus search input after debounce triggers reload
|
||||
- *(ui)* Move album grid CSS to AlbumGrid.razor.css for scoped isolation
|
||||
- *(ui)* Add height:100% to album-card-fallback so icon centers vertically in grid mode
|
||||
- *(ui)* Add margin-bottom to SortFilterBar for spacing from grid
|
||||
- *(ui)* Add ActionButtons slot to SortFilterBar, move album actions into bar
|
||||
- *(ui)* Push ActionButtons to right with ms-auto wrapper
|
||||
- *(ui)* Keep header and toolbar visible when cosplayers search returns empty
|
||||
- *(ui)* Move cosplayer grid CSS to CosplayerGrid.razor.css for scoped isolation
|
||||
- *(ui)* Move cosplayer actions into SortFilterBar, fix sortAsc always sending
|
||||
- Close outer div and remove invalid @{} blocks in SearchDropdown
|
||||
- Pressing enter with nothing selected does nothing
|
||||
- Move query param parsing to OnParametersSet
|
||||
- Parse search query before await in Albums OnInitializedAsync
|
||||
- Use case-insensitive ILike search for persons and albums
|
||||
- Override Bootstrap dropdown-menu display:none
|
||||
- Return 401 Unauthorized when user data is null in PersonController
|
||||
- Allow anonymous search for persons and albums
|
||||
- Materialize person search query before client-side visibility filter
|
||||
- Restrict user list to admin only
|
||||
- Add [FromQuery] to TagController.GetAll parameter
|
||||
- Use zero-based page numbers in AssetController to match API convention
|
||||
- Guard ILIKE filter in AlbumRepository.SearchQuery against null/empty query
|
||||
- Restore Include(p => p.Albums) in PersonRepository queries
|
||||
- Paginate PersonRepository.SearchQuery by ID before loading albums
|
||||
- Prevent duplicate @key errors across all list renderings
|
||||
- Filter album count by user visibility in person search
|
||||
- Restore ToAlbumPreviewDto mapper method per review feedback #91
|
||||
- Standardize pagination to zero-based across all layers
|
||||
- Move SharedWith visibility check to client side for translatable queries
|
||||
- Only reject Page < 0, keep original PageSize validation
|
||||
- Restore PageSize < 1 validation with upper limit of 250
|
||||
- *(cosplayer-detail)* Prevent banner image shuffle on album search/sort
|
||||
- *(cosplayer-detail)* Debounce search, keep page intact on filter changes
|
||||
- *(cosplayer-detail)* Remove search debounce, instant inline reload
|
||||
- Replace untranslatable Album navigation in stats queries with direct join table queries
|
||||
- Replace untranslatable Tags navigation in TopTags stats query with direct join table query
|
||||
- *(albums)* Use MaxPageSize constant for unassigned album fetch
|
||||
- Implement CosplayersMissingProfile stat
|
||||
- Make card components middle-clickable
|
||||
- Remove underline from album card person link
|
||||
- *(album-form)* Searchable person combobox with modal-safe positioning
|
||||
- *(jobs)* Use grey for queued segments in layered progress bar
|
||||
- *(repo)* Scope Maintainer to only maintained persons in GetAllVisible and SearchQuery
|
||||
- *(controller)* Scope Maintainer album visibility to only maintained persons
|
||||
- *(controller)* Scope Maintainer asset access to only maintained cosplayers in Get
|
||||
- *(controller)* Add Maintainer scope to Album BulkUpdate, Delete, BulkDelete
|
||||
- *(mapper)* Allow Maintainer to see Visibility field in DTO responses
|
||||
- *(media)* Replace bool parameter with EAccessLevel, add deleted asset check
|
||||
- *(stats)* Add ProtectedAssets count, fix PrivateAssets to count only Private
|
||||
- *(dto)* Default AssetCreateDto.Visibility to Private instead of Protected
|
||||
- *(repo)* Use PagedParametersDto.MaxPageSize const instead of hardcoded 150
|
||||
- *(migration)* Remap existing EAccessLevel values to prevent data corruption
|
||||
- *(migration)* Default Visibility to Protected instead of Public, preserve old IsPubliclyShared mapping
|
||||
- *(dto)* Make AssetCreateDto.UploadedBy nullable for scanner-created assets
|
||||
- *(model)* Add [PrimaryKey] annotation to PersonMaintainer for clarity
|
||||
- *(repo)* Scope AlbumRepository.SearchQuery maintainer visibility to maintained persons
|
||||
- *(controller)* Scope AssetController maintainer permissions via album→person chain
|
||||
- *(mapper)* Replace null-forgiving operator with null-conditional in AlbumMapper
|
||||
- *(controller)* Split Maintainer from User in AssetController.GetAll visibility filter
|
||||
- *(media)* Allow Maintainer to access media files for cosplayers they maintain
|
||||
- *(mapper)* Scope asset counts in album/ person previews to visible assets
|
||||
- Add missing app.css link to index.html
|
||||
- Null reference in User page maintainer display
|
||||
- Remove underline from user links, hide email from non-admin viewers
|
||||
- Remove underline from maintainer links, load assigned person names in user edit modal
|
||||
- NRE on user page when user data not yet loaded
|
||||
- Show email to self-viewing users on user page
|
||||
- Enforce data visibility per access level with test coverage
|
||||
- Replace jsonPath with response.body for null-value checks in visibility tests
|
||||
- Replace remaining jsonPath calls in asset visibility tests
|
||||
- Allow maintainer assignment for all user roles in UI
|
||||
- Address PR #127 review issues — fallback, eager load, indentation, Maintainer tests
|
||||
- Show maintained cosplayers section for all user types in admin edit modal
|
||||
- Show maintained cosplayers section for all user types in user profile edit modal
|
||||
- Add entity-level visibility gating for albums and persons
|
||||
- Update border colors and browse-mode visibility in AlbumDetail
|
||||
- Show borders for maintainers in select mode
|
||||
- Prevent jagged wrapping of toolbar and action buttons
|
||||
- Album-card-select and cosplayer-card-select only on selected cards
|
||||
- Wrap CosplayerDetail action buttons into mobile dropdown
|
||||
- Add cascade checkbox to CosplayerDetail album visibility modal
|
||||
- Cosplayers page bugs
|
||||
- Prevent private assets with null UploadedBy leaking to anonymous users
|
||||
- Homepage preview navigation, grid observer crash, and cascade labels
|
||||
- Label Delete button and cropper modal overlay
|
||||
- Album detail back button uses history.back() with fallback to /albums
|
||||
- Soft-delete users in UserController.Delete instead of hard-delete
|
||||
- Add ConfirmDialog to AdminUsers delete user flow
|
||||
- Add ConfirmDialog to User delete user flow
|
||||
- Add ConfirmDialog to AlbumDetail delete flows
|
||||
- Add ConfirmDialog to CosplayerDetail delete flows
|
||||
- Add ConfirmDialog to Albums bulk delete flow
|
||||
- Add ConfirmDialog to Cosplayers bulk delete flow
|
||||
- Add ConfirmDialog to Settings folder deletion flow
|
||||
- *(UI)* Make the delete cosplayer button in cosplayer detail page be just an icon to conform with albums detail page
|
||||
- Apply Visibility and MaintainerUserIds in PersonController.BulkUpdate
|
||||
- Split multi-request test blocks into single-request blocks
|
||||
- Guard against null PersonOwnerId in MergePeople albums query
|
||||
- Gate registration behind UserRegistrationEnabled setting
|
||||
- Handle 204/empty responses in AssetService and always reset isLoading in AlbumAssetPicker
|
||||
- Use Albums.Count==0 instead of !Any() for unlinked filter, add trace logging
|
||||
- Use OnParametersSetAsync instead of fire-and-forget to ensure Blazor re-renders after init
|
||||
- Use parameterized SqlQueryRaw instead of unquoted string.Format to prevent SQL error
|
||||
- Filter directory names to only paths containing a slash after prefix, excluding filenames
|
||||
- Restrict maintenance page and nav link to admin only
|
||||
- Add scoped CSS for maintenance page to make embedded picker fill viewport
|
||||
- Inline all markup in .razor template to restore CSS isolation
|
||||
- Change LoginService from Singleton to Scoped to match Blazored.LocalStorage lifetime
|
||||
- Simplify maintenance page CSS to flex-fill container without vh calc
|
||||
- Adjust embedded picker viewport height offset
|
||||
- Address PR #142 review issues
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Switch to repository pattern
|
||||
- *(API)* Switch to use UserRepository AuthController.cs
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Cleans the code
|
||||
- *(API)* Extract BulkDtoinseparate file
|
||||
- *(API)* Switch to use a class for paged search
|
||||
- *(API)* Adds Bulk capabilities
|
||||
- *(API)* Move classes and Standardlize
|
||||
- *(API)* Rename Asset Dtos
|
||||
- *(API)* AlbumController explicitly marked all of the x in lambdas as they were accidentally hiding previous stage, also improved null checking
|
||||
- Reformatted AssetController and AssetCreateDto files
|
||||
- *(API)* Asset controller reformatted params
|
||||
- Improve readability of CorsAllowedOrigins parsing block
|
||||
- *(jobs)* Flatten ThumbnailJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten PHashJob to batch-based sub-jobs
|
||||
- *(jobs)* Flatten MetadataJob to batch-based sub-jobs
|
||||
- Make LoginService singleton, centralize auth state and persistence
|
||||
- Extract ImagePreview shared component from Home and AlbumDetail
|
||||
- Remove list view, add grid/masonry toggle to album detail
|
||||
- *(css)* Move shared modal styles to global MilkyShot.css/SCSS, remove duplicated scoped CSS
|
||||
- *(css)* Remove SCSS, switch to plain CSS with Bootstrap custom properties
|
||||
- *(api)* Fold unassigned album filter into search endpoint via query param
|
||||
- *(dto)* Move Unassigned filter into PagedSearchParametersDto
|
||||
- Replace individual albumPage/albumSize params with PagedParametersDto in person detail endpoint
|
||||
- Replace custom masonry JS with Bootstrap grid + Masonry.js approach
|
||||
- *(auth)* Extract AuthServiceBase, replace retry with re-auth on 401
|
||||
- *(auth)* Remove SendWithRefreshAsync, rely solely on JwtTokenRefresher
|
||||
- *(ui)* Replace inline password form with modal
|
||||
- *(ui)* Complete user profile page redesign with card layout
|
||||
- Move cosplayer link to same line as album name
|
||||
- Center-align header, album name on top, cosplayer smaller below
|
||||
- Extract password strength UI into shared PasswordField component
|
||||
- Replace tuple + custom header with ChildrenResponse DTO
|
||||
- *(ui)* ModalFrame + EmptyState components, remove duplicate CSS, remove main border
|
||||
- *(jobs)* Use darken() for phash and create-albums hover/active too
|
||||
- Project PersonPreviewDto directly in repository instead of [NotMapped] entity property
|
||||
- Project AlbumPreviewDto directly in repository with visibility-aware asset count
|
||||
- Clean up imports, simplify checks, and add SearchDropdown component
|
||||
- *(albums)* Extract visibility-aware AssetCount into AlbumMapper overload
|
||||
- Replace hardcoded 250 page size limit with PagedParametersDto.MaxPageSize
|
||||
- Use MaxPageSize constant as default pageSize across all frontend services
|
||||
- Reuse AlbumGrid in CosplayerDetail via FetchAlbums delegate
|
||||
- Move all visibility filtering from mappers and controllers to repositories
|
||||
- Rename AlbumSearchParametersDto.UploadedBy to AssetUploadedBy
|
||||
- Extract role badge colors to SCSS theme layer
|
||||
- Generate access level dropdown from enum values
|
||||
- Extract role checks to LoginService, update all consumers
|
||||
- Move CanEdit logic to LoginService, remove local page properties
|
||||
- Extract person search/assign UI into shared SearchAssignBadges component
|
||||
- Extract VisibilityModal shared component, add editing controls to Albums page
|
||||
- Centralize cascade visibility logic in AlbumService.CascadeVisibilityToAssetsAsync
|
||||
- Replace inline visibility modal with shared VisibilityModal in Cosplayers.razor
|
||||
- Move Unlink cosplayer button from album header to album edit form
|
||||
- Add Embedded mode, UnlinkedOnly param, rename methods from Unlinked to generic names
|
||||
- Rename UnlinkedAssetGroupDto to AssetGroupDto
|
||||
- Replace individual [FromQuery] params with AssetBrowseOptionsDto
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- Update agents.md for Blazor WASM architecture
|
||||
- Add CORS configuration section to README
|
||||
- *(readme)* Add wildcard CORS warning and MilkStream CORS setup guide
|
||||
- Add XML documentation to all public APIs across all projects
|
||||
- Update AGENTS.md with XML doc enforcement and CORS guidance
|
||||
- Update AGENTS.md with verified infrastructure gotchas
|
||||
- Add API reuse convention to AGENTS.md — prefer optional query params over new endpoints
|
||||
- Add Gitea repo owner note to AGENTS.md
|
||||
- Add granular commit convention to AGENTS.md
|
||||
- Document REST Client tests in AGENTS.md
|
||||
- Update AGENTS.md with missing conventions and project info
|
||||
- Add use case catalog for new auth/permissions model
|
||||
- Add Gitea label levels note to AGENTS.md
|
||||
- Add PR workflow section to AGENTS.md
|
||||
- Reconcile AGENTS.md with current codebase
|
||||
- *(repo)* Fix stale pageSize default in IAlbumRepository XML doc
|
||||
- Add data size estimates to AGENTS.md
|
||||
- Clarify Curators and Admins can be maintainers for credited attribution
|
||||
- Fix stale column references and tooling versions in AGENTS.md
|
||||
- Add visibility rules (R1–R7) to AGENTS.md
|
||||
|
||||
### ⚡ Performance
|
||||
|
||||
- Push visibility filter into SQL for PersonRepository queries
|
||||
- Add partial index on Assets for visibility-aware search queries
|
||||
- Simplify ORDER BY to total count and add trigram GIN indexes
|
||||
- Add AsSplitQuery to repository queries with multiple Include calls
|
||||
- Replace in-memory path parsing with SQL SPLIT_PART aggregation and double-LIKE filter
|
||||
- Replace in-memory path parsing with SELECT DISTINCT SQL, remove dir count badges
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
- Style cosplayer link with inherited color and person icon
|
||||
- Match cosplayer link font size to h3, remove icon, keep regular weight
|
||||
- Consistent inline typography for album title and cosplayer name with dash separator
|
||||
- Borderless edit pencil with hover reveal on header
|
||||
- Cosplayer link dims on hover instead of blue/underline
|
||||
- Replace badge backgrounds with colored icons + matching launch buttons
|
||||
- Custom job color palette based on purpose, avoid red
|
||||
- Mobile launch button cyan for better contrast
|
||||
- Soften accent gradient stops (5%/10% instead of 15%/30%)
|
||||
- Use rounded-square thumbnails for album results
|
||||
- Add chevron-right indicator to search dropdown headers
|
||||
- Use Bootstrap dropdown-menu and dropdown-item classes
|
||||
- *(repo)* Convert visibility filters to switch expressions for readability
|
||||
- *(repo)* Expand switch expression arms for readability
|
||||
- *(repo)* Use explicit named enum cases in PersonRepository switch expressions
|
||||
- Standardize access level comparison to switch expressions
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
- More unit testing on HTTP client
|
||||
- *(api)* Add person endpoint tests to WepApiTest.http
|
||||
- *(api)* Add comprehensive http tests for all person and album endpoints
|
||||
- Add album update test without removePerson field
|
||||
- Use real album from search for update test, not zero-GUID fallback
|
||||
- All tests create their own data — create album returns ID, all mutations use self-created resources
|
||||
- Add endpoint tests for refresh token, albums, curator auth levels
|
||||
- Add anonymous search tests for persons and albums
|
||||
- Handle empty person search results gracefully
|
||||
- Set personId from admin search to ensure it's always populated
|
||||
- Add user id to profile update request body
|
||||
- Fix curator user access level from Admin (2) to Curator (1)
|
||||
- Restructure WepApiTest.http with three clearly separated users
|
||||
- Add cleanup of regular test user in REST Client suite
|
||||
- Add page=0&pageSize=5 to paginated list endpoints
|
||||
- Add merge and bulk visibility tests to WepApiTest.http
|
||||
- Flatten test numbering to sequential 1–137
|
||||
- Added rate limiting stress test
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
- Removes auto generated docker-compose file for deployment
|
||||
- *(docker)* Removes auto generated docker-compose file for deployment
|
||||
- *(API)* Changes the category name of the logger
|
||||
- Added datagrip files
|
||||
- *(config)* Separates connection string defaults for docker and local webserver run
|
||||
- Removed unused using in Program.cs
|
||||
- Swapped empty array initializer with empty collection initializer in Asset hash
|
||||
- Commented out unused logger in Album controller and added a TODO: about adding logging capabilities
|
||||
- Marked navigation property in folder as nullable
|
||||
- Commented out logger in FolderController and added a TODO regarding logging
|
||||
- Removed redundant type cast in tag search
|
||||
- Removed useless using declarations in TagMapper
|
||||
- Commented out the logger in tagController and added a TODO regarding logging
|
||||
- Removed more unused using directives
|
||||
- *(docker)* Added NoCache build option for lactose
|
||||
- *(docker)* Updated dockerfile to build using dotnet 9.0
|
||||
- *(NuGet)* [**breaking**] Added dependency to Magick.NET-Q16-HDRI-OpenMP-x64
|
||||
- Updated nuget packages to latest releases supporting Net 8.0, removed ImageMagick package.
|
||||
- Removed MySql package from required nuget packages
|
||||
- Refined db connector params
|
||||
- *(API)* Removes Redundant Check
|
||||
- Split TokenGeneratorProvider file in their respective classes
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Moved milkstream services under a services namespace
|
||||
- *(frontend)* Switched to a more recent session storage implementation which doesn't have a security vulnerability.
|
||||
- Updated run profiles
|
||||
- Remove temporary debug panel from Jobs page
|
||||
- Remove redundant XML doc comments from ServiceBase
|
||||
- Upgrade target framework to net10.0
|
||||
- Add dotnet-tools.json for ef tool
|
||||
- *(dev)* Fix ovveride PM Compose deploy
|
||||
- Remove debug auth logging middleware from Program.cs
|
||||
- Remove unused banner-cell-empty CSS class
|
||||
- *(test)* Remove Swagger test — disabled in container environments
|
||||
- Added the test images folder to the dockerignore avoiding them being sent every time a docker build starts
|
||||
- Revert docker-compose.yml to match develop
|
||||
- Updated packages, removed unused packages. ImageSharp now requires a license
|
||||
- Commit compiled CSS for role badge classes
|
||||
- Fix Dockerfiles, add debug compose to solution
|
||||
- Use record for config classes
|
||||
- Adjusted rate-limits of thumbs, preview and jobs to avoid triggering them during normal usage
|
||||
- Add Gitea Actions workflow to build and push containers on version tags
|
||||
|
||||
### 🛡️ Security
|
||||
|
||||
- Upgrade to .NET 10 — packages, Docker images, and Swagger fix
|
||||
|
||||
### ◀️ Revert
|
||||
|
||||
- Switch album detail back to masonry layout
|
||||
- Restore docker-compose.yml — testing-only CORS/baseUrl changes committed by accident
|
||||
- Restore docker-compose.yml LactoseBaseUrl to 192.168.50.100
|
||||
- Remove AllowAnonymous from AlbumController.Search and PersonController.GetAll
|
||||
- Change LoginService back to Singleton (DI error was from docker fast mode)
|
||||
|
||||
### CI
|
||||
|
||||
- Added build + push to internal registry action
|
||||
|
||||
### Fix
|
||||
|
||||
- *(API)* Disables Asset Create
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
- *(API)* Refresh token being changed the last 10 minutes instead of every moment
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ public class LactoseDbContext : DbContext {
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder) {
|
||||
modelBuilder.HasPostgresExtension("vector");
|
||||
modelBuilder.HasPostgresExtension("pg_trgm");
|
||||
modelBuilder.HasDbFunction(() => PgFunctions.Md5(default!));
|
||||
//Album Relationships
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.PersonOwner).WithMany(e => e.Albums);
|
||||
modelBuilder.Entity<Album>().HasOne(e => e.CoverAsset);
|
||||
@@ -78,6 +79,9 @@ public class LactoseDbContext : DbContext {
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.UploadedBy })
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(e => new { e.Visibility, e.CreatedAt })
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
modelBuilder.Entity<Asset>().HasIndex(a => a.OriginalFilename)
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm")
|
||||
.HasMethod("gin")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lactose.Context;
|
||||
|
||||
/// <summary>
|
||||
/// Maps PostgreSQL functions not covered by the Npgsql provider for use in LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
public static class PgFunctions {
|
||||
/// <summary>
|
||||
/// Maps to the PostgreSQL <c>md5</c> function. Only callable inside LINQ-to-SQL queries.
|
||||
/// </summary>
|
||||
/// <param name="value">The string to hash.</param>
|
||||
/// <returns>The md5 hex digest of <paramref name="value"/>.</returns>
|
||||
[DbFunction("md5", "pg_catalog")]
|
||||
public static string Md5(string value) => throw new NotSupportedException("PgFunctions.Md5 is only usable inside LINQ-to-SQL queries.");
|
||||
}
|
||||
@@ -67,7 +67,8 @@ public class AlbumController(
|
||||
pagingOptions.Unassigned,
|
||||
uid ?? default,
|
||||
accessLevel,
|
||||
pagingOptions.AssetUploadedBy
|
||||
pagingOptions.AssetUploadedBy,
|
||||
pagingOptions.PersonOwnerId
|
||||
).ToList();
|
||||
|
||||
return Ok(albums);
|
||||
@@ -85,7 +86,7 @@ public class AlbumController(
|
||||
Guid? uid = authService.GetUserData(User)?.Id;
|
||||
var accessLevel = authService.GetUserData(User)?.AccessLevel ?? EAccessLevel.User;
|
||||
|
||||
var album = albumRepository.Find(id);
|
||||
var album = albumRepository.FindWithAssets(id);
|
||||
|
||||
// If the album does not exist, return a 404
|
||||
if (album == null) return NotFound();
|
||||
|
||||
@@ -99,7 +99,8 @@ public class AssetController(
|
||||
searchOptionsDto.Type, from, to, searchOptionsDto.Random, searchOptionsDto.Seed,
|
||||
searchOptionsDto.Page, searchOptionsDto.PageSize, out int total,
|
||||
uid, accessLevel,
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search
|
||||
searchOptionsDto.Unlinked, searchOptionsDto.FolderId, searchOptionsDto.UploadedBy, searchOptionsDto.Search,
|
||||
searchOptionsDto.IncludeCount
|
||||
);
|
||||
|
||||
logger.LogTrace(
|
||||
@@ -114,11 +115,9 @@ public class AssetController(
|
||||
"""
|
||||
);
|
||||
|
||||
var dtoList = assets.ToAssetPreviewDto(accessLevel, uid).ToList();
|
||||
|
||||
logger.LogTrace($"Returning {dtoList.Count} assets (total: {total})");
|
||||
logger.LogTrace($"Returning {assets.Count()} assets (total: {total})");
|
||||
Response.Headers["X-Total-Count"] = total.ToString();
|
||||
return Ok(dtoList);
|
||||
return Ok(assets);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -43,7 +43,7 @@ namespace Lactose.Controllers;
|
||||
Active = false
|
||||
};
|
||||
|
||||
folderRepository.Create(newFolder);
|
||||
folderRepository.Insert(newFolder);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ namespace Lactose.Controllers;
|
||||
|
||||
if (accessLevel != EAccessLevel.Admin) { return Unauthorized(); }
|
||||
|
||||
folderRepository.Delete(id);
|
||||
folderRepository.Remove(id);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,9 @@ public class MediaController(
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Thumbnails are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.ThumbnailPath)) return NotFound();
|
||||
return PhysicalFile(asset.ThumbnailPath, "image/webp");
|
||||
@@ -88,6 +91,9 @@ public class MediaController(
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
// Previews are immutable per asset — allow browser/proxy caching
|
||||
Response.Headers.CacheControl = "public, max-age=86400";
|
||||
|
||||
if (CanAccessAssetDirectly(user, asset.DeletedAt)) {
|
||||
if (string.IsNullOrEmpty(asset.PreviewPath)) return NotFound();
|
||||
return PhysicalFile(asset.PreviewPath, "image/webp");
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Lactose.Jobs;
|
||||
|
||||
static class PathUtils {
|
||||
internal static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ public class PreviewJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, PreviewPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, PreviewPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -279,16 +279,4 @@ public class PreviewJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All previews generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ public class ThumbnailJob : Job {
|
||||
)
|
||||
);
|
||||
|
||||
var path = PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
var path = PathUtils.PathFromGuid(asset.Id, ThumbnailPath!);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(path)))
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
@@ -279,16 +279,4 @@ public class ThumbnailJob : Job {
|
||||
else
|
||||
JobStatus.Complete("All thumbnails generated successfully.");
|
||||
}
|
||||
|
||||
static string PathFromGuid(Guid id, string root) {
|
||||
var s = id.ToString("N");
|
||||
|
||||
return Path.Combine(
|
||||
root,
|
||||
s[..2],
|
||||
s.Substring(2, 2),
|
||||
s.Substring(4, 2),
|
||||
$"{s}.webp"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,31 +30,38 @@ public static class AlbumMapper {
|
||||
|
||||
/// <summary>
|
||||
/// Maps an Album object to an AlbumFullDto object.
|
||||
/// Uses pre-projected <see cref="Album.VisibleAssetPreviews"/> when available; otherwise falls back to mapping loaded assets.
|
||||
/// </summary>
|
||||
/// <param name="album">The album to map.</param>
|
||||
/// <param name="accessLevel">The requesting user's access level.</param>
|
||||
/// <param name="viewerId">The ID of the requesting user.</param>
|
||||
/// <returns>An AlbumFullDto object.</returns>
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) => new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = album.Assets?.Count(a => a.DeletedAt == null) ?? 0,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = album.Assets?.Select(asset => asset.Id).ToList() ?? [],
|
||||
AssetPreviews = album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList() ?? []
|
||||
};
|
||||
public static AlbumFullDto ToAlbumFullDto(this Album album, EAccessLevel accessLevel, Guid? viewerId) {
|
||||
var previews = album.VisibleAssetPreviews
|
||||
?? album.Assets?.Select(asset => new AlbumAssetPreviewDto {
|
||||
Id = asset.Id,
|
||||
ResolutionWidth = asset.ResolutionWidth,
|
||||
ResolutionHeight = asset.ResolutionHeight,
|
||||
HasThumbnail = !string.IsNullOrEmpty(asset.ThumbnailPath),
|
||||
HasPreview = !string.IsNullOrEmpty(asset.PreviewPath),
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? asset.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? asset.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || asset.UploadedBy == viewerId ? asset.DeletedAt : null
|
||||
}).ToList()
|
||||
?? [];
|
||||
|
||||
return new AlbumFullDto {
|
||||
Id = album.Id,
|
||||
Name = album.Title,
|
||||
Person = album.PersonOwnerId,
|
||||
PersonName = album.PersonOwner?.Name,
|
||||
CoverAssetId = album.CoverAssetId,
|
||||
AssetCount = previews.Count(p => p.DeletedAt == null),
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? album.Visibility : null,
|
||||
DeletedAt = null,
|
||||
Images = previews.Select(preview => preview.Id).ToList(),
|
||||
AssetPreviews = previews
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -27,10 +27,14 @@ public static class PersonMapper {
|
||||
ProfileCropX = person.ProfileCropX,
|
||||
ProfileCropY = person.ProfileCropY,
|
||||
ProfileCropZoom = person.ProfileCropZoom,
|
||||
TotalAlbums = person.AlbumTotalCount > 0 ? person.AlbumTotalCount : albums.Count,
|
||||
TotalAssets = albums.Sum(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0),
|
||||
TotalAlbums = person.AlbumTotalCount,
|
||||
TotalAssets = person.TotalAssetCount,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? person.Visibility : null,
|
||||
Albums = albums.Select(a => a.ToAlbumPreviewDto(accessLevel, viewerId)).ToList(),
|
||||
Albums = albums.Select(a => {
|
||||
var dto = a.ToAlbumPreviewDto(accessLevel, viewerId);
|
||||
dto.AssetCount = person.AlbumAssetCounts?.GetValueOrDefault(a.Id, 0) ?? 0;
|
||||
return dto;
|
||||
}).ToList(),
|
||||
MaintainerUserIds = maintainers.Select(u => u.Id).ToList(),
|
||||
MaintainerUsernames = maintainers.Select(u => u.Username).ToList()
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
+601
@@ -0,0 +1,601 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections;
|
||||
using Lactose.Context;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
[DbContext(typeof(LactoseDbContext))]
|
||||
[Migration("20260811104813_AddAssetVisibleCreatedAtIndex")]
|
||||
partial class AddAssetVisibleCreatedAtIndex
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.9")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "pg_trgm");
|
||||
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.Property<Guid>("AlbumsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AlbumsId", "AssetsId");
|
||||
|
||||
b.HasIndex("AssetsId");
|
||||
|
||||
b.ToTable("AlbumAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.Property<Guid>("AssetsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("AssetsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("AssetTag");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CoverAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PersonOwnerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CoverAssetId");
|
||||
|
||||
b.HasIndex("PersonOwnerId");
|
||||
|
||||
b.HasIndex("Title")
|
||||
.HasDatabaseName("IX_Albums_Title_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Title"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Title"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<float?>("Duration")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<long>("FileSize")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<Guid?>("FolderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("FrameRate")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<BitArray>("Hash")
|
||||
.IsRequired()
|
||||
.HasColumnType("bit(64)");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalFilename")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("OriginalPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("PreviewFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("PreviewPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("PreviewSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ResolutionWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ThumbnailFormat")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(16)");
|
||||
|
||||
b.Property<string>("ThumbnailPath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<int>("ThumbnailSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("UploadedBy")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId");
|
||||
|
||||
b.HasIndex("OriginalFilename")
|
||||
.HasDatabaseName("IX_Assets_OriginalFilename_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("OriginalFilename"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("OriginalFilename"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("OriginalPath")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.ToTable("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("AssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("BoundingBoxX1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxX2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY1")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("BoundingBoxY2")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageHeight")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("ImageWidth")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid?>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AssetId");
|
||||
|
||||
b.HasIndex("PersonId");
|
||||
|
||||
b.ToTable("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Active")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("BasePath")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<string>("RegexPattern")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Folders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.JobRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("Finished")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("JobType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Message")
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<DateTime?>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(2048)");
|
||||
|
||||
b.Property<Guid?>("ParentJobId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float>("Progress")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("Started")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ProfileAssetId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<float?>("ProfileCropX")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropY")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<float?>("ProfileCropZoom")
|
||||
.HasColumnType("real");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Visibility")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Name")
|
||||
.HasDatabaseName("IX_People_Name_Trgm");
|
||||
|
||||
NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Name"), "gin");
|
||||
NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Name"), new[] { "gin_trgm_ops" });
|
||||
|
||||
b.HasIndex("ProfileAssetId");
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("PersonId", "UserId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PersonMaintainers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Setting", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<int>("DisplayType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Options")
|
||||
.HasColumnType("text[]");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<Guid?>("ParentId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentId");
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AccessLevel")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("BannedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(128)");
|
||||
|
||||
b.Property<DateTime?>("LastLogin")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Password")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(255)");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpires")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("VARCHAR(64)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AlbumAsset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Album", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AlbumsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AssetTag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Album", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "CoverAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("CoverAssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "PersonOwner")
|
||||
.WithMany("Albums")
|
||||
.HasForeignKey("PersonOwnerId");
|
||||
|
||||
b.Navigation("CoverAsset");
|
||||
|
||||
b.Navigation("PersonOwner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Folder", "Folder")
|
||||
.WithMany("Assets")
|
||||
.HasForeignKey("FolderId");
|
||||
|
||||
b.HasOne("Lactose.Models.User", "Uploader")
|
||||
.WithMany("UploadedAssets")
|
||||
.HasForeignKey("UploadedBy");
|
||||
|
||||
b.Navigation("Folder");
|
||||
|
||||
b.Navigation("Uploader");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Face", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "Asset")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("AssetId");
|
||||
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany("Faces")
|
||||
.HasForeignKey("PersonId");
|
||||
|
||||
b.Navigation("Asset");
|
||||
|
||||
b.Navigation("Person");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Asset", "ProfileAsset")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileAssetId");
|
||||
|
||||
b.Navigation("ProfileAsset");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.PersonMaintainer", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Person", "Person")
|
||||
.WithMany()
|
||||
.HasForeignKey("PersonId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Lactose.Models.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Person");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Tag", b =>
|
||||
{
|
||||
b.HasOne("Lactose.Models.Tag", "Parent")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentId");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Asset", b =>
|
||||
{
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Folder", b =>
|
||||
{
|
||||
b.Navigation("Assets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.Person", b =>
|
||||
{
|
||||
b.Navigation("Albums");
|
||||
|
||||
b.Navigation("Faces");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lactose.Models.User", b =>
|
||||
{
|
||||
b.Navigation("UploadedAssets");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lactose.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAssetVisibleCreatedAtIndex : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Assets_VisibleCreatedAt",
|
||||
table: "Assets",
|
||||
columns: new[] { "Visibility", "CreatedAt" },
|
||||
filter: "\"DeletedAt\" IS NULL");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Assets_VisibleCreatedAt",
|
||||
table: "Assets");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,6 +190,10 @@ namespace Lactose.Migrations
|
||||
|
||||
b.HasIndex("UploadedBy");
|
||||
|
||||
b.HasIndex("Visibility", "CreatedAt")
|
||||
.HasDatabaseName("IX_Assets_VisibleCreatedAt")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
b.HasIndex("Visibility", "UploadedBy")
|
||||
.HasDatabaseName("IX_Assets_VisibleForSearch")
|
||||
.HasFilter("\"DeletedAt\" IS NULL");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Album;
|
||||
using Butter.Types;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
@@ -66,4 +67,11 @@ public class Album {
|
||||
public Asset? CoverAsset { get; set; }
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility-filtered asset previews for the album detail view.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public List<AlbumAssetPreviewDto>? VisibleAssetPreviews { get; set; }
|
||||
}
|
||||
|
||||
@@ -86,4 +86,18 @@ public class Person {
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public int AlbumTotalCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the total number of non-deleted assets across all visible albums.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public int TotalAssetCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-album visible asset counts for the paged album list.
|
||||
/// Populated by <c>FindVisible</c>; ignored by EF Core.
|
||||
/// </summary>
|
||||
[NotMapped]
|
||||
public Dictionary<Guid, int>? AlbumAssetCounts { get; set; }
|
||||
}
|
||||
@@ -245,4 +245,18 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
//app.UseHttpsRedirection();
|
||||
app.MapControllers();
|
||||
|
||||
// Auto-save middleware: ensures pending changes are persisted at the end of every
|
||||
// non-GET request even if the caller forgets to call Save() explicitly.
|
||||
// This is a safety net — explicit Save() calls in controllers and jobs still work
|
||||
// as before and take effect immediately. The middleware's SaveChanges is a no-op
|
||||
// when no tracked changes remain.
|
||||
app.Use(async (context, next) => {
|
||||
await next();
|
||||
if (context.Request.Method != "GET") {
|
||||
var db = context.RequestServices.GetRequiredService<LactoseDbContext>();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
});
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -27,8 +27,8 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums;
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null) {
|
||||
IQueryable<Album> albumsQuery = context.Albums.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrEmpty(query))
|
||||
albumsQuery = albumsQuery.Where(x => EF.Functions.ILike(x.Title, $"%{query}%"));
|
||||
@@ -39,6 +39,9 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
if (uploadedBy.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.Assets!.Any(asset => asset.UploadedBy == uploadedBy.Value));
|
||||
|
||||
if (personOwnerId.HasValue)
|
||||
albumsQuery = albumsQuery.Where(a => a.PersonOwnerId == personOwnerId.Value);
|
||||
|
||||
// Apply visibility filter scoped to access level
|
||||
albumsQuery = accessLevel switch {
|
||||
< EAccessLevel.Maintainer => albumsQuery.Where(a => a.Visibility <= EVisibility.Protected),
|
||||
@@ -69,6 +72,7 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
return [];
|
||||
|
||||
var pagedAlbums = context.Albums
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
@@ -109,20 +113,19 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? Find(Guid id) => context.Albums
|
||||
public Album? Find(Guid id) => context.Albums.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindWithAssets(Guid id) => context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Album? FindVisible(Guid id, Guid? userId, EAccessLevel accessLevel) {
|
||||
var album = context.Albums
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.CoverAsset)
|
||||
.AsNoTracking()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.Assets)
|
||||
.FirstOrDefault(a => a.Id == id);
|
||||
|
||||
if (album == null) return null;
|
||||
@@ -139,33 +142,40 @@ public class AlbumRepository(LactoseDbContext context) : IAlbumRepository {
|
||||
|
||||
if (!canSeeAlbum) return null;
|
||||
|
||||
if (album.Assets == null) return album;
|
||||
// R2: Filter assets at the database level and project only the DTO fields
|
||||
IQueryable<Asset> assetsQuery = context.Assets
|
||||
.AsNoTracking()
|
||||
.Where(a => a.Albums!.Any(al => al.Id == id));
|
||||
|
||||
album.Assets = accessLevel switch {
|
||||
EAccessLevel.Admin => [.. album.Assets.OrderBy(a => a.OriginalFilename)],
|
||||
EAccessLevel.Curator => album.Assets.Where(a => a.DeletedAt == null || a.UploadedBy == userId).OrderBy(a => a.OriginalFilename).ToList(),
|
||||
EAccessLevel.Maintainer when userId.HasValue => [.. FilterAssetsForMaintainer(album, userId.Value).OrderBy(a => a.OriginalFilename)],
|
||||
_ => album.Assets.Where(a => a.DeletedAt == null && (
|
||||
assetsQuery = accessLevel switch {
|
||||
EAccessLevel.Admin => assetsQuery,
|
||||
EAccessLevel.Curator => assetsQuery.Where(a => a.DeletedAt == null || a.UploadedBy == userId),
|
||||
EAccessLevel.Maintainer when userId.HasValue && album.PersonOwnerId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == album.PersonOwnerId.Value)
|
||||
=> assetsQuery.Where(a => a.DeletedAt == null),
|
||||
_ => assetsQuery.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
(a.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(a.Visibility == EVisibility.Protected && (userId.HasValue || accessLevel >= EAccessLevel.Maintainer)) ||
|
||||
(a.Visibility == EVisibility.Private && a.UploadedBy == userId)
|
||||
)).OrderBy(a => a.OriginalFilename).ToList()
|
||||
))
|
||||
};
|
||||
|
||||
album.VisibleAssetPreviews = assetsQuery
|
||||
.OrderBy(a => a.OriginalFilename)
|
||||
.Select(a => new AlbumAssetPreviewDto {
|
||||
Id = a.Id,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return album;
|
||||
}
|
||||
|
||||
private List<Asset> FilterAssetsForMaintainer(Album album, Guid userId) {
|
||||
if (album.PersonOwnerId.HasValue && context.PersonMaintainers.Any(pm =>
|
||||
pm.UserId == userId && pm.PersonId == album.PersonOwnerId.Value))
|
||||
return album.Assets!.Where(a => a.DeletedAt == null).ToList();
|
||||
|
||||
return album.Assets!.Where(a => a.DeletedAt == null && (
|
||||
a.Visibility == EVisibility.Public ||
|
||||
a.Visibility == EVisibility.Protected ||
|
||||
a.UploadedBy == userId
|
||||
)).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Album> FindByPerson(Guid personId) => context.Albums.Where(a => a.PersonOwnerId == personId);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Context;
|
||||
using Lactose.Mapper;
|
||||
@@ -177,8 +178,8 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
.Take(limit);
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null) {
|
||||
var query = context.Assets.AsQueryable();
|
||||
public IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true) {
|
||||
var query = context.Assets.AsNoTracking().AsQueryable();
|
||||
if (type.HasValue)
|
||||
query = query.Where(a => a.Type == type.Value);
|
||||
if (from.HasValue)
|
||||
@@ -213,45 +214,61 @@ public class AssetRepository(LactoseDbContext context, ILogger<AssetRepository>
|
||||
))
|
||||
};
|
||||
|
||||
total = query.Count();
|
||||
total = includeCount ? query.Count() : 0;
|
||||
|
||||
if (orderRandomly && seed.HasValue) {
|
||||
var allIds = query.Select(a => a.Id).ToList();
|
||||
var seedHash = seed.Value.GetHashCode();
|
||||
var shuffledIds = allIds
|
||||
.OrderBy(id => DeterministicHash(id, seedHash))
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToList();
|
||||
// Deterministic page of IDs first: random order is a seeded md5 shuffle on the server,
|
||||
// so the full ID set is never materialized in memory.
|
||||
IQueryable<Asset> ordered = orderRandomly switch {
|
||||
true when seed.HasValue => query.OrderBy(a => PgFunctions.Md5(a.Id.ToString() + seed.Value.ToString())),
|
||||
true => query.OrderBy(a => a.Id),
|
||||
false => query.OrderByDescending(a => a.CreatedAt)
|
||||
};
|
||||
|
||||
var assets = context.Assets
|
||||
.AsSplitQuery()
|
||||
.Where(a => shuffledIds.Contains(a.Id))
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner)
|
||||
.ToList();
|
||||
|
||||
return shuffledIds.Select(id => assets.First(a => a.Id == id)).ToList();
|
||||
}
|
||||
|
||||
IQueryable<Asset> dataQuery = query
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.Albums!).ThenInclude(a => a.PersonOwner);
|
||||
dataQuery = orderRandomly ? dataQuery.OrderBy(a => a.Id) : dataQuery.OrderByDescending(a => a.CreatedAt);
|
||||
|
||||
return dataQuery
|
||||
var pageIds = ordered
|
||||
.Skip(pageNumber * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static int DeterministicHash(Guid id, int seed) {
|
||||
var bytes = id.ToByteArray();
|
||||
unchecked {
|
||||
var hash = seed;
|
||||
for (var i = 0; i < bytes.Length; i++)
|
||||
hash = hash * 31 + bytes[i];
|
||||
return hash & 0x7FFFFFFF;
|
||||
if (pageIds.Count == 0)
|
||||
return [];
|
||||
|
||||
var assets = query
|
||||
.Where(a => pageIds.Contains(a.Id))
|
||||
.Select(a => new AssetPreviewDto {
|
||||
Id = a.Id,
|
||||
MimeType = a.MimeType,
|
||||
ResolutionWidth = a.ResolutionWidth,
|
||||
ResolutionHeight = a.ResolutionHeight,
|
||||
HasThumbnail = a.ThumbnailPath != null && a.ThumbnailPath != "",
|
||||
HasPreview = a.PreviewPath != null && a.PreviewPath != "",
|
||||
FileName = accessLevel >= EAccessLevel.Curator ? a.OriginalFilename : null,
|
||||
Visibility = accessLevel >= EAccessLevel.Maintainer ? a.Visibility : null,
|
||||
DeletedAt = accessLevel >= EAccessLevel.Admin || a.UploadedBy == userId ? a.DeletedAt : null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Album and cosplayer names per asset via a single grouped query
|
||||
var albumLinks = (from a in context.Assets
|
||||
from al in a.Albums!
|
||||
where pageIds.Contains(a.Id)
|
||||
select new { AssetId = a.Id, al.Id, al.Title, al.PersonOwnerId, PersonName = al.PersonOwner!.Name })
|
||||
.ToList();
|
||||
|
||||
var linksByAsset = albumLinks
|
||||
.GroupBy(x => x.AssetId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
foreach (var dto in assets) {
|
||||
if (!linksByAsset.TryGetValue(dto.Id, out var links)) continue;
|
||||
dto.AlbumNames = links.Select(x => x.Title).Distinct().ToList();
|
||||
dto.AlbumIds = links.Select(x => x.Id).Distinct().ToList();
|
||||
dto.CosplayerNames = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonName).Distinct().ToList();
|
||||
dto.CosplayerIds = links.Where(x => x.PersonOwnerId.HasValue).Select(x => x.PersonOwnerId!.Value).Distinct().ToList();
|
||||
}
|
||||
|
||||
var orderMap = pageIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. assets.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -17,7 +17,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
static void OnFolderRemoved(Folder e) => FolderRemoved?.Invoke(null, e);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Create(Folder folder) {
|
||||
public void Insert(Folder folder) {
|
||||
context.Folders.Add(folder);
|
||||
context.SaveChanges();
|
||||
if (folder.Active) OnFolderAdded(folder);
|
||||
@@ -48,7 +48,7 @@ public class FolderRepository(LactoseDbContext context) : IFolderRepository, IAs
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Delete(Guid id) {
|
||||
public void Remove(Guid id) {
|
||||
var folder = context.Folders.Find(id);
|
||||
|
||||
if (folder != null) {
|
||||
|
||||
@@ -39,6 +39,13 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <returns>The album if found; otherwise, null.</returns>
|
||||
public Album? Find(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with its assets loaded for collection replacement.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the album.</param>
|
||||
/// <returns>The album with its asset collection loaded if found; otherwise, null.</returns>
|
||||
public Album? FindWithAssets(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Finds an album by its ID with assets filtered by the requesting user's access level.
|
||||
/// </summary>
|
||||
@@ -96,7 +103,8 @@ public interface IAlbumRepository : IDisposable {
|
||||
/// <param name="userId">The current user's ID for access-level filtering.</param>
|
||||
/// <param name="accessLevel">The current user's access level. Regular users only see albums with visible assets.</param>
|
||||
/// <param name="uploadedBy">Optional filter for albums containing assets uploaded by a specific user.</param>
|
||||
/// <param name="personOwnerId">Optional filter for albums owned by a specific person.</param>
|
||||
/// <returns>A list of album previews matching the search query.</returns>
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null);
|
||||
public IEnumerable<AlbumPreviewDto> SearchQuery(string query, int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid userId = default, EAccessLevel accessLevel = EAccessLevel.User, Guid? uploadedBy = null, Guid? personOwnerId = null);
|
||||
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Butter.Dtos.Asset;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
@@ -193,8 +194,9 @@ public interface IAssetRepository : IDisposable {
|
||||
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
|
||||
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
|
||||
/// <param name="search">Optional search term for ILike matching against OriginalFilename.</param>
|
||||
/// <returns>A paginated collection of assets matching the filters.</returns>
|
||||
IEnumerable<Asset> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null);
|
||||
/// <param name="includeCount">If true (default), computes the total matching count. When false, <paramref name="total"/> is set to zero and the count query is skipped.</param>
|
||||
/// <returns>A paginated collection of asset previews matching the filters.</returns>
|
||||
IEnumerable<AssetPreviewDto> GetAssets(EAssetType? type, DateTime? from, DateTime? to, bool orderRandomly, Guid? seed, int pageNumber, int pageSize, out int total, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User, bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns groups of unlinked assets (not assigned to any album), grouped by folder for drill-down browsing.
|
||||
|
||||
@@ -10,7 +10,7 @@ public interface IFolderRepository : IDisposable {
|
||||
/// Creates a new folder.
|
||||
/// </summary>
|
||||
/// <param name="folder">The folder to create.</param>
|
||||
void Create(Folder folder);
|
||||
void Insert(Folder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing folder.
|
||||
@@ -20,10 +20,10 @@ public interface IFolderRepository : IDisposable {
|
||||
void Update(Guid id, Folder folder);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a folder by ID.
|
||||
/// Removes a folder by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the folder to delete.</param>
|
||||
void Delete(Guid id);
|
||||
/// <param name="id">The ID of the folder to remove.</param>
|
||||
void Remove(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a folder by ID.
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace Lactose.Repositories;
|
||||
/// <summary>
|
||||
/// Interface for media repository to handle media data retrieval.
|
||||
/// </summary>
|
||||
public interface IMediaRepository {
|
||||
public interface IMediaRepository : IDisposable {
|
||||
/// <summary>
|
||||
/// Retrieves the original media data.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,8 @@ using Butter.Dtos.Person;
|
||||
using Butter.Types;
|
||||
using Lactose.Models;
|
||||
|
||||
namespace Lactose.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for person repository operations.
|
||||
/// </summary>
|
||||
|
||||
@@ -49,10 +49,10 @@ public interface ITagRepository : IDisposable {
|
||||
void Save();
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a tag by the given <paramref name="id"/>
|
||||
/// Deletes the given tag from the repository.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
void Delete(Tag id);
|
||||
/// <param name="tag"></param>
|
||||
void Delete(Tag tag);
|
||||
|
||||
/// <summary>
|
||||
/// Insert a new Tag
|
||||
|
||||
@@ -92,4 +92,10 @@ public class MediaRepository(LactoseDbContext context, IPersonRepository personR
|
||||
Path = media.PreviewPath
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() {
|
||||
context.Dispose();
|
||||
personRepository.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -16,23 +16,14 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
public void Save() => context.SaveChanges();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? Find(Guid id) => context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
public Person? Find(Guid id) => context.People.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Person? FindVisible(Guid id, Guid? userId = null, EAccessLevel accessLevel = EAccessLevel.User,
|
||||
string? albumSearch = null, string? albumSortBy = null, bool albumSortAsc = true,
|
||||
int albumPage = 0, int albumPageSize = PagedParametersDto.MaxPageSize) {
|
||||
var person = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.ProfileAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.CoverAsset)
|
||||
.Include(p => p.Albums)!.ThenInclude(a => a.Assets)
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Maintainers)
|
||||
.FirstOrDefault(p => p.Id == id);
|
||||
|
||||
@@ -49,78 +40,118 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
|
||||
if (!canSeePerson) return null;
|
||||
|
||||
if (person.Albums == null) return person;
|
||||
bool isMaintainerOfPerson = accessLevel == EAccessLevel.Maintainer && userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id);
|
||||
|
||||
// Filter albums and assets by visibility
|
||||
switch (accessLevel) {
|
||||
case EAccessLevel.Admin:
|
||||
case EAccessLevel.Curator:
|
||||
IQueryable<Album> albumsQuery = context.Albums
|
||||
.AsNoTracking()
|
||||
.Where(a => a.PersonOwnerId == id);
|
||||
|
||||
// Album search — ILike so the trgm GIN index on Title can be used
|
||||
if (!string.IsNullOrEmpty(albumSearch))
|
||||
albumsQuery = albumsQuery.Where(a => EF.Functions.ILike(a.Title, $"%{albumSearch}%"));
|
||||
|
||||
// Album visibility filter. Admin, curators, and maintainers of this person see all albums;
|
||||
// everyone else sees Protected-or-below albums plus albums that contain at least one visible asset.
|
||||
bool seesAllAlbums = accessLevel >= EAccessLevel.Curator || isMaintainerOfPerson;
|
||||
if (!seesAllAlbums) {
|
||||
albumsQuery = albumsQuery.Where(a => a.Visibility <= EVisibility.Protected || a.Assets!.Any(
|
||||
asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)));
|
||||
}
|
||||
|
||||
IOrderedQueryable<Album> ordered;
|
||||
switch (albumSortBy?.ToLowerInvariant()) {
|
||||
case "name":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.Title) : albumsQuery.OrderByDescending(a => a.Title);
|
||||
break;
|
||||
|
||||
case EAccessLevel.Maintainer when userId.HasValue
|
||||
&& context.PersonMaintainers.Any(pm => pm.UserId == userId.Value && pm.PersonId == id):
|
||||
case "created":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
|
||||
break;
|
||||
|
||||
default:
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Visibility <= EVisibility.Protected || (
|
||||
a.Assets != null && a.Assets.Any(asset =>
|
||||
asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
)
|
||||
)
|
||||
))
|
||||
.ToList();
|
||||
|
||||
foreach (var album in person.Albums) {
|
||||
if (album.Assets != null) {
|
||||
album.Assets = album.Assets
|
||||
.Where(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
case "updated":
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.UpdatedAt) : albumsQuery.OrderByDescending(a => a.UpdatedAt);
|
||||
break;
|
||||
case "assets":
|
||||
if (seesAllAlbums) {
|
||||
ordered = albumSortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null))
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null));
|
||||
} else {
|
||||
ordered = albumSortAsc
|
||||
? albumsQuery.OrderBy(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))))
|
||||
: albumsQuery.OrderByDescending(a => a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId))));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
ordered = albumSortAsc ? albumsQuery.OrderBy(a => a.CreatedAt) : albumsQuery.OrderByDescending(a => a.CreatedAt);
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply album search
|
||||
if (!string.IsNullOrEmpty(albumSearch)) {
|
||||
person.Albums = person.Albums
|
||||
.Where(a => a.Title.Contains(albumSearch, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
// Total album count (after search and visibility filters, before pagination)
|
||||
person.AlbumTotalCount = ordered.Count();
|
||||
|
||||
// Total visible assets across all visible albums of the person (before search and pagination)
|
||||
if (seesAllAlbums) {
|
||||
person.TotalAssetCount = context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null);
|
||||
} else {
|
||||
person.TotalAssetCount = context.Assets
|
||||
.Where(a => a.Albums!.Any(al => al.PersonOwnerId == id))
|
||||
.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)));
|
||||
}
|
||||
|
||||
// Apply album sorting
|
||||
if (!string.IsNullOrEmpty(albumSortBy)) {
|
||||
person.Albums = albumSortBy.ToLowerInvariant() switch {
|
||||
"name" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Title)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Title)],
|
||||
"created" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.CreatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.CreatedAt)],
|
||||
"updated" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.UpdatedAt)]
|
||||
: [..person.Albums.OrderByDescending(a => a.UpdatedAt)],
|
||||
"assets" => albumSortAsc
|
||||
? [..person.Albums.OrderBy(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)]
|
||||
: [..person.Albums.OrderByDescending(a => a.Assets?.Count(asset => asset.DeletedAt == null) ?? 0)],
|
||||
_ => person.Albums
|
||||
};
|
||||
}
|
||||
|
||||
// Apply album pagination
|
||||
person.AlbumTotalCount = person.Albums.Count;
|
||||
person.Albums = person.Albums
|
||||
var albumIds = ordered
|
||||
.Skip(albumPage * albumPageSize)
|
||||
.Take(albumPageSize)
|
||||
.Select(a => a.Id)
|
||||
.ToList();
|
||||
|
||||
if (albumIds.Count == 0)
|
||||
return person;
|
||||
|
||||
person.Albums = context.Albums
|
||||
.AsNoTracking()
|
||||
.AsSplitQuery()
|
||||
.Include(a => a.PersonOwner)
|
||||
.Include(a => a.CoverAsset)
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.ToList();
|
||||
|
||||
// Per-album visible asset counts at the database level
|
||||
if (seesAllAlbums) {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new { a.Id, Count = a.Assets!.Count(asset => asset.DeletedAt == null) })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
} else {
|
||||
person.AlbumAssetCounts = context.Albums
|
||||
.Where(a => albumIds.Contains(a.Id))
|
||||
.Select(a => new {
|
||||
a.Id,
|
||||
Count = a.Assets!.Count(asset => asset.DeletedAt == null && (
|
||||
asset.Visibility == EVisibility.Public ||
|
||||
(asset.Visibility == EVisibility.Protected && userId.HasValue) ||
|
||||
(asset.Visibility == EVisibility.Private && asset.UploadedBy == userId)))
|
||||
})
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
}
|
||||
|
||||
var orderMap = albumIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
person.Albums = [.. person.Albums.OrderBy(a => orderMap.GetValueOrDefault(a.Id))];
|
||||
|
||||
return person;
|
||||
}
|
||||
|
||||
@@ -203,12 +234,21 @@ public class PersonRepository(LactoseDbContext context) : IPersonRepository {
|
||||
return [];
|
||||
|
||||
var pagedPeople = context.People
|
||||
.AsSplitQuery()
|
||||
.Include(p => p.Albums)
|
||||
.AsNoTracking()
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.ToList();
|
||||
|
||||
var dtos = pagedPeople.Select(p => p.ToPersonPreviewDto(accessLevel)).ToList();
|
||||
// Album counts via a single grouped query instead of loading every album row
|
||||
var albumCounts = context.People
|
||||
.Where(p => personIds.Contains(p.Id))
|
||||
.Select(p => new { p.Id, Count = p.Albums!.Count })
|
||||
.ToDictionary(x => x.Id, x => x.Count);
|
||||
|
||||
var dtos = pagedPeople.Select(p => {
|
||||
var dto = p.ToPersonPreviewDto(accessLevel);
|
||||
dto.TotalAlbums = albumCounts.GetValueOrDefault(p.Id, 0);
|
||||
return dto;
|
||||
}).ToList();
|
||||
|
||||
var orderMap = personIds.Select((id, i) => (id, i)).ToDictionary(x => x.id, x => x.i);
|
||||
return [.. dtos.OrderBy(d => orderMap.GetValueOrDefault(d.Id))];
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
"UserID": "root",
|
||||
"Password": "testOnlyDb",
|
||||
"Database": "TestDb",
|
||||
"connString":"Server=127.0.0.1;Port=3306;Database=TestDb;User Id=root;Password=testOnlyDb;"
|
||||
"connString":"Server=127.0.0.1;Port=5432;Database=TestDb;User Id=root;Password=testOnlyDb;"
|
||||
},
|
||||
"DatabaseAddress": {
|
||||
"Host": "localhost",
|
||||
"Port": 3306
|
||||
"Port": 5432
|
||||
},
|
||||
"SignKey": {
|
||||
"Key": "32_CHARACTERS_KEY_IS_REQUIRED_TO_WORK"
|
||||
|
||||
@@ -377,8 +377,7 @@
|
||||
}
|
||||
|
||||
async Task<List<AlbumPreviewDto>?> FetchPersonAlbums(int page, int pageSize, string? search, string? sortBy, bool sortAsc) {
|
||||
var p = await personService.GetByIdAsync(Id, page, pageSize, search, sortBy, sortAsc);
|
||||
return p?.Albums;
|
||||
return await albumService.GetAlbumsAsync(page, pageSize, search, sortBy, sortAsc, personOwnerId: Id);
|
||||
}
|
||||
|
||||
void OpenEditForm() => showForm = true;
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
async Task LoadFirstPage() {
|
||||
_randomSeed = Guid.NewGuid();
|
||||
isLoading = true;
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, 0, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
RebuildFlatList();
|
||||
@@ -190,7 +190,7 @@
|
||||
isLoadingMore = true;
|
||||
StateHasChanged();
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, currentPage, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Add(items);
|
||||
loadedMaxPage = currentPage;
|
||||
@@ -221,7 +221,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30);
|
||||
var items = await assetService.GetAssetsAsync(EAssetType.Image, true, _randomSeed, pageToLoad, 30, includeCount: false);
|
||||
if (items?.Count > 0) {
|
||||
allAssetPages.Insert(0, items);
|
||||
loadedMinPage = pageToLoad;
|
||||
|
||||
@@ -131,23 +131,8 @@ public static class RegexHighlighter
|
||||
ClassAnchor, ClassQuantifier, ClassCharClass, ClassEscape,
|
||||
ClassNamedGroup, ClassGroupRef, ClassOperator, ClassLiteral);
|
||||
|
||||
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start)
|
||||
{
|
||||
int depth = 1;
|
||||
int i = start + 1;
|
||||
while (i < span.Length && depth > 0)
|
||||
{
|
||||
if (span[i] == '\\')
|
||||
{
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (span[i] == '(') depth++;
|
||||
if (span[i] == ')') depth--;
|
||||
i++;
|
||||
}
|
||||
return Math.Min(i - 1, span.Length - 1);
|
||||
}
|
||||
private static int FindNamedGroupEnd(ReadOnlySpan<char> span, int start) =>
|
||||
FindMatchingParen(span, start);
|
||||
|
||||
private static int FindMatchingParen(ReadOnlySpan<char> span, int start)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<Target Name="SetGitVersion" BeforeTargets="BeforeCompile" Condition="'$(InformationalVersion)' == ''">
|
||||
<Exec Command="git describe --tags --always --dirty 2>nul || echo unknown"
|
||||
<Exec Command="git describe --tags --always --dirty || echo unknown"
|
||||
ConsoleToMSBuild="true" StandardOutputImportance="low">
|
||||
<Output TaskParameter="ConsoleOutput" PropertyName="GitVersion" />
|
||||
</Exec>
|
||||
|
||||
@@ -24,8 +24,9 @@ public sealed class AlbumService(
|
||||
/// <param name="sortBy">Optional field to sort by (e.g. "name", "created", "updated", "assets", "person").</param>
|
||||
/// <param name="sortAsc">Whether to sort ascending. Default is <c>false</c> (descending).</param>
|
||||
/// <param name="unassigned">When <c>true</c>, only return albums with no person assigned.</param>
|
||||
/// <param name="personOwnerId">Optional person ID to filter albums by their owner.</param>
|
||||
/// <returns>A list of album previews, or null if the request failed.</returns>
|
||||
public async Task<List<AlbumPreviewDto>?> GetAlbumsAsync(int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? search = null, string? sortBy = null, bool sortAsc = false, bool unassigned = false) {
|
||||
public async Task<List<AlbumPreviewDto>?> GetAlbumsAsync(int page = 0, int pageSize = PagedParametersDto.MaxPageSize, string? search = null, string? sortBy = null, bool sortAsc = false, bool unassigned = false, Guid? personOwnerId = null) {
|
||||
var url = $"/api/album?page={page}&pageSize={pageSize}";
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
url += $"&search={Uri.EscapeDataString(search)}";
|
||||
@@ -34,6 +35,8 @@ public sealed class AlbumService(
|
||||
url += $"&sortAsc={sortAsc.ToString().ToLowerInvariant()}";
|
||||
if (unassigned)
|
||||
url += "&unassigned=true";
|
||||
if (personOwnerId.HasValue)
|
||||
url += $"&personOwnerId={personOwnerId.Value}";
|
||||
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
|
||||
@@ -26,10 +26,11 @@ public sealed class AssetService(
|
||||
/// <param name="folderId">Optional folder ID to filter assets by their scan folder.</param>
|
||||
/// <param name="uploadedBy">Optional uploader user ID to filter assets by their uploader.</param>
|
||||
/// <param name="search">Optional search term for filename matching.</param>
|
||||
/// <param name="includeCount">If true, the API also computes the total matching count. Skipping it avoids a full count query.</param>
|
||||
/// <returns>A list of asset previews, or null if the request failed.</returns>
|
||||
public async Task<List<AssetPreviewDto>?> GetAssetsAsync(
|
||||
EAssetType? type = null, bool random = false, Guid? seed = null, int page = 0, int pageSize = PagedParametersDto.MaxPageSize,
|
||||
bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null
|
||||
bool unlinked = false, Guid? folderId = null, Guid? uploadedBy = null, string? search = null, bool includeCount = true
|
||||
) {
|
||||
var url = $"/api/asset?page={page}&pageSize={pageSize}&random={random}";
|
||||
if (type.HasValue)
|
||||
@@ -44,6 +45,8 @@ public sealed class AssetService(
|
||||
url += $"&uploadedBy={uploadedBy.Value}";
|
||||
if (!string.IsNullOrEmpty(search))
|
||||
url += $"&search={Uri.EscapeDataString(search)}";
|
||||
if (!includeCount)
|
||||
url += "&includeCount=false";
|
||||
|
||||
var response = await Client.GetAsync(url);
|
||||
|
||||
|
||||
@@ -131,8 +131,11 @@ public sealed class LoginService : ServiceBase {
|
||||
AuthInfo? auth = null;
|
||||
try {
|
||||
auth = await localStorage.GetItemAsync<AuthInfo>("auth");
|
||||
} catch {
|
||||
try { await localStorage.RemoveItemAsync("auth"); } catch { }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to read auth from local storage, attempting cleanup");
|
||||
try { await localStorage.RemoveItemAsync("auth"); } catch (Exception cleanupEx) {
|
||||
logger.LogWarning(cleanupEx, "Failed to cleanup auth from local storage");
|
||||
}
|
||||
}
|
||||
|
||||
if (auth == null)
|
||||
@@ -191,7 +194,9 @@ public sealed class LoginService : ServiceBase {
|
||||
var failureResult = await response.Content.ReadFromJsonAsync<AuthResultDto>();
|
||||
if (!string.IsNullOrEmpty(failureResult?.ErrorMessage))
|
||||
error = failureResult.ErrorMessage;
|
||||
} catch { /* use default message */ }
|
||||
} catch (Exception ex) {
|
||||
logger.LogDebug(ex, "Failed to parse login failure response, using default message");
|
||||
}
|
||||
|
||||
return (false, error, null);
|
||||
}
|
||||
@@ -253,7 +258,9 @@ public sealed class LoginService : ServiceBase {
|
||||
ForceLogout?.Invoke(error);
|
||||
return;
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to parse refresh failure response");
|
||||
}
|
||||
|
||||
ForceLogoutReason = "Session expired. Please log in again.";
|
||||
await Logout();
|
||||
@@ -288,12 +295,16 @@ public sealed class LoginService : ServiceBase {
|
||||
logger.LogInformation("Logging out current user with ID: {AuthInfoUserId}", authInfo?.UserId);
|
||||
try {
|
||||
await Client.PostAsync("api/auth/logout", null);
|
||||
} catch { /* best-effort — clear local state regardless */ }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Logout API call failed, clearing local state anyway");
|
||||
}
|
||||
AuthInfo = null;
|
||||
LoggedUser = null;
|
||||
try {
|
||||
await localStorage.RemoveItemAsync("auth");
|
||||
} catch { }
|
||||
} catch (Exception ex) {
|
||||
logger.LogWarning(ex, "Failed to clear auth from local storage");
|
||||
}
|
||||
logger.LogInformation("Logout completed");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
try { builder.WebHost.UseStaticWebAssets(); } catch (DirectoryNotFoundException) { }
|
||||
try { builder.WebHost.UseStaticWebAssets(); } catch (DirectoryNotFoundException ex) {
|
||||
Console.Error.WriteLine($"Static web assets not found (expected in published mode): {ex.Message}");
|
||||
}
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
|
||||
_A place where to look for images_
|
||||
|
||||
**TODO: fill more information here**
|
||||
|
||||
## Configuration
|
||||
|
||||
### CORS allowed origins
|
||||
@@ -93,3 +91,5 @@ milkstream:
|
||||
If you expose MilkStream on a non-standard port or via HTTPS, adjust both values
|
||||
accordingly and list the exact `scheme://host:port` that appears in the browser's
|
||||
address bar.
|
||||
|
||||
<img src="scorecard.png" width="100%">
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@
|
||||
volumes:
|
||||
- "pg_dbdata:/var/lib/postgresql/data"
|
||||
ports:
|
||||
- "3306:5432"
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U root -d TestDb"]
|
||||
interval: 3s
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Reference in New Issue
Block a user