<?php namespace App\Models\Abstracted; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Storage; use App\Casts\PostgresBytea; use App\Jobs\CatalogMove; use App\Models\{Make,Person,Software,Tag}; abstract class Catalog extends Model { protected static $includeSubSecTime = FALSE; protected $casts = [ 'created_manual' => 'datetime', 'subsectime' => 'int', 'thumbnail' => PostgresBytea::class, ]; public const fs = 'nas'; private ?string $move_reason; protected array $init = []; /* STATIC */ public static function boot() { parent::boot(); // Any video saved, queue it to be moved. self::saved(function($item) { if ($item->scanned && (! $item->duplicate) && (! $item->remove) && ($item->shouldMove() === TRUE)) { Log::info(sprintf('Need to Move [%s] to [%s]',$item->file_name_rel(),$item->file_name_rel(FALSE))); CatalogMove::dispatch($item) ->onQueue('move'); } }); } /** * Return the prefix for the file path - dependent on the object * * @return string */ public static function dir_prefix(): string { return config(static::config.'.dir').'/'; } /* RELATIONS */ /** * People in Multimedia Object * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function people() { return $this->belongsToMany(Person::class); } /** * Software used to create Multimedia Object * * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ public function software() { return $this->belongsTo(Software::class); } /** * Tags added to Multimedia Object * * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany */ public function tags() { return $this->belongsToMany(Tag::class); } /* SCOPES */ /** * Find records marked as duplicate * * @param $query * @return mixed */ public function scopeDuplicates($query) { return $query->notRemove() ->where('duplicate',TRUE) ->where(fn($q)=>$q->where('ignore_duplicate','<>',TRUE)->orWhereNull('ignore_duplicate')); } /** * Search Database for duplicates of this object * * @param $query * @return mixed */ public function scopeMyDuplicates($query) { if (! $this->exists) return $query; // Exclude this record $query->where('id','<>',$this->attributes['id']); // Skip ignore dups $query->where(fn($q)=>$q->whereNull('ignore_duplicate') ->orWhere('ignore_duplicate',FALSE)); $query ->where(function($q) { $q->when($this->attributes['signature'], fn($q)=>$q->where('signature','=',$this->attributes['signature'])) ->orWhere('file_signature','=',$this->attributes['file_signature']); }) // Where the signature is the same ->orWhere(function($q) { // Or they have the same time taken with the same camera if ($this->attributes['created'] && $this->software_id) { $q->where(fn($q)=>$q->where('created','=',$this->attributes['created']) ->orWhere('created_manual','=',$this->attributes['created'])); if (static::$includeSubSecTime) $q->where('subsectime','=',Arr::get($this->attributes,'subsectime')); $q->where('software_id','=',$this->attributes['software_id']); } elseif ($this->attributes['created_manual'] && $this->software_id) { $q->where(fn($q)=>$q->where('created','=',$this->attributes['created_manual']) ->orWhere('created_manual','=',$this->attributes['created_manual'])); if (static::$includeSubSecTime) $q->where('subsectime','=',Arr::get($this->attributes,'subsectime')); $q->where('software_id','=',$this->attributes['software_id']); } }) ->orderBy('id'); return $query; } /** * Multimedia NOT duplicate. * * @return \Illuminate\Database\Eloquent\Builder */ public function scopeNotDuplicate($query) { return $query->where( fn($q)=>$q->where('duplicate','<>',TRUE) ->orWhere('duplicate','=',NULL) ->orWhere(fn($q)=>$q->where('duplicate','=',TRUE)->where('ignore_duplicate','=',TRUE)) ); } /** * Multimedia NOT pending removal. * * @return \Illuminate\Database\Eloquent\Builder */ public function scopeNotRemove($query) { return $query->where(fn($q)=>$q->where('remove','<>',TRUE)->orWhere('remove','=',NULL)); } /** * Multimedia NOT scanned. * * @return \Illuminate\Database\Eloquent\Builder */ public function scopeNotScanned($query) { return $query->where(fn($q)=>$q->where('scanned','<>',TRUE)->orWhere('scanned','=',NULL)); } /* ABSTRACTS */ abstract public function getObjectOriginal(string $property): mixed; /* ATTRIBUTES */ /** * Return the time the media was created on the device * * This will be (in priority order) * + the value of created_manual (Carbon) * + the value of created * * @param string|null $date * @return Carbon|null */ public function getCreatedAttribute(string $date=NULL): ?Carbon { $result = $this->created_manual ?: ($date ? Carbon::create($date) : NULL); if ($result && static::$includeSubSecTime) $result->microseconds($this->subsectime*1000); return $result ?: $this->getObjectOriginal('creation_date'); } /** * What device was the multimedia created on * * @return string */ public function getDeviceAttribute(): string { $result = ''; if ($this->software_id) { if ($this->software->model_id) { if ($this->software->model->make_id) { $result .= $this->software->model->make->name; } $result .= ($result ? ' ' : '').$this->software->model->name; } $result .= ($result ? ' ' : '').$this->software->name; } return $result; } /** * Return item dimensions */ public function getDimensionsAttribute(): string { return $this->width.'x'.$this->height; } /** * Return the file size * * @return int|null */ public function getFileSizeAttribute(): ?int { return (! $this->isReadable()) ? NULL : filesize($this->file_name(FALSE)); } public function getGPSAttribute(): ?string { return ($this->gps_lat && $this->gps_lon) ? sprintf('%s/%s',$this->gps_lat,$this->gps_lon) : NULL; } /* METHODS */ /** * Return the filename. * * If $short is TRUE, it is the RELATIVE filename that it should be called (and can be compared to $this->filename, * to see if it is in the wrong place). This path (like filename) is without the DIR prefix, and DIR location. * If $short is FALSE, it is the FULL path of the actual file (where $this->filename is the RELATIVE path, * without the DIR prefix, and DIR location) * * @param bool $short * @return string */ public function file_name(bool $short=TRUE): string { if ($short || preg_match('#^/#',$this->filename)) { // If the date created is not set, the file name will be based on the ID of the file. $file = sprintf('%s.%s', (is_null($this->created) ? sprintf('UNKNOWN/%07s',$this->file_path_id()) : $this->created->format('Y/m/d-His'). ($this->subsectime ? sprintf('_%03d',$this->subsectime) : '' ). sprintf('-%05s',$this->id)), $this->type() ); return $file; } else return Storage::disk(self::fs) ->path($this->file_name_rel()); } /** * Return the path relative to our HTML root * * When $source is TRUE, this is the current filename with the DIR prefix added, without the DIR location, * When $source is FALSE, this is the NEW filename, with the DIR prefix added, without the DIR location. * * @param bool $source * @return string */ public function file_name_rel(bool $source=TRUE): string { return config(static::config.'.dir').DIRECTORY_SEPARATOR.($source ? $this->filename : $this->file_name()); } /** * Calculate a file path ID based on the id of the file * * We use this when we cannot determine the create time of the image */ public function file_path_id($sep=3,$depth=9): string { return trim(chunk_split(sprintf("%0{$depth}s",$this->id),$sep,'/'),'/'); } /** * Set values from the media object * * @return void * @throws \Exception */ public function init(): void { foreach ($this->init as $item) { switch ($item) { case 'creation_date': $this->created = $this->getObjectOriginal('creation_date'); break; case 'gps': $this->gps_lat = $this->getObjectOriginal('gps_lat'); $this->gps_lon = $this->getObjectOriginal('gps_lon'); break; case 'heightwidth': $this->height = $this->getObjectOriginal('height'); $this->width = $this->getObjectOriginal('width'); break; case 'signature': $this->signature = $this->getObjectOriginal('signature'); $this->file_signature = $this->getObjectOriginal('file_signature'); break; case 'software': $ma = NULL; if ($x=$this->getObjectOriginal('make')) $ma = Make::firstOrCreate([ 'name'=>$x, ]); $mo = \App\Models\Model::firstOrCreate([ 'name'=>$this->getObjectOriginal('model') ?: NULL, 'make_id'=>$ma?->id, ]); $so = Software::firstOrCreate([ 'name'=>$this->getObjectOriginal('software') ?: NULL, 'model_id'=>$mo->id, ]); $this->software_id = $so->id; break; case 'subsectime': $this->subsectime = $this->getObjectOriginal($item); break; default: throw new \Exception('Unknown init item: '.$item); } } $this->custom_init(); } /** * Does the file require moving * * @return bool */ public function isMoveable(): bool { // No change to the name $this->move_reason = 'Filenames match already'; if ($this->filename === $this->file_name()) return FALSE; // If there is already a file in the target. // @todo If the target file is to be deleted, we could move this file $this->move_reason = 'Target file exists'; if (Storage::disk(self::fs)->exists($this->file_name())) return FALSE; // Test if the source is readable $this->move_reason = 'Source is not readable'; if (! $this->isReadable()) return FALSE; // Test if the dir is writable (so we can remove the file) $this->move_reason = 'Source parent dir not writable'; if (! $this->isParentWritable(dirname($this->file_name_rel(TRUE)))) return FALSE; // Test if the target dir is writable // @todo The target dir may not exist yet, so we should check that a parent exists and is writable. $this->move_reason = 'Target parent dir is not writable'; if (! $this->isParentWritable(dirname($this->file_name_rel(FALSE)))) return FALSE; // Otherwise we can move it $this->move_reason = NULL; return TRUE; } public function isMoveableReason(): ?string { return $this->move_reason ?? NULL; } /** * Determine if the parent dir is writable. * * $dir should be a relative path, with our DIR prefix (from dirname($this->file_name_rel())) * * @param string $dir * @return bool */ public function isParentWritable(string $dir): bool { $path = Storage::disk(self::fs)->path($dir); if (Storage::disk(self::fs)->exists($dir) && is_dir($path) && is_writable($path)) return TRUE; elseif ($dir === '.') return FALSE; else return ($this->isParentWritable(dirname($dir))); } /** * Return if this source file is readable. * * @return bool */ public function isReadable(): bool { return is_readable($this->file_name(FALSE)); } /** * Get the id of the next record */ public function next(): ?self { return static::where('id','>',$this->id) ->orderby('id','ASC') ->first(); } /** * Get the id of the previous record */ public function previous(): ?self { return static::where('id','<',$this->id) ->orderby('id','DESC') ->first(); } /** * Display the media signature */ public function signature($short=FALSE) { return ($short && $this->signature) ? stringtrim($this->signature) : $this->signature; } /** * Determine if the image should be moved */ public function shouldMove(): bool { return $this->filename !== $this->file_name(); } /** * Find duplicate images based on some attributes of the current image */ private function list_duplicates($includeme=FALSE) { $o = static::select(); if ($this->id AND ! $includeme) $o->where('id','!=',$this->id); // Ignore photo's pending removal. if (! $includeme) $o->where(function($query) { $query->where('remove','<>',TRUE) ->orWhere('remove','=',NULL); }); // Where the signalist_duplicatesture is the same $o->where(function($query) { $query->where('signature','=',$this->signature); // Or they have the same time taken with the same camera if ($this->date_created AND ($this->model OR $this->make)) { $query->orWhere(function($query) { $query->where('date_created','=',$this->date_created ? $this->date_created : NULL); if (Schema::hasColumn($this->getTable(),'subsectime')) $query->where('subsectime','=',$this->subsectime ?: NULL); if (! is_null($this->model)) $query->where('model','=',$this->model); if (! is_null($this->make)) $query->where('make','=',$this->make); }); } }); return $o->get(); } }