mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: add koel:doctor artisan command
This commit is contained in:
parent
8257e74099
commit
f5861ad518
12 changed files with 475 additions and 30 deletions
399
app/Console/Commands/DoctorCommand.php
Normal file
399
app/Console/Commands/DoctorCommand.php
Normal file
|
@ -0,0 +1,399 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Enums\SongStorageType;
|
||||||
|
use App\Facades\License;
|
||||||
|
use App\Http\Integrations\Lastfm\LastfmConnector;
|
||||||
|
use App\Http\Integrations\Lastfm\Requests\GetArtistInfoRequest;
|
||||||
|
use App\Http\Integrations\Spotify\SpotifyClient;
|
||||||
|
use App\Http\Integrations\YouTube\Requests\SearchVideosRequest;
|
||||||
|
use App\Http\Integrations\YouTube\YouTubeConnector;
|
||||||
|
use App\Models\Artist;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Song;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\LastfmService;
|
||||||
|
use App\Services\SongStorages\SongStorage;
|
||||||
|
use App\Services\SpotifyService;
|
||||||
|
use App\Services\YouTubeService;
|
||||||
|
use Closure;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Mail\Message;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Throwable;
|
||||||
|
use TiBeN\CrontabManager\CrontabAdapter;
|
||||||
|
use TiBeN\CrontabManager\CrontabRepository;
|
||||||
|
|
||||||
|
class DoctorCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'koel:doctor';
|
||||||
|
protected $description = 'Check Koel setup';
|
||||||
|
|
||||||
|
private array $errors = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (PHP_OS_FAMILY === 'Windows' || PHP_OS_FAMILY === 'Unknown') {
|
||||||
|
$this->components->error('This command is only available on Linux systems.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->alert('Checking Koel setup...');
|
||||||
|
$this->line('');
|
||||||
|
|
||||||
|
if (exec('whoami') === 'root') {
|
||||||
|
$this->components->error('This command cannot be run as root.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->checkFrameworkDirectoryPermissions();
|
||||||
|
$this->checkMediaStorage();
|
||||||
|
$this->checkDatabaseConnection();
|
||||||
|
$this->checkFullTextSearch();
|
||||||
|
$this->checkApiHealth();
|
||||||
|
$this->checkFFMpeg();
|
||||||
|
$this->checkPhpExtensions();
|
||||||
|
$this->checkPhpConfiguration();
|
||||||
|
$this->checkStreamingMethod();
|
||||||
|
$this->checkServiceIntegrations();
|
||||||
|
$this->checkMailConfiguration();
|
||||||
|
$this->checkScheduler();
|
||||||
|
$this->checkPlusLicense();
|
||||||
|
|
||||||
|
if ($this->errors) {
|
||||||
|
$this->reportErroneousResult();
|
||||||
|
} else {
|
||||||
|
$this->output->success('Your Koel setup should be good to go!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportErroneousResult(): void
|
||||||
|
{
|
||||||
|
$this->components->error('There are errors in your Koel setup. Koel will not work properly.');
|
||||||
|
|
||||||
|
if (File::isWritable(base_path('storage/logs/laravel.log'))) {
|
||||||
|
/** @var Throwable $error */
|
||||||
|
foreach ($this->errors as $error) {
|
||||||
|
Log::error('[KOEL.DOCTOR] ' . $error->getMessage(), ['error' => $error]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->error('You can find more details in ' . base_path('storage/logs/laravel.log'));
|
||||||
|
} else {
|
||||||
|
$this->components->error('The list of errors is as follows:');
|
||||||
|
|
||||||
|
/** @var Throwable $error */
|
||||||
|
foreach ($this->errors as $i => $error) {
|
||||||
|
$this->line(" <error>[$i]</error> " . $error->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkPlusLicense(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$status = License::getStatus(checkCache: false);
|
||||||
|
|
||||||
|
if ($status->hasNoLicense()) {
|
||||||
|
$this->reportInfo('Koel Plus license status', 'Not available');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status->isValid()) {
|
||||||
|
$this->reportSuccess('Koel Plus license status', 'Active');
|
||||||
|
} else {
|
||||||
|
$this->reportError('Koel Plus license status', 'Invalid');
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportWarning('Cannot check for Koel Plus license status');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkScheduler(): void
|
||||||
|
{
|
||||||
|
$crontab = new CrontabRepository(new CrontabAdapter());
|
||||||
|
|
||||||
|
if (InstallSchedulerCommand::schedulerInstalled($crontab)) {
|
||||||
|
$this->reportSuccess('Koel scheduler status', 'Installed');
|
||||||
|
} else {
|
||||||
|
$this->reportWarning('Koel scheduler status', 'Not installed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkMailConfiguration(): void
|
||||||
|
{
|
||||||
|
if (!config('mail.default') || config('mail.default') === 'log') {
|
||||||
|
$this->reportWarning('Mailer configuration', 'Not available');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recipient = Str::uuid() . '@mailinator.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::raw('This is a test email.', static fn (Message $message) => $message->to($recipient));
|
||||||
|
$this->reportSuccess('Mailer configuration');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('Mailer configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkServiceIntegrations(): void
|
||||||
|
{
|
||||||
|
if (!LastfmService::enabled()) {
|
||||||
|
$this->reportWarning('Last.fm integration', 'Not available');
|
||||||
|
} else {
|
||||||
|
/** @var LastfmConnector $connector */
|
||||||
|
$connector = app(LastfmConnector::class);
|
||||||
|
|
||||||
|
/** @var Artist $artist */
|
||||||
|
$artist = Artist::factory()->make(['name' => 'Pink Floyd']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dto = $connector->send(new GetArtistInfoRequest($artist))->dto();
|
||||||
|
|
||||||
|
if (!$dto) {
|
||||||
|
throw new Exception('No data returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reportSuccess('Last.fm integration');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('Last.fm integration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!YouTubeService::enabled()) {
|
||||||
|
$this->reportWarning('YouTube integration', 'Not available');
|
||||||
|
} else {
|
||||||
|
/** @var YouTubeConnector $connector */
|
||||||
|
$connector = app(YouTubeConnector::class);
|
||||||
|
|
||||||
|
/** @var Song $artist */
|
||||||
|
$song = Song::factory()->forArtist(['name' => 'Pink Floyd'])->make(['title' => 'Comfortably Numb']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$object = $connector->send(new SearchVideosRequest($song))->object();
|
||||||
|
|
||||||
|
if (object_get($object, 'error')) {
|
||||||
|
throw new Exception(object_get($object, 'error.message'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reportSuccess('YouTube integration');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('YouTube integration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SpotifyService::enabled()) {
|
||||||
|
$this->reportWarning('Spotify integration', 'Not available');
|
||||||
|
} else {
|
||||||
|
/** @var SpotifyService $service */
|
||||||
|
$service = app(SpotifyService::class);
|
||||||
|
Cache::forget(SpotifyClient::ACCESS_TOKEN_CACHE_KEY);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$image = $service->tryGetArtistImage(Artist::factory()->make([
|
||||||
|
'id' => 999,
|
||||||
|
'name' => 'Pink Floyd',
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (!$image) {
|
||||||
|
throw new Exception('No result returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reportSuccess('Spotify integration');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('Spotify integration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkStreamingMethod(): void
|
||||||
|
{
|
||||||
|
$this->reportInfo('Streaming method', config('koel.streaming.method'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkPhpConfiguration(): void
|
||||||
|
{
|
||||||
|
$this->reportInfo('Max upload size', ini_get('upload_max_filesize'));
|
||||||
|
$this->reportInfo('Max post size', ini_get('post_max_size'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkPhpExtensions(): void
|
||||||
|
{
|
||||||
|
$this->assert(
|
||||||
|
condition: extension_loaded('zip'),
|
||||||
|
success: 'PHP extension <info>zip</info> is loaded. Multi-file downloading is supported.',
|
||||||
|
warning: 'PHP extension <info>zip</info> is not loaded. Multi-file downloading will not be available.',
|
||||||
|
);
|
||||||
|
|
||||||
|
// as "gd" and "SimpleXML" are both required in the composer.json file, we don't need to check for them
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkFFMpeg(): void
|
||||||
|
{
|
||||||
|
$ffmpegPath = config('koel.streaming.ffmpeg_path');
|
||||||
|
|
||||||
|
if ($ffmpegPath) {
|
||||||
|
$this->assert(
|
||||||
|
condition: File::exists($ffmpegPath) && is_executable($ffmpegPath),
|
||||||
|
success: "FFmpeg binary <info>$ffmpegPath</info> is executable.",
|
||||||
|
warning: "FFmpeg binary <info>$ffmpegPath</info> does not exist or is not executable. "
|
||||||
|
. 'Transcoding will not be available.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$this->reportWarning('FFmpeg path is not set. Transcoding will not be available.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkApiHealth(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
Http::get(config('app.url') . '/api/ping');
|
||||||
|
$this->reportSuccess('API is healthy');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('API is healthy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkFullTextSearch(): void
|
||||||
|
{
|
||||||
|
if (config('scout.driver') === 'tntsearch') {
|
||||||
|
$this->assertDirectoryPermissions(base_path('storage/search-indexes'), 'TNT search index');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config('scout.driver') === 'algolia') {
|
||||||
|
try {
|
||||||
|
Song::search('foo')->raw();
|
||||||
|
$this->reportSuccess('Full-text search (using Algolia)');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('Full-text search (using Algolia)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkDatabaseConnection(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
User::query()->count('id');
|
||||||
|
$this->reportSuccess('Checking database connection');
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError('Checking database connection');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkMediaStorage(): void
|
||||||
|
{
|
||||||
|
/** @var SongStorage $storage */
|
||||||
|
$storage = app(SongStorage::class);
|
||||||
|
$name = $storage->getStorageType()->value ?: 'local';
|
||||||
|
|
||||||
|
if (!$storage->getStorageType()->supported()) {
|
||||||
|
$this->reportError("Media storage driver <info>$name</info>", 'Not supported');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($storage->getStorageType() === SongStorageType::LOCAL && !Setting::get('media_path')) {
|
||||||
|
$this->reportWarning('Media path', 'Not set');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$storage->testSetup();
|
||||||
|
$this->reportSuccess("Media storage setup (<info>$name</info>)");
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->collectError($e);
|
||||||
|
$this->reportError("Media storage setup (<info>$name</info>)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkFrameworkDirectoryPermissions(): void
|
||||||
|
{
|
||||||
|
$this->assertDirectoryPermissions(base_path('storage/framework/sessions'), 'Session');
|
||||||
|
$this->assertDirectoryPermissions(base_path('storage/framework/cache'), 'Cache');
|
||||||
|
$this->assertDirectoryPermissions(base_path('storage/logs'), 'Log');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportError(string $message, ?string $value = 'ERROR'): void
|
||||||
|
{
|
||||||
|
$this->components->twoColumnDetail($message, "<error>$value</error>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportSuccess(string $message, ?string $value = 'OK'): void
|
||||||
|
{
|
||||||
|
$this->components->twoColumnDetail($message, "<info>$value</info>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportWarning(string $message, ?string $second = 'WARNING'): void
|
||||||
|
{
|
||||||
|
$this->components->twoColumnDetail($message, "<comment>$second</comment>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reportInfo(string $message, ?string $value = null): void
|
||||||
|
{
|
||||||
|
$this->components->twoColumnDetail($message, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertDirectoryPermissions(string $path, string $name): void
|
||||||
|
{
|
||||||
|
$this->assert(
|
||||||
|
condition: File::isReadable($path) && File::isWritable($path),
|
||||||
|
success: "$name directory <info>$path</info> is readable/writable.",
|
||||||
|
error: "$name directory <info>$path</info> is not readable/writable.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assert(
|
||||||
|
Closure|bool $condition,
|
||||||
|
Closure|string|null $success = null,
|
||||||
|
Closure|string|null $error = null,
|
||||||
|
Closure|string|null $warning = null,
|
||||||
|
): void {
|
||||||
|
$result = value($condition);
|
||||||
|
|
||||||
|
if ($result) {
|
||||||
|
$this->reportSuccess(value($success));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error && $warning) {
|
||||||
|
throw new InvalidArgumentException('Cannot have both error and warning.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($error) {
|
||||||
|
$this->reportError(value($error));
|
||||||
|
} else {
|
||||||
|
$this->reportWarning(value($warning));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function collectError(Throwable $e): void
|
||||||
|
{
|
||||||
|
$this->errors[] = $e;
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,7 +42,7 @@ class InstallSchedulerCommand extends Command
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function schedulerInstalled(CrontabRepository $crontab): bool
|
public static function schedulerInstalled(CrontabRepository $crontab): bool
|
||||||
{
|
{
|
||||||
return (bool) $crontab->findJobByRegex('/artisan schedule:run/');
|
return (bool) $crontab->findJobByRegex('/artisan schedule:run/');
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,8 @@ use SpotifyWebAPI\SpotifyWebAPI;
|
||||||
*/
|
*/
|
||||||
class SpotifyClient
|
class SpotifyClient
|
||||||
{
|
{
|
||||||
|
public const ACCESS_TOKEN_CACHE_KEY = 'spotify.access_token';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public SpotifyWebAPI $wrapped,
|
public SpotifyWebAPI $wrapped,
|
||||||
private readonly ?Session $session,
|
private readonly ?Session $session,
|
||||||
|
@ -26,14 +28,14 @@ class SpotifyClient
|
||||||
|
|
||||||
private function setAccessToken(): void
|
private function setAccessToken(): void
|
||||||
{
|
{
|
||||||
$token = $this->cache->get('spotify.access_token');
|
$token = $this->cache->get(self::ACCESS_TOKEN_CACHE_KEY);
|
||||||
|
|
||||||
if (!$token) {
|
if (!$token) {
|
||||||
$this->session->requestCredentialsToken();
|
$this->session->requestCredentialsToken();
|
||||||
$token = $this->session->getAccessToken();
|
$token = $this->session->getAccessToken();
|
||||||
|
|
||||||
// Spotify's tokens expire after 1 hour, so we'll cache them with some buffer to an extra call.
|
// Spotify's tokens expire after 1 hour, so we'll cache them with some buffer to an extra call.
|
||||||
$this->cache->put('spotify.access_token', $token, 59 * 60);
|
$this->cache->put(self::ACCESS_TOKEN_CACHE_KEY, $token, 59 * 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->wrapped->setAccessToken($token);
|
$this->wrapped->setAccessToken($token);
|
||||||
|
|
|
@ -93,7 +93,7 @@ final class DropboxStorage extends CloudStorage
|
||||||
$this->filesystem->delete('test.txt');
|
$this->filesystem->delete('test.txt');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getStorageType(): SongStorageType
|
public function getStorageType(): SongStorageType
|
||||||
{
|
{
|
||||||
return SongStorageType::DROPBOX;
|
return SongStorageType::DROPBOX;
|
||||||
}
|
}
|
||||||
|
|
|
@ -101,7 +101,18 @@ final class LocalStorage extends SongStorage
|
||||||
throw_unless(File::delete($path), new Exception("Failed to delete song file: $path"));
|
throw_unless(File::delete($path), new Exception("Failed to delete song file: $path"));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getStorageType(): SongStorageType
|
public function testSetup(): void
|
||||||
|
{
|
||||||
|
$mediaPath = Setting::get('media_path');
|
||||||
|
|
||||||
|
if (File::isReadable($mediaPath) && File::isWritable($mediaPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception("The media path $mediaPath is not readable or writable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getStorageType(): SongStorageType
|
||||||
{
|
{
|
||||||
return SongStorageType::LOCAL;
|
return SongStorageType::LOCAL;
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,7 +62,7 @@ class S3CompatibleStorage extends CloudStorage
|
||||||
Storage::disk('s3')->delete('test.txt');
|
Storage::disk('s3')->delete('test.txt');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getStorageType(): SongStorageType
|
public function getStorageType(): SongStorageType
|
||||||
{
|
{
|
||||||
return SongStorageType::S3;
|
return SongStorageType::S3;
|
||||||
}
|
}
|
||||||
|
|
|
@ -89,7 +89,7 @@ final class S3LambdaStorage extends S3CompatibleStorage
|
||||||
throw new MethodNotImplementedException('Lambda storage does not support deleting from filesystem.');
|
throw new MethodNotImplementedException('Lambda storage does not support deleting from filesystem.');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getStorageType(): SongStorageType
|
public function getStorageType(): SongStorageType
|
||||||
{
|
{
|
||||||
return SongStorageType::S3_LAMBDA;
|
return SongStorageType::S3_LAMBDA;
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,12 +74,18 @@ final class SftpStorage extends SongStorage
|
||||||
return $localPath;
|
return $localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSetup(): void
|
||||||
|
{
|
||||||
|
Storage::disk('sftp')->put('test.txt', 'Koel test file');
|
||||||
|
Storage::disk('sftp')->delete('test.txt');
|
||||||
|
}
|
||||||
|
|
||||||
private function generateRemotePath(string $filename, User $uploader): string
|
private function generateRemotePath(string $filename, User $uploader): string
|
||||||
{
|
{
|
||||||
return sprintf('%s__%s__%s', $uploader->id, Str::lower(Ulid::generate()), $filename);
|
return sprintf('%s__%s__%s', $uploader->id, Str::lower(Ulid::generate()), $filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getStorageType(): SongStorageType
|
public function getStorageType(): SongStorageType
|
||||||
{
|
{
|
||||||
return SongStorageType::SFTP;
|
return SongStorageType::SFTP;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,12 +10,14 @@ use Illuminate\Http\UploadedFile;
|
||||||
|
|
||||||
abstract class SongStorage
|
abstract class SongStorage
|
||||||
{
|
{
|
||||||
abstract protected function getStorageType(): SongStorageType;
|
abstract public function getStorageType(): SongStorageType;
|
||||||
|
|
||||||
abstract public function storeUploadedFile(UploadedFile $file, User $uploader): Song;
|
abstract public function storeUploadedFile(UploadedFile $file, User $uploader): Song;
|
||||||
|
|
||||||
abstract public function delete(Song $song, bool $backup = false): void;
|
abstract public function delete(Song $song, bool $backup = false): void;
|
||||||
|
|
||||||
|
abstract public function testSetup(): void;
|
||||||
|
|
||||||
protected function assertSupported(): void
|
protected function assertSupported(): void
|
||||||
{
|
{
|
||||||
throw_unless(
|
throw_unless(
|
||||||
|
|
BIN
docs/assets/img/doctor.webp
Normal file
BIN
docs/assets/img/doctor.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 90 KiB |
|
@ -13,6 +13,7 @@ You can run `php artisan list` from your Koel installation directory and pipe th
|
||||||
php artisan list | grep koel
|
php artisan list | grep koel
|
||||||
koel
|
koel
|
||||||
koel:admin:change-password Change a user's password
|
koel:admin:change-password Change a user's password
|
||||||
|
koel:doctor Check Koel setup
|
||||||
koel:init Install or upgrade Koel
|
koel:init Install or upgrade Koel
|
||||||
koel:license:activate Activate a Koel Plus license
|
koel:license:activate Activate a Koel Plus license
|
||||||
koel:license:deactivate Deactivate the currently active Koel Plus license
|
koel:license:deactivate Deactivate the currently active Koel Plus license
|
||||||
|
@ -52,6 +53,16 @@ php artisan koel:admin:change-password [<email>]
|
||||||
|---------|--------------------------------------------------------------|
|
|---------|--------------------------------------------------------------|
|
||||||
| `email` | The user's email. If empty, will get the default admin user. |
|
| `email` | The user's email. If empty, will get the default admin user. |
|
||||||
|
|
||||||
|
### `koel:doctor`
|
||||||
|
|
||||||
|
Check Koel setup.
|
||||||
|
|
||||||
|
#### Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan koel:doctor
|
||||||
|
```
|
||||||
|
|
||||||
### `koel:init`
|
### `koel:init`
|
||||||
|
|
||||||
Install or upgrade Koel.
|
Install or upgrade Koel.
|
||||||
|
|
|
@ -37,45 +37,59 @@ php artisan cache:clear
|
||||||
php artisan config:clear
|
php artisan config:clear
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Run Koel Doctor
|
||||||
|
|
||||||
|
For Linux and macOS systems, Koel comes with a `doctor` command that checks your setup for common issues.
|
||||||
|
You can run it by executing the following **as your web server user**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan koel:doctor
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will check your environment and configuration for common issues (file/folder permissions, storage setup, server configuration, etc.) and provide you with a report.
|
||||||
|
An example output might look like this:
|
||||||
|
|
||||||
|
![Koel's homepage](../assets/img/doctor.webp)
|
||||||
|
|
||||||
If you're still stuck, check below for a couple of common issues and their solutions.
|
If you're still stuck, check below for a couple of common issues and their solutions.
|
||||||
|
|
||||||
## Common Issues
|
## Common Issues
|
||||||
|
|
||||||
### You run into a permission issue
|
::: details You run into a permission issue
|
||||||
|
Make sure your web server has the necessary permissions to _recursively_ read/write to critical folders like `storage`, `bootstrap/cache`, and `public`.
|
||||||
Solution: Make sure your web server has the necessary permissions to _recursively_ read/write to critical folders like `storage`, `bootstrap/cache`, and `public`.
|
|
||||||
Also, remember to run artisan commands as your web server user (e.g. `www-data` or `nginx`), **never** as `root`, as these commands might create files that your web server user must have access to.
|
Also, remember to run artisan commands as your web server user (e.g. `www-data` or `nginx`), **never** as `root`, as these commands might create files that your web server user must have access to.
|
||||||
If you use the Docker installation, for example, run the scan command as the `www-data` user as follows:
|
If you use the Docker installation, for example, run the scan command as the `www-data` user as follows:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker exec --user www-data <container_name_for_koel> php artisan koel:scan
|
docker exec --user www-data <container_name_for_koel> php artisan koel:scan
|
||||||
```
|
```
|
||||||
|
:::
|
||||||
|
|
||||||
### You receive a `Class 'Pusher' not found` error
|
::: details You receive a `Class 'Pusher' not found` error
|
||||||
|
Add or set `BROADCAST_DRIVER=log` in your `.env` file. This will instruct Laravel to use `log` as the default broadcast driver instead.
|
||||||
|
:::
|
||||||
|
|
||||||
Solution: Add or set `BROADCAST_DRIVER=log` in your `.env` file. This will instruct Laravel to use `log` as the default broadcast driver instead.
|
::: details You receive an "Unknown error" when scanning using the web interface
|
||||||
|
Try scanning from the command line with `php artisan koel:sync`. Most of the time, you should receive a more detailed, easier to debug, message.
|
||||||
### You receive an "Unknown error" when scanning using the web interface
|
|
||||||
|
|
||||||
Solution: Try scanning from the command line with `php artisan koel:sync`. Most of the time, you should receive a more detailed, easier to debug, message.
|
|
||||||
See also: [Music Discovery](usage/music-discovery).
|
See also: [Music Discovery](usage/music-discovery).
|
||||||
|
:::
|
||||||
|
|
||||||
### You receive an `Integrity constraint violation: 1062 Duplicate entry for key 'artists_name_unique'` error when scanning
|
::: details You receive an `Integrity constraint violation: 1062 Duplicate entry for key 'artists_name_unique'` error when scanning
|
||||||
|
Set your database and table collation to `utf8_unicode_ci` or `utf8mb4_unicode_ci`.
|
||||||
|
:::
|
||||||
|
|
||||||
Solution: Set your database and table collation to `utf8_unicode_ci`.
|
::: details You receive an <input random strings here> error when running `yarn`
|
||||||
|
This most likely has little to do with Koel but more with your node/npm/yarn environment and installation. Deleting `node_modules` and rerunning the command sometimes help.
|
||||||
|
:::
|
||||||
|
|
||||||
### You receive an <input random strings here> error when running `yarn`
|
::: details Song stops playing, and you receive a `Failed to load resource: net::ERR_CONTENT_LENGTH_MISMATCH` error
|
||||||
|
This may sometimes happen with the native PHP streaming method. Check [Streaming Music](usage/streaming) for alternatives.
|
||||||
|
:::
|
||||||
|
|
||||||
Solution: This most likely has little to do with Koel but more with your node/npm/yarn environment and installation. Deleting `node_modules` and rerunning the command sometimes help.
|
::: details You receive a `Multiple licenses found` warning when running `koel:license:status` command
|
||||||
|
Koel Plus only requires one license key. If it detects more than one key in the database, the warning will be issued.
|
||||||
### Song stops playing, and you receive a `Failed to load resource: net::ERR_CONTENT_LENGTH_MISMATCH` error
|
|
||||||
|
|
||||||
Solution: This may sometimes happen with the native PHP streaming method. Check [Streaming Music](usage/streaming) for alternatives.
|
|
||||||
|
|
||||||
### You receive a `Multiple licenses found` warning when running `koel:license:status` command
|
|
||||||
|
|
||||||
Solution: Koel Plus only requires one license key. If it detects more than one key in the database, the warning will be issued.
|
|
||||||
Most of the time this shouldn't cause any problem, but if you're experiencing issues, try emptying the `licenses` table and re-activating your license key.
|
Most of the time this shouldn't cause any problem, but if you're experiencing issues, try emptying the `licenses` table and re-activating your license key.
|
||||||
|
:::
|
||||||
|
|
||||||
## Reinstalling Koel
|
## Reinstalling Koel
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue