refactor: use adapters for streamers

This commit is contained in:
Phan An 2024-02-24 14:28:49 +07:00
parent ff79332c6a
commit 52285a1c48
27 changed files with 447 additions and 474 deletions

View file

@ -0,0 +1,18 @@
<?php
namespace App\Exceptions;
use Exception;
class UnsupportedSongStorageTypeException extends Exception
{
private function __construct(string $storageType)
{
parent::__construct("Unsupported song storage type: $storageType");
}
public function create(string $storageType): self
{
return new self($storageType);
}
}

View file

@ -4,21 +4,18 @@ namespace App\Http\Controllers;
use App\Http\Requests\SongPlayRequest; use App\Http\Requests\SongPlayRequest;
use App\Models\Song; use App\Models\Song;
use App\Services\Streamers\StreamerFactory; use App\Services\Streamer\Streamer;
class PlayController extends Controller class PlayController extends Controller
{ {
public function __invoke( public function __invoke(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null)
StreamerFactory $streamerFactory, {
SongPlayRequest $request,
Song $song,
?bool $transcode = null,
?int $bitRate = null
) {
$this->authorize('access', $song); $this->authorize('access', $song);
return $streamerFactory return (new Streamer(song: $song, config: [
->createStreamer($song, $transcode, $bitRate, (float) $request->time) 'transcode' => (bool) $transcode,
->stream(); 'bit_rate' => $bitRate,
'start_time' => (float) $request->time,
]))->stream();
} }
} }

View file

@ -2,21 +2,21 @@
namespace App\Providers; namespace App\Providers;
use App\Services\Streamers\LocalStreamer; use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamers\PhpStreamer; use App\Services\Streamer\Adapters\PhpStreamerAdapter;
use App\Services\Streamers\XAccelRedirectStreamer; use App\Services\Streamer\Adapters\XAccelRedirectStreamerAdapter;
use App\Services\Streamers\XSendFileStreamer; use App\Services\Streamer\Adapters\XSendFileStreamerAdapter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class StreamerServiceProvider extends ServiceProvider class StreamerServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
$this->app->bind(LocalStreamer::class, static function (): LocalStreamer { $this->app->bind(LocalStreamerAdapter::class, function (): LocalStreamerAdapter {
return match (config('koel.streaming.method')) { return match (config('koel.streaming.method')) {
'x-sendfile' => new XSendFileStreamer(), 'x-sendfile' => $this->app->make(XSendFileStreamerAdapter::class),
'x-accel-redirect' => new XAccelRedirectStreamer(), 'x-accel-redirect' => $this->app->make(XAccelRedirectStreamerAdapter::class),
default => new PhpStreamer(), default => $this->app->make(PhpStreamerAdapter::class),
}; };
}); });
} }

View file

@ -0,0 +1,20 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
use App\Services\SongStorages\DropboxStorage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
class DropboxStreamerAdapter implements StreamerAdapter
{
public function __construct(private DropboxStorage $storage)
{
}
public function stream(Song $song, array $config = []): Redirector|RedirectResponse
{
return redirect($this->storage->getSongPresignedUrl($song));
}
}

View file

@ -0,0 +1,7 @@
<?php
namespace App\Services\Streamer\Adapters;
abstract class LocalStreamerAdapter implements StreamerAdapter
{
}

View file

