diff --git a/.env.example b/.env.example index 34f57d91b..d313931bd 100644 --- a/.env.example +++ b/.env.example @@ -108,6 +108,8 @@ FILAMENT_USERS= BIFROST_API_KEY=your-secure-api-key-here +YOUTUBE_API_KEY= + TURNSTILE_SITE_KEY= TURNSTILE_SECRET_KEY= # Optional comma-separated allow list of hostnames the challenge may be solved on. diff --git a/app/Http/Controllers/Api/VideoController.php b/app/Http/Controllers/Api/VideoController.php new file mode 100644 index 000000000..42ca35ddc --- /dev/null +++ b/app/Http/Controllers/Api/VideoController.php @@ -0,0 +1,18 @@ +json(['data' => $videos->latest()]) + ->setPublic() + ->setMaxAge(900); + } +} diff --git a/app/Services/YouTubeVideoService.php b/app/Services/YouTubeVideoService.php new file mode 100644 index 000000000..0facb9336 --- /dev/null +++ b/app/Services/YouTubeVideoService.php @@ -0,0 +1,99 @@ + + */ + public function latest(): array + { + if ($cached = Cache::get(self::CACHE_KEY)) { + return $cached; + } + + $videos = $this->fetch(); + + if ($videos === null) { + return Cache::get(self::LAST_GOOD_CACHE_KEY, []); + } + + Cache::put(self::CACHE_KEY, $videos, now()->addHour()); + Cache::forever(self::LAST_GOOD_CACHE_KEY, $videos); + + return $videos; + } + + /** + * Null when the API isn't configured or the request fails, so callers can + * tell "no videos" apart from "couldn't ask". + * + * @return list|null + */ + protected function fetch(): ?array + { + $key = config('services.youtube.api_key'); + $channelId = config('services.youtube.channel_id'); + + if (! $key || ! $channelId) { + return null; + } + + $response = Http::timeout(10)->get(self::ENDPOINT, [ + 'part' => 'snippet,contentDetails', + // Every channel's uploads playlist is its id with UC swapped for UU. + 'playlistId' => 'UU'.substr($channelId, 2), + 'maxResults' => 25, + 'key' => $key, + ]); + + if ($response->failed()) { + Log::warning('YouTube videos fetch failed', ['status' => $response->status()]); + + return null; + } + + return collect($response->json('items', [])) + // Private and deleted uploads stay in the playlist without a + // publish date; they have nothing to watch. + ->filter(fn (array $item) => filled($item['contentDetails']['videoPublishedAt'] ?? null)) + ->map(function (array $item) { + $id = $item['contentDetails']['videoId']; + $snippet = $item['snippet']; + $thumbnails = $snippet['thumbnails'] ?? []; + + return [ + 'id' => $id, + 'title' => $snippet['title'] ?? '', + 'description' => $snippet['description'] ?? '', + 'published_at' => $item['contentDetails']['videoPublishedAt'], + 'thumbnail' => $thumbnails['high']['url'] + ?? $thumbnails['medium']['url'] + ?? "https://i.ytimg.com/vi/{$id}/hqdefault.jpg", + 'url' => "https://www.youtube.com/watch?v={$id}", + ]; + }) + ->values() + ->all(); + } +} diff --git a/config/services.php b/config/services.php index 2c7c53e43..336c7e569 100644 --- a/config/services.php +++ b/config/services.php @@ -83,6 +83,12 @@ 'hostnames' => env('TURNSTILE_HOSTNAMES'), ], + 'youtube' => [ + 'api_key' => env('YOUTUBE_API_KEY'), + // @NativePHPOfficial + 'channel_id' => env('YOUTUBE_CHANNEL_ID', 'UCbkAE6vLlR6lOy_nxd--22g'), + ], + 'satis' => [ 'url' => env('SATIS_API_URL', 'https://plugins.nativephp.com'), 'api_key' => env('SATIS_API_KEY'), diff --git a/routes/api.php b/routes/api.php index 4eb7fea7e..e2a7b3d3c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ use App\Http\Controllers\Api\LicenseController; use App\Http\Controllers\Api\PluginAccessController; use App\Http\Controllers\Api\TemporaryLinkController; +use App\Http\Controllers\Api\VideoController; use App\Http\Controllers\McpController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -39,6 +40,11 @@ ->name('mcp.api.plugins.show'); }); +// Latest NativePHP YouTube uploads, for the Jump app's Videos tab. +Route::get('videos', VideoController::class) + ->middleware('throttle:60,1') + ->name('api.videos'); + Route::middleware('auth.api_key')->group(function (): void { Route::prefix('plugins')->name('api.plugins.')->group(function (): void { Route::get('/access', [PluginAccessController::class, 'index'])->name('access'); diff --git a/tests/Feature/Api/VideosTest.php b/tests/Feature/Api/VideosTest.php new file mode 100644 index 000000000..4826545b6 --- /dev/null +++ b/tests/Feature/Api/VideosTest.php @@ -0,0 +1,112 @@ + 'test-key', + 'services.youtube.channel_id' => 'UCbkAE6vLlR6lOy_nxd--22g', + ]); + + Cache::flush(); + } + + public function test_it_returns_the_latest_uploads(): void + { + Http::fake([ + 'www.googleapis.com/youtube/v3/playlistItems*' => Http::response($this->playlistResponse()), + ]); + + $this->getJson('/api/videos') + ->assertOk() + ->assertHeader('Cache-Control', 'max-age=900, public') + ->assertJsonCount(1, 'data') + ->assertJsonPath('data.0', [ + 'id' => 'abc123', + 'title' => 'Jump explained', + 'description' => 'How Jump works.', + 'published_at' => '2026-09-01T12:00:00Z', + 'thumbnail' => 'https://i.ytimg.com/vi/abc123/hqdefault.jpg', + 'url' => 'https://www.youtube.com/watch?v=abc123', + ]); + + Http::assertSent(fn ($request) => $request['playlistId'] === 'UUbkAE6vLlR6lOy_nxd--22g' + && $request['key'] === 'test-key'); + } + + public function test_it_caches_the_list(): void + { + Http::fake([ + 'www.googleapis.com/youtube/v3/playlistItems*' => Http::response($this->playlistResponse()), + ]); + + $this->getJson('/api/videos')->assertOk(); + $this->getJson('/api/videos')->assertOk(); + + Http::assertSentCount(1); + } + + public function test_it_serves_the_last_good_list_when_youtube_fails(): void + { + Cache::forever(YouTubeVideoService::LAST_GOOD_CACHE_KEY, [['id' => 'stale']]); + + Http::fake([ + 'www.googleapis.com/youtube/v3/playlistItems*' => Http::response([], 403), + ]); + + $this->getJson('/api/videos') + ->assertOk() + ->assertJsonPath('data.0.id', 'stale'); + } + + public function test_it_returns_an_empty_list_without_an_api_key(): void + { + config(['services.youtube.api_key' => null]); + Http::fake(); + + $this->getJson('/api/videos') + ->assertOk() + ->assertExactJson(['data' => []]); + + Http::assertNothingSent(); + } + + /** + * One published upload plus a private one, which the playlist still lists + * without a publish date. + */ + private function playlistResponse(): array + { + return [ + 'items' => [ + [ + 'snippet' => [ + 'title' => 'Jump explained', + 'description' => 'How Jump works.', + 'thumbnails' => [ + 'high' => ['url' => 'https://i.ytimg.com/vi/abc123/hqdefault.jpg'], + ], + ], + 'contentDetails' => [ + 'videoId' => 'abc123', + 'videoPublishedAt' => '2026-09-01T12:00:00Z', + ], + ], + [ + 'snippet' => ['title' => 'Private video', 'description' => ''], + 'contentDetails' => ['videoId' => 'hidden1'], + ], + ], + ]; + } +}