Skip to content
Closed
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
83 changes: 83 additions & 0 deletions app/Console/Commands/RescanFailedFaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

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

namespace App\Console\Commands;

use App\Enum\FaceScanStatus;
use App\Jobs\DispatchFaceScanJob;
use App\Models\Photo;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;

/**
* Artisan command for maintenance re-scanning of failed photos.
*
* Usage:
* php artisan lychee:rescan-failed-faces
* — re-enqueue all photos with face_scan_status = 'failed'
* php artisan lychee:rescan-failed-faces --stuck-pending --older-than=60
* — also reset photos stuck in 'pending' for > N minutes back to null,
* making them eligible for a fresh scan
*/
class RescanFailedFaces extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lychee:rescan-failed-faces
{--stuck-pending : Also reset photos stuck in pending state}
{--older-than=60 : Minutes threshold for stuck-pending reset}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Re-enqueue failed face scan photos; optionally reset stuck pending records.';

/**
* Execute the console command.
*/
public function handle(): int
{
$reset_count = 0;
$dispatched = 0;

// Reset stuck-pending records if requested
if ($this->option('stuck-pending') === true) {
$older_than = (int) $this->option('older-than');
$cutoff = Carbon::now()->subMinutes($older_than);

$reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value)
->where('updated_at', '<', $cutoff)
->update(['face_scan_status' => null]);

$this->info("Reset {$reset_count} stuck-pending photo(s) older than {$older_than} minutes.");
Log::info("lychee:rescan-failed-faces reset {$reset_count} stuck-pending records.");
}

// Re-enqueue failed photos
Photo::query()
->select('id')
->where('face_scan_status', '=', FaceScanStatus::FAILED->value)
->lazyById(200, 'id')
->each(function (Photo $photo) use (&$dispatched): void {
Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]);
DispatchFaceScanJob::dispatch($photo->id);
$dispatched++;
});

$this->info("Dispatched {$dispatched} re-scan job(s) for failed photos.");
Log::info("lychee:rescan-failed-faces dispatched {$dispatched} re-scan jobs.");

return Command::SUCCESS;
}
}
67 changes: 67 additions & 0 deletions app/Console/Commands/ScanFaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

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

namespace App\Console\Commands;

use App\Enum\FaceScanStatus;
use App\Jobs\DispatchFaceScanJob;
use App\Models\Photo;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

/**
* Artisan command to enqueue photos for face detection.
*
* Usage:
* php artisan lychee:scan-faces — all unscanned photos
* php artisan lychee:scan-faces --album={id} — only direct photos in given album
*/
class ScanFaces extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'lychee:scan-faces
{--album= : Album ID — only direct photos in this album (non-recursive)}';

/**
* The console command description.
*
* @var string
*/
protected $description = 'Enqueue unscanned photos for AI Vision face detection.';

/**
* Execute the console command.
*/
public function handle(): int
{
$album_id = $this->option('album');

$query = Photo::query()->select('id')->whereNull('face_scan_status');

if ($album_id !== null) {
$query->whereHas('albums', fn ($q) => $q->where('albums.id', '=', $album_id));
}

$dispatched = 0;

$query->lazyById(200, 'id')->each(function (Photo $photo) use (&$dispatched): void {
Photo::where('id', '=', $photo->id)->update(['face_scan_status' => FaceScanStatus::PENDING->value]);
DispatchFaceScanJob::dispatch($photo->id);
$dispatched++;
});

$this->info("Dispatched {$dispatched} face scan job(s).");
Log::info("lychee:scan-faces dispatched {$dispatched} jobs.");

return Command::SUCCESS;
}
}
16 changes: 16 additions & 0 deletions app/Contracts/Http/Requests/HasFace.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

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

namespace App\Contracts\Http\Requests;

use App\Models\Face;

interface HasFace
{
public function face(): Face;
}
16 changes: 16 additions & 0 deletions app/Contracts/Http/Requests/HasPerson.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

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

namespace App\Contracts\Http\Requests;

use App\Models\Person;

interface HasPerson
{
public function person(): Person;
}
17 changes: 17 additions & 0 deletions app/Enum/FacePermissionMode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

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

namespace App\Enum;

enum FacePermissionMode: string
{
case PUBLIC = 'public';
case PRIVATE = 'private';
case PRIVACY_PRESERVING = 'privacy-preserving';
case RESTRICTED = 'restricted';
}
16 changes: 16 additions & 0 deletions app/Enum/FaceScanStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

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

namespace App\Enum;

enum FaceScanStatus: string
{
case PENDING = 'pending';
case COMPLETED = 'completed';
case FAILED = 'failed';
}
59 changes: 59 additions & 0 deletions app/Http/Controllers/Admin/Maintenance/ResetStuckFaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

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

namespace App\Http\Controllers\Admin\Maintenance;

use App\Enum\FaceScanStatus;
use App\Http\Requests\Maintenance\MaintenanceRequest;
use App\Http\Requests\Maintenance\ResetStuckFacesRequest;
use App\Models\Photo;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;

/**
* Admin maintenance controller to detect and reset photos stuck in the 'pending'
* face scan state (e.g. after a worker crash).
*
* GET /Maintenance::resetStuckFaces — returns count of stuck records
* POST /Maintenance::resetStuckFaces — resets stuck records back to null
*/
class ResetStuckFaces extends Controller
{
/** @var int Default age in minutes before a pending scan is considered stuck */
private const DEFAULT_OLDER_THAN = 720; // 12 hours

/**
* Check: return count of photos stuck in 'pending' longer than the threshold.
*
* @return int
*/
public function check(MaintenanceRequest $_request): int
{
$cutoff = Carbon::now()->subMinutes(self::DEFAULT_OLDER_THAN);

return Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value)
->where('updated_at', '<', $cutoff)
->count();
}

/**
* Do: reset stuck pending photos back to null and return the count reset.
*
* @return array{reset_count: int}
*/
public function do(ResetStuckFacesRequest $request): array
{
$cutoff = Carbon::now()->subMinutes($request->olderThanMinutes());

$reset_count = Photo::where('face_scan_status', '=', FaceScanStatus::PENDING->value)
->where('updated_at', '<', $cutoff)
->update(['face_scan_status' => null]);

return ['reset_count' => $reset_count];
}
}
96 changes: 96 additions & 0 deletions app/Http/Controllers/AiVision/FaceController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

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

namespace App\Http\Controllers\AiVision;

use App\Http\Requests\Face\AssignFaceRequest;
use App\Http\Requests\Face\DestroyDismissedFacesRequest;
use App\Http\Requests\Face\ToggleDismissedRequest;
use App\Http\Resources\Models\FaceResource;
use App\Models\Face;
use App\Models\Person;
use App\Repositories\ConfigManager;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;

/**
* Controller for Face assignment, dismiss, and cleanup operations.
*/
class FaceController extends Controller
{
/**
* Assign a face to an existing person or create a new person.
*
* POST /Face/{id}/assign
*
* @return FaceResource
*/
public function assign(AssignFaceRequest $request, string $id): FaceResource
{
$face = $request->face();

if ($request->person() !== null) {
$face->person_id = $request->person()->id;
} else {
$is_searchable_default = app(ConfigManager::class)->getValueAsBool('ai_vision_face_person_is_searchable_default');
$person = new Person();
$person->name = $request->newPersonName();
$person->is_searchable = $is_searchable_default;
$person->save();
$face->person_id = $person->id;
}

$face->save();

return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person']));
}

/**
* Toggle the is_dismissed flag on a face.
* Only the photo owner or admin can dismiss/undismiss.
*
* PATCH /Face/{id}
*
* @return FaceResource
*/
public function toggleDismissed(ToggleDismissedRequest $request, string $id): FaceResource
{
$face = $request->face();
$face->is_dismissed = !$face->is_dismissed;
$face->save();

return FaceResource::fromModel($face->load(['suggestions.suggestedFace.person', 'person']));
}

/**
* Hard-delete all dismissed faces and remove their crop files.
* Admin-only.
*
* DELETE /Face/dismissed
*
* @return array{deleted_count: int}
*/
public function destroyDismissed(DestroyDismissedFacesRequest $_request): array
{
$dismissed_faces = Face::where('is_dismissed', '=', true)->get();
$count = 0;

foreach ($dismissed_faces as $face) {
// Delete crop file
if ($face->crop_token !== null) {
$tok = $face->crop_token;
$path = 'faces/' . substr($tok, 0, 2) . '/' . substr($tok, 2, 2) . '/' . $tok . '.jpg';
Storage::disk('images')->delete($path);
}
$face->delete();
$count++;
}

return ['deleted_count' => $count];
}
}
Loading
Loading