mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
refactor: use adapters for streamers
This commit is contained in:
parent
ff79332c6a
commit
52285a1c48
27 changed files with 447 additions and 474 deletions
18
app/Exceptions/UnsupportedSongStorageTypeException.php
Normal file
18
app/Exceptions/UnsupportedSongStorageTypeException.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -4,21 +4,18 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Http\Requests\SongPlayRequest;
|
||||
use App\Models\Song;
|
||||
use App\Services\Streamers\StreamerFactory;
|
||||
use App\Services\Streamer\Streamer;
|
||||
|
||||
class PlayController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
StreamerFactory $streamerFactory,
|
||||
SongPlayRequest $request,
|
||||
Song $song,
|
||||
?bool $transcode = null,
|
||||
?int $bitRate = null
|
||||
) {
|
||||
public function __invoke(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null)
|
||||
{
|
||||
$this->authorize('access', $song);
|
||||
|
||||
return $streamerFactory
|
||||
->createStreamer($song, $transcode, $bitRate, (float) $request->time)
|
||||
->stream();
|
||||
return (new Streamer(song: $song, config: [
|
||||
'transcode' => (bool) $transcode,
|
||||
'bit_rate' => $bitRate,
|
||||
'start_time' => (float) $request->time,
|
||||
]))->stream();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,21 +2,21 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Streamers\LocalStreamer;
|
||||
use App\Services\Streamers\PhpStreamer;
|
||||
use App\Services\Streamers\XAccelRedirectStreamer;
|
||||
use App\Services\Streamers\XSendFileStreamer;
|
||||
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\PhpStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\XAccelRedirectStreamerAdapter;
|
||||
use App\Services\Streamer\Adapters\XSendFileStreamerAdapter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class StreamerServiceProvider extends ServiceProvider
|
||||
{
|
||||
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')) {
|
||||
'x-sendfile' => new XSendFileStreamer(),
|
||||
'x-accel-redirect' => new XAccelRedirectStreamer(),
|
||||
default => new PhpStreamer(),
|
||||
'x-sendfile' => $this->app->make(XSendFileStreamerAdapter::class),
|
||||
'x-accel-redirect' => $this->app->make(XAccelRedirectStreamerAdapter::class),
|
||||
default => $this->app->make(PhpStreamerAdapter::class),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
20
app/Services/Streamer/Adapters/DropboxStreamerAdapter.php
Normal file
20
app/Services/Streamer/Adapters/DropboxStreamerAdapter.php
Normal 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));
|
||||
}
|
||||
}
|
7
app/Services/Streamer/Adapters/LocalStreamerAdapter.php
Normal file
7
app/Services/Streamer/Adapters/LocalStreamerAdapter.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Streamer\Adapters;
|
||||
|
||||
abstract class LocalStreamerAdapter implements StreamerAdapter
|
||||
{
|
||||
}
|
|
@ -1,7 +1,8 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Streamers;
|
||||
namespace App\Services\Streamer\Adapters;
|
||||
|
||||
use App\Models\Song;
|
||||
use DaveRandom\Resume\FileResource;
|
||||
use DaveRandom\Resume\InvalidRangeHeaderException;
|
||||
use DaveRandom\Resume\NonExistentFileException;
|
||||
|
@ -14,18 +15,19 @@ use Symfony\Component\HttpFoundation\Response;
|
|||
|
||||
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 {
|
||||
$path = $song->storage_metadata->getPath();
|
||||
$rangeHeader = get_request_header('Range');
|
||||
|
||||
// On Safari, "Range" header value can be "bytes=0-1" which breaks streaming.
|
||||
$rangeHeader = $rangeHeader === 'bytes=0-1' ? 'bytes=0-' : $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);
|
||||
} catch (InvalidRangeHeaderException) {
|
||||
abort(Response::HTTP_BAD_REQUEST);
|
|
@ -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));
|
||||
}
|
||||
}
|
10
app/Services/Streamer/Adapters/StreamerAdapter.php
Normal file
10
app/Services/Streamer/Adapters/StreamerAdapter.php
Normal 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
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -1,25 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Streamers;
|
||||
namespace App\Services\Streamer\Adapters;
|
||||
|
||||
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.
|
||||
* @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)
|
||||
// It will then be use as `alias` in X-Accel config location block.
|
||||
// See nginx.conf.example.
|
||||
header('X-Media-Root: ' . Setting::get('media_path'));
|
||||
header("X-Accel-Redirect: /media/$relativePath");
|
||||
header("Content-Type: $this->contentType");
|
||||
header('Content-Disposition: inline; filename="' . basename($this->song->path) . '"');
|
||||
header("Content-Type: $contentType");
|
||||
header('Content-Disposition: inline; filename="' . basename($path) . '"');
|
||||
|
||||
exit;
|
||||
}
|
23
app/Services/Streamer/Adapters/XSendFileStreamerAdapter.php
Normal file
23
app/Services/Streamer/Adapters/XSendFileStreamerAdapter.php
Normal 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;
|
||||
}
|
||||
}
|
71
app/Services/Streamer/Streamer.php
Normal file
71
app/Services/Streamer/Streamer.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -3,10 +3,9 @@
|
|||
namespace Tests\Feature\KoelPlus;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Services\Streamers\LocalStreamer;
|
||||
use App\Services\Streamer\Adapters\LocalStreamerAdapter;
|
||||
use App\Services\TokenManager;
|
||||
use App\Values\CompositeToken;
|
||||
use Mockery;
|
||||
use Tests\PlusTestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
|
@ -24,13 +23,9 @@ class SongPlayTest extends PlusTestCase
|
|||
'path' => test_path('songs/blank.mp3'),
|
||||
]);
|
||||
|
||||
$mockStreamer = $this->mock(LocalStreamer::class);
|
||||
|
||||
$mockStreamer->shouldReceive('setSong')->with(
|
||||
Mockery::on(static fn (Song $retrievedSong): bool => $retrievedSong->id === $song->id)
|
||||
)->once();
|
||||
|
||||
$mockStreamer->shouldReceive('stream')->once();
|
||||
$this->mock(LocalStreamerAdapter::class)
|
||||
->shouldReceive('stream')
|
||||
->once();
|
||||
|
||||
$this->get("play/$song->id?t=$token->audioToken")
|
||||
->assertOk();
|
||||
|
@ -46,13 +41,9 @@ class SongPlayTest extends PlusTestCase
|
|||
/** @var CompositeToken $token */
|
||||
$token = app(TokenManager::class)->createCompositeToken($song->owner);
|
||||
|
||||
$mockStreamer = $this->mock(LocalStreamer::class);
|
||||
|
||||
$mockStreamer->shouldReceive('setSong')->with(
|
||||
Mockery::on(static fn (Song $retrievedSong): bool => $retrievedSong->id === $song->id)
|
||||
)->once();
|
||||
|
||||
$mockStreamer->shouldReceive('stream')->once();
|
||||
$this->mock(LocalStreamerAdapter::class)
|
||||
->shouldReceive('stream')
|
||||
->once();
|
||||
|
||||
$this->get("play/$song->id?t=$token->audioToken")
|
||||
->assertOk();
|
||||
|
|
|
@ -3,10 +3,11 @@
|
|||
namespace Tests\Feature;
|
||||
|
||||
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\Values\CompositeToken;
|
||||
use Mockery;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Tests\TestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
|
@ -26,15 +27,55 @@ class SongPlayTest extends TestCase
|
|||
'path' => test_path('songs/blank.mp3'),
|
||||
]);
|
||||
|
||||
$mockStreamer = $this->mock(LocalStreamer::class);
|
||||
|
||||
$mockStreamer->shouldReceive('setSong')->with(
|
||||
Mockery::on(static fn (Song $retrievedSong): bool => $retrievedSong->id === $song->id)
|
||||
)->once();
|
||||
|
||||
$mockStreamer->shouldReceive('stream')->once();
|
||||
$this->mock(LocalStreamerAdapter::class)
|
||||
->shouldReceive('stream')
|
||||
->once();
|
||||
|
||||
$this->get("play/$song->id?t=$token->audioToken")
|
||||
->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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
97
tests/Integration/Services/Streamer/StreamerTest.php
Normal file
97
tests/Integration/Services/Streamer/StreamerTest.php
Normal 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]);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue