mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: v7
This commit is contained in:
commit
9a1e3d9e36
1238 changed files with 36320 additions and 21126 deletions
|
@ -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
|
||||
|
|
93
.env.example
93
.env.example
|
@ -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
|
||||
|
|
2
.github/workflows/unit-backend-mysql.yml
vendored
2
.github/workflows/unit-backend-mysql.yml
vendored
|
@ -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
|
||||
|
||||
|
|
2
.github/workflows/unit-backend-pgsql.yml
vendored
2
.github/workflows/unit-backend-pgsql.yml
vendored
|
@ -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:
|
||||
|
|
2
.github/workflows/unit-backend.yml
vendored
2
.github/workflows/unit-backend.yml
vendored
|
@ -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
5
.gitignore
vendored
|
@ -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
|
||||
|
|
2
.version
2
.version
|
@ -1 +1 @@
|
|||
v6.12.1
|
||||
v7.0.0
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
18
app/Builders/PodcastBuilder.php
Normal file
18
app/Builders/PodcastBuilder.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
19
app/Casts/EncryptedValueCast.php
Normal file
19
app/Casts/EncryptedValueCast.php
Normal 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;
|
||||
}
|
||||
}
|
44
app/Casts/LicenseInstanceCast.php
Normal file
44
app/Casts/LicenseInstanceCast.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
44
app/Casts/LicenseMetaCast.php
Normal file
44
app/Casts/LicenseMetaCast.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
30
app/Casts/Podcast/CategoriesCast.php
Normal file
30
app/Casts/Podcast/CategoriesCast.php
Normal 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();
|
||||
}
|
||||
}
|
25
app/Casts/Podcast/EnclosureCast.php
Normal file
25
app/Casts/Podcast/EnclosureCast.php
Normal 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();
|
||||
}
|
||||
}
|
30
app/Casts/Podcast/EpisodeMetadataCast.php
Normal file
30
app/Casts/Podcast/EpisodeMetadataCast.php
Normal 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([]);
|
||||
}
|
||||
}
|
30
app/Casts/Podcast/PodcastMetadataCast.php
Normal file
30
app/Casts/Podcast/PodcastMetadataCast.php
Normal 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([]);
|
||||
}
|
||||
}
|
31
app/Casts/Podcast/PodcastStateCast.php
Normal file
31
app/Casts/Podcast/PodcastStateCast.php
Normal 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();
|
||||
}
|
||||
}
|
28
app/Casts/SongLyricsCast.php
Normal file
28
app/Casts/SongLyricsCast.php
Normal 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;
|
||||
}
|
||||
}
|
24
app/Casts/SongStorageCast.php
Normal file
24
app/Casts/SongStorageCast.php
Normal 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;
|
||||
}
|
||||
}
|
28
app/Casts/SongTitleCast.php
Normal file
28
app/Casts/SongTitleCast.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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 */
|
||||
|
|
44
app/Console/Commands/ActivateLicenseCommand.php
Normal file
44
app/Console/Commands/ActivateLicenseCommand.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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.');
|
||||
|
|
61
app/Console/Commands/CheckLicenseStatusCommand.php
Normal file
61
app/Console/Commands/CheckLicenseStatusCommand.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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()) {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Traits;
|
||||
namespace App\Console\Commands\Concerns;
|
||||
|
||||
/**
|
||||
* @method void error($message, $verbosity = null)
|
48
app/Console/Commands/DeactivateLicenseCommand.php
Normal file
48
app/Console/Commands/DeactivateLicenseCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
189
app/Console/Commands/ScanCommand.php
Normal file
189
app/Console/Commands/ScanCommand.php
Normal 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;
|
||||
}
|
||||
}
|
98
app/Console/Commands/Storage/SetupDropboxStorageCommand.php
Normal file
98
app/Console/Commands/Storage/SetupDropboxStorageCommand.php
Normal 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;
|
||||
}
|
||||
}
|
54
app/Console/Commands/Storage/SetupLocalStorageCommand.php
Normal file
54
app/Console/Commands/Storage/SetupLocalStorageCommand.php
Normal 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();
|
||||
}
|
||||
}
|
63
app/Console/Commands/Storage/SetupS3StorageCommand.php
Normal file
63
app/Console/Commands/Storage/SetupS3StorageCommand.php
Normal 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;
|
||||
}
|
||||
}
|
49
app/Console/Commands/Storage/StorageCommand.php
Normal file
49
app/Console/Commands/Storage/StorageCommand.php
Normal 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 Koel’s storage';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('This command will set up and configure Koel’s 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
42
app/Console/Commands/SyncPodcastsCommand.php
Normal file
42
app/Console/Commands/SyncPodcastsCommand.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
11
app/Enums/LicenseStatus.php
Normal file
11
app/Enums/LicenseStatus.php
Normal file
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LicenseStatus
|
||||
{
|
||||
case VALID;
|
||||
case INVALID;
|
||||
case NO_LICENSE;
|
||||
case UNKNOWN;
|
||||
}
|
9
app/Enums/PlayableType.php
Normal file
9
app/Enums/PlayableType.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PlayableType: string
|
||||
{
|
||||
case SONG = 'song';
|
||||
case PODCAST_EPISODE = 'episode';
|
||||
}
|
10
app/Enums/ScanResultType.php
Normal file
10
app/Enums/ScanResultType.php
Normal file
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ScanResultType: string
|
||||
{
|
||||
case SUCCESS = 'Success';
|
||||
case ERROR = 'Error';
|
||||
case SKIPPED = 'Skipped';
|
||||
}
|
38
app/Enums/SmartPlaylistModel.php
Normal file
38
app/Enums/SmartPlaylistModel.php
Normal 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);
|
||||
}
|
||||
}
|
28
app/Enums/SmartPlaylistOperator.php
Normal file
28
app/Enums/SmartPlaylistOperator.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
23
app/Enums/SongStorageType.php
Normal file
23
app/Enums/SongStorageType.php
Normal 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;
|
||||
}
|
||||
}
|
15
app/Events/MediaScanCompleted.php
Normal file
15
app/Events/MediaScanCompleted.php
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
16
app/Events/NewPlaylistCollaboratorJoined.php
Normal file
16
app/Events/NewPlaylistCollaboratorJoined.php
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class CannotRemoveOwnerFromPlaylistException extends Exception
|
||||
{
|
||||
}
|
24
app/Exceptions/FailedToActivateLicenseException.php
Normal file
24
app/Exceptions/FailedToActivateLicenseException.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
19
app/Exceptions/FailedToParsePodcastFeedException.php
Normal file
19
app/Exceptions/FailedToParsePodcastFeedException.php
Normal 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);
|
||||
}
|
||||
}
|
13
app/Exceptions/KoelPlusRequiredException.php
Normal file
13
app/Exceptions/KoelPlusRequiredException.php
Normal 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);
|
||||
}
|
||||
}
|
13
app/Exceptions/MethodNotImplementedException.php
Normal file
13
app/Exceptions/MethodNotImplementedException.php
Normal 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.");
|
||||
}
|
||||
}
|
9
app/Exceptions/NotAPlaylistCollaboratorException.php
Normal file
9
app/Exceptions/NotAPlaylistCollaboratorException.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class NotAPlaylistCollaboratorException extends Exception
|
||||
{
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class OperationNotApplicableForSmartPlaylistException extends Exception
|
||||
{
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class PlaylistCollaborationTokenExpiredException extends Exception
|
||||
{
|
||||
}
|
19
app/Exceptions/UnsupportedSongStorageTypeException.php
Normal file
19
app/Exceptions/UnsupportedSongStorageTypeException.php
Normal 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);
|
||||
}
|
||||
}
|
15
app/Exceptions/UserAlreadySubscribedToPodcast.php
Normal file
15
app/Exceptions/UserAlreadySubscribedToPodcast.php
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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
24
app/Facades/License.php
Normal 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';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
25
app/Filesystems/DropboxFilesystem.php
Normal file
25
app/Filesystems/DropboxFilesystem.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
20
app/Http/Controllers/API/ActivateLicenseController.php
Normal file
20
app/Http/Controllers/API/ActivateLicenseController.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()));
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()),
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
20
app/Http/Controllers/API/ForgotPasswordController.php
Normal file
20
app/Http/Controllers/API/ForgotPasswordController.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
17
app/Http/Controllers/API/GetOneTimeTokenController.php
Normal file
17
app/Http/Controllers/API/GetOneTimeTokenController.php
Normal 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)]);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
49
app/Http/Controllers/API/LambdaSongController.php
Normal file
49
app/Http/Controllers/API/LambdaSongController.php
Normal 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();
|
||||
}
|
||||
}
|
27
app/Http/Controllers/API/LikeMultipleSongsController.php
Normal file
27
app/Http/Controllers/API/LikeMultipleSongsController.php
Normal 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));
|
||||
}
|
||||
}
|
20
app/Http/Controllers/API/MovePlaylistSongsController.php
Normal file
20
app/Http/Controllers/API/MovePlaylistSongsController.php
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
17
app/Http/Controllers/API/Podcast/FetchEpisodeController.php
Normal file
17
app/Http/Controllers/API/Podcast/FetchEpisodeController.php
Normal 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);
|
||||
}
|
||||
}
|
49
app/Http/Controllers/API/Podcast/PodcastController.php
Normal file
49
app/Http/Controllers/API/Podcast/PodcastController.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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
Loading…
Reference in a new issue