@ -1,7 +1,8 @@
<?php <?php
namespace App\Services\Streamers; namespace App\Services\Streamer\Adapters;
use App\Models\Song;
use DaveRandom\Resume\FileResource; use DaveRandom\Resume\FileResource;
use DaveRandom\Resume\InvalidRangeHeaderException; use DaveRandom\Resume\InvalidRangeHeaderException;
use DaveRandom\Resume\NonExistentFileException; use DaveRandom\Resume\NonExistentFileException;
@ -14,18 +15,19 @@ use Symfony\Component\HttpFoundation\Response;
use function DaveRandom\Resume\get_request_header; use function DaveRandom\Resume\get_request_header;
class PhpStreamer extends LocalStreamer class PhpStreamerAdapter extends LocalStreamerAdapter
{ {
public function stream(): void public function stream(Song $song, array $config = []): void
{ {
try { try {
$path = $song->storage_metadata->getPath();
$rangeHeader = get_request_header('Range'); $rangeHeader = get_request_header('Range');
// On Safari, "Range" header value can be "bytes=0-1" which breaks streaming. // On Safari, "Range" header value can be "bytes=0-1" which breaks streaming.
$rangeHeader = $rangeHeader === 'bytes=0-1' ? 'bytes=0-' : $rangeHeader; $rangeHeader = $rangeHeader === 'bytes=0-1' ? 'bytes=0-' : $rangeHeader;
$rangeSet = RangeSet::createFromHeader($rangeHeader); $rangeSet = RangeSet::createFromHeader($rangeHeader);
$resource = new FileResource($this->song->path, mime_content_type($this->song->path)); $resource = new FileResource($path, mime_content_type($path));
(new ResourceServlet($resource))->sendResource($rangeSet); (new ResourceServlet($resource))->sendResource($rangeSet);
} catch (InvalidRangeHeaderException) { } catch (InvalidRangeHeaderException) {
abort(Response::HTTP_BAD_REQUEST); abort(Response::HTTP_BAD_REQUEST);

View file

@ -0,0 +1,20 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
use App\Services\SongStorages\S3CompatibleStorage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
class S3CompatibleStreamerAdapter implements StreamerAdapter
{
public function __construct(private S3CompatibleStorage $storage)
{
}
public function stream(Song $song, array $config = []): Redirector|RedirectResponse
{
return redirect($this->storage->getSongPresignedUrl($song));
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
interface StreamerAdapter
{
public function stream(Song $song, array $config = []); // @phpcs:ignore
}

View file

@ -0,0 +1,45 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
use Illuminate\Support\Arr;
class TranscodingStreamerAdapter implements StreamerAdapter
{
/**
* On-the-fly stream the current song while transcoding.
*/
public function stream(Song $song, array $config = []): void
{
$ffmpeg = config('koel.streaming.ffmpeg_path');
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)
?: config('koel.streaming.bitrate');
$startTime = filter_var(Arr::get($config, 'start_time', 0), FILTER_SANITIZE_NUMBER_FLOAT);
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

@ -1,25 +1,29 @@
<?php <?php
namespace App\Services\Streamers; namespace App\Services\Streamer\Adapters;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Song;
class XAccelRedirectStreamer extends LocalStreamer class XAccelRedirectStreamerAdapter extends LocalStreamerAdapter
{ {
/** /**
* Stream the current song using nginx's X-Accel-Redirect. * Stream the current song using nginx's X-Accel-Redirect.
* @link https://www.nginx.com/resources/wiki/start/topics/examples/xsendfile/
*/ */
public function stream(): void public function stream(Song $song, array $config = []): void
{ {
$relativePath = str_replace(Setting::get('media_path'), '', $this->song->path); $path = $song->storage_metadata->getPath();
$contentType = 'audio/' . pathinfo($path, PATHINFO_EXTENSION);
$relativePath = str_replace(Setting::get('media_path'), '', $path);
// We send our media_path value as a 'X-Media-Root' header to downstream (nginx) // We send our media_path value as a 'X-Media-Root' header to downstream (nginx)
// It will then be use as `alias` in X-Accel config location block. // It will then be use as `alias` in X-Accel config location block.
// See nginx.conf.example. // See nginx.conf.example.
header('X-Media-Root: ' . Setting::get('media_path')); header('X-Media-Root: ' . Setting::get('media_path'));
header("X-Accel-Redirect: /media/$relativePath"); header("X-Accel-Redirect: /media/$relativePath");
header("Content-Type: $this->contentType"); header("Content-Type: $contentType");
header('Content-Disposition: inline; filename="' . basename($this->song->path) . '"'); header('Content-Disposition: inline; filename="' . basename($path) . '"');
exit; exit;
} }

View file

@ -0,0 +1,23 @@
<?php
namespace App\Services\Streamer\Adapters;
use App\Models\Song;
class XSendFileStreamerAdapter extends LocalStreamerAdapter
{
/**
* Stream the current song using Apache's x_sendfile module.
*/
public function stream(Song $song, array $config = []): void
{
$path = $song->storage_metadata->getPath();
$contentType = 'audio/' . pathinfo($path, PATHINFO_EXTENSION);
header("X-Sendfile: $path");
header("Content-Type: $contentType");
header('Content-Disposition: inline; filename="' . basename($path) . '"');
exit;
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace App\Services\Streamer;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\UnsupportedSongStorageTypeException;
use App\Models\Song;
use App\Services\Streamer\Adapters\DropboxStreamerAdapter;
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
use App\Services\Streamer\Adapters\StreamerAdapter;
use App\Services\Streamer\Adapters\TranscodingStreamerAdapter;
use App\Values\SongStorageTypes;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class Streamer
{
private StreamerAdapter $adapter;
public function __construct(private Song $song, ?StreamerAdapter $adapter = null, private array $config = [])
{
// Turn off error reporting to make sure our stream isn't interfered.
@error_reporting(0);
$this->adapter = $adapter ?? $this->resolveAdapter();
}
private function resolveAdapter(): StreamerAdapter
{
throw_unless(SongStorageTypes::supported($this->song->storage), KoelPlusRequiredException::class);
if ($this->shouldTranscode()) {
return app(TranscodingStreamerAdapter::class);
}
return match ($this->song->storage) {
SongStorageTypes::LOCAL, '' => app(LocalStreamerAdapter::class),
SongStorageTypes::S3, SongStorageTypes::S3_LAMBDA => app(S3CompatibleStreamerAdapter::class),
SongStorageTypes::DROPBOX => app(DropboxStreamerAdapter::class),
default => throw UnsupportedSongStorageTypeException::make($this->song->storage),
};
}
public function stream(): mixed
{
return $this->adapter->stream($this->song, $this->config);
}
private function shouldTranscode(): bool
{
// We only transcode local files. "Remote" transcoding (e.g., from Dropbox) is not supported.
if ($this->song->storage !== SongStorageTypes::LOCAL) {
return false;
}
if (Arr::get($this->config, 'transcode', false)) {
return true;
}
return $this->song->storage === SongStorageTypes::LOCAL
&& Str::endsWith(File::mimeType($this->song->storage_metadata->getPath()), 'flac')
&& config('koel.streaming.transcode_flac');
}
public function getAdapter(): StreamerAdapter
{
return $this->adapter;
}
}

View file

@ -1,20 +0,0 @@
<?php
namespace App\Services\Streamers;
use App\Services\SongStorages\DropboxStorage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
class DropboxStreamer extends Streamer
{
public function __construct(private DropboxStorage $storage)
{
parent::__construct();
}
public function stream(): Redirector|RedirectResponse
{
return redirect($this->storage->getSongPresignedUrl($this->song));
}
}

View file

@ -1,22 +0,0 @@
<?php
namespace App\Services\Streamers;
use App\Models\Song;
abstract class LocalStreamer extends Streamer
{
protected function supported(): bool
{
return true;
}
public function setSong(Song $song): void
{
$this->song = $song;
// Hard code the content type instead of relying on PHP's fileinfo()
// or even Symfony's MIMETypeGuesser, since they appear to be wrong sometimes.
$this->contentType = 'audio/' . pathinfo($this->song->storage_metadata->getPath(), PATHINFO_EXTENSION);
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Services\Streamers;
use App\Services\SongStorages\S3CompatibleStorage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
class S3CompatibleStreamer extends Streamer
{
public function __construct(private S3CompatibleStorage $storage)
{
parent::__construct();
}
/**
* Stream the current song from the Object Storable server.
* Actually, we just redirect the request to the object's presigned URL.
*/
public function stream(): Redirector|RedirectResponse
{
return redirect($this->storage->getSongPresignedUrl($this->song));
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Services\Streamers;
use App\Models\Song;
abstract class Streamer
{
protected ?Song $song = null;
protected ?string $contentType = null;
public function __construct()
{
// Turn off error reporting to make sure our stream isn't interfered.
@error_reporting(0);
}
public function setSong(Song $song): void
{
$this->song = $song;
}
abstract public function stream(); // @phpcs:ignore
}

View file

@ -1,50 +0,0 @@
<?php
namespace App\Services\Streamers;
use App\Exceptions\KoelPlusRequiredException;
use App\Models\Song;
use App\Services\TranscodingService;
use App\Values\SongStorageTypes;
class StreamerFactory
{
public function __construct(private TranscodingService $transcodingService)
{
}
public function createStreamer(
Song $song,
?bool $transcode = null,
?int $bitRate = null,
float $startTime = 0.0
): Streamer {
throw_unless(SongStorageTypes::supported($song->storage), KoelPlusRequiredException::class);
if ($song->storage === SongStorageTypes::S3 || $song->storage === SongStorageTypes::S3_LAMBDA) {
return self::makeStreamerFromClass(S3CompatibleStreamer::class, $song);
}
if ($song->storage === SongStorageTypes::DROPBOX) {
return self::makeStreamerFromClass(DropboxStreamer::class, $song);
}
$transcode ??= $this->transcodingService->songShouldBeTranscoded($song);
if ($transcode) {
/** @var TranscodingStreamer $streamer */
$streamer = self::makeStreamerFromClass(TranscodingStreamer::class, $song);
$streamer->setBitRate($bitRate ?: config('koel.streaming.bitrate'));
$streamer->setStartTime($startTime);
return $streamer;
}
return self::makeStreamerFromClass(LocalStreamer::class, $song);
}
private static function makeStreamerFromClass(string $class, Song $song): Streamer
{
return tap(app($class), static fn (Streamer $streamer) => $streamer->setSong($song));
}
}

View file

@ -1,62 +0,0 @@
<?php
namespace App\Services\Streamers;
class TranscodingStreamer extends Streamer
{
/**
* Bit rate the stream should be transcoded at.
*/
private ?int $bitRate = null;
/**
* Time point to start transcoding from.
*/
private ?float $startTime = null;
/**
* On-the-fly stream the current song while transcoding.
*/
public function stream(): void
{
setlocale(LC_CTYPE, 'en_US.UTF-8'); // #1481 special chars might be stripped otherwise
$ffmpeg = config('koel.streaming.ffmpeg_path');
abort_unless(is_executable($ffmpeg), 500, 'Transcoding requires valid ffmpeg settings.');
$bitRate = filter_var($this->bitRate, FILTER_SANITIZE_NUMBER_INT);
header('Content-Type: audio/mpeg');
header('Content-Disposition: attachment; filename="' . basename($this->song->path) . '"');
$args = [
'-i ' . escapeshellarg($this->song->path),
'-map 0:0',
'-v 0',
"-ab {$bitRate}k",
'-f mp3',
'-',
];
if ($this->startTime) {
array_unshift($args, "-ss $this->startTime");
}
passthru("$ffmpeg " . implode(' ', $args));
}
public function setBitRate(int $bitRate): void
{
$this->bitRate = $bitRate;
}
public function setStartTime(float $startTime): void
{
$this->startTime = $startTime;
}
protected function supported(): bool
{
return true;
}
}

View file

@ -1,18 +0,0 @@
<?php
namespace App\Services\Streamers;
class XSendFileStreamer extends LocalStreamer
{
/**
* Stream the current song using Apache's x_sendfile module.
*/
public function stream(): void
{
header("X-Sendfile: {$this->song->path}");
header("Content-Type: $this->contentType");
header('Content-Disposition: inline; filename="' . basename($this->song->path) . '"');
exit;
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace App\Services;
use App\Models\Song;
class TranscodingService
{
public function songShouldBeTranscoded(Song $song): bool
{
return ends_with(mime_content_type($song->path), 'flac') && config('koel.streaming.transcode_flac');
}
}

View file

@ -3,10 +3,9 @@
namespace Tests\Feature\KoelPlus; namespace Tests\Feature\KoelPlus;
use App\Models\Song; use App\Models\Song;
use App\Services\Streamers\LocalStreamer; use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\TokenManager; use App\Services\TokenManager;
use App\Values\CompositeToken; use App\Values\CompositeToken;
use Mockery;
use Tests\PlusTestCase; use Tests\PlusTestCase;
use function Tests\create_user; use function Tests\create_user;
@ -24,13 +23,9 @@ class SongPlayTest extends PlusTestCase
'path' => test_path('songs/blank.mp3'), 'path' => test_path('songs/blank.mp3'),
]); ]);
$mockStreamer = $this->mock(LocalStreamer::class); $this->mock(LocalStreamerAdapter::class)
->shouldReceive('stream')
$mockStreamer->shouldReceive('setSong')->with( ->once();
Mockery::on(static fn (Song $retrievedSong): bool => $retrievedSong->id === $song->id)
)->once();
$mockStreamer->shouldReceive('stream')->once();
$this->get("play/$song->id?t=$token->audioToken") $this->get("play/$song->id?t=$token->audioToken")
->assertOk(); ->assertOk();
@ -46,13 +41,9 @@ class SongPlayTest extends PlusTestCase
/** @var CompositeToken $token */ /** @var CompositeToken $token */
$token = app(TokenManager::class)->createCompositeToken($song->owner); $token = app(TokenManager::class)->createCompositeToken($song->owner);
$mockStreamer = $this->mock(LocalStreamer::class); $this->mock(LocalStreamerAdapter::class)
->shouldReceive('stream')
$mockStreamer->shouldReceive('setSong')->with( ->once();
Mockery::on(static fn (Song $retrievedSong): bool => $retrievedSong->id === $song->id)
)->once();
$mockStreamer->shouldReceive('stream')->once();
$this->get("play/$song->id?t=$token->audioToken") $this->get("play/$song->id?t=$token->audioToken")
->assertOk(); ->assertOk();

View file

@ -3,10 +3,11 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Song; use App\Models\Song;
use App\Services\Streamers\LocalStreamer; use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\TranscodingStreamerAdapter;
use App\Services\TokenManager; use App\Services\TokenManager;
use App\Values\CompositeToken; use App\Values\CompositeToken;
use Mockery; use Illuminate\Support\Facades\File;
use Tests\TestCase; use Tests\TestCase;
use function Tests\create_user; use function Tests\create_user;
@ -26,15 +27,55 @@ class SongPlayTest extends TestCase
'path' => test_path('songs/blank.mp3'), 'path' => test_path('songs/blank.mp3'),
]); ]);
$mockStreamer = $this->mock(LocalStreamer::class); $this->mock(LocalStreamerAdapter::class)
->shouldReceive('stream')
$mockStreamer->shouldReceive('setSong')->with( ->once();
Mockery::on(static fn (Song $retrievedSong): bool => $retrievedSong->id === $song->id)
)->once();
$mockStreamer->shouldReceive('stream')->once();
$this->get("play/$song->id?t=$token->audioToken") $this->get("play/$song->id?t=$token->audioToken")
->assertOk(); ->assertOk();
} }
public function testTranscoding(): void
{
config(['koel.streaming.transcode_flac' => true]);
$user = create_user();
/** @var CompositeToken $token */
$token = app(TokenManager::class)->createCompositeToken($user);
/** @var Song $song */
$song = Song::factory()->create(['path' => test_path('songs/blank.mp3')]);
File::partialMock()
->shouldReceive('mimeType')
->with($song->path)
->andReturn('audio/flac');
$this->mock(TranscodingStreamerAdapter::class)
->shouldReceive('stream')
->once();
$this->get("play/$song->id?t=$token->audioToken")
->assertOk();
config(['koel.streaming.transcode_flac' => false]);
}
public function testForceTranscoding(): void
{
$user = create_user();
/** @var CompositeToken $token */
$token = app(TokenManager::class)->createCompositeToken($user);
/** @var Song $song */
$song = Song::factory()->create(['path' => test_path('songs/blank.mp3')]);
$this->mock(TranscodingStreamerAdapter::class)
->shouldReceive('stream')
->once();
$this->get("play/$song->id/1/128?t=$token->audioToken")
->assertOk();
}
} }

View file

@ -1,130 +0,0 @@
<?php
namespace Tests\Integration\Factories;
use App\Exceptions\KoelPlusRequiredException;
use App\Models\Song;
use App\Services\Streamers\PhpStreamer;
use App\Services\Streamers\S3CompatibleStreamer;
use App\Services\Streamers\StreamerFactory;
use App\Services\Streamers\TranscodingStreamer;
use App\Services\Streamers\XAccelRedirectStreamer;
use App\Services\Streamers\XSendFileStreamer;
use App\Services\TranscodingService;
use App\Values\SongStorageTypes;
use Exception;
use Mockery;
use phpmock\mockery\PHPMockery;
use Tests\TestCase;
use function Tests\test_path;
class StreamerFactoryTest extends TestCase
{
private StreamerFactory $streamerFactory;
public function setUp(): void
{
parent::setUp();
$this->streamerFactory = app(StreamerFactory::class);
PHPMockery::mock('App\Services\Streamers', 'file_exists')->andReturn(true);
}
public function testCreateStreamer(): void
{
collect(SongStorageTypes::ALL_TYPES)
->each(function (?string $type): void {
switch ($type) {
case SongStorageTypes::S3:
self::expectException(KoelPlusRequiredException::class);
$this->streamerFactory->createStreamer(
Song::factory()->make(['path' => "s3://bucket/foo.mp3", 'storage' => $type])
);
break;
case SongStorageTypes::S3_LAMBDA:
self::assertInstanceOf(S3CompatibleStreamer::class, $this->streamerFactory->createStreamer(
Song::factory()->make(['path' => "s3://bucket/foo.mp3", 'storage' => $type])
));
break;
case SongStorageTypes::DROPBOX:
self::expectException(KoelPlusRequiredException::class);
$this->streamerFactory->createStreamer(
Song::factory()->make(['path' => "dropbox://foo.mp3", 'storage' => $type])
);
break;
case SongStorageTypes::LOCAL:
self::assertInstanceOf(PhpStreamer::class, $this->streamerFactory->createStreamer(
Song::factory()->make(['path' => test_path('songs/blank.mp3'), 'storage' => $type])
));
break;
default:
throw new Exception("Unhandled storage type: $type");
}
});
}
public function testCreateTranscodingStreamerIfSupported(): void
{
$this->swap(TranscodingService::class, Mockery::mock(TranscodingService::class, [
'songShouldBeTranscoded' => true,
]));
/** @var StreamerFactory $streamerFactory */
$streamerFactory = app(StreamerFactory::class);
/** @var Song $song */
$song = Song::factory()->make(['path' => test_path('songs/blank.mp3')]);
self::assertInstanceOf(TranscodingStreamer::class, $streamerFactory->createStreamer($song));
}
public function testCreateTranscodingStreamerIfForced(): void
{
$this->swap(TranscodingService::class, Mockery::mock(TranscodingService::class, [
'songShouldBeTranscoded' => false,
]));
/** @var StreamerFactory $streamerFactory */
$streamerFactory = app(StreamerFactory::class);
/** @var Song $song */
$song = Song::factory()->make(['path' => test_path('songs/blank.mp3')]);
self::assertInstanceOf(TranscodingStreamer::class, $streamerFactory->createStreamer($song, true));
}
/** @return array<mixed> */
public function provideStreamingConfigData(): array
{
return [
[null, PhpStreamer::class],
['x-sendfile', XSendFileStreamer::class],
['x-accel-redirect', XAccelRedirectStreamer::class],
];
}
/**
* @dataProvider provideStreamingConfigData
*
* @param string|null $config
* @param string $expectedClass
*/
public function testCreatePhpStreamer($config, $expectedClass): void
{
$this->swap(TranscodingService::class, Mockery::mock(TranscodingService::class, [
'songShouldBeTranscoded' => false,
]));
config(['koel.streaming.method' => $config]);
/** @var StreamerFactory $streamerFactory */
$streamerFactory = app(StreamerFactory::class);
/** @var Song $song */
$song = Song::factory()->make(['path' => test_path('songs/blank.mp3')]);
self::assertInstanceOf($expectedClass, $streamerFactory->createStreamer($song));
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace Tests\Integration\KoelPlus\Services\Streamer;
use App\Models\Song;
use App\Services\Streamer\Adapters\DropboxStreamerAdapter;
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
use App\Services\Streamer\Streamer;
use App\Values\SongStorageTypes;
use Exception;
use Illuminate\Support\Facades\File;
use Tests\PlusTestCase;
class StreamerTest extends PlusTestCase
{
public function testResolveAdapters(): void
{
File::partialMock()->shouldReceive('mimeType')->andReturn('audio/mpeg');
collect(SongStorageTypes::ALL_TYPES)
->each(static function (?string $type): void {
/** @var Song $song */
$song = Song::factory()->make(['storage' => $type]);
$streamer = new Streamer($song);
switch ($type) {
case SongStorageTypes::S3:
case SongStorageTypes::S3_LAMBDA:
self::assertInstanceOf(S3CompatibleStreamerAdapter::class, $streamer->getAdapter());
break;
case SongStorageTypes::DROPBOX:
self::assertInstanceOf(DropboxStreamerAdapter::class, $streamer->getAdapter());
break;
case SongStorageTypes::LOCAL:
case '':
self::assertInstanceOf(LocalStreamerAdapter::class, $streamer->getAdapter());
break;
default:
throw new Exception("Unhandled storage type: $type");
}
});
}
}

View file

@ -1,57 +0,0 @@
<?php
namespace Tests\Integration\KoelPlus;
use App\Models\Song;
use App\Services\Streamers\PhpStreamer;
use App\Services\Streamers\S3CompatibleStreamer;
use App\Services\Streamers\StreamerFactory;
use App\Values\SongStorageTypes;
use Exception;
use phpmock\mockery\PHPMockery;
use Tests\PlusTestCase;
use function Tests\test_path;
class StreamerFactoryTest extends PlusTestCase
{
private StreamerFactory $streamerFactory;
public function setUp(): void
{
parent::setUp();
$this->streamerFactory = app(StreamerFactory::class);
PHPMockery::mock('App\Services\Streamers', 'file_exists')->andReturn(true);
}
public function testCreateStreamer(): void
{
collect(SongStorageTypes::ALL_TYPES)
->each(function (?string $type): void {
switch ($type) {
case SongStorageTypes::S3:
case SongStorageTypes::S3_LAMBDA:
self::assertInstanceOf(S3CompatibleStreamer::class, $this->streamerFactory->createStreamer(
Song::factory()->make(['path' => "s3://bucket/foo.mp3", 'storage' => $type])
));
break;
case SongStorageTypes::DROPBOX:
$this->streamerFactory->createStreamer(
Song::factory()->make(['path' => "dropbox://foo.mp3", 'storage' => $type])
);
break;
case SongStorageTypes::LOCAL:
self::assertInstanceOf(PhpStreamer::class, $this->streamerFactory->createStreamer(
Song::factory()->make(['path' => test_path('songs/blank.mp3'), 'storage' => $type])
));
break;
default:
throw new Exception("Unhandled storage type: $type");
}
});
}
}

View file

@ -0,0 +1,97 @@
<?php
namespace Tests\Integration\Services\Streamer;
use App\Exceptions\KoelPlusRequiredException;
use App\Models\Song;
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
use App\Services\Streamer\Adapters\PhpStreamerAdapter;
use App\Services\Streamer\Adapters\S3CompatibleStreamerAdapter;
use App\Services\Streamer\Adapters\TranscodingStreamerAdapter;
use App\Services\Streamer\Adapters\XAccelRedirectStreamerAdapter;
use App\Services\Streamer\Adapters\XSendFileStreamerAdapter;
use App\Services\Streamer\Streamer;
use App\Values\SongStorageTypes;
use Exception;
use Illuminate\Support\Facades\File;
use Tests\TestCase;
use function Tests\test_path;
class StreamerTest extends TestCase
{
public function testResolveAdapters(): void
{
collect(SongStorageTypes::ALL_TYPES)
->each(static function (?string $type): void {
/** @var Song $song */
$song = Song::factory()->make(['storage' => $type]);
switch ($type) {
case SongStorageTypes::S3:
case SongStorageTypes::DROPBOX:
self::expectException(KoelPlusRequiredException::class);
new Streamer($song);
break;
case SongStorageTypes::S3_LAMBDA:
self::assertInstanceOf(S3CompatibleStreamerAdapter::class, (new Streamer($song))->getAdapter());
break;
case SongStorageTypes::LOCAL:
self::assertInstanceOf(LocalStreamerAdapter::class, (new Streamer($song))->getAdapter());
break;
default:
throw new Exception("Unhandled storage type: $type");
}
});
}
public function testResolveTranscodingAdapter(): void
{
config(['koel.streaming.transcode_flac' => true]);
File::partialMock()->shouldReceive('mimeType')->andReturn('audio/flac');
/** @var Song $song */
$song = Song::factory()->make(['path' => test_path('songs/blank.mp3')]);
self::assertInstanceOf(TranscodingStreamerAdapter::class, (new Streamer($song))->getAdapter());
config(['koel.streaming.transcode_flac' => false]);
}
public function testForceTranscodingAdapter(): void
{
/** @var Song $song */
$song = Song::factory()->make(['path' => test_path('songs/blank.mp3')]);
self::assertInstanceOf(
TranscodingStreamerAdapter::class,
(new Streamer($song, null, ['transcode' => true]))->getAdapter()
);
}
/** @return array<mixed> */
public function provideStreamConfigData(): array
{
return [
PhpStreamerAdapter::class => [null, PhpStreamerAdapter::class],
XSendFileStreamerAdapter::class => ['x-sendfile', XSendFileStreamerAdapter::class],
XAccelRedirectStreamerAdapter::class => ['x-accel-redirect', XAccelRedirectStreamerAdapter::class],
];
}
/** @dataProvider provideStreamConfigData */
public function testResolveLocalAdapter(?string $config, string $expectedClass): void
{
config(['koel.streaming.method' => $config]);
/** @var Song $song */
$song = Song::factory()->make(['path' => test_path('songs/blank.mp3')]);
self::assertInstanceOf($expectedClass, (new Streamer($song))->getAdapter());
config(['koel.streaming.method' => null]);
}
}