From bdf903aff6d167843571dd34f96a053d56014bb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:18:07 +0000 Subject: [PATCH 1/5] Initial plan From 46e2e514a5758ba7cd9ba498181a60a7e1a5b7eb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:25:18 +0000 Subject: [PATCH 2/5] fix: deduplicate tag album photos by adding distinct() to query Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/263462c1-aa7c-43a2-8b7a-d39a29bccd71 Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- app/Relations/HasManyPhotosByTag.php | 6 +++++ .../Album/AlbumPhotosEndpointTest.php | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/app/Relations/HasManyPhotosByTag.php b/app/Relations/HasManyPhotosByTag.php index 3f4c93d1149..e86278314d1 100644 --- a/app/Relations/HasManyPhotosByTag.php +++ b/app/Relations/HasManyPhotosByTag.php @@ -106,6 +106,12 @@ public function addEagerConstraints(array $albums): void ) ->where(fn (Builder $q) => $this->getPhotoIdsWithTags($q, $tag_ids, $album->is_and)); } + + // The LEFT JOIN with photo_album (added by applySearchabilityFilter/applySensitivityFilter + // for access control checks) produces one row per album membership. Since a photo can + // belong to multiple regular albums, this causes duplicate rows in the result. Adding + // DISTINCT ensures each photo appears only once. + $this->getRelationQuery()->distinct(); } /** diff --git a/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php b/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php index 96eb9889746..119dba42791 100644 --- a/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php +++ b/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php @@ -18,6 +18,7 @@ namespace Tests\Feature_v2\Album; +use App\Models\Album; use Tests\Feature_v2\Base\BaseApiWithDataTest; /** @@ -125,4 +126,30 @@ public function testGetAlbumPhotosMissingAlbumId(): void 'message' => 'The album id field is required.', ]); } + + public function testTagAlbumPhotosNoDuplicatesWhenPhotoInMultipleAlbums(): void + { + // Regression test for: tag album photo listing returns duplicate entries. + // When a photo belongs to multiple regular albums, the LEFT JOIN with + // photo_album in applySearchabilityFilter produces one row per album + // membership, causing duplicate photos in the tag album response. + + $this->actingAs($this->userMayUpload1); + + // photo1 is already in album1 and has the 'test' tag. + // tagAlbum1 is linked to the 'test' tag. + // Add photo1 to a second album to trigger the duplicate via the photo_album JOIN. + $extraAlbum = Album::factory()->as_root()->owned_by($this->userMayUpload1)->create(); + $this->photo1->albums()->attach($extraAlbum->id); + + $response = $this->getJsonWithData('Album::photos', ['album_id' => $this->tagAlbum1->id]); + $this->assertOk($response); + + $photos = $response->json('photos'); + $photoIds = array_column($photos, 'id'); + + // Each photo must appear exactly once. + $this->assertCount(count(array_unique($photoIds)), $photos, 'Tag album must not return duplicate photos'); + $this->assertContains($this->photo1->id, $photoIds); + } } From 72f04c3ae3572b8417432a74ce753540a2a311ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 11:27:00 +0000 Subject: [PATCH 3/5] test: clarify duplicate photo assertion in tag album test Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/263462c1-aa7c-43a2-8b7a-d39a29bccd71 Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- tests/Feature_v2/Album/AlbumPhotosEndpointTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php b/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php index 119dba42791..44125ce175e 100644 --- a/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php +++ b/tests/Feature_v2/Album/AlbumPhotosEndpointTest.php @@ -149,7 +149,7 @@ public function testTagAlbumPhotosNoDuplicatesWhenPhotoInMultipleAlbums(): void $photoIds = array_column($photos, 'id'); // Each photo must appear exactly once. - $this->assertCount(count(array_unique($photoIds)), $photos, 'Tag album must not return duplicate photos'); + $this->assertSame(count(array_unique($photoIds)), count($photoIds), 'Tag album must not return duplicate photos'); $this->assertContains($this->photo1->id, $photoIds); } } From 93fa5fda9e99de32f724b415df5dfdc498a9d37c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:33:41 +0000 Subject: [PATCH 4/5] fix: replace distinct() with whereIn subquery to avoid PostgreSQL crash Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/2bcb1a88-be7d-4d83-a3f9-935c031e33ae Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- app/Relations/HasManyPhotosByTag.php | 38 ++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/app/Relations/HasManyPhotosByTag.php b/app/Relations/HasManyPhotosByTag.php index e86278314d1..5026a8a0cf0 100644 --- a/app/Relations/HasManyPhotosByTag.php +++ b/app/Relations/HasManyPhotosByTag.php @@ -12,6 +12,7 @@ use App\Enum\OrderSortingType; use App\Exceptions\Internal\NotImplementedException; use App\Models\Extensions\SortingDecorator; +use App\Models\Photo; use App\Models\TagAlbum; use App\Repositories\ConfigManager; use Illuminate\Database\Eloquent\Builder; @@ -70,10 +71,6 @@ public function addEagerConstraints(array $albums): void /** @var TagAlbum $album */ $album = $albums[0]; - $tag_ids = DB::table('tag_albums_tags')->where('album_id', '=', $album->id) - ->select('tag_id') - ->pluck('tag_id')->all(); - $tag_ids = $album->relationLoaded('tags') ? $album->tags->pluck('id')->all() : DB::table('tag_albums_tags') @@ -86,10 +83,33 @@ public function addEagerConstraints(array $albums): void $unlocked_album_ids = \App\Policies\AlbumPolicy::getUnlockedAlbumIDs(); $config_manager = app(ConfigManager::class); + + // Build a subquery that selects the IDs of photos the current user may see + // that also carry the required tags. We deliberately keep the access-control + // JOINs (photo_album, albums, computed_access_permissions) *inside* this + // subquery rather than on the outer relation query: + // + // • applySearchabilityFilter / applySensitivityFilter both add a + // LEFT JOIN photo_album that produces one row per album membership. + // A photo belonging to N regular albums therefore appears N times. + // + // • If we applied those JOINs directly to the outer query and used + // SELECT DISTINCT to collapse duplicates, PostgreSQL would require + // every ORDER BY expression to appear literally in the SELECT list. + // Raw expressions such as COALESCE(photos.rating_avg, 0) used for + // rating sorting are not in photos.* as a literal expression, so + // PostgreSQL raises an error. + // + // By using a whereIn subquery we avoid both problems: the outer relation + // query has no extra JOINs (no duplicates) and no DISTINCT (no ORDER BY + // restriction). + /** @var \App\Models\Builders\PhotoBuilder $ids_query */ + $ids_query = Photo::query()->select('photos.id'); + if ($config_manager->getValueAsBool('TA_override_visibility')) { $this->photo_query_policy ->applySensitivityFilter( - query: $this->getRelationQuery(), + query: $ids_query, user: $user, origin: null, include_nsfw: !$config_manager->getValueAsBool('hide_nsfw_in_tag_albums') @@ -98,7 +118,7 @@ public function addEagerConstraints(array $albums): void } else { $this->photo_query_policy ->applySearchabilityFilter( - query: $this->getRelationQuery(), + query: $ids_query, user: $user, unlocked_album_ids: $unlocked_album_ids, origin: null, @@ -107,11 +127,7 @@ public function addEagerConstraints(array $albums): void ->where(fn (Builder $q) => $this->getPhotoIdsWithTags($q, $tag_ids, $album->is_and)); } - // The LEFT JOIN with photo_album (added by applySearchabilityFilter/applySensitivityFilter - // for access control checks) produces one row per album membership. Since a photo can - // belong to multiple regular albums, this causes duplicate rows in the result. Adding - // DISTINCT ensures each photo appears only once. - $this->getRelationQuery()->distinct(); + $this->getRelationQuery()->whereIn('photos.id', $ids_query); } /** From 4a66bfca5f766bb662a9639d192788b4542d3a0f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:35:12 +0000 Subject: [PATCH 5/5] fix: use PhotoBuilder import instead of FQCN in PHPDoc Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/2bcb1a88-be7d-4d83-a3f9-935c031e33ae Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com> --- app/Relations/HasManyPhotosByTag.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/Relations/HasManyPhotosByTag.php b/app/Relations/HasManyPhotosByTag.php index 5026a8a0cf0..736e1102404 100644 --- a/app/Relations/HasManyPhotosByTag.php +++ b/app/Relations/HasManyPhotosByTag.php @@ -11,6 +11,7 @@ use App\Contracts\Exceptions\InternalLycheeException; use App\Enum\OrderSortingType; use App\Exceptions\Internal\NotImplementedException; +use App\Models\Builders\PhotoBuilder; use App\Models\Extensions\SortingDecorator; use App\Models\Photo; use App\Models\TagAlbum; @@ -103,7 +104,7 @@ public function addEagerConstraints(array $albums): void // By using a whereIn subquery we avoid both problems: the outer relation // query has no extra JOINs (no duplicates) and no DISTINCT (no ORDER BY // restriction). - /** @var \App\Models\Builders\PhotoBuilder $ids_query */ + /** @var PhotoBuilder $ids_query */ $ids_query = Photo::query()->select('photos.id'); if ($config_manager->getValueAsBool('TA_override_visibility')) {