diff --git a/app/Importers/Image.php b/app/Importers/Image.php new file mode 100644 index 0000000..5cf95d6 --- /dev/null +++ b/app/Importers/Image.php @@ -0,0 +1,34 @@ +guessExtension(), $supportedMimes); + } + + public function __construct(private UploadedFile $file, private Album $location) + { + } + + public function import(): array { + $image = ImageModel::create([ + 'album_id' => $this->location->id + ]); + $imageId = $image->id; + + return [ + new ImportImage($this->file->getPathname(), $image), + new GenerateFullscreen($image), + new GenerateThumbnail($image), + ]; + } +} \ No newline at end of file diff --git a/app/Importers/Image/Jobs/GenerateFullscreen.php b/app/Importers/Image/Jobs/GenerateFullscreen.php new file mode 100644 index 0000000..bfbd9c0 --- /dev/null +++ b/app/Importers/Image/Jobs/GenerateFullscreen.php @@ -0,0 +1,58 @@ +source = Storage::disk('images')->path($this->image->album_id . '/original/' . $this->image->id . '.avif'); + $this->destination = $this->image->album_id . '/lightbox/' . $this->image->id . '.avif'; + } + + public function handle(): void + { + if ($this->batch()->cancelled()) { + return; + } + + $image = InterventionImage::read($this->source); + if($image->width() >= $image->height()) { + // landscape + $image->scaleDown(width: config('gallery.image.fullscreen.maxWidth', 2000)); + } else { + // portrait + $image->scaleDown(height: config('gallery.image.fullscreen.maxHeight', 2000)); + } + + Storage::disk('images')->put($this->destination, $image->toAvif(config('gallery.image.quality', 80))); + } + + public function failed(?Throwable $exception): void + { + $this->image->delete(); + + Log::error($exception, [ + 'image' => $this->image, + 'source' => $this->source, + 'destination' => $this->destination + ]); + } +} diff --git a/app/Importers/Image/Jobs/GenerateThumbnail.php b/app/Importers/Image/Jobs/GenerateThumbnail.php new file mode 100644 index 0000000..e43e26a --- /dev/null +++ b/app/Importers/Image/Jobs/GenerateThumbnail.php @@ -0,0 +1,58 @@ +source = Storage::disk('images')->path($this->image->album_id . '/original/' . $this->image->id . '.avif'); + $this->destination = $this->image->album_id . '/thumbnail/' . $this->image->id . '.avif'; + } + + public function handle(): void + { + if ($this->batch()->cancelled()) { + return; + } + + $image = InterventionImage::read($this->source); + if($image->width() >= $image->height()) { + // landscape + $image->scaleDown(width: config('gallery.image.thumbnail.maxWidth', 150)); + } else { + // portrait + $image->scaleDown(height: config('gallery.image.thumbnail.maxHeight', 150)); + } + + Storage::disk('images')->put($this->destination, $image->toAvif(config('gallery.image.quality', 80))); + } + + public function failed(?Throwable $exception): void + { + $this->image->delete(); + + Log::error($exception, [ + 'image' => $this->image, + 'source' => $this->source, + 'destination' => $this->destination + ]); + } +} diff --git a/app/Importers/Image/Jobs/ImportImage.php b/app/Importers/Image/Jobs/ImportImage.php new file mode 100644 index 0000000..2d1eda8 --- /dev/null +++ b/app/Importers/Image/Jobs/ImportImage.php @@ -0,0 +1,40 @@ +batch()->cancelled()) { + return; + } + + $encoded = InterventionImage::read($this->tmpFile)->toAvif(config('gallery.image.quality', 80)); + Storage::disk('images')->put("{$this->image->album->id}/original/{$this->image->id}.avif", $encoded); + } + + public function failed(?Throwable $exception): void + { + Log::error($exception, ['image' => $this->image, 'tmpFile' => $this->tmpFile]); + $this->image->delete(); + } +} diff --git a/app/ImportsMedia.php b/app/ImportsMedia.php new file mode 100644 index 0000000..07d469b --- /dev/null +++ b/app/ImportsMedia.php @@ -0,0 +1,13 @@ +isImage()) { + $image = Image::create([ + 'album' => $this->album, + 'isCover' => false + ]); + $encoded = InterventionImage::read($this->file)->toAvif(config('gallery.original.quality', 80)); + Storage::disk('images')->put("{$$this->album->id}/original/{$image->id}.avif"); + } + } + + +} diff --git a/app/Livewire/Drawer/Album/ProgressMonitor.php b/app/Livewire/Drawer/Album/ProgressMonitor.php new file mode 100644 index 0000000..11b3525 --- /dev/null +++ b/app/Livewire/Drawer/Album/ProgressMonitor.php @@ -0,0 +1,56 @@ +mutation = $mutation; + $this->currentUrl = url()->current(); + } + + public function monitorProgress() : void { + $batch = Bus::findBatch($this->mutation->batch_id); + $this->progress = $batch->progress(); + $this->totalJobs = $batch->totalJobs; + $this->processedJobs = $batch->processedJobs(); + $this->failedJobs = $batch->failedJobs; + + if($batch == null || $batch->finished()) { + $this->mutation->delete(); + $this->redirect($this->currentUrl, navigate: true); + } + } + + public function render(): View|Factory + { + $this->monitorProgress(); + return view('livewire.drawer.album.progress-monitor'); + } +} diff --git a/app/Models/BatchMutation.php b/app/Models/BatchMutation.php new file mode 100644 index 0000000..51dedce --- /dev/null +++ b/app/Models/BatchMutation.php @@ -0,0 +1,24 @@ +belongsTo(Album::class); + } + + public function getBatchAttribute() : Batch { + return Bus::findBatch($this->batchId); + } +} diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 0000000..9811982 --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,36 @@ +email, [ + // + ]); + }); + } +} diff --git a/app/Providers/MediaImporterServiceProvider.php b/app/Providers/MediaImporterServiceProvider.php new file mode 100644 index 0000000..e993802 --- /dev/null +++ b/app/Providers/MediaImporterServiceProvider.php @@ -0,0 +1,25 @@ +app->singleton(MediaImporter::class, function (Application $app) { + return new MediaImporter(); + }); + } + + public function boot(MediaImporter $service): void + { + $importers = config('gallery.importers'); + foreach ($importers as $importer) { + $service->register($importer); + } + } +} diff --git a/app/Services/MediaImporter.php b/app/Services/MediaImporter.php new file mode 100644 index 0000000..da27b21 --- /dev/null +++ b/app/Services/MediaImporter.php @@ -0,0 +1,29 @@ +importers, $importer); + } + } + + public function import(UploadedFile $file, Album $location): array { + foreach ($this->importers as $importer) { + if($importer::supports($file)) { + return (new $importer($file, $location))->import(); + } + } + + throw new UnsupportedMediaTypeHttpException("The provided MediaType is not supported"); + } +} \ No newline at end of file diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..ebc3fb9 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,82 @@ + env('BROADCAST_CONNECTION', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over WebSockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/config/gallery.php b/config/gallery.php new file mode 100644 index 0000000..8c5b51d --- /dev/null +++ b/config/gallery.php @@ -0,0 +1,11 @@ + [ + \App\Importers\Image::class, + ], + 'image' => [ + 'suffixes' => ['png', 'gif', 'bmp', 'svg', 'avi', 'jpg', 'jpeg'], + 'quality' => 80, + ] +]; \ No newline at end of file diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 0000000..5101f6f --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,213 @@ + env('HORIZON_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('HORIZON_PATH', 'horizon'), + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env( + 'HORIZON_PREFIX', + Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' + ), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:default' => 60, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Silenced Jobs + |-------------------------------------------------------------------------- + | + | Silencing a job will instruct Horizon to not place the job in the list + | of completed jobs within the Horizon dashboard. This setting may be + | used to fully remove any noisy jobs from the completed jobs list. + | + */ + + 'silenced' => [ + // App\Jobs\ExampleJob::class, + ], + + /* + |-------------------------------------------------------------------------- + | Metrics + |-------------------------------------------------------------------------- + | + | Here you can configure how many snapshots should be kept to display in + | the metrics graph. This will get used in combination with Horizon's + | `horizon:snapshot` schedule to define how long to retain metrics. + | + */ + + 'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon master + | supervisor may consume before it is terminated and restarted. For + | configuring these limits on your workers, see the next section. + | + */ + + 'memory_limit' => 64, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'defaults' => [ + 'supervisor-1' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 128, + 'tries' => 1, + 'timeout' => 60, + 'nice' => 0, + ], + ], + + 'environments' => [ + 'production' => [ + 'supervisor-1' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], + ], + + 'local' => [ + 'supervisor-1' => [ + 'maxProcesses' => 3, + ], + ], + ], +]; diff --git a/config/image.php b/config/image.php new file mode 100644 index 0000000..858cdfa --- /dev/null +++ b/config/image.php @@ -0,0 +1,21 @@ + \Intervention\Image\Drivers\Gd\Driver::class + +]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..798ead6 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,91 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + ], + ], + + ], + +]; diff --git a/database/migrations/2024_05_28_153156_create_batch_mutations_table.php b/database/migrations/2024_05_28_153156_create_batch_mutations_table.php new file mode 100644 index 0000000..3af3eab --- /dev/null +++ b/database/migrations/2024_05_28_153156_create_batch_mutations_table.php @@ -0,0 +1,31 @@ +id(); + $table->timestamps(); + $table->string('batch_id'); + $table->integer('files'); + $table->foreignIdFor(Album::class); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('import_batch'); + } +}; diff --git a/resources/views/components/icons/success.blade.php b/resources/views/components/icons/success.blade.php new file mode 100644 index 0000000..a118f7a --- /dev/null +++ b/resources/views/components/icons/success.blade.php @@ -0,0 +1,3 @@ +merge(['class' => "flex-shrink-0 text-green-500 dark:text-green-400"]) }} aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 20"> + + \ No newline at end of file diff --git a/resources/views/components/ui/spinner.blade.php b/resources/views/components/ui/spinner.blade.php new file mode 100644 index 0000000..8d37e56 --- /dev/null +++ b/resources/views/components/ui/spinner.blade.php @@ -0,0 +1,7 @@ +
+ + Loading... +
\ No newline at end of file diff --git a/resources/views/livewire/drawer/album/progress-monitor.blade.php b/resources/views/livewire/drawer/album/progress-monitor.blade.php new file mode 100644 index 0000000..1cf9b46 --- /dev/null +++ b/resources/views/livewire/drawer/album/progress-monitor.blade.php @@ -0,0 +1,20 @@ +
+ +
+
+ Album wird verarbeitet +
+

+ Die Dateien in diesem Album wurden geändert.
+ Bitte warte einen moment bis Verarbeitung abgeschlossen ist. +

+ +
+ Aufgabe {{ $processedJobs }} von {{ $totalJobs }} + {{ $progress }}% +
+
+
+
+
+
\ No newline at end of file diff --git a/routes/channels.php b/routes/channels.php new file mode 100644 index 0000000..df2ad28 --- /dev/null +++ b/routes/channels.php @@ -0,0 +1,7 @@ +id === (int) $id; +});