chore: resolve conflicts

This commit is contained in:
Phan An 2022-08-02 11:33:24 +02:00
commit 527e7abb70
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
681 changed files with 34779 additions and 12557 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_style = space
indent_size = 2 indent_size = 2
[*.php] [{*.php, *.xml, *.xml.dist}]
indent_size = 4 indent_size = 4

View file

@ -6,7 +6,7 @@ APP_NAME=Koel
# pgsql (PostgreSQL) # pgsql (PostgreSQL)
# sqlsrv (Microsoft SQL Server) # sqlsrv (Microsoft SQL Server)
# sqlite-persistent (Local sqlite file) # 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_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
@ -46,16 +46,42 @@ MEMORY_LIMIT=
# Can be either 'php' (default), 'x-sendfile', or 'x-accel-redirect' # 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/#streaming-music for more information.
# Note: This setting doesn't have effect if the media needs transcoding (e.g. FLAC). # 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 STREAMING_METHOD=php
# If you want Koel to integrate with Last.fm, set the API details here. # Full text search driver.
# See https://docs.koel.dev/3rd-party.html#last-fm for more information # 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_KEY=
LASTFM_API_SECRET= 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 # installation guide at https://docs.koel.dev/aws-s3.html
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
@ -63,7 +89,7 @@ AWS_REGION=
AWS_ENDPOINT= 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. # See https://docs.koel.dev/3rd-party.html#youtube for more information.
YOUTUBE_API_KEY= YOUTUBE_API_KEY=
@ -74,8 +100,7 @@ YOUTUBE_API_KEY=
CDN_URL= CDN_URL=
# If you want to transcode FLAC to MP3 and stream it on the fly, make sure the # To transcode FLAC to MP3 and stream it on the fly, make sure the following settings are sane.
# following settings are sane.
# The full path of ffmpeg binary. # The full path of ffmpeg binary.
FFMPEG_PATH=/usr/local/bin/ffmpeg FFMPEG_PATH=/usr/local/bin/ffmpeg
@ -90,12 +115,6 @@ OUTPUT_BIT_RATE=128
# environment, such a download will (silently) fail. # environment, such a download will (silently) fail.
ALLOW_DOWNLOAD=true 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. # 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. # 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_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null

View file

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

View file

@ -1,19 +1,37 @@
{ {
"root": true, "parser": "vue-eslint-parser",
"parser": "@typescript-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": [ "plugins": [
"@typescript-eslint" "@typescript-eslint"
], ],
"extends": [ "globals": {
"eslint:recommended", "KOEL_ENV": "readonly",
"plugin:@typescript-eslint/eslint-recommended", "FileReader": "readonly",
"plugin:@typescript-eslint/recommended" "defineProps": "readonly",
], "defineEmits": "readonly",
"defineExpose": "readonly",
"withDefaults": "readonly"
},
"rules": { "rules": {
"camelcase": 0, "camelcase": 0,
"no-multi-str": 0, "no-multi-str": 0,
"no-empty": 0, "no-empty": 0,
"quotes": 0, "quotes": 0,
"no-use-before-define": 0,
"@typescript-eslint/no-var-requires": 0, "@typescript-eslint/no-var-requires": 0,
"@typescript-eslint/camelcase": 0, "@typescript-eslint/camelcase": 0,
"@typescript-eslint/member-delimiter-style": 0, "@typescript-eslint/member-delimiter-style": 0,
@ -21,6 +39,13 @@
"@typescript-eslint/no-inferrable-types": 0, "@typescript-eslint/no-inferrable-types": 0,
"@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-non-null-assertion": 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: on:
push: push:
branches: branches:
- master - master
# @fixme Tmp.disable until ready
# - next
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@ -19,11 +21,11 @@ jobs:
- name: Set up PHP - name: Set up PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: 7.4 php-version: 8.1
tools: composer:v2 tools: composer:v2
extensions: pdo_sqlite extensions: pdo_sqlite
- name: Install PHP dependencies - name: Install PHP dependencies
uses: ramsey/composer-install@v1 uses: ramsey/composer-install@v2
with: with:
composer-options: --prefer-dist composer-options: --prefer-dist
- name: Generate app key - name: Generate app key
@ -32,12 +34,6 @@ jobs:
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: '14' 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 - name: Run E2E tests
uses: cypress-io/github-action@v2 uses: cypress-io/github-action@v2
with: with:

View file

@ -1,7 +1,7 @@
on: on:
push: push:
tags: 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 name: Upload Release Assets
@ -14,22 +14,20 @@ jobs:
id: get_version id: get_version
run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//}
- uses: actions/checkout@v2 - uses: actions/checkout@v2
with:
submodules: recursive
- name: Set up PHP - name: Set up PHP
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: 7.4 php-version: 8.1
tools: composer:v2 tools: composer:v2
extensions: pdo_sqlite extensions: pdo_sqlite, zip, gd
- name: Install PHP dependencies - name: Install PHP dependencies
uses: ramsey/composer-install@v1 uses: ramsey/composer-install@v2
with: with:
composer-options: --prefer-dist composer-options: --prefer-dist
- name: Set up Node - name: Set up Node
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: '14' node-version: 16
- name: Build project - name: Build project
run: | run: |
sudo apt install pngquant zip unzip sudo apt install pngquant zip unzip
@ -39,10 +37,10 @@ jobs:
run: | run: |
sed -i 's/DB_CONNECTION=sqlite/DB_CONNECTION=sqlite-persistent/' .env sed -i 's/DB_CONNECTION=sqlite/DB_CONNECTION=sqlite-persistent/' .env
sed -i 's/DB_DATABASE=koel/DB_DATABASE=koel.db/' .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 ../ cd ../
zip -r /tmp/koel-${{ steps.get_version.outputs.VERSION }}.zip koel/ 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 - name: Create release
id: create_release id: create_release
uses: actions/create-release@v1 uses: actions/create-release@v1
@ -59,7 +57,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: /tmp/koel-${{ steps.get_version.outputs.VERSION }}.zip
asset_name: koel-${{ steps.get_version.outputs.VERSION }}.zip asset_name: koel-${{ steps.get_version.outputs.VERSION }}.zip
asset_content_type: application/zip asset_content_type: application/zip
@ -69,7 +67,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: 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_path: /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz
asset_name: koel-${{ steps.get_version.outputs.VERSION }}.tar.gz asset_name: koel-${{ steps.get_version.outputs.VERSION }}.tar.gz
asset_content_type: application/gzip asset_content_type: application/gzip

View file

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

View file

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

View file

@ -11,20 +11,16 @@ class ChangePasswordCommand extends Command
{ {
use AskForPassword; 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.}"; {email? : The user's email. If empty, will get the default admin user.}";
protected $description = "Change a user's password"; protected $description = "Change a user's password";
private Hash $hash; public function __construct(private Hash $hash)
public function __construct(Hash $hash)
{ {
parent::__construct(); parent::__construct();
$this->hash = $hash;
} }
public function handle(): void public function handle(): int
{ {
$email = $this->argument('email'); $email = $this->argument('email');
@ -34,7 +30,7 @@ class ChangePasswordCommand extends Command
if (!$user) { if (!$user) {
$this->error('The user account cannot be found.'); $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)"); $this->comment("Changing the user's password (ID: $user->id, email: $user->email)");
@ -43,5 +39,7 @@ class ChangePasswordCommand extends Command
$user->save(); $user->save();
$this->comment('Alrighty, the new password has been saved. Enjoy! 👌'); $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]); $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\Exceptions\InstallationFailedException;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User; use App\Models\User;
use App\Repositories\SettingRepository;
use App\Services\MediaCacheService; use App\Services\MediaCacheService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Contracts\Console\Kernel as Artisan; use Illuminate\Contracts\Console\Kernel as Artisan;
use Illuminate\Contracts\Hashing\Hasher as Hash; use Illuminate\Contracts\Hashing\Hasher as Hash;
use Illuminate\Database\DatabaseManager as DB; use Illuminate\Database\DatabaseManager as DB;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use Jackiedo\DotenvEditor\DotenvEditor; use Jackiedo\DotenvEditor\DotenvEditor;
use Psr\Log\LoggerInterface;
use Throwable; use Throwable;
class InitCommand extends Command class InitCommand extends Command
@ -22,48 +24,40 @@ class InitCommand extends Command
private const DEFAULT_ADMIN_NAME = 'Koel'; private const DEFAULT_ADMIN_NAME = 'Koel';
private const DEFAULT_ADMIN_EMAIL = 'admin@koel.dev'; private const DEFAULT_ADMIN_EMAIL = 'admin@koel.dev';
private const DEFAULT_ADMIN_PASSWORD = 'KoelIsCool'; 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 $signature = 'koel:init {--no-assets}';
protected $description = 'Install or upgrade Koel'; 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; private bool $adminSeeded = false;
public function __construct( public function __construct(
MediaCacheService $mediaCacheService, private MediaCacheService $mediaCacheService,
SettingRepository $settingRepository, private Artisan $artisan,
Artisan $artisan, private Hash $hash,
Hash $hash, private DotenvEditor $dotenvEditor,
DotenvEditor $dotenvEditor, private DB $db,
DB $db private LoggerInterface $logger
) { ) {
parent::__construct(); 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->alert('KOEL INSTALLATION WIZARD');
$this->comment('Remember, you can always install/upgrade manually following the guide here:'); $this->info(
$this->info('📙 ' . config('koel.misc.docs_url') . PHP_EOL); 'As a reminder, you can always install/upgrade manually following the guide at '
. config('koel.misc.docs_url')
. PHP_EOL
);
if ($this->inNoInteractionMode()) { if ($this->inNoInteractionMode()) {
$this->info('Running in no-interaction mode'); $this->components->info('Running in no-interaction mode');
} }
try { try {
$this->clearCaches();
$this->loadEnvFile();
$this->maybeGenerateAppKey(); $this->maybeGenerateAppKey();
$this->maybeSetUpDatabase(); $this->maybeSetUpDatabase();
$this->migrateDatabase(); $this->migrateDatabase();
@ -71,32 +65,79 @@ class InitCommand extends Command
$this->maybeSetMediaPath(); $this->maybeSetMediaPath();
$this->maybeCompileFrontEndAssets(); $this->maybeCompileFrontEndAssets();
} catch (Throwable $e) { } catch (Throwable $e) {
$this->error("Oops! Koel installation or upgrade didn't finish successfully."); $this->logger->error($e);
$this->error('Please try again, or visit ' . config('koel.misc.docs_url') . ' for manual installation.');
$this->error('😥 Sorry for this. You deserve better.');
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) { if ($this->adminSeeded) {
$this->comment( $this->info(
sprintf('Log in with email %s and password %s', self::DEFAULT_ADMIN_EMAIL, self::DEFAULT_ADMIN_PASSWORD) sprintf('Log in with email %s and password %s', self::DEFAULT_ADMIN_EMAIL, self::DEFAULT_ADMIN_PASSWORD)
); );
} }
if (Setting::get('media_path')) { 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->info('Again, visit 📙 ' . config('koel.misc.docs_url') . ' for more tips and tweaks.');
$this->comment(
$this->info(
"Feeling generous and want to support Koel's development? Check out " "Feeling generous and want to support Koel's development? Check out "
. config('koel.misc.sponsor_github_url') . 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 private function setUpDatabase(): void
{ {
$config = [ $config = [
'DB_CONNECTION' => '',
'DB_HOST' => '', 'DB_HOST' => '',
'DB_PORT' => '', 'DB_PORT' => '',
'DB_DATABASE' => '',
'DB_USERNAME' => '', 'DB_USERNAME' => '',
'DB_PASSWORD' => '', 'DB_PASSWORD' => '',
]; ];
@ -163,16 +202,83 @@ class InitCommand extends Command
private function setUpAdminAccount(): void 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([ $this->adminSeeded = true;
'name' => self::DEFAULT_ADMIN_NAME, });
'email' => self::DEFAULT_ADMIN_EMAIL, }
'password' => $this->hash->make(self::DEFAULT_ADMIN_PASSWORD),
'is_admin' => 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 private function maybeSetMediaPath(): void
@ -187,6 +293,7 @@ class InitCommand extends Command
return; 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 $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) { while (true) {
@ -196,103 +303,23 @@ class InitCommand extends Command
return; return;
} }
if ($this->isValidMediaPath($path)) { if (self::isValidMediaPath($path)) {
Setting::set('media_path', $path); Setting::set('media_path', $path);
return; 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 private function maybeCompileFrontEndAssets(): void
{ {
if ($this->inNoAssetsMode()) { if ($this->inNoAssetsMode()) {
return; return;
} }
$this->info('Now to front-end stuff'); $this->components->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');
$runOkOrThrow = static function (string $command): void { $runOkOrThrow = static function (string $command): void {
passthru($command, $status); passthru($command, $status);
@ -300,31 +327,35 @@ class InitCommand extends Command
}; };
$runOkOrThrow('yarn install --colors'); $runOkOrThrow('yarn install --colors');
$this->components->info('Compiling assets');
chdir('../..'); $runOkOrThrow('yarn build');
$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);
} }
private function setMediaPathFromEnvFile(): void private function setMediaPathFromEnvFile(): void
{ {
with(config('koel.media_path'), function (?string $path): void { $path = config('koel.media_path');
if (!$path) {
return;
}
if ($this->isValidMediaPath($path)) { if (!$path) {
Setting::set('media_path', $path); return;
} else { }
$this->warn(sprintf('The path %s does not exist or not readable. Skipping.', $path));
} 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; namespace App\Console\Commands;
use App\Events\LibraryChanged; use App\Services\LibraryManager;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class PruneLibraryCommand extends Command class PruneLibraryCommand extends Command
@ -10,9 +10,16 @@ class PruneLibraryCommand extends Command
protected $signature = 'koel:prune'; protected $signature = 'koel:prune';
protected $description = 'Remove empty artists and albums'; 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.'); $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\Libraries\WatchRecord\InotifyWatchRecord;
use App\Models\Setting; use App\Models\Setting;
use App\Services\FileSynchronizer;
use App\Services\MediaSyncService; use App\Services\MediaSyncService;
use App\Values\SyncResult;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar; use Symfony\Component\Console\Helper\ProgressBar;
class SyncCommand extends Command class SyncCommand extends Command
{ {
protected $signature = 'koel:sync protected $signature = 'koel:sync
{record? : A single watch record. Consult Wiki for more info.} {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}'; {--force : Force re-syncing even unchanged files}';
protected $description = 'Sync songs found in configured directory against the database.'; 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(); 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'); $record = $this->argument('record');
if (!$record) { if ($record) {
$this->syncSingleRecord($record);
} else {
$this->syncAll(); $this->syncAll();
return;
} }
$this->syngle($record); return self::SUCCESS;
} }
/** /**
* Sync all files in the configured media path. * 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. // Notice that this is only meaningful for existing records.
// New records will have every applicable field sync'ed in. // New records will have every applicable field synced in.
$tags = $this->option('tags') ? explode(',', $this->option('tags')) : []; $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( $this->newLine(2);
PHP_EOL . PHP_EOL $this->components->info('Scanning completed!');
. "<info>Completed! $this->synced new or updated song(s)</info>, "
. "$this->ignored unchanged song(s), " $this->components->bulletList([
. "and <comment>$this->invalid invalid file(s)</comment>." "<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. * @param string $record The watch record.
* As of current we only support inotifywait. * As of current we only support inotifywait.
* Some examples: * Some examples:
@ -79,45 +83,39 @@ class SyncCommand extends Command
* *
* @see http://man7.org/linux/man-pages/man1/inotifywait.1.html * @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)); $this->mediaSyncService->syncByWatchRecord(new InotifyWatchRecord($record));
} }
/** public function onSyncProgress(SyncResult $result): void
* Log a song's sync status to console.
*/
public function logSyncStatusToConsole(string $path, int $result, ?string $reason = null): void
{ {
$name = basename($path); if (!$this->option('verbose')) {
$this->progressBar->advance();
if ($result === FileSynchronizer::SYNC_RESULT_UNMODIFIED) { return;
++$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);
}
++$this->invalid; $path = trim(Str::replaceFirst($this->mediaPath, '', $result->path), DIRECTORY_SEPARATOR);
} else {
++$this->synced; $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 if ($path) {
{ return $path;
$this->progressBar->advance();
}
private function ensureMediaPath(): void
{
if (Setting::get('media_path')) {
return;
} }
$this->warn("Media path hasn't been configured. Let's set it up."); $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.'); $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 $signature = 'koel:tidy';
protected $hidden = true; protected $hidden = true;
public function handle(): void public function handle(): int
{ {
$this->warn('koel:tidy has been renamed. Use koel:prune instead.'); $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; namespace App\Events;
use App\Values\SyncResult; use App\Values\SyncResultCollection;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
class MediaSyncCompleted extends Event class MediaSyncCompleted extends Event
{ {
use SerializesModels; use SerializesModels;
public SyncResult $result; public function __construct(public SyncResultCollection $results)
public function __construct(SyncResult $result)
{ {
$this->result = $result;
} }
} }

View file

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

View file

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

View file

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

View file

@ -10,12 +10,7 @@ class SongsBatchUnliked extends Event
{ {
use SerializesModels; use SerializesModels;
public Collection $songs; public function __construct(public Collection $songs, public User $user)
public User $user;
public function __construct(Collection $songs, 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 class StreamerFactory
{ {
private DirectStreamerInterface $directStreamer;
private TranscodingStreamerInterface $transcodingStreamer;
private ObjectStorageStreamerInterface $objectStorageStreamer;
private TranscodingService $transcodingService;
public function __construct( public function __construct(
DirectStreamerInterface $directStreamer, private DirectStreamerInterface $directStreamer,
TranscodingStreamerInterface $transcodingStreamer, private TranscodingStreamerInterface $transcodingStreamer,
ObjectStorageStreamerInterface $objectStorageStreamer, private ObjectStorageStreamerInterface $objectStorageStreamer,
TranscodingService $transcodingService private TranscodingService $transcodingService
) { ) {
$this->directStreamer = $directStreamer;
$this->transcodingStreamer = $transcodingStreamer;
$this->objectStorageStreamer = $objectStorageStreamer;
$this->transcodingService = $transcodingService;
} }
public function createStreamer( 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)); return $cdnUrl ? $cdnUrl . '/' . trim(ltrim($name, '/')) : trim(asset($name));
} }
/** function album_cover_path(?string $fileName): ?string
* A copy of Laravel Mix but catered to our directory structure.
*
* @throws InvalidArgumentException
*/
function asset_rev(string $file, ?string $manifestFile = null): string
{ {
static $manifest = null; return $fileName ? public_path(config('koel.album_cover_dir') . $fileName) : 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.");
} }
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); return $fileName ? static_url(config('koel.artist_image_dir') . $fileName) : null;
}
function artist_image_url(string $fileName): string
{
return static_url(config('koel.artist_image_dir') . $fileName);
} }
function koel_version(): string function koel_version(): string

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserLoginRequest; use App\Http\Requests\API\UserLoginRequest;
use App\Models\User; use App\Models\User;
use App\Repositories\UserRepository; use App\Repositories\UserRepository;
@ -15,23 +16,13 @@ class AuthController extends Controller
{ {
use ThrottlesLogins; use ThrottlesLogins;
private UserRepository $userRepository; /** @param User $user */
private HashManager $hash;
private TokenManager $tokenManager;
/** @var User */
private ?Authenticatable $currentUser;
public function __construct( public function __construct(
UserRepository $userRepository, private UserRepository $userRepository,
HashManager $hash, private HashManager $hash,
TokenManager $tokenManager, private TokenManager $tokenManager,
?Authenticatable $currentUser private ?Authenticatable $user
) { ) {
$this->userRepository = $userRepository;
$this->hash = $hash;
$this->tokenManager = $tokenManager;
$this->currentUser = $currentUser;
} }
public function login(UserLoginRequest $request) public function login(UserLoginRequest $request)
@ -50,7 +41,9 @@ class AuthController extends Controller
public function logout() public function logout()
{ {
$this->tokenManager->destroyTokens($this->currentUser); if ($this->user) {
$this->tokenManager->destroyTokens($this->user);
}
return response()->noContent(); 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; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Repositories\InteractionRepository; use App\Repositories\InteractionRepository;
use App\Repositories\PlaylistRepository; use App\Repositories\PlaylistRepository;
@ -18,41 +19,19 @@ class DataController extends Controller
{ {
private const RECENTLY_PLAYED_EXCERPT_COUNT = 7; private const RECENTLY_PLAYED_EXCERPT_COUNT = 7;
private LastfmService $lastfmService; /** @param User $currentUser */
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;
public function __construct( public function __construct(
LastfmService $lastfmService, private LastfmService $lastfmService,
YouTubeService $youTubeService, private YouTubeService $youTubeService,
ITunesService $iTunesService, private ITunesService $iTunesService,
MediaCacheService $mediaCacheService, private MediaCacheService $mediaCacheService,
SettingRepository $settingRepository, private SettingRepository $settingRepository,
PlaylistRepository $playlistRepository, private PlaylistRepository $playlistRepository,
InteractionRepository $interactionRepository, private InteractionRepository $interactionRepository,
UserRepository $userRepository, private UserRepository $userRepository,
ApplicationInformationService $applicationInformationService, private ApplicationInformationService $applicationInformationService,
?Authenticatable $currentUser 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() public function index()

View file

@ -2,20 +2,29 @@
namespace App\Http\Controllers\API\Interaction; namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\BatchInteractionRequest; use App\Http\Requests\API\BatchInteractionRequest;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class BatchLikeController extends Controller class BatchLikeController extends Controller
{ {
/** @param User $user */
public function __construct(private InteractionService $interactionService, protected ?Authenticatable $user)
{
}
public function store(BatchInteractionRequest $request) 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); return response()->json($interactions);
} }
public function destroy(BatchInteractionRequest $request) public function destroy(BatchInteractionRequest $request)
{ {
$this->interactionService->batchUnlike((array) $request->songs, $this->currentUser); $this->interactionService->batchUnlike((array) $request->songs, $this->user);
return response()->noContent(); 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; namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SongLikeRequest; use App\Http\Requests\API\SongLikeRequest;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class LikeController extends Controller class LikeController extends Controller
{ {
/** @param User $user */
public function __construct(private InteractionService $interactionService, private ?Authenticatable $user)
{
}
public function store(SongLikeRequest $request) 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; namespace App\Http\Controllers\API\Interaction;
use App\Events\SongStartedPlaying; use App\Events\SongStartedPlaying;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Interaction\StorePlayCountRequest; use App\Http\Requests\API\Interaction\StorePlayCountRequest;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlayCountController extends Controller class PlayCountController extends Controller
{ {
/** @param User $user */
public function __construct(private InteractionService $interactionService, private ?Authenticatable $user)
{
}
public function store(StorePlayCountRequest $request) 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)); event(new SongStartedPlaying($interaction->song, $interaction->user));
return response()->json($interaction); return response()->json($interaction);

View file

@ -2,26 +2,20 @@
namespace App\Http\Controllers\API\Interaction; namespace App\Http\Controllers\API\Interaction;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Repositories\InteractionRepository; use App\Repositories\InteractionRepository;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
class RecentlyPlayedController extends Controller class RecentlyPlayedController extends Controller
{ {
private InteractionRepository $interactionRepository; /** @param User $user */
public function __construct(private InteractionRepository $interactionRepository, private ?Authenticatable $user)
public function __construct( {
InteractionService $interactionService,
InteractionRepository $interactionRepository,
?Authenticatable $currentUser
) {
parent::__construct($interactionService, $currentUser);
$this->interactionRepository = $interactionRepository;
} }
public function index(?int $count = null) 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; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\LastfmSetSessionKeyRequest; use App\Http\Requests\API\LastfmSetSessionKeyRequest;
use App\Models\User; use App\Models\User;
use App\Services\LastfmService; use App\Services\LastfmService;
@ -9,20 +10,14 @@ use Illuminate\Contracts\Auth\Authenticatable;
class LastfmController extends Controller class LastfmController extends Controller
{ {
private LastfmService $lastfm; /** @param User $currentUser */
public function __construct(private LastfmService $lastfm, private ?Authenticatable $currentUser)
/** @var User */
private ?Authenticatable $currentUser;
public function __construct(LastfmService $lastfm, ?Authenticatable $currentUser)
{ {
$this->lastfm = $lastfm;
$this->currentUser = $currentUser;
} }
public function setSessionKey(LastfmSetSessionKeyRequest $request) public function setSessionKey(LastfmSetSessionKeyRequest $request)
{ {
$this->lastfm->setUserSessionKey($this->currentUser, trim($request->key)); $this->lastfm->setUserSessionKey($this->currentUser, $request->key);
return response()->noContent(); return response()->noContent();
} }

View file

@ -2,12 +2,18 @@
namespace App\Http\Controllers\API\MediaInformation; namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Album; use App\Models\Album;
use App\Services\MediaInformationService;
class AlbumController extends Controller class AlbumController extends Controller
{ {
public function __construct(private MediaInformationService $mediaInformationService)
{
}
public function show(Album $album) 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; namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Artist; use App\Models\Artist;
use App\Services\MediaInformationService;
class ArtistController extends Controller class ArtistController extends Controller
{ {
public function __construct(private MediaInformationService $mediaInformationService)
{
}
public function show(Artist $artist) 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; namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Song; use App\Models\Song;
use App\Services\MediaInformationService; use App\Services\MediaInformationService;
use App\Services\YouTubeService; use App\Services\YouTubeService;
class SongController extends Controller class SongController extends Controller
{ {
private YouTubeService $youTubeService; public function __construct(
private MediaInformationService $mediaInformationService,
public function __construct(MediaInformationService $mediaInformationService, YouTubeService $youTubeService) private YouTubeService $youTubeService
{ ) {
parent::__construct($mediaInformationService);
$this->youTubeService = $youTubeService;
} }
public function show(Song $song) public function show(Song $song)
{ {
return response()->json([ return response()->json([
'lyrics' => $song->lyrics, 'lyrics' => nl2br($song->lyrics), // backward compat
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album), 'album_info' => $this->mediaInformationService->getAlbumInformation($song->album)?->toArray() ?: [],
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist), 'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist)?->toArray() ?: [],
'youtube' => $this->youTubeService->searchVideosRelatedToSong($song), '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; namespace App\Http\Controllers\API\ObjectStorage\S3;
use App\Exceptions\SongPathNotFoundException; use App\Exceptions\SongPathNotFoundException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest; use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest;
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest; use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
use App\Services\S3Service; use App\Services\S3Service;
@ -10,23 +11,20 @@ use Illuminate\Http\Response;
class SongController extends Controller class SongController extends Controller
{ {
private S3Service $s3Service; public function __construct(private S3Service $s3Service)
public function __construct(S3Service $s3Service)
{ {
$this->s3Service = $s3Service;
} }
public function put(PutSongRequest $request) public function put(PutSongRequest $request)
{ {
$artist = array_get($request->tags, 'artist', ''); $artist = array_get($request->tags, 'artist', '');
$albumartist = trim(array_get($request->tags, 'albumartist', ''));
$song = $this->s3Service->createSongEntry( $song = $this->s3Service->createSongEntry(
$request->bucket, $request->bucket,
$request->key, $request->key,
$artist, $artist,
array_get($request->tags, 'album'), array_get($request->tags, 'album'),
(bool) $albumartist && $albumartist !== $artist, trim(array_get($request->tags, 'albumartist')),
array_get($request->tags, 'cover'), array_get($request->tags, 'cover'),
trim(array_get($request->tags, 'title', '')), trim(array_get($request->tags, 'title', '')),
(int) array_get($request->tags, 'duration', 0), (int) array_get($request->tags, 'duration', 0),
@ -41,7 +39,7 @@ class SongController extends Controller
{ {
try { try {
$this->s3Service->deleteSongEntry($request->bucket, $request->key); $this->s3Service->deleteSongEntry($request->bucket, $request->key);
} catch (SongPathNotFoundException $exception) { } catch (SongPathNotFoundException) {
abort(Response::HTTP_NOT_FOUND); abort(Response::HTTP_NOT_FOUND);
} }

View file

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

View file

@ -2,17 +2,22 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistSongUpdateRequest; use App\Http\Requests\API\PlaylistSongUpdateRequest;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\User;
use App\Services\PlaylistService;
use App\Services\SmartPlaylistService; use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlaylistSongController extends Controller class PlaylistSongController extends Controller
{ {
private SmartPlaylistService $smartPlaylistService; /** @param User $user */
public function __construct(
public function __construct(SmartPlaylistService $smartPlaylistService) private SmartPlaylistService $smartPlaylistService,
{ private PlaylistService $playlistService,
$this->smartPlaylistService = $smartPlaylistService; private Authenticatable $user
) {
} }
public function index(Playlist $playlist) public function index(Playlist $playlist)
@ -21,19 +26,20 @@ class PlaylistSongController extends Controller
return response()->json( return response()->json(
$playlist->is_smart $playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist)->pluck('id') ? $this->smartPlaylistService->getSongs($playlist, $this->user)->pluck('id')
: $playlist->songs->pluck('id') : $playlist->songs->pluck('id')
); );
} }
/** @deprecated */
public function update(PlaylistSongUpdateRequest $request, Playlist $playlist) public function update(PlaylistSongUpdateRequest $request, Playlist $playlist)
{ {
$this->authorize('owner', $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; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ProfileUpdateRequest; use App\Http\Requests\API\ProfileUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use App\Services\TokenManager; use App\Services\TokenManager;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Hashing\Hasher as Hash; use Illuminate\Contracts\Hashing\Hasher;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class ProfileController extends Controller class ProfileController extends Controller
{ {
private Hash $hash; /** @param User $user */
private TokenManager $tokenManager; public function __construct(
private Hasher $hash,
/** @var User */ private TokenManager $tokenManager,
private ?Authenticatable $currentUser; private ?Authenticatable $user
) {
public function __construct(Hash $hash, TokenManager $tokenManager, ?Authenticatable $currentUser)
{
$this->hash = $hash;
$this->tokenManager = $tokenManager;
$this->currentUser = $currentUser;
} }
public function show() public function show()
{ {
return response()->json($this->currentUser); return UserResource::make($this->user);
} }
public function update(ProfileUpdateRequest $request) public function update(ProfileUpdateRequest $request)
{ {
if (config('koel.misc.demo')) { if (config('koel.misc.demo')) {
return response()->json(); return response()->noContent();
} }
throw_unless( 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']) ValidationException::withMessages(['current_password' => 'Invalid current password'])
); );
@ -46,12 +43,14 @@ class ProfileController extends Controller
$data['password'] = $this->hash->make($request->new_password); $data['password'] = $this->hash->make($request->new_password);
} }
$this->currentUser->update($data); $this->user->update($data);
$responseData = $request->new_password $response = UserResource::make($this->user)->response();
? ['token' => $this->tokenManager->refreshToken($this->currentUser)->plainTextToken]
: [];
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; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ScrobbleStoreRequest; use App\Http\Requests\API\ScrobbleStoreRequest;
use App\Jobs\ScrobbleJob; use App\Jobs\ScrobbleJob;
use App\Models\Song; use App\Models\Song;
@ -10,12 +11,9 @@ use Illuminate\Contracts\Auth\Authenticatable;
class ScrobbleController extends Controller class ScrobbleController extends Controller
{ {
/** @var User */ /** @param User $currentUser */
private ?Authenticatable $currentUser; public function __construct(private ?Authenticatable $currentUser)
public function __construct(?Authenticatable $currentUser)
{ {
$this->currentUser = $currentUser;
} }
public function store(ScrobbleStoreRequest $request, Song $song) public function store(ScrobbleStoreRequest $request, Song $song)

View file

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

View file

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

View file

@ -2,26 +2,23 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SettingRequest; use App\Http\Requests\API\SettingRequest;
use App\Models\Setting; use App\Models\Setting;
use App\Models\User;
use App\Services\MediaSyncService; use App\Services\MediaSyncService;
class SettingController extends Controller class SettingController extends Controller
{ {
private MediaSyncService $mediaSyncService; public function __construct(private MediaSyncService $mediaSyncService)
public function __construct(MediaSyncService $mediaSyncService)
{ {
$this->mediaSyncService = $mediaSyncService;
} }
// @TODO: This should be a PUT request public function update(SettingRequest $request)
public function store(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, Setting::set('media_path', rtrim(trim($request->media_path), '/'));
// but let's just do this async now.
$this->mediaSyncService->sync(); $this->mediaSyncService->sync();
return response()->noContent(); return response()->noContent();

View file

@ -2,30 +2,48 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\SongUpdateRequest; 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\AlbumRepository;
use App\Repositories\ArtistRepository; 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 class SongController extends Controller
{ {
private ArtistRepository $artistRepository; /** @param User $user */
private AlbumRepository $albumRepository; public function __construct(
private SongService $songService,
public function __construct(ArtistRepository $artistRepository, AlbumRepository $albumRepository) private AlbumRepository $albumRepository,
{ private ArtistRepository $artistRepository,
$this->artistRepository = $artistRepository; private LibraryManager $libraryManager,
$this->albumRepository = $albumRepository; private ?Authenticatable $user
) {
} }
public function update(SongUpdateRequest $request) 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([ return response()->json([
'artists' => $this->artistRepository->getByIds($updatedSongs->pluck('artist_id')->all()), 'songs' => SongResource::collection($updatedSongs),
'albums' => $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->all()), 'albums' => AlbumResource::collection($albums),
'songs' => $updatedSongs, 'artists' => ArtistResource::collection($artists),
'removed' => $this->libraryManager->prune(),
]); ]);
} }
} }

View file

@ -2,35 +2,42 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Events\MediaCacheObsolete;
use App\Exceptions\MediaPathNotSetException; use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException; use App\Exceptions\SongUploadFailedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UploadRequest; 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 App\Services\UploadService;
use Illuminate\Http\JsonResponse; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response; use Illuminate\Http\Response;
class UploadController extends Controller 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 { 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) { } catch (MediaPathNotSetException $e) {
abort(Response::HTTP_FORBIDDEN, $e->getMessage()); abort(Response::HTTP_FORBIDDEN, $e->getMessage());
} catch (SongUploadFailedException $e) { } catch (SongUploadFailedException $e) {
abort(Response::HTTP_BAD_REQUEST, $e->getMessage()); 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; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\UserStoreRequest; use App\Http\Requests\API\UserStoreRequest;
use App\Http\Requests\API\UserUpdateRequest; use App\Http\Requests\API\UserUpdateRequest;
use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use Illuminate\Contracts\Hashing\Hasher as Hash; use App\Repositories\UserRepository;
use App\Services\UserService;
class UserController extends Controller class UserController extends Controller
{ {
private Hash $hash; public function __construct(private UserRepository $userRepository, private UserService $userService)
public function __construct(Hash $hash)
{ {
$this->hash = $hash; }
public function index()
{
$this->authorize('admin', User::class);
return UserResource::collection($this->userRepository->getAll());
} }
public function store(UserStoreRequest $request) public function store(UserStoreRequest $request)
{ {
return response()->json(User::create([ $this->authorize('admin', User::class);
'name' => $request->name,
'email' => $request->email, return UserResource::make($this->userService->createUser(
'password' => $this->hash->make($request->password), $request->name,
'is_admin' => $request->is_admin, $request->email,
])); $request->password,
$request->get('is_admin') ?: false
));
} }
public function update(UserUpdateRequest $request, User $user) public function update(UserUpdateRequest $request, User $user)
{ {
$data = $request->only('name', 'email', 'is_admin'); $this->authorize('admin', User::class);
if ($request->password) { return UserResource::make($this->userService->updateUser(
$data['password'] = $this->hash->make($request->password); $user,
} $request->name,
$request->email,
$user->update($data); $request->password,
$request->get('is_admin') ?: false
return response()->json($user); ));
} }
public function destroy(User $user) public function destroy(User $user)
{ {
$this->authorize('destroy', $user); $this->authorize('destroy', $user);
$this->userService->deleteUser($user);
$user->delete();
return response()->noContent(); return response()->noContent();
} }

View file

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

View file

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

View file

@ -2,10 +2,16 @@
namespace App\Http\Controllers\Download; namespace App\Http\Controllers\Download;
use App\Http\Controllers\Controller;
use App\Models\Artist; use App\Models\Artist;
use App\Services\DownloadService;
class ArtistController extends Controller class ArtistController extends Controller
{ {
public function __construct(private DownloadService $downloadService)
{
}
public function show(Artist $artist) public function show(Artist $artist)
{ {
return response()->download($this->downloadService->from($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; 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\Repositories\InteractionRepository;
use App\Services\DownloadService; use App\Services\DownloadService;
use Illuminate\Contracts\Auth\Authenticatable;
class FavoritesController extends Controller class FavoritesController extends Controller
{ {
private InteractionRepository $interactionRepository; /** @param User $user */
public function __construct(
public function __construct(DownloadService $downloadService, InteractionRepository $interactionRepository) private DownloadService $downloadService,
{ private InteractionRepository $interactionRepository,
parent::__construct($downloadService); private ?Authenticatable $user
) {
$this->interactionRepository = $interactionRepository;
} }
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)); return response()->download($this->downloadService->from($songs));
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -8,17 +8,14 @@ use App\Models\Song;
class PlayController extends Controller class PlayController extends Controller
{ {
private StreamerFactory $streamerFactory; public function __construct(private StreamerFactory $streamerFactory)
public function __construct(StreamerFactory $streamerFactory)
{ {
$this->streamerFactory = $streamerFactory;
} }
public function show(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null) public function show(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null)
{ {
return $this->streamerFactory return $this->streamerFactory
->createStreamer($song, $transcode, $bitRate, floatval($request->time)) ->createStreamer($song, $transcode, $bitRate, (float) $request->time)
->stream(); ->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\Authenticate;
use App\Http\Middleware\ForceHttps; use App\Http\Middleware\ForceHttps;
use App\Http\Middleware\ObjectStorageAuthenticate; use App\Http\Middleware\ObjectStorageAuthenticate;
use App\Http\Middleware\ThrottleRequests;
use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrimStrings;
use Illuminate\Auth\Middleware\Authorize; use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode; use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize; use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Routing\Middleware\SubstituteBindings; use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
class Kernel extends HttpKernel class Kernel extends HttpKernel
{ {
@ -25,7 +24,6 @@ class Kernel extends HttpKernel
CheckForMaintenanceMode::class, CheckForMaintenanceMode::class,
ValidatePostSize::class, ValidatePostSize::class,
TrimStrings::class, TrimStrings::class,
ConvertEmptyStringsToNull::class,
ForceHttps::class, ForceHttps::class,
]; ];

View file

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

View file

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

View file

@ -4,6 +4,7 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Response;
/** /**
* Authenticate requests from Object Storage services (like S3). * Authenticate requests from Object Storage services (like S3).
@ -13,9 +14,7 @@ class ObjectStorageAuthenticate
{ {
public function handle(Request $request, Closure $next) // @phpcs:ignore public function handle(Request $request, Closure $next) // @phpcs:ignore
{ {
if ($request->appKey !== config('app.key')) { abort_unless($request->get('appKey') === config('app.key'), Response::HTTP_UNAUTHORIZED);
return response('Unauthorized.', 401);
}
return $next($request); 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; namespace App\Http\Requests\API;
/** @property string $cover */ /** @property string $cover */
class AlbumCoverUpdateRequest extends AbstractMediaImageUpdateRequest class AlbumCoverUpdateRequest extends MediaImageUpdateRequest
{ {
protected function getImageFieldName(): string protected function getImageFieldName(): string
{ {

View file

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

View file

@ -4,6 +4,6 @@ namespace App\Http\Requests\API\Interaction;
use App\Http\Requests\API\Request as BaseRequest; 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; use App\Rules\ImageData;
abstract class AbstractMediaImageUpdateRequest extends Request abstract class MediaImageUpdateRequest extends Request
{ {
public function authorize(): bool public function authorize(): bool
{ {

View file

@ -2,8 +2,8 @@
namespace App\Http\Requests\API; 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
{ {
} }

View file

@ -3,15 +3,10 @@
namespace App\Http\Requests\API; namespace App\Http\Requests\API;
/** /**
* @property string $media_path * @property-read string $media_path
*/ */
class SettingRequest extends Request class SettingRequest extends Request
{ {
public function authorize(): bool
{
return auth()->user()->is_admin;
}
/** @return array<mixed> */ /** @return array<mixed> */
public function rules(): array public function rules(): array
{ {

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