This commit is contained in:
Phan An 2024-07-07 22:59:05 +02:00 committed by GitHub
commit 9a1e3d9e36
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
1238 changed files with 36320 additions and 21126 deletions

View file

@ -4,5 +4,5 @@ trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[{*.php, *.xml, *.xml.dist}]
[{*.php,*.xml,*.xml.dist}]
indent_size = 4

View file

@ -21,20 +21,63 @@ DB_DATABASE=koel
DB_USERNAME=koel
DB_PASSWORD=
# Some providers (e.g. Heroku) provide a "database URL" instead separated config values, which
# you can use here instead.
DATABASE_URL=
# The absolute path to the root CA bundle if you're connecting to the MySQL database via SSL.
MYSQL_ATTR_SSL_CA=
# The storage driver. Valid values are:
# local: Store files on the server's local filesystem.
# sftp: Store files on an SFTP server.
# s3: Store files on Amazon S3 or a S3-compatible service (e.g. Cloudflare R2 or DigitalOcean Spaces). Koel Plus only.
# dropbox: Store files on Dropbox. Koel Plus only.
STORAGE_DRIVER=local
# The ABSOLUTE path to your media. This value can always be changed later via the web interface.
# Required if you're using the local file system to store your media (STORAGE_DRIVER=local).
MEDIA_PATH=
# S3 or S3-compatible service settings. Required if you're using S3 to store your media (STORAGE_DRIVER=s3).
# Remember to set CORS policy to allow access from your Koel's domain (or "*").
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# For Cloudflare R2, set this to "auto". For S3 and other services, set this to the region of your bucket.
AWS_REGION=
AWS_ENDPOINT=
AWS_BUCKET=
# Dropbox settings. Required if you're using Dropbox to store your media (STORAGE_DRIVER=dropbox)
# Follow these steps to have these values filled:
# 1. Create a Dropbox app at https://www.dropbox.com/developers/apps
# 2. Run `php artisan koel:setup-dropbox` from the CLI and follow the instructions.
DROPBOX_APP_KEY=
DROPBOX_APP_SECRET=
DROPBOX_REFRESH_TOKEN=
# SFTP settings. Required if you're using SFTP to store your media (STORAGE_DRIVER=sftp).
SFTP_HOST=
SFTP_PORT=
# The absolute path of the directory to store the media files on the SFTP server.
# Make sure the directory exists and is writable by the SFTP user.
SFTP_ROOT=
# You can use either a username/password pair…
SFTP_USERNAME=
SFTP_PASSWORD=
# …or private key authentication:
SFTP_PRIVATE_KEY=
SFTP_PASSPHRASE=
# By default, Koel ignores dot files and folders. This greatly improves performance if your media
# root have folders like .git or .cache. If by any chance your media files are under a dot folder,
# set the following setting to false.
@ -54,7 +97,7 @@ MEMORY_LIMIT=
# The streaming method.
# Can be either 'php' (default), 'x-sendfile', or 'x-accel-redirect'
# See https://docs.koel.dev/#streaming-music for more information.
# See https://docs.koel.dev/usage/streaming for more information.
# Note: This setting doesn't have effect if the media needs transcoding (e.g. FLAC).
# ##################################################
# IMPORTANT: It's HIGHLY recommended to use 'x-sendfile' or 'x-accel-redirect' if
@ -86,21 +129,13 @@ LASTFM_API_SECRET=
# Spotify API can be used to fetch artist and album images.
# To integrate Koel with Spotify, create a Spotify application at
# https://developer.spotify.com/dashboard/applications and set the credentials here.
# Consult Koel's doc for more information.
# Consult Koel's docs for more information.
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
# To use Amazon S3 with Koel, fill the info here and follow the
# installation guide at https://docs.koel.dev/aws-s3.html
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_ENDPOINT=
# To integrate Koel with YouTube, set the API key here.
# See https://docs.koel.dev/3rd-party.html#youtube for more information.
# See https://docs.koel.dev/service-integrations#youtube for more information.
YOUTUBE_API_KEY=
@ -111,29 +146,55 @@ CDN_URL=
# To transcode FLAC to MP3 and stream it on the fly, make sure the following settings are sane.
# If you don't want to transcode FLAC (i.e. to stream it as-is), set this to false.
TRANSCODE_FLAC=false
# The full path of ffmpeg binary.
FFMPEG_PATH=/usr/local/bin/ffmpeg
# The bit rate of the output mp3 stream. Higher value results in better quality,
# but slower streaming and more bandwidth.
OUTPUT_BIT_RATE=128
# Whether to allow song downloading.
# Note that if you're downloading more than one song, Koel will zip them up
# using PHP's ZipArchive. So if the module isn't available in the current
# environment, such a download will (silently) fail.
ALLOW_DOWNLOAD=true
# Whether to create a backup of a song instead of deleting it from the filesystem.
# If true, the song will simply be renamed into a .bak file.
# Whether to create a backup of a song when deleting it from the filesystem.
BACKUP_ON_DELETE=true
# If using SSO, set the providers details here. Koel will automatically enable SSO if these values are set.
# Create an OAuth client and get these values from https://console.developers.google.com/apis/credentials
SSO_GOOGLE_CLIENT_ID=
SSO_GOOGLE_CLIENT_SECRET=
# The domain that users must belong to in order to be able to log in.
SSO_GOOGLE_HOSTED_DOMAIN=yourdomain.com
# Koel can be configured to authenticate users via a reverse proxy.
# To enable this feature, set PROXY_AUTH_ENABLED to true and provide the necessary configuration below.
PROXY_AUTH_ENABLED=false
# The header name that contains the unique identifier for the user
PROXY_AUTH_USER_HEADER=remote-user
# The header name that contains the user's preferred, humanly-readable name
PROXY_AUTH_PREFERRED_NAME_HEADER=remote-preferred-name
# A comma-separated list of allowed proxy IPs or CIDRs, for example, 10.10.1.0/24 or 2001:0db8:/32
# If empty, NO requests will be allowed (which means proxy authentication is disabled).
PROXY_AUTH_ALLOW_LIST=
# Sync logs can be found under storage/logs/. Valid options are:
# all: Log everything (errored-, skipped-, and successfully processed file).
# error: Log errors only. This is the default.
SYNC_LOG_LEVEL=error
# Koel attempts to detect if your website uses HTTPS and generates secure URLs accordingly.
# If this attempt fails for any reason, you can force it by setting this value to true.
FORCE_HTTPS=
@ -156,12 +217,14 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
SQS_PUBLIC_KEY=
SQS_SECRET_KEY=
SQS_QUEUE_PREFIX=
SQS_QUEUE_NAME=
SQS_QUEUE_REGION=
# The variables below are Laravel-specific.
# You can change them if you know what you're doing. Otherwise, just leave them as-is.
BROADCAST_DRIVER=log

View file

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [ 8.0 ]
php-version: [ 8.1, 8.2 ]
mysql-version: [ 5.7, 8.0 ]
fail-fast: false

View file

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [ 8.0, 8.1 ]
php-version: [ 8.1, 8.2 ]
fail-fast: false
services:

View file

@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [ 8.0, 8.1 ]
php-version: [ 8.1, 8.2 ]
fail-fast: false
steps:
- uses: actions/checkout@v1

5
.gitignore vendored
View file

@ -81,8 +81,13 @@ cypress/downloads/
/log
coverage.xml
.phpunit.result.cache
.phpunit.cache
log.json
.php_cs.cache
### VitePress ###
docs/.vitepress/dist
docs/.vitepress/cache
demo-credits.json

View file

@ -1 +1 @@
v6.12.1
v7.0.0

View file

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018 Phan An
Copyright (c) 2015 Phan An
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -15,7 +15,7 @@ the [Official Documentation](https://docs.koel.dev).
## Development
See the [Development Guide](https://docs.koel.dev/#local-development).
See the [Development Guide](https://docs.koel.dev/development).
## Koel Player

View file

@ -2,13 +2,32 @@
namespace App\Builders;
use App\Facades\License;
use App\Models\Album;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class AlbumBuilder extends Builder
{
public function isStandard(): static
public function isStandard(): self
{
return $this->whereNot('albums.id', Album::UNKNOWN_ID);
}
public function accessibleBy(User $user): self
{
if (License::isCommunity()) {
// With the Community license, all albums are accessible by all users.
return $this;
}
return $this->join('songs', static function (JoinClause $join) use ($user): void {
$join->on('albums.id', 'songs.album_id')
->where(static function (JoinClause $query) use ($user): void {
$query->where('songs.owner_id', $user->id)
->orWhere('songs.is_public', true);
});
});
}
}

View file

@ -2,13 +2,32 @@
namespace App\Builders;
use App\Facades\License;
use App\Models\Artist;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class ArtistBuilder extends Builder
{
public function isStandard(): static
public function isStandard(): self
{
return $this->whereNotIn('artists.id', [Artist::UNKNOWN_ID, Artist::VARIOUS_ID]);
}
public function accessibleBy(User $user): self
{
if (License::isCommunity()) {
// With the Community license, all artists are accessible by all users.
return $this;
}
return $this->join('songs', static function (JoinClause $join) use ($user): void {
$join->on('artists.id', 'songs.artist_id')
->where(static function (JoinClause $query) use ($user): void {
$query->where('songs.owner_id', $user->id)
->orWhere('songs.is_public', true);
});
});
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace App\Builders;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
class PodcastBuilder extends Builder
{
public function subscribedBy(User $user): self
{
return $this->join('podcast_user', static function (JoinClause $join) use ($user): void {
$join->on('podcasts.id', 'podcast_user.podcast_id')
->where('user_id', $user->id);
});
}
}

View file

@ -2,13 +2,43 @@
namespace App\Builders;
use App\Facades\License;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Query\JoinClause;
use Webmozart\Assert\Assert;
/**
* @method self logSql()
*/
class SongBuilder extends Builder
{
public function inDirectory(string $path): static
public const SORT_COLUMNS_NORMALIZE_MAP = [
'title' => 'songs.title',
'track' => 'songs.track',
'length' => 'songs.length',
'created_at' => 'songs.created_at',
'disc' => 'songs.disc',
'artist_name' => 'artists.name',
'album_name' => 'albums.name',
'podcast_title' => 'podcasts.title',
'podcast_author' => 'podcasts.author',
];
private const VALID_SORT_COLUMNS = [
'songs.title',
'songs.track',
'songs.length',
'songs.created_at',
'artists.name',
'albums.name',
'podcasts.title',
'podcasts.author',
];
private User $user;
public function inDirectory(string $path): self
{
// Make sure the path ends with a directory separator.
$path = rtrim(trim($path), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
@ -16,15 +46,22 @@ class SongBuilder extends Builder
return $this->where('path', 'LIKE', "$path%");
}
public function withMeta(User $user): static
public function withMeta(bool $requiresInteractions = false): self
{
$joinClosure = function (JoinClause $join): void {
$join->on('interactions.song_id', 'songs.id')->where('interactions.user_id', $this->user->id);
};
return $this
->with('artist', 'album', 'album.artist')
->leftJoin('interactions', static function (JoinClause $join) use ($user): void {
$join->on('interactions.song_id', '=', 'songs.id')->where('interactions.user_id', $user->id);
})
->join('albums', 'songs.album_id', '=', 'albums.id')
->join('artists', 'songs.artist_id', '=', 'artists.id')
->when(
$requiresInteractions,
static fn (self $query) => $query->join('interactions', $joinClosure),
static fn (self $query) => $query->leftJoin('interactions', $joinClosure)
)
->leftJoin('albums', 'songs.album_id', 'albums.id')
->leftJoin('artists', 'songs.artist_id', 'artists.id')
->distinct('songs.id')
->select(
'songs.*',
'albums.name',
@ -34,8 +71,79 @@ class SongBuilder extends Builder
);
}
public function hostedOnS3(): static
public function accessible(): self
{
return $this->where('path', 'LIKE', 's3://%');
if (License::isCommunity()) {
// In the Community Edition, all songs are accessible by all users.
return $this;
}
// We want to alias both podcasts and podcast_user tables to avoid possible conflicts with other joins.
return $this->leftJoin('podcasts as podcasts_a11y', 'songs.podcast_id', 'podcasts_a11y.id')
->leftJoin('podcast_user as podcast_user_a11y', function (JoinClause $join): void {
$join->on('podcasts_a11y.id', 'podcast_user_a11y.podcast_id')
->where('podcast_user_a11y.user_id', $this->user->id);
})
->where(function (Builder $query): void {
// Songs must be public or owned by the user.
$query->where('songs.is_public', true)
->orWhere('songs.owner_id', $this->user->id);
})->whereNot(static function (Builder $query): void {
// Episodes must belong to a podcast that the user is not subscribed to.
$query->whereNotNull('songs.podcast_id')
->whereNull('podcast_user_a11y.podcast_id');
});
}
private function sortByOneColumn(string $column, string $direction): self
{
$column = self::normalizeSortColumn($column);
Assert::oneOf($column, self::VALID_SORT_COLUMNS);
Assert::oneOf(strtolower($direction), ['asc', 'desc']);
return $this
->orderBy($column, $direction)
->when($column === 'artists.name', static fn (self $query) => $query->orderBy('albums.name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title'))
->when($column === 'albums.name', static fn (self $query) => $query->orderBy('artists.name')
->orderBy('songs.disc')
->orderBy('songs.track')
->orderBy('songs.title'))
->when($column === 'track', static fn (self $query) => $query->orderBy('songs.disc')
->orderBy('songs.track'));
}
public function sort(array $columns, string $direction): self
{
$this->leftJoin('podcasts', 'songs.podcast_id', 'podcasts.id');
foreach ($columns as $column) {
$this->sortByOneColumn($column, $direction);
}
return $this;
}
private static function normalizeSortColumn(string $column): string
{
return key_exists($column, self::SORT_COLUMNS_NORMALIZE_MAP)
? self::SORT_COLUMNS_NORMALIZE_MAP[$column]
: $column;
}
public function storedOnCloud(): self
{
return $this->whereNotNull('storage')
->where('storage', '!=', '');
}
public function forUser(User $user): self
{
$this->user = $user;
return $this;
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Facades\Crypt;
class EncryptedValueCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?string
{
return $value ? Crypt::decrypt($value) : null;
}
public function set($model, string $key, $value, array $attributes): ?string
{
return $value ? Crypt::encrypt($value) : null;
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Casts;
use App\Values\LicenseInstance;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Facades\Log;
use Throwable;
class LicenseInstanceCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?LicenseInstance
{
try {
return $value ? LicenseInstance::fromJsonObject(json_decode($value)) : null;
} catch (Throwable) {
Log::error('Failed to cast-get license instance', [
'model' => $model,
'key' => $key,
'value' => $value,
'attributes' => $attributes,
]);
return null;
}
}
/** @param ?LicenseInstance $value */
public function set($model, string $key, $value, array $attributes): ?string
{
try {
return $value?->toJson();
} catch (Throwable) {
Log::error('Failed to cast-set license instance', [
'model' => $model,
'key' => $key,
'value' => $value,
'attributes' => $attributes,
]);
return null;
}
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Casts;
use App\Values\LicenseMeta;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Facades\Log;
use Throwable;
class LicenseMetaCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): ?LicenseMeta
{
try {
return $value ? LicenseMeta::fromJsonObject(json_decode($value)) : null;
} catch (Throwable) {
Log::error('Failed to cast-get license meta', [
'model' => $model,
'key' => $key,
'value' => $value,
'attributes' => $attributes,
]);
return null;
}
}
/** @param ?LicenseMeta $value */
public function set($model, string $key, $value, array $attributes): ?string
{
try {
return $value?->toJson();
} catch (Throwable) {
Log::error('Failed to cast-set license meta', [
'model' => $model,
'key' => $key,
'value' => $value,
'attributes' => $attributes,
]);
return null;
}
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\CategoryCollection;
use Throwable;
class CategoriesCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): CategoryCollection
{
try {
return CategoryCollection::fromArray($value ? json_decode($value, true) : []);
} catch (Throwable) {
return CategoryCollection::make();
}
}
/** @param CategoryCollection|array<mixed> $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = CategoryCollection::fromArray($value);
}
return $value->toJson();
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\Enclosure;
class EnclosureCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): Enclosure
{
return Enclosure::fromArray(json_decode($value, true));
}
/** @param Enclosure|array $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = Enclosure::fromArray($value);
}
return $value->toJson();
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\EpisodeMetadata;
use Throwable;
class EpisodeMetadataCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): EpisodeMetadata
{
try {
return EpisodeMetadata::fromArray(json_decode($value, true));
} catch (Throwable) {
return EpisodeMetadata::fromArray([]);
}
}
/** @param EpisodeMetadata|array<mixed>|null $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = EpisodeMetadata::fromArray($value);
}
return $value?->toJson() ?? json_encode([]);
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Casts\Podcast;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use PhanAn\Poddle\Values\ChannelMetadata;
use Throwable;
class PodcastMetadataCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): ChannelMetadata
{
try {
return ChannelMetadata::fromArray(json_decode($value, true));
} catch (Throwable) {
return ChannelMetadata::fromArray([]);
}
}
/** @param ChannelMetadata|array<mixed>|null $value */
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (is_array($value)) {
$value = ChannelMetadata::fromArray($value);
}
return $value?->toJson() ?? json_encode([]);
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Casts\Podcast;
use App\Values\Podcast\PodcastState;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class PodcastStateCast implements CastsAttributes
{
public function get(Model $model, string $key, mixed $value, array $attributes): PodcastState
{
if (is_string($value)) {
$value = json_decode($value, true);
}
return PodcastState::fromArray($value ?? []);
}
/**
* @param PodcastState|array|null $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if (is_array($value)) {
$value = PodcastState::fromArray($value);
}
return $value?->toJson();
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class SongLyricsCast implements CastsAttributes
{
/** @param string|null $value */
public function get(Model $model, string $key, mixed $value, array $attributes): string
{
if (!$value) {
return '';
}
// Since we're displaying the lyrics using <pre>, replace breaks with newlines and strip all tags.
$value = strip_tags(preg_replace('#<br\s*/?>#i', PHP_EOL, $value));
// also remove the timestamps that often come with LRC files
return preg_replace('/\[\d{2}:\d{2}.\d{2}]\s*/m', '', $value);
}
public function set(Model $model, string $key, mixed $value, array $attributes): mixed
{
return $value;
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Casts;
use App\Enums\SongStorageType;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class SongStorageCast implements CastsAttributes
{
/** @param string|null $value */
public function get(Model $model, string $key, mixed $value, array $attributes): SongStorageType
{
return SongStorageType::tryFrom($value) ?? SongStorageType::LOCAL;
}
/** @param SongStorageType|string|null $value */
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
$type = $value instanceof SongStorageType ? $value : SongStorageType::tryFrom($value);
return $type?->value;
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Casts;
use App\Models\Song;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
class SongTitleCast implements CastsAttributes
{
/**
* @param Song $model
* @param string|null $value
*/
public function get(Model $model, string $key, mixed $value, array $attributes): string
{
// If the title is empty, we "guess" the title by extracting the filename from the song's path.
return $value ?: pathinfo($model->path, PATHINFO_FILENAME);
}
/**
* @param string $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
return html_entity_decode($value);
}
}

View file

@ -4,15 +4,12 @@ namespace App\Casts;
use App\Values\UserPreferences;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Arr;
class UserPreferencesCast implements CastsAttributes
{
public function get($model, string $key, $value, array $attributes): UserPreferences
{
$arr = json_decode($value, true) ?: [];
return UserPreferences::make(Arr::get($arr, 'lastfm_session_key'));
return UserPreferences::fromArray(json_decode($value, true) ?: []);
}
/** @param UserPreferences|null $value */

View file

@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Services\License\Contracts\LicenseServiceInterface;
use Illuminate\Console\Command;
use Throwable;
class ActivateLicenseCommand extends Command
{
protected $signature = 'koel:license:activate {key : The license key to activate.}';
protected $description = 'Activate a Koel Plus license';
public function __construct(private readonly LicenseServiceInterface $licenseService)
{
parent::__construct();
}
public function handle(): int
{
$this->components->info('Activating license…');
try {
$license = $this->licenseService->activate($this->argument('key'));
} catch (Throwable $e) {
$this->components->error($e->getMessage());
return self::FAILURE;
}
$this->output->success('Koel Plus activated! All Plus features are now available.');
$this->components->twoColumnDetail('License Key', $license->short_key);
$this->components->twoColumnDetail(
'Registered To',
"{$license->meta->customerName} <{$license->meta->customerEmail}>"
);
$this->components->twoColumnDetail('Expires On', 'Never ever');
$this->newLine();
return self::SUCCESS;
}
}

View file

@ -2,8 +2,8 @@
namespace App\Console\Commands\Admin;
use App\Console\Commands\Traits\AskForPassword;
use App\Models\User;
use App\Console\Commands\Concerns\AskForPassword;
use App\Repositories\UserRepository;
use Illuminate\Console\Command;
use Illuminate\Contracts\Hashing\Hasher as Hash;
@ -15,7 +15,7 @@ class ChangePasswordCommand extends Command
{email? : The user's email. If empty, will get the default admin user.}";
protected $description = "Change a user's password";
public function __construct(private Hash $hash)
public function __construct(private readonly Hash $hash, private readonly UserRepository $userRepository)
{
parent::__construct();
}
@ -24,10 +24,9 @@ class ChangePasswordCommand extends Command
{
$email = $this->argument('email');
/** @var User|null $user */
$user = $email
? User::query()->where('email', $email)->first()
: User::query()->where('is_admin', true)->first();
? $this->userRepository->findOneByEmail($email)
: $this->userRepository->getDefaultAdminUser();
if (!$user) {
$this->error('The user account cannot be found.');

View file

@ -0,0 +1,61 @@
<?php
namespace App\Console\Commands;
use App\Enums\LicenseStatus;
use App\Services\License\Contracts\LicenseServiceInterface;
use Illuminate\Console\Command;
use Throwable;
class CheckLicenseStatusCommand extends Command
{
protected $signature = 'koel:license:status';
protected $description = 'Check the current Koel Plus license status';
public function __construct(private readonly LicenseServiceInterface $licenseService)
{
parent::__construct();
}
public function handle(): int
{
$this->components->info('Checking your Koel Plus license status…');
try {
$status = $this->licenseService->getStatus(checkCache: false);
switch ($status->status) {
case LicenseStatus::VALID:
$this->output->success('You have a valid Koel Plus license. All Plus features are enabled.');
$this->components->twoColumnDetail('License Key', $status->license->short_key);
$this->components->twoColumnDetail(
'Registered To',
"{$status->license->meta->customerName} <{$status->license->meta->customerEmail}>"
);
$this->components->twoColumnDetail('Expires On', 'Never ever');
$this->newLine();
break;
case LicenseStatus::NO_LICENSE:
$this->components->info(
'No license found. You can purchase one at https://store.koel.dev'
. config('lemonsqueezy.plus_product_id')
);
break;
case LicenseStatus::INVALID:
$this->components->error('Your license is invalid. Plus features will not be available.');
break;
default:
$this->components->warn('Your license status is unknown. Please try again later.');
}
} catch (Throwable $e) {
$this->output->error($e->getMessage());
}
return self::SUCCESS;
}
}

View file

@ -27,6 +27,12 @@ class CollectTagsCommand extends Command
public function handle(): int
{
if (config('koel.storage_driver') !== 'local') {
$this->components->error('This command only works with the local storage driver.');
return self::INVALID;
}
$tags = collect($this->argument('tag'))->unique();
if ($tags->diff(self::COLLECTABLE_TAGS)->isNotEmpty()) {

View file

@ -1,6 +1,6 @@
<?php
namespace App\Console\Commands\Traits;
namespace App\Console\Commands\Concerns;
/**
* @method void error($message, $verbosity = null)

View file

@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Services\License\Contracts\LicenseServiceInterface;
use Illuminate\Console\Command;
use Throwable;
class DeactivateLicenseCommand extends Command
{
protected $signature = 'koel:license:deactivate';
protected $description = 'Deactivate the currently active Koel Plus license';
public function __construct(private readonly LicenseServiceInterface $licenseService)
{
parent::__construct();
}
public function handle(): int
{
$status = $this->licenseService->getStatus();
if ($status->hasNoLicense()) {
$this->components->warn('No active Plus license found.');
return self::SUCCESS;
}
if (!$this->confirm('Are you sure you want to deactivate your Koel Plus license?')) {
$this->output->warning('License deactivation aborted.');
return self::SUCCESS;
}
$this->components->info('Deactivating your license…');
try {
$this->licenseService->deactivate($status->license);
$this->components->info('Koel Plus has been deactivated. Plus features are now disabled.');
return self::SUCCESS;
} catch (Throwable $e) {
$this->components->error('Failed to deactivate Koel Plus: ' . $e->getMessage());
return self::FAILURE;
}
}
}

View file

@ -5,6 +5,7 @@ namespace App\Console\Commands;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Playlist;
use App\Models\Podcast;
use App\Models\Song;
use Illuminate\Console\Command;
@ -15,6 +16,7 @@ class ImportSearchableEntitiesCommand extends Command
Album::class,
Artist::class,
Playlist::class,
Podcast::class,
];
protected $signature = 'koel:search:import';
@ -23,10 +25,6 @@ class ImportSearchableEntitiesCommand extends Command
public function handle(): int
{
foreach (self::SEARCHABLE_ENTITIES as $entity) {
if (!class_exists($entity)) {
continue;
}
$this->call('scout:import', ['model' => $entity]);
}

View file

@ -2,16 +2,17 @@
namespace App\Console\Commands;
use App\Console\Commands\Traits\AskForPassword;
use App\Console\Commands\Concerns\AskForPassword;
use App\Exceptions\InstallationFailedException;
use App\Models\Setting;
use App\Models\User;
use App\Services\MediaCacheService;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel as Artisan;
use Illuminate\Contracts\Hashing\Hasher as Hash;
use Illuminate\Database\DatabaseManager as DB;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Str;
use Jackiedo\DotenvEditor\DotenvEditor;
use Psr\Log\LoggerInterface;
@ -26,18 +27,16 @@ class InitCommand extends Command
private const DEFAULT_ADMIN_PASSWORD = 'KoelIsCool';
private const NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT = 10;
protected $signature = 'koel:init {--no-assets}';
protected $signature = 'koel:init {--no-assets : Do not compile front-end assets}';
protected $description = 'Install or upgrade Koel';
private bool $adminSeeded = false;
public function __construct(
private MediaCacheService $mediaCacheService,
private Artisan $artisan,
private Hash $hash,
private DotenvEditor $dotenvEditor,
private DB $db,
private LoggerInterface $logger
private readonly Hash $hash,
private readonly DotenvEditor $dotenvEditor,
private readonly DB $db,
private readonly LoggerInterface $logger
) {
parent::__construct();
}
@ -57,6 +56,7 @@ class InitCommand extends Command
try {
$this->clearCaches();
$this->composerInstall();
$this->loadEnvFile();
$this->maybeGenerateAppKey();
$this->maybeSetUpDatabase();
@ -70,7 +70,7 @@ class InitCommand extends Command
$this->components->error("Oops! Koel installation or upgrade didn't finish successfully.");
$this->components->error('Please check the error log at storage/logs/laravel.log and try again.');
$this->components->error('You can also visit ' . config('koel.misc.docs_url') . ' for other options.');
$this->components->error('For further troubleshooting, visit https://docs.koel.dev/troubleshooting.');
$this->components->error('😥 Sorry for this. You deserve better.');
return self::FAILURE;
@ -86,8 +86,8 @@ class InitCommand extends Command
);
}
if (Setting::get('media_path')) {
$this->info('You can also scan for media now with `php artisan koel:sync`.');
if (!Setting::get('media_path')) {
$this->info('You can set up the storage with `php artisan koel:storage`.');
}
$this->info('Again, visit 📙 ' . config('koel.misc.docs_url') . ' for more tips and tweaks.');
@ -105,20 +105,27 @@ class InitCommand extends Command
private function clearCaches(): void
{
$this->components->task('Clearing caches', function (): void {
$this->artisan->call('config:clear');
$this->artisan->call('cache:clear');
$this->components->task('Clearing caches', static function (): void {
Artisan::call('config:clear', ['--quiet' => true]);
Artisan::call('cache:clear', ['--quiet' => true]);
});
}
private function composerInstall(): void
{
$this->components->task('Installing packages (be patient!)', static function (): void {
self::runOkOrThrow('composer install --no-interaction --quiet');
});
}
private function loadEnvFile(): void
{
if (!file_exists(base_path('.env'))) {
if (!File::exists(base_path('.env'))) {
$this->components->task('Copying .env file', static function (): void {
copy(base_path('.env.example'), base_path('.env'));
File::copy(base_path('.env.example'), base_path('.env'));
});
} else {
$this->components->info('.env file exists -- skipping');
$this->components->task('.env file exists -- skipping');
}
$this->dotenvEditor->load(base_path('.env'));
@ -137,8 +144,7 @@ class InitCommand extends Command
}
});
$this->newLine();
$this->components->info('Using app key: ' . Str::limit($key, 16));
$this->components->task('Using app key: ' . Str::limit($key, 16));
}
/**
@ -174,10 +180,7 @@ class InitCommand extends Command
$config['DB_PASSWORD'] = (string) $this->ask('DB password');
}
foreach ($config as $key => $value) {
$this->dotenvEditor->setKey($key, $value);
}
$this->dotenvEditor->setKeys($config);
$this->dotenvEditor->save();
// Set the config so that the next DB attempt uses refreshed credentials
@ -220,12 +223,11 @@ class InitCommand extends Command
if (!User::query()->count()) {
$this->setUpAdminAccount();
$this->components->task('Seeding data', function (): void {
$this->artisan->call('db:seed', ['--force' => true]);
$this->components->task('Seeding data', static function (): void {
Artisan::call('db:seed', ['--force' => true, '--quiet' => true]);
});
} else {
$this->newLine();
$this->components->info('Data already seeded -- skipping');
$this->components->task('Data already seeded -- skipping');
}
}
@ -246,8 +248,9 @@ class InitCommand extends Command
try {
// Make sure the config cache is cleared before another attempt.
$this->artisan->call('config:clear');
$this->db->reconnect()->getPdo();
Artisan::call('config:clear', ['--quiet' => true]);
$this->db->reconnect();
$this->db->getDoctrineSchemaManager()->listTables();
break;
} catch (Throwable $e) {
@ -274,12 +277,9 @@ class InitCommand extends Command
private function migrateDatabase(): void
{
$this->components->task('Migrating database', function (): void {
$this->artisan->call('migrate', ['--force' => true]);
$this->components->task('Migrating database', static function (): void {
Artisan::call('migrate', ['--force' => true, '--quiet' => true]);
});
// Clear the media cache, just in case we did any media-related migration
$this->mediaCacheService->clear();
}
private function maybeSetMediaPath(): void
@ -295,7 +295,8 @@ class InitCommand extends Command
}
$this->newLine();
$this->info('The absolute path to your media directory. If this is skipped (left blank) now, you can set it later via the web interface.'); // @phpcs-ignore-line
$this->info('The absolute path to your media directory. You can leave it blank and set it later via the web interface.'); // @phpcs-ignore-line
$this->info('If you plan to use Koel with a cloud provider (S3 or Dropbox), you can also skip this.');
while (true) {
$path = $this->ask('Media path', config('koel.media_path'));
@ -320,16 +321,18 @@ class InitCommand extends Command
return;
}
$this->newLine();
$this->components->info('Now to front-end stuff');
$runOkOrThrow = static function (string $command): void {
passthru($command, $status);
throw_if((bool) $status, InstallationFailedException::class);
};
$runOkOrThrow('yarn install --colors');
$this->components->info('Installing npm dependencies');
$this->newLine();
self::runOkOrThrow('yarn install --colors');
$this->components->info('Compiling assets');
$runOkOrThrow('yarn build');
self::runOkOrThrow('yarn run --colors build');
}
private static function runOkOrThrow(string $command): void
{
throw_unless(Process::forever()->run($command)->successful(), InstallationFailedException::class);
}
private function setMediaPathFromEnvFile(): void
@ -349,7 +352,7 @@ class InitCommand extends Command
private static function isValidMediaPath(string $path): bool
{
return is_dir($path) && is_readable($path);
return File::isDirectory($path) && File::isReadable($path);
}
/**

View file

@ -10,7 +10,7 @@ class PruneLibraryCommand extends Command
protected $signature = 'koel:prune';
protected $description = 'Remove empty artists and albums';
public function __construct(private LibraryManager $libraryManager)
public function __construct(private readonly LibraryManager $libraryManager)
{
parent::__construct();
}

View file

@ -0,0 +1,189 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use App\Models\User;
use App\Repositories\UserRepository;
use App\Services\MediaScanner;
use App\Values\ScanConfiguration;
use App\Values\ScanResult;
use App\Values\WatchRecord\InotifyWatchRecord;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
class ScanCommand extends Command
{
protected $signature = 'koel:scan
{record? : A single watch record. Consult Wiki for more info.}
{--O|owner= : The ID of the user who should own the newly scanned songs. Defaults to the first admin user.}
{--P|private : Whether to make the newly scanned songs private to the user.}
{--V|verbose : Show more details about the scanning process
{--I|ignore=* : The comma-separated tags to ignore (exclude) from scanning}
{--F|force : Force re-scanning even unchanged files}';
protected $description = 'Scan for songs in the configured directory.';
private ?string $mediaPath;
private ProgressBar $progressBar;
public function __construct(
private readonly MediaScanner $scanner,
private readonly UserRepository $userRepository
) {
parent::__construct();
$this->scanner->on('paths-gathered', function (array $paths): void {
$this->progressBar = new ProgressBar($this->output, count($paths));
});
$this->scanner->on('progress', [$this, 'onScanProgress']);
}
protected function configure(): void
{
parent::configure();
$this->setAliases(['koel:sync']);
}
public function handle(): int
{
if (config('koel.storage_driver') !== 'local') {
$this->components->error('This command only works with the local storage driver.');
return self::INVALID;
}
$this->mediaPath = $this->getMediaPath();
$config = ScanConfiguration::make(
owner: $this->getOwner(),
// When scanning via CLI, the songs should be public by default, unless explicitly specified otherwise.
makePublic: !$this->option('private'),
ignores: collect($this->option('ignore'))->sort()->values()->all(),
force: $this->option('force')
);
$record = $this->argument('record');
if ($record) {
$this->scanSingleRecord($record, $config);
} else {
$this->scanMediaPath($config);
}
return self::SUCCESS;
}
/**
* Scan all files in the configured media path.
*/
private function scanMediaPath(ScanConfiguration $config): void
{
$this->components->info('Scanning ' . $this->mediaPath);
if ($config->ignores) {
$this->components->info('Ignoring tag(s): ' . implode(', ', $config->ignores));
}
$results = $this->scanner->scan($config);
$this->newLine(2);
$this->components->info('Scanning completed!');
$this->components->bulletList([
"<fg=green>{$results->success()->count()}</> new or updated song(s)",
"<fg=yellow>{$results->skipped()->count()}</> unchanged song(s)",
"<fg=red>{$results->error()->count()}</> invalid file(s)",
]);
}
/**
* @param string $record The watch record.
* As of current we only support inotifywait.
* Some examples:
* - "DELETE /var/www/media/gone.mp3"
* - "CLOSE_WRITE,CLOSE /var/www/media/new.mp3"
* - "MOVED_TO /var/www/media/new_dir"
*
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html
*/
private function scanSingleRecord(string $record, ScanConfiguration $config): void
{
$this->scanner->scanWatchRecord(new InotifyWatchRecord($record), $config);
}
public function onScanProgress(ScanResult $result): void
{
if (!$this->option('verbose')) {
$this->progressBar->advance();
return;
}
$path = trim(Str::replaceFirst($this->mediaPath, '', $result->path), DIRECTORY_SEPARATOR);
$this->components->twoColumnDetail($path, match (true) {
$result->isSuccess() => "<fg=green>OK</>",
$result->isSkipped() => "<fg=yellow>SKIPPED</>",
$result->isError() => "<fg=red>ERROR</>",
default => throw new RuntimeException("Unknown scan result type: {$result->type->value}")
});
if ($result->isError()) {
$this->output->writeln("<fg=red>$result->error</>");
}
}
private function getMediaPath(): string
{
$path = Setting::get('media_path');
if ($path) {
return $path;
}
$this->warn("Media path hasn't been configured. Let's set it up.");
while (true) {
$path = $this->ask('Absolute path to your media directory');
if (File::isDirectory($path) && File::isReadable($path)) {
Setting::set('media_path', $path);
break;
}
$this->error('The path does not exist or is not readable. Try again.');
}
return $path;
}
private function getOwner(): User
{
$specifiedOwner = $this->option('owner');
if ($specifiedOwner) {
$user = User::findOr($specifiedOwner, function () use ($specifiedOwner): void {
$this->components->error("User with ID $specifiedOwner does not exist.");
exit(self::INVALID);
});
$this->components->info("Setting owner to $user->name (ID $user->id).");
return $user;
}
$user = $this->userRepository->getDefaultAdminUser();
$this->components->warn(
"No song owner specified. Setting the first admin ($user->name, ID $user->id) as owner."
);
return $user;
}
}

View file

@ -0,0 +1,98 @@
<?php
namespace App\Console\Commands\Storage;
use App\Facades\License;
use App\Services\SongStorages\DropboxStorage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Jackiedo\DotenvEditor\DotenvEditor;
use Throwable;
class SetupDropboxStorageCommand extends Command
{
protected $signature = 'koel:storage:dropbox';
protected $description = 'Set up Dropbox as the storage driver for Koel';
public function __construct(private readonly DotenvEditor $dotenvEditor)
{
parent::__construct();
}
public function handle(): int
{
if (!License::isPlus()) {
$this->components->error('Dropbox as a storage driver is only available in Koel Plus.');
return self::FAILURE;
}
$this->components->info('Setting up Dropbox as the storage driver for Koel.');
$this->components->warn('Changing the storage configuration can cause irreversible data loss.');
$this->components->warn('Consider backing up your data before proceeding.');
$config = ['STORAGE_DRIVER' => 'dropbox'];
$config['DROPBOX_APP_KEY'] = $this->ask('Enter your Dropbox app key');
$config['DROPBOX_APP_SECRET'] = $this->ask('Enter your Dropbox app secret');
$cacheKey = Str::uuid()->toString();
Cache::put(
$cacheKey,
['app_key' => $config['DROPBOX_APP_KEY'], 'app_secret' => $config['DROPBOX_APP_SECRET']],
now()->addMinutes(15)
);
$tmpUrl = route('dropbox.authorize', ['state' => $cacheKey]);
$this->comment('Please visit the following link to authorize Koel to access your Dropbox account:');
$this->info($tmpUrl);
$this->comment('The link will expire in 15 minutes.');
$this->comment('After you have authorized Koel, enter the access code below.');
$accessCode = $this->ask('Enter the access code');
$response = Http::asForm()
->withBasicAuth($config['DROPBOX_APP_KEY'], $config['DROPBOX_APP_SECRET'])
->post('https://api.dropboxapi.com/oauth2/token', [
'code' => $accessCode,
'grant_type' => 'authorization_code',
]);
if ($response->failed()) {
$this->error(
'Failed to authorize with Dropbox. The server said: ' . $response->json('error_description') . '.'
);
return self::FAILURE;
}
$config['DROPBOX_REFRESH_TOKEN'] = $response->json('refresh_token');
$this->dotenvEditor->setKeys($config);
$this->dotenvEditor->save();
Artisan::call('config:clear', ['--quiet' => true]);
$this->comment('Uploading a test file to make sure everything is working...');
try {
app(DropboxStorage::class)->testSetup();
} catch (Throwable $e) {
$this->error('Failed to upload test file: ' . $e->getMessage() . '.');
$this->comment('Please make sure the app has the correct permissions and try again.');
$this->dotenvEditor->restore();
Artisan::call('config:clear', ['--quiet' => true]);
return self::FAILURE;
}
$this->components->info('All done!');
Cache::forget($cacheKey);
return self::SUCCESS;
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Console\Commands\Storage;
use App\Models\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\File;
use Jackiedo\DotenvEditor\DotenvEditor;
class SetupLocalStorageCommand extends Command
{
protected $signature = 'koel:storage:local';
protected $description = 'Set up the local storage for Koel';
public function __construct(private readonly DotenvEditor $dotenvEditor)
{
parent::__construct();
}
public function handle(): int
{
$this->components->info('Setting up local storage for Koel.');
$this->components->warn('Changing the storage configuration can cause irreversible data loss.');
$this->components->warn('Consider backing up your data before proceeding.');
Setting::set('media_path', $this->askForMediaPath());
$this->dotenvEditor->setKey('STORAGE_DRIVER', 'local');
$this->dotenvEditor->save();
Artisan::call('config:clear', ['--quiet' => true]);
$this->components->info('Local storage has been set up.');
if ($this->components->confirm('Would you want to initialize a scan now?', true)) {
$this->call('koel:scan');
}
return self::SUCCESS;
}
private function askForMediaPath(): string
{
$mediaPath = $this->components->ask('Enter the absolute path to your media files', Setting::get('media_path'));
if (File::isReadable($mediaPath) && File::isWritable($mediaPath)) {
return $mediaPath;
}
$this->components->error('The path you entered is not read- and/or writeable. Please check and try again.');
return $this->askForMediaPath();
}
}

View file

@ -0,0 +1,63 @@
<?php
namespace App\Console\Commands\Storage;
use App\Facades\License;
use App\Services\SongStorages\S3CompatibleStorage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Jackiedo\DotenvEditor\DotenvEditor;
use Throwable;
class SetupS3StorageCommand extends Command
{
protected $signature = 'koel:storage:s3';
protected $description = 'Set up Amazon S3 or a compatible service as the storage driver for Koel';
public function __construct(private readonly DotenvEditor $dotenvEditor)
{
parent::__construct();
}
public function handle(): int
{
if (!License::isPlus()) {
$this->components->error('S3 as a storage driver is only available in Koel Plus.');
return self::FAILURE;
}
$this->components->info('Setting up S3 or an S3-compatible service as the storage driver for Koel.');
$this->components->warn('Changing the storage configuration can cause irreversible data loss.');
$this->components->warn('Consider backing up your data before proceeding.');
$config = ['STORAGE_DRIVER' => 's3'];
$config['AWS_ACCESS_KEY_ID'] = $this->ask('Enter the access key ID (AWS_ACCESS_KEY_ID)');
$config['AWS_SECRET_ACCESS_KEY'] = $this->ask('Enter the secret access key (AWS_SECRET_ACCESS_KEY)');
$config['AWS_REGION'] = $this->ask('Enter the region (AWS_REGION). For Cloudflare R2, use "auto".');
$config['AWS_ENDPOINT'] = $this->ask('Enter the endpoint (AWS_ENDPOINT)');
$config['AWS_BUCKET'] = $this->ask('Enter the bucket name (AWS_BUCKET)');
$this->dotenvEditor->setKeys($config);
$this->dotenvEditor->save();
$this->comment('Uploading a test file to make sure everything is working...');
try {
app(S3CompatibleStorage::class)->testSetup();
} catch (Throwable $e) {
$this->error('Failed to upload test file: ' . $e->getMessage() . '.');
$this->comment('Please check your configuration and try again.');
$this->dotenvEditor->restore();
Artisan::call('config:clear', ['--quiet' => true]);
return self::FAILURE;
}
$this->components->info('All done!');
return self::SUCCESS;
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands\Storage;
use App\Facades\License;
use App\Models\Setting;
use Illuminate\Console\Command;
class StorageCommand extends Command
{
protected $signature = 'koel:storage';
protected $description = 'Set up and configure Koels storage';
public function handle(): int
{
$this->info('This command will set up and configure Koels storage.');
$this->info('Current storage configuration:');
$this->components->twoColumnDetail('Driver', config('koel.storage_driver'));
if (config('koel.storage_driver') === 'local') {
$this->components->twoColumnDetail('Media path', Setting::get('media_path') ?: '<not set>');
}
if (License::isPlus()) {
$choices = [
'local' => 'This server',
's3' => 'Amazon S3 or compatible services (DO Spaces, Cloudflare R2, etc.)',
'dropbox' => 'Dropbox',
];
$driver = $this->choice(
'Where do you want to store your media files?',
$choices,
config('koel.storage_driver')
);
} else {
$driver = 'local';
}
if ($this->call("koel:storage:$driver") === self::SUCCESS) {
$this->output->success('Storage has been set up.');
return self::SUCCESS;
}
return self::FAILURE;
}
}

View file

@ -1,140 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Setting;
use App\Services\MediaSyncService;
use App\Values\SyncResult;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
class SyncCommand extends Command
{
protected $signature = 'koel:sync
{record? : A single watch record. Consult Wiki for more info.}
{--ignore=* : The comma-separated tags to ignore (exclude) from syncing}
{--force : Force re-syncing even unchanged files}';
protected $description = 'Sync songs found in configured directory against the database.';
private ?string $mediaPath;
private ProgressBar $progressBar;
public function __construct(private MediaSyncService $mediaSyncService)
{
parent::__construct();
$this->mediaSyncService->on('paths-gathered', function (array $paths): void {
$this->progressBar = new ProgressBar($this->output, count($paths));
});
$this->mediaSyncService->on('progress', [$this, 'onSyncProgress']);
}
public function handle(): int
{
$this->mediaPath = $this->getMediaPath();
$record = $this->argument('record');
if ($record) {
$this->syncSingleRecord($record);
} else {
$this->syncAll();
}
return self::SUCCESS;
}
/**
* Sync all files in the configured media path.
*/
private function syncAll(): void
{
$this->components->info('Scanning ' . $this->mediaPath);
// The tags to ignore from syncing.
// Notice that this is only meaningful for existing records.
// New records will have every applicable field synced in.
$ignores = collect($this->option('ignore'))->sort()->values()->all();
if ($ignores) {
$this->components->info('Ignoring tag(s): ' . implode(', ', $ignores));
}
$results = $this->mediaSyncService->sync($ignores, $this->option('force'));
$this->newLine(2);
$this->components->info('Scanning completed!');
$this->components->bulletList([
"<fg=green>{$results->success()->count()}</> new or updated song(s)",
"<fg=yellow>{$results->skipped()->count()}</> unchanged song(s)",
"<fg=red>{$results->error()->count()}</> invalid file(s)",
]);
}
/**
* @param string $record The watch record.
* As of current we only support inotifywait.
* Some examples:
* - "DELETE /var/www/media/gone.mp3"
* - "CLOSE_WRITE,CLOSE /var/www/media/new.mp3"
* - "MOVED_TO /var/www/media/new_dir"
*
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html
*/
private function syncSingleRecord(string $record): void
{
$this->mediaSyncService->syncByWatchRecord(new InotifyWatchRecord($record));
}
public function onSyncProgress(SyncResult $result): void
{
if (!$this->option('verbose')) {
$this->progressBar->advance();
return;
}
$path = trim(Str::replaceFirst($this->mediaPath, '', $result->path), DIRECTORY_SEPARATOR);
$this->components->twoColumnDetail($path, match (true) {
$result->isSuccess() => "<fg=green>OK</>",
$result->isSkipped() => "<fg=yellow>SKIPPED</>",
$result->isError() => "<fg=red>ERROR</>",
default => throw new RuntimeException("Unknown sync result type: {$result->type}")
});
if ($result->isError()) {
$this->output->writeln("<fg=red>$result->error</>");
}
}
private function getMediaPath(): string
{
$path = Setting::get('media_path');
if ($path) {
return $path;
}
$this->warn("Media path hasn't been configured. Let's set it up.");
while (true) {
$path = $this->ask('Absolute path to your media directory');
if (is_dir($path) && is_readable($path)) {
Setting::set('media_path', $path);
break;
}
$this->error('The path does not exist or is not readable. Try again.');
}
return $path;
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Models\Podcast;
use App\Services\PodcastService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPodcastsCommand extends Command
{
protected $signature = 'koel:podcasts:sync';
protected $description = 'Synchronize podcasts.';
public function __construct(private readonly PodcastService $podcastService)
{
parent::__construct();
}
public function handle(): int
{
Podcast::query()->get()->each(function (Podcast $podcast): void {
try {
$this->info("Checking \"$podcast->title\" for new content…");
if (!$this->podcastService->isPodcastObsolete($podcast)) {
$this->warn('└── The podcast feed has not been updated recently, skipping.');
return;
}
$this->info('└── Synchronizing episodes…');
$this->podcastService->refreshPodcast($podcast);
} catch (Throwable $e) {
Log::error($e);
}
});
return self::SUCCESS;
}
}

View file

@ -2,6 +2,10 @@
namespace App\Console;
use App\Console\Commands\PruneLibraryCommand;
use App\Console\Commands\ScanCommand;
use App\Console\Commands\SyncPodcastsCommand;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
class Kernel extends ConsoleKernel
@ -10,4 +14,11 @@ class Kernel extends ConsoleKernel
{
$this->load(__DIR__ . '/Commands');
}
protected function schedule(Schedule $schedule): void
{
$schedule->command(ScanCommand::class)->daily();
$schedule->command(PruneLibraryCommand::class)->daily();
$schedule->command(SyncPodcastsCommand::class)->daily();
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum LicenseStatus
{
case VALID;
case INVALID;
case NO_LICENSE;
case UNKNOWN;
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum PlayableType: string
{
case SONG = 'song';
case PODCAST_EPISODE = 'episode';
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum ScanResultType: string
{
case SUCCESS = 'Success';
case ERROR = 'Error';
case SKIPPED = 'Skipped';
}

View file

@ -0,0 +1,38 @@
<?php
namespace App\Enums;
enum SmartPlaylistModel: string
{
case TITLE = 'title';
case ALBUM_NAME = 'album.name';
case ARTIST_NAME = 'artist.name';
case PLAY_COUNT = 'interactions.play_count';
case LAST_PLAYED = 'interactions.last_played_at';
case USER_ID = 'interactions.user_id';
case LENGTH = 'length';
case DATE_ADDED = 'created_at';
case DATE_MODIFIED = 'updated_at';
case GENRE = 'genre';
case YEAR = 'year';
public function toColumnName(): string
{
return match ($this) {
self::TITLE => 'songs.title',
self::LENGTH => 'songs.length',
self::GENRE => 'songs.genre',
self::YEAR => 'songs.year',
self::ALBUM_NAME => 'albums.name',
self::ARTIST_NAME => 'artists.name',
self::DATE_ADDED => 'songs.created_at',
self::DATE_MODIFIED => 'songs.updated_at',
default => $this->value,
};
}
public function isDate(): bool
{
return in_array($this, [self::LAST_PLAYED, self::DATE_ADDED, self::DATE_MODIFIED], true);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
enum SmartPlaylistOperator: string
{
case IS = 'is';
case IS_NOT = 'isNot';
case CONTAINS = 'contains';
case NOT_CONTAIN = 'notContain';
case IS_BETWEEN = 'isBetween';
case IS_GREATER_THAN = 'isGreaterThan';
case IS_LESS_THAN = 'isLessThan';
case BEGINS_WITH = 'beginsWith';
case ENDS_WITH = 'endsWith';
case IN_LAST = 'inLast';
case NOT_IN_LAST = 'notInLast';
case IS_NOT_BETWEEN = 'isNotBetween';
public function toEloquentClause(): string
{
return match ($this) {
self::IS_BETWEEN => 'whereBetween',
self::IS_NOT_BETWEEN => 'whereNotBetween',
default => 'where',
};
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Enums;
use App\Facades\License;
enum SongStorageType: string
{
case S3 = 's3';
case S3_LAMBDA = 's3-lambda';
case DROPBOX = 'dropbox';
case SFTP = 'sftp';
case LOCAL = '';
public function supported(): bool
{
if (License::isPlus()) {
return true;
}
return $this === self::LOCAL || $this === self::S3_LAMBDA;
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Values\ScanResultCollection;
use Illuminate\Queue\SerializesModels;
class MediaScanCompleted extends Event
{
use SerializesModels;
public function __construct(public ScanResultCollection $results)
{
}
}

View file

@ -1,15 +0,0 @@
<?php
namespace App\Events;
use App\Values\SyncResultCollection;
use Illuminate\Queue\SerializesModels;
class MediaSyncCompleted extends Event
{
use SerializesModels;
public function __construct(public SyncResultCollection $results)
{
}
}

View file

@ -6,7 +6,7 @@ use App\Models\User;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class SongsBatchUnliked extends Event
class MultipleSongsLiked extends Event
{
use SerializesModels;

View file

@ -6,7 +6,7 @@ use App\Models\User;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
class SongsBatchLiked extends Event
class MultipleSongsUnliked extends Event
{
use SerializesModels;

View file

@ -0,0 +1,16 @@
<?php
namespace App\Events;
use App\Models\PlaylistCollaborationToken;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class NewPlaylistCollaboratorJoined extends Event
{
use SerializesModels;
public function __construct(public User $collaborator, public PlaylistCollaborationToken $token)
{
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class CannotRemoveOwnerFromPlaylistException extends Exception
{
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Exceptions;
use Exception;
use Saloon\Exceptions\Request\RequestException;
use Throwable;
final class FailedToActivateLicenseException extends Exception
{
public static function fromThrowable(Throwable $e): self
{
return new self($e->getMessage(), $e->getCode(), $e);
}
public static function fromRequestException(RequestException $e): self
{
try {
return new self(object_get($e->getResponse()->object(), 'error'), $e->getStatus());
} catch (Throwable) {
return self::fromThrowable($e);
}
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
final class FailedToParsePodcastFeedException extends Exception
{
private function __construct(string $url, Throwable $previous)
{
parent::__construct("Failed to parse the podcast feed at $url.", (int) $previous->getCode(), $previous);
}
public static function create(string $url, Throwable $previous): self
{
return new self($url, $previous);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use Exception;
class KoelPlusRequiredException extends Exception
{
public function __construct(string $message = 'This feature is only available in Koel Plus.')
{
parent::__construct($message);
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Exceptions;
use Exception;
class MethodNotImplementedException extends Exception
{
public static function method(string $method): self
{
return new self("Method $method is not implemented.");
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class NotAPlaylistCollaboratorException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class OperationNotApplicableForSmartPlaylistException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class PlaylistCollaborationTokenExpiredException extends Exception
{
}

View file

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

View file

@ -0,0 +1,15 @@
<?php
namespace App\Exceptions;
use App\Models\Podcast;
use App\Models\User;
use Exception;
final class UserAlreadySubscribedToPodcast extends Exception
{
public static function make(User $user, Podcast $podcast): self
{
return new self("User $user->id has already subscribed to podcast $podcast->id");
}
}

View file

@ -6,7 +6,8 @@ use App\Models\Song;
use Illuminate\Support\Facades\Facade;
/**
* @method static string fromSong(Song $song)
* @method static string getLocalPath(Song $song)
* @see \App\Services\DownloadService
*/
class Download extends Facade
{

24
app/Facades/License.php Normal file
View file

@ -0,0 +1,24 @@
<?php
namespace App\Facades;
use App\Exceptions\KoelPlusRequiredException;
use Illuminate\Support\Facades\Facade;
/**
* @method static bool isPlus()
* @method static bool isCommunity()
* @see \App\Services\LicenseService
*/
class License extends Facade
{
public static function requirePlus(): void
{
throw_unless(static::isPlus(), KoelPlusRequiredException::class);
}
protected static function getFacadeAccessor(): string
{
return 'License';
}
}

View file

@ -1,50 +0,0 @@
<?php
namespace App\Factories;
use App\Models\Song;
use App\Services\Streamers\DirectStreamerInterface;
use App\Services\Streamers\ObjectStorageStreamerInterface;
use App\Services\Streamers\StreamerInterface;
use App\Services\Streamers\TranscodingStreamerInterface;
use App\Services\TranscodingService;
class StreamerFactory
{
public function __construct(
private DirectStreamerInterface $directStreamer,
private TranscodingStreamerInterface $transcodingStreamer,
private ObjectStorageStreamerInterface $objectStorageStreamer,
private TranscodingService $transcodingService
) {
}
public function createStreamer(
Song $song,
?bool $transcode = null,
?int $bitRate = null,
float $startTime = 0.0
): StreamerInterface {
if ($song->s3_params) {
$this->objectStorageStreamer->setSong($song);
return $this->objectStorageStreamer;
}
if ($transcode === null && $this->transcodingService->songShouldBeTranscoded($song)) {
$transcode = true;
}
if ($transcode) {
$this->transcodingStreamer->setSong($song);
$this->transcodingStreamer->setBitRate($bitRate ?: config('koel.streaming.bitrate'));
$this->transcodingStreamer->setStartTime($startTime);
return $this->transcodingStreamer;
}
$this->directStreamer->setSong($song);
return $this->directStreamer;
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Filesystems;
use DateTimeInterface;
use League\Flysystem\Filesystem;
use Spatie\FlysystemDropbox\DropboxAdapter;
class DropboxFilesystem extends Filesystem
{
public function __construct(private readonly DropboxAdapter $adapter)
{
parent::__construct($adapter, ['case_sensitive' => false]);
}
public function temporaryUrl(string $path, ?DateTimeInterface $expiresAt = null, array $config = []): string
{
return $this->adapter->getUrl($path);
}
public function getAdapter(): DropboxAdapter
{
return $this->adapter;
}
}

View file

@ -1,13 +1,16 @@
<?php
use App\Facades\License;
use Illuminate\Support\Facades\File as FileFacade;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Get a URL for static file requests.
* If this installation of Koel has a CDN_URL configured, use it as the base.
* Otherwise, just use a full URL to the asset.
*
* @param string $name The optional resource name/path
* @param string|null $name The optional resource name/path
*/
function static_url(?string $name = null): string
{
@ -36,20 +39,40 @@ function artist_image_url(?string $fileName): ?string
return $fileName ? static_url(config('koel.artist_image_dir') . $fileName) : null;
}
function playlist_cover_path(?string $fileName): ?string
{
return $fileName ? public_path(config('koel.playlist_cover_dir') . $fileName) : null;
}
function playlist_cover_url(?string $fileName): ?string
{
return $fileName ? static_url(config('koel.playlist_cover_dir') . $fileName) : null;
}
function user_avatar_path(?string $fileName): ?string
{
return $fileName ? public_path(config('koel.user_avatar_dir') . $fileName) : null;
}
function user_avatar_url(?string $fileName): ?string
{
return $fileName ? static_url(config('koel.user_avatar_dir') . $fileName) : null;
}
function koel_version(): string
{
return trim(file_get_contents(base_path('.version')));
return trim(FileFacade::get(base_path('.version')));
}
/**
* @throws Throwable
*/
function attempt(callable $callback, bool $log = true): mixed
function attempt(callable $callback, bool $log = true, bool $throw = false): mixed
{
try {
return $callback();
} catch (Throwable $e) {
if (app()->runningUnitTests()) {
if (app()->runningUnitTests() || $throw) {
throw $e;
}
@ -70,3 +93,58 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed
{
return !value($condition) ? attempt($callback, $log) : null;
}
function gravatar(string $email, int $size = 192): string
{
return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5(Str::lower($email)));
}
function avatar_or_gravatar(?string $avatar, string $email): string
{
if (!$avatar) {
return gravatar($email);
}
if (Str::startsWith($avatar, ['http://', 'https://'])) {
return $avatar;
}
return user_avatar_url($avatar);
}
/**
* A quick check to determine if a mailer is configured.
* This is not bulletproof but should work in most cases.
*/
function mailer_configured(): bool
{
return config('mail.default') && !in_array(config('mail.default'), ['log', 'array'], true);
}
/** @return array<string> */
function collect_sso_providers(): array
{
if (License::isCommunity()) {
return [];
}
$providers = [];
if (
config('services.google.client_id')
&& config('services.google.client_secret')
&& config('services.google.hd')
) {
$providers[] = 'Google';
}
return $providers;
}
function get_mtime(string|SplFileInfo $file): int
{
$file = is_string($file) ? new SplFileInfo($file) : $file;
// Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows.
return attempt(static fn () => $file->getMTime()) ?? time();
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ActivateLicenseRequest;
use App\Models\License;
use App\Services\License\Contracts\LicenseServiceInterface;
class ActivateLicenseController extends Controller
{
public function __invoke(ActivateLicenseRequest $request, LicenseServiceInterface $licenseService)
{
$this->authorize('activate', License::class);
$licenseService->activate($request->key);
return response()->noContent();
}
}

View file

@ -9,7 +9,7 @@ use App\Repositories\AlbumRepository;
class AlbumController extends Controller
{
public function __construct(private AlbumRepository $repository)
public function __construct(private readonly AlbumRepository $repository)
{
}

View file

@ -12,8 +12,10 @@ use Illuminate\Contracts\Auth\Authenticatable;
class AlbumSongController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
public function __construct(
private readonly SongRepository $songRepository,
private readonly ?Authenticatable $user
) {
}
public function index(Album $album)

View file

@ -9,7 +9,7 @@ use App\Repositories\AlbumRepository;
class ArtistAlbumController extends Controller
{
public function __construct(private AlbumRepository $albumRepository)
public function __construct(private readonly AlbumRepository $albumRepository)
{
}

View file

@ -9,7 +9,7 @@ use App\Repositories\ArtistRepository;
class ArtistController extends Controller
{
public function __construct(private ArtistRepository $repository)
public function __construct(private readonly ArtistRepository $repository)
{
}

View file

@ -12,8 +12,10 @@ use Illuminate\Contracts\Auth\Authenticatable;
class ArtistSongController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
public function __construct(
private readonly SongRepository $songRepository,
private readonly ?Authenticatable $user
) {
}
public function index(Artist $artist)

View file

@ -2,23 +2,45 @@
namespace App\Http\Controllers\API;
use App\Exceptions\InvalidCredentialsException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserLoginRequest;
use App\Services\AuthenticationService;
use App\Values\CompositeToken;
use Closure;
use Illuminate\Foundation\Auth\ThrottlesLogins;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Throwable;
class AuthController extends Controller
{
use ThrottlesLogins;
public function __construct(private AuthenticationService $auth)
public function __construct(private readonly AuthenticationService $auth)
{
}
public function login(UserLoginRequest $request)
{
$compositeToken = $this->throttleLoginRequest(
fn () => $this->auth->login($request->email, $request->password),
$request
);
return response()->json($compositeToken->toArray());
}
public function loginUsingOneTimeToken(Request $request)
{
$compositeToken = $this->throttleLoginRequest(
fn () => $this->auth->loginViaOneTimeToken($request->input('token')),
$request
);
return response()->json($compositeToken->toArray());
}
private function throttleLoginRequest(Closure $callback, Request $request): CompositeToken
{
if ($this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
@ -26,14 +48,14 @@ class AuthController extends Controller
}
try {
return response()->json($this->auth->login($request->email, $request->password)->toArray());
} catch (InvalidCredentialsException) {
return $callback();
} catch (Throwable) {
$this->incrementLoginAttempts($request);
abort(Response::HTTP_UNAUTHORIZED, 'Invalid credentials');
}
}
public function logout(Request $request)
public function logout(Request $request): Response
{
attempt(fn () => $this->auth->logoutViaBearerToken($request->bearerToken()));

View file

@ -5,15 +5,12 @@ namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SearchRequest;
use App\Http\Resources\ExcerptSearchResource;
use App\Models\User;
use App\Services\SearchService;
use Illuminate\Contracts\Auth\Authenticatable;
class ExcerptSearchController extends Controller
{
/** @param User $user */
public function __invoke(SearchRequest $request, SearchService $searchService, Authenticatable $user)
public function __invoke(SearchRequest $request, SearchService $searchService)
{
return ExcerptSearchResource::make($searchService->excerptSearch($request->q, $user));
return ExcerptSearchResource::make($searchService->excerptSearch($request->q));
}
}

View file

@ -3,14 +3,15 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\File;
use Throwable;
class FetchDemoCreditController extends Controller
class FetchDemoCreditsController extends Controller
{
public function __invoke()
{
try {
return response()->json(json_decode(file_get_contents(resource_path('demo-credits.json')), true));
return response()->json(json_decode(File::get(resource_path('demo-credits.json')), true));
} catch (Throwable) {
return response()->json();
}

View file

@ -8,11 +8,13 @@ use App\Http\Resources\PlaylistResource;
use App\Http\Resources\QueueStateResource;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Repositories\PlaylistRepository;
use App\Repositories\SettingRepository;
use App\Repositories\SongRepository;
use App\Services\ApplicationInformationService;
use App\Services\ITunesService;
use App\Services\LastfmService;
use App\Services\License\Contracts\LicenseServiceInterface;
use App\Services\QueueService;
use App\Services\SpotifyService;
use App\Services\YouTubeService;
@ -25,20 +27,25 @@ class FetchInitialDataController extends Controller
ITunesService $iTunesService,
SettingRepository $settingRepository,
SongRepository $songRepository,
PlaylistRepository $playlistRepository,
ApplicationInformationService $applicationInformationService,
QueueService $queueService,
LicenseServiceInterface $licenseService,
?Authenticatable $user
) {
$licenseStatus = $licenseService->getStatus();
return response()->json([
'settings' => $user->is_admin ? $settingRepository->getAllAsKeyValueArray() : [],
'playlists' => PlaylistResource::collection($user->playlists),
'playlists' => PlaylistResource::collection($playlistRepository->getAllAccessibleByUser($user)),
'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders),
'current_user' => UserResource::make($user, true),
'use_last_fm' => LastfmService::used(),
'use_spotify' => SpotifyService::enabled(),
'use_you_tube' => YouTubeService::enabled(),
'use_i_tunes' => $iTunesService->used(),
'allow_download' => config('koel.download.allow'),
'uses_last_fm' => LastfmService::used(),
'uses_spotify' => SpotifyService::enabled(),
'uses_you_tube' => YouTubeService::enabled(),
'uses_i_tunes' => $iTunesService->used(),
'allows_download' => config('koel.download.allow'),
'media_path_set' => (bool) $settingRepository->getByKey('media_path'),
'supports_transcoding' => config('koel.streaming.ffmpeg_path')
&& is_executable(config('koel.streaming.ffmpeg_path')),
'cdn_url' => static_url(),
@ -46,9 +53,17 @@ class FetchInitialDataController extends Controller
'latest_version' => $user->is_admin
? $applicationInformationService->getLatestVersionNumber()
: koel_version(),
'song_count' => $songRepository->count(),
'song_length' => $songRepository->getTotalLength(),
'song_count' => $songRepository->countSongs(),
'song_length' => $songRepository->getTotalSongLength(),
'queue_state' => QueueStateResource::make($queueService->getQueueState($user)),
'koel_plus' => [
'active' => $licenseStatus->isValid(),
'short_key' => $licenseStatus->license?->short_key,
'customer_name' => $licenseStatus->license?->meta->customerName,
'customer_email' => $licenseStatus->license?->meta->customerEmail,
'product_id' => config('lemonsqueezy.product_id'),
],
'storage_driver' => config('koel.storage_driver'),
]);
}
}

View file

@ -17,7 +17,6 @@ class FetchOverviewController extends Controller
AlbumRepository $albumRepository,
ArtistRepository $artistRepository
) {
return response()->json([
'most_played_songs' => SongResource::collection($songRepository->getMostPlayed()),
'recently_played_songs' => SongResource::collection($songRepository->getRecentlyPlayed()),

View file

@ -17,7 +17,12 @@ class FetchSongsForQueueController extends Controller
return SongResource::collection(
$request->order === 'rand'
? $repository->getRandom($request->limit, $user)
: $repository->getForQueue($request->sort, $request->order, $request->limit, $user)
: $repository->getForQueue(
explode(',', $request->sort),
$request->order,
$request->limit,
$user
)
);
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ForgotPasswordRequest;
use App\Services\AuthenticationService;
use Illuminate\Http\Response;
class ForgotPasswordController extends Controller
{
public function __invoke(ForgotPasswordRequest $request, AuthenticationService $auth)
{
static::disableInDemo();
return $auth->trySendResetPasswordLink($request->email)
? response()->noContent()
: response('', Response::HTTP_NOT_FOUND);
}
}

View file

@ -9,7 +9,7 @@ use Illuminate\Http\Response;
class GenreController extends Controller
{
public function __construct(private GenreRepository $repository)
public function __construct(private readonly GenreRepository $repository)
{
}

View file

@ -24,7 +24,7 @@ class GenreSongController extends Controller
return SongResource::collection(
$repository->getByGenre(
$genre === Genre::NO_GENRE ? '' : $genre,
$request->sort ?: 'songs.title',
$request->sort ? explode(',', $request->sort) : ['songs.title'],
$request->order ?: 'asc',
$user
)

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\AuthenticationService;
use Illuminate\Contracts\Auth\Authenticatable;
class GetOneTimeTokenController extends Controller
{
/** @param User $user */
public function __invoke(AuthenticationService $auth, Authenticatable $user)
{
return response()->json(['token' => $auth->generateOneTimeToken($user)]);
}
}

View file

@ -1,32 +0,0 @@
<?php
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\BatchInteractionRequest;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Arr;
class BatchLikeController extends Controller
{
/** @param User $user */
public function __construct(private InteractionService $interactionService, protected ?Authenticatable $user)
{
}
public function store(BatchInteractionRequest $request)
{
$interactions = $this->interactionService->batchLike((array) $request->songs, $this->user);
return response()->json($interactions);
}
public function destroy(BatchInteractionRequest $request)
{
$this->interactionService->batchUnlike(Arr::wrap($request->songs), $this->user);
return response()->noContent();
}
}

View file

@ -1,21 +0,0 @@
<?php
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class RecentlyPlayedController extends Controller
{
/** @param User $user */
public function __construct(private InteractionRepository $interactionRepository, private ?Authenticatable $user)
{
}
public function index(?int $count = null)
{
return response()->json($this->interactionRepository->getRecentlyPlayed($this->user, $count));
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\API;
use App\Exceptions\SongPathNotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest;
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
use App\Services\SongStorages\S3LambdaStorage;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class LambdaSongController extends Controller
{
public function __construct(private readonly S3LambdaStorage $storage)
{
}
public function put(PutSongRequest $request)
{
$artist = Arr::get($request->tags, 'artist', '');
$song = $this->storage->createSongEntry(
$request->bucket,
$request->key,
$artist,
Arr::get($request->tags, 'album'),
trim(Arr::get($request->tags, 'albumartist')),
Arr::get($request->tags, 'cover'),
trim(Arr::get($request->tags, 'title', '')),
(int) Arr::get($request->tags, 'duration', 0),
(int) Arr::get($request->tags, 'track'),
(string) Arr::get($request->tags, 'lyrics', '')
);
return response()->json($song);
}
public function remove(RemoveSongRequest $request)
{
try {
$this->storage->deleteSongEntry($request->bucket, $request->key);
} catch (SongPathNotFoundException) {
abort(Response::HTTP_NOT_FOUND);
}
return response()->noContent();
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\InteractWithMultipleSongsRequest;
use App\Models\Song;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Collection;
class LikeMultipleSongsController extends Controller
{
/** @param User $user */
public function __invoke(
InteractWithMultipleSongsRequest $request,
InteractionService $interactionService,
Authenticatable $user
) {
/** @var Collection<array-key, Song> $songs */
$songs = Song::query()->findMany($request->songs);
$songs->each(fn (Song $song) => $this->authorize('access', $song));
return response()->json($interactionService->likeMany($songs, $user));
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\MovePlaylistSongsRequest;
use App\Models\Playlist;
use App\Services\PlaylistService;
class MovePlaylistSongsController extends Controller
{
public function __invoke(MovePlaylistSongsRequest $request, Playlist $playlist, PlaylistService $service)
{
$this->authorize('collaborate', $playlist);
$service->movePlayablesInPlaylist($playlist, $request->songs, $request->target, $request->type);
return response()->noContent();
}
}

View file

@ -1,48 +0,0 @@
<?php
namespace App\Http\Controllers\API\ObjectStorage\S3;
use App\Exceptions\SongPathNotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest;
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
use App\Services\S3Service;
use Illuminate\Http\Response;
class SongController extends Controller
{
public function __construct(private S3Service $s3Service)
{
}
public function put(PutSongRequest $request)
{
$artist = array_get($request->tags, 'artist', '');
$song = $this->s3Service->createSongEntry(
$request->bucket,
$request->key,
$artist,
array_get($request->tags, 'album'),
trim(array_get($request->tags, 'albumartist')),
array_get($request->tags, 'cover'),
trim(array_get($request->tags, 'title', '')),
(int) array_get($request->tags, 'duration', 0),
(int) array_get($request->tags, 'track'),
(string) array_get($request->tags, 'lyrics', '')
);
return response()->json($song);
}
public function remove(RemoveSongRequest $request)
{
try {
$this->s3Service->deleteSongEntry($request->bucket, $request->key);
} catch (SongPathNotFoundException) {
abort(Response::HTTP_NOT_FOUND);
}
return response()->noContent();
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\API\PlaylistCollaboration;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistResource;
use App\Models\User;
use App\Services\PlaylistCollaborationService;
use Illuminate\Contracts\Auth\Authenticatable;
class AcceptPlaylistCollaborationInviteController extends Controller
{
/** @param User $user */
public function __invoke(PlaylistCollaborationService $service, Authenticatable $user)
{
$playlist = $service->acceptUsingToken(request()->input('token'), $user);
return PlaylistResource::make($playlist);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\API\PlaylistCollaboration;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistCollaborationTokenResource;
use App\Models\Playlist;
use App\Services\PlaylistCollaborationService;
use Illuminate\Contracts\Auth\Authenticatable;
class CreatePlaylistCollaborationTokenController extends Controller
{
public function __invoke(
Playlist $playlist,
PlaylistCollaborationService $collaborationService,
Authenticatable $user
) {
$this->authorize('invite-collaborators', $playlist);
return PlaylistCollaborationTokenResource::make($collaborationService->createToken($playlist));
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\API\PlaylistCollaboration;
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\NotAPlaylistCollaboratorException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistCollaboration\PlaylistCollaboratorDestroyRequest;
use App\Models\Playlist;
use App\Repositories\UserRepository;
use App\Services\PlaylistCollaborationService;
use Illuminate\Http\Response;
class PlaylistCollaboratorController extends Controller
{
public function __construct(
private readonly PlaylistCollaborationService $service,
private readonly UserRepository $userRepository
) {
}
public function index(Playlist $playlist)
{
$this->authorize('collaborate', $playlist);
return $this->service->getCollaborators($playlist);
}
public function destroy(Playlist $playlist, PlaylistCollaboratorDestroyRequest $request)
{
$this->authorize('own', $playlist);
$collaborator = $this->userRepository->getOne($request->collaborator);
try {
$this->service->removeCollaborator($playlist, $collaborator);
return response()->noContent();
} catch (KoelPlusRequiredException) {
abort(Response::HTTP_FORBIDDEN, 'This feature is only available for Plus users.');
} catch (CannotRemoveOwnerFromPlaylistException) {
abort(Response::HTTP_FORBIDDEN, 'You cannot remove yourself from your own playlist.');
} catch (NotAPlaylistCollaboratorException) {
abort(Response::HTTP_NOT_FOUND, 'This user is not a collaborator of this playlist.');
}
}
}

View file

@ -3,14 +3,15 @@
namespace App\Http\Controllers\API;
use App\Exceptions\PlaylistBothSongsAndRulesProvidedException;
use App\Facades\License;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistStoreRequest;
use App\Http\Requests\API\PlaylistUpdateRequest;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
use App\Repositories\PlaylistFolderRepository;
use App\Repositories\PlaylistRepository;
use App\Services\PlaylistService;
use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Contracts\Auth\Authenticatable;
@ -21,15 +22,16 @@ class PlaylistController extends Controller
{
/** @param User $user */
public function __construct(
private PlaylistService $playlistService,
private PlaylistFolderRepository $folderRepository,
private ?Authenticatable $user
private readonly PlaylistService $playlistService,
private readonly PlaylistRepository $playlistRepository,
private readonly PlaylistFolderRepository $folderRepository,
private readonly ?Authenticatable $user
) {
}
public function index()
{
return PlaylistResource::collection($this->user->playlists);
return PlaylistResource::collection($this->playlistRepository->getAllAccessibleByUser($this->user));
}
public function store(PlaylistStoreRequest $request)
@ -37,7 +39,6 @@ class PlaylistController extends Controller
$folder = null;
if ($request->folder_id) {
/** @var PlaylistFolder $folder */
$folder = $this->folderRepository->getOne($request->folder_id);
$this->authorize('own', $folder);
}
@ -48,7 +49,8 @@ class PlaylistController extends Controller
$this->user,
$folder,
Arr::wrap($request->songs),
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null,
$request->own_songs_only && $request->rules && License::isPlus()
);
return PlaylistResource::make($playlist);
@ -64,7 +66,6 @@ class PlaylistController extends Controller
$folder = null;
if ($request->folder_id) {
/** @var PlaylistFolder $folder */
$folder = $this->folderRepository->getOne($request->folder_id);
$this->authorize('own', $folder);
}
@ -74,7 +75,8 @@ class PlaylistController extends Controller
$playlist,
$request->name,
$folder,
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null,
$request->own_songs_only && $request->rules && License::isPlus()
)
);
}

View file

@ -14,8 +14,10 @@ use Illuminate\Contracts\Auth\Authenticatable;
class PlaylistFolderController extends Controller
{
/** @param User $user */
public function __construct(private PlaylistFolderService $service, private ?Authenticatable $user)
{
public function __construct(
private readonly PlaylistFolderService $service,
private readonly ?Authenticatable $user
) {
}
public function index()

View file

@ -11,7 +11,7 @@ use Illuminate\Support\Arr;
class PlaylistFolderPlaylistController extends Controller
{
public function __construct(private PlaylistFolderService $service)
public function __construct(private readonly PlaylistFolderService $service)
{
}
@ -28,7 +28,7 @@ class PlaylistFolderPlaylistController extends Controller
{
$this->authorize('own', $playlistFolder);
$this->service->movePlaylistsToRootLevel(Arr::wrap($request->playlists));
$this->service->movePlaylistsToRootLevel($playlistFolder, Arr::wrap($request->playlists));
return response()->noContent();
}

View file

@ -2,9 +2,11 @@
namespace App\Http\Controllers\API;
use App\Facades\License;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\AddSongsToPlaylistRequest;
use App\Http\Requests\API\RemoveSongsFromPlaylistRequest;
use App\Http\Resources\CollaborativeSongResource;
use App\Http\Resources\SongResource;
use App\Models\Playlist;
use App\Models\User;
@ -12,48 +14,59 @@ use App\Repositories\SongRepository;
use App\Services\PlaylistService;
use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class PlaylistSongController extends Controller
{
/** @param User $user */
public function __construct(
private SongRepository $songRepository,
private PlaylistService $playlistService,
private SmartPlaylistService $smartPlaylistService,
private ?Authenticatable $user
private readonly SongRepository $songRepository,
private readonly PlaylistService $playlistService,
private readonly SmartPlaylistService $smartPlaylistService,
private readonly ?Authenticatable $user
) {
}
public function index(Playlist $playlist)
{
$this->authorize('own', $playlist);
if ($playlist->is_smart) {
$this->authorize('own', $playlist);
return SongResource::collection($this->smartPlaylistService->getSongs($playlist, $this->user));
}
return SongResource::collection(
$playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist, $this->user)
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
);
$this->authorize('collaborate', $playlist);
return self::createResourceCollection($this->songRepository->getByStandardPlaylist($playlist, $this->user));
}
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
{
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->authorize('collaborate', $playlist);
$this->playlistService->addSongsToPlaylist($playlist, $request->songs);
$playables = $this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user);
return response()->noContent();
return self::createResourceCollection(
$this->playlistService->addPlayablesToPlaylist($playlist, $playables, $this->user)
);
}
private static function createResourceCollection(Collection $songs): ResourceCollection
{
return License::isPlus()
? CollaborativeSongResource::collection($songs)
: SongResource::collection($songs);
}
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
{
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
$this->authorize('collaborate', $playlist);
$this->playlistService->removePlayablesFromPlaylist($playlist, $request->songs);
return response()->noContent();
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\Song as Episode;
class FetchEpisodeController extends Controller
{
public function __invoke(Episode $episode)
{
$this->authorize('view', $episode->podcast);
return SongResource::make($episode);
}
}

View file

@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Exceptions\UserAlreadySubscribedToPodcast;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Podcast\PodcastStoreRequest;
use App\Http\Resources\PodcastResource;
use App\Http\Resources\PodcastResourceCollection;
use App\Models\Podcast;
use App\Models\User;
use App\Repositories\PodcastRepository;
use App\Services\PodcastService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class PodcastController extends Controller
{
/** @param User $user */
public function __construct(
private readonly PodcastService $podcastService,
private readonly PodcastRepository $podcastRepository,
private readonly ?Authenticatable $user
) {
}
public function index()
{
return PodcastResourceCollection::make($this->podcastRepository->getAllByUser($this->user));
}
public function store(PodcastStoreRequest $request)
{
self::disableInDemo();
try {
return PodcastResource::make($this->podcastService->addPodcast($request->url, $this->user));
} catch (UserAlreadySubscribedToPodcast) {
abort(Response::HTTP_CONFLICT, 'You have already subscribed to this podcast.');
}
}
public function show(Podcast $podcast)
{
$this->authorize('view', $podcast);
return PodcastResource::make($podcast);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\API\Podcast;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\Podcast;
use App\Repositories\SongRepository;
use App\Services\PodcastService;
class PodcastEpisodeController extends Controller
{
public function __construct(
private readonly SongRepository $episodeRepository,
private readonly PodcastService $podcastService
) {
}
public function index(Podcast $podcast)
{
if (request()->get('refresh')) {
$this->podcastService->refreshPodcast($podcast);
}
return SongResource::collection($this->episodeRepository->getEpisodesByPodcast($podcast));
}
}

Some files were not shown because too many files have changed in this diff Show more