Skip to content
Open
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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions app/Http/Controllers/Api/VideoController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\YouTubeVideoService;
use Illuminate\Http\JsonResponse;

class VideoController extends Controller
{
public function __invoke(YouTubeVideoService $videos): JsonResponse
{
return response()
->json(['data' => $videos->latest()])
->setPublic()
->setMaxAge(900);
}
}
99 changes: 99 additions & 0 deletions app/Services/YouTubeVideoService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

/**
* The latest uploads from the NativePHP YouTube channel, via the YouTube Data
* API (the channel RSS feed at youtube.com/feeds now 404s). Served to the Jump
* app's Videos tab through /api/videos.
*
* Results are cached for an hour. The last successful list is kept
* indefinitely so a YouTube outage or quota hiccup serves slightly stale
* videos instead of an empty page.
*/
class YouTubeVideoService
{
public const CACHE_KEY = 'youtube.videos';

public const LAST_GOOD_CACHE_KEY = 'youtube.videos.last_good';

private const ENDPOINT = 'https://www.googleapis.com/youtube/v3/playlistItems';

/**
* @return list<array{id: string, title: string, description: string, published_at: ?string, thumbnail: string, url: string}>
*/
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<array{id: string, title: string, description: string, published_at: ?string, thumbnail: string, url: string}>|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();
}
}
6 changes: 6 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
6 changes: 6 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
112 changes: 112 additions & 0 deletions tests/Feature/Api/VideosTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<?php

namespace Tests\Feature\Api;

use App\Services\YouTubeVideoService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class VideosTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();

config([
'services.youtube.api_key' => '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'],
],
],
];
}
}
Loading