Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions app/Enum/SmartAlbumType.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

use App\Enum\Traits\DecorateBackedEnum;
use App\Repositories\ConfigManager;
use Illuminate\Support\Facades\Auth;
use LycheeVerify\Contract\VerifyInterface;

/**
Expand All @@ -31,6 +32,8 @@ enum SmartAlbumType: string
case FOUR_STARS = 'four_stars';
case FIVE_STARS = 'five_stars';
case BEST_PICTURES = 'best_pictures';
case MY_RATED_PICTURES = 'my_rated_pictures';
case MY_BEST_PICTURES = 'my_best_pictures';

/**
* Return whether the smart album is enabled.
Expand All @@ -53,6 +56,10 @@ public function is_enabled(ConfigManager $config_manager): bool
self::FIVE_STARS => $config_manager->getValueAsBool('enable_5_stars'),
// Best Pictures requires both config AND Lychee SE license
self::BEST_PICTURES => $config_manager->getValueAsBool('enable_best_pictures') && $this->isLycheeSEActive(),
// My Rated Pictures shows all photos the user has rated (authenticated users only)
self::MY_RATED_PICTURES => Auth::check() && $config_manager->getValueAsBool('enable_my_rated_pictures'),
// My Best Pictures requires authenticated user, config, AND Lychee SE license
self::MY_BEST_PICTURES => Auth::check() && $config_manager->getValueAsBool('enable_my_best_pictures') && $this->isLycheeSEActive(),
};
}

Expand All @@ -71,4 +78,29 @@ private function isLycheeSEActive(): bool
return false;
}
}

/**
* Whether the album requires the user to have upload rights.
*
* @return bool
*/
public function require_upload_rights(): bool
{
return match ($this) {
self::UNSORTED,
self::STARRED,
self::RECENT,
self::ON_THIS_DAY,
self::UNRATED,
self::UNTAGGED => true,
self::ONE_STAR,
self::TWO_STARS,
self::THREE_STARS,
self::FOUR_STARS,
self::FIVE_STARS,
self::BEST_PICTURES,
self::MY_RATED_PICTURES,
self::MY_BEST_PICTURES => false,
};
}
}
4 changes: 4 additions & 0 deletions app/Factories/AlbumFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
use App\SmartAlbums\BestPicturesAlbum;
use App\SmartAlbums\FiveStarsAlbum;
use App\SmartAlbums\FourStarsAlbum;
use App\SmartAlbums\MyBestPicturesAlbum;
use App\SmartAlbums\MyRatedPicturesAlbum;
use App\SmartAlbums\OneStarAlbum;
use App\SmartAlbums\OnThisDayAlbum;
use App\SmartAlbums\RecentAlbum;
Expand Down Expand Up @@ -47,6 +49,8 @@ class AlbumFactory
SmartAlbumType::FOUR_STARS->value => FourStarsAlbum::class,
SmartAlbumType::FIVE_STARS->value => FiveStarsAlbum::class,
SmartAlbumType::BEST_PICTURES->value => BestPicturesAlbum::class,
SmartAlbumType::MY_RATED_PICTURES->value => MyRatedPicturesAlbum::class,
SmartAlbumType::MY_BEST_PICTURES->value => MyBestPicturesAlbum::class,
];

public function __construct(
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Middleware/ConfigIntegrity.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ class ConfigIntegrity
'rating_album_view_mode',
'enable_best_pictures',
'best_pictures_count',
'enable_my_best_pictures',
'my_best_pictures_count',
];

public const PRO_FIELDS = [
Expand Down
5 changes: 5 additions & 0 deletions app/Policies/AlbumPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,12 @@ public function isOwner(?User $user, BaseAlbum|BaseAlbumImpl $album): bool
*/
public function canSee(?User $user, BaseSmartAlbum $smart_album): bool
{
// We do not require upload rights for all albums
$require_upload_rights = SmartAlbumType::from($smart_album->get_id())->require_upload_rights();

return ($user?->may_upload === true) ||
($user?->may_upload === false && !$require_upload_rights) ||
// if $user is null then we require that the album is public.
$smart_album->public_permissions() !== null;
}

Expand Down
2 changes: 2 additions & 0 deletions app/SmartAlbums/BaseSmartAlbum.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use App\Models\Extensions\ToArrayThrowsNotImplemented;
use App\Models\Extensions\UTCBasedTimes;
use App\Models\Photo;
use App\Models\User;
use App\Policies\AlbumPolicy;
use App\Policies\PhotoQueryPolicy;
use App\Repositories\ConfigManager;
Expand Down Expand Up @@ -108,6 +109,7 @@ public function get_photos(): LengthAwarePaginator
*/
public function photos(): Builder
{
/** @var ?User $user */
$user = Auth::user();
$unlocked_album_ids = AlbumPolicy::getUnlockedAlbumIDs();

Expand Down
135 changes: 135 additions & 0 deletions app/SmartAlbums/MyBestPicturesAlbum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

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

namespace App\SmartAlbums;

use App\Enum\SmartAlbumType;
use App\Exceptions\ConfigurationKeyMissingException;
use App\Exceptions\Internal\FrameworkException;
use App\Exceptions\Internal\InvalidOrderDirectionException;
use App\Exceptions\Internal\InvalidQueryModelException;
use App\Models\Photo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;

/**
* Smart album containing the top N highest-rated photos by the current user.
* Photos with the same rating as the Nth photo are included (ties).
* Only visible to authenticated users with Lychee SE license.
*/
class MyBestPicturesAlbum extends BaseSmartAlbum
{
public const ID = SmartAlbumType::MY_BEST_PICTURES->value;

/**
* @throws ConfigurationKeyMissingException
* @throws FrameworkException
*/
protected function __construct()
{
// The condition filters for photos that have been rated by current user
// The tie-inclusion logic is handled in getPhotosAttribute()
parent::__construct(
id: SmartAlbumType::MY_BEST_PICTURES,
smart_condition: fn (Builder $q) => $q->whereHas('ratings', function ($query): void {
$query->where('user_id', '=', Auth::id() ?? 0);
})
);
}

public static function getInstance(): self
{
return new self();
}

/**
* Override to implement tie-inclusion logic for top N photos rated by current user.
*
* @return LengthAwarePaginator<int,Photo>
*
* @throws InvalidOrderDirectionException
* @throws InvalidQueryModelException
*/
protected function getPhotosAttribute(): LengthAwarePaginator
{
if ($this->photos !== null) {
return $this->photos;
}

$limit = $this->config_manager->getValueAsInt('my_best_pictures_count');

// Get the Nth photo's rating to determine the cutoff
$cutoff_rating = $this->getCutoffRating($limit);

if ($cutoff_rating === null) {
// No photos with ratings from this user, return empty paginator
$this->photos = new LengthAwarePaginator([], 0, $limit);

return $this->photos;
}

// Include all photos with user rating >= cutoff (this handles ties)
// We need to join with photo_ratings to filter by the user's rating
$query = $this->photos()
->join('photo_ratings as pr_filter', function ($join) use ($cutoff_rating): void {
$join->on('photos.id', '=', 'pr_filter.photo_id')
->where('pr_filter.user_id', '=', Auth::id() ?? 0)
->where('pr_filter.rating', '>=', $cutoff_rating);
})
->select('photos.*'); // Ensure we only select photo columns

// Always sort by user's rating DESC for My Best Pictures
// We already have the join from above, so order by that rating
$query = $query->orderByRaw('pr_filter.rating DESC')
->orderBy('photos.created_at', 'DESC');

/** @var LengthAwarePaginator<int,Photo> $photos */
$photos = $query->paginate($this->config_manager->getValueAsInt('photos_pagination_limit'));

$this->photos = $photos;

return $this->photos;
}

/**
* Get the rating of the Nth photo (by current user) to use as the cutoff.
*
* @param int $limit the N for "top N photos"
*
* @return int|null the rating at position N, or null if fewer than N rated photos exist
*/
private function getCutoffRating(int $limit): ?int
{
$user_id = Auth::id() ?? 0;

// Get the Nth highest rating from current user
$nth_rating = DB::table('photo_ratings')
->where('user_id', '=', $user_id)
->join('photos', 'photo_ratings.photo_id', '=', 'photos.id')
->orderBy('photo_ratings.rating', 'DESC')
->skip($limit - 1)
->take(1)
->value('photo_ratings.rating');

if ($nth_rating === null) {
// Fewer than N photos with ratings from this user exist
// Get the lowest rating among existing rated photos from this user
$lowest_rating = DB::table('photo_ratings')
->where('user_id', '=', $user_id)
->join('photos', 'photo_ratings.photo_id', '=', 'photos.id')
->orderBy('photo_ratings.rating', 'ASC')
->value('photo_ratings.rating');

return $lowest_rating;
}

return $nth_rating;
}
}
71 changes: 71 additions & 0 deletions app/SmartAlbums/MyRatedPicturesAlbum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

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

namespace App\SmartAlbums;

use App\Contracts\Exceptions\InternalLycheeException;
use App\Enum\SmartAlbumType;
use App\Exceptions\ConfigurationKeyMissingException;
use App\Exceptions\Internal\FrameworkException;
use App\Models\Photo;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;

/**
* Smart album containing all photos rated by the current user.
* Shows photos ordered by user's rating (highest first), then by creation date (newest first).
* Only visible to authenticated users.
*/
class MyRatedPicturesAlbum extends BaseSmartAlbum
{
public const ID = SmartAlbumType::MY_RATED_PICTURES->value;

/**
* @throws ConfigurationKeyMissingException
* @throws FrameworkException
*/
protected function __construct()
{
// The condition filters for photos that have been rated by current user
parent::__construct(
id: SmartAlbumType::MY_RATED_PICTURES,
smart_condition: fn (Builder $q) => $q->whereHas('ratings', function ($query): void {
$query->where('user_id', '=', Auth::id() ?? 0);
})
);
}

public static function getInstance(): self
{
return new self();
}

/**
* Override to add custom ordering: user's rating DESC, then created_at DESC.
*
* @return \App\Eloquent\FixedQueryBuilder<Photo>
*
* @throws InternalLycheeException
*/
public function photos(): Builder
{
// Get base query from parent (includes security filtering and smart condition)
$query = parent::photos();

// Add join with photo_ratings to access the user's rating for ordering
// Use leftJoin since the whereHas in smart_condition already ensures ratings exist
return $query
->leftJoin('photo_ratings', function ($join): void {
$join->on('photos.id', '=', 'photo_ratings.photo_id')
->where('photo_ratings.user_id', '=', Auth::id() ?? 0);
})
->orderByRaw('photo_ratings.rating DESC')
->orderBy('photos.created_at', 'DESC')
->select('photos.*'); // Ensure we only select photo columns
}
}
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^8.3",
"php-parallel-lint/php-parallel-lint": "^1.3",
"phpstan/phpstan": "^2.1",
"phpunit/phpunit": "^11.0",
"rector/rector": "^2.0",
"tomasvotruba/class-leak": "^2.0"
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading