Merge pull request #1466 from koel/next

This commit is contained in:
Phan An 2022-08-02 11:47:07 +02:00 committed by GitHub
commit 4c923fae93
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
683 changed files with 34779 additions and 12561 deletions

View file

@ -1,6 +1,8 @@
[*.{js,css,sass,scss,json,coffee,vue,html}]
[*]
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.php]
[{*.php, *.xml, *.xml.dist}]
indent_size = 4

View file

@ -6,7 +6,7 @@ APP_NAME=Koel
# pgsql (PostgreSQL)
# sqlsrv (Microsoft SQL Server)
# sqlite-persistent (Local sqlite file)
# IMPORTANT: This value must present for artisan koel:init command to work.
# IMPORTANT: This value must present for `artisan koel:init` command to work.
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
@ -46,16 +46,42 @@ MEMORY_LIMIT=
# Can be either 'php' (default), 'x-sendfile', or 'x-accel-redirect'
# See https://docs.koel.dev/#streaming-music 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
# you plan to use the Koel mobile apps.
# ##################################################
STREAMING_METHOD=php
# If you want Koel to integrate with Last.fm, set the API details here.
# See https://docs.koel.dev/3rd-party.html#last-fm for more information
# Full text search driver.
# Koel supports all drivers supported by Laravel (see https://laravel.com/docs/9.x/scout).
# Available drivers: 'tntsearch' (default), 'database', 'algolia' or 'meilisearch'.
# For Algolia or MeiliSearch, you need to provide the corresponding credentials.
SCOUT_DRIVER=tntsearch
ALGOLIA_APP_ID=
ALGOLIA_SECRET=
MEILISEARCH_HOST=
MEILISEARCH_KEY=
# Last.fm API can be used to fetch artist and album information, as well as to
# allow users to connect to their Last.fm account and scrobble.
# To integrate Koel with Last.fm, create an API account at
# https://www.last.fm/api/account/create and set the credentials here.
# Consult Koel's doc for more information.
LASTFM_API_KEY=
LASTFM_API_SECRET=
# If you want to use Amazon S3 with Koel, fill the info here and follow the
# 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.
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=
@ -63,7 +89,7 @@ AWS_REGION=
AWS_ENDPOINT=
# If you want Koel to integrate with YouTube, set the API key here.
# To integrate Koel with YouTube, set the API key here.
# See https://docs.koel.dev/3rd-party.html#youtube for more information.
YOUTUBE_API_KEY=
@ -74,8 +100,7 @@ YOUTUBE_API_KEY=
CDN_URL=
# If you want to transcode FLAC to MP3 and stream it on the fly, make sure the
# following settings are sane.
# To transcode FLAC to MP3 and stream it on the fly, make sure the following settings are sane.
# The full path of ffmpeg binary.
FFMPEG_PATH=/usr/local/bin/ffmpeg
@ -90,12 +115,6 @@ OUTPUT_BIT_RATE=128
# environment, such a download will (silently) fail.
ALLOW_DOWNLOAD=true
# If this is set to true, the query to get artist, album, and song information will be cached.
# This can give a boost to Koel's boot time, especially if your library is huge.
# However, the cache deserialization process can be memory sensitive, so if you encounter
# errors, try setting this to false.
CACHE_MEDIA=true
# Koel attempts to detect if your website use HTTPS and generates secure URLs accordingly.
# If this attempts for any reason, you can force it by setting this value to true.
@ -128,4 +147,3 @@ MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null

View file

@ -1,2 +0,0 @@
libs
tests

View file

@ -1,19 +1,37 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"parser": "vue-eslint-parser",
"env": {
"browser": true
},
"parserOptions": {
"parser": "@typescript-eslint/parser",
"ecmaVersion": 2020
},
"extends": [
"plugin:vue/vue3-recommended"
],
"ignorePatterns": [
"cypress/fixtures",
"cypress/screenshots",
"resources/assets/js/tests/__coverage__"
],
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"KOEL_ENV": "readonly",
"FileReader": "readonly",
"defineProps": "readonly",
"defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
},
"rules": {
"camelcase": 0,
"no-multi-str": 0,
"no-empty": 0,
"quotes": 0,
"no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/camelcase": 0,
"@typescript-eslint/member-delimiter-style": 0,
@ -21,6 +39,13 @@
"@typescript-eslint/no-inferrable-types": 0,
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/ban-ts-ignore": 0
"@typescript-eslint/ban-ts-comment": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/explicit-module-boundary-types": 0,
"standard/no-callback-literal": 0,
"vue/valid-v-on": 0,
"vue/no-side-effects-in-computed-properties": 0,
"vue/max-attributes-per-line": 0,
"vue/no-v-html": 0
}
}

View file

@ -1,8 +1,10 @@
name: e2e
name: End to End Tests
on:
push:
branches:
- master
# @fixme Tmp.disable until ready
# - next
pull_request:
workflow_dispatch:
jobs:
@ -19,11 +21,11 @@ jobs:
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: 7.4
php-version: 8.1
tools: composer:v2
extensions: pdo_sqlite
- name: Install PHP dependencies
uses: ramsey/composer-install@v1
uses: ramsey/composer-install@v2
with:
composer-options: --prefer-dist
- name: Generate app key
@ -32,12 +34,6 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install JavaScript dependencies
run: |
cd ./resources/assets && yarn install --no-progress
cd ../.. && yarn install --no-progress
- name: Lint
run: yarn lint
- name: Run E2E tests
uses: cypress-io/github-action@v2
with:

View file

@ -1,7 +1,7 @@
on:
push:
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Upload Release Assets
@ -14,22 +14,20 @@ jobs:
id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
- uses: actions/checkout@v2
with:
submodules: recursive
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: 7.4
php-version: 8.1
tools: composer:v2
extensions: pdo_sqlite
extensions: pdo_sqlite, zip, gd
- name: Install PHP dependencies
uses: ramsey/composer-install@v1
uses: ramsey/composer-install@v2
with:
composer-options: --prefer-dist
- name: Set up Node
uses: actions/setup-node@v2
with:
node-version: '14'
node-version: 16
- name: Build project
run: |
sudo apt install pngquant zip unzip
@ -39,10 +37,10 @@ jobs:
run: |
sed -i 's/DB_CONNECTION=sqlite/DB_CONNECTION=sqlite-persistent/' .env
sed -i 's/DB_DATABASE=koel/DB_DATABASE=koel.db/' .env
rm -rf .git ./node_modules ./resources/assets/.git ./resources/assets/node_modules ./storage/search-indexes/*.index ./koel.db ./.env
rm -rf .git ./node_modules ./storage/search-indexes/*.index ./koel.db ./.env
cd ../
zip -r /tmp/koel-${{ steps.get_version.outputs.VERSION }}.zip koel/
tar -zcvf /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz koel/
tar -zcf /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz koel/
- name: Create release
id: create_release
uses: actions/create-release@v1
@ -59,7 +57,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing its ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: /tmp/koel-${{ steps.get_version.outputs.VERSION }}.zip
asset_name: koel-${{ steps.get_version.outputs.VERSION }}.zip
asset_content_type: application/zip
@ -69,7 +67,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing its ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz
asset_name: koel-${{ steps.get_version.outputs.VERSION }}.tar.gz
asset_content_type: application/gzip

View file

@ -1,16 +1,32 @@
name: unit
name: Backend Unit Tests
on:
pull_request:
branches:
- master
- next
paths:
- '!resources/assets/**'
- .github/workflows/unit-backend.yml
push:
branches:
- master
pull_request:
- next
paths:
- '!resources/assets/**'
- .github/workflows/unit-backend.yml
workflow_dispatch:
branches:
- master
- next
paths:
- '!resources/assets/**'
- .github/workflows/unit-backend.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php-version: [ 7.4, 8.0 ]
php-version: [ 8.0, 8.1 ]
fail-fast: false
steps:
- uses: actions/checkout@v1
@ -33,6 +49,12 @@ jobs:
run: composer analyze -- --no-progress
- name: Run tests
run: composer coverage
- name: Upload logs if broken
uses: actions/upload-artifact@v1
if: failure()
with:
name: logs
path: storage/logs
- name: Upload coverage
uses: codecov/codecov-action@v1
with:

46
.github/workflows/unit-frontend.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: Frontend Unit Tests
on:
pull_request:
branches:
- master
- next
paths:
- resources/assets/**
- .github/workflows/unit-frontend.yml
push:
branches:
- master
- next
paths:
- resources/assets/**
- .github/workflows/unit-frontend.yml
workflow_dispatch:
branches:
- master
- next
paths:
- resources/assets/**
- .github/workflows/unit-frontend.yml
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node-version: [ 14, 16, 17 ] # 15 conflicts with @typescript-eslint/eslint-plugin@5
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Install dependencies
run: yarn install
- name: Lint
run: yarn lint
- name: Run unit tests
run: yarn test:unit
- name: Collect coverage
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}

5
.gitignore vendored
View file

@ -74,8 +74,9 @@ Temporary Items
*~
# Cypress
cypress/screenshots
cypress/videos
cypress/screenshots/
cypress/videos/
cypress/downloads/
/log
coverage.xml

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "resources/assets"]
path = resources/assets
url = https://github.com/koel/core.git

View file

@ -832,7 +832,7 @@ paths:
security:
- Bearer Token: []
/api/settings:
post:
put:
summary: Save the application settings
tags:
- settings

View file

@ -11,20 +11,16 @@ class ChangePasswordCommand extends Command
{
use AskForPassword;
protected $signature = "koel:admin:change-password
protected $signature = "koel:admin:change-password
{email? : The user's email. If empty, will get the default admin user.}";
protected $description = "Change a user's password";
private Hash $hash;
public function __construct(Hash $hash)
public function __construct(private Hash $hash)
{
parent::__construct();
$this->hash = $hash;
}
public function handle(): void
public function handle(): int
{
$email = $this->argument('email');
@ -34,7 +30,7 @@ class ChangePasswordCommand extends Command
if (!$user) {
$this->error('The user account cannot be found.');
return;
return self::FAILURE;
}
$this->comment("Changing the user's password (ID: $user->id, email: $user->email)");
@ -43,5 +39,7 @@ class ChangePasswordCommand extends Command
$user->save();
$this->comment('Alrighty, the new password has been saved. Enjoy! 👌');
return self::SUCCESS;
}
}

View file

@ -30,6 +30,6 @@ class ImportSearchableEntitiesCommand extends Command
$this->call('scout:import', ['model' => $entity]);
}
return 0;
return self::SUCCESS;
}
}

View file

@ -6,13 +6,15 @@ use App\Console\Commands\Traits\AskForPassword;
use App\Exceptions\InstallationFailedException;
use App\Models\Setting;
use App\Models\User;
use App\Repositories\SettingRepository;
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\Str;
use Jackiedo\DotenvEditor\DotenvEditor;
use Psr\Log\LoggerInterface;
use Throwable;
class InitCommand extends Command
@ -22,48 +24,40 @@ class InitCommand extends Command
private const DEFAULT_ADMIN_NAME = 'Koel';
private const DEFAULT_ADMIN_EMAIL = 'admin@koel.dev';
private const DEFAULT_ADMIN_PASSWORD = 'KoelIsCool';
private const NON_INTERACTION_MAX_ATTEMPT_COUNT = 10;
private const NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT = 10;
protected $signature = 'koel:init {--no-assets}';
protected $description = 'Install or upgrade Koel';
private MediaCacheService $mediaCacheService;
private Artisan $artisan;
private DotenvEditor $dotenvEditor;
private Hash $hash;
private DB $db;
private SettingRepository $settingRepository;
private bool $adminSeeded = false;
public function __construct(
MediaCacheService $mediaCacheService,
SettingRepository $settingRepository,
Artisan $artisan,
Hash $hash,
DotenvEditor $dotenvEditor,
DB $db
private MediaCacheService $mediaCacheService,
private Artisan $artisan,
private Hash $hash,
private DotenvEditor $dotenvEditor,
private DB $db,
private LoggerInterface $logger
) {
parent::__construct();
$this->mediaCacheService = $mediaCacheService;
$this->artisan = $artisan;
$this->dotenvEditor = $dotenvEditor;
$this->hash = $hash;
$this->db = $db;
$this->settingRepository = $settingRepository;
}
public function handle(): void
public function handle(): int
{
$this->comment('Attempting to install or upgrade Koel.');
$this->comment('Remember, you can always install/upgrade manually following the guide here:');
$this->info('📙 ' . config('koel.misc.docs_url') . PHP_EOL);
$this->alert('KOEL INSTALLATION WIZARD');
$this->info(
'As a reminder, you can always install/upgrade manually following the guide at '
. config('koel.misc.docs_url')
. PHP_EOL
);
if ($this->inNoInteractionMode()) {
$this->info('Running in no-interaction mode');
$this->components->info('Running in no-interaction mode');
}
try {
$this->clearCaches();
$this->loadEnvFile();
$this->maybeGenerateAppKey();
$this->maybeSetUpDatabase();
$this->migrateDatabase();
@ -71,32 +65,79 @@ class InitCommand extends Command
$this->maybeSetMediaPath();
$this->maybeCompileFrontEndAssets();
} catch (Throwable $e) {
$this->error("Oops! Koel installation or upgrade didn't finish successfully.");
$this->error('Please try again, or visit ' . config('koel.misc.docs_url') . ' for manual installation.');
$this->error('😥 Sorry for this. You deserve better.');
$this->logger->error($e);
return;
$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('😥 Sorry for this. You deserve better.');
return self::FAILURE;
}
$this->comment(PHP_EOL . '🎆 Success! Koel can now be run from localhost with `php artisan serve`.');
$this->newLine();
$this->output->success('All done!');
$this->info('Koel can now be run from localhost with `php artisan serve`.');
if ($this->adminSeeded) {
$this->comment(
$this->info(
sprintf('Log in with email %s and password %s', self::DEFAULT_ADMIN_EMAIL, self::DEFAULT_ADMIN_PASSWORD)
);
}
if (Setting::get('media_path')) {
$this->comment('You can also scan for media with `php artisan koel:sync`.');
$this->info('You can also scan for media now with `php artisan koel:sync`.');
}
$this->comment('Again, visit 📙 ' . config('koel.misc.docs_url') . ' for the official documentation.');
$this->comment(
$this->info('Again, visit 📙 ' . config('koel.misc.docs_url') . ' for more tips and tweaks.');
$this->info(
"Feeling generous and want to support Koel's development? Check out "
. config('koel.misc.sponsor_github_url')
. ' 🤗'
);
$this->comment('Thanks for using Koel. You rock! 🤘');
$this->info('Thanks for using Koel. You rock! 🤘');
return self::SUCCESS;
}
private function clearCaches(): void
{
$this->components->task('Clearing caches', function (): void {
$this->artisan->call('config:clear');
$this->artisan->call('cache:clear');
});
}
private function loadEnvFile(): void
{
if (!file_exists(base_path('.env'))) {
$this->components->task('Copying .env file', static function (): void {
copy(base_path('.env.example'), base_path('.env'));
});
} else {
$this->components->info('.env file exists -- skipping');
}
$this->dotenvEditor->load(base_path('.env'));
}
private function maybeGenerateAppKey(): void
{
$key = $this->laravel['config']['app.key'];
$this->components->task($key ? 'Retrieving app key' : 'Generating app key', function () use (&$key): void {
if (!$key) {
// Generate the key manually to prevent some clashes with `php artisan key:generate`
$key = $this->generateRandomKey();
$this->dotenvEditor->setKey('APP_KEY', $key);
$this->laravel['config']['app.key'] = $key;
}
});
$this->newLine();
$this->components->info('Using app key: ' . Str::limit($key, 16));
}
/**
@ -105,10 +146,8 @@ class InitCommand extends Command
private function setUpDatabase(): void
{
$config = [
'DB_CONNECTION' => '',
'DB_HOST' => '',
'DB_PORT' => '',
'DB_DATABASE' => '',
'DB_USERNAME' => '',
'DB_PASSWORD' => '',
];
@ -163,16 +202,83 @@ class InitCommand extends Command
private function setUpAdminAccount(): void
{
$this->info("Creating default admin account");
$this->components->task('Creating default admin account', function (): void {
User::create([
'name' => self::DEFAULT_ADMIN_NAME,
'email' => self::DEFAULT_ADMIN_EMAIL,
'password' => $this->hash->make(self::DEFAULT_ADMIN_PASSWORD),
'is_admin' => true,
]);
User::create([
'name' => self::DEFAULT_ADMIN_NAME,
'email' => self::DEFAULT_ADMIN_EMAIL,
'password' => $this->hash->make(self::DEFAULT_ADMIN_PASSWORD),
'is_admin' => true,
]);
$this->adminSeeded = true;
});
}
$this->adminSeeded = true;
private function maybeSeedDatabase(): void
{
if (!User::count()) {
$this->setUpAdminAccount();
$this->components->task('Seeding data', function (): void {
$this->artisan->call('db:seed', ['--force' => true]);
});
} else {
$this->newLine();
$this->components->info('Data already seeded -- skipping');
}
}
private function maybeSetUpDatabase(): void
{
$attempt = 0;
while (true) {
// In non-interactive mode, we must not endlessly attempt to connect.
// Doing so will just end up with a huge amount of "failed to connect" logs.
// We do retry a little, though, just in case there's some kind of temporary failure.
if ($this->inNoInteractionMode() && $attempt >= self::NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT) {
$this->components->error('Maximum database connection attempts reached. Giving up.');
break;
}
$attempt++;
try {
// Make sure the config cache is cleared before another attempt.
$this->artisan->call('config:clear');
$this->db->reconnect()->getPdo();
break;
} catch (Throwable $e) {
$this->logger->error($e);
// We only try to update credentials if running in interactive mode.
// Otherwise, we require admin intervention to fix them.
// This avoids inadvertently wiping credentials if there's a connection failure.
if ($this->inNoInteractionMode()) {
$warning = sprintf(
"Cannot connect to the database. Attempt: %d/%d",
$attempt,
self::NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT
);
$this->components->warn($warning);
} else {
$this->components->warn("Cannot connect to the database. Let's set it up.");
$this->setUpDatabase();
}
}
}
}
private function migrateDatabase(): void
{
$this->components->task('Migrating database', function (): void {
$this->artisan->call('migrate', ['--force' => true]);
});
// Clear the media cache, just in case we did any media-related migration
$this->mediaCacheService->clear();
}
private function maybeSetMediaPath(): void
@ -187,6 +293,7 @@ class InitCommand extends Command
return;
}
$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
while (true) {
@ -196,103 +303,23 @@ class InitCommand extends Command
return;
}
if ($this->isValidMediaPath($path)) {
if (self::isValidMediaPath($path)) {
Setting::set('media_path', $path);
return;
}
$this->error('The path does not exist or not readable. Try again.');
$this->components->error('The path does not exist or not readable. Try again?');
}
}
private function maybeGenerateAppKey(): void
{
if (!config('app.key')) {
$this->info('Generating app key');
$this->artisan->call('key:generate');
} else {
$this->comment('App key exists -- skipping');
}
}
private function maybeSeedDatabase(): void
{
if (!User::count()) {
$this->setUpAdminAccount();
$this->info('Seeding initial data');
$this->artisan->call('db:seed', ['--force' => true]);
} else {
$this->comment('Data seeded -- skipping');
}
}
private function maybeSetUpDatabase(): void
{
$attemptCount = 0;
while (true) {
// In non-interactive mode, we must not endlessly attempt to connect.
// Doing so will just end up with a huge amount of "failed to connect" logs.
// We do retry a little, though, just in case there's some kind of temporary failure.
if ($this->inNoInteractionMode() && $attemptCount >= self::NON_INTERACTION_MAX_ATTEMPT_COUNT) {
$this->warn("Maximum database connection attempts reached. Giving up.");
break;
}
$attemptCount++;
try {
// Make sure the config cache is cleared before another attempt.
$this->artisan->call('config:clear');
$this->db->reconnect()->getPdo();
break;
} catch (Throwable $e) {
$this->error($e->getMessage());
// We only try to update credentials if running in interactive mode.
// Otherwise, we require admin intervention to fix them.
// This avoids inadvertently wiping credentials if there's a connection failure.
if ($this->inNoInteractionMode()) {
$warning = sprintf(
"%sKoel cannot connect to the database. Attempt: %d/%d",
PHP_EOL,
$attemptCount,
self::NON_INTERACTION_MAX_ATTEMPT_COUNT
);
$this->warn($warning);
} else {
$this->warn(sprintf("%sKoel cannot connect to the database. Let's set it up.", PHP_EOL));
$this->setUpDatabase();
}
}
}
}
private function migrateDatabase(): void
{
$this->info('Migrating database');
$this->artisan->call('migrate', ['--force' => true]);
// Clear the media cache, just in case we did any media-related migration
$this->mediaCacheService->clear();
}
private function maybeCompileFrontEndAssets(): void
{
if ($this->inNoAssetsMode()) {
return;
}
$this->info('Now to front-end stuff');
// We need to run several yarn commands:
// - The first to install node_modules in the resources/assets submodule
// - The second and third for the root folder, to build Koel's front-end assets with Mix.
chdir('./resources/assets');
$this->info('├── Installing Node modules in resources/assets directory');
$this->components->info('Now to front-end stuff');
$runOkOrThrow = static function (string $command): void {
passthru($command, $status);
@ -300,31 +327,35 @@ class InitCommand extends Command
};
$runOkOrThrow('yarn install --colors');
chdir('../..');
$this->info('└── Compiling assets');
$runOkOrThrow('yarn install --colors');
$runOkOrThrow('yarn build --colors');
}
private function isValidMediaPath(string $path): bool
{
return is_dir($path) && is_readable($path);
$this->components->info('Compiling assets');
$runOkOrThrow('yarn build');
}
private function setMediaPathFromEnvFile(): void
{
with(config('koel.media_path'), function (?string $path): void {
if (!$path) {
return;
}
$path = config('koel.media_path');
if ($this->isValidMediaPath($path)) {
Setting::set('media_path', $path);
} else {
$this->warn(sprintf('The path %s does not exist or not readable. Skipping.', $path));
}
});
if (!$path) {
return;
}
if (self::isValidMediaPath($path)) {
Setting::set('media_path', $path);
} else {
$this->components->warn(sprintf('The path %s does not exist or not readable. Skipping.', $path));
}
}
private static function isValidMediaPath(string $path): bool
{
return is_dir($path) && is_readable($path);
}
/**
* Generate a random key for the application.
*/
private function generateRandomKey(): string
{
return 'base64:' . base64_encode(Encrypter::generateKey($this->laravel['config']['app.cipher']));
}
}

View file

@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Events\LibraryChanged;
use App\Services\LibraryManager;
use Illuminate\Console\Command;
class PruneLibraryCommand extends Command
@ -10,9 +10,16 @@ class PruneLibraryCommand extends Command
protected $signature = 'koel:prune';
protected $description = 'Remove empty artists and albums';
public function handle(): void
public function __construct(private LibraryManager $libraryManager)
{
event(new LibraryChanged());
parent::__construct();
}
public function handle(): int
{
$this->libraryManager->prune();
$this->info('Empty artists and albums removed.');
return self::SUCCESS;
}
}

View file

@ -4,72 +4,76 @@ namespace App\Console\Commands;
use App\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Setting;
use App\Services\FileSynchronizer;
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.}
{--tags= : The comma-separated tags to sync into the database}
{--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 int $ignored = 0;
private int $invalid = 0;
private int $synced = 0;
private MediaSyncService $mediaSyncService;
private ?ProgressBar $progressBar = null;
private ?string $mediaPath;
private ProgressBar $progressBar;
public function __construct(MediaSyncService $mediaSyncService)
public function __construct(private MediaSyncService $mediaSyncService)
{
parent::__construct();
$this->mediaSyncService = $mediaSyncService;
$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(): void
public function handle(): int
{
$this->ensureMediaPath();
$this->mediaPath = $this->getMediaPath();
$record = $this->argument('record');
if (!$record) {
if ($record) {
$this->syncSingleRecord($record);
} else {
$this->syncAll();
return;
}
$this->syngle($record);
return self::SUCCESS;
}
/**
* Sync all files in the configured media path.
*/
protected function syncAll(): void
private function syncAll(): void
{
$this->info('Syncing media from ' . Setting::get('media_path') . PHP_EOL);
$this->components->info('Scanning ' . $this->mediaPath);
// Get the tags to sync.
// The tags to ignore from syncing.
// Notice that this is only meaningful for existing records.
// New records will have every applicable field sync'ed in.
$tags = $this->option('tags') ? explode(',', $this->option('tags')) : [];
// New records will have every applicable field synced in.
$ignores = $this->option('ignore') ? explode(',', $this->option('ignore')) : [];
$this->mediaSyncService->sync(null, $tags, $this->option('force'), $this);
$results = $this->mediaSyncService->sync($ignores, $this->option('force'));
$this->output->writeln(
PHP_EOL . PHP_EOL
. "<info>Completed! $this->synced new or updated song(s)</info>, "
. "$this->ignored unchanged song(s), "
. "and <comment>$this->invalid invalid file(s)</comment>."
);
$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)",
]);
}
/**
* SYNc a sinGLE file or directory. See my awesome pun?
*
* @param string $record The watch record.
* As of current we only support inotifywait.
* Some examples:
@ -79,45 +83,39 @@ class SyncCommand extends Command
*
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html
*/
public function syngle(string $record): void
private function syncSingleRecord(string $record): void
{
$this->mediaSyncService->syncByWatchRecord(new InotifyWatchRecord($record));
}
/**
* Log a song's sync status to console.
*/
public function logSyncStatusToConsole(string $path, int $result, ?string $reason = null): void
public function onSyncProgress(SyncResult $result): void
{
$name = basename($path);
if (!$this->option('verbose')) {
$this->progressBar->advance();
if ($result === FileSynchronizer::SYNC_RESULT_UNMODIFIED) {
++$this->ignored;
} elseif ($result === FileSynchronizer::SYNC_RESULT_BAD_FILE) {
if ($this->option('verbose')) {
$this->error(PHP_EOL . "'$name' is not a valid media file: " . $reason);
}
return;
}
++$this->invalid;
} else {
++$this->synced;
$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</>");
}
}
public function createProgressBar(int $max): void
private function getMediaPath(): string
{
$this->progressBar = $this->getOutput()->createProgressBar($max);
}
$path = Setting::get('media_path');
public function advanceProgressBar(): void
{
$this->progressBar->advance();
}
private function ensureMediaPath(): void
{
if (Setting::get('media_path')) {
return;
if ($path) {
return $path;
}
$this->warn("Media path hasn't been configured. Let's set it up.");
@ -132,5 +130,7 @@ class SyncCommand extends Command
$this->error('The path does not exist or is not readable. Try again.');
}
return $path;
}
}

View file

@ -9,8 +9,10 @@ class TidyLibraryCommand extends Command
protected $signature = 'koel:tidy';
protected $hidden = true;
public function handle(): void
public function handle(): int
{
$this->warn('koel:tidy has been renamed. Use koel:prune instead.');
return self::SUCCESS;
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Events;
use App\Models\Album;
use Illuminate\Queue\SerializesModels;
class AlbumInformationFetched extends Event
{
use SerializesModels;
private Album $album;
private array $information;
public function __construct(Album $album, array $information)
{
$this->album = $album;
$this->information = $information;
}
public function getAlbum(): Album
{
return $this->album;
}
/** @return array<mixed> */
public function getInformation(): array
{
return $this->information;
}
}

View file

@ -1,31 +0,0 @@
<?php
namespace App\Events;
use App\Models\Artist;
use Illuminate\Queue\SerializesModels;
class ArtistInformationFetched
{
use SerializesModels;
private Artist $artist;
private array $information;
public function __construct(Artist $artist, array $information)
{
$this->artist = $artist;
$this->information = $information;
}
public function getArtist(): Artist
{
return $this->artist;
}
/** @return array<mixed> */
public function getInformation(): array
{
return $this->information;
}
}

View file

@ -1,7 +0,0 @@
<?php
namespace App\Events;
class MediaCacheObsolete extends Event
{
}

View file

@ -2,17 +2,14 @@
namespace App\Events;
use App\Values\SyncResult;
use App\Values\SyncResultCollection;
use Illuminate\Queue\SerializesModels;
class MediaSyncCompleted extends Event
{
use SerializesModels;
public SyncResult $result;
public function __construct(SyncResult $result)
public function __construct(public SyncResultCollection $results)
{
$this->result = $result;
}
}

View file

@ -3,19 +3,13 @@
namespace App\Events;
use App\Models\Interaction;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class SongLikeToggled extends Event
{
use SerializesModels;
public Interaction $interaction;
public ?User $user = null;
public function __construct(Interaction $interaction, ?User $user = null)
public function __construct(public Interaction $interaction)
{
$this->interaction = $interaction;
$this->user = $user ?: auth()->user();
}
}

View file

@ -10,12 +10,7 @@ class SongStartedPlaying extends Event
{
use SerializesModels;
public Song $song;
public User $user;
public function __construct(Song $song, User $user)
public function __construct(public Song $song, public User $user)
{
$this->song = $song;
$this->user = $user;
}
}

View file

@ -10,12 +10,7 @@ class SongsBatchLiked extends Event
{
use SerializesModels;
public Collection $songs;
public User $user;
public function __construct(Collection $songs, User $user)
public function __construct(public Collection $songs, public User $user)
{
$this->songs = $songs;
$this->user = $user;
}
}

View file

@ -10,12 +10,7 @@ class SongsBatchUnliked extends Event
{
use SerializesModels;
public Collection $songs;
public User $user;
public function __construct(Collection $songs, User $user)
public function __construct(public Collection $songs, public User $user)
{
$this->songs = $songs;
$this->user = $user;
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Exceptions;
use Exception;
use Throwable;
class SpotifyIntegrationDisabledException extends Exception
{
private function __construct(string $message = '', int $code = 0, ?Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
public static function create(): self
{
return new self('Spotify integration is disabled.');
}
}

View file

@ -1,37 +0,0 @@
<?php
namespace App\Factories;
use App\Values\SmartPlaylistRule;
use Webmozart\Assert\Assert;
class SmartPlaylistRuleParameterFactory
{
/**
* @param array<mixed> $value
*
* @return array<string>
*/
public function createParameters(string $model, string $operator, array $value): array
{
$ruleParameterMap = [
SmartPlaylistRule::OPERATOR_BEGINS_WITH => [$model, 'LIKE', "$value[0]%"],
SmartPlaylistRule::OPERATOR_ENDS_WITH => [$model, 'LIKE', "%$value[0]"],
SmartPlaylistRule::OPERATOR_IS => [$model, '=', $value[0]],
SmartPlaylistRule::OPERATOR_IS_NOT => [$model, '<>', $value[0]],
SmartPlaylistRule::OPERATOR_CONTAINS => [$model, 'LIKE', "%$value[0]%"],
SmartPlaylistRule::OPERATOR_NOT_CONTAIN => [$model, 'NOT LIKE', "%$value[0]%"],
SmartPlaylistRule::OPERATOR_IS_LESS_THAN => [$model, '<', $value[0]],
SmartPlaylistRule::OPERATOR_IS_GREATER_THAN => [$model, '>', $value[0]],
SmartPlaylistRule::OPERATOR_IS_BETWEEN => [$model, $value],
SmartPlaylistRule::OPERATOR_NOT_IN_LAST => static fn (): array => [$model, '<', now()->subDays($value[0])],
SmartPlaylistRule::OPERATOR_IN_LAST => static fn (): array => [$model, '>=', now()->subDays($value[0])],
];
Assert::keyExists($ruleParameterMap, $operator);
return is_callable($ruleParameterMap[$operator])
? $ruleParameterMap[$operator]()
: $ruleParameterMap[$operator];
}
}

View file

@ -11,21 +11,12 @@ use App\Services\TranscodingService;
class StreamerFactory
{
private DirectStreamerInterface $directStreamer;
private TranscodingStreamerInterface $transcodingStreamer;
private ObjectStorageStreamerInterface $objectStorageStreamer;
private TranscodingService $transcodingService;
public function __construct(
DirectStreamerInterface $directStreamer,
TranscodingStreamerInterface $transcodingStreamer,
ObjectStorageStreamerInterface $objectStorageStreamer,
TranscodingService $transcodingService
private DirectStreamerInterface $directStreamer,
private TranscodingStreamerInterface $transcodingStreamer,
private ObjectStorageStreamerInterface $objectStorageStreamer,
private TranscodingService $transcodingService
) {
$this->directStreamer = $directStreamer;
$this->transcodingStreamer = $transcodingStreamer;
$this->objectStorageStreamer = $objectStorageStreamer;
$this->transcodingService = $transcodingService;
}
public function createStreamer(

View file

@ -14,48 +14,24 @@ function static_url(?string $name = null): string
return $cdnUrl ? $cdnUrl . '/' . trim(ltrim($name, '/')) : trim(asset($name));
}
/**
* A copy of Laravel Mix but catered to our directory structure.
*
* @throws InvalidArgumentException
*/
function asset_rev(string $file, ?string $manifestFile = null): string
function album_cover_path(?string $fileName): ?string
{
static $manifest = null;
$manifestFile = $manifestFile ?: public_path('mix-manifest.json');
if ($manifest === null) {
$manifest = json_decode(file_get_contents($manifestFile), true);
}
if (isset($manifest[$file])) {
return file_exists(public_path('hot'))
? "http://localhost:8080$manifest[$file]"
: static_url($manifest[$file]);
}
throw new InvalidArgumentException("File $file not defined in asset manifest.");
return $fileName ? public_path(config('koel.album_cover_dir') . $fileName) : null;
}
function album_cover_path(string $fileName): string
function album_cover_url(?string $fileName): ?string
{
return public_path(config('koel.album_cover_dir') . $fileName);
return $fileName ? static_url(config('koel.album_cover_dir') . $fileName) : null;
}
function album_cover_url(string $fileName): string
function artist_image_path(?string $fileName): ?string
{
return static_url(config('koel.album_cover_dir') . $fileName);
return $fileName ? public_path(config('koel.artist_image_dir') . $fileName) : null;
}
function artist_image_path(string $fileName): string
function artist_image_url(?string $fileName): ?string
{
return public_path(config('koel.artist_image_dir') . $fileName);
}
function artist_image_url(string $fileName): string
{
return static_url(config('koel.artist_image_dir') . $fileName);
return $fileName ? static_url(config('koel.artist_image_dir') . $fileName) : null;
}
function koel_version(): string

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\API;
use App\Events\LibraryChanged;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\AlbumCoverUpdateRequest;
use App\Models\Album;
use App\Services\MediaMetadataService;
@ -10,11 +11,8 @@ use Illuminate\Http\JsonResponse;
class AlbumCoverController extends Controller
{
private MediaMetadataService $mediaMetadataService;
public function __construct(MediaMetadataService $mediaMetadataService)
public function __construct(private MediaMetadataService $mediaMetadataService)
{
$this->mediaMetadataService = $mediaMetadataService;
}
public function update(AlbumCoverUpdateRequest $request, Album $album)

View file

@ -2,17 +2,15 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\MediaMetadataService;
use Illuminate\Http\JsonResponse;
class AlbumThumbnailController extends Controller
{
private MediaMetadataService $mediaMetadataService;
public function __construct(MediaMetadataService $mediaMetadataService)
public function __construct(private MediaMetadataService $mediaMetadataService)
{
$this->mediaMetadataService = $mediaMetadataService;
}
public function show(Album $album): JsonResponse

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\API;
use App\Events\LibraryChanged;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ArtistImageUpdateRequest;
use App\Models\Artist;
use App\Services\MediaMetadataService;
@ -10,11 +11,8 @@ use Illuminate\Http\JsonResponse;
class ArtistImageController extends Controller
{
private MediaMetadataService $mediaMetadataService;
public function __construct(MediaMetadataService $mediaMetadataService)
public function __construct(private MediaMetadataService $mediaMetadataService)
{
$this->mediaMetadataService = $mediaMetadataService;
}
public function update(ArtistImageUpdateRequest $request, Artist $artist)

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserLoginRequest;
use App\Models\User;
use App\Repositories\UserRepository;
@ -15,23 +16,13 @@ class AuthController extends Controller
{
use ThrottlesLogins;
private UserRepository $userRepository;
private HashManager $hash;
private TokenManager $tokenManager;
/** @var User */
private ?Authenticatable $currentUser;
/** @param User $user */
public function __construct(
UserRepository $userRepository,
HashManager $hash,
TokenManager $tokenManager,
?Authenticatable $currentUser
private UserRepository $userRepository,
private HashManager $hash,
private TokenManager $tokenManager,
private ?Authenticatable $user
) {
$this->userRepository = $userRepository;
$this->hash = $hash;
$this->tokenManager = $tokenManager;
$this->currentUser = $currentUser;
}
public function login(UserLoginRequest $request)
@ -50,7 +41,9 @@ class AuthController extends Controller
public function logout()
{
$this->tokenManager->destroyTokens($this->currentUser);
if ($this->user) {
$this->tokenManager->destroyTokens($this->user);
}
return response()->noContent();
}

View file

@ -1,9 +0,0 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller as BaseController;
abstract class Controller extends BaseController
{
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Repositories\PlaylistRepository;
@ -18,41 +19,19 @@ class DataController extends Controller
{
private const RECENTLY_PLAYED_EXCERPT_COUNT = 7;
private LastfmService $lastfmService;
private YouTubeService $youTubeService;
private ITunesService $iTunesService;
private MediaCacheService $mediaCacheService;
private SettingRepository $settingRepository;
private PlaylistRepository $playlistRepository;
private InteractionRepository $interactionRepository;
private UserRepository $userRepository;
private ApplicationInformationService $applicationInformationService;
/** @var User */
private ?Authenticatable $currentUser;
/** @param User $currentUser */
public function __construct(
LastfmService $lastfmService,
YouTubeService $youTubeService,
ITunesService $iTunesService,
MediaCacheService $mediaCacheService,
SettingRepository $settingRepository,
PlaylistRepository $playlistRepository,
InteractionRepository $interactionRepository,
UserRepository $userRepository,
ApplicationInformationService $applicationInformationService,
?Authenticatable $currentUser
private LastfmService $lastfmService,
private YouTubeService $youTubeService,
private ITunesService $iTunesService,
private MediaCacheService $mediaCacheService,
private SettingRepository $settingRepository,
private PlaylistRepository $playlistRepository,
private InteractionRepository $interactionRepository,
private UserRepository $userRepository,
private ApplicationInformationService $applicationInformationService,
private ?Authenticatable $currentUser
) {
$this->lastfmService = $lastfmService;
$this->youTubeService = $youTubeService;
$this->iTunesService = $iTunesService;
$this->mediaCacheService = $mediaCacheService;
$this->settingRepository = $settingRepository;
$this->playlistRepository = $playlistRepository;
$this->interactionRepository = $interactionRepository;
$this->userRepository = $userRepository;
$this->applicationInformationService = $applicationInformationService;
$this->currentUser = $currentUser;
}
public function index()

View file

@ -2,20 +2,29 @@
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;
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->currentUser);
$interactions = $this->interactionService->batchLike((array) $request->songs, $this->user);
return response()->json($interactions);
}
public function destroy(BatchInteractionRequest $request)
{
$this->interactionService->batchUnlike((array) $request->songs, $this->currentUser);
$this->interactionService->batchUnlike((array) $request->songs, $this->user);
return response()->noContent();
}

View file

@ -1,22 +0,0 @@
<?php
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller as BaseController;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class Controller extends BaseController
{
protected InteractionService $interactionService;
/** @var User */
protected ?Authenticatable $currentUser = null;
public function __construct(InteractionService $interactionService, ?Authenticatable $currentUser)
{
$this->interactionService = $interactionService;
$this->currentUser = $currentUser;
}
}

View file

@ -2,12 +2,21 @@
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SongLikeRequest;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class LikeController extends Controller
{
/** @param User $user */
public function __construct(private InteractionService $interactionService, private ?Authenticatable $user)
{
}
public function store(SongLikeRequest $request)
{
return response()->json($this->interactionService->toggleLike($request->song, $this->currentUser));
return response()->json($this->interactionService->toggleLike($request->song, $this->user));
}
}

View file

@ -3,13 +3,22 @@
namespace App\Http\Controllers\API\Interaction;
use App\Events\SongStartedPlaying;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Interaction\StorePlayCountRequest;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlayCountController extends Controller
{
/** @param User $user */
public function __construct(private InteractionService $interactionService, private ?Authenticatable $user)
{
}
public function store(StorePlayCountRequest $request)
{
$interaction = $this->interactionService->increasePlayCount($request->song, $this->currentUser);
$interaction = $this->interactionService->increasePlayCount($request->song, $this->user);
event(new SongStartedPlaying($interaction->song, $interaction->user));
return response()->json($interaction);

View file

@ -2,26 +2,20 @@
namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class RecentlyPlayedController extends Controller
{
private InteractionRepository $interactionRepository;
public function __construct(
InteractionService $interactionService,
InteractionRepository $interactionRepository,
?Authenticatable $currentUser
) {
parent::__construct($interactionService, $currentUser);
$this->interactionRepository = $interactionRepository;
/** @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->currentUser, $count));
return response()->json($this->interactionRepository->getRecentlyPlayed($this->user, $count));
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\LastfmSetSessionKeyRequest;
use App\Models\User;
use App\Services\LastfmService;
@ -9,20 +10,14 @@ use Illuminate\Contracts\Auth\Authenticatable;
class LastfmController extends Controller
{
private LastfmService $lastfm;
/** @var User */
private ?Authenticatable $currentUser;
public function __construct(LastfmService $lastfm, ?Authenticatable $currentUser)
/** @param User $currentUser */
public function __construct(private LastfmService $lastfm, private ?Authenticatable $currentUser)
{
$this->lastfm = $lastfm;
$this->currentUser = $currentUser;
}
public function setSessionKey(LastfmSetSessionKeyRequest $request)
{
$this->lastfm->setUserSessionKey($this->currentUser, trim($request->key));
$this->lastfm->setUserSessionKey($this->currentUser, $request->key);
return response()->noContent();
}

View file

@ -2,12 +2,18 @@
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\MediaInformationService;
class AlbumController extends Controller
{
public function __construct(private MediaInformationService $mediaInformationService)
{
}
public function show(Album $album)
{
return response()->json($this->mediaInformationService->getAlbumInformation($album));
return response()->json($this->mediaInformationService->getAlbumInformation($album)?->toArray() ?: []);
}
}

View file

@ -2,12 +2,18 @@
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Artist;
use App\Services\MediaInformationService;
class ArtistController extends Controller
{
public function __construct(private MediaInformationService $mediaInformationService)
{
}
public function show(Artist $artist)
{
return response()->json($this->mediaInformationService->getArtistInformation($artist));
return response()->json($this->mediaInformationService->getArtistInformation($artist)?->toArray() ?: []);
}
}

View file

@ -1,16 +0,0 @@
<?php
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\API\Controller as BaseController;
use App\Services\MediaInformationService;
class Controller extends BaseController
{
protected MediaInformationService $mediaInformationService;
public function __construct(MediaInformationService $mediaInformationService)
{
$this->mediaInformationService = $mediaInformationService;
}
}

View file

@ -2,27 +2,25 @@
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Song;
use App\Services\MediaInformationService;
use App\Services\YouTubeService;
class SongController extends Controller
{
private YouTubeService $youTubeService;
public function __construct(MediaInformationService $mediaInformationService, YouTubeService $youTubeService)
{
parent::__construct($mediaInformationService);
$this->youTubeService = $youTubeService;
public function __construct(
private MediaInformationService $mediaInformationService,
private YouTubeService $youTubeService
) {
}
public function show(Song $song)
{
return response()->json([
'lyrics' => $song->lyrics,
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album),
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist),
'lyrics' => nl2br($song->lyrics), // backward compat
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album)?->toArray() ?: [],
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist)?->toArray() ?: [],
'youtube' => $this->youTubeService->searchVideosRelatedToSong($song),
]);
}

View file

@ -1,9 +0,0 @@
<?php
namespace App\Http\Controllers\API\ObjectStorage;
use App\Http\Controllers\API\Controller as BaseController;
class Controller extends BaseController
{
}

View file

@ -1,9 +0,0 @@
<?php
namespace App\Http\Controllers\API\ObjectStorage\S3;
use App\Http\Controllers\API\ObjectStorage\Controller as BaseController;
class Controller extends BaseController
{
}

View file

@ -3,6 +3,7 @@
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;
@ -10,23 +11,20 @@ use Illuminate\Http\Response;
class SongController extends Controller
{
private S3Service $s3Service;
public function __construct(S3Service $s3Service)
public function __construct(private S3Service $s3Service)
{
$this->s3Service = $s3Service;
}
public function put(PutSongRequest $request)
{
$artist = array_get($request->tags, 'artist', '');
$albumartist = trim(array_get($request->tags, 'albumartist', ''));
$song = $this->s3Service->createSongEntry(
$request->bucket,
$request->key,
$artist,
array_get($request->tags, 'album'),
(bool) $albumartist && $albumartist !== $artist,
trim(array_get($request->tags, 'albumartist')),
array_get($request->tags, 'cover'),
trim(array_get($request->tags, 'title', '')),
(int) array_get($request->tags, 'duration', 0),
@ -41,7 +39,7 @@ class SongController extends Controller
{
try {
$this->s3Service->deleteSongEntry($request->bucket, $request->key);
} catch (SongPathNotFoundException $exception) {
} catch (SongPathNotFoundException) {
abort(Response::HTTP_NOT_FOUND);
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistStoreRequest;
use App\Http\Requests\API\PlaylistUpdateRequest;
use App\Models\Playlist;
@ -12,20 +13,12 @@ use Illuminate\Contracts\Auth\Authenticatable;
class PlaylistController extends Controller
{
private PlaylistRepository $playlistRepository;
private PlaylistService $playlistService;
/** @var User */
private ?Authenticatable $currentUser;
/** @param User $user */
public function __construct(
PlaylistRepository $playlistRepository,
PlaylistService $playlistService,
?Authenticatable $currentUser
private PlaylistRepository $playlistRepository,
private PlaylistService $playlistService,
private ?Authenticatable $user
) {
$this->playlistRepository = $playlistRepository;
$this->playlistService = $playlistService;
$this->currentUser = $currentUser;
}
public function index()
@ -37,7 +30,7 @@ class PlaylistController extends Controller
{
$playlist = $this->playlistService->createPlaylist(
$request->name,
$this->currentUser,
$this->user,
(array) $request->songs,
$request->rules
);
@ -62,6 +55,6 @@ class PlaylistController extends Controller
$playlist->delete();
return response()->json();
return response()->noContent();
}
}

View file

@ -2,17 +2,22 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistSongUpdateRequest;
use App\Models\Playlist;
use App\Models\User;
use App\Services\PlaylistService;
use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlaylistSongController extends Controller
{
private SmartPlaylistService $smartPlaylistService;
public function __construct(SmartPlaylistService $smartPlaylistService)
{
$this->smartPlaylistService = $smartPlaylistService;
/** @param User $user */
public function __construct(
private SmartPlaylistService $smartPlaylistService,
private PlaylistService $playlistService,
private Authenticatable $user
) {
}
public function index(Playlist $playlist)
@ -21,19 +26,20 @@ class PlaylistSongController extends Controller
return response()->json(
$playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist)->pluck('id')
? $this->smartPlaylistService->getSongs($playlist, $this->user)->pluck('id')
: $playlist->songs->pluck('id')
);
}
/** @deprecated */
public function update(PlaylistSongUpdateRequest $request, Playlist $playlist)
{
$this->authorize('owner', $playlist);
abort_if($playlist->is_smart, 403, 'A smart playlist\'s content cannot be updated manually.');
abort_if($playlist->is_smart, 403, 'A smart playlist cannot be populated manually.');
$playlist->songs()->sync((array) $request->songs);
$this->playlistService->populatePlaylist($playlist, (array) $request->songs);
return response()->json();
return response()->noContent();
}
}

View file

@ -2,41 +2,38 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ProfileUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\TokenManager;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Hashing\Hasher as Hash;
use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Validation\ValidationException;
class ProfileController extends Controller
{
private Hash $hash;
private TokenManager $tokenManager;
/** @var User */
private ?Authenticatable $currentUser;
public function __construct(Hash $hash, TokenManager $tokenManager, ?Authenticatable $currentUser)
{
$this->hash = $hash;
$this->tokenManager = $tokenManager;
$this->currentUser = $currentUser;
/** @param User $user */
public function __construct(
private Hasher $hash,
private TokenManager $tokenManager,
private ?Authenticatable $user
) {
}
public function show()
{
return response()->json($this->currentUser);
return UserResource::make($this->user);
}
public function update(ProfileUpdateRequest $request)
{
if (config('koel.misc.demo')) {
return response()->json();
return response()->noContent();
}
throw_unless(
$this->hash->check($request->current_password, $this->currentUser->password),
$this->hash->check($request->current_password, $this->user->password),
ValidationException::withMessages(['current_password' => 'Invalid current password'])
);
@ -46,12 +43,14 @@ class ProfileController extends Controller
$data['password'] = $this->hash->make($request->new_password);
}
$this->currentUser->update($data);
$this->user->update($data);
$responseData = $request->new_password
? ['token' => $this->tokenManager->refreshToken($this->currentUser)->plainTextToken]
: [];
$response = UserResource::make($this->user)->response();
return response()->json($responseData);
if ($request->new_password) {
$response->header('Authorization', $this->tokenManager->refreshToken($this->user)->plainTextToken);
}
return $response;
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ScrobbleStoreRequest;
use App\Jobs\ScrobbleJob;
use App\Models\Song;
@ -10,12 +11,9 @@ use Illuminate\Contracts\Auth\Authenticatable;
class ScrobbleController extends Controller
{
/** @var User */
private ?Authenticatable $currentUser;
public function __construct(?Authenticatable $currentUser)
/** @param User $currentUser */
public function __construct(private ?Authenticatable $currentUser)
{
$this->currentUser = $currentUser;
}
public function store(ScrobbleStoreRequest $request, Song $song)

View file

@ -2,31 +2,23 @@
namespace App\Http\Controllers\API\Search;
use App\Http\Controllers\API\Controller;
use App\Http\Controllers\Controller;
use App\Services\SearchService;
use Illuminate\Http\Request;
use InvalidArgumentException;
class ExcerptSearchController extends Controller
{
private SearchService $searchService;
public function __construct(SearchService $searchService)
public function __construct(private SearchService $searchService)
{
$this->searchService = $searchService;
}
public function index(Request $request)
{
if (!$request->get('q')) {
throw new InvalidArgumentException('A search query is required.');
}
throw_unless((bool) $request->get('q'), new InvalidArgumentException('A search query is required.'));
$count = (int) $request->get('count', SearchService::DEFAULT_EXCERPT_RESULT_COUNT);
if ($count < 0) {
throw new InvalidArgumentException('Invalid count parameter.');
}
throw_if($count < 0, new InvalidArgumentException('Invalid count parameter.'));
return [
'results' => $this->searchService->excerptSearch($request->get('q'), $count),

View file

@ -2,25 +2,20 @@
namespace App\Http\Controllers\API\Search;
use App\Http\Controllers\API\Controller;
use App\Http\Controllers\Controller;
use App\Services\SearchService;
use Illuminate\Http\Request;
use InvalidArgumentException;
class SongSearchController extends Controller
{
private SearchService $searchService;
public function __construct(SearchService $searchService)
public function __construct(private SearchService $searchService)
{
$this->searchService = $searchService;
}
public function index(Request $request)
{
if (!$request->get('q')) {
throw new InvalidArgumentException('A search query is required.');
}
throw_unless((bool) $request->get('q'), new InvalidArgumentException('A search query is required.'));
return [
'songs' => $this->searchService->searchSongs($request->get('q')),

View file

@ -2,26 +2,23 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SettingRequest;
use App\Models\Setting;
use App\Models\User;
use App\Services\MediaSyncService;
class SettingController extends Controller
{
private MediaSyncService $mediaSyncService;
public function __construct(MediaSyncService $mediaSyncService)
public function __construct(private MediaSyncService $mediaSyncService)
{
$this->mediaSyncService = $mediaSyncService;
}
// @TODO: This should be a PUT request
public function store(SettingRequest $request)
public function update(SettingRequest $request)
{
Setting::set('media_path', rtrim(trim($request->media_path), '/'));
$this->authorize('admin', User::class);
// In a next version we should opt for a "MediaPathChanged" event,
// but let's just do this async now.
Setting::set('media_path', rtrim(trim($request->media_path), '/'));
$this->mediaSyncService->sync();
return response()->noContent();

View file

@ -2,30 +2,48 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SongUpdateRequest;
use App\Models\Song;
use App\Http\Resources\AlbumResource;
use App\Http\Resources\ArtistResource;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository;
use App\Services\LibraryManager;
use App\Services\SongService;
use App\Values\SongUpdateData;
use Illuminate\Contracts\Auth\Authenticatable;
class SongController extends Controller
{
private ArtistRepository $artistRepository;
private AlbumRepository $albumRepository;
public function __construct(ArtistRepository $artistRepository, AlbumRepository $albumRepository)
{
$this->artistRepository = $artistRepository;
$this->albumRepository = $albumRepository;
/** @param User $user */
public function __construct(
private SongService $songService,
private AlbumRepository $albumRepository,
private ArtistRepository $artistRepository,
private LibraryManager $libraryManager,
private ?Authenticatable $user
) {
}
public function update(SongUpdateRequest $request)
{
$updatedSongs = Song::updateInfo($request->songs, $request->data);
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
$albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray(), $this->user);
$artists = $this->artistRepository->getByIds(
array_merge(
$updatedSongs->pluck('artist_id')->all(),
$updatedSongs->pluck('album_artist_id')->all()
)
);
return response()->json([
'artists' => $this->artistRepository->getByIds($updatedSongs->pluck('artist_id')->all()),
'albums' => $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->all()),
'songs' => $updatedSongs,
'songs' => SongResource::collection($updatedSongs),
'albums' => AlbumResource::collection($albums),
'artists' => ArtistResource::collection($artists),
'removed' => $this->libraryManager->prune(),
]);
}
}

View file

@ -2,35 +2,42 @@
namespace App\Http\Controllers\API;
use App\Events\MediaCacheObsolete;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UploadRequest;
use App\Http\Resources\AlbumResource;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\AlbumRepository;
use App\Repositories\SongRepository;
use App\Services\UploadService;
use Illuminate\Http\JsonResponse;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class UploadController extends Controller
{
private UploadService $uploadService;
/** @param User $user */
public function __invoke(
UploadService $uploadService,
AlbumRepository $albumRepository,
SongRepository $songRepository,
UploadRequest $request,
Authenticatable $user
) {
$this->authorize('admin', User::class);
public function __construct(UploadService $uploadService)
{
$this->uploadService = $uploadService;
}
public function store(UploadRequest $request): JsonResponse
{
try {
$song = $this->uploadService->handleUploadedFile($request->file);
$song = $songRepository->getOne($uploadService->handleUploadedFile($request->file)->id);
return response()->json([
'song' => SongResource::make($song),
'album' => AlbumResource::make($albumRepository->getOne($song->album_id)),
]);
} catch (MediaPathNotSetException $e) {
abort(Response::HTTP_FORBIDDEN, $e->getMessage());
} catch (SongUploadFailedException $e) {
abort(Response::HTTP_BAD_REQUEST, $e->getMessage());
}
event(new MediaCacheObsolete());
return response()->json($song->load('album', 'artist'));
}
}

View file

@ -2,48 +2,56 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserStoreRequest;
use App\Http\Requests\API\UserUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher as Hash;
use App\Repositories\UserRepository;
use App\Services\UserService;
class UserController extends Controller
{
private Hash $hash;
public function __construct(Hash $hash)
public function __construct(private UserRepository $userRepository, private UserService $userService)
{
$this->hash = $hash;
}
public function index()
{
$this->authorize('admin', User::class);
return UserResource::collection($this->userRepository->getAll());
}
public function store(UserStoreRequest $request)
{
return response()->json(User::create([
'name' => $request->name,
'email' => $request->email,
'password' => $this->hash->make($request->password),
'is_admin' => $request->is_admin,
]));
$this->authorize('admin', User::class);
return UserResource::make($this->userService->createUser(
$request->name,
$request->email,
$request->password,
$request->get('is_admin') ?: false
));
}
public function update(UserUpdateRequest $request, User $user)
{
$data = $request->only('name', 'email', 'is_admin');
$this->authorize('admin', User::class);
if ($request->password) {
$data['password'] = $this->hash->make($request->password);
}
$user->update($data);
return response()->json($user);
return UserResource::make($this->userService->updateUser(
$user,
$request->name,
$request->email,
$request->password,
$request->get('is_admin') ?: false
));
}
public function destroy(User $user)
{
$this->authorize('destroy', $user);
$user->delete();
$this->userService->deleteUser($user);
return response()->noContent();
}

View file

@ -2,17 +2,15 @@
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\YouTubeSearchRequest;
use App\Models\Song;
use App\Services\YouTubeService;
class YouTubeController extends Controller
{
private YouTubeService $youTubeService;
public function __construct(YouTubeService $youTubeService)
public function __construct(private YouTubeService $youTubeService)
{
$this->youTubeService = $youTubeService;
}
public function searchVideosRelatedToSong(YouTubeSearchRequest $request, Song $song)

View file

@ -2,10 +2,16 @@
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\DownloadService;
class AlbumController extends Controller
{
public function __construct(private DownloadService $downloadService)
{
}
public function show(Album $album)
{
return response()->download($this->downloadService->from($album));

View file

@ -2,10 +2,16 @@
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Artist;
use App\Services\DownloadService;
class ArtistController extends Controller
{
public function __construct(private DownloadService $downloadService)
{
}
public function show(Artist $artist)
{
return response()->download($this->downloadService->from($artist));

View file

@ -1,16 +0,0 @@
<?php
namespace App\Http\Controllers\Download;
use App\Http\Controllers\API\Controller as BaseController;
use App\Services\DownloadService;
abstract class Controller extends BaseController
{
protected DownloadService $downloadService;
public function __construct(DownloadService $downloadService)
{
$this->downloadService = $downloadService;
}
}

View file

@ -2,24 +2,25 @@
namespace App\Http\Controllers\Download;
use App\Http\Requests\Download\Request;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Services\DownloadService;
use Illuminate\Contracts\Auth\Authenticatable;
class FavoritesController extends Controller
{
private InteractionRepository $interactionRepository;
public function __construct(DownloadService $downloadService, InteractionRepository $interactionRepository)
{
parent::__construct($downloadService);
$this->interactionRepository = $interactionRepository;
/** @param User $user */
public function __construct(
private DownloadService $downloadService,
private InteractionRepository $interactionRepository,
private ?Authenticatable $user
) {
}
public function show(Request $request)
public function show()
{
$songs = $this->interactionRepository->getUserFavorites($request->user());
$songs = $this->interactionRepository->getUserFavorites($this->user);
return response()->download($this->downloadService->from($songs));
}

View file

@ -2,10 +2,16 @@
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Playlist;
use App\Services\DownloadService;
class PlaylistController extends Controller
{
public function __construct(private DownloadService $downloadService)
{
}
public function show(Playlist $playlist)
{
$this->authorize('owner', $playlist);

View file

@ -2,19 +2,15 @@
namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Http\Requests\Download\SongRequest;
use App\Repositories\SongRepository;
use App\Services\DownloadService;
class SongController extends Controller
{
private SongRepository $songRepository;
public function __construct(DownloadService $downloadService, SongRepository $songRepository)
public function __construct(private DownloadService $downloadService, private SongRepository $songRepository)
{
parent::__construct($downloadService);
$this->songRepository = $songRepository;
}
public function show(SongRequest $request)

View file

@ -10,13 +10,8 @@ use Illuminate\Http\Response;
class ITunesController extends Controller
{
private ITunesService $iTunesService;
private TokenManager $tokenManager;
public function __construct(ITunesService $iTunesService, TokenManager $tokenManager)
public function __construct(private ITunesService $iTunesService, private TokenManager $tokenManager)
{
$this->iTunesService = $iTunesService;
$this->tokenManager = $tokenManager;
}
public function viewSong(ViewSongOnITunesRequest $request, Album $album)

View file

@ -11,17 +11,12 @@ use Illuminate\Http\Response;
class LastfmController extends Controller
{
private LastfmService $lastfm;
private TokenManager $tokenManager;
/** @var User */
private ?Authenticatable $currentUser;
public function __construct(LastfmService $lastfm, TokenManager $tokenManager, ?Authenticatable $currentUser)
{
$this->lastfm = $lastfm;
$this->tokenManager = $tokenManager;
$this->currentUser = $currentUser;
/** @param User $currentUser */
public function __construct(
private LastfmService $lastfm,
private TokenManager $tokenManager,
private ?Authenticatable $currentUser
) {
}
public function connect()

View file

@ -8,17 +8,14 @@ use App\Models\Song;
class PlayController extends Controller
{
private StreamerFactory $streamerFactory;
public function __construct(StreamerFactory $streamerFactory)
public function __construct(private StreamerFactory $streamerFactory)
{
$this->streamerFactory = $streamerFactory;
}
public function show(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null)
{
return $this->streamerFactory
->createStreamer($song, $transcode, $bitRate, floatval($request->time))
->createStreamer($song, $transcode, $bitRate, (float) $request->time)
->stream();
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource;
use App\Models\Album;
use App\Models\User;
use App\Repositories\AlbumRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class AlbumController extends Controller
{
/** @param User $user */
public function __construct(private AlbumRepository $albumRepository, private ?Authenticatable $user)
{
}
public function index()
{
$pagination = Album::withMeta($this->user)
->isStandard()
->orderBy('albums.name')
->simplePaginate(21);
return AlbumResource::collection($pagination);
}
public function show(Album $album)
{
return AlbumResource::make($this->albumRepository->getOne($album->id, $this->user));
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\Album;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class AlbumSongController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
}
public function index(Album $album)
{
return SongResource::collection($this->songRepository->getByAlbum($album, $this->user));
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtistResource;
use App\Models\Artist;
use App\Models\User;
use App\Repositories\ArtistRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class ArtistController extends Controller
{
/** @param User $user */
public function __construct(private ArtistRepository $artistRepository, private ?Authenticatable $user)
{
}
public function index()
{
$pagination = Artist::withMeta($this->user)
->isStandard()
->orderBy('artists.name')
->simplePaginate(21);
return ArtistResource::collection($pagination);
}
public function show(Artist $artist)
{
return ArtistResource::make($this->artistRepository->getOne($artist->id, $this->user));
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\Artist;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class ArtistSongController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
}
public function index(Artist $artist)
{
return SongResource::collection($this->songRepository->getByArtist($artist, $this->user));
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
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\YouTubeService;
use Illuminate\Contracts\Auth\Authenticatable;
class DataController extends Controller
{
/** @param User $user */
public function __construct(
private LastfmService $lastfmService,
private YouTubeService $youTubeService,
private ITunesService $iTunesService,
private SettingRepository $settingRepository,
private PlaylistRepository $playlistRepository,
private SongRepository $songRepository,
private ApplicationInformationService $applicationInformationService,
private ?Authenticatable $user
) {
}
public function index()
{
return response()->json([
'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [],
'playlists' => $this->playlistRepository->getAllByCurrentUser(),
'current_user' => UserResource::make($this->user),
'use_last_fm' => $this->lastfmService->used(),
'use_you_tube' => $this->youTubeService->enabled(), // @todo clean this mess up
'use_i_tunes' => $this->iTunesService->used(),
'allow_download' => config('koel.download.allow'),
'supports_transcoding' => config('koel.streaming.ffmpeg_path')
&& is_executable(config('koel.streaming.ffmpeg_path')),
'cdn_url' => static_url(),
'current_version' => koel_version(),
'latest_version' => $this->user->is_admin
? $this->applicationInformationService->getLatestVersionNumber()
: koel_version(),
'song_count' => $this->songRepository->count(),
'song_length' => $this->songRepository->getTotalLength(),
]);
}
}

View file

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

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class FavoriteSongController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
}
public function index()
{
return SongResource::collection($this->songRepository->getFavorites($this->user));
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\MediaInformationService;
class FetchAlbumInformationController extends Controller
{
public function __invoke(Album $album, MediaInformationService $informationService)
{
return response()->json($informationService->getAlbumInformation($album));
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Models\Artist;
use App\Services\MediaInformationService;
class FetchArtistInformationController extends Controller
{
public function __invoke(Artist $artist, MediaInformationService $informationService)
{
return response()->json($informationService->getArtistInformation($artist));
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource;
use App\Http\Resources\ArtistResource;
use App\Http\Resources\SongResource;
use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository;
use App\Repositories\SongRepository;
class OverviewController extends Controller
{
public function __construct(
private SongRepository $songRepository,
private AlbumRepository $albumRepository,
private ArtistRepository $artistRepository
) {
}
public function index()
{
return response()->json([
'most_played_songs' => SongResource::collection($this->songRepository->getMostPlayed()),
'recently_played_songs' => SongResource::collection($this->songRepository->getRecentlyPlayed()),
'recently_added_albums' => AlbumResource::collection($this->albumRepository->getRecentlyAdded()),
'recently_added_songs' => SongResource::collection($this->songRepository->getRecentlyAdded()),
'most_played_artists' => ArtistResource::collection($this->artistRepository->getMostPlayed()),
'most_played_albums' => AlbumResource::collection($this->albumRepository->getMostPlayed()),
]);
}
}

View file

@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Events\SongStartedPlaying;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Interaction\StorePlayCountRequest;
use App\Http\Resources\InteractionResource;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlayCountController extends Controller
{
/** @param User $user */
public function __construct(private InteractionService $interactionService, private ?Authenticatable $user)
{
}
public function store(StorePlayCountRequest $request)
{
$interaction = $this->interactionService->increasePlayCount($request->song, $this->user);
event(new SongStartedPlaying($interaction->song, $interaction->user));
return InteractionResource::make($interaction);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\AddSongsToPlaylistRequest;
use App\Http\Controllers\V6\Requests\RemoveSongsFromPlaylistRequest;
use App\Http\Resources\SongResource;
use App\Models\Playlist;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\PlaylistService;
use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class PlaylistSongController extends Controller
{
/** @param User $user */
public function __construct(
private SongRepository $songRepository,
private PlaylistService $playlistService,
private SmartPlaylistService $smartPlaylistService,
private ?Authenticatable $user
) {
}
public function index(Playlist $playlist)
{
$this->authorize('owner', $playlist);
return SongResource::collection(
$playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist, $this->user)
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
);
}
public function add(Playlist $playlist, AddSongsToPlaylistRequest $request)
{
$this->authorize('owner', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->addSongsToPlaylist($playlist, $request->songs);
return response()->noContent();
}
public function remove(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
{
$this->authorize('owner', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
return response()->noContent();
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\QueueFetchSongRequest;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class QueueController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
}
public function fetchSongs(QueueFetchSongRequest $request)
{
if ($request->order === 'rand') {
return SongResource::collection($this->songRepository->getRandom($request->limit, $this->user));
} else {
return SongResource::collection(
$this->songRepository->getForQueue(
$request->sort,
$request->order,
$request->limit,
$this->user,
)
);
}
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class RecentlyPlayedSongController extends Controller
{
private const MAX_ITEM_COUNT = 128;
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
}
public function index()
{
return SongResource::collection($this->songRepository->getRecentlyPlayed(self::MAX_ITEM_COUNT, $this->user));
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\SongListRequest;
use App\Http\Resources\SongResource;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use Illuminate\Contracts\Auth\Authenticatable;
class SongController extends Controller
{
/** @param User $user */
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
{
}
public function show(Song $song)
{
return SongResource::make($this->songRepository->getOne($song->id));
}
public function index(SongListRequest $request)
{
return SongResource::collection(
$this->songRepository->getForListing(
$request->sort ?: 'songs.title',
$request->order ?: 'asc',
$this->user
)
);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Controllers\V6\Requests\SearchRequest;
use App\Http\Resources\SongResource;
use App\Models\User;
use App\Services\V6\SearchService;
use Illuminate\Contracts\Auth\Authenticatable;
class SongSearchController extends Controller
{
/** @param User $user */
public function __invoke(SearchRequest $request, SearchService $searchService, Authenticatable $user)
{
return SongResource::collection($searchService->searchSongs($request->q, $user));
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read array<string> $songs
*/
class AddSongsToPlaylistRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'songs' => 'required|array',
'songs.*' => 'exists:songs,id',
];
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
use App\Repositories\SongRepository;
use Illuminate\Validation\Rule;
/**
* @property-read string|null $sort
* @property-read string $order
* @property-read int $limit
*/
class QueueFetchSongRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'order' => ['required', Rule::in('asc', 'desc', 'rand')],
'limit' => 'required|integer|min:1',
'sort' => [
'required_unless:order,rand',
Rule::in(array_keys(SongRepository::SORT_COLUMNS_NORMALIZE_MAP)),
],
];
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read array<string> $songs
*/
class RemoveSongsFromPlaylistRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'songs' => 'required|array',
'songs.*' => 'exists:songs,id',
];
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read string $q
*/
class SearchRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return ['q' => 'required'];
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read string $order
* @property-read string $sort
*/
class SongListRequest extends Request
{
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers\V6\Requests;
use App\Http\Requests\API\Request;
/**
* @property-read string|null $pageToken
*/
class YouTubeSearchRequest extends Request
{
}

View file

@ -5,14 +5,13 @@ namespace App\Http;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ObjectStorageAuthenticate;
use App\Http\Middleware\ThrottleRequests;
use App\Http\Middleware\TrimStrings;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
class Kernel extends HttpKernel
{
@ -25,7 +24,6 @@ class Kernel extends HttpKernel
CheckForMaintenanceMode::class,
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
ForceHttps::class,
];

View file

@ -8,17 +8,14 @@ use Illuminate\Http\Request;
class Authenticate
{
protected Guard $auth;
public function __construct(Guard $auth)
public function __construct(protected Guard $auth)
{
$this->auth = $auth;
}
public function handle(Request $request, Closure $next) // @phpcs:ignore
{
if ($this->auth->guest()) {
if ($request->ajax() || $request->route()->getName() === 'play') {
if ($request->ajax() || $request->wantsJson() || $request->route()->getName() === 'play') {
return response('Unauthorized.', 401);
} else {
return redirect()->guest('/');

View file

@ -8,11 +8,8 @@ use Illuminate\Routing\UrlGenerator;
class ForceHttps
{
private UrlGenerator $url;
public function __construct(UrlGenerator $url)
public function __construct(private UrlGenerator $url)
{
$this->url = $url;
}
public function handle(Request $request, Closure $next) // @phpcs:ignore

View file

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Authenticate requests from Object Storage services (like S3).
@ -13,9 +14,7 @@ class ObjectStorageAuthenticate
{
public function handle(Request $request, Closure $next) // @phpcs:ignore
{
if ($request->appKey !== config('app.key')) {
return response('Unauthorized.', 401);
}
abort_unless($request->get('appKey') === config('app.key'), Response::HTTP_UNAUTHORIZED);
return $next($request);
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Routing\Middleware\ThrottleRequests as BaseThrottleRequests;
use Symfony\Component\HttpFoundation\Response;
class ThrottleRequests extends BaseThrottleRequests
{
public function handle($request, Closure $next, $maxAttempts = 300, $decayMinutes = 1, $prefix = ''): Response
{
if (app()->environment('production')) {
return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix);
}
return $next($request);
}
}

View file

@ -3,7 +3,7 @@
namespace App\Http\Requests\API;
/** @property string $cover */
class AlbumCoverUpdateRequest extends AbstractMediaImageUpdateRequest
class AlbumCoverUpdateRequest extends MediaImageUpdateRequest
{
protected function getImageFieldName(): string
{

View file

@ -3,7 +3,7 @@
namespace App\Http\Requests\API;
/** @property string $image */
class ArtistImageUpdateRequest extends AbstractMediaImageUpdateRequest
class ArtistImageUpdateRequest extends MediaImageUpdateRequest
{
protected function getImageFieldName(): string
{

View file

@ -4,6 +4,6 @@ namespace App\Http\Requests\API\Interaction;
use App\Http\Requests\API\Request as BaseRequest;
class Request extends BaseRequest
abstract class Request extends BaseRequest
{
}

View file

@ -4,7 +4,7 @@ namespace App\Http\Requests\API;
use App\Rules\ImageData;
abstract class AbstractMediaImageUpdateRequest extends Request
abstract class MediaImageUpdateRequest extends Request
{
public function authorize(): bool
{

View file

@ -2,8 +2,8 @@
namespace App\Http\Requests\API;
use App\Http\Requests\AbstractRequest;
use App\Http\Requests\Request as BaseRequest;
class Request extends AbstractRequest
abstract class Request extends BaseRequest
{
}

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