feat: add koel:doctor artisan command

This commit is contained in:
Phan An 2024-09-08 13:21:06 +02:00
parent 8257e74099
commit f5861ad518
12 changed files with 475 additions and 30 deletions

View 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;
}
}

View file

@ -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/');
} }

View file

@ -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);

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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;
} }

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View file

@ -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.

View file

@ -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 &lt;input random strings here&gt; 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 &lt;input random strings here&gt; 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