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\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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
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
|
<?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);
|
|
@ -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
|
<?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;
|
||||||
}
|
}
|
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;
|
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();
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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