mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: use transcode-and-cache instead of direct on-the-fly transcode&streaming
This commit is contained in:
parent
85316a378b
commit
6664e1d1ea
9 changed files with 134 additions and 49 deletions
|
@ -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
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
56
app/Values/TranscodeResult.php
Normal file
56
app/Values/TranscodeResult.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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());
|
||||||
|
|
||||||
|
|
55
tests/Integration/Values/TranscodeResultTest.php
Normal file
55
tests/Integration/Values/TranscodeResultTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue