Skip to content
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
ae74a55
specification
ildyria Dec 27, 2025
ef0ee27
first stab
ildyria Dec 27, 2025
6032535
Fix formatting and phpstan
ildyria Dec 27, 2025
8efe6d1
fix migration
ildyria Dec 27, 2025
f1567e5
test: add concurrency tests for photo rating feature (I10-I11)
ildyria Dec 27, 2025
f826563
feat: add frontend service layer for photo rating (I7)
ildyria Dec 27, 2025
2d8fd7a
feat: add photo rating widget to details drawer (I8-I9)
ildyria Dec 27, 2025
be3fc7e
feat: add rating overlays to photo thumbs and full views (I9a-I9d)
ildyria Dec 27, 2025
69dd341
docs: mark I9b-I9d as complete in tasks.md
ildyria Dec 27, 2025
0af4a04
feat: add config settings for rating visibility (I12a)
ildyria Dec 27, 2025
b56f9f8
refactor: use VisibilityType enum for rating view modes
ildyria Dec 27, 2025
30a8677
refactor: use BaseConfigMigration for rating config
ildyria Dec 27, 2025
c9ddae7
docs: mark I12a as complete in tasks.md
ildyria Dec 27, 2025
52e5a5a
docs: update documentation for photo rating feature (I12)
ildyria Dec 27, 2025
6571da9
docs: mark I12 and I13 complete - feature fully implemented
ildyria Dec 27, 2025
3a4d449
docs: add I14 increment for N+1 query performance optimization
ildyria Dec 27, 2025
a567aad
remove stupid timestamps
ildyria Dec 27, 2025
39845e9
Formatting
ildyria Dec 27, 2025
c5634a8
refactor settings
ildyria Dec 27, 2025
6f68773
fix tests
ildyria Dec 27, 2025
90302a6
minore refactoring
ildyria Dec 27, 2025
96845a4
refine front-end
ildyria Dec 27, 2025
bbc1f93
improve UI/UX
ildyria Dec 27, 2025
96e9f69
fix test
ildyria Dec 27, 2025
1761fc8
fix test
ildyria Dec 27, 2025
6ea0d61
fixes
ildyria Dec 27, 2025
3ab5743
fix factory
ildyria Dec 27, 2025
707c20d
add GD coverage back
ildyria Dec 27, 2025
f549ee5
add relationship + eager loading
ildyria Dec 27, 2025
ea250fa
Fix
ildyria Dec 27, 2025
d55f628
fix star/header/cover refresh issues
ildyria Dec 27, 2025
01845fc
fix pgsql request
ildyria Dec 27, 2025
0f05b74
translations
ildyria Dec 28, 2025
823d0d0
more UI/UX fixes
ildyria Dec 28, 2025
b809b74
fixes
ildyria Dec 28, 2025
9a23d4d
formatting
ildyria Dec 28, 2025
84d51cb
add more strict requirements
ildyria Dec 28, 2025
58c6367
more fix
ildyria Dec 28, 2025
9b7fb5a
more fix
ildyria Dec 28, 2025
74b1e5e
Fix tests
ildyria Dec 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/Actions/Album/Create.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ private function setStatistics(Album $album): void
'download_count' => 0,
'favourite_count' => 0,
'shared_count' => 0,
'rating_sum' => 0,
'rating_count' => 0,
]);
}
}
1 change: 1 addition & 0 deletions app/Actions/Album/PositionData.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function get(AbstractAlbum $album, bool $include_sub_albums = false): Pos
},
'palette',
'tags',
'rating',
])
->whereNotNull('latitude')
->whereNotNull('longitude');
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Albums/Flow.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ private function getQuery(Album|null $base, bool $with_relations): AlbumBuilder

$base_query = Album::query();
if ($with_relations) {
$base_query->with(['cover', 'cover.size_variants', 'statistics', 'photos', 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags']);
$base_query->with(['cover', 'cover.size_variants', 'statistics', 'photos', 'photos.statistics', 'photos.size_variants', 'photos.palette', 'photos.tags', 'photos.rating']);
}

// Only join what we need for ordering.
Expand Down
1 change: 1 addition & 0 deletions app/Actions/Albums/PositionData.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public function do(): PositionDataResource
},
'palette',
'tags',
'rating',
])
->whereNotNull('latitude')
->whereNotNull('longitude'),
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Photo/Pipes/Shared/SaveStatistics.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public function handle(PhotoDTO $state, \Closure $next): PhotoDTO
'download_count' => 0,
'favourite_count' => 0,
'shared_count' => 0,
'rating_sum' => 0,
'rating_count' => 0,
]);

$state->getPhoto()->setRelation('statistics', $stats);
Expand Down
103 changes: 103 additions & 0 deletions app/Actions/Photo/Rating.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2025 LycheeOrg.
*/

namespace App\Actions\Photo;

use App\Exceptions\ConflictingPropertyException;
use App\Models\Photo;
use App\Models\PhotoRating;
use App\Models\Statistics;
use App\Models\User;
use Illuminate\Support\Facades\DB;

class Rating
{
/**
* Set or update the rating for the photo.
*
* Handles:
* - Creating new ratings (rating > 0, no existing rating)
* - Updating existing ratings (rating > 0, existing rating)
* - Removing ratings (rating == 0)
* - Atomic statistics updates
*
* @param Photo $photo The photo to rate
* @param User $user The user rating the photo
* @param int $rating The rating value (0-5, where 0 removes the rating)
*
* @return Photo the photo with refreshed statistics
*
* @throws ConflictingPropertyException if a database conflict occurs during the transaction
*/
public function do(Photo $photo, User $user, int $rating): Photo
{
try {
DB::transaction(function () use ($photo, $user, $rating): void {
// Ensure statistics record exists atomically (Q001-07)
$statistics = Statistics::firstOrCreate(
['photo_id' => $photo->id],
[
'album_id' => null,
'visit_count' => 0,
'download_count' => 0,
'favourite_count' => 0,
'shared_count' => 0,
'rating_sum' => 0,
'rating_count' => 0,
]
);

if ($rating > 0) {
// Find existing rating by this user for this photo
$existing_rating = PhotoRating::where('photo_id', $photo->id)
->where('user_id', $user->id)
->first();

if ($existing_rating !== null) {
// Update: adjust statistics delta
$delta = $rating - $existing_rating->rating;
$statistics->rating_sum += $delta;
$existing_rating->rating = $rating;
$existing_rating->save();
} else {
// Insert: create new rating and increment statistics
PhotoRating::create([
'photo_id' => $photo->id,
'user_id' => $user->id,
'rating' => $rating,
]);
$statistics->rating_sum += $rating;
$statistics->rating_count++;
}

$statistics->save();
} else {
// Rating == 0: remove rating (idempotent, Q001-06)
$existing_rating = PhotoRating::where('photo_id', $photo->id)
->where('user_id', $user->id)
->first();

if ($existing_rating !== null) {
$statistics->rating_sum -= $existing_rating->rating;
$statistics->rating_count--;
$statistics->save();
$existing_rating->delete();
}
// If no existing rating, do nothing (idempotent)
}
});

// Reload photo with fresh statistics
$photo->refresh();

return $photo;
} catch (\Throwable $e) {
throw new ConflictingPropertyException('Failed to update photo rating due to a conflict. Please try again.', $e);
}
}
}
2 changes: 1 addition & 1 deletion app/Actions/Photo/Timeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function do(): Builder
// @codeCoverageIgnoreEnd

return $this->photo_query_policy->applySearchabilityFilter(
query: Photo::query()->with(['statistics', 'size_variants', 'statistics', 'palette', 'tags']),
query: Photo::query()->with(['statistics', 'size_variants', 'statistics', 'palette', 'tags', 'rating']),
origin: null,
include_nsfw: !$this->config_manager->getValueAsBool('hide_nsfw_in_timeline')
)->orderBy($order->value, OrderSortingType::DESC->value);
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Search/PhotoSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function query(array $terms): Collection
public function sqlQuery(array $terms, ?Album $album = null): Builder
{
$query = $this->photo_query_policy->applySearchabilityFilter(
query: Photo::query()->with(['albums', 'statistics', 'size_variants', 'palette', 'tags']),
query: Photo::query()->with(['albums', 'statistics', 'size_variants', 'palette', 'tags', 'rating']),
origin: $album,
include_nsfw: !$this->config_manager->getValueAsBool('hide_nsfw_in_search')
);
Expand Down
2 changes: 1 addition & 1 deletion app/Actions/Tag/GetTagWithPhotos.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public function do(Tag $tag): TagWithPhotosResource
$user = Auth::user();

$base_query = Photo::query()
->with(['size_variants', 'statistics', 'palette', 'tags'])
->with(['size_variants', 'statistics', 'palette', 'tags', 'rating'])
->when(
$user->may_administrate === false,
fn ($q) => $q->where('photos.owner_id', Auth::id())
Expand Down
1 change: 1 addition & 0 deletions app/Contracts/Http/Requests/RequestAttribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ class RequestAttribute
public const FILE_ATTRIBUTE = 'file';
public const SHALL_OVERRIDE_ATTRIBUTE = 'shall_override';
public const IS_STARRED_ATTRIBUTE = 'is_starred';
public const RATING_ATTRIBUTE = 'rating';
public const DIRECTION_ATTRIBUTE = 'direction';

public const SINGLE_PATH_ATTRIBUTE = 'path';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
namespace App\Enum;

/**
* Enum ThumbOverlayVisibilityType.
* Enum VisibilityType.
*
* All the allowed display possibilities of the overlay on thumbs
* All the allowed visibility modes for UI elements
*/
enum ThumbOverlayVisibilityType: string
enum VisibilityType: string
{
case NEVER = 'never';
case ALWAYS = 'always';
Expand Down
8 changes: 4 additions & 4 deletions app/Factories/AlbumFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ public function findBaseAlbumOrFail(string $album_id, bool $with_relations = tru
$tag_album_query = TagAlbum::query();

if ($with_relations) {
$album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']);
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']);
$album_query->with(['access_permissions', 'photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
}

$ret = $album_query->find($album_id) ?? $tag_album_query->find($album_id);
Expand Down Expand Up @@ -173,8 +173,8 @@ public function findBaseAlbumsOrFail(array $album_ids, bool $with_relations = tr
$album_query = Album::query();

if ($with_relations) {
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']);
$album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags']);
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
$album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
}

/** @var ($albums_only is true ? array<int,Album> : array<int,TagAlbum>)&array */
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Gallery/FrameController.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,12 @@ private function loadPhoto(AbstractAlbum|null $album, int $retries = 5): ?Photo
// default query
if ($album === null) {
$query = $this->photo_query_policy->applySearchabilityFilter(
query: Photo::query()->with(['albums', 'size_variants', 'palette', 'tags']),
query: Photo::query()->with(['albums', 'size_variants', 'palette', 'tags', 'rating']),
origin: null,
include_nsfw: !request()->configs()->getValueAsBool('hide_nsfw_in_frame')
);
} else {
$query = $album->photos()->with(['albums', 'size_variants', 'palette', 'tags']);
$query = $album->photos()->with(['albums', 'size_variants', 'palette', 'tags', 'rating']);
}

/** @var ?Photo $photo */
Expand Down
31 changes: 31 additions & 0 deletions app/Http/Controllers/Gallery/PhotoController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,22 @@
use App\Actions\Import\FromUrl;
use App\Actions\Photo\Delete;
use App\Actions\Photo\MoveOrDuplicate;
use App\Actions\Photo\Rating;
use App\Actions\Photo\Rotate;
use App\Constants\FileSystem;
use App\Contracts\Models\AbstractAlbum;
use App\Enum\FileStatus;
use App\Enum\SizeVariantType;
use App\Exceptions\ConfigurationException;
use App\Exceptions\ConflictingPropertyException;
use App\Http\Requests\Photo\CopyPhotosRequest;
use App\Http\Requests\Photo\DeletePhotosRequest;
use App\Http\Requests\Photo\EditPhotoRequest;
use App\Http\Requests\Photo\FromUrlRequest;
use App\Http\Requests\Photo\MovePhotosRequest;
use App\Http\Requests\Photo\RenamePhotoRequest;
use App\Http\Requests\Photo\RotatePhotoRequest;
use App\Http\Requests\Photo\SetPhotoRatingRequest;
use App\Http\Requests\Photo\SetPhotosStarredRequest;
use App\Http\Requests\Photo\SetPhotosTagsRequest;
use App\Http\Requests\Photo\UploadPhotoRequest;
Expand Down Expand Up @@ -166,6 +169,34 @@ public function star(SetPhotosStarredRequest $request): void
}
}

/**
* Set the rating for a photo.
*
* @param SetPhotoRatingRequest $request
* @param Rating $rating
*
* @return PhotoResource
*
* @throws ConflictingPropertyException
*/
public function rate(SetPhotoRatingRequest $request, Rating $rating): PhotoResource
{
if (!$request->configs()->getValueAsBool('rating_enabled')) {
throw new ConfigurationException('photo rating feature is disabled by configuration');
}

/** @var \App\Models\User $user */
$user = Auth::user();

$photo = $rating->do(
$request->photo(),
$user,
$request->rating()
);

return new PhotoResource($photo, null);
}

/**
* Moves the photos to an album.
*/
Expand Down
5 changes: 5 additions & 0 deletions app/Http/Middleware/ConfigIntegrity.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ class ConfigIntegrity
'flow_carousel_height',
'date_format_flow_published',
'date_format_flow_min_max',
'rating_public',
'rating_show_only_when_user_rated',
'rating_photo_view_mode',
'rating_show_avg_in_photo_view',
'rating_album_view_mode',
];

public const PRO_FIELDS = [
Expand Down
65 changes: 65 additions & 0 deletions app/Http/Requests/Photo/SetPhotoRatingRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/**
* SPDX-License-Identifier: MIT
* Copyright (c) 2017-2018 Tobias Reich
* Copyright (c) 2018-2025 LycheeOrg.
*/

namespace App\Http\Requests\Photo;

use App\Contracts\Http\Requests\HasPhoto;
use App\Contracts\Http\Requests\RequestAttribute;
use App\Http\Requests\BaseApiRequest;
use App\Http\Requests\Traits\HasPhotoTrait;
use App\Models\Photo;
use App\Policies\PhotoPolicy;
use App\Rules\RandomIDRule;
use Illuminate\Support\Facades\Gate;

/**
* Class SetPhotoRatingRequest.
*/
class SetPhotoRatingRequest extends BaseApiRequest implements HasPhoto
{
use HasPhotoTrait;

protected int $rating;

/**
* {@inheritDoc}
*/
public function authorize(): bool
{
return Gate::check(PhotoPolicy::CAN_SEE, [Photo::class, $this->photo]);
}

/**
* {@inheritDoc}
*/
public function rules(): array
{
return [
RequestAttribute::PHOTO_ID_ATTRIBUTE => ['required', new RandomIDRule(false)],
RequestAttribute::RATING_ATTRIBUTE => 'required|integer|min:0|max:5',
];
}

/**
* {@inheritDoc}
*/
protected function processValidatedValues(array $values, array $files): void
{
/** @var ?string $photo_id */
$photo_id = $values[RequestAttribute::PHOTO_ID_ATTRIBUTE];
$this->photo = Photo::query()
->with(['albums'])
->findOrFail($photo_id);
$this->rating = intval($values[RequestAttribute::RATING_ATTRIBUTE]);
}

public function rating(): int
{
return $this->rating;
}
}
Loading
Loading