Skip to content

Commit 9b11bc3

Browse files
authored
Specification for smart person albums (#4471)
1 parent adc5894 commit 9b11bc3

132 files changed

Lines changed: 3310 additions & 229 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Actions\Album;
10+
11+
use App\Exceptions\ModelDBException;
12+
use App\Exceptions\UnauthenticatedException;
13+
use App\Models\PersonAlbum;
14+
use Illuminate\Support\Facades\Auth;
15+
16+
class CreatePersonAlbum
17+
{
18+
/**
19+
* Create a new smart album based on persons.
20+
*
21+
* @param string $title
22+
* @param string[] $person_ids
23+
* @param bool $is_and
24+
*
25+
* @return PersonAlbum
26+
*
27+
* @throws ModelDBException
28+
* @throws UnauthenticatedException
29+
*/
30+
public function create(string $title, array $person_ids, bool $is_and): PersonAlbum
31+
{
32+
/** @var int */
33+
$user_id = Auth::id() ?? throw new UnauthenticatedException();
34+
35+
$album = new PersonAlbum();
36+
$album->title = $title;
37+
$album->owner_id = $user_id;
38+
$album->is_and = $is_and;
39+
$album->save();
40+
41+
$album->persons()->sync($person_ids);
42+
43+
$this->setStatistics($album);
44+
45+
return $album;
46+
}
47+
48+
private function setStatistics(PersonAlbum $album): void
49+
{
50+
$album->statistics()->create([
51+
'album_id' => $album->id,
52+
'photo_id' => null,
53+
'visit_count' => 0,
54+
'download_count' => 0,
55+
'favourite_count' => 0,
56+
'shared_count' => 0,
57+
]);
58+
}
59+
}

app/Actions/Album/Delete.php

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use App\Actions\Shop\PurchasableService;
1212
use App\Constants\AccessPermissionConstants as APC;
13+
use App\Constants\PersonAlbumPersons;
1314
use App\Constants\PhotoAlbum as PA;
1415
use App\DTO\Delete\AlbumsToBeDeletedDTO;
1516
use App\DTO\Delete\PhotosToBeDeletedDTO;
@@ -69,11 +70,17 @@ class Delete
6970
*/
7071
public function do(array $album_ids): void
7172
{
73+
$person_albums_ids = DB::table('person_albums')->whereIn('id', $album_ids)->select('id')->pluck('id')->all();
74+
75+
$this->deletePersonAlbums($person_albums_ids);
76+
77+
$album_ids = array_diff($album_ids, $person_albums_ids);
78+
7279
$tag_albums_ids = DB::table('tag_albums')->whereIn('id', $album_ids)->select('id')->pluck('id')->all();
7380

7481
$this->deleteTagAlbums($tag_albums_ids);
7582

76-
$album_ids = array_diff($album_ids, $tag_albums_ids);
83+
$album_ids = array_diff($album_ids, $tag_albums_ids, $person_albums_ids);
7784
// Nothing else to do. Woop woop.
7885
if (count($album_ids) === 0) {
7986
return;
@@ -124,10 +131,34 @@ private function deleteTagAlbums(array $tag_album_ids): void
124131
DB::table('live_metrics')->whereIn('album_id', $tag_album_ids)->delete();
125132
DB::table(APC::ACCESS_PERMISSIONS)->whereIn(APC::BASE_ALBUM_ID, $tag_album_ids)->delete();
126133
DB::table('statistics')->whereIn('album_id', $tag_album_ids)->delete();
134+
DB::table('tag_albums_tags')->whereIn('album_id', $tag_album_ids)->delete();
127135
DB::table('tag_albums')->whereIn('id', $tag_album_ids)->delete();
128136
DB::table('base_albums')->whereIn('id', $tag_album_ids)->delete();
129137
}
130138

139+
/**
140+
* Delete person albums and dependencies.
141+
*
142+
* @param array $person_album_ids
143+
*
144+
* @return void
145+
*/
146+
private function deletePersonAlbums(array $person_album_ids): void
147+
{
148+
if (count($person_album_ids) === 0) {
149+
return;
150+
}
151+
152+
$purchasable_service = resolve(PurchasableService::class);
153+
$purchasable_service->deleteMultipleAlbumPurchasables($person_album_ids);
154+
DB::table('live_metrics')->whereIn('album_id', $person_album_ids)->delete();
155+
DB::table(APC::ACCESS_PERMISSIONS)->whereIn(APC::BASE_ALBUM_ID, $person_album_ids)->delete();
156+
DB::table('statistics')->whereIn('album_id', $person_album_ids)->delete();
157+
DB::table(PersonAlbumPersons::PERSON_ALBUM_PERSONS)->whereIn(PersonAlbumPersons::ALBUM_ID, $person_album_ids)->delete();
158+
DB::table('person_albums')->whereIn('id', $person_album_ids)->delete();
159+
DB::table('base_albums')->whereIn('id', $person_album_ids)->delete();
160+
}
161+
131162
/**
132163
* We fetch all the photos that actually need to be deleted.
133164
*

app/Actions/Albums/Top.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use App\Models\Album;
2020
use App\Models\Builders\AlbumBuilder;
2121
use App\Models\Extensions\SortingDecorator;
22+
use App\Models\PersonAlbum;
2223
use App\Models\TagAlbum;
2324
use App\Models\User;
2425
use App\Policies\AlbumPolicy;
@@ -86,6 +87,17 @@ public function get(): TopAlbumDTO
8687
->orderBy($this->sorting->column, $this->sorting->order)
8788
->get();
8889

90+
/** @var BaseCollection<int,PersonAlbum> $person_albums */
91+
$person_albums = collect();
92+
if ($this->config_manager->getValueAsBool('ai_vision_face_enabled')) {
93+
$person_album_query = $this->album_query_policy
94+
->applyVisibilityFilter(PersonAlbum::query()->with(['access_permissions', 'owner']), $user);
95+
96+
$person_albums = (new SortingDecorator($person_album_query))
97+
->orderBy($this->sorting->column, $this->sorting->order)
98+
->get();
99+
}
100+
89101
$pinned_album_query = $this->album_query_policy
90102
->applyVisibilityFilter(Album::query()->with(['access_permissions', 'owner'])
91103
->joinSub(DB::table('base_albums')->select(['id', 'is_pinned'])->where('is_pinned', '=', true), 'pinned', 'pinned.id', '=', 'albums.id'), $user);
@@ -118,6 +130,7 @@ public function get(): TopAlbumDTO
118130
return new TopAlbumDTO(
119131
smart_albums: $smart_albums,
120132
tag_albums: $tag_albums,
133+
person_albums: $person_albums,
121134
pinned_albums: $pinned_albums,
122135
albums: $a->values(),
123136
shared_albums: $b->values());
@@ -132,6 +145,7 @@ public function get(): TopAlbumDTO
132145
return new TopAlbumDTO(
133146
smart_albums: $smart_albums,
134147
tag_albums: $tag_albums,
148+
person_albums: $person_albums,
135149
pinned_albums: $pinned_albums,
136150
albums: $albums);
137151
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Constants;
10+
11+
class PersonAlbumPersons
12+
{
13+
// computed table name
14+
public const PERSON_ALBUM_PERSONS = 'person_albums_persons';
15+
16+
// Id names
17+
public const PERSON_ID = 'person_albums_persons.person_id';
18+
public const ALBUM_ID = 'person_albums_persons.album_id';
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Contracts\Http\Requests;
10+
11+
use App\Models\PersonAlbum;
12+
13+
interface HasPersonAlbum extends HasBaseAlbum
14+
{
15+
/**
16+
* @return PersonAlbum|null
17+
*/
18+
public function album(): ?PersonAlbum;
19+
}

app/DTO/TopAlbumDTO.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@ class TopAlbumDTO
1717
*
1818
* @param Collection<int,\App\SmartAlbums\BaseSmartAlbum> $smart_albums
1919
* @param Collection<int,\App\Models\TagAlbum> $tag_albums
20+
* @param Collection<int,\App\Models\PersonAlbum> $person_albums
2021
* @param Collection<int,\App\Models\Album> $pinned_albums
2122
* @param Collection<int,\App\Models\Album> $albums
2223
* @param Collection<int,\App\Models\Album>|null $shared_albums
2324
*/
2425
public function __construct(
2526
public Collection $smart_albums,
2627
public Collection $tag_albums,
28+
public Collection $person_albums,
2729
public Collection $pinned_albums,
2830
public Collection $albums,
2931
public ?Collection $shared_albums = null,

app/Factories/AlbumFactory.php

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use App\Models\Album;
1515
use App\Models\BaseAlbumImpl;
1616
use App\Models\Extensions\BaseAlbum;
17+
use App\Models\PersonAlbum;
1718
use App\Models\TagAlbum;
1819
use App\Repositories\ConfigManager;
1920
use App\SmartAlbums\BaseSmartAlbum;
@@ -53,6 +54,8 @@ class AlbumFactory
5354
SmartAlbumType::MY_BEST_PICTURES->value => MyBestPicturesAlbum::class,
5455
];
5556

57+
private const PHOTOS_RELATIONS = ['photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating'];
58+
5659
public function __construct(
5760
protected readonly ConfigManager $config_manager,
5861
) {
@@ -120,13 +123,15 @@ public function findBaseAlbumOrFail(string $album_id, bool $with_relations = tru
120123
{
121124
$album_query = Album::query()->with(['access_permissions']);
122125
$tag_album_query = TagAlbum::query()->with(['access_permissions']);
126+
$person_album_query = PersonAlbum::query()->with(['access_permissions']);
123127

124128
if ($with_relations) {
125-
$album_query->with(['photos', 'children', 'children.owner', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
126-
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
129+
$album_query->with(array_merge(['children', 'children.owner'], self::PHOTOS_RELATIONS));
130+
$tag_album_query->with(array_merge(['tags'], self::PHOTOS_RELATIONS));
131+
$person_album_query->with(array_merge(['persons'], self::PHOTOS_RELATIONS));
127132
}
128133

129-
$ret = $album_query->find($album_id) ?? $tag_album_query->find($album_id);
134+
$ret = $album_query->find($album_id) ?? $tag_album_query->find($album_id) ?? $person_album_query->find($album_id);
130135
if ($ret === null) {
131136
throw (new ModelNotFoundException())->setModel(BaseAlbumImpl::class, [$album_id]);
132137
}
@@ -176,7 +181,7 @@ public function findAbstractAlbumsOrFail(array $album_ids, bool $with_relations
176181
* shall be loaded, too.
177182
* @param bool $albums_only if true, only albums are returned, not tag albums
178183
*
179-
* @return ($albums_only is true ? Collection<int,Album> : Collection<int,Album|TagAlbum>) a possibly empty list of {@link BaseAlbum}
184+
* @return ($albums_only is true ? Collection<int,Album> : Collection<int,Album|TagAlbum|PersonAlbum>) a possibly empty list of {@link BaseAlbum}
180185
*
181186
* @throws ModelNotFoundException
182187
*/
@@ -188,19 +193,24 @@ public function findBaseAlbumsOrFail(array $album_ids, bool $with_relations = tr
188193
$album_ids = array_diff(array_unique($album_ids), [null]);
189194

190195
$tag_album_query = TagAlbum::query();
196+
$person_album_query = PersonAlbum::query();
191197
$album_query = Album::query();
192198

193199
if ($with_relations) {
194-
$tag_album_query->with(['tags', 'photos', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
195-
$album_query->with(['photos', 'children', 'photos.size_variants', 'photos.statistics', 'photos.palette', 'photos.tags', 'photos.rating']);
200+
$tag_album_query->with(array_merge(['tags'], self::PHOTOS_RELATIONS));
201+
$person_album_query->with(array_merge(['persons'], self::PHOTOS_RELATIONS));
202+
$album_query->with(array_merge(['children'], self::PHOTOS_RELATIONS));
196203
}
197204

198205
/** @var ($albums_only is true ? array<int,Album> : array<int,TagAlbum>)&array */
199206
$tag_albums = $albums_only ? [] : $tag_album_query->findMany($album_ids)->all(); /** @phpstan-ignore varTag.type */
200207

208+
/** @var array<int,PersonAlbum> $person_albums */
209+
$person_albums = $albums_only ? [] : $person_album_query->findMany($album_ids)->all();
210+
201211
/** @var array<int,Album> $albums */
202212
$albums = $album_query->findMany($album_ids)->all();
203-
$result = new Collection(array_merge($tag_albums, $albums));
213+
$result = new Collection(array_merge($tag_albums, $person_albums, $albums));
204214

205215
if ($result->count() !== count($album_ids)) {
206216
throw (new ModelNotFoundException())->setModel(BaseAlbumImpl::class, $album_ids);

app/Http/Controllers/AiVision/AlbumPeopleController.php

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,10 @@ public function index(GetAlbumPersonsRequest $request): PaginatedPersonsResource
4343
->whereHas('albums', fn ($qa) => $qa->where('albums.id', $album->id))
4444
)
4545
)
46+
// Non-admin: only show searchable persons, plus the person linked to the current user
47+
->when($user?->may_administrate !== true, fn ($q) => $q->searchable($user?->id))
4648
->orderBy('name');
4749

48-
// Non-admin: only show searchable persons, plus the person linked to the current user
49-
if ($user?->may_administrate !== true) {
50-
$query->searchable($user?->id);
51-
}
52-
5350
$persons = $query->paginate(50);
5451

5552
return new PaginatedPersonsResource($persons);

app/Http/Controllers/AiVision/PeopleController.php

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,21 @@
88

99
namespace App\Http\Controllers\AiVision;
1010

11+
use App\Constants\PersonAlbumPersons;
1112
use App\Http\Requests\Person\DestroyPersonRequest;
1213
use App\Http\Requests\Person\ListPersonsRequest;
1314
use App\Http\Requests\Person\ShowPersonRequest;
1415
use App\Http\Requests\Person\StorePersonRequest;
1516
use App\Http\Requests\Person\UpdatePersonRequest;
1617
use App\Http\Resources\Collections\PaginatedPersonsResource;
1718
use App\Http\Resources\Models\PersonResource;
19+
use App\Jobs\CleanupOrphanedPersonAlbumsJob;
20+
use App\Models\Face;
1821
use App\Models\Person;
1922
use App\Repositories\ConfigManager;
2023
use Illuminate\Routing\Controller;
2124
use Illuminate\Support\Facades\Auth;
25+
use Illuminate\Support\Facades\DB;
2226

2327
/**
2428
* CRUD controller for Person records.
@@ -34,18 +38,9 @@ class PeopleController extends Controller
3438
public function index(ListPersonsRequest $_request): PaginatedPersonsResource
3539
{
3640
$user = Auth::user();
37-
$query = Person::query()->orderBy('name');
38-
39-
if ($user === null || !$user->may_administrate) {
40-
// Non-admin: only show searchable persons, plus the person linked to the current user
41-
$user_id = $user?->id;
42-
$query->where(function ($q) use ($user_id): void {
43-
$q->where('is_searchable', '=', true);
44-
if ($user_id !== null) {
45-
$q->orWhere('user_id', '=', $user_id);
46-
}
47-
});
48-
}
41+
$query = Person::query()
42+
->when($user?->may_administrate !== true, fn ($q) => $q->searchable($user?->id))
43+
->orderBy('name');
4944

5045
$persons = $query->paginate(50);
5146

@@ -112,6 +107,14 @@ public function update(UpdatePersonRequest $request): PersonResource
112107
public function destroy(DestroyPersonRequest $request): void
113108
{
114109
$person = Person::findOrFail($request->personId());
110+
// Break the link between the person and its person albums
111+
DB::table(PersonAlbumPersons::PERSON_ALBUM_PERSONS)->where('person_id', '=', $person->id)->delete();
112+
// detach all faces from the person and mark them as dismissed
113+
Face::where('person_id', '=', $person->id)->update(['person_id' => null, 'is_dismissed' => true]);
114+
115+
// Delete the person record (we should be clean).
115116
$person->delete();
117+
118+
CleanupOrphanedPersonAlbumsJob::dispatch();
116119
}
117120
}

0 commit comments

Comments
 (0)