feat: use transcode-and-cache instead of direct on-the-fly transcode&streaming

This commit is contained in:
Phan An 2024-09-03 12:52:07 +02:00
parent 85316a378b
commit 6664e1d1ea
9 changed files with 134 additions and 49 deletions

View file

@ -105,7 +105,7 @@ MEMORY_LIMIT=
# See https://docs.koel.dev/usage/streaming for more information. # See https://docs.koel.dev/usage/streaming for more information.
# Note: This setting doesn't have effect if the media needs transcoding (e.g. FLAC). # Note: This setting doesn't have effect if the media needs transcoding (e.g. FLAC).
# ################################################## # ##################################################
# IMPORTANT: It's HIGHLY recommended to use 'x-sendfile' or 'x-accel-redirect' if # It's HIGHLY recommended to use 'x-sendfile' or 'x-accel-redirect' if
# you plan to use the Koel mobile apps. # you plan to use the Koel mobile apps.
# ################################################## # ##################################################
STREAMING_METHOD=php STREAMING_METHOD=php

View file

@ -33,13 +33,7 @@ class DownloadService
} }
if ($song->isEpisode()) { if ($song->isEpisode()) {
$playable = EpisodePlayable::retrieveForEpisode($song); return EpisodePlayable::getForEpisode($song)->path;
if (!$playable?->valid()) {
$playable = EpisodePlayable::createForEpisode($song);
}
return $playable->path;
} }
if ($song->storage === SongStorageType::LOCAL) { if ($song->storage === SongStorageType::LOCAL) {

View file

@ -27,12 +27,6 @@ class PodcastStreamerAdapter implements StreamerAdapter
return response()->redirectTo($streamableUrl); return response()->redirectTo($streamableUrl);
} }
$playable = EpisodePlayable::retrieveForEpisode($song); $this->streamLocalPath(EpisodePlayable::getForEpisode($song)->path);
if (!$playable?->valid()) {
$playable = EpisodePlayable::createForEpisode($song);
}
$this->streamLocalPath($playable->path);
} }
} }

View file

@ -3,43 +3,21 @@
namespace App\Services\Streamer\Adapters; namespace App\Services\Streamer\Adapters;
use App\Models\Song; use App\Models\Song;
use App\Services\Streamer\Adapters\Concerns\StreamsLocalPath;
use App\Values\TranscodeResult;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
class TranscodingStreamerAdapter implements StreamerAdapter class TranscodingStreamerAdapter implements StreamerAdapter
{ {
/** use StreamsLocalPath;
* On-the-fly stream the current song while transcoding.
*/
public function stream(Song $song, array $config = []): void public function stream(Song $song, array $config = []): void
{ {
$ffmpeg = config('koel.streaming.ffmpeg_path'); abort_unless(is_executable(config('koel.streaming.ffmpeg_path')), 500, 'ffmpeg not found or not executable.');
abort_unless(is_executable($ffmpeg), 500, 'Transcoding requires valid ffmpeg settings.');
$path = $song->storage_metadata->getPath();
$bitRate = filter_var(Arr::get($config, 'bit_rate'), FILTER_SANITIZE_NUMBER_INT) $bitRate = filter_var(Arr::get($config, 'bit_rate'), FILTER_SANITIZE_NUMBER_INT)
?: config('koel.streaming.bitrate'); ?: config('koel.streaming.bitrate');
$startTime = filter_var(Arr::get($config, 'start_time', 0), FILTER_SANITIZE_NUMBER_FLOAT); $this->streamLocalPath(TranscodeResult::getForSong($song, $bitRate)->path);
setlocale(LC_CTYPE, 'en_US.UTF-8'); // #1481 special chars might be stripped otherwise
header('Content-Type: audio/mpeg');
header('Content-Disposition: attachment; filename="' . basename($path) . '"');
$args = [
'-i ' . escapeshellarg($path),
'-map 0:0',
'-v 0',
"-ab {$bitRate}k",
'-f mp3',
'-',
];
if ($startTime) {
array_unshift($args, "-ss $startTime");
}
passthru("$ffmpeg " . implode(' ', $args));
} }
} }

View file

@ -25,12 +25,15 @@ final class EpisodePlayable implements Arrayable, Jsonable
return File::isReadable($this->path) && $this->checksum === md5_file($this->path); return File::isReadable($this->path) && $this->checksum === md5_file($this->path);
} }
public static function retrieveForEpisode(Episode $episode): ?self public static function getForEpisode(Episode $episode): ?self
{ {
return Cache::get("episode-playable.$episode->id"); /** @var self|null $cached */
$cached = Cache::get("episode-playable.$episode->id");
return $cached?->valid() ? $cached : self::createForEpisode($episode);
} }
public static function createForEpisode(Episode $episode): self private static function createForEpisode(Episode $episode): self
{ {
$dir = sys_get_temp_dir() . '/koel-episodes'; $dir = sys_get_temp_dir() . '/koel-episodes';
$file = sprintf('%s/%s.mp3', $dir, $episode->id); $file = sprintf('%s/%s.mp3', $dir, $episode->id);

View file

@ -0,0 +1,56 @@
<?php
namespace App\Values;
use App\Models\Song;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
final class TranscodeResult
{
public function __construct(public readonly string $path, public readonly string $checksum)
{
}
public static function getForSong(Song $song, int $bitRate, ?string $transcodedPath = null): self
{
/** @var self|null $cached */
$cached = Cache::get("transcoded.{$song->id}.$bitRate");
return $cached?->valid() ? $cached : self::createForSong($song, $bitRate, $transcodedPath);
}
private static function createForSong(Song $song, int $bitRate, ?string $transcodedPath = null): self
{
setlocale(LC_CTYPE, 'en_US.UTF-8'); // #1481 special chars might be stripped otherwise
$dir = sys_get_temp_dir() . '/koel-transcodes';
$transcodedPath ??= sprintf('%s/%s.%s.mp3', $dir, $song, $bitRate);
File::ensureDirectoryExists($dir);
Process::timeout(60)->run([
config('koel.streaming.ffmpeg_path'),
'-i',
$song->storage_metadata->getPath(),
'-vn',
'-b:a',
"{$bitRate}k",
'-preset',
'ultrafast',
'-y', // Overwrite output file if it exists
$transcodedPath,
]);
$cached = new self($transcodedPath, md5_file($transcodedPath));
Cache::forever("transcoded.{$song->id}.$bitRate", $cached);
return $cached;
}
private function valid(): bool
{
return File::isReadable($this->path) && $this->checksum === md5_file($this->path);
}
}

View file

@ -8,11 +8,11 @@ use App\Models\PodcastUserPivot;
use App\Models\Song; use App\Models\Song;
use App\Services\PodcastService; use App\Services\PodcastService;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack; use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response; use GuzzleHttp\Psr7\Response;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Psr\Http\Client\ClientInterface;
use Tests\TestCase; use Tests\TestCase;
use function Tests\create_user; use function Tests\create_user;

View file

@ -21,12 +21,17 @@ class EpisodePlayableTest extends TestCase
'path' => 'https://example.com/episode.mp3', 'path' => 'https://example.com/episode.mp3',
]); ]);
$playable = EpisodePlayable::createForEpisode($episode); $playable = EpisodePlayable::getForEpisode($episode);
Http::assertSentCount(1);
self::assertSame('acbd18db4cc2f85cedef654fccc4a4d8', $playable->checksum); self::assertSame('acbd18db4cc2f85cedef654fccc4a4d8', $playable->checksum);
self::assertTrue(Cache::has("episode-playable.$episode->id")); self::assertTrue(Cache::has("episode-playable.$episode->id"));
$retrieved = EpisodePlayable::retrieveForEpisode($episode); $retrieved = EpisodePlayable::getForEpisode($episode);
// No extra HTTP request should be made.
Http::assertSentCount(1);
self::assertSame($playable, $retrieved); self::assertSame($playable, $retrieved);
self::assertTrue($retrieved->valid()); self::assertTrue($retrieved->valid());

View file

@ -0,0 +1,55 @@
<?php
namespace Tests\Integration\Values;
use App\Models\Song;
use App\Values\TranscodeResult;
use Illuminate\Process\PendingProcess;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Tests\TestCase;
use function Tests\test_path;
class TranscodeResultTest extends TestCase
{
public function testCreateAndRetrieve(): void
{
config(['koel.streaming.ffmpeg_path' => '/usr/bin/ffmpeg']);
Process::fake();
/** @var Song $song */
$song = Song::factory()->create();
$result = TranscodeResult::getForSong($song, 128, test_path('songs/full.mp3'));
$closure = static function (PendingProcess $process) use ($song): bool {
return $process->command === [
'/usr/bin/ffmpeg',
'-i',
$song->storage_metadata->getPath(),
'-vn',
'-b:a',
'128k',
'-preset',
'ultrafast',
'-y',
test_path('songs/full.mp3'),
];
};
Process::assertRanTimes($closure, 1);
self::assertSame('3c7b4e187277e40f8ae793650336e03b', $result->checksum);
self::assertSame(test_path('songs/full.mp3'), $result->path);
self::assertTrue(Cache::has("transcoded.{$song->id}.128"));
$cached = TranscodeResult::getForSong($song, 128, test_path('songs/full.mp3'));
// No extra ffmpeg process should be run.
Process::assertRanTimes($closure, 1);
self::assertSame('3c7b4e187277e40f8ae793650336e03b', $cached->checksum);
self::assertSame(test_path('songs/full.mp3'), $cached->path);
}
}