mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Add image caching for playlist items
Move current_image caching to plugins Remove redundant check Add test for plugin cache Skip puppeteer on GH actions
This commit is contained in:
parent
faaccdc6fc
commit
580a5833a8
7 changed files with 247 additions and 82 deletions
81
app/Jobs/CommonFunctions.php
Normal file
81
app/Jobs/CommonFunctions.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
class CommonFunctions
|
||||
{
|
||||
public static function generateImage(string $markup): string {
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
|
||||
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
|
||||
|
||||
// Generate PNG
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
try {
|
||||
BrowsershotLambda::html($markup)
|
||||
->windowSize($device->width ?? 800, $device->height ?? 480)
|
||||
->save($pngPath);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
Browsershot::html($markup)
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
|
||||
->windowSize($device->width ?? 800, $device->height ?? 480)
|
||||
->save($pngPath);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
CommonFunctions::convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
} catch (\ImagickException $e) {
|
||||
throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ImagickException
|
||||
*/
|
||||
private static function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
|
||||
{
|
||||
$imagick = new \Imagick($pngPath);
|
||||
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
$imagick->stripImage();
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
$imagick->clear();
|
||||
}
|
||||
|
||||
public static function cleanupFolder(): void
|
||||
{
|
||||
$activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
$activePluginImageUuids = Plugin::pluck('current_image')->filter()->toArray();
|
||||
$activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids);
|
||||
|
||||
$files = Storage::disk('public')->files('/images/generated/');
|
||||
foreach ($files as $file) {
|
||||
if (basename($file) === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
// Get filename without path and extension
|
||||
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
|
||||
// If the UUID is not in use by any device, move it to archive
|
||||
if (! in_array($fileUuid, $activeImageUuids)) {
|
||||
Storage::disk('public')->delete($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
app/Jobs/GeneratePluginJob.php
Normal file
37
app/Jobs/GeneratePluginJob.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class GeneratePluginJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly int $pluginId,
|
||||
private readonly string $markup
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$newImageUuid = CommonFunctions::generateImage($this->markup);
|
||||
|
||||
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
|
||||
\Log::info("Plugin $this->pluginId: updated with new image: $newImageUuid");
|
||||
|
||||
CommonFunctions::cleanupFolder();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8,10 +8,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
|||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
class GenerateScreenJob implements ShouldQueue
|
||||
{
|
||||
|
|
@ -30,72 +26,11 @@ class GenerateScreenJob implements ShouldQueue
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$device = Device::find($this->deviceId);
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
|
||||
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
|
||||
$newImageUuid = CommonFunctions::generateImage($this->markup);
|
||||
|
||||
// Generate PNG
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
try {
|
||||
BrowsershotLambda::html($this->markup)
|
||||
->windowSize($device->width ?? 800, $device->height ?? 480)
|
||||
->save($pngPath);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
Browsershot::html($this->markup)
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
|
||||
->windowSize($device->width ?? 800, $device->height ?? 480)
|
||||
->save($pngPath);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
|
||||
\Log::info("Device $this->deviceId: updated with new image: $newImageUuid");
|
||||
|
||||
try {
|
||||
$this->convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
} catch (\ImagickException $e) {
|
||||
throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
$device->update(['current_screen_image' => $uuid]);
|
||||
\Log::info("Device $this->deviceId: updated with new image: $uuid");
|
||||
|
||||
$this->cleanupFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ImagickException
|
||||
*/
|
||||
private function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
|
||||
{
|
||||
$imagick = new \Imagick($pngPath);
|
||||
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
$imagick->stripImage();
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
$imagick->clear();
|
||||
}
|
||||
|
||||
private function cleanupFolder(): void
|
||||
{
|
||||
$activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
|
||||
$files = Storage::disk('public')->files('/images/generated/');
|
||||
foreach ($files as $file) {
|
||||
if (basename($file) === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
// Get filename without path and extension
|
||||
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
|
||||
// If the UUID is not in use by any device, move it to archive
|
||||
if (! in_array($fileUuid, $activeImageUuids)) {
|
||||
Storage::disk('public')->delete($file);
|
||||
}
|
||||
}
|
||||
CommonFunctions::cleanupFolder();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class Plugin extends Model
|
|||
'data_payload' => 'json',
|
||||
'data_payload_updated_at' => 'datetime',
|
||||
'is_native' => 'boolean',
|
||||
'current_image' => 'string',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
|
|
|
|||
28
database/migrations/2025_05_10_182724_add_plugin_cache.php
Normal file
28
database/migrations/2025_05_10_182724_add_plugin_cache.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->string('current_image')->nullable()->after('data_payload_updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('current_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\GenerateScreenJob;
|
||||
use App\Jobs\GeneratePluginJob;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -46,28 +47,31 @@ Route::get('/display', function (Request $request) {
|
|||
// Get current screen image from mirror device or continue if not available
|
||||
if (! $image_uuid = $device->mirrorDevice?->current_screen_image) {
|
||||
$refreshTimeOverride = null;
|
||||
$nextPlaylistItem = $device->getNextPlaylistItem();
|
||||
// Skip if cloud proxy is enabled for the device
|
||||
if (! $device->proxy_cloud || $device->getNextPlaylistItem()) {
|
||||
$playlistItem = $device->getNextPlaylistItem();
|
||||
if (! $device->proxy_cloud && $nextPlaylistItem) {
|
||||
$refreshTimeOverride = $nextPlaylistItem->playlist()->first()->refresh_time;
|
||||
$plugin = $nextPlaylistItem->plugin;
|
||||
|
||||
if ($playlistItem) {
|
||||
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
|
||||
// Check and update stale data if needed
|
||||
if ($plugin->isDataStale() || $plugin->current_image == null) {
|
||||
$plugin->updateDataPayload();
|
||||
|
||||
$plugin = $playlistItem->plugin;
|
||||
|
||||
// Check and update stale data if needed
|
||||
if ($plugin->isDataStale()) {
|
||||
$plugin->updateDataPayload();
|
||||
}
|
||||
|
||||
$playlistItem->update(['last_displayed_at' => now()]);
|
||||
if ($plugin->render_markup) {
|
||||
$markup = Blade::render($plugin->render_markup, ['data' => $plugin->data_payload]);
|
||||
} elseif ($plugin->render_markup_view) {
|
||||
$markup = view($plugin->render_markup_view, ['data' => $plugin->data_payload])->render();
|
||||
}
|
||||
|
||||
GenerateScreenJob::dispatchSync($device->id, $markup);
|
||||
GeneratePluginJob::dispatchSync($plugin->id, $markup);
|
||||
}
|
||||
|
||||
$plugin->refresh();
|
||||
|
||||
if ($plugin->current_image != null)
|
||||
{
|
||||
$nextPlaylistItem->update(['last_displayed_at' => now()]);
|
||||
$device->update(['current_screen_image' => $plugin->current_image]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistItem;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
|
@ -9,6 +12,7 @@ uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|||
|
||||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->makeDirectory('/images/generated');
|
||||
});
|
||||
|
||||
test('device can fetch display data with valid credentials', function () {
|
||||
|
|
@ -464,3 +468,78 @@ test('authenticated user can fetch their devices', function () {
|
|||
]
|
||||
]);
|
||||
});
|
||||
|
||||
test('plugin doesn\'t update image unless required', function () {
|
||||
// Create source device with a playlist
|
||||
$device = Device::factory()->create([
|
||||
'mac_address' => '55:11:22:33:44:55',
|
||||
'api_key' => 'source-api-key',
|
||||
'proxy_cloud' => false,
|
||||
]);
|
||||
|
||||
$plugin = Plugin::factory()->create([
|
||||
'name' => 'Zen Quotes',
|
||||
'polling_url' => 'https://zenquotes.io/api/today',
|
||||
'data_stale_minutes' => 1,
|
||||
'data_strategy' => 'polling',
|
||||
'polling_verb' => 'get',
|
||||
'render_markup_view' => 'recipes.zen',
|
||||
'is_native' => false,
|
||||
'data_payload_updated_at' => null,
|
||||
]);
|
||||
|
||||
$playlist = Playlist::factory()->create([
|
||||
'device_id' => $device->id,
|
||||
'name' => 'update_test',
|
||||
'is_active' => true,
|
||||
'weekdays' => null,
|
||||
'active_from' => null,
|
||||
'active_until' => null,
|
||||
]);
|
||||
|
||||
PlaylistItem::factory()->create([
|
||||
'playlist_id' => $playlist->id,
|
||||
'plugin_id' => $plugin->id,
|
||||
'order' => 1,
|
||||
'is_active' => true,
|
||||
'last_displayed_at' => null,
|
||||
]);
|
||||
|
||||
// initial request, generates the image
|
||||
$firstResponse = $this->withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'rssi' => -70,
|
||||
'battery_voltage' => 3.8,
|
||||
'fw-version' => '1.0.0',
|
||||
])->get('/api/display');
|
||||
|
||||
$firstResponse->assertOk();
|
||||
expect($firstResponse['filename'])->not->toBe('setup-logo.bmp');
|
||||
|
||||
// second request after 15 seconds, shouldn't generate a new image
|
||||
$plugin->update(['data_payload_updated_at' => now()->addSeconds(-15)]);
|
||||
$secondResponse = $this->withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'rssi' => -70,
|
||||
'battery_voltage' => 3.8,
|
||||
'fw-version' => '1.0.0',
|
||||
])->get('/api/display');
|
||||
|
||||
expect($secondResponse['filename'])
|
||||
->toBe($firstResponse['filename']);
|
||||
|
||||
// third request after 75 seconds, should generate a new image
|
||||
$plugin->update(['data_payload_updated_at' => now()->addSeconds(-75)]);
|
||||
$thirdResponse = $this->withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'rssi' => -70,
|
||||
'battery_voltage' => 3.8,
|
||||
'fw-version' => '1.0.0',
|
||||
])->get('/api/display');
|
||||
|
||||
expect($thirdResponse['filename'])
|
||||
->not->toBe($firstResponse['filename']);
|
||||
})->skipOnGitHubActions();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue