diff --git a/.editorconfig b/.editorconfig index a20e82ce..f1938bbd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,8 @@ -[*.{js,css,sass,scss,json,coffee,vue,html}] +[*] +insert_final_newline = true +trim_trailing_whitespace = true indent_style = space indent_size = 2 -[*.php] +[{*.php, *.xml, *.xml.dist}] indent_size = 4 diff --git a/.env.example b/.env.example index 4504c978..4a776e2e 100644 --- a/.env.example +++ b/.env.example @@ -6,7 +6,7 @@ APP_NAME=Koel # pgsql (PostgreSQL) # sqlsrv (Microsoft SQL Server) # sqlite-persistent (Local sqlite file) -# IMPORTANT: This value must present for artisan koel:init command to work. +# IMPORTANT: This value must present for `artisan koel:init` command to work. DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 @@ -46,16 +46,42 @@ MEMORY_LIMIT= # Can be either 'php' (default), 'x-sendfile', or 'x-accel-redirect' # See https://docs.koel.dev/#streaming-music for more information. # Note: This setting doesn't have effect if the media needs transcoding (e.g. FLAC). +# ################################################## +# IMPORTANT: It's HIGHLY recommended to use 'x-sendfile' or 'x-accel-redirect' if +# you plan to use the Koel mobile apps. +# ################################################## STREAMING_METHOD=php -# If you want Koel to integrate with Last.fm, set the API details here. -# See https://docs.koel.dev/3rd-party.html#last-fm for more information +# Full text search driver. +# Koel supports all drivers supported by Laravel (see https://laravel.com/docs/9.x/scout). +# Available drivers: 'tntsearch' (default), 'database', 'algolia' or 'meilisearch'. +# For Algolia or MeiliSearch, you need to provide the corresponding credentials. +SCOUT_DRIVER=tntsearch +ALGOLIA_APP_ID= +ALGOLIA_SECRET= +MEILISEARCH_HOST= +MEILISEARCH_KEY= + + +# Last.fm API can be used to fetch artist and album information, as well as to +# allow users to connect to their Last.fm account and scrobble. +# To integrate Koel with Last.fm, create an API account at +# https://www.last.fm/api/account/create and set the credentials here. +# Consult Koel's doc for more information. LASTFM_API_KEY= LASTFM_API_SECRET= -# If you want to use Amazon S3 with Koel, fill the info here and follow the +# Spotify API can be used to fetch artist and album images. +# To integrate Koel with Spotify, create a Spotify application at +# https://developer.spotify.com/dashboard/applications and set the credentials here. +# Consult Koel's doc for more information. +SPOTIFY_CLIENT_ID= +SPOTIFY_CLIENT_SECRET= + + +# To use Amazon S3 with Koel, fill the info here and follow the # installation guide at https://docs.koel.dev/aws-s3.html AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= @@ -63,7 +89,7 @@ AWS_REGION= AWS_ENDPOINT= -# If you want Koel to integrate with YouTube, set the API key here. +# To integrate Koel with YouTube, set the API key here. # See https://docs.koel.dev/3rd-party.html#youtube for more information. YOUTUBE_API_KEY= @@ -74,8 +100,7 @@ YOUTUBE_API_KEY= CDN_URL= -# If you want to transcode FLAC to MP3 and stream it on the fly, make sure the -# following settings are sane. +# To transcode FLAC to MP3 and stream it on the fly, make sure the following settings are sane. # The full path of ffmpeg binary. FFMPEG_PATH=/usr/local/bin/ffmpeg @@ -90,12 +115,6 @@ OUTPUT_BIT_RATE=128 # environment, such a download will (silently) fail. ALLOW_DOWNLOAD=true -# If this is set to true, the query to get artist, album, and song information will be cached. -# This can give a boost to Koel's boot time, especially if your library is huge. -# However, the cache deserialization process can be memory sensitive, so if you encounter -# errors, try setting this to false. -CACHE_MEDIA=true - # Koel attempts to detect if your website use HTTPS and generates secure URLs accordingly. # If this attempts for any reason, you can force it by setting this value to true. @@ -128,4 +147,3 @@ MAIL_PORT=2525 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null - diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index e184eaef..00000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -libs -tests diff --git a/.eslintrc b/.eslintrc index c13acf37..7a09041a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,19 +1,37 @@ { - "root": true, - "parser": "@typescript-eslint/parser", + "parser": "vue-eslint-parser", + "env": { + "browser": true + }, + "parserOptions": { + "parser": "@typescript-eslint/parser", + "ecmaVersion": 2020 + }, + "extends": [ + "plugin:vue/vue3-recommended" + ], + "ignorePatterns": [ + "cypress/fixtures", + "cypress/screenshots", + "resources/assets/js/tests/__coverage__" + ], "plugins": [ "@typescript-eslint" ], - "extends": [ - "eslint:recommended", - "plugin:@typescript-eslint/eslint-recommended", - "plugin:@typescript-eslint/recommended" - ], + "globals": { + "KOEL_ENV": "readonly", + "FileReader": "readonly", + "defineProps": "readonly", + "defineEmits": "readonly", + "defineExpose": "readonly", + "withDefaults": "readonly" + }, "rules": { "camelcase": 0, "no-multi-str": 0, "no-empty": 0, "quotes": 0, + "no-use-before-define": 0, "@typescript-eslint/no-var-requires": 0, "@typescript-eslint/camelcase": 0, "@typescript-eslint/member-delimiter-style": 0, @@ -21,6 +39,13 @@ "@typescript-eslint/no-inferrable-types": 0, "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/no-non-null-assertion": 0, - "@typescript-eslint/ban-ts-ignore": 0 + "@typescript-eslint/ban-ts-comment": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/explicit-module-boundary-types": 0, + "standard/no-callback-literal": 0, + "vue/valid-v-on": 0, + "vue/no-side-effects-in-computed-properties": 0, + "vue/max-attributes-per-line": 0, + "vue/no-v-html": 0 } } diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9653ebfe..7d7f21be 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -1,8 +1,10 @@ -name: e2e +name: End to End Tests on: push: branches: - master + # @fixme Tmp.disable until ready + # - next pull_request: workflow_dispatch: jobs: @@ -19,11 +21,11 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: 7.4 + php-version: 8.1 tools: composer:v2 extensions: pdo_sqlite - name: Install PHP dependencies - uses: ramsey/composer-install@v1 + uses: ramsey/composer-install@v2 with: composer-options: --prefer-dist - name: Generate app key @@ -32,12 +34,6 @@ jobs: uses: actions/setup-node@v2 with: node-version: '14' - - name: Install JavaScript dependencies - run: | - cd ./resources/assets && yarn install --no-progress - cd ../.. && yarn install --no-progress - - name: Lint - run: yarn lint - name: Run E2E tests uses: cypress-io/github-action@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8719705..af1e1ded 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,7 +1,7 @@ on: push: tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 name: Upload Release Assets @@ -14,22 +14,20 @@ jobs: id: get_version run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} - uses: actions/checkout@v2 - with: - submodules: recursive - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: 7.4 + php-version: 8.1 tools: composer:v2 - extensions: pdo_sqlite + extensions: pdo_sqlite, zip, gd - name: Install PHP dependencies - uses: ramsey/composer-install@v1 + uses: ramsey/composer-install@v2 with: composer-options: --prefer-dist - name: Set up Node uses: actions/setup-node@v2 with: - node-version: '14' + node-version: 16 - name: Build project run: | sudo apt install pngquant zip unzip @@ -39,10 +37,10 @@ jobs: run: | sed -i 's/DB_CONNECTION=sqlite/DB_CONNECTION=sqlite-persistent/' .env sed -i 's/DB_DATABASE=koel/DB_DATABASE=koel.db/' .env - rm -rf .git ./node_modules ./resources/assets/.git ./resources/assets/node_modules ./storage/search-indexes/*.index ./koel.db ./.env + rm -rf .git ./node_modules ./storage/search-indexes/*.index ./koel.db ./.env cd ../ zip -r /tmp/koel-${{ steps.get_version.outputs.VERSION }}.zip koel/ - tar -zcvf /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz koel/ + tar -zcf /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz koel/ - name: Create release id: create_release uses: actions/create-release@v1 @@ -59,7 +57,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing its ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: /tmp/koel-${{ steps.get_version.outputs.VERSION }}.zip asset_name: koel-${{ steps.get_version.outputs.VERSION }}.zip asset_content_type: application/zip @@ -69,7 +67,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.create_release.outputs.upload_url }} # This pulls from the CREATE RELEASE step above, referencing its ID to get its outputs object, which include a `upload_url`. See this blog post for more info: https://jasonet.co/posts/new-features-of-github-actions/#passing-data-to-future-steps + upload_url: ${{ steps.create_release.outputs.upload_url }} asset_path: /tmp/koel-${{ steps.get_version.outputs.VERSION }}.tar.gz asset_name: koel-${{ steps.get_version.outputs.VERSION }}.tar.gz asset_content_type: application/gzip diff --git a/.github/workflows/unit.yml b/.github/workflows/unit-backend.yml similarity index 63% rename from .github/workflows/unit.yml rename to .github/workflows/unit-backend.yml index c1ccd746..a4b023d9 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit-backend.yml @@ -1,16 +1,32 @@ -name: unit +name: Backend Unit Tests on: + pull_request: + branches: + - master + - next + paths: + - '!resources/assets/**' + - .github/workflows/unit-backend.yml push: branches: - master - pull_request: + - next + paths: + - '!resources/assets/**' + - .github/workflows/unit-backend.yml workflow_dispatch: + branches: + - master + - next + paths: + - '!resources/assets/**' + - .github/workflows/unit-backend.yml jobs: test: runs-on: ubuntu-latest strategy: matrix: - php-version: [ 7.4, 8.0 ] + php-version: [ 8.0, 8.1 ] fail-fast: false steps: - uses: actions/checkout@v1 @@ -33,6 +49,12 @@ jobs: run: composer analyze -- --no-progress - name: Run tests run: composer coverage + - name: Upload logs if broken + uses: actions/upload-artifact@v1 + if: failure() + with: + name: logs + path: storage/logs - name: Upload coverage uses: codecov/codecov-action@v1 with: diff --git a/.github/workflows/unit-frontend.yml b/.github/workflows/unit-frontend.yml new file mode 100644 index 00000000..e1b47368 --- /dev/null +++ b/.github/workflows/unit-frontend.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index ec50a4aa..a3b69e77 100644 --- a/.gitignore +++ b/.gitignore @@ -74,8 +74,9 @@ Temporary Items *~ # Cypress -cypress/screenshots -cypress/videos +cypress/screenshots/ +cypress/videos/ +cypress/downloads/ /log coverage.xml diff --git a/.gitmodules b/.gitmodules index 43269d20..e69de29b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "resources/assets"] - path = resources/assets - url = https://github.com/koel/core.git diff --git a/api-docs/api.yaml b/api-docs/api.yaml index 34c8fee1..a0960b8e 100644 --- a/api-docs/api.yaml +++ b/api-docs/api.yaml @@ -832,7 +832,7 @@ paths: security: - Bearer Token: [] /api/settings: - post: + put: summary: Save the application settings tags: - settings diff --git a/app/Console/Commands/Admin/ChangePasswordCommand.php b/app/Console/Commands/Admin/ChangePasswordCommand.php index 2a023af6..d1cfc072 100644 --- a/app/Console/Commands/Admin/ChangePasswordCommand.php +++ b/app/Console/Commands/Admin/ChangePasswordCommand.php @@ -11,20 +11,16 @@ class ChangePasswordCommand extends Command { use AskForPassword; - protected $signature = "koel:admin:change-password + protected $signature = "koel:admin:change-password {email? : The user's email. If empty, will get the default admin user.}"; protected $description = "Change a user's password"; - private Hash $hash; - - public function __construct(Hash $hash) + public function __construct(private Hash $hash) { parent::__construct(); - - $this->hash = $hash; } - public function handle(): void + public function handle(): int { $email = $this->argument('email'); @@ -34,7 +30,7 @@ class ChangePasswordCommand extends Command if (!$user) { $this->error('The user account cannot be found.'); - return; + return self::FAILURE; } $this->comment("Changing the user's password (ID: $user->id, email: $user->email)"); @@ -43,5 +39,7 @@ class ChangePasswordCommand extends Command $user->save(); $this->comment('Alrighty, the new password has been saved. Enjoy! 👌'); + + return self::SUCCESS; } } diff --git a/app/Console/Commands/ImportSearchableEntitiesCommand.php b/app/Console/Commands/ImportSearchableEntitiesCommand.php index 22bfeb53..b6a24694 100644 --- a/app/Console/Commands/ImportSearchableEntitiesCommand.php +++ b/app/Console/Commands/ImportSearchableEntitiesCommand.php @@ -30,6 +30,6 @@ class ImportSearchableEntitiesCommand extends Command $this->call('scout:import', ['model' => $entity]); } - return 0; + return self::SUCCESS; } } diff --git a/app/Console/Commands/InitCommand.php b/app/Console/Commands/InitCommand.php index 27b348e8..cf57a8a9 100644 --- a/app/Console/Commands/InitCommand.php +++ b/app/Console/Commands/InitCommand.php @@ -6,13 +6,15 @@ use App\Console\Commands\Traits\AskForPassword; use App\Exceptions\InstallationFailedException; use App\Models\Setting; use App\Models\User; -use App\Repositories\SettingRepository; use App\Services\MediaCacheService; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel as Artisan; use Illuminate\Contracts\Hashing\Hasher as Hash; use Illuminate\Database\DatabaseManager as DB; +use Illuminate\Encryption\Encrypter; +use Illuminate\Support\Str; use Jackiedo\DotenvEditor\DotenvEditor; +use Psr\Log\LoggerInterface; use Throwable; class InitCommand extends Command @@ -22,48 +24,40 @@ class InitCommand extends Command private const DEFAULT_ADMIN_NAME = 'Koel'; private const DEFAULT_ADMIN_EMAIL = 'admin@koel.dev'; private const DEFAULT_ADMIN_PASSWORD = 'KoelIsCool'; - private const NON_INTERACTION_MAX_ATTEMPT_COUNT = 10; + private const NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT = 10; protected $signature = 'koel:init {--no-assets}'; protected $description = 'Install or upgrade Koel'; - private MediaCacheService $mediaCacheService; - private Artisan $artisan; - private DotenvEditor $dotenvEditor; - private Hash $hash; - private DB $db; - private SettingRepository $settingRepository; private bool $adminSeeded = false; public function __construct( - MediaCacheService $mediaCacheService, - SettingRepository $settingRepository, - Artisan $artisan, - Hash $hash, - DotenvEditor $dotenvEditor, - DB $db + private MediaCacheService $mediaCacheService, + private Artisan $artisan, + private Hash $hash, + private DotenvEditor $dotenvEditor, + private DB $db, + private LoggerInterface $logger ) { parent::__construct(); - - $this->mediaCacheService = $mediaCacheService; - $this->artisan = $artisan; - $this->dotenvEditor = $dotenvEditor; - $this->hash = $hash; - $this->db = $db; - $this->settingRepository = $settingRepository; } - public function handle(): void + public function handle(): int { - $this->comment('Attempting to install or upgrade Koel.'); - $this->comment('Remember, you can always install/upgrade manually following the guide here:'); - $this->info('📙 ' . config('koel.misc.docs_url') . PHP_EOL); + $this->alert('KOEL INSTALLATION WIZARD'); + $this->info( + 'As a reminder, you can always install/upgrade manually following the guide at ' + . config('koel.misc.docs_url') + . PHP_EOL + ); if ($this->inNoInteractionMode()) { - $this->info('Running in no-interaction mode'); + $this->components->info('Running in no-interaction mode'); } try { + $this->clearCaches(); + $this->loadEnvFile(); $this->maybeGenerateAppKey(); $this->maybeSetUpDatabase(); $this->migrateDatabase(); @@ -71,32 +65,79 @@ class InitCommand extends Command $this->maybeSetMediaPath(); $this->maybeCompileFrontEndAssets(); } catch (Throwable $e) { - $this->error("Oops! Koel installation or upgrade didn't finish successfully."); - $this->error('Please try again, or visit ' . config('koel.misc.docs_url') . ' for manual installation.'); - $this->error('😥 Sorry for this. You deserve better.'); + $this->logger->error($e); - return; + $this->components->error("Oops! Koel installation or upgrade didn't finish successfully."); + $this->components->error('Please check the error log at storage/logs/laravel.log and try again.'); + $this->components->error('You can also visit ' . config('koel.misc.docs_url') . ' for other options.'); + $this->components->error('😥 Sorry for this. You deserve better.'); + + return self::FAILURE; } - $this->comment(PHP_EOL . '🎆 Success! Koel can now be run from localhost with `php artisan serve`.'); + $this->newLine(); + $this->output->success('All done!'); + $this->info('Koel can now be run from localhost with `php artisan serve`.'); if ($this->adminSeeded) { - $this->comment( + $this->info( sprintf('Log in with email %s and password %s', self::DEFAULT_ADMIN_EMAIL, self::DEFAULT_ADMIN_PASSWORD) ); } if (Setting::get('media_path')) { - $this->comment('You can also scan for media with `php artisan koel:sync`.'); + $this->info('You can also scan for media now with `php artisan koel:sync`.'); } - $this->comment('Again, visit 📙 ' . config('koel.misc.docs_url') . ' for the official documentation.'); - $this->comment( + $this->info('Again, visit 📙 ' . config('koel.misc.docs_url') . ' for more tips and tweaks.'); + + $this->info( "Feeling generous and want to support Koel's development? Check out " . config('koel.misc.sponsor_github_url') . ' 🤗' ); - $this->comment('Thanks for using Koel. You rock! 🤘'); + + $this->info('Thanks for using Koel. You rock! 🤘'); + + return self::SUCCESS; + } + + private function clearCaches(): void + { + $this->components->task('Clearing caches', function (): void { + $this->artisan->call('config:clear'); + $this->artisan->call('cache:clear'); + }); + } + + private function loadEnvFile(): void + { + if (!file_exists(base_path('.env'))) { + $this->components->task('Copying .env file', static function (): void { + copy(base_path('.env.example'), base_path('.env')); + }); + } else { + $this->components->info('.env file exists -- skipping'); + } + + $this->dotenvEditor->load(base_path('.env')); + } + + private function maybeGenerateAppKey(): void + { + $key = $this->laravel['config']['app.key']; + + $this->components->task($key ? 'Retrieving app key' : 'Generating app key', function () use (&$key): void { + if (!$key) { + // Generate the key manually to prevent some clashes with `php artisan key:generate` + $key = $this->generateRandomKey(); + $this->dotenvEditor->setKey('APP_KEY', $key); + $this->laravel['config']['app.key'] = $key; + } + }); + + $this->newLine(); + $this->components->info('Using app key: ' . Str::limit($key, 16)); } /** @@ -105,10 +146,8 @@ class InitCommand extends Command private function setUpDatabase(): void { $config = [ - 'DB_CONNECTION' => '', 'DB_HOST' => '', 'DB_PORT' => '', - 'DB_DATABASE' => '', 'DB_USERNAME' => '', 'DB_PASSWORD' => '', ]; @@ -163,16 +202,83 @@ class InitCommand extends Command private function setUpAdminAccount(): void { - $this->info("Creating default admin account"); + $this->components->task('Creating default admin account', function (): void { + User::create([ + 'name' => self::DEFAULT_ADMIN_NAME, + 'email' => self::DEFAULT_ADMIN_EMAIL, + 'password' => $this->hash->make(self::DEFAULT_ADMIN_PASSWORD), + 'is_admin' => true, + ]); - User::create([ - 'name' => self::DEFAULT_ADMIN_NAME, - 'email' => self::DEFAULT_ADMIN_EMAIL, - 'password' => $this->hash->make(self::DEFAULT_ADMIN_PASSWORD), - 'is_admin' => true, - ]); + $this->adminSeeded = true; + }); + } - $this->adminSeeded = true; + private function maybeSeedDatabase(): void + { + if (!User::count()) { + $this->setUpAdminAccount(); + + $this->components->task('Seeding data', function (): void { + $this->artisan->call('db:seed', ['--force' => true]); + }); + } else { + $this->newLine(); + $this->components->info('Data already seeded -- skipping'); + } + } + + private function maybeSetUpDatabase(): void + { + $attempt = 0; + + while (true) { + // In non-interactive mode, we must not endlessly attempt to connect. + // Doing so will just end up with a huge amount of "failed to connect" logs. + // We do retry a little, though, just in case there's some kind of temporary failure. + if ($this->inNoInteractionMode() && $attempt >= self::NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT) { + $this->components->error('Maximum database connection attempts reached. Giving up.'); + break; + } + + $attempt++; + + try { + // Make sure the config cache is cleared before another attempt. + $this->artisan->call('config:clear'); + $this->db->reconnect()->getPdo(); + + break; + } catch (Throwable $e) { + $this->logger->error($e); + + // We only try to update credentials if running in interactive mode. + // Otherwise, we require admin intervention to fix them. + // This avoids inadvertently wiping credentials if there's a connection failure. + if ($this->inNoInteractionMode()) { + $warning = sprintf( + "Cannot connect to the database. Attempt: %d/%d", + $attempt, + self::NON_INTERACTION_MAX_DATABASE_ATTEMPT_COUNT + ); + + $this->components->warn($warning); + } else { + $this->components->warn("Cannot connect to the database. Let's set it up."); + $this->setUpDatabase(); + } + } + } + } + + private function migrateDatabase(): void + { + $this->components->task('Migrating database', function (): void { + $this->artisan->call('migrate', ['--force' => true]); + }); + + // Clear the media cache, just in case we did any media-related migration + $this->mediaCacheService->clear(); } private function maybeSetMediaPath(): void @@ -187,6 +293,7 @@ class InitCommand extends Command return; } + $this->newLine(); $this->info('The absolute path to your media directory. If this is skipped (left blank) now, you can set it later via the web interface.'); // @phpcs-ignore-line while (true) { @@ -196,103 +303,23 @@ class InitCommand extends Command return; } - if ($this->isValidMediaPath($path)) { + if (self::isValidMediaPath($path)) { Setting::set('media_path', $path); return; } - $this->error('The path does not exist or not readable. Try again.'); + $this->components->error('The path does not exist or not readable. Try again?'); } } - private function maybeGenerateAppKey(): void - { - if (!config('app.key')) { - $this->info('Generating app key'); - $this->artisan->call('key:generate'); - } else { - $this->comment('App key exists -- skipping'); - } - } - - private function maybeSeedDatabase(): void - { - if (!User::count()) { - $this->setUpAdminAccount(); - $this->info('Seeding initial data'); - $this->artisan->call('db:seed', ['--force' => true]); - } else { - $this->comment('Data seeded -- skipping'); - } - } - - private function maybeSetUpDatabase(): void - { - $attemptCount = 0; - - while (true) { - // In non-interactive mode, we must not endlessly attempt to connect. - // Doing so will just end up with a huge amount of "failed to connect" logs. - // We do retry a little, though, just in case there's some kind of temporary failure. - if ($this->inNoInteractionMode() && $attemptCount >= self::NON_INTERACTION_MAX_ATTEMPT_COUNT) { - $this->warn("Maximum database connection attempts reached. Giving up."); - break; - } - - $attemptCount++; - - try { - // Make sure the config cache is cleared before another attempt. - $this->artisan->call('config:clear'); - $this->db->reconnect()->getPdo(); - - break; - } catch (Throwable $e) { - $this->error($e->getMessage()); - - // We only try to update credentials if running in interactive mode. - // Otherwise, we require admin intervention to fix them. - // This avoids inadvertently wiping credentials if there's a connection failure. - if ($this->inNoInteractionMode()) { - $warning = sprintf( - "%sKoel cannot connect to the database. Attempt: %d/%d", - PHP_EOL, - $attemptCount, - self::NON_INTERACTION_MAX_ATTEMPT_COUNT - ); - $this->warn($warning); - } else { - $this->warn(sprintf("%sKoel cannot connect to the database. Let's set it up.", PHP_EOL)); - $this->setUpDatabase(); - } - } - } - } - - private function migrateDatabase(): void - { - $this->info('Migrating database'); - $this->artisan->call('migrate', ['--force' => true]); - - // Clear the media cache, just in case we did any media-related migration - $this->mediaCacheService->clear(); - } - private function maybeCompileFrontEndAssets(): void { if ($this->inNoAssetsMode()) { return; } - $this->info('Now to front-end stuff'); - - // We need to run several yarn commands: - // - The first to install node_modules in the resources/assets submodule - // - The second and third for the root folder, to build Koel's front-end assets with Mix. - - chdir('./resources/assets'); - $this->info('├── Installing Node modules in resources/assets directory'); + $this->components->info('Now to front-end stuff'); $runOkOrThrow = static function (string $command): void { passthru($command, $status); @@ -300,31 +327,35 @@ class InitCommand extends Command }; $runOkOrThrow('yarn install --colors'); - - chdir('../..'); - $this->info('└── Compiling assets'); - - $runOkOrThrow('yarn install --colors'); - $runOkOrThrow('yarn build --colors'); - } - - private function isValidMediaPath(string $path): bool - { - return is_dir($path) && is_readable($path); + $this->components->info('Compiling assets'); + $runOkOrThrow('yarn build'); } private function setMediaPathFromEnvFile(): void { - with(config('koel.media_path'), function (?string $path): void { - if (!$path) { - return; - } + $path = config('koel.media_path'); - if ($this->isValidMediaPath($path)) { - Setting::set('media_path', $path); - } else { - $this->warn(sprintf('The path %s does not exist or not readable. Skipping.', $path)); - } - }); + if (!$path) { + return; + } + + if (self::isValidMediaPath($path)) { + Setting::set('media_path', $path); + } else { + $this->components->warn(sprintf('The path %s does not exist or not readable. Skipping.', $path)); + } + } + + private static function isValidMediaPath(string $path): bool + { + return is_dir($path) && is_readable($path); + } + + /** + * Generate a random key for the application. + */ + private function generateRandomKey(): string + { + return 'base64:' . base64_encode(Encrypter::generateKey($this->laravel['config']['app.cipher'])); } } diff --git a/app/Console/Commands/PruneLibraryCommand.php b/app/Console/Commands/PruneLibraryCommand.php index 86fcf66d..bf5f0488 100644 --- a/app/Console/Commands/PruneLibraryCommand.php +++ b/app/Console/Commands/PruneLibraryCommand.php @@ -2,7 +2,7 @@ namespace App\Console\Commands; -use App\Events\LibraryChanged; +use App\Services\LibraryManager; use Illuminate\Console\Command; class PruneLibraryCommand extends Command @@ -10,9 +10,16 @@ class PruneLibraryCommand extends Command protected $signature = 'koel:prune'; protected $description = 'Remove empty artists and albums'; - public function handle(): void + public function __construct(private LibraryManager $libraryManager) { - event(new LibraryChanged()); + parent::__construct(); + } + + public function handle(): int + { + $this->libraryManager->prune(); $this->info('Empty artists and albums removed.'); + + return self::SUCCESS; } } diff --git a/app/Console/Commands/SyncCommand.php b/app/Console/Commands/SyncCommand.php index d61007ec..47e8d48f 100644 --- a/app/Console/Commands/SyncCommand.php +++ b/app/Console/Commands/SyncCommand.php @@ -4,72 +4,76 @@ namespace App\Console\Commands; use App\Libraries\WatchRecord\InotifyWatchRecord; use App\Models\Setting; -use App\Services\FileSynchronizer; use App\Services\MediaSyncService; +use App\Values\SyncResult; use Illuminate\Console\Command; +use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Console\Helper\ProgressBar; class SyncCommand extends Command { protected $signature = 'koel:sync {record? : A single watch record. Consult Wiki for more info.} - {--tags= : The comma-separated tags to sync into the database} + {--ignore= : The comma-separated tags to ignore (exclude) from syncing} {--force : Force re-syncing even unchanged files}'; protected $description = 'Sync songs found in configured directory against the database.'; - private int $ignored = 0; - private int $invalid = 0; - private int $synced = 0; - private MediaSyncService $mediaSyncService; - private ?ProgressBar $progressBar = null; + private ?string $mediaPath; + private ProgressBar $progressBar; - public function __construct(MediaSyncService $mediaSyncService) + public function __construct(private MediaSyncService $mediaSyncService) { parent::__construct(); - $this->mediaSyncService = $mediaSyncService; + $this->mediaSyncService->on('paths-gathered', function (array $paths): void { + $this->progressBar = new ProgressBar($this->output, count($paths)); + }); + + $this->mediaSyncService->on('progress', [$this, 'onSyncProgress']); } - public function handle(): void + public function handle(): int { - $this->ensureMediaPath(); + $this->mediaPath = $this->getMediaPath(); + $record = $this->argument('record'); - if (!$record) { + if ($record) { + $this->syncSingleRecord($record); + } else { $this->syncAll(); - - return; } - $this->syngle($record); + return self::SUCCESS; } /** * Sync all files in the configured media path. */ - protected function syncAll(): void + private function syncAll(): void { - $this->info('Syncing media from ' . Setting::get('media_path') . PHP_EOL); + $this->components->info('Scanning ' . $this->mediaPath); - // Get the tags to sync. + // The tags to ignore from syncing. // Notice that this is only meaningful for existing records. - // New records will have every applicable field sync'ed in. - $tags = $this->option('tags') ? explode(',', $this->option('tags')) : []; + // New records will have every applicable field synced in. + $ignores = $this->option('ignore') ? explode(',', $this->option('ignore')) : []; - $this->mediaSyncService->sync(null, $tags, $this->option('force'), $this); + $results = $this->mediaSyncService->sync($ignores, $this->option('force')); - $this->output->writeln( - PHP_EOL . PHP_EOL - . "Completed! $this->synced new or updated song(s), " - . "$this->ignored unchanged song(s), " - . "and $this->invalid invalid file(s)." - ); + $this->newLine(2); + $this->components->info('Scanning completed!'); + + $this->components->bulletList([ + "{$results->success()->count()} new or updated song(s)", + "{$results->skipped()->count()} unchanged song(s)", + "{$results->error()->count()} invalid file(s)", + ]); } /** - * SYNc a sinGLE file or directory. See my awesome pun? - * * @param string $record The watch record. * As of current we only support inotifywait. * Some examples: @@ -79,45 +83,39 @@ class SyncCommand extends Command * * @see http://man7.org/linux/man-pages/man1/inotifywait.1.html */ - public function syngle(string $record): void + private function syncSingleRecord(string $record): void { $this->mediaSyncService->syncByWatchRecord(new InotifyWatchRecord($record)); } - /** - * Log a song's sync status to console. - */ - public function logSyncStatusToConsole(string $path, int $result, ?string $reason = null): void + public function onSyncProgress(SyncResult $result): void { - $name = basename($path); + if (!$this->option('verbose')) { + $this->progressBar->advance(); - if ($result === FileSynchronizer::SYNC_RESULT_UNMODIFIED) { - ++$this->ignored; - } elseif ($result === FileSynchronizer::SYNC_RESULT_BAD_FILE) { - if ($this->option('verbose')) { - $this->error(PHP_EOL . "'$name' is not a valid media file: " . $reason); - } + return; + } - ++$this->invalid; - } else { - ++$this->synced; + $path = trim(Str::replaceFirst($this->mediaPath, '', $result->path), DIRECTORY_SEPARATOR); + + $this->components->twoColumnDetail($path, match (true) { + $result->isSuccess() => "OK", + $result->isSkipped() => "SKIPPED", + $result->isError() => "ERROR", + default => throw new RuntimeException("Unknown sync result type: {$result->type}") + }); + + if ($result->isError()) { + $this->output->writeln("$result->error"); } } - public function createProgressBar(int $max): void + private function getMediaPath(): string { - $this->progressBar = $this->getOutput()->createProgressBar($max); - } + $path = Setting::get('media_path'); - public function advanceProgressBar(): void - { - $this->progressBar->advance(); - } - - private function ensureMediaPath(): void - { - if (Setting::get('media_path')) { - return; + if ($path) { + return $path; } $this->warn("Media path hasn't been configured. Let's set it up."); @@ -132,5 +130,7 @@ class SyncCommand extends Command $this->error('The path does not exist or is not readable. Try again.'); } + + return $path; } } diff --git a/app/Console/Commands/TidyLibraryCommand.php b/app/Console/Commands/TidyLibraryCommand.php index b854d190..85be3cd1 100644 --- a/app/Console/Commands/TidyLibraryCommand.php +++ b/app/Console/Commands/TidyLibraryCommand.php @@ -9,8 +9,10 @@ class TidyLibraryCommand extends Command protected $signature = 'koel:tidy'; protected $hidden = true; - public function handle(): void + public function handle(): int { $this->warn('koel:tidy has been renamed. Use koel:prune instead.'); + + return self::SUCCESS; } } diff --git a/app/Events/AlbumInformationFetched.php b/app/Events/AlbumInformationFetched.php deleted file mode 100644 index 898bc709..00000000 --- a/app/Events/AlbumInformationFetched.php +++ /dev/null @@ -1,31 +0,0 @@ -album = $album; - $this->information = $information; - } - - public function getAlbum(): Album - { - return $this->album; - } - - /** @return array */ - public function getInformation(): array - { - return $this->information; - } -} diff --git a/app/Events/ArtistInformationFetched.php b/app/Events/ArtistInformationFetched.php deleted file mode 100644 index 1f9fb63e..00000000 --- a/app/Events/ArtistInformationFetched.php +++ /dev/null @@ -1,31 +0,0 @@ -artist = $artist; - $this->information = $information; - } - - public function getArtist(): Artist - { - return $this->artist; - } - - /** @return array */ - public function getInformation(): array - { - return $this->information; - } -} diff --git a/app/Events/MediaCacheObsolete.php b/app/Events/MediaCacheObsolete.php deleted file mode 100644 index e7945284..00000000 --- a/app/Events/MediaCacheObsolete.php +++ /dev/null @@ -1,7 +0,0 @@ -result = $result; } } diff --git a/app/Events/SongLikeToggled.php b/app/Events/SongLikeToggled.php index a1a69ec2..de9a02a6 100644 --- a/app/Events/SongLikeToggled.php +++ b/app/Events/SongLikeToggled.php @@ -3,19 +3,13 @@ namespace App\Events; use App\Models\Interaction; -use App\Models\User; use Illuminate\Queue\SerializesModels; class SongLikeToggled extends Event { use SerializesModels; - public Interaction $interaction; - public ?User $user = null; - - public function __construct(Interaction $interaction, ?User $user = null) + public function __construct(public Interaction $interaction) { - $this->interaction = $interaction; - $this->user = $user ?: auth()->user(); } } diff --git a/app/Events/SongStartedPlaying.php b/app/Events/SongStartedPlaying.php index d9ada059..a5172c09 100644 --- a/app/Events/SongStartedPlaying.php +++ b/app/Events/SongStartedPlaying.php @@ -10,12 +10,7 @@ class SongStartedPlaying extends Event { use SerializesModels; - public Song $song; - public User $user; - - public function __construct(Song $song, User $user) + public function __construct(public Song $song, public User $user) { - $this->song = $song; - $this->user = $user; } } diff --git a/app/Events/SongsBatchLiked.php b/app/Events/SongsBatchLiked.php index 2b666e3c..007c55cf 100644 --- a/app/Events/SongsBatchLiked.php +++ b/app/Events/SongsBatchLiked.php @@ -10,12 +10,7 @@ class SongsBatchLiked extends Event { use SerializesModels; - public Collection $songs; - public User $user; - - public function __construct(Collection $songs, User $user) + public function __construct(public Collection $songs, public User $user) { - $this->songs = $songs; - $this->user = $user; } } diff --git a/app/Events/SongsBatchUnliked.php b/app/Events/SongsBatchUnliked.php index 5db145ea..51883a6a 100644 --- a/app/Events/SongsBatchUnliked.php +++ b/app/Events/SongsBatchUnliked.php @@ -10,12 +10,7 @@ class SongsBatchUnliked extends Event { use SerializesModels; - public Collection $songs; - public User $user; - - public function __construct(Collection $songs, User $user) + public function __construct(public Collection $songs, public User $user) { - $this->songs = $songs; - $this->user = $user; } } diff --git a/app/Exceptions/SpotifyIntegrationDisabledException.php b/app/Exceptions/SpotifyIntegrationDisabledException.php new file mode 100644 index 00000000..61027db4 --- /dev/null +++ b/app/Exceptions/SpotifyIntegrationDisabledException.php @@ -0,0 +1,19 @@ + $value - * - * @return array - */ - 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]; - } -} diff --git a/app/Factories/StreamerFactory.php b/app/Factories/StreamerFactory.php index 53f7141f..ec41f005 100644 --- a/app/Factories/StreamerFactory.php +++ b/app/Factories/StreamerFactory.php @@ -11,21 +11,12 @@ use App\Services\TranscodingService; class StreamerFactory { - private DirectStreamerInterface $directStreamer; - private TranscodingStreamerInterface $transcodingStreamer; - private ObjectStorageStreamerInterface $objectStorageStreamer; - private TranscodingService $transcodingService; - public function __construct( - DirectStreamerInterface $directStreamer, - TranscodingStreamerInterface $transcodingStreamer, - ObjectStorageStreamerInterface $objectStorageStreamer, - TranscodingService $transcodingService + private DirectStreamerInterface $directStreamer, + private TranscodingStreamerInterface $transcodingStreamer, + private ObjectStorageStreamerInterface $objectStorageStreamer, + private TranscodingService $transcodingService ) { - $this->directStreamer = $directStreamer; - $this->transcodingStreamer = $transcodingStreamer; - $this->objectStorageStreamer = $objectStorageStreamer; - $this->transcodingService = $transcodingService; } public function createStreamer( diff --git a/app/Helpers.php b/app/Helpers.php index 799ebc9f..6941f5b6 100644 --- a/app/Helpers.php +++ b/app/Helpers.php @@ -14,48 +14,24 @@ function static_url(?string $name = null): string return $cdnUrl ? $cdnUrl . '/' . trim(ltrim($name, '/')) : trim(asset($name)); } -/** - * A copy of Laravel Mix but catered to our directory structure. - * - * @throws InvalidArgumentException - */ -function asset_rev(string $file, ?string $manifestFile = null): string +function album_cover_path(?string $fileName): ?string { - static $manifest = null; - - $manifestFile = $manifestFile ?: public_path('mix-manifest.json'); - - if ($manifest === null) { - $manifest = json_decode(file_get_contents($manifestFile), true); - } - - if (isset($manifest[$file])) { - return file_exists(public_path('hot')) - ? "http://localhost:8080$manifest[$file]" - : static_url($manifest[$file]); - } - - throw new InvalidArgumentException("File $file not defined in asset manifest."); + return $fileName ? public_path(config('koel.album_cover_dir') . $fileName) : null; } -function album_cover_path(string $fileName): string +function album_cover_url(?string $fileName): ?string { - return public_path(config('koel.album_cover_dir') . $fileName); + return $fileName ? static_url(config('koel.album_cover_dir') . $fileName) : null; } -function album_cover_url(string $fileName): string +function artist_image_path(?string $fileName): ?string { - return static_url(config('koel.album_cover_dir') . $fileName); + return $fileName ? public_path(config('koel.artist_image_dir') . $fileName) : null; } -function artist_image_path(string $fileName): string +function artist_image_url(?string $fileName): ?string { - return public_path(config('koel.artist_image_dir') . $fileName); -} - -function artist_image_url(string $fileName): string -{ - return static_url(config('koel.artist_image_dir') . $fileName); + return $fileName ? static_url(config('koel.artist_image_dir') . $fileName) : null; } function koel_version(): string diff --git a/app/Http/Controllers/API/AlbumCoverController.php b/app/Http/Controllers/API/AlbumCoverController.php index 8152bcd1..a4806338 100644 --- a/app/Http/Controllers/API/AlbumCoverController.php +++ b/app/Http/Controllers/API/AlbumCoverController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API; use App\Events\LibraryChanged; +use App\Http\Controllers\Controller; use App\Http\Requests\API\AlbumCoverUpdateRequest; use App\Models\Album; use App\Services\MediaMetadataService; @@ -10,11 +11,8 @@ use Illuminate\Http\JsonResponse; class AlbumCoverController extends Controller { - private MediaMetadataService $mediaMetadataService; - - public function __construct(MediaMetadataService $mediaMetadataService) + public function __construct(private MediaMetadataService $mediaMetadataService) { - $this->mediaMetadataService = $mediaMetadataService; } public function update(AlbumCoverUpdateRequest $request, Album $album) diff --git a/app/Http/Controllers/API/AlbumThumbnailController.php b/app/Http/Controllers/API/AlbumThumbnailController.php index 3577b5bb..b7931db5 100644 --- a/app/Http/Controllers/API/AlbumThumbnailController.php +++ b/app/Http/Controllers/API/AlbumThumbnailController.php @@ -2,17 +2,15 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Models\Album; use App\Services\MediaMetadataService; use Illuminate\Http\JsonResponse; class AlbumThumbnailController extends Controller { - private MediaMetadataService $mediaMetadataService; - - public function __construct(MediaMetadataService $mediaMetadataService) + public function __construct(private MediaMetadataService $mediaMetadataService) { - $this->mediaMetadataService = $mediaMetadataService; } public function show(Album $album): JsonResponse diff --git a/app/Http/Controllers/API/ArtistImageController.php b/app/Http/Controllers/API/ArtistImageController.php index a7cedc6d..25af7b4a 100644 --- a/app/Http/Controllers/API/ArtistImageController.php +++ b/app/Http/Controllers/API/ArtistImageController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\API; use App\Events\LibraryChanged; +use App\Http\Controllers\Controller; use App\Http\Requests\API\ArtistImageUpdateRequest; use App\Models\Artist; use App\Services\MediaMetadataService; @@ -10,11 +11,8 @@ use Illuminate\Http\JsonResponse; class ArtistImageController extends Controller { - private MediaMetadataService $mediaMetadataService; - - public function __construct(MediaMetadataService $mediaMetadataService) + public function __construct(private MediaMetadataService $mediaMetadataService) { - $this->mediaMetadataService = $mediaMetadataService; } public function update(ArtistImageUpdateRequest $request, Artist $artist) diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php index 95593e86..d35a9972 100644 --- a/app/Http/Controllers/API/AuthController.php +++ b/app/Http/Controllers/API/AuthController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\UserLoginRequest; use App\Models\User; use App\Repositories\UserRepository; @@ -15,23 +16,13 @@ class AuthController extends Controller { use ThrottlesLogins; - private UserRepository $userRepository; - private HashManager $hash; - private TokenManager $tokenManager; - - /** @var User */ - private ?Authenticatable $currentUser; - + /** @param User $user */ public function __construct( - UserRepository $userRepository, - HashManager $hash, - TokenManager $tokenManager, - ?Authenticatable $currentUser + private UserRepository $userRepository, + private HashManager $hash, + private TokenManager $tokenManager, + private ?Authenticatable $user ) { - $this->userRepository = $userRepository; - $this->hash = $hash; - $this->tokenManager = $tokenManager; - $this->currentUser = $currentUser; } public function login(UserLoginRequest $request) @@ -50,7 +41,9 @@ class AuthController extends Controller public function logout() { - $this->tokenManager->destroyTokens($this->currentUser); + if ($this->user) { + $this->tokenManager->destroyTokens($this->user); + } return response()->noContent(); } diff --git a/app/Http/Controllers/API/Controller.php b/app/Http/Controllers/API/Controller.php deleted file mode 100644 index d4773ded..00000000 --- a/app/Http/Controllers/API/Controller.php +++ /dev/null @@ -1,9 +0,0 @@ -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() diff --git a/app/Http/Controllers/API/Interaction/BatchLikeController.php b/app/Http/Controllers/API/Interaction/BatchLikeController.php index b035cc86..e589cad9 100644 --- a/app/Http/Controllers/API/Interaction/BatchLikeController.php +++ b/app/Http/Controllers/API/Interaction/BatchLikeController.php @@ -2,20 +2,29 @@ namespace App\Http\Controllers\API\Interaction; +use App\Http\Controllers\Controller; use App\Http\Requests\API\BatchInteractionRequest; +use App\Models\User; +use App\Services\InteractionService; +use Illuminate\Contracts\Auth\Authenticatable; class BatchLikeController extends Controller { + /** @param User $user */ + public function __construct(private InteractionService $interactionService, protected ?Authenticatable $user) + { + } + public function store(BatchInteractionRequest $request) { - $interactions = $this->interactionService->batchLike((array) $request->songs, $this->currentUser); + $interactions = $this->interactionService->batchLike((array) $request->songs, $this->user); return response()->json($interactions); } public function destroy(BatchInteractionRequest $request) { - $this->interactionService->batchUnlike((array) $request->songs, $this->currentUser); + $this->interactionService->batchUnlike((array) $request->songs, $this->user); return response()->noContent(); } diff --git a/app/Http/Controllers/API/Interaction/Controller.php b/app/Http/Controllers/API/Interaction/Controller.php deleted file mode 100644 index b3313083..00000000 --- a/app/Http/Controllers/API/Interaction/Controller.php +++ /dev/null @@ -1,22 +0,0 @@ -interactionService = $interactionService; - $this->currentUser = $currentUser; - } -} diff --git a/app/Http/Controllers/API/Interaction/LikeController.php b/app/Http/Controllers/API/Interaction/LikeController.php index 87a8dc18..813fa15e 100644 --- a/app/Http/Controllers/API/Interaction/LikeController.php +++ b/app/Http/Controllers/API/Interaction/LikeController.php @@ -2,12 +2,21 @@ namespace App\Http\Controllers\API\Interaction; +use App\Http\Controllers\Controller; use App\Http\Requests\API\SongLikeRequest; +use App\Models\User; +use App\Services\InteractionService; +use Illuminate\Contracts\Auth\Authenticatable; class LikeController extends Controller { + /** @param User $user */ + public function __construct(private InteractionService $interactionService, private ?Authenticatable $user) + { + } + public function store(SongLikeRequest $request) { - return response()->json($this->interactionService->toggleLike($request->song, $this->currentUser)); + return response()->json($this->interactionService->toggleLike($request->song, $this->user)); } } diff --git a/app/Http/Controllers/API/Interaction/PlayCountController.php b/app/Http/Controllers/API/Interaction/PlayCountController.php index 0b4b3b12..ccf428ca 100644 --- a/app/Http/Controllers/API/Interaction/PlayCountController.php +++ b/app/Http/Controllers/API/Interaction/PlayCountController.php @@ -3,13 +3,22 @@ namespace App\Http\Controllers\API\Interaction; use App\Events\SongStartedPlaying; +use App\Http\Controllers\Controller; use App\Http\Requests\API\Interaction\StorePlayCountRequest; +use App\Models\User; +use App\Services\InteractionService; +use Illuminate\Contracts\Auth\Authenticatable; class PlayCountController extends Controller { + /** @param User $user */ + public function __construct(private InteractionService $interactionService, private ?Authenticatable $user) + { + } + public function store(StorePlayCountRequest $request) { - $interaction = $this->interactionService->increasePlayCount($request->song, $this->currentUser); + $interaction = $this->interactionService->increasePlayCount($request->song, $this->user); event(new SongStartedPlaying($interaction->song, $interaction->user)); return response()->json($interaction); diff --git a/app/Http/Controllers/API/Interaction/RecentlyPlayedController.php b/app/Http/Controllers/API/Interaction/RecentlyPlayedController.php index d4569e2f..e39d36bf 100644 --- a/app/Http/Controllers/API/Interaction/RecentlyPlayedController.php +++ b/app/Http/Controllers/API/Interaction/RecentlyPlayedController.php @@ -2,26 +2,20 @@ namespace App\Http\Controllers\API\Interaction; +use App\Http\Controllers\Controller; +use App\Models\User; use App\Repositories\InteractionRepository; -use App\Services\InteractionService; use Illuminate\Contracts\Auth\Authenticatable; class RecentlyPlayedController extends Controller { - private InteractionRepository $interactionRepository; - - public function __construct( - InteractionService $interactionService, - InteractionRepository $interactionRepository, - ?Authenticatable $currentUser - ) { - parent::__construct($interactionService, $currentUser); - - $this->interactionRepository = $interactionRepository; + /** @param User $user */ + public function __construct(private InteractionRepository $interactionRepository, private ?Authenticatable $user) + { } public function index(?int $count = null) { - return response()->json($this->interactionRepository->getRecentlyPlayed($this->currentUser, $count)); + return response()->json($this->interactionRepository->getRecentlyPlayed($this->user, $count)); } } diff --git a/app/Http/Controllers/API/LastfmController.php b/app/Http/Controllers/API/LastfmController.php index 5bbfcb24..7a83ad0e 100644 --- a/app/Http/Controllers/API/LastfmController.php +++ b/app/Http/Controllers/API/LastfmController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\LastfmSetSessionKeyRequest; use App\Models\User; use App\Services\LastfmService; @@ -9,20 +10,14 @@ use Illuminate\Contracts\Auth\Authenticatable; class LastfmController extends Controller { - private LastfmService $lastfm; - - /** @var User */ - private ?Authenticatable $currentUser; - - public function __construct(LastfmService $lastfm, ?Authenticatable $currentUser) + /** @param User $currentUser */ + public function __construct(private LastfmService $lastfm, private ?Authenticatable $currentUser) { - $this->lastfm = $lastfm; - $this->currentUser = $currentUser; } public function setSessionKey(LastfmSetSessionKeyRequest $request) { - $this->lastfm->setUserSessionKey($this->currentUser, trim($request->key)); + $this->lastfm->setUserSessionKey($this->currentUser, $request->key); return response()->noContent(); } diff --git a/app/Http/Controllers/API/MediaInformation/AlbumController.php b/app/Http/Controllers/API/MediaInformation/AlbumController.php index 4767d067..e7b4eacf 100644 --- a/app/Http/Controllers/API/MediaInformation/AlbumController.php +++ b/app/Http/Controllers/API/MediaInformation/AlbumController.php @@ -2,12 +2,18 @@ namespace App\Http\Controllers\API\MediaInformation; +use App\Http\Controllers\Controller; use App\Models\Album; +use App\Services\MediaInformationService; class AlbumController extends Controller { + public function __construct(private MediaInformationService $mediaInformationService) + { + } + public function show(Album $album) { - return response()->json($this->mediaInformationService->getAlbumInformation($album)); + return response()->json($this->mediaInformationService->getAlbumInformation($album)?->toArray() ?: []); } } diff --git a/app/Http/Controllers/API/MediaInformation/ArtistController.php b/app/Http/Controllers/API/MediaInformation/ArtistController.php index bfaaa9e2..26126728 100644 --- a/app/Http/Controllers/API/MediaInformation/ArtistController.php +++ b/app/Http/Controllers/API/MediaInformation/ArtistController.php @@ -2,12 +2,18 @@ namespace App\Http\Controllers\API\MediaInformation; +use App\Http\Controllers\Controller; use App\Models\Artist; +use App\Services\MediaInformationService; class ArtistController extends Controller { + public function __construct(private MediaInformationService $mediaInformationService) + { + } + public function show(Artist $artist) { - return response()->json($this->mediaInformationService->getArtistInformation($artist)); + return response()->json($this->mediaInformationService->getArtistInformation($artist)?->toArray() ?: []); } } diff --git a/app/Http/Controllers/API/MediaInformation/Controller.php b/app/Http/Controllers/API/MediaInformation/Controller.php deleted file mode 100644 index 669bb51a..00000000 --- a/app/Http/Controllers/API/MediaInformation/Controller.php +++ /dev/null @@ -1,16 +0,0 @@ -mediaInformationService = $mediaInformationService; - } -} diff --git a/app/Http/Controllers/API/MediaInformation/SongController.php b/app/Http/Controllers/API/MediaInformation/SongController.php index dc99011e..a8406845 100644 --- a/app/Http/Controllers/API/MediaInformation/SongController.php +++ b/app/Http/Controllers/API/MediaInformation/SongController.php @@ -2,27 +2,25 @@ namespace App\Http\Controllers\API\MediaInformation; +use App\Http\Controllers\Controller; use App\Models\Song; use App\Services\MediaInformationService; use App\Services\YouTubeService; class SongController extends Controller { - private YouTubeService $youTubeService; - - public function __construct(MediaInformationService $mediaInformationService, YouTubeService $youTubeService) - { - parent::__construct($mediaInformationService); - - $this->youTubeService = $youTubeService; + public function __construct( + private MediaInformationService $mediaInformationService, + private YouTubeService $youTubeService + ) { } public function show(Song $song) { return response()->json([ - 'lyrics' => $song->lyrics, - 'album_info' => $this->mediaInformationService->getAlbumInformation($song->album), - 'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist), + 'lyrics' => nl2br($song->lyrics), // backward compat + 'album_info' => $this->mediaInformationService->getAlbumInformation($song->album)?->toArray() ?: [], + 'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist)?->toArray() ?: [], 'youtube' => $this->youTubeService->searchVideosRelatedToSong($song), ]); } diff --git a/app/Http/Controllers/API/ObjectStorage/Controller.php b/app/Http/Controllers/API/ObjectStorage/Controller.php deleted file mode 100644 index cccb8f0e..00000000 --- a/app/Http/Controllers/API/ObjectStorage/Controller.php +++ /dev/null @@ -1,9 +0,0 @@ -s3Service = $s3Service; } public function put(PutSongRequest $request) { $artist = array_get($request->tags, 'artist', ''); - $albumartist = trim(array_get($request->tags, 'albumartist', '')); + $song = $this->s3Service->createSongEntry( $request->bucket, $request->key, $artist, array_get($request->tags, 'album'), - (bool) $albumartist && $albumartist !== $artist, + trim(array_get($request->tags, 'albumartist')), array_get($request->tags, 'cover'), trim(array_get($request->tags, 'title', '')), (int) array_get($request->tags, 'duration', 0), @@ -41,7 +39,7 @@ class SongController extends Controller { try { $this->s3Service->deleteSongEntry($request->bucket, $request->key); - } catch (SongPathNotFoundException $exception) { + } catch (SongPathNotFoundException) { abort(Response::HTTP_NOT_FOUND); } diff --git a/app/Http/Controllers/API/PlaylistController.php b/app/Http/Controllers/API/PlaylistController.php index bbca28a4..c62de6c4 100644 --- a/app/Http/Controllers/API/PlaylistController.php +++ b/app/Http/Controllers/API/PlaylistController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\PlaylistStoreRequest; use App\Http\Requests\API\PlaylistUpdateRequest; use App\Models\Playlist; @@ -12,20 +13,12 @@ use Illuminate\Contracts\Auth\Authenticatable; class PlaylistController extends Controller { - private PlaylistRepository $playlistRepository; - private PlaylistService $playlistService; - - /** @var User */ - private ?Authenticatable $currentUser; - + /** @param User $user */ public function __construct( - PlaylistRepository $playlistRepository, - PlaylistService $playlistService, - ?Authenticatable $currentUser + private PlaylistRepository $playlistRepository, + private PlaylistService $playlistService, + private ?Authenticatable $user ) { - $this->playlistRepository = $playlistRepository; - $this->playlistService = $playlistService; - $this->currentUser = $currentUser; } public function index() @@ -37,7 +30,7 @@ class PlaylistController extends Controller { $playlist = $this->playlistService->createPlaylist( $request->name, - $this->currentUser, + $this->user, (array) $request->songs, $request->rules ); @@ -62,6 +55,6 @@ class PlaylistController extends Controller $playlist->delete(); - return response()->json(); + return response()->noContent(); } } diff --git a/app/Http/Controllers/API/PlaylistSongController.php b/app/Http/Controllers/API/PlaylistSongController.php index 1542dce9..ddc6459b 100644 --- a/app/Http/Controllers/API/PlaylistSongController.php +++ b/app/Http/Controllers/API/PlaylistSongController.php @@ -2,17 +2,22 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\PlaylistSongUpdateRequest; use App\Models\Playlist; +use App\Models\User; +use App\Services\PlaylistService; use App\Services\SmartPlaylistService; +use Illuminate\Contracts\Auth\Authenticatable; class PlaylistSongController extends Controller { - private SmartPlaylistService $smartPlaylistService; - - public function __construct(SmartPlaylistService $smartPlaylistService) - { - $this->smartPlaylistService = $smartPlaylistService; + /** @param User $user */ + public function __construct( + private SmartPlaylistService $smartPlaylistService, + private PlaylistService $playlistService, + private Authenticatable $user + ) { } public function index(Playlist $playlist) @@ -21,19 +26,20 @@ class PlaylistSongController extends Controller return response()->json( $playlist->is_smart - ? $this->smartPlaylistService->getSongs($playlist)->pluck('id') + ? $this->smartPlaylistService->getSongs($playlist, $this->user)->pluck('id') : $playlist->songs->pluck('id') ); } + /** @deprecated */ public function update(PlaylistSongUpdateRequest $request, Playlist $playlist) { $this->authorize('owner', $playlist); - abort_if($playlist->is_smart, 403, 'A smart playlist\'s content cannot be updated manually.'); + abort_if($playlist->is_smart, 403, 'A smart playlist cannot be populated manually.'); - $playlist->songs()->sync((array) $request->songs); + $this->playlistService->populatePlaylist($playlist, (array) $request->songs); - return response()->json(); + return response()->noContent(); } } diff --git a/app/Http/Controllers/API/ProfileController.php b/app/Http/Controllers/API/ProfileController.php index e4dd7223..faba321d 100644 --- a/app/Http/Controllers/API/ProfileController.php +++ b/app/Http/Controllers/API/ProfileController.php @@ -2,41 +2,38 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\ProfileUpdateRequest; +use App\Http\Resources\UserResource; use App\Models\User; use App\Services\TokenManager; use Illuminate\Contracts\Auth\Authenticatable; -use Illuminate\Contracts\Hashing\Hasher as Hash; +use Illuminate\Contracts\Hashing\Hasher; use Illuminate\Validation\ValidationException; class ProfileController extends Controller { - private Hash $hash; - private TokenManager $tokenManager; - - /** @var User */ - private ?Authenticatable $currentUser; - - public function __construct(Hash $hash, TokenManager $tokenManager, ?Authenticatable $currentUser) - { - $this->hash = $hash; - $this->tokenManager = $tokenManager; - $this->currentUser = $currentUser; + /** @param User $user */ + public function __construct( + private Hasher $hash, + private TokenManager $tokenManager, + private ?Authenticatable $user + ) { } public function show() { - return response()->json($this->currentUser); + return UserResource::make($this->user); } public function update(ProfileUpdateRequest $request) { if (config('koel.misc.demo')) { - return response()->json(); + return response()->noContent(); } throw_unless( - $this->hash->check($request->current_password, $this->currentUser->password), + $this->hash->check($request->current_password, $this->user->password), ValidationException::withMessages(['current_password' => 'Invalid current password']) ); @@ -46,12 +43,14 @@ class ProfileController extends Controller $data['password'] = $this->hash->make($request->new_password); } - $this->currentUser->update($data); + $this->user->update($data); - $responseData = $request->new_password - ? ['token' => $this->tokenManager->refreshToken($this->currentUser)->plainTextToken] - : []; + $response = UserResource::make($this->user)->response(); - return response()->json($responseData); + if ($request->new_password) { + $response->header('Authorization', $this->tokenManager->refreshToken($this->user)->plainTextToken); + } + + return $response; } } diff --git a/app/Http/Controllers/API/ScrobbleController.php b/app/Http/Controllers/API/ScrobbleController.php index 0c35dc41..c77a3e33 100644 --- a/app/Http/Controllers/API/ScrobbleController.php +++ b/app/Http/Controllers/API/ScrobbleController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\ScrobbleStoreRequest; use App\Jobs\ScrobbleJob; use App\Models\Song; @@ -10,12 +11,9 @@ use Illuminate\Contracts\Auth\Authenticatable; class ScrobbleController extends Controller { - /** @var User */ - private ?Authenticatable $currentUser; - - public function __construct(?Authenticatable $currentUser) + /** @param User $currentUser */ + public function __construct(private ?Authenticatable $currentUser) { - $this->currentUser = $currentUser; } public function store(ScrobbleStoreRequest $request, Song $song) diff --git a/app/Http/Controllers/API/Search/ExcerptSearchController.php b/app/Http/Controllers/API/Search/ExcerptSearchController.php index b894efa3..5d5d9f6e 100644 --- a/app/Http/Controllers/API/Search/ExcerptSearchController.php +++ b/app/Http/Controllers/API/Search/ExcerptSearchController.php @@ -2,31 +2,23 @@ namespace App\Http\Controllers\API\Search; -use App\Http\Controllers\API\Controller; +use App\Http\Controllers\Controller; use App\Services\SearchService; use Illuminate\Http\Request; use InvalidArgumentException; class ExcerptSearchController extends Controller { - private SearchService $searchService; - - public function __construct(SearchService $searchService) + public function __construct(private SearchService $searchService) { - $this->searchService = $searchService; } public function index(Request $request) { - if (!$request->get('q')) { - throw new InvalidArgumentException('A search query is required.'); - } + throw_unless((bool) $request->get('q'), new InvalidArgumentException('A search query is required.')); $count = (int) $request->get('count', SearchService::DEFAULT_EXCERPT_RESULT_COUNT); - - if ($count < 0) { - throw new InvalidArgumentException('Invalid count parameter.'); - } + throw_if($count < 0, new InvalidArgumentException('Invalid count parameter.')); return [ 'results' => $this->searchService->excerptSearch($request->get('q'), $count), diff --git a/app/Http/Controllers/API/Search/SongSearchController.php b/app/Http/Controllers/API/Search/SongSearchController.php index 8157e623..0c7f767b 100644 --- a/app/Http/Controllers/API/Search/SongSearchController.php +++ b/app/Http/Controllers/API/Search/SongSearchController.php @@ -2,25 +2,20 @@ namespace App\Http\Controllers\API\Search; -use App\Http\Controllers\API\Controller; +use App\Http\Controllers\Controller; use App\Services\SearchService; use Illuminate\Http\Request; use InvalidArgumentException; class SongSearchController extends Controller { - private SearchService $searchService; - - public function __construct(SearchService $searchService) + public function __construct(private SearchService $searchService) { - $this->searchService = $searchService; } public function index(Request $request) { - if (!$request->get('q')) { - throw new InvalidArgumentException('A search query is required.'); - } + throw_unless((bool) $request->get('q'), new InvalidArgumentException('A search query is required.')); return [ 'songs' => $this->searchService->searchSongs($request->get('q')), diff --git a/app/Http/Controllers/API/SettingController.php b/app/Http/Controllers/API/SettingController.php index b5c56758..0b0750e4 100644 --- a/app/Http/Controllers/API/SettingController.php +++ b/app/Http/Controllers/API/SettingController.php @@ -2,26 +2,23 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\SettingRequest; use App\Models\Setting; +use App\Models\User; use App\Services\MediaSyncService; class SettingController extends Controller { - private MediaSyncService $mediaSyncService; - - public function __construct(MediaSyncService $mediaSyncService) + public function __construct(private MediaSyncService $mediaSyncService) { - $this->mediaSyncService = $mediaSyncService; } - // @TODO: This should be a PUT request - public function store(SettingRequest $request) + public function update(SettingRequest $request) { - Setting::set('media_path', rtrim(trim($request->media_path), '/')); + $this->authorize('admin', User::class); - // In a next version we should opt for a "MediaPathChanged" event, - // but let's just do this async now. + Setting::set('media_path', rtrim(trim($request->media_path), '/')); $this->mediaSyncService->sync(); return response()->noContent(); diff --git a/app/Http/Controllers/API/SongController.php b/app/Http/Controllers/API/SongController.php index a2b3076a..c8a5e8c5 100644 --- a/app/Http/Controllers/API/SongController.php +++ b/app/Http/Controllers/API/SongController.php @@ -2,30 +2,48 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\SongUpdateRequest; -use App\Models\Song; +use App\Http\Resources\AlbumResource; +use App\Http\Resources\ArtistResource; +use App\Http\Resources\SongResource; +use App\Models\User; use App\Repositories\AlbumRepository; use App\Repositories\ArtistRepository; +use App\Services\LibraryManager; +use App\Services\SongService; +use App\Values\SongUpdateData; +use Illuminate\Contracts\Auth\Authenticatable; class SongController extends Controller { - private ArtistRepository $artistRepository; - private AlbumRepository $albumRepository; - - public function __construct(ArtistRepository $artistRepository, AlbumRepository $albumRepository) - { - $this->artistRepository = $artistRepository; - $this->albumRepository = $albumRepository; + /** @param User $user */ + public function __construct( + private SongService $songService, + private AlbumRepository $albumRepository, + private ArtistRepository $artistRepository, + private LibraryManager $libraryManager, + private ?Authenticatable $user + ) { } public function update(SongUpdateRequest $request) { - $updatedSongs = Song::updateInfo($request->songs, $request->data); + $updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request)); + $albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray(), $this->user); + + $artists = $this->artistRepository->getByIds( + array_merge( + $updatedSongs->pluck('artist_id')->all(), + $updatedSongs->pluck('album_artist_id')->all() + ) + ); return response()->json([ - 'artists' => $this->artistRepository->getByIds($updatedSongs->pluck('artist_id')->all()), - 'albums' => $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->all()), - 'songs' => $updatedSongs, + 'songs' => SongResource::collection($updatedSongs), + 'albums' => AlbumResource::collection($albums), + 'artists' => ArtistResource::collection($artists), + 'removed' => $this->libraryManager->prune(), ]); } } diff --git a/app/Http/Controllers/API/UploadController.php b/app/Http/Controllers/API/UploadController.php index f5f48523..55bd9a8c 100644 --- a/app/Http/Controllers/API/UploadController.php +++ b/app/Http/Controllers/API/UploadController.php @@ -2,35 +2,42 @@ namespace App\Http\Controllers\API; -use App\Events\MediaCacheObsolete; use App\Exceptions\MediaPathNotSetException; use App\Exceptions\SongUploadFailedException; +use App\Http\Controllers\Controller; use App\Http\Requests\API\UploadRequest; +use App\Http\Resources\AlbumResource; +use App\Http\Resources\SongResource; +use App\Models\User; +use App\Repositories\AlbumRepository; +use App\Repositories\SongRepository; use App\Services\UploadService; -use Illuminate\Http\JsonResponse; +use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Http\Response; class UploadController extends Controller { - private UploadService $uploadService; + /** @param User $user */ + public function __invoke( + UploadService $uploadService, + AlbumRepository $albumRepository, + SongRepository $songRepository, + UploadRequest $request, + Authenticatable $user + ) { + $this->authorize('admin', User::class); - public function __construct(UploadService $uploadService) - { - $this->uploadService = $uploadService; - } - - public function store(UploadRequest $request): JsonResponse - { try { - $song = $this->uploadService->handleUploadedFile($request->file); + $song = $songRepository->getOne($uploadService->handleUploadedFile($request->file)->id); + + return response()->json([ + 'song' => SongResource::make($song), + 'album' => AlbumResource::make($albumRepository->getOne($song->album_id)), + ]); } catch (MediaPathNotSetException $e) { abort(Response::HTTP_FORBIDDEN, $e->getMessage()); } catch (SongUploadFailedException $e) { abort(Response::HTTP_BAD_REQUEST, $e->getMessage()); } - - event(new MediaCacheObsolete()); - - return response()->json($song->load('album', 'artist')); } } diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php index 5994550a..9aa99268 100644 --- a/app/Http/Controllers/API/UserController.php +++ b/app/Http/Controllers/API/UserController.php @@ -2,48 +2,56 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\UserStoreRequest; use App\Http\Requests\API\UserUpdateRequest; +use App\Http\Resources\UserResource; use App\Models\User; -use Illuminate\Contracts\Hashing\Hasher as Hash; +use App\Repositories\UserRepository; +use App\Services\UserService; class UserController extends Controller { - private Hash $hash; - - public function __construct(Hash $hash) + public function __construct(private UserRepository $userRepository, private UserService $userService) { - $this->hash = $hash; + } + + public function index() + { + $this->authorize('admin', User::class); + + return UserResource::collection($this->userRepository->getAll()); } public function store(UserStoreRequest $request) { - return response()->json(User::create([ - 'name' => $request->name, - 'email' => $request->email, - 'password' => $this->hash->make($request->password), - 'is_admin' => $request->is_admin, - ])); + $this->authorize('admin', User::class); + + return UserResource::make($this->userService->createUser( + $request->name, + $request->email, + $request->password, + $request->get('is_admin') ?: false + )); } public function update(UserUpdateRequest $request, User $user) { - $data = $request->only('name', 'email', 'is_admin'); + $this->authorize('admin', User::class); - if ($request->password) { - $data['password'] = $this->hash->make($request->password); - } - - $user->update($data); - - return response()->json($user); + return UserResource::make($this->userService->updateUser( + $user, + $request->name, + $request->email, + $request->password, + $request->get('is_admin') ?: false + )); } public function destroy(User $user) { $this->authorize('destroy', $user); - - $user->delete(); + $this->userService->deleteUser($user); return response()->noContent(); } diff --git a/app/Http/Controllers/API/YouTubeController.php b/app/Http/Controllers/API/YouTubeController.php index cb2f2f11..b507e081 100644 --- a/app/Http/Controllers/API/YouTubeController.php +++ b/app/Http/Controllers/API/YouTubeController.php @@ -2,17 +2,15 @@ namespace App\Http\Controllers\API; +use App\Http\Controllers\Controller; use App\Http\Requests\API\YouTubeSearchRequest; use App\Models\Song; use App\Services\YouTubeService; class YouTubeController extends Controller { - private YouTubeService $youTubeService; - - public function __construct(YouTubeService $youTubeService) + public function __construct(private YouTubeService $youTubeService) { - $this->youTubeService = $youTubeService; } public function searchVideosRelatedToSong(YouTubeSearchRequest $request, Song $song) diff --git a/app/Http/Controllers/Download/AlbumController.php b/app/Http/Controllers/Download/AlbumController.php index ce3792fc..0ba68a40 100644 --- a/app/Http/Controllers/Download/AlbumController.php +++ b/app/Http/Controllers/Download/AlbumController.php @@ -2,10 +2,16 @@ namespace App\Http\Controllers\Download; +use App\Http\Controllers\Controller; use App\Models\Album; +use App\Services\DownloadService; class AlbumController extends Controller { + public function __construct(private DownloadService $downloadService) + { + } + public function show(Album $album) { return response()->download($this->downloadService->from($album)); diff --git a/app/Http/Controllers/Download/ArtistController.php b/app/Http/Controllers/Download/ArtistController.php index c903d252..c2939ae0 100644 --- a/app/Http/Controllers/Download/ArtistController.php +++ b/app/Http/Controllers/Download/ArtistController.php @@ -2,10 +2,16 @@ namespace App\Http\Controllers\Download; +use App\Http\Controllers\Controller; use App\Models\Artist; +use App\Services\DownloadService; class ArtistController extends Controller { + public function __construct(private DownloadService $downloadService) + { + } + public function show(Artist $artist) { return response()->download($this->downloadService->from($artist)); diff --git a/app/Http/Controllers/Download/Controller.php b/app/Http/Controllers/Download/Controller.php deleted file mode 100644 index 7908f3cc..00000000 --- a/app/Http/Controllers/Download/Controller.php +++ /dev/null @@ -1,16 +0,0 @@ -downloadService = $downloadService; - } -} diff --git a/app/Http/Controllers/Download/FavoritesController.php b/app/Http/Controllers/Download/FavoritesController.php index 2a33d0b6..e8b5e768 100644 --- a/app/Http/Controllers/Download/FavoritesController.php +++ b/app/Http/Controllers/Download/FavoritesController.php @@ -2,24 +2,25 @@ namespace App\Http\Controllers\Download; -use App\Http\Requests\Download\Request; +use App\Http\Controllers\Controller; +use App\Models\User; use App\Repositories\InteractionRepository; use App\Services\DownloadService; +use Illuminate\Contracts\Auth\Authenticatable; class FavoritesController extends Controller { - private InteractionRepository $interactionRepository; - - public function __construct(DownloadService $downloadService, InteractionRepository $interactionRepository) - { - parent::__construct($downloadService); - - $this->interactionRepository = $interactionRepository; + /** @param User $user */ + public function __construct( + private DownloadService $downloadService, + private InteractionRepository $interactionRepository, + private ?Authenticatable $user + ) { } - public function show(Request $request) + public function show() { - $songs = $this->interactionRepository->getUserFavorites($request->user()); + $songs = $this->interactionRepository->getUserFavorites($this->user); return response()->download($this->downloadService->from($songs)); } diff --git a/app/Http/Controllers/Download/PlaylistController.php b/app/Http/Controllers/Download/PlaylistController.php index d32b9b7b..8d6b84ad 100644 --- a/app/Http/Controllers/Download/PlaylistController.php +++ b/app/Http/Controllers/Download/PlaylistController.php @@ -2,10 +2,16 @@ namespace App\Http\Controllers\Download; +use App\Http\Controllers\Controller; use App\Models\Playlist; +use App\Services\DownloadService; class PlaylistController extends Controller { + public function __construct(private DownloadService $downloadService) + { + } + public function show(Playlist $playlist) { $this->authorize('owner', $playlist); diff --git a/app/Http/Controllers/Download/SongController.php b/app/Http/Controllers/Download/SongController.php index 5cd8e913..c583d12d 100644 --- a/app/Http/Controllers/Download/SongController.php +++ b/app/Http/Controllers/Download/SongController.php @@ -2,19 +2,15 @@ namespace App\Http\Controllers\Download; +use App\Http\Controllers\Controller; use App\Http\Requests\Download\SongRequest; use App\Repositories\SongRepository; use App\Services\DownloadService; class SongController extends Controller { - private SongRepository $songRepository; - - public function __construct(DownloadService $downloadService, SongRepository $songRepository) + public function __construct(private DownloadService $downloadService, private SongRepository $songRepository) { - parent::__construct($downloadService); - - $this->songRepository = $songRepository; } public function show(SongRequest $request) diff --git a/app/Http/Controllers/ITunesController.php b/app/Http/Controllers/ITunesController.php index b7c768aa..0b218483 100644 --- a/app/Http/Controllers/ITunesController.php +++ b/app/Http/Controllers/ITunesController.php @@ -10,13 +10,8 @@ use Illuminate\Http\Response; class ITunesController extends Controller { - private ITunesService $iTunesService; - private TokenManager $tokenManager; - - public function __construct(ITunesService $iTunesService, TokenManager $tokenManager) + public function __construct(private ITunesService $iTunesService, private TokenManager $tokenManager) { - $this->iTunesService = $iTunesService; - $this->tokenManager = $tokenManager; } public function viewSong(ViewSongOnITunesRequest $request, Album $album) diff --git a/app/Http/Controllers/LastfmController.php b/app/Http/Controllers/LastfmController.php index 14e73921..9b1fd251 100644 --- a/app/Http/Controllers/LastfmController.php +++ b/app/Http/Controllers/LastfmController.php @@ -11,17 +11,12 @@ use Illuminate\Http\Response; class LastfmController extends Controller { - private LastfmService $lastfm; - private TokenManager $tokenManager; - - /** @var User */ - private ?Authenticatable $currentUser; - - public function __construct(LastfmService $lastfm, TokenManager $tokenManager, ?Authenticatable $currentUser) - { - $this->lastfm = $lastfm; - $this->tokenManager = $tokenManager; - $this->currentUser = $currentUser; + /** @param User $currentUser */ + public function __construct( + private LastfmService $lastfm, + private TokenManager $tokenManager, + private ?Authenticatable $currentUser + ) { } public function connect() diff --git a/app/Http/Controllers/PlayController.php b/app/Http/Controllers/PlayController.php index a78187b0..be7bd676 100644 --- a/app/Http/Controllers/PlayController.php +++ b/app/Http/Controllers/PlayController.php @@ -8,17 +8,14 @@ use App\Models\Song; class PlayController extends Controller { - private StreamerFactory $streamerFactory; - - public function __construct(StreamerFactory $streamerFactory) + public function __construct(private StreamerFactory $streamerFactory) { - $this->streamerFactory = $streamerFactory; } public function show(SongPlayRequest $request, Song $song, ?bool $transcode = null, ?int $bitRate = null) { return $this->streamerFactory - ->createStreamer($song, $transcode, $bitRate, floatval($request->time)) + ->createStreamer($song, $transcode, $bitRate, (float) $request->time) ->stream(); } } diff --git a/app/Http/Controllers/V6/API/AlbumController.php b/app/Http/Controllers/V6/API/AlbumController.php new file mode 100644 index 00000000..a870775f --- /dev/null +++ b/app/Http/Controllers/V6/API/AlbumController.php @@ -0,0 +1,33 @@ +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)); + } +} diff --git a/app/Http/Controllers/V6/API/AlbumSongController.php b/app/Http/Controllers/V6/API/AlbumSongController.php new file mode 100644 index 00000000..bf71be0b --- /dev/null +++ b/app/Http/Controllers/V6/API/AlbumSongController.php @@ -0,0 +1,23 @@ +songRepository->getByAlbum($album, $this->user)); + } +} diff --git a/app/Http/Controllers/V6/API/ArtistController.php b/app/Http/Controllers/V6/API/ArtistController.php new file mode 100644 index 00000000..60dfe81e --- /dev/null +++ b/app/Http/Controllers/V6/API/ArtistController.php @@ -0,0 +1,33 @@ +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)); + } +} diff --git a/app/Http/Controllers/V6/API/ArtistSongController.php b/app/Http/Controllers/V6/API/ArtistSongController.php new file mode 100644 index 00000000..72f0196d --- /dev/null +++ b/app/Http/Controllers/V6/API/ArtistSongController.php @@ -0,0 +1,23 @@ +songRepository->getByArtist($artist, $this->user)); + } +} diff --git a/app/Http/Controllers/V6/API/DataController.php b/app/Http/Controllers/V6/API/DataController.php new file mode 100644 index 00000000..65f6be4e --- /dev/null +++ b/app/Http/Controllers/V6/API/DataController.php @@ -0,0 +1,53 @@ +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(), + ]); + } +} diff --git a/app/Http/Controllers/V6/API/ExcerptSearchController.php b/app/Http/Controllers/V6/API/ExcerptSearchController.php new file mode 100644 index 00000000..74ab5bd3 --- /dev/null +++ b/app/Http/Controllers/V6/API/ExcerptSearchController.php @@ -0,0 +1,19 @@ +excerptSearch($request->q, $user)); + } +} diff --git a/app/Http/Controllers/V6/API/FavoriteSongController.php b/app/Http/Controllers/V6/API/FavoriteSongController.php new file mode 100644 index 00000000..a3cf96ce --- /dev/null +++ b/app/Http/Controllers/V6/API/FavoriteSongController.php @@ -0,0 +1,22 @@ +songRepository->getFavorites($this->user)); + } +} diff --git a/app/Http/Controllers/V6/API/FetchAlbumInformationController.php b/app/Http/Controllers/V6/API/FetchAlbumInformationController.php new file mode 100644 index 00000000..41675a88 --- /dev/null +++ b/app/Http/Controllers/V6/API/FetchAlbumInformationController.php @@ -0,0 +1,15 @@ +json($informationService->getAlbumInformation($album)); + } +} diff --git a/app/Http/Controllers/V6/API/FetchArtistInformationController.php b/app/Http/Controllers/V6/API/FetchArtistInformationController.php new file mode 100644 index 00000000..12939917 --- /dev/null +++ b/app/Http/Controllers/V6/API/FetchArtistInformationController.php @@ -0,0 +1,15 @@ +json($informationService->getArtistInformation($artist)); + } +} diff --git a/app/Http/Controllers/V6/API/OverviewController.php b/app/Http/Controllers/V6/API/OverviewController.php new file mode 100644 index 00000000..09967660 --- /dev/null +++ b/app/Http/Controllers/V6/API/OverviewController.php @@ -0,0 +1,33 @@ +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()), + ]); + } +} diff --git a/app/Http/Controllers/V6/API/PlayCountController.php b/app/Http/Controllers/V6/API/PlayCountController.php new file mode 100644 index 00000000..d05900a9 --- /dev/null +++ b/app/Http/Controllers/V6/API/PlayCountController.php @@ -0,0 +1,27 @@ +interactionService->increasePlayCount($request->song, $this->user); + event(new SongStartedPlaying($interaction->song, $interaction->user)); + + return InteractionResource::make($interaction); + } +} diff --git a/app/Http/Controllers/V6/API/PlaylistSongController.php b/app/Http/Controllers/V6/API/PlaylistSongController.php new file mode 100644 index 00000000..fc80713a --- /dev/null +++ b/app/Http/Controllers/V6/API/PlaylistSongController.php @@ -0,0 +1,60 @@ +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(); + } +} diff --git a/app/Http/Controllers/V6/API/QueueController.php b/app/Http/Controllers/V6/API/QueueController.php new file mode 100644 index 00000000..12a60749 --- /dev/null +++ b/app/Http/Controllers/V6/API/QueueController.php @@ -0,0 +1,34 @@ +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, + ) + ); + } + } +} diff --git a/app/Http/Controllers/V6/API/RecentlyPlayedSongController.php b/app/Http/Controllers/V6/API/RecentlyPlayedSongController.php new file mode 100644 index 00000000..d2c4e189 --- /dev/null +++ b/app/Http/Controllers/V6/API/RecentlyPlayedSongController.php @@ -0,0 +1,24 @@ +songRepository->getRecentlyPlayed(self::MAX_ITEM_COUNT, $this->user)); + } +} diff --git a/app/Http/Controllers/V6/API/SongController.php b/app/Http/Controllers/V6/API/SongController.php new file mode 100644 index 00000000..c91c1928 --- /dev/null +++ b/app/Http/Controllers/V6/API/SongController.php @@ -0,0 +1,35 @@ +songRepository->getOne($song->id)); + } + + public function index(SongListRequest $request) + { + return SongResource::collection( + $this->songRepository->getForListing( + $request->sort ?: 'songs.title', + $request->order ?: 'asc', + $this->user + ) + ); + } +} diff --git a/app/Http/Controllers/V6/API/SongSearchController.php b/app/Http/Controllers/V6/API/SongSearchController.php new file mode 100644 index 00000000..343f55da --- /dev/null +++ b/app/Http/Controllers/V6/API/SongSearchController.php @@ -0,0 +1,19 @@ +searchSongs($request->q, $user)); + } +} diff --git a/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php b/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php new file mode 100644 index 00000000..5008c1b8 --- /dev/null +++ b/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php @@ -0,0 +1,20 @@ + $songs + */ +class AddSongsToPlaylistRequest extends Request +{ + /** @return array */ + public function rules(): array + { + return [ + 'songs' => 'required|array', + 'songs.*' => 'exists:songs,id', + ]; + } +} diff --git a/app/Http/Controllers/V6/Requests/QueueFetchSongRequest.php b/app/Http/Controllers/V6/Requests/QueueFetchSongRequest.php new file mode 100644 index 00000000..baf435a9 --- /dev/null +++ b/app/Http/Controllers/V6/Requests/QueueFetchSongRequest.php @@ -0,0 +1,28 @@ + */ + 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)), + ], + ]; + } +} diff --git a/app/Http/Controllers/V6/Requests/RemoveSongsFromPlaylistRequest.php b/app/Http/Controllers/V6/Requests/RemoveSongsFromPlaylistRequest.php new file mode 100644 index 00000000..80fc5dfb --- /dev/null +++ b/app/Http/Controllers/V6/Requests/RemoveSongsFromPlaylistRequest.php @@ -0,0 +1,20 @@ + $songs + */ +class RemoveSongsFromPlaylistRequest extends Request +{ + /** @return array */ + public function rules(): array + { + return [ + 'songs' => 'required|array', + 'songs.*' => 'exists:songs,id', + ]; + } +} diff --git a/app/Http/Controllers/V6/Requests/SearchRequest.php b/app/Http/Controllers/V6/Requests/SearchRequest.php new file mode 100644 index 00000000..6bc31052 --- /dev/null +++ b/app/Http/Controllers/V6/Requests/SearchRequest.php @@ -0,0 +1,17 @@ + */ + public function rules(): array + { + return ['q' => 'required']; + } +} diff --git a/app/Http/Controllers/V6/Requests/SongListRequest.php b/app/Http/Controllers/V6/Requests/SongListRequest.php new file mode 100644 index 00000000..1492fa8a --- /dev/null +++ b/app/Http/Controllers/V6/Requests/SongListRequest.php @@ -0,0 +1,13 @@ +auth = $auth; } public function handle(Request $request, Closure $next) // @phpcs:ignore { if ($this->auth->guest()) { - if ($request->ajax() || $request->route()->getName() === 'play') { + if ($request->ajax() || $request->wantsJson() || $request->route()->getName() === 'play') { return response('Unauthorized.', 401); } else { return redirect()->guest('/'); diff --git a/app/Http/Middleware/ForceHttps.php b/app/Http/Middleware/ForceHttps.php index ff6e3638..eab93658 100644 --- a/app/Http/Middleware/ForceHttps.php +++ b/app/Http/Middleware/ForceHttps.php @@ -8,11 +8,8 @@ use Illuminate\Routing\UrlGenerator; class ForceHttps { - private UrlGenerator $url; - - public function __construct(UrlGenerator $url) + public function __construct(private UrlGenerator $url) { - $this->url = $url; } public function handle(Request $request, Closure $next) // @phpcs:ignore diff --git a/app/Http/Middleware/ObjectStorageAuthenticate.php b/app/Http/Middleware/ObjectStorageAuthenticate.php index 9ebc1caf..2ea5c1f2 100644 --- a/app/Http/Middleware/ObjectStorageAuthenticate.php +++ b/app/Http/Middleware/ObjectStorageAuthenticate.php @@ -4,6 +4,7 @@ namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; +use Illuminate\Http\Response; /** * Authenticate requests from Object Storage services (like S3). @@ -13,9 +14,7 @@ class ObjectStorageAuthenticate { public function handle(Request $request, Closure $next) // @phpcs:ignore { - if ($request->appKey !== config('app.key')) { - return response('Unauthorized.', 401); - } + abort_unless($request->get('appKey') === config('app.key'), Response::HTTP_UNAUTHORIZED); return $next($request); } diff --git a/app/Http/Middleware/ThrottleRequests.php b/app/Http/Middleware/ThrottleRequests.php new file mode 100644 index 00000000..cfa23e3b --- /dev/null +++ b/app/Http/Middleware/ThrottleRequests.php @@ -0,0 +1,19 @@ +environment('production')) { + return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/API/AlbumCoverUpdateRequest.php b/app/Http/Requests/API/AlbumCoverUpdateRequest.php index bef6fff4..af6bfe49 100644 --- a/app/Http/Requests/API/AlbumCoverUpdateRequest.php +++ b/app/Http/Requests/API/AlbumCoverUpdateRequest.php @@ -3,7 +3,7 @@ namespace App\Http\Requests\API; /** @property string $cover */ -class AlbumCoverUpdateRequest extends AbstractMediaImageUpdateRequest +class AlbumCoverUpdateRequest extends MediaImageUpdateRequest { protected function getImageFieldName(): string { diff --git a/app/Http/Requests/API/ArtistImageUpdateRequest.php b/app/Http/Requests/API/ArtistImageUpdateRequest.php index fb9778bb..3a9f8cdb 100644 --- a/app/Http/Requests/API/ArtistImageUpdateRequest.php +++ b/app/Http/Requests/API/ArtistImageUpdateRequest.php @@ -3,7 +3,7 @@ namespace App\Http\Requests\API; /** @property string $image */ -class ArtistImageUpdateRequest extends AbstractMediaImageUpdateRequest +class ArtistImageUpdateRequest extends MediaImageUpdateRequest { protected function getImageFieldName(): string { diff --git a/app/Http/Requests/API/Interaction/Request.php b/app/Http/Requests/API/Interaction/Request.php index b3e59c93..128094b4 100644 --- a/app/Http/Requests/API/Interaction/Request.php +++ b/app/Http/Requests/API/Interaction/Request.php @@ -4,6 +4,6 @@ namespace App\Http\Requests\API\Interaction; use App\Http\Requests\API\Request as BaseRequest; -class Request extends BaseRequest +abstract class Request extends BaseRequest { } diff --git a/app/Http/Requests/API/AbstractMediaImageUpdateRequest.php b/app/Http/Requests/API/MediaImageUpdateRequest.php similarity index 92% rename from app/Http/Requests/API/AbstractMediaImageUpdateRequest.php rename to app/Http/Requests/API/MediaImageUpdateRequest.php index 9d2b98ac..389d73ba 100644 --- a/app/Http/Requests/API/AbstractMediaImageUpdateRequest.php +++ b/app/Http/Requests/API/MediaImageUpdateRequest.php @@ -4,7 +4,7 @@ namespace App\Http\Requests\API; use App\Rules\ImageData; -abstract class AbstractMediaImageUpdateRequest extends Request +abstract class MediaImageUpdateRequest extends Request { public function authorize(): bool { diff --git a/app/Http/Requests/API/Request.php b/app/Http/Requests/API/Request.php index 72a024b5..be9c8926 100644 --- a/app/Http/Requests/API/Request.php +++ b/app/Http/Requests/API/Request.php @@ -2,8 +2,8 @@ namespace App\Http\Requests\API; -use App\Http\Requests\AbstractRequest; +use App\Http\Requests\Request as BaseRequest; -class Request extends AbstractRequest +abstract class Request extends BaseRequest { } diff --git a/app/Http/Requests/API/SettingRequest.php b/app/Http/Requests/API/SettingRequest.php index b68fa1b8..70126c40 100644 --- a/app/Http/Requests/API/SettingRequest.php +++ b/app/Http/Requests/API/SettingRequest.php @@ -3,15 +3,10 @@ namespace App\Http\Requests\API; /** - * @property string $media_path + * @property-read string $media_path */ class SettingRequest extends Request { - public function authorize(): bool - { - return auth()->user()->is_admin; - } - /** @return array */ public function rules(): array { diff --git a/app/Http/Requests/API/UploadRequest.php b/app/Http/Requests/API/UploadRequest.php index e95f8029..9bc0ede0 100644 --- a/app/Http/Requests/API/UploadRequest.php +++ b/app/Http/Requests/API/UploadRequest.php @@ -2,17 +2,12 @@ namespace App\Http\Requests\API; -use App\Http\Requests\AbstractRequest; +use App\Http\Requests\Request; use Illuminate\Http\UploadedFile; /** @property UploadedFile $file */ -class UploadRequest extends AbstractRequest +class UploadRequest extends Request { - public function authorize(): bool - { - return auth()->user()->is_admin; - } - /** @return array */ public function rules(): array { @@ -20,7 +15,7 @@ class UploadRequest extends AbstractRequest 'file' => [ 'required', 'file', - 'mimetypes:audio/flac,audio/mpeg,audio/ogg,audio/x-flac,audio/x-aac', + 'mimes:mp3,mpga,aac,flac,ogg,oga,opus', ], ]; } diff --git a/app/Http/Requests/API/UserStoreRequest.php b/app/Http/Requests/API/UserStoreRequest.php index 3b7a74c5..a29592bb 100644 --- a/app/Http/Requests/API/UserStoreRequest.php +++ b/app/Http/Requests/API/UserStoreRequest.php @@ -5,18 +5,12 @@ namespace App\Http\Requests\API; use Illuminate\Validation\Rules\Password; /** - * @property string $password - * @property string $name - * @property string $email - * @property bool $is_admin + * @property-read string $password + * @property-read string $name + * @property-read string $email */ class UserStoreRequest extends Request { - public function authorize(): bool - { - return auth()->user()->is_admin; - } - /** @return array */ public function rules(): array { @@ -24,7 +18,7 @@ class UserStoreRequest extends Request 'name' => 'required', 'email' => 'required|email|unique:users', 'password' => ['required', Password::defaults()], - 'is_admin' => 'required', + 'is_admin' => 'sometimes', ]; } } diff --git a/app/Http/Requests/API/UserUpdateRequest.php b/app/Http/Requests/API/UserUpdateRequest.php index 10be5b03..f3f79468 100644 --- a/app/Http/Requests/API/UserUpdateRequest.php +++ b/app/Http/Requests/API/UserUpdateRequest.php @@ -6,18 +6,12 @@ use App\Models\User; use Illuminate\Validation\Rules\Password; /** - * @property string $password - * @property string $name - * @property string $email - * @property bool $is_admin + * @property-read string $password + * @property-read string $name + * @property-read string $email */ class UserUpdateRequest extends Request { - public function authorize(): bool - { - return auth()->user()->is_admin; - } - /** @return array */ public function rules(): array { @@ -28,6 +22,7 @@ class UserUpdateRequest extends Request 'name' => 'required', 'email' => 'required|email|unique:users,email,' . $user->id, 'password' => ['sometimes', Password::defaults()], + 'is_admin' => 'sometimes', ]; } } diff --git a/app/Http/Requests/Download/Request.php b/app/Http/Requests/Download/Request.php index d2f9d1b1..b656d471 100644 --- a/app/Http/Requests/Download/Request.php +++ b/app/Http/Requests/Download/Request.php @@ -4,7 +4,7 @@ namespace App\Http\Requests\Download; use App\Http\Requests\API\Request as BaseRequest; -class Request extends BaseRequest +abstract class Request extends BaseRequest { public function authorize(): bool { diff --git a/app/Http/Requests/AbstractRequest.php b/app/Http/Requests/Request.php similarity index 83% rename from app/Http/Requests/AbstractRequest.php rename to app/Http/Requests/Request.php index 91a7ddb3..7e51f4ff 100644 --- a/app/Http/Requests/AbstractRequest.php +++ b/app/Http/Requests/Request.php @@ -4,7 +4,7 @@ namespace App\Http\Requests; use Illuminate\Foundation\Http\FormRequest; -abstract class AbstractRequest extends FormRequest +abstract class Request extends FormRequest { public function authorize(): bool { diff --git a/app/Http/Requests/SongPlayRequest.php b/app/Http/Requests/SongPlayRequest.php index 737f87c0..d571e49c 100644 --- a/app/Http/Requests/SongPlayRequest.php +++ b/app/Http/Requests/SongPlayRequest.php @@ -3,9 +3,9 @@ namespace App\Http\Requests; /** - * @property float $time + * @property float|string $time * @property string $api_token */ -class SongPlayRequest extends AbstractRequest +class SongPlayRequest extends Request { } diff --git a/app/Http/Requests/SpotifyCallbackRequest.php b/app/Http/Requests/SpotifyCallbackRequest.php new file mode 100644 index 00000000..2478eebb --- /dev/null +++ b/app/Http/Requests/SpotifyCallbackRequest.php @@ -0,0 +1,11 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'albums', + 'id' => $this->album->id, + 'name' => $this->album->name, + 'artist_id' => $this->album->artist_id, + 'artist_name' => $this->album->artist->name, + 'cover' => $this->album->cover, + 'created_at' => $this->album->created_at, + 'length' => (float) $this->album->length, + 'play_count' => (int) $this->album->play_count, + 'song_count' => (int) $this->album->song_count, + ]; + } +} diff --git a/app/Http/Resources/ArtistResource.php b/app/Http/Resources/ArtistResource.php new file mode 100644 index 00000000..310d7f15 --- /dev/null +++ b/app/Http/Resources/ArtistResource.php @@ -0,0 +1,30 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'artists', + 'id' => $this->artist->id, + 'name' => $this->artist->name, + 'image' => $this->artist->image, + 'length' => (float) $this->artist->length, + 'play_count' => (int) $this->artist->play_count, + 'song_count' => (int) $this->artist->song_count, + 'album_count' => (int) $this->artist->album_count, + 'created_at' => $this->artist->created_at, + ]; + } +} diff --git a/app/Http/Resources/ExcerptSearchResource.php b/app/Http/Resources/ExcerptSearchResource.php new file mode 100644 index 00000000..f38d6e19 --- /dev/null +++ b/app/Http/Resources/ExcerptSearchResource.php @@ -0,0 +1,24 @@ + */ + public function toArray($request): array + { + return [ + 'songs' => SongResource::collection($this->result->songs), + 'artists' => ArtistResource::collection($this->result->artists), + 'albums' => AlbumResource::collection($this->result->albums), + ]; + } +} diff --git a/app/Http/Resources/InteractionResource.php b/app/Http/Resources/InteractionResource.php new file mode 100644 index 00000000..2caf515c --- /dev/null +++ b/app/Http/Resources/InteractionResource.php @@ -0,0 +1,28 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'interactions', + 'id' => $this->interaction->id, + 'songId' => $this->interaction->song_id, // @fixme backwards compatibility + 'song_id' => $this->interaction->song_id, + 'liked' => $this->interaction->liked, + 'playCount' => $this->interaction->play_count, // @fixme backwards compatibility + 'play_count' => $this->interaction->play_count, + ]; + } +} diff --git a/app/Http/Resources/SongResource.php b/app/Http/Resources/SongResource.php new file mode 100644 index 00000000..ffb8eb19 --- /dev/null +++ b/app/Http/Resources/SongResource.php @@ -0,0 +1,38 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'songs', + 'id' => $this->song->id, + 'title' => $this->song->title, + 'lyrics' => $this->song->lyrics, + 'album_id' => $this->song->album->id, + 'album_name' => $this->song->album->name, + 'artist_id' => $this->song->artist->id, + 'artist_name' => $this->song->artist->name, + 'album_artist_id' => $this->song->album->artist->id, + 'album_artist_name' => $this->song->album->artist->name, + 'album_cover' => $this->song->album->cover, + 'length' => $this->song->length, + 'liked' => (bool) $this->song->liked, + 'play_count' => (int) $this->song->play_count, + 'track' => $this->song->track, + 'disc' => $this->song->disc, + 'created_at' => $this->song->created_at, + ]; + } +} diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php new file mode 100644 index 00000000..a13a94e6 --- /dev/null +++ b/app/Http/Resources/UserResource.php @@ -0,0 +1,27 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'users', + 'id' => $this->user->id, + 'name' => $this->user->name, + 'email' => $this->user->email, + 'avatar' => $this->user->avatar, + 'is_admin' => $this->user->is_admin, + ]; + } +} diff --git a/app/Listeners/DeleteNonExistingRecordsPostSync.php b/app/Listeners/DeleteNonExistingRecordsPostSync.php index 203d412f..c33dc0cc 100644 --- a/app/Listeners/DeleteNonExistingRecordsPostSync.php +++ b/app/Listeners/DeleteNonExistingRecordsPostSync.php @@ -5,27 +5,22 @@ namespace App\Listeners; use App\Events\MediaSyncCompleted; use App\Models\Song; use App\Repositories\SongRepository; -use App\Services\Helper; +use App\Values\SyncResult; class DeleteNonExistingRecordsPostSync { - private SongRepository $songRepository; - private Helper $helper; - - public function __construct(SongRepository $songRepository, Helper $helper) + public function __construct(private SongRepository $songRepository) { - $this->songRepository = $songRepository; - $this->helper = $helper; } public function handle(MediaSyncCompleted $event): void { - $hashes = $event->result - ->validEntries() - ->map(fn (string $path): string => $this->helper->getFileHash($path)) - ->merge($this->songRepository->getAllHostedOnS3()->pluck('id')) + $paths = $event->results + ->valid() + ->map(static fn (SyncResult $result) => $result->path) + ->merge($this->songRepository->getAllHostedOnS3()->pluck('path')) ->toArray(); - Song::deleteWhereIDsNotIn($hashes); + Song::deleteWhereValueNotIn($paths, 'path'); } } diff --git a/app/Listeners/DownloadAlbumCover.php b/app/Listeners/DownloadAlbumCover.php deleted file mode 100644 index 92be6658..00000000 --- a/app/Listeners/DownloadAlbumCover.php +++ /dev/null @@ -1,33 +0,0 @@ -mediaMetadataService = $mediaMetadataService; - } - - public function handle(AlbumInformationFetched $event): void - { - $info = $event->getInformation(); - $album = $event->getAlbum(); - - $image = array_get($info, 'image'); - - // If our current album has no cover, and Last.fm has one, steal it? - if (!$album->has_cover && $image && ini_get('allow_url_fopen')) { - try { - $this->mediaMetadataService->downloadAlbumCover($album, $image); - } catch (Throwable $e) { - } - } - } -} diff --git a/app/Listeners/DownloadArtistImage.php b/app/Listeners/DownloadArtistImage.php deleted file mode 100644 index 831fab12..00000000 --- a/app/Listeners/DownloadArtistImage.php +++ /dev/null @@ -1,33 +0,0 @@ -mediaMetadataService = $mediaMetadataService; - } - - public function handle(ArtistInformationFetched $event): void - { - $info = $event->getInformation(); - $artist = $event->getArtist(); - - $image = array_get($info, 'image'); - - // If our artist has no image, and Last.fm has one, we steal it? - if (!$artist->has_image && $image && ini_get('allow_url_fopen')) { - try { - $this->mediaMetadataService->downloadArtistImage($artist, $image); - } catch (Throwable $e) { - } - } - } -} diff --git a/app/Listeners/LoveTrackOnLastfm.php b/app/Listeners/LoveTrackOnLastfm.php index 7a074fe6..628a03b8 100644 --- a/app/Listeners/LoveTrackOnLastfm.php +++ b/app/Listeners/LoveTrackOnLastfm.php @@ -9,18 +9,15 @@ use Illuminate\Contracts\Queue\ShouldQueue; class LoveTrackOnLastfm implements ShouldQueue { - private LastfmService $lastfm; - - public function __construct(LastfmService $lastfm) + public function __construct(private LastfmService $lastfm) { - $this->lastfm = $lastfm; } public function handle(SongLikeToggled $event): void { if ( !$this->lastfm->enabled() || - !$event->user->lastfm_session_key || + !$event->interaction->user->lastfm_session_key || $event->interaction->song->artist->is_unknown ) { return; @@ -28,7 +25,7 @@ class LoveTrackOnLastfm implements ShouldQueue $this->lastfm->toggleLoveTrack( LastfmLoveTrackParameters::make($event->interaction->song->title, $event->interaction->song->artist->name), - $event->user->lastfm_session_key, + $event->interaction->user->lastfm_session_key, $event->interaction->liked ); } diff --git a/app/Listeners/PruneLibrary.php b/app/Listeners/PruneLibrary.php index d81caf35..7bc05988 100644 --- a/app/Listeners/PruneLibrary.php +++ b/app/Listeners/PruneLibrary.php @@ -2,19 +2,16 @@ namespace App\Listeners; -use App\Services\MediaSyncService; +use App\Services\LibraryManager; class PruneLibrary { - private MediaSyncService $mediaSyncService; - - public function __construct(MediaSyncService $mediaSyncService) + public function __construct(private LibraryManager $libraryManager) { - $this->mediaSyncService = $mediaSyncService; } public function handle(): void { - $this->mediaSyncService->prune(); + $this->libraryManager->prune(); } } diff --git a/app/Models/Album.php b/app/Models/Album.php index f8c7017d..f527d186 100644 --- a/app/Models/Album.php +++ b/app/Models/Album.php @@ -2,12 +2,18 @@ namespace App\Models; +use Carbon\Carbon; +use Illuminate\Contracts\Database\Query\Builder as BuilderContract; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; use Laravel\Scout\Searchable; /** @@ -16,7 +22,6 @@ use Laravel\Scout\Searchable; * @property bool $has_cover If the album has a non-default cover image * @property int $id * @property string $name Name of the album - * @property bool $is_compilation If the album is a compilation from multiple artists * @property Artist $artist The album's artist * @property int $artist_id * @property Collection $songs @@ -25,6 +30,10 @@ use Laravel\Scout\Searchable; * @property string|null $thumbnail_path The full path to the thumbnail. * Notice that this doesn't guarantee the thumbnail exists. * @property string|null $thumbnail The public URL to the album's thumbnail + * @property Carbon $created_at + * @property float|string $length Total length of the album in seconds (dynamically calculated) + * @property int|string $play_count Total number of times the album's songs have been played (dynamically calculated) + * @property int|string $song_count Total number of songs on the album (dynamically calculated) * * @method static self firstOrCreate(array $where, array $params = []) * @method static self|null find(int $id) @@ -32,33 +41,30 @@ use Laravel\Scout\Searchable; * @method static self first() * @method static Builder whereArtistIdAndName(int $id, string $name) * @method static orderBy(...$params) + * @method static Builder latest() */ class Album extends Model { use HasFactory; use Searchable; - use SupportsDeleteWhereIDsNotIn; + use SupportsDeleteWhereValueNotIn; public const UNKNOWN_ID = 1; public const UNKNOWN_NAME = 'Unknown Album'; - public const UNKNOWN_COVER = 'unknown-album.png'; protected $guarded = ['id']; protected $hidden = ['updated_at']; protected $casts = ['artist_id' => 'integer']; + + /** @deprecated */ protected $appends = ['is_compilation']; /** * Get an album using some provided information. * If such is not found, a new album will be created using the information. */ - public static function getOrCreate(Artist $artist, ?string $name = null, ?bool $isCompilation = false): self + public static function getOrCreate(Artist $artist, ?string $name = null): self { - // If this is a compilation album, its artist must be "Various Artists" - if ($isCompilation) { - $artist = Artist::getVariousArtist(); - } - return static::firstOrCreate([ 'artist_id' => $artist->id, 'name' => trim($name) ?: self::UNKNOWN_NAME, @@ -75,76 +81,89 @@ class Album extends Model return $this->hasMany(Song::class); } - public function getIsUnknownAttribute(): bool + protected function isUnknown(): Attribute { - return $this->id === self::UNKNOWN_ID; + return Attribute::get(fn (): bool => $this->id === self::UNKNOWN_ID); } - public function setCoverAttribute(?string $value): void + protected function cover(): Attribute { - $this->attributes['cover'] = $value ?: self::UNKNOWN_COVER; + return Attribute::get(static fn (?string $value): ?string => album_cover_url($value)); } - public function getCoverAttribute(?string $value): string + protected function hasCover(): Attribute { - return album_cover_url($value ?: self::UNKNOWN_COVER); + return Attribute::get(fn (): bool => $this->cover_path && file_exists($this->cover_path)); } - public function getHasCoverAttribute(): bool + protected function coverPath(): Attribute { - $cover = array_get($this->attributes, 'cover'); + return Attribute::get(function () { + $cover = Arr::get($this->attributes, 'cover'); - if (!$cover) { - return false; - } - - if ($cover === self::UNKNOWN_COVER) { - return false; - } - - return file_exists(album_cover_path($cover)); - } - - public function getCoverPathAttribute(): ?string - { - $cover = array_get($this->attributes, 'cover'); - - return $cover ? album_cover_path($cover) : null; + return $cover ? album_cover_path($cover) : null; + }); } /** * Sometimes the tags extracted from getID3 are HTML entity encoded. * This makes sure they are always sane. */ - public function getNameAttribute(string $value): string + protected function name(): Attribute { - return html_entity_decode($value); + return Attribute::get(static fn (string $value) => html_entity_decode($value)); } - public function getIsCompilationAttribute(): bool + protected function thumbnailName(): Attribute { - return $this->artist_id === Artist::VARIOUS_ID; + return Attribute::get(function (): ?string { + if (!$this->has_cover) { + return null; + } + + $parts = pathinfo($this->cover_path); + + return sprintf('%s_thumb.%s', $parts['filename'], $parts['extension']); + }); } - public function getThumbnailNameAttribute(): ?string + protected function thumbnailPath(): Attribute { - if (!$this->has_cover) { - return null; - } - - $parts = pathinfo($this->cover_path); - - return sprintf('%s_thumb.%s', $parts['filename'], $parts['extension']); + return Attribute::get(fn () => $this->thumbnail_name ? album_cover_path($this->thumbnail_name) : null); } - public function getThumbnailPathAttribute(): ?string + protected function thumbnail(): Attribute { - return $this->thumbnail_name ? album_cover_path($this->thumbnail_name) : null; + return Attribute::get(fn () => $this->thumbnail_name ? album_cover_url($this->thumbnail_name) : null); } - public function getThumbnailAttribute(): ?string + /** @deprecated Only here for backward compat with mobile apps */ + protected function isCompilation(): Attribute { - return $this->thumbnail_name ? album_cover_url($this->thumbnail_name) : null; + return Attribute::get(fn () => $this->artist_id === Artist::VARIOUS_ID); + } + + public function scopeIsStandard(Builder $query): Builder + { + return $query->whereNot('albums.id', self::UNKNOWN_ID); + } + + public static function withMeta(User $scopedUser): BuilderContract + { + return static::query() + ->with('artist') + ->leftJoin('songs', 'albums.id', '=', 'songs.album_id') + ->leftJoin('interactions', static function (JoinClause $join) use ($scopedUser): void { + $join->on('songs.id', '=', 'interactions.song_id') + ->where('interactions.user_id', $scopedUser->id); + }) + ->groupBy('albums.id') + ->select( + 'albums.*', + DB::raw('CAST(SUM(interactions.play_count) AS INTEGER) AS play_count') + ) + ->withCount('songs AS song_count') + ->withSum('songs AS length', 'length'); } /** @return array */ diff --git a/app/Models/Artist.php b/app/Models/Artist.php index 6a4cadf3..87421d24 100644 --- a/app/Models/Artist.php +++ b/app/Models/Artist.php @@ -3,12 +3,17 @@ namespace App\Models; use App\Facades\Util; +use Carbon\Carbon; +use Illuminate\Contracts\Database\Query\Builder as BuilderContract; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Query\JoinClause; +use Illuminate\Support\Arr; +use Illuminate\Support\Facades\DB; use Laravel\Scout\Searchable; /** @@ -20,6 +25,11 @@ use Laravel\Scout\Searchable; * @property Collection $songs * @property bool $has_image If the artist has a (non-default) image * @property string|null $image_path Absolute path to the artist's image + * @property float|string $length Total length of the artist's songs in seconds (dynamically calculated) + * @property string|int $play_count Total number of times the artist has been played (dynamically calculated) + * @property string|int $song_count Total number of songs by the artist (dynamically calculated) + * @property string|int $album_count Total number of albums by the artist (dynamically calculated) + * @property Carbon $created_at * * @method static self find(int $id) * @method static self firstOrCreate(array $where, array $params = []) @@ -27,12 +37,13 @@ use Laravel\Scout\Searchable; * @method static self first() * @method static Builder whereName(string $name) * @method static Builder orderBy(...$params) + * @method static Builder join(...$params) */ class Artist extends Model { use HasFactory; use Searchable; - use SupportsDeleteWhereIDsNotIn; + use SupportsDeleteWhereValueNotIn; public const UNKNOWN_ID = 1; public const UNKNOWN_NAME = 'Unknown Artist'; @@ -42,11 +53,6 @@ class Artist extends Model protected $guarded = ['id']; protected $hidden = ['created_at', 'updated_at']; - public static function getVariousArtist(): self - { - return static::find(self::VARIOUS_ID); - } - /** * Get an Artist object from their name. * If such is not found, a new artist will be created. @@ -68,60 +74,69 @@ class Artist extends Model return $this->hasMany(Album::class); } - /** - * An artist can have many songs. - * Unless he is Rick Astley. - */ - public function songs(): HasManyThrough + public function songs(): HasMany { - return $this->hasManyThrough(Song::class, Album::class); + return $this->hasMany(Song::class); } - public function getIsUnknownAttribute(): bool + protected function isUnknown(): Attribute { - return $this->id === self::UNKNOWN_ID; + return Attribute::get(fn (): bool => $this->id === self::UNKNOWN_ID); } - public function getIsVariousAttribute(): bool + protected function isVarious(): Attribute { - return $this->id === self::VARIOUS_ID; + return Attribute::get(fn (): bool => $this->id === self::VARIOUS_ID); } /** * Sometimes the tags extracted from getID3 are HTML entity encoded. * This makes sure they are always sane. */ - public function getNameAttribute(string $value): string + protected function name(): Attribute { - return html_entity_decode($value ?: self::UNKNOWN_NAME); + return Attribute::get(static fn (string $value): string => html_entity_decode($value) ?: self::UNKNOWN_NAME); } /** * Turn the image name into its absolute URL. */ - public function getImageAttribute(?string $value): ?string + protected function image(): Attribute { - return $value ? artist_image_url($value) : null; + return Attribute::get(static fn (?string $value): ?string => artist_image_url($value)); } - public function getImagePathAttribute(): ?string + protected function imagePath(): Attribute { - if (!$this->has_image) { - return null; - } - - return artist_image_path(array_get($this->attributes, 'image')); + return Attribute::get(fn (): ?string => artist_image_path(Arr::get($this->attributes, 'image'))); } - public function getHasImageAttribute(): bool + protected function hasImage(): Attribute { - $image = array_get($this->attributes, 'image'); + return Attribute::get(function (): bool { + $image = Arr::get($this->attributes, 'image'); - if (!$image) { - return false; - } + return $image && file_exists(artist_image_path($image)); + }); + } - return file_exists(artist_image_path($image)); + public function scopeIsStandard(Builder $query): Builder + { + return $query->whereNotIn('artists.id', [self::UNKNOWN_ID, self::VARIOUS_ID]); + } + + public static function withMeta(User $scopedUser): BuilderContract + { + return static::query() + ->leftJoin('songs', 'artists.id', '=', 'songs.artist_id') + ->leftJoin('interactions', static function (JoinClause $join) use ($scopedUser): void { + $join->on('interactions.song_id', '=', 'songs.id') + ->where('interactions.user_id', $scopedUser->id); + }) + ->groupBy('artists.id') + ->select(['artists.*', DB::raw('CAST(SUM(interactions.play_count) AS INTEGER) AS play_count')]) + ->withCount('albums AS album_count', 'songs AS song_count') + ->withSum('songs AS length', 'length'); } /** @return array */ diff --git a/app/Models/Interaction.php b/app/Models/Interaction.php index 33f7d657..3331b5c4 100644 --- a/app/Models/Interaction.php +++ b/app/Models/Interaction.php @@ -13,11 +13,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property Song $song * @property User $user * @property int $id + * @property string $song_id * * @method static self firstOrCreate(array $where, array $params = []) * @method static self find(int $id) * @method static Builder whereSongIdAndUserId(string $songId, string $userId) * @method static Builder whereIn(...$params) + * @method static Builder where(...$params) */ class Interaction extends Model { diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 828b27d6..82a1413a 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Casts\SmartPlaylistRulesCast; use App\Values\SmartPlaylistRuleGroup; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -16,7 +17,8 @@ use Laravel\Scout\Searchable; * @property int $user_id * @property Collection|array $songs * @property int $id - * @property Collection|array $rule_groups + * @property Collection|array $rule_groups + * @property Collection|array $rules * @property bool $is_smart * @property string $name * @property user $user @@ -32,7 +34,6 @@ class Playlist extends Model protected $guarded = ['id']; protected $casts = [ - 'user_id' => 'int', 'rules' => SmartPlaylistRulesCast::class, ]; @@ -48,15 +49,15 @@ class Playlist extends Model return $this->belongsTo(User::class); } - public function getIsSmartAttribute(): bool + protected function isSmart(): Attribute { - return $this->rule_groups->isNotEmpty(); + return Attribute::get(fn (): bool => $this->rule_groups->isNotEmpty()); } - /** @return Collection|array */ - public function getRuleGroupsAttribute(): Collection + protected function ruleGroups(): Attribute { - return $this->rules; + // aliasing the attribute to avoid confusion + return Attribute::get(fn () => $this->rules); } /** @return array */ diff --git a/app/Models/Setting.php b/app/Models/Setting.php index 551b2904..2384ac8a 100644 --- a/app/Models/Setting.php +++ b/app/Models/Setting.php @@ -17,26 +17,24 @@ class Setting extends Model use HasFactory; protected $primaryKey = 'key'; + protected $keyType = 'string'; public $timestamps = false; protected $guarded = []; - /** - * Get a setting value. - */ - public static function get(string $key) // @phpcs:ignore - { - $record = self::find($key); + protected $casts = ['value' => 'json']; - return $record ? $record->value : null; + public static function get(string $key): mixed + { + return self::find($key)?->value; } /** * Set a setting (no pun) value. * - * @param string|array $key the key of the setting, or an associative array of settings, + * @param array|string $key the key of the setting, or an associative array of settings, * in which case $value will be discarded */ - public static function set($key, $value = null): void + public static function set(array|string $key, $value = ''): void { if (is_array($key)) { foreach ($key as $k => $v) { @@ -48,21 +46,4 @@ class Setting extends Model self::updateOrCreate(compact('key'), compact('value')); } - - /** - * Serialize the setting value before saving into the database. - * This makes settings more flexible. - */ - public function setValueAttribute($value): void - { - $this->attributes['value'] = serialize($value); - } - - /** - * Get the unserialized setting value. - */ - public function getValueAttribute($value) // @phpcs:ignore - { - return unserialize($value); - } } diff --git a/app/Models/Song.php b/app/Models/Song.php index 362be271..e8c2b996 100644 --- a/app/Models/Song.php +++ b/app/Models/Song.php @@ -2,14 +2,17 @@ namespace App\Models; -use App\Events\LibraryChanged; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Laravel\Scout\Searchable; /** @@ -25,7 +28,9 @@ use Laravel\Scout\Searchable; * @property string $id * @property int $artist_id * @property int $mtime - * @property int $contributing_artist_id + * @property ?bool $liked Whether the song is liked by the current user (dynamically calculated) + * @property ?int $play_count The number of times the song has been played by the current user (dynamically calculated) + * @property Carbon $created_at * * @method static self updateOrCreate(array $where, array $params) * @method static Builder select(string $string) @@ -35,23 +40,24 @@ use Laravel\Scout\Searchable; * @method static int count() * @method static self|Collection|null find($id) * @method static Builder take(int $count) + * @method static float|int sum(string $column) + * @method static Builder latest(string $column = 'created_at') + * @method static Builder where(...$params) + * @method static Song findOrFail(string $id) */ class Song extends Model { use HasFactory; use Searchable; - use SupportsDeleteWhereIDsNotIn; + use SupportsDeleteWhereValueNotIn; use SupportsS3; + public const ID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'; + public $incrementing = false; protected $guarded = []; - /** - * Attributes to be hidden from JSON outputs. - * Here we specify to hide lyrics as well to save some bandwidth (actually, lots of it). - * Lyrics can then be queried on demand. - */ - protected $hidden = ['lyrics', 'updated_at', 'path', 'mtime']; + protected $hidden = ['updated_at', 'path', 'mtime']; protected $casts = [ 'length' => 'float', @@ -62,107 +68,9 @@ class Song extends Model protected $keyType = 'string'; - /** - * Update song info. - * - * @param array $ids - * @param array $data the data array, with these supported fields: - * - title - * - artistName - * - albumName - * - lyrics - * All of these are optional, in which case the info will not be changed - * (except for lyrics, which will be emptied) - * - * @return Collection|array - */ - public static function updateInfo(array $ids, array $data): Collection + protected static function booted(): void { - /* - * A collection of the updated songs. - * - * @var Collection - */ - $updatedSongs = collect(); - - $ids = (array) $ids; - // If we're updating only one song, take into account the title, lyrics, and track number. - $single = count($ids) === 1; - - foreach ($ids as $id) { - /** @var Song|null $song */ - $song = self::with('album', 'album.artist')->find($id); - - if (!$song) { - continue; - } - - $updatedSongs->push($song->updateSingle( - $single ? trim($data['title']) : $song->title, - trim($data['albumName'] ?: $song->album->name), - trim($data['artistName']) ?: $song->artist->name, - $single ? trim($data['lyrics']) : $song->lyrics, - $single ? (int) $data['track'] : $song->track, - (int) $data['compilationState'] - )); - } - - // Our library may have been changed. Broadcast an event to tidy it up if need be. - if ($updatedSongs->count()) { - event(new LibraryChanged()); - } - - return $updatedSongs; - } - - public function updateSingle( - string $title, - string $albumName, - string $artistName, - string $lyrics, - int $track, - int $compilationState - ): self { - if ($artistName === Artist::VARIOUS_NAME) { - // If the artist name is "Various Artists", it's a compilation song no matter what. - $compilationState = 1; - // and since we can't determine the real contributing artist, it's "Unknown" - $artistName = Artist::UNKNOWN_NAME; - } - - $artist = Artist::getOrCreate($artistName); - - switch ($compilationState) { - case 1: // ALL, or forcing compilation status to be Yes - $isCompilation = true; - break; - - case 2: // Keep current compilation status - $isCompilation = $this->album->artist_id === Artist::VARIOUS_ID; - break; - - default: - $isCompilation = false; - break; - } - - $album = Album::getOrCreate($artist, $albumName, $isCompilation); - - $this->artist_id = $artist->id; - $this->album_id = $album->id; - $this->title = $title; - $this->lyrics = $lyrics; - $this->track = $track; - - $this->save(); - - // Clean up unnecessary data from the object - unset($this->album); - unset($this->artist); - // and make sure the lyrics is shown - $this->makeVisible('lyrics'); - - return $this; + static::creating(static fn (self $song) => $song->id = Str::uuid()->toString()); } public function artist(): BelongsTo @@ -196,33 +104,46 @@ class Song extends Model return $query->where('path', 'LIKE', "$path%"); } - /** - * Sometimes the tags extracted from getID3 are HTML entity encoded. - * This makes sure they are always sane. - */ - public function setTitleAttribute(string $value): void + protected function title(): Attribute { - $this->attributes['title'] = html_entity_decode($value); + return new Attribute( + get: fn (?string $value) => $value ?: pathinfo($this->path, PATHINFO_FILENAME), + set: static fn (string $value) => html_entity_decode($value) + ); } - /** - * Some songs don't have a title. - * Fall back to the file name (without extension) for such. - */ - public function getTitleAttribute(?string $value): string + protected function lyrics(): Attribute { - return $value ?: pathinfo($this->path, PATHINFO_FILENAME); + // Since we're displaying the lyrics using
, replace breaks with newlines and strip all tags.
+        $normalizer = static fn (?string $value): string => strip_tags(preg_replace('##i', PHP_EOL, $value));
+
+        return new Attribute(get: $normalizer, set: $normalizer);
     }
 
-    /**
-     * Prepare the lyrics for displaying.
-     */
-    public function getLyricsAttribute(string $value): string
+    public static function withMeta(User $scopedUser, ?Builder $query = null): Builder
     {
-        // We don't use nl2br() here, because the function actually preserves line breaks -
-        // it just _appends_ a "
" after each of them. This would cause our client - // implementation of br2nl to fail with duplicated line breaks. - return str_replace(["\r\n", "\r", "\n"], '
', $value); + $query ??= static::query(); + + return $query + ->with('artist', 'album', 'album.artist') + ->leftJoin('interactions', static function (JoinClause $join) use ($scopedUser): void { + $join->on('interactions.song_id', '=', 'songs.id') + ->where('interactions.user_id', $scopedUser->id); + }) + ->join('albums', 'songs.album_id', '=', 'albums.id') + ->join('artists', 'songs.artist_id', '=', 'artists.id') + ->select( + 'songs.*', + 'albums.name', + 'artists.name', + 'interactions.liked', + 'interactions.play_count' + ); + } + + public function scopeWithMeta(Builder $query, User $scopedUser): Builder + { + return static::withMeta($scopedUser, $query); } /** @return array */ diff --git a/app/Models/SupportsDeleteWhereIDsNotIn.php b/app/Models/SupportsDeleteWhereIDsNotIn.php deleted file mode 100644 index 561b611d..00000000 --- a/app/Models/SupportsDeleteWhereIDsNotIn.php +++ /dev/null @@ -1,67 +0,0 @@ -|array $ids the array of IDs - * @param string $key name of the primary key - */ - public static function deleteWhereIDsNotIn(array $ids, string $key = 'id'): void - { - $maxChunkSize = config('database.default') === 'sqlite-persistent' ? 999 : 65535; - - // If the number of entries is lower than, or equals to maxChunkSize, just go ahead. - if (count($ids) <= $maxChunkSize) { - static::whereNotIn($key, $ids)->delete(); - - return; - } - - // Otherwise, we get the actual IDs that should be deleted… - $allIDs = static::select($key)->get()->pluck($key)->all(); - $whereInIDs = array_diff($allIDs, $ids); - - // …and see if we can delete them instead. - if (count($whereInIDs) < $maxChunkSize) { - static::whereIn($key, $whereInIDs)->delete(); - - return; - } - - // If that's not possible (i.e. this array has more than maxChunkSize elements, too) - // then we'll delete chunk by chunk. - static::deleteByChunk($ids, $key, $maxChunkSize); - } - - /** - * Delete records chunk by chunk. - * - * @param array|array $ids The array of record IDs to delete - * @param string $key Name of the primary key - * @param int $chunkSize Size of each chunk. Defaults to 2^16-1 (65535) - */ - public static function deleteByChunk(array $ids, string $key = 'id', int $chunkSize = 65535): void - { - DB::transaction(static function () use ($ids, $key, $chunkSize): void { - foreach (array_chunk($ids, $chunkSize) as $chunk) { - static::whereIn($key, $chunk)->delete(); - } - }); - } -} diff --git a/app/Models/SupportsDeleteWhereValueNotIn.php b/app/Models/SupportsDeleteWhereValueNotIn.php new file mode 100644 index 00000000..f33390e5 --- /dev/null +++ b/app/Models/SupportsDeleteWhereValueNotIn.php @@ -0,0 +1,57 @@ +delete(); + + return; + } + + // Otherwise, we get the actual IDs that should be deleted… + $allIDs = static::select($field)->get()->pluck($field)->all(); + $whereInIDs = array_diff($allIDs, $values); + + // …and see if we can delete them instead. + if (count($whereInIDs) < $maxChunkSize) { + static::whereIn($field, $whereInIDs)->delete(); + + return; + } + + // If that's not possible (i.e. this array has more than maxChunkSize elements, too) + // then we'll delete chunk by chunk. + static::deleteByChunk($values, $field, $maxChunkSize); + } + + public static function deleteByChunk(array $values, string $field = 'id', int $chunkSize = 65535): void + { + DB::transaction(static function () use ($values, $field, $chunkSize): void { + foreach (array_chunk($values, $chunkSize) as $chunk) { + static::whereIn($field, $chunk)->delete(); + } + }); + } +} diff --git a/app/Models/SupportsS3.php b/app/Models/SupportsS3.php index 871dcdff..69bb4c31 100644 --- a/app/Models/SupportsS3.php +++ b/app/Models/SupportsS3.php @@ -3,28 +3,26 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; /** - * @property array $s3_params + * @property array|null $s3_params The bucket and key name of an S3 object. * * @method static Builder hostedOnS3() */ trait SupportsS3 { - /** - * Get the bucket and key name of an S3 object. - * - * @return array|null - */ - public function getS3ParamsAttribute(): ?array + protected function s3Params(): Attribute { - if (!preg_match('/^s3:\\/\\/(.*)/', $this->path, $matches)) { - return null; - } + return Attribute::get(function (): ?array { + if (!preg_match('/^s3:\\/\\/(.*)/', $this->path, $matches)) { + return null; + } - [$bucket, $key] = explode('/', $matches[1], 2); + [$bucket, $key] = explode('/', $matches[1], 2); - return compact('bucket', 'key'); + return compact('bucket', 'key'); + }); } public static function getPathFromS3BucketAndKey(string $bucket, string $key): string diff --git a/app/Models/User.php b/app/Models/User.php index 505d5075..fcc77844 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Casts\UserPreferencesCast; use App\Values\UserPreferences; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Query\Builder; @@ -15,14 +16,16 @@ use Laravel\Sanctum\HasApiTokens; * @property UserPreferences $preferences * @property int $id * @property bool $is_admin - * @property string $lastfm_session_key + * @property ?string $lastfm_session_key * @property string $name * @property string $email * @property string $password + * @property-read string $avatar * * @method static self create(array $params) * @method static int count() * @method static Builder where(...$params) + * @method static self|null firstWhere(...$params) */ class User extends Authenticatable { @@ -32,9 +35,9 @@ class User extends Authenticatable protected $guarded = ['id']; protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at']; + protected $appends = ['avatar']; protected $casts = [ - 'id' => 'int', 'is_admin' => 'bool', 'preferences' => UserPreferencesCast::class, ]; @@ -49,6 +52,21 @@ class User extends Authenticatable return $this->hasMany(Interaction::class); } + protected function avatar(): Attribute + { + return Attribute::get( + fn () => sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($this->email)) + ); + } + + /** + * Get the user's Last.fm session key. + */ + protected function lastfmSessionKey(): Attribute + { + return Attribute::get(fn (): ?string => $this->preferences->lastFmSessionKey); + } + /** * Determine if the user is connected to Last.fm. */ @@ -56,14 +74,4 @@ class User extends Authenticatable { return (bool) $this->lastfm_session_key; } - - /** - * Get the user's Last.fm session key. - * - * @return string|null The key if found, or null if user isn't connected to Last.fm - */ - public function getLastfmSessionKeyAttribute(): ?string - { - return $this->preferences->lastFmSessionKey; - } } diff --git a/app/Observers/SongObserver.php b/app/Observers/SongObserver.php deleted file mode 100644 index e12818e6..00000000 --- a/app/Observers/SongObserver.php +++ /dev/null @@ -1,26 +0,0 @@ -helper = $helper; - } - - public function creating(Song $song): void - { - $this->setFileHashAsId($song); - } - - private function setFileHashAsId(Song $song): void - { - $song->id = $this->helper->getFileHash($song->path); - } -} diff --git a/app/Policies/PlaylistPolicy.php b/app/Policies/PlaylistPolicy.php index 840d5ef8..085d99f2 100644 --- a/app/Policies/PlaylistPolicy.php +++ b/app/Policies/PlaylistPolicy.php @@ -9,6 +9,6 @@ class PlaylistPolicy { public function owner(User $user, Playlist $playlist): bool { - return $user->id === $playlist->user_id; + return $playlist->user->is($user); } } diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php index 3cab68d7..192f0aa4 100644 --- a/app/Policies/UserPolicy.php +++ b/app/Policies/UserPolicy.php @@ -6,8 +6,13 @@ use App\Models\User; class UserPolicy { + public function admin(User $currentUser): bool + { + return $currentUser->is_admin; + } + public function destroy(User $currentUser, User $userToDestroy): bool { - return $currentUser->is_admin && $currentUser->id !== $userToDestroy->id; + return $currentUser->is_admin && $currentUser->isNot($userToDestroy); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0754f532..aa0c727d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,14 @@ namespace App\Providers; +use App\Services\SpotifyService; use Illuminate\Database\DatabaseManager; use Illuminate\Database\Schema\Builder; use Illuminate\Database\SQLiteConnection; +use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Factory as Validator; -use Laravel\Tinker\TinkerServiceProvider; +use SpotifyWebAPI\Session as SpotifySession; class AppServiceProvider extends ServiceProvider { @@ -26,6 +28,15 @@ class AppServiceProvider extends ServiceProvider // Add some custom validation rules $validator->extend('path.valid', static fn ($attribute, $value): bool => is_dir($value) && is_readable($value)); + + // disable wrapping JSON resource in a `data` key + JsonResource::withoutWrapping(); + + $this->app->bind(SpotifySession::class, static function () { + return SpotifyService::enabled() + ? new SpotifySession(config('koel.spotify.client_id'), config('koel.spotify.client_secret')) + : null; + }); } /** @@ -33,8 +44,8 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - if ($this->app->environment() !== 'production') { - $this->app->register(TinkerServiceProvider::class); + if ($this->app->environment() !== 'production' && class_exists('Laravel\Tinker\TinkerServiceProvider')) { + $this->app->register('Laravel\Tinker\TinkerServiceProvider'); } } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 5f9678cb..086d6ce9 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -14,11 +14,6 @@ use Illuminate\Validation\Rules\Password; class AuthServiceProvider extends ServiceProvider { - /** - * The policy mappings for the application. - * - * @var array - */ protected $policies = [ Playlist::class => PlaylistPolicy::class, User::class => UserPolicy::class, diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 0d38af02..b5569871 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -2,10 +2,7 @@ namespace App\Providers; -use App\Events\AlbumInformationFetched; -use App\Events\ArtistInformationFetched; use App\Events\LibraryChanged; -use App\Events\MediaCacheObsolete; use App\Events\MediaSyncCompleted; use App\Events\SongLikeToggled; use App\Events\SongsBatchLiked; @@ -13,20 +10,16 @@ use App\Events\SongsBatchUnliked; use App\Events\SongStartedPlaying; use App\Listeners\ClearMediaCache; use App\Listeners\DeleteNonExistingRecordsPostSync; -use App\Listeners\DownloadAlbumCover; -use App\Listeners\DownloadArtistImage; use App\Listeners\LoveMultipleTracksOnLastfm; use App\Listeners\LoveTrackOnLastfm; use App\Listeners\PruneLibrary; use App\Listeners\UnloveMultipleTracksOnLastfm; use App\Listeners\UpdateLastfmNowPlaying; use App\Models\Album; -use App\Models\Song; use App\Observers\AlbumObserver; -use App\Observers\SongObserver; -use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; +use Illuminate\Foundation\Support\Providers\EventServiceProvider as BaseServiceProvider; -class EventServiceProvider extends ServiceProvider +class EventServiceProvider extends BaseServiceProvider { protected $listen = [ SongLikeToggled::class => [ @@ -50,18 +43,6 @@ class EventServiceProvider extends ServiceProvider ClearMediaCache::class, ], - MediaCacheObsolete::class => [ - ClearMediaCache::class, - ], - - AlbumInformationFetched::class => [ - DownloadAlbumCover::class, - ], - - ArtistInformationFetched::class => [ - DownloadArtistImage::class, - ], - MediaSyncCompleted::class => [ DeleteNonExistingRecordsPostSync::class, ], @@ -71,7 +52,6 @@ class EventServiceProvider extends ServiceProvider { parent::boot(); - Song::observe(SongObserver::class); Album::observe(AlbumObserver::class); } } diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index c311d011..27b04974 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; +use Webmozart\Assert\Assert; class RouteServiceProvider extends ServiceProvider { @@ -11,17 +12,35 @@ class RouteServiceProvider extends ServiceProvider public function map(): void { - $this->mapApiRoutes(); - $this->mapWebRoutes(); + self::loadVersionAwareRoutes('web'); + self::loadVersionAwareRoutes('api'); } - protected function mapWebRoutes(): void + private static function loadVersionAwareRoutes(string $type): void { - Route::middleware('web')->group(base_path('routes/web.php')); + Assert::oneOf($type, ['web', 'api']); + + Route::group([], base_path(sprintf('routes/%s.base.php', $type))); + + $apiVersion = self::getApiVersion(); + $routeFile = $apiVersion ? base_path(sprintf('routes/%s.%s.php', $type, $apiVersion)) : null; + + if ($routeFile && file_exists($routeFile)) { + Route::group([], $routeFile); + } } - protected function mapApiRoutes(): void + private static function getApiVersion(): ?string { - Route::prefix('api')->middleware('api')->group(base_path('routes/api.php')); + // In the test environment, the route service provider is loaded _before_ the request is made, + // so we can't rely on the header. + // Instead, we manually set the API version as an env variable in applicable test cases. + $version = app()->runningUnitTests() ? env('X_API_VERSION') : request()->header('X-Api-Version'); + + if ($version) { + Assert::oneOf($version, ['v6']); + } + + return $version; } } diff --git a/app/Providers/StreamerServiceProvider.php b/app/Providers/StreamerServiceProvider.php index 112e463c..9ab1a072 100644 --- a/app/Providers/StreamerServiceProvider.php +++ b/app/Providers/StreamerServiceProvider.php @@ -17,16 +17,11 @@ class StreamerServiceProvider extends ServiceProvider public function register(): void { $this->app->bind(DirectStreamerInterface::class, static function (): DirectStreamerInterface { - switch (config('koel.streaming.method')) { - case 'x-sendfile': - return new XSendFileStreamer(); - - case 'x-accel-redirect': - return new XAccelRedirectStreamer(); - - default: - return new PhpStreamer(); - } + return match (config('koel.streaming.method')) { + 'x-sendfile' => new XSendFileStreamer(), + 'x-accel-redirect' => new XAccelRedirectStreamer(), + default => new PhpStreamer(), + }; }); $this->app->bind(TranscodingStreamerInterface::class, TranscodingStreamer::class); diff --git a/app/Repositories/AlbumRepository.php b/app/Repositories/AlbumRepository.php index dfd4dfce..0f817a9c 100644 --- a/app/Repositories/AlbumRepository.php +++ b/app/Repositories/AlbumRepository.php @@ -2,20 +2,49 @@ namespace App\Repositories; -use App\Models\Song; +use App\Models\Album; +use App\Models\User; use App\Repositories\Traits\Searchable; +use Illuminate\Support\Collection; -class AlbumRepository extends AbstractRepository +class AlbumRepository extends Repository { use Searchable; - /** @return array */ - public function getNonEmptyAlbumIds(): array + public function getOne(int $id, ?User $scopedUser = null): Album { - return Song::select('album_id') - ->groupBy('album_id') - ->get() - ->pluck('album_id') - ->toArray(); + return Album::withMeta($scopedUser ?? $this->auth->user()) + ->where('albums.id', $id) + ->first(); + } + + /** @return Collection|array */ + public function getRecentlyAdded(int $count = 6, ?User $scopedUser = null): Collection + { + return Album::withMeta($scopedUser ?? $this->auth->user()) + ->isStandard() + ->latest('albums.created_at') + ->limit($count) + ->get(); + } + + /** @return Collection|array */ + public function getMostPlayed(int $count = 6, ?User $scopedUser = null): Collection + { + $scopedUser ??= $this->auth->user(); + + return Album::withMeta($scopedUser ?? $this->auth->user()) + ->isStandard() + ->orderByDesc('play_count') + ->limit($count) + ->get(); + } + + /** @return Collection|array */ + public function getByIds(array $ids, ?User $scopedUser = null): Collection + { + return Album::withMeta($scopedUser ?? $this->auth->user()) + ->whereIn('albums.id', $ids) + ->get(); } } diff --git a/app/Repositories/ArtistRepository.php b/app/Repositories/ArtistRepository.php index 6e74c3e7..08acd661 100644 --- a/app/Repositories/ArtistRepository.php +++ b/app/Repositories/ArtistRepository.php @@ -2,20 +2,38 @@ namespace App\Repositories; -use App\Models\Song; +use App\Models\Artist; +use App\Models\User; use App\Repositories\Traits\Searchable; +use Illuminate\Database\Eloquent\Collection; -class ArtistRepository extends AbstractRepository +class ArtistRepository extends Repository { use Searchable; - /** @return array */ - public function getNonEmptyArtistIds(): array + /** @return Collection|array */ + public function getMostPlayed(int $count = 6, ?User $scopedUser = null): Collection { - return Song::select('artist_id') - ->groupBy('artist_id') - ->get() - ->pluck('artist_id') - ->toArray(); + return Artist::withMeta($scopedUser ?? $this->auth->user()) + ->isStandard() + ->orderByDesc('play_count') + ->limit($count) + ->get(); + } + + public function getOne(int $id, ?User $scopedUser = null): Artist + { + return Artist::withMeta($scopedUser ?? $this->auth->user()) + ->where('artists.id', $id) + ->first(); + } + + /** @return Collection|array */ + public function getByIds(array $ids, ?User $scopedUser = null): Collection + { + return Artist::withMeta($scopedUser ?? $this->auth->user()) + ->isStandard() + ->whereIn('artists.id', $ids) + ->get(); } } diff --git a/app/Repositories/InteractionRepository.php b/app/Repositories/InteractionRepository.php index ba1ec0d2..07d46dc1 100644 --- a/app/Repositories/InteractionRepository.php +++ b/app/Repositories/InteractionRepository.php @@ -8,7 +8,7 @@ use App\Repositories\Traits\ByCurrentUser; use Illuminate\Database\Query\Builder; use Illuminate\Support\Collection; -class InteractionRepository extends AbstractRepository +class InteractionRepository extends Repository { use ByCurrentUser; diff --git a/app/Repositories/PlaylistRepository.php b/app/Repositories/PlaylistRepository.php index 79f000c6..538205dc 100644 --- a/app/Repositories/PlaylistRepository.php +++ b/app/Repositories/PlaylistRepository.php @@ -6,7 +6,7 @@ use App\Models\Playlist; use App\Repositories\Traits\ByCurrentUser; use Illuminate\Support\Collection; -class PlaylistRepository extends AbstractRepository +class PlaylistRepository extends Repository { use ByCurrentUser; diff --git a/app/Repositories/AbstractRepository.php b/app/Repositories/Repository.php similarity index 90% rename from app/Repositories/AbstractRepository.php rename to app/Repositories/Repository.php index 0c073b69..1faaa30c 100644 --- a/app/Repositories/AbstractRepository.php +++ b/app/Repositories/Repository.php @@ -3,11 +3,11 @@ namespace App\Repositories; use Illuminate\Contracts\Auth\Guard; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; use Throwable; -abstract class AbstractRepository implements RepositoryInterface +abstract class Repository implements RepositoryInterface { private string $modelClass; protected Model $model; @@ -22,7 +22,7 @@ abstract class AbstractRepository implements RepositoryInterface // rendering the whole installation failing. try { $this->auth = app(Guard::class); - } catch (Throwable $e) { + } catch (Throwable) { } } diff --git a/app/Repositories/RepositoryInterface.php b/app/Repositories/RepositoryInterface.php index c0b1ec38..ffb9a68b 100644 --- a/app/Repositories/RepositoryInterface.php +++ b/app/Repositories/RepositoryInterface.php @@ -2,8 +2,8 @@ namespace App\Repositories; -use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Collection; interface RepositoryInterface { diff --git a/app/Repositories/SettingRepository.php b/app/Repositories/SettingRepository.php index 2666b3ae..0ba93e14 100644 --- a/app/Repositories/SettingRepository.php +++ b/app/Repositories/SettingRepository.php @@ -2,11 +2,18 @@ namespace App\Repositories; -class SettingRepository extends AbstractRepository +use App\Models\Setting; + +class SettingRepository extends Repository { /** @return array */ public function getAllAsKeyValueArray(): array { - return $this->model->pluck('value', 'key')->all(); + return $this->model->pluck('value', 'key')->toArray(); + } + + public function getByKey(string $key): mixed + { + return Setting::get($key); } } diff --git a/app/Repositories/SongRepository.php b/app/Repositories/SongRepository.php index 07feba23..a2be86d4 100644 --- a/app/Repositories/SongRepository.php +++ b/app/Repositories/SongRepository.php @@ -2,27 +2,36 @@ namespace App\Repositories; +use App\Models\Album; +use App\Models\Artist; +use App\Models\Playlist; use App\Models\Song; +use App\Models\User; use App\Repositories\Traits\Searchable; -use App\Services\Helper; +use Illuminate\Contracts\Database\Query\Builder; +use Illuminate\Contracts\Pagination\Paginator; use Illuminate\Support\Collection; +use Webmozart\Assert\Assert; -class SongRepository extends AbstractRepository +class SongRepository extends Repository { use Searchable; - private Helper $helper; + public const SORT_COLUMNS_NORMALIZE_MAP = [ + 'title' => 'songs.title', + 'track' => 'songs.track', + 'length' => 'songs.length', + 'disc' => 'songs.disc', + 'artist_name' => 'artists.name', + 'album_name' => 'albums.name', + ]; - public function __construct(Helper $helper) - { - parent::__construct(); - - $this->helper = $helper; - } + private const VALID_SORT_COLUMNS = ['songs.title', 'songs.track', 'songs.length', 'artists.name', 'albums.name']; + private const DEFAULT_QUEUE_LIMIT = 500; public function getOneByPath(string $path): ?Song { - return $this->getOneById($this->helper->getFileHash($path)); + return Song::where('path', $path)->first(); } /** @return Collection|array */ @@ -30,4 +39,165 @@ class SongRepository extends AbstractRepository { return Song::hostedOnS3()->get(); } + + /** @return Collection|array */ + public function getRecentlyAdded(int $count = 10, ?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user())->latest()->limit($count)->get(); + } + + /** @return Collection|array */ + public function getMostPlayed(int $count = 7, ?User $scopedUser = null): Collection + { + $scopedUser ??= $this->auth->user(); + + return Song::withMeta($scopedUser) + ->where('interactions.play_count', '>', 0) + ->orderByDesc('interactions.play_count') + ->limit($count) + ->get(); + } + + /** @return Collection|array */ + public function getRecentlyPlayed(int $count = 7, ?User $scopedUser = null): Collection + { + $scopedUser ??= $this->auth->user(); + + return Song::withMeta($scopedUser) + ->where('interactions.play_count', '>', 0) + ->orderByDesc('interactions.updated_at') + ->limit($count) + ->get(); + } + + public function getForListing( + string $sortColumn, + string $sortDirection, + ?User $scopedUser = null, + int $perPage = 50 + ): Paginator { + return self::applySort( + Song::withMeta($scopedUser ?? $this->auth->user()), + $sortColumn, + $sortDirection + ) + ->simplePaginate($perPage); + } + + /** @return Collection|array */ + public function getForQueue( + string $sortColumn, + string $sortDirection, + int $limit = self::DEFAULT_QUEUE_LIMIT, + ?User $scopedUser = null, + ): Collection { + return self::applySort( + Song::withMeta($scopedUser ?? $this->auth->user()), + $sortColumn, + $sortDirection + ) + ->limit($limit) + ->get(); + } + + /** @return Collection|array */ + public function getFavorites(?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user())->where('interactions.liked', true)->get(); + } + + /** @return Collection|array */ + public function getByAlbum(Album $album, ?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user()) + ->where('album_id', $album->id) + ->orderBy('songs.track') + ->orderBy('songs.disc') + ->orderBy('songs.title') + ->get(); + } + + /** @return Collection|array */ + public function getByArtist(Artist $artist, ?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user()) + ->where('songs.artist_id', $artist->id) + ->orderBy('albums.name') + ->orderBy('songs.track') + ->orderBy('songs.disc') + ->orderBy('songs.title') + ->get(); + } + + /** @return Collection|array */ + public function getByStandardPlaylist(Playlist $playlist, ?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user()) + ->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id') + ->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id') + ->where('playlists.id', $playlist->id) + ->orderBy('songs.title') + ->get(); + } + + /** @return Collection|array */ + public function getRandom(int $limit, ?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user())->inRandomOrder()->limit($limit)->get(); + } + + /** @return Collection|array */ + public function getByIds(array $ids, ?User $scopedUser = null): Collection + { + return Song::withMeta($scopedUser ?? $this->auth->user())->whereIn('songs.id', $ids)->get(); + } + + public function getOne($id, ?User $scopedUser = null): Song + { + return Song::withMeta($scopedUser ?? $this->auth->user())->findOrFail($id); + } + + public function count(): int + { + return Song::count(); + } + + public function getTotalLength(): float + { + return Song::sum('length'); + } + + private static function normalizeSortColumn(string $column): string + { + return key_exists($column, self::SORT_COLUMNS_NORMALIZE_MAP) + ? self::SORT_COLUMNS_NORMALIZE_MAP[$column] + : $column; + } + + private static function applySort(Builder $query, string $column, string $direction): Builder + { + $column = self::normalizeSortColumn($column); + + Assert::oneOf($column, self::VALID_SORT_COLUMNS); + Assert::oneOf(strtolower($direction), ['asc', 'desc']); + + $query->orderBy($column, $direction); + + if ($column === 'artists.name') { + $query->orderBy('albums.name') + ->orderBy('songs.track') + ->orderBy('songs.disc') + ->orderBy('songs.title'); + } elseif ($column === 'albums.name') { + $query->orderBy('artists.name') + ->orderBy('songs.track') + ->orderBy('songs.disc') + ->orderBy('songs.title'); + } elseif ($column === 'track') { + $query->orderBy('songs.track') + ->orderBy('song.disc'); + } + + return $query; + } } diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php index f922cb43..0b1fc344 100644 --- a/app/Repositories/UserRepository.php +++ b/app/Repositories/UserRepository.php @@ -2,6 +2,6 @@ namespace App\Repositories; -class UserRepository extends AbstractRepository +class UserRepository extends Repository { } diff --git a/app/Rules/ImageData.php b/app/Rules/ImageData.php index 6e96da25..75005416 100644 --- a/app/Rules/ImageData.php +++ b/app/Rules/ImageData.php @@ -12,8 +12,8 @@ class ImageData implements Rule try { [$header,] = explode(';', $value); - return (bool) preg_match('/data:image\/(jpe?g|png|gif)/i', $header); - } catch (Throwable $exception) { + return (bool) preg_match('/data:image\/(jpe?g|png|webp|gif)/i', $header); + } catch (Throwable) { return false; } } diff --git a/app/Rules/ValidSmartPlaylistRulePayload.php b/app/Rules/ValidSmartPlaylistRulePayload.php index 0d01a32b..caff9e7a 100644 --- a/app/Rules/ValidSmartPlaylistRulePayload.php +++ b/app/Rules/ValidSmartPlaylistRulePayload.php @@ -19,7 +19,7 @@ class ValidSmartPlaylistRulePayload implements Rule } return true; - } catch (Throwable $e) { + } catch (Throwable) { return false; } } diff --git a/app/Services/AbstractApiClient.php b/app/Services/ApiClient.php similarity index 97% rename from app/Services/AbstractApiClient.php rename to app/Services/ApiClient.php index 08280e54..072cad01 100644 --- a/app/Services/AbstractApiClient.php +++ b/app/Services/ApiClient.php @@ -13,7 +13,7 @@ use SimpleXMLElement; use Webmozart\Assert\Assert; /** - * @method object get(string $uri, array $data = [], bool $appendKey = true) + * @method object|null get(string $uri, array $data = [], bool $appendKey = true) * @method object post($uri, array $data = [], bool $appendKey = true) * @method object put($uri, array $data = [], bool $appendKey = true) * @method object patch($uri, array $data = [], bool $appendKey = true) @@ -26,7 +26,7 @@ use Webmozart\Assert\Assert; * @method Promise headAsync($uri, array $data = [], bool $appendKey = true) * @method Promise deleteAsync($uri, array $data = [], bool $appendKey = true) */ -abstract class AbstractApiClient +abstract class ApiClient { private const MAGIC_METHODS = [ 'get', diff --git a/app/Services/ApplicationInformationService.php b/app/Services/ApplicationInformationService.php index 75067933..a9e53524 100644 --- a/app/Services/ApplicationInformationService.php +++ b/app/Services/ApplicationInformationService.php @@ -11,15 +11,8 @@ class ApplicationInformationService { private const CACHE_KEY = 'latestKoelVersion'; - private Client $client; - private Cache $cache; - private Logger $logger; - - public function __construct(Client $client, Cache $cache, Logger $logger) + public function __construct(private Client $client, private Cache $cache, private Logger $logger) { - $this->client = $client; - $this->cache = $cache; - $this->logger = $logger; } /** diff --git a/app/Services/DownloadService.php b/app/Services/DownloadService.php index 92fbb0fe..2341f88c 100644 --- a/app/Services/DownloadService.php +++ b/app/Services/DownloadService.php @@ -13,40 +13,33 @@ use InvalidArgumentException; class DownloadService { - private S3Service $s3Service; - - public function __construct(S3Service $s3Service) + public function __construct(private S3Service $s3Service) { - $this->s3Service = $s3Service; } /** * Generic method to generate a download archive from various source types. * - * @param Song|Collection|Album|Artist|Playlist $mixed - * - * @throws InvalidArgumentException - * * @return string Full path to the generated archive */ - public function from($mixed): string + public function from(Playlist|Song|Album|Artist|Collection $downloadable): string { - switch (get_class($mixed)) { + switch (get_class($downloadable)) { case Song::class: - return $this->fromSong($mixed); + return $this->fromSong($downloadable); case Collection::class: case EloquentCollection::class: - return $this->fromMultipleSongs($mixed); + return $this->fromMultipleSongs($downloadable); case Album::class: - return $this->fromAlbum($mixed); + return $this->fromAlbum($downloadable); case Artist::class: - return $this->fromArtist($mixed); + return $this->fromArtist($downloadable); case Playlist::class: - return $this->fromPlaylist($mixed); + return $this->fromPlaylist($downloadable); } throw new InvalidArgumentException('Unsupported download type.'); diff --git a/app/Services/FileSynchronizer.php b/app/Services/FileSynchronizer.php index 6d623e89..7ed65bb7 100644 --- a/app/Services/FileSynchronizer.php +++ b/app/Services/FileSynchronizer.php @@ -6,198 +6,92 @@ use App\Models\Album; use App\Models\Artist; use App\Models\Song; use App\Repositories\SongRepository; +use App\Values\SongScanInformation; +use App\Values\SyncResult; use getID3; -use getid3_lib; use Illuminate\Contracts\Cache\Repository as Cache; -use InvalidArgumentException; +use Illuminate\Support\Arr; use SplFileInfo; use Symfony\Component\Finder\Finder; use Throwable; class FileSynchronizer { - public const SYNC_RESULT_SUCCESS = 1; - public const SYNC_RESULT_BAD_FILE = 2; - public const SYNC_RESULT_UNMODIFIED = 3; - - private getID3 $getID3; - private MediaMetadataService $mediaMetadataService; - private Helper $helper; - private SongRepository $songRepository; - private Cache $cache; - private Finder $finder; private ?int $fileModifiedTime = null; private ?string $filePath = null; - /** - * A (MD5) hash of the file's path. - * This value is unique, and can be used to query a Song record. - */ - private ?string $fileHash = null; - /** * The song model that's associated with the current file. */ private ?Song $song; - private ?string $syncError; + private ?string $syncError = null; public function __construct( - getID3 $getID3, - MediaMetadataService $mediaMetadataService, - Helper $helper, - SongRepository $songRepository, - Cache $cache, - Finder $finder + private getID3 $getID3, + private MediaMetadataService $mediaMetadataService, + private SongRepository $songRepository, + private Cache $cache, + private Finder $finder ) { - $this->getID3 = $getID3; - $this->mediaMetadataService = $mediaMetadataService; - $this->helper = $helper; - $this->songRepository = $songRepository; - $this->cache = $cache; - $this->finder = $finder; } - /** @param string|SplFileInfo $path */ - public function setFile($path): self + public function setFile(string|SplFileInfo $path): self { - $splFileInfo = null; - $splFileInfo = $path instanceof SplFileInfo ? $path : new SplFileInfo($path); + $file = $path instanceof SplFileInfo ? $path : new SplFileInfo($path); - // Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows. - try { - $this->fileModifiedTime = $splFileInfo->getMTime(); - } catch (Throwable $e) { - // Not worth logging the error. Just use current stamp for mtime. - $this->fileModifiedTime = time(); - } - - $this->filePath = $splFileInfo->getPathname(); - $this->fileHash = $this->helper->getFileHash($this->filePath); - $this->song = $this->songRepository->getOneById($this->fileHash); // @phpstan-ignore-line - $this->syncError = null; + $this->filePath = $file->getRealPath(); + $this->song = $this->songRepository->getOneByPath($this->filePath); + $this->fileModifiedTime = Helper::getModifiedTime($file); return $this; } - /** - * Get all applicable info from the file. - * - * @return array - */ - public function getFileInfo(): array + public function getFileScanInformation(): ?SongScanInformation { $info = $this->getID3->analyze($this->filePath); + $this->syncError = Arr::get($info, 'error.0') ?: (Arr::get($info, 'playtime_seconds') ? null : 'Empty file'); - if (isset($info['error']) || !isset($info['playtime_seconds'])) { - $this->syncError = isset($info['error']) ? $info['error'][0] : 'No playtime found'; - - return []; - } - - // Copy the available tags over to comment. - // This is a helper from getID3, though it doesn't really work well. - // We'll still prefer getting ID3v2 tags directly later. - getid3_lib::CopyTagsToComments($info); - - $props = [ - 'artist' => '', - 'album' => '', - 'albumartist' => '', - 'compilation' => false, - 'title' => basename($this->filePath, '.' . pathinfo($this->filePath, PATHINFO_EXTENSION)), - 'length' => $info['playtime_seconds'], - 'track' => $this->getTrackNumberFromInfo($info), - 'disc' => (int) array_get($info, 'comments.part_of_a_set.0', 1), - 'lyrics' => '', - 'cover' => array_get($info, 'comments.picture', [null])[0], - 'path' => $this->filePath, - 'mtime' => $this->fileModifiedTime, - ]; - - $comments = array_get($info, 'comments_html'); - - if (!$comments) { - return $props; - } - - $this->gatherPropsFromTags($info, $comments, $props); - $props['compilation'] = (bool) $props['compilation'] || $this->isCompilation($props); - - return $props; + return $this->syncError ? null : SongScanInformation::fromGetId3Info($info); } /** - * Sync the song with all available media info against the database. + * Sync the song with all available media info into the database. * - * @param array $tags The (selective) tags to sync (if the song exists) + * @param array $ignores The tags to ignore/exclude (only taken into account if the song already exists) * @param bool $force Whether to force syncing, even if the file is unchanged */ - public function sync(array $tags, bool $force = false): int + public function sync(array $ignores = [], bool $force = false): SyncResult { if (!$this->isFileNewOrChanged() && !$force) { - return self::SYNC_RESULT_UNMODIFIED; + return SyncResult::skipped($this->filePath); } - $info = $this->getFileInfo(); + $info = $this->getFileScanInformation()?->toArray(); if (!$info) { - return self::SYNC_RESULT_BAD_FILE; + return SyncResult::error($this->filePath, $this->syncError); } - // Fixes #366. If the file is new, we use all tags by simply setting $force to false. - if ($this->isFileNew()) { - $force = false; + if (!$this->isFileNew()) { + Arr::forget($info, $ignores); } - if ($this->isFileChanged() || $force) { - // This is a changed file, or the user is forcing updates. - // In such a case, the user must have specified a list of tags to sync. - // A sample command could be: ./artisan koel:sync --force --tags=artist,album,lyrics - // We cater for these tags by removing those not specified. - - // There's a special case with 'album' though. - // If 'compilation' tag is specified, 'album' must be counted in as well. - // But if 'album' isn't specified, we don't want to update normal albums. - // This variable is to keep track of this state. - $changeCompilationAlbumOnly = false; - - if (in_array('compilation', $tags, true) && !in_array('album', $tags, true)) { - $tags[] = 'album'; - $changeCompilationAlbumOnly = true; - } - - $info = array_intersect_key($info, array_flip($tags)); - - // If the "artist" tag is specified, use it. - // Otherwise, re-use the existing model value. - $artist = isset($info['artist']) ? Artist::getOrCreate($info['artist']) : $this->song->album->artist; - - // If the "album" tag is specified, use it. - // Otherwise, re-use the existing model value. - if (isset($info['album'])) { - $album = $changeCompilationAlbumOnly - ? $this->song->album - : Album::getOrCreate($artist, $info['album'], array_get($info, 'compilation')); - } else { - $album = $this->song->album; - } - } else { - // The file is newly added. - $artist = Artist::getOrCreate($info['artist']); - $album = Album::getOrCreate($artist, $info['album'], array_get($info, 'compilation')); - } + $artist = Arr::get($info, 'artist') ? Artist::getOrCreate($info['artist']) : $this->song->artist; + $albumArtist = Arr::get($info, 'albumartist') ? Artist::getOrCreate($info['albumartist']) : $artist; + $album = Arr::get($info, 'album') ? Album::getOrCreate($albumArtist, $info['album']) : $this->song->album; if (!$album->has_cover) { - $this->generateAlbumCover($album, array_get($info, 'cover')); + $this->tryGenerateAlbumCover($album, Arr::get($info, 'cover', [])); } - $data = array_except($info, ['artist', 'albumartist', 'album', 'cover', 'compilation']); + $data = Arr::except($info, ['album', 'artist', 'albumartist', 'cover']); $data['album_id'] = $album->id; $data['artist_id'] = $artist->id; - $this->song = Song::updateOrCreate(['id' => $this->fileHash], $data); - return self::SYNC_RESULT_SUCCESS; + $this->song = Song::updateOrCreate(['path' => $this->filePath], $data); + + return SyncResult::success($this->filePath); } /** @@ -205,24 +99,27 @@ class FileSynchronizer * * @param array|null $coverData */ - private function generateAlbumCover(Album $album, ?array $coverData): void + private function tryGenerateAlbumCover(Album $album, ?array $coverData): void { - // If the album has no cover, we try to get the cover image from existing tag data - if ($coverData) { - $extension = explode('/', $coverData['image_mime']); - $extension = $extension[1] ?? 'png'; + try { + // If the album has no cover, we try to get the cover image from existing tag data + if ($coverData) { + $extension = explode('/', $coverData['image_mime']); + $extension = $extension[1] ?? 'png'; - $this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension); + $this->mediaMetadataService->writeAlbumCover($album, $coverData['data'], $extension); - return; - } + return; + } - // Or, if there's a cover image under the same directory, use it. - $cover = $this->getCoverFileUnderSameDirectory(); + // Or, if there's a cover image under the same directory, use it. + $cover = $this->getCoverFileUnderSameDirectory(); - if ($cover) { - $extension = pathinfo($cover, PATHINFO_EXTENSION); - $this->mediaMetadataService->writeAlbumCover($album, file_get_contents($cover), $extension); + if ($cover) { + $extension = pathinfo($cover, PATHINFO_EXTENSION); + $this->mediaMetadataService->writeAlbumCover($album, $cover, $extension); + } + } catch (Throwable) { } } @@ -230,8 +127,6 @@ class FileSynchronizer * Issue #380. * Some albums have its own cover image under the same directory as cover|folder.jpg/png. * We'll check if such a cover file is found, and use it if positive. - * - * @throws InvalidArgumentException */ private function getCoverFileUnderSameDirectory(): ?string { @@ -251,15 +146,15 @@ class FileSynchronizer $cover = $matches ? $matches[0] : null; - return $cover && $this->isImage($cover) ? $cover : null; + return $cover && self::isImage($cover) ? $cover : null; }); } - private function isImage(string $path): bool + private static function isImage(string $path): bool { try { return (bool) exif_imagetype($path); - } catch (Throwable $e) { + } catch (Throwable) { return false; } } @@ -285,65 +180,6 @@ class FileSynchronizer return $this->isFileNew() || $this->isFileChanged(); } - public function getSyncError(): ?string - { - return $this->syncError; - } - - private function getTrackNumberFromInfo(array $info): int - { - $track = 0; - - // Apparently track numbers can be stored with different indices as the following. - $trackIndices = [ - 'comments.track', - 'comments.tracknumber', - 'comments.track_number', - ]; - - for ($i = 0; $i < count($trackIndices) && $track === 0; ++$i) { - $track = (int) array_get($info, $trackIndices[$i], [0])[0]; - } - - return $track; - } - - private function gatherPropsFromTags(array $info, array $comments, array &$props): void - { - $propertyMap = [ - 'artist' => 'artist', - 'albumartist' => 'band', - 'album' => 'album', - 'title' => 'title', - 'lyrics' => ['unsychronised_lyric', 'unsynchronised_lyric'], - 'compilation' => 'part_of_a_compilation', - ]; - - foreach ($propertyMap as $name => $tags) { - foreach ((array) $tags as $tag) { - $value = array_get($info, "tags.id3v2.$tag", [null])[0] ?: array_get($comments, $tag, [''])[0]; - - if ($value) { - $props[$name] = $value; - } - } - - // Fixes #323, where tag names can be htmlentities()'ed - if (is_string($props[$name]) && $props[$name]) { - $props[$name] = trim(html_entity_decode($props[$name])); - } - } - } - - private function isCompilation(array $props): bool - { - // A "compilation" property can be determined by: - // - "part_of_a_compilation" tag (used by iTunes), or - // - "albumartist" (used by non-retarded applications). - // Also, the latter is only valid if the value is NOT the same as "artist". - return $props['albumartist'] && $props['artist'] !== $props['albumartist']; - } - public function getSong(): ?Song { return $this->song; diff --git a/app/Services/Helper.php b/app/Services/Helper.php index a6696e80..0d786ee0 100644 --- a/app/Services/Helper.php +++ b/app/Services/Helper.php @@ -2,14 +2,30 @@ namespace App\Services; +use SplFileInfo; +use Throwable; + class Helper { /** * Get a unique hash from a file path. * This hash can then be used as the Song record's ID. */ - public function getFileHash(string $path): string + public static function getFileHash(string $path): string { return md5(config('app.key') . $path); } + + public static function getModifiedTime(string|SplFileInfo $file): int + { + $file = is_string($file) ? new SplFileInfo($file) : $file; + + // Workaround for #344, where getMTime() fails for certain files with Unicode names on Windows. + try { + return $file->getMTime(); + } catch (Throwable) { + // Just use current stamp for mtime. + return time(); + } + } } diff --git a/app/Services/ITunesService.php b/app/Services/ITunesService.php index aa0a597f..80bb3bc4 100644 --- a/app/Services/ITunesService.php +++ b/app/Services/ITunesService.php @@ -4,7 +4,7 @@ namespace App\Services; use Throwable; -class ITunesService extends AbstractApiClient implements ApiConsumerInterface +class ITunesService extends ApiClient implements ApiConsumerInterface { /** * Determines whether to use iTunes services. diff --git a/app/Services/ImageWriter.php b/app/Services/ImageWriter.php index e64d3bc5..e28ab6c7 100644 --- a/app/Services/ImageWriter.php +++ b/app/Services/ImageWriter.php @@ -10,17 +10,14 @@ class ImageWriter private const DEFAULT_MAX_WIDTH = 500; private const DEFAULT_QUALITY = 80; - private ImageManager $imageManager; - - public function __construct(ImageManager $imageManager) + public function __construct(private ImageManager $imageManager) { - $this->imageManager = $imageManager; } - public function writeFromBinaryData(string $destination, string $data, array $config = []): void + public function write(string $destination, object|string $source, array $config = []): void { $img = $this->imageManager - ->make($data) + ->make($source) ->resize( $config['max_width'] ?? self::DEFAULT_MAX_WIDTH, null, diff --git a/app/Services/InteractionService.php b/app/Services/InteractionService.php index 3e63deef..100b6747 100644 --- a/app/Services/InteractionService.php +++ b/app/Services/InteractionService.php @@ -33,7 +33,7 @@ class InteractionService } /** - * Like or unlike a song on behalf of a user. + * Like or unlike a song as a user. * * @return Interaction the affected Interaction object */ diff --git a/app/Services/LastfmService.php b/app/Services/LastfmService.php index 2545a72f..10d5abe7 100644 --- a/app/Services/LastfmService.php +++ b/app/Services/LastfmService.php @@ -2,14 +2,18 @@ namespace App\Services; +use App\Models\Album; +use App\Models\Artist; use App\Models\User; +use App\Values\AlbumInformation; +use App\Values\ArtistInformation; use App\Values\LastfmLoveTrackParameters; use GuzzleHttp\Promise\Promise; use GuzzleHttp\Promise\Utils; use Illuminate\Support\Collection; use Throwable; -class LastfmService extends AbstractApiClient implements ApiConsumerInterface +class LastfmService extends ApiClient implements ApiConsumerInterface { /** * Override the key param, since, again, Last.fm wants to be different. @@ -32,27 +36,22 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface return $this->getKey() && $this->getSecret(); } - /** @return array|null */ - public function getArtistInformation(string $name): ?array + public function getArtistInformation(Artist $artist): ?ArtistInformation { if (!$this->enabled()) { return null; } - $name = urlencode($name); + $name = urlencode($artist->name); try { return $this->cache->remember( md5("lastfm_artist_$name"), now()->addWeek(), - function () use ($name): ?array { + function () use ($name): ?ArtistInformation { $response = $this->get("?method=artist.getInfo&autocorrect=1&artist=$name&format=json"); - if (!$response || !isset($response->artist)) { - return null; - } - - return $this->buildArtistInformation($response->artist); + return $response?->artist ? ArtistInformation::fromLastFmData($response->artist) : null; } ); } catch (Throwable $e) { @@ -62,34 +61,14 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface } } - /** - * Build a Koel-usable array of artist information using the data from Last.fm. - * - * @param mixed $data - * - * @return array - */ - private function buildArtistInformation($data): array - { - return [ - 'url' => $data->url, - 'image' => count($data->image) > 3 ? $data->image[3]->{'#text'} : $data->image[0]->{'#text'}, - 'bio' => [ - 'summary' => isset($data->bio) ? $this->formatText($data->bio->summary) : '', - 'full' => isset($data->bio) ? $this->formatText($data->bio->content) : '', - ], - ]; - } - - /** @return array|null */ - public function getAlbumInformation(string $albumName, string $artistName): ?array + public function getAlbumInformation(Album $album): ?AlbumInformation { if (!$this->enabled()) { return null; } - $albumName = urlencode($albumName); - $artistName = urlencode($artistName); + $albumName = urlencode($album->name); + $artistName = urlencode($album->artist->name); try { $cacheKey = md5("lastfm_album_{$albumName}_{$artistName}"); @@ -97,15 +76,11 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface return $this->cache->remember( $cacheKey, now()->addWeek(), - function () use ($albumName, $artistName): ?array { + function () use ($albumName, $artistName): ?AlbumInformation { $response = $this ->get("?method=album.getInfo&autocorrect=1&album=$albumName&artist=$artistName&format=json"); - if (!$response || !isset($response->album)) { - return null; - } - - return $this->buildAlbumInformation($response->album); + return $response?->album ? AlbumInformation::fromLastFmData($response->album) : null; } ); } catch (Throwable $e) { @@ -115,30 +90,6 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface } } - /** - * Build a Koel-usable array of album information using the data from Last.fm. - * - * @param mixed $data - * - * @return array - */ - private function buildAlbumInformation($data): array - { - return [ - 'url' => $data->url, - 'image' => count($data->image) > 3 ? $data->image[3]->{'#text'} : $data->image[0]->{'#text'}, - 'wiki' => [ - 'summary' => isset($data->wiki) ? $this->formatText($data->wiki->summary) : '', - 'full' => isset($data->wiki) ? $this->formatText($data->wiki->content) : '', - ], - 'tracks' => array_map(static fn ($track): array => [ - 'title' => $track->name, - 'length' => (int) $track->duration, - 'url' => $track->url, - ], isset($data->tracks) ? $data->tracks->track : []), - ]; - } - /** * Get Last.fm's session key for the authenticated user using a token. * @@ -192,8 +143,8 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface { try { $this->post('/', $this->buildAuthCallParams([ - 'track' => $params->getTrackName(), - 'artist' => $params->getArtistName(), + 'track' => $params->trackName, + 'artist' => $params->artistName, 'sk' => $sessionKey, 'method' => $love ? 'track.love' : 'track.unlove', ]), false); @@ -209,8 +160,8 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface { $promises = $parameterCollection->map( fn (LastfmLoveTrackParameters $params): Promise => $this->postAsync('/', $this->buildAuthCallParams([ - 'track' => $params->getTrackName(), - 'artist' => $params->getArtistName(), + 'track' => $params->trackName, + 'artist' => $params->artistName, 'sk' => $sessionKey, 'method' => $love ? 'track.love' : 'track.unlove', ]), false) @@ -223,14 +174,11 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface } } - /** - * @param int|float $duration Duration of the track, in seconds - */ public function updateNowPlaying( string $artistName, string $trackName, string $albumName, - $duration, + int|float $duration, string $sessionKey ): void { $params = [ @@ -265,7 +213,7 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface * * @return array|string */ - public function buildAuthCallParams(array $params, bool $toString = false) // @phpcs:ignore + public function buildAuthCallParams(array $params, bool $toString = false): array|string { $params['api_key'] = $this->getKey(); ksort($params); @@ -294,18 +242,6 @@ class LastfmService extends AbstractApiClient implements ApiConsumerInterface return rtrim($query, '&'); } - /** - * Correctly format a value returned by Last.fm. - */ - protected function formatText(?string $value): string - { - if (!$value) { - return ''; - } - - return trim(str_replace('Read more on Last.fm', '', nl2br(strip_tags(html_entity_decode($value))))); - } - public function getKey(): ?string { return config('koel.lastfm.key'); diff --git a/app/Services/LibraryManager.php b/app/Services/LibraryManager.php new file mode 100644 index 00000000..6ef87153 --- /dev/null +++ b/app/Services/LibraryManager.php @@ -0,0 +1,47 @@ +, + * artists: Collection, + * } + */ + public function prune(bool $dryRun = false): array + { + return DB::transaction(static function () use ($dryRun): array { + /** @var Builder $albumQuery */ + $albumQuery = Album::leftJoin('songs', 'songs.album_id', '=', 'albums.id') + ->whereNull('songs.album_id') + ->whereNotIn('albums.id', [Album::UNKNOWN_ID]); + + /** @var Builder $artistQuery */ + $artistQuery = Artist::leftJoin('songs', 'songs.artist_id', '=', 'artists.id') + ->leftJoin('albums', 'albums.artist_id', '=', 'artists.id') + ->whereNull('songs.artist_id') + ->whereNull('albums.artist_id') + ->whereNotIn('artists.id', [Artist::UNKNOWN_ID, Artist::VARIOUS_ID]); + + $results = [ + 'albums' => $albumQuery->get('albums.*'), + 'artists' => $artistQuery->get('artists.*'), + ]; + + if (!$dryRun) { + $albumQuery->delete(); + $artistQuery->delete(); + } + + return $results; + }); + } +} diff --git a/app/Services/MediaCacheService.php b/app/Services/MediaCacheService.php index 11aeb435..506a9163 100644 --- a/app/Services/MediaCacheService.php +++ b/app/Services/MediaCacheService.php @@ -11,11 +11,8 @@ class MediaCacheService { private const CACHE_KEY = 'media_cache'; - private Cache $cache; - - public function __construct(Cache $cache) + public function __construct(private Cache $cache) { - $this->cache = $cache; } /** diff --git a/app/Services/MediaInformationService.php b/app/Services/MediaInformationService.php index 0f924b27..2d3b8dbf 100644 --- a/app/Services/MediaInformationService.php +++ b/app/Services/MediaInformationService.php @@ -2,63 +2,53 @@ namespace App\Services; -use App\Events\AlbumInformationFetched; -use App\Events\ArtistInformationFetched; use App\Models\Album; use App\Models\Artist; +use App\Values\AlbumInformation; +use App\Values\ArtistInformation; +use Throwable; class MediaInformationService { - private LastfmService $lastfmService; - - public function __construct(LastfmService $lastfmService) - { - $this->lastfmService = $lastfmService; + public function __construct( + private LastfmService $lastfmService, + private MediaMetadataService $mediaMetadataService + ) { } - /** - * Get extra information about an album from Last.fm. - * - * @return array|null the album info in an array format, or null on failure - */ - public function getAlbumInformation(Album $album): ?array + public function getAlbumInformation(Album $album): ?AlbumInformation { if ($album->is_unknown) { return null; } - $info = $this->lastfmService->getAlbumInformation($album->name, $album->artist->name); + $info = $this->lastfmService->getAlbumInformation($album) ?: AlbumInformation::make(); - if ($info) { - event(new AlbumInformationFetched($album, $info)); - - // The album may have been updated. - $album->refresh(); - $info['cover'] = $album->cover; + if (!$album->has_cover) { + try { + $this->mediaMetadataService->tryDownloadAlbumCover($album); + $info->cover = $album->cover; + } catch (Throwable) { + } } return $info; } - /** - * Get extra information about an artist from Last.fm. - * - * @return array|null the artist info in an array format, or null on failure - */ - public function getArtistInformation(Artist $artist): ?array + public function getArtistInformation(Artist $artist): ?ArtistInformation { if ($artist->is_unknown) { return null; } - $info = $this->lastfmService->getArtistInformation($artist->name); + $info = $this->lastfmService->getArtistInformation($artist) ?: ArtistInformation::make(); - if ($info) { - event(new ArtistInformationFetched($artist, $info)); - - // The artist may have been updated. - $artist->refresh(); - $info['image'] = $artist->image; + if (!$artist->has_image) { + try { + $this->mediaMetadataService->tryDownloadArtistImage($artist); + $info->image = $artist->image; + } catch (Throwable) { + } } return $info; diff --git a/app/Services/MediaMetadataService.php b/app/Services/MediaMetadataService.php index 5bde724b..9d8009d8 100644 --- a/app/Services/MediaMetadataService.php +++ b/app/Services/MediaMetadataService.php @@ -4,42 +4,43 @@ namespace App\Services; use App\Models\Album; use App\Models\Artist; +use Illuminate\Support\Str; use Psr\Log\LoggerInterface; use Throwable; class MediaMetadataService { - private ImageWriter $imageWriter; - private LoggerInterface $logger; - - public function __construct(ImageWriter $imageWriter, LoggerInterface $logger) - { - $this->imageWriter = $imageWriter; - $this->logger = $logger; + public function __construct( + private SpotifyService $spotifyService, + private ImageWriter $imageWriter, + private LoggerInterface $logger + ) { } - public function downloadAlbumCover(Album $album, string $imageUrl): void + public function tryDownloadAlbumCover(Album $album): void { - $extension = explode('.', $imageUrl); - $this->writeAlbumCover($album, file_get_contents($imageUrl), last($extension)); + optional($this->spotifyService->tryGetAlbumCover($album), function (string $coverUrl) use ($album): void { + $this->writeAlbumCover($album, $coverUrl); + }); } /** - * Write an album cover image file with binary data and update the Album with the new cover attribute. + * Write an album cover image file and update the Album with the new cover attribute. * + * @param string $source Path, URL, or even binary data. See https://image.intervention.io/v2/api/make. * @param string $destination The destination path. Automatically generated if empty. */ public function writeAlbumCover( Album $album, - string $binaryData, - string $extension, - string $destination = '', + string $source, + string $extension = 'png', + ?string $destination = '', bool $cleanUp = true ): void { try { $extension = trim(strtolower($extension), '. '); $destination = $destination ?: $this->generateAlbumCoverPath($extension); - $this->imageWriter->writeFromBinaryData($destination, $binaryData); + $this->imageWriter->write($destination, $source); if ($cleanUp) { $this->deleteAlbumCoverFiles($album); @@ -52,28 +53,30 @@ class MediaMetadataService } } - public function downloadArtistImage(Artist $artist, string $imageUrl): void + public function tryDownloadArtistImage(Artist $artist): void { - $extension = explode('.', $imageUrl); - $this->writeArtistImage($artist, file_get_contents($imageUrl), last($extension)); + optional($this->spotifyService->tryGetArtistImage($artist), function (string $imageUrl) use ($artist): void { + $this->writeArtistImage($artist, $imageUrl); + }); } /** - * Write an artist image file with binary data and update the Artist with the new image attribute. + * Write an artist image file update the Artist with the new image attribute. * + * @param string $source Path, URL, or even binary data. See https://image.intervention.io/v2/api/make. * @param string $destination The destination path. Automatically generated if empty. */ public function writeArtistImage( Artist $artist, - string $binaryData, - string $extension, - string $destination = '', + string $source, + string $extension = 'png', + ?string $destination = '', bool $cleanUp = true ): void { try { $extension = trim(strtolower($extension), '. '); $destination = $destination ?: $this->generateArtistImagePath($extension); - $this->imageWriter->writeFromBinaryData($destination, $binaryData); + $this->imageWriter->write($destination, $source); if ($cleanUp && $artist->has_image) { @unlink($artist->image_path); @@ -85,24 +88,14 @@ class MediaMetadataService } } - /** - * Generate the absolute path for an album cover image. - * - * @param string $extension The extension of the cover (without dot) - */ private function generateAlbumCoverPath(string $extension): string { - return album_cover_path(sprintf('%s.%s', sha1(uniqid()), $extension)); + return album_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.'))); } - /** - * Generate the absolute path for an artist image. - * - * @param string $extension The extension of the cover (without dot) - */ - private function generateArtistImagePath($extension): string + private function generateArtistImagePath(string $extension): string { - return artist_image_path(sprintf('%s.%s', sha1(uniqid()), $extension)); + return artist_image_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.'))); } /** @@ -124,11 +117,7 @@ class MediaMetadataService private function createThumbnailForAlbum(Album $album): void { - $this->imageWriter->writeFromBinaryData( - $album->thumbnail_path, - file_get_contents($album->cover_path), - ['max_width' => 48, 'blur' => 10] - ); + $this->imageWriter->write($album->thumbnail_path, $album->cover_path, ['max_width' => 48, 'blur' => 10]); } private function deleteAlbumCoverFiles(Album $album): void diff --git a/app/Services/MediaSyncService.php b/app/Services/MediaSyncService.php index cc853f9f..9753a61c 100644 --- a/app/Services/MediaSyncService.php +++ b/app/Services/MediaSyncService.php @@ -2,122 +2,66 @@ namespace App\Services; -use App\Console\Commands\SyncCommand; use App\Events\LibraryChanged; use App\Events\MediaSyncCompleted; use App\Libraries\WatchRecord\WatchRecordInterface; -use App\Models\Album; -use App\Models\Artist; -use App\Models\Setting; use App\Models\Song; -use App\Repositories\AlbumRepository; -use App\Repositories\ArtistRepository; +use App\Repositories\SettingRepository; use App\Repositories\SongRepository; -use App\Values\SyncResult; +use App\Values\SyncResultCollection; use Psr\Log\LoggerInterface; use SplFileInfo; use Symfony\Component\Finder\Finder; class MediaSyncService { - /** - * All applicable tags in a media file that we cater for. - * Note that each isn't necessarily a valid ID3 tag name. - */ - public const APPLICABLE_TAGS = [ - 'artist', - 'album', - 'title', - 'length', - 'track', - 'disc', - 'lyrics', - 'cover', - 'mtime', - 'compilation', - ]; - - private SongRepository $songRepository; - private FileSynchronizer $fileSynchronizer; - private Finder $finder; - private ArtistRepository $artistRepository; - private AlbumRepository $albumRepository; - private LoggerInterface $logger; + /** @var array */ + private array $events = []; public function __construct( - SongRepository $songRepository, - ArtistRepository $artistRepository, - AlbumRepository $albumRepository, - FileSynchronizer $fileSynchronizer, - Finder $finder, - LoggerInterface $logger + private SettingRepository $settingRepository, + private SongRepository $songRepository, + private FileSynchronizer $fileSynchronizer, + private Finder $finder, + private LoggerInterface $logger ) { - $this->songRepository = $songRepository; - $this->fileSynchronizer = $fileSynchronizer; - $this->finder = $finder; - $this->artistRepository = $artistRepository; - $this->albumRepository = $albumRepository; - $this->logger = $logger; } /** - * Tags to be synced. - */ - protected array $tags = []; - - /** - * Sync the media. Oh sync the media. - * - * @param array $tags The tags to sync. + * @param array $ignores The tags to ignore. * Only taken into account for existing records. * New records will have all tags synced in regardless. * @param bool $force Whether to force syncing even unchanged files - * @param SyncCommand $syncCommand The SyncMedia command object, to log to console if executed by artisan */ - public function sync( - ?string $mediaPath = null, - array $tags = [], - bool $force = false, - ?SyncCommand $syncCommand = null - ): void { + public function sync(array $ignores = [], bool $force = false): SyncResultCollection + { + /** @var string $mediaPath */ + $mediaPath = $this->settingRepository->getByKey('media_path'); + $this->setSystemRequirements(); - $this->setTags($tags); - $syncResult = SyncResult::init(); + $results = SyncResultCollection::create(); + $songPaths = $this->gatherFiles($mediaPath); - $songPaths = $this->gatherFiles($mediaPath ?: Setting::get('media_path')); - - if ($syncCommand) { - $syncCommand->createProgressBar(count($songPaths)); + if (isset($this->events['paths-gathered'])) { + $this->events['paths-gathered']($songPaths); } foreach ($songPaths as $path) { - $result = $this->fileSynchronizer->setFile($path)->sync($this->tags, $force); + $result = $this->fileSynchronizer->setFile($path)->sync($ignores, $force); + $results->add($result); - switch ($result) { - case FileSynchronizer::SYNC_RESULT_SUCCESS: - $syncResult->success->add($path); - break; - - case FileSynchronizer::SYNC_RESULT_UNMODIFIED: - $syncResult->unmodified->add($path); - break; - - default: - $syncResult->bad->add($path); - break; - } - - if ($syncCommand) { - $syncCommand->advanceProgressBar(); - $syncCommand->logSyncStatusToConsole($path, $result, $this->fileSynchronizer->getSyncError()); + if (isset($this->events['progress'])) { + $this->events['progress']($result); } } - event(new MediaSyncCompleted($syncResult)); + event(new MediaSyncCompleted($results)); // Trigger LibraryChanged, so that PruneLibrary handler is fired to prune the lib. event(new LibraryChanged()); + + return $results; } /** @@ -132,7 +76,7 @@ class MediaSyncService return iterator_to_array( $this->finder->create() ->ignoreUnreadableDirs() - ->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/phanan/koel/issues/450 + ->ignoreDotFiles((bool) config('koel.ignore_dot_files')) // https://github.com/koel/koel/issues/450 ->files() ->followLinks() ->name('/\.(mp3|ogg|m4a|flac)$/i') @@ -170,35 +114,6 @@ class MediaSyncService } } - /** - * Construct an array of tags to be synced into the database from an input array of tags. - * If the input array is empty or contains only invalid items, we use all tags. - * Otherwise, we only use the valid items in it. - * - * @param array $tags - */ - public function setTags(array $tags = []): void - { - $this->tags = array_intersect($tags, self::APPLICABLE_TAGS) ?: self::APPLICABLE_TAGS; - - // We always keep track of mtime. - if (!in_array('mtime', $this->tags, true)) { - $this->tags[] = 'mtime'; - } - } - - public function prune(): void - { - $inUseAlbums = $this->albumRepository->getNonEmptyAlbumIds(); - $inUseAlbums[] = Album::UNKNOWN_ID; - Album::deleteWhereIDsNotIn($inUseAlbums); - - $inUseArtists = $this->artistRepository->getNonEmptyArtistIds(); - $inUseArtists[] = Artist::UNKNOWN_ID; - $inUseArtists[] = Artist::VARIOUS_ID; - Artist::deleteWhereIDsNotIn(array_filter($inUseArtists)); - } - private function setSystemRequirements(): void { if (!app()->runningInConsole()) { @@ -225,9 +140,9 @@ class MediaSyncService private function handleNewOrModifiedFileRecord(string $path): void { - $result = $this->fileSynchronizer->setFile($path)->sync($this->tags); + $result = $this->fileSynchronizer->setFile($path)->sync(); - if ($result === FileSynchronizer::SYNC_RESULT_SUCCESS) { + if ($result->isSuccess()) { $this->logger->info("Synchronized $path"); } else { $this->logger->info("Failed to synchronized $path. Maybe an invalid file?"); @@ -252,11 +167,16 @@ class MediaSyncService private function handleNewOrModifiedDirectoryRecord(string $path): void { foreach ($this->gatherFiles($path) as $file) { - $this->fileSynchronizer->setFile($file)->sync($this->tags); + $this->fileSynchronizer->setFile($file)->sync(); } $this->logger->info("Synced all song(s) under $path"); event(new LibraryChanged()); } + + public function on(string $event, callable $callback): void + { + $this->events[$event] = $callback; + } } diff --git a/app/Services/PlaylistService.php b/app/Services/PlaylistService.php index 6a7c5d98..d7eb09b1 100644 --- a/app/Services/PlaylistService.php +++ b/app/Services/PlaylistService.php @@ -21,4 +21,20 @@ class PlaylistService return $playlist; } + + public function addSongsToPlaylist(Playlist $playlist, array $songIds): void + { + $playlist->songs()->syncWithoutDetaching($songIds); + } + + public function removeSongsFromPlaylist(Playlist $playlist, array $songIds): void + { + $playlist->songs()->detach($songIds); + } + + /** @deprecated */ + public function populatePlaylist(Playlist $playlist, array $songIds): void + { + $playlist->songs()->sync($songIds); + } } diff --git a/app/Services/S3Service.php b/app/Services/S3Service.php index 5bb3770c..f1a697c8 100644 --- a/app/Services/S3Service.php +++ b/app/Services/S3Service.php @@ -13,24 +13,12 @@ use Illuminate\Cache\Repository as Cache; class S3Service implements ObjectStorageInterface { - private ?S3ClientInterface $s3Client; - private Cache $cache; - private MediaMetadataService $mediaMetadataService; - private SongRepository $songRepository; - private Helper $helper; - public function __construct( - ?S3ClientInterface $s3Client, - Cache $cache, - MediaMetadataService $mediaMetadataService, - SongRepository $songRepository, - Helper $helper + private ?S3ClientInterface $s3Client, + private Cache $cache, + private MediaMetadataService $mediaMetadataService, + private SongRepository $songRepository, ) { - $this->s3Client = $s3Client; - $this->cache = $cache; - $this->mediaMetadataService = $mediaMetadataService; - $this->songRepository = $songRepository; - $this->helper = $helper; } public function getSongPublicUrl(Song $song): string @@ -53,7 +41,7 @@ class S3Service implements ObjectStorageInterface string $key, string $artistName, string $albumName, - bool $compilation, + string $albumArtistName, ?array $cover, string $title, float $duration, @@ -63,7 +51,12 @@ class S3Service implements ObjectStorageInterface $path = Song::getPathFromS3BucketAndKey($bucket, $key); $artist = Artist::getOrCreate($artistName); - $album = Album::getOrCreate($artist, $albumName, $compilation); + + $albumArtist = $albumArtistName && $albumArtistName !== $artistName + ? Artist::getOrCreate($albumArtistName) + : $artist; + + $album = Album::getOrCreate($albumArtist, $albumName); if ($cover) { $this->mediaMetadataService->writeAlbumCover( @@ -73,7 +66,7 @@ class S3Service implements ObjectStorageInterface ); } - $song = Song::updateOrCreate(['id' => $this->helper->getFileHash($path)], [ + $song = Song::updateOrCreate(['id' => Helper::getFileHash($path)], [ 'path' => $path, 'album_id' => $album->id, 'artist_id' => $artist->id, @@ -94,9 +87,7 @@ class S3Service implements ObjectStorageInterface $path = Song::getPathFromS3BucketAndKey($bucket, $key); $song = $this->songRepository->getOneByPath($path); - if (!$song) { - throw SongPathNotFoundException::create($path); - } + throw_unless((bool) $song, SongPathNotFoundException::create($path)); $song->delete(); event(new LibraryChanged()); diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php index a7ab86de..58b321a5 100644 --- a/app/Services/SearchService.php +++ b/app/Services/SearchService.php @@ -16,18 +16,11 @@ class SearchService { public const DEFAULT_EXCERPT_RESULT_COUNT = 6; - private SongRepository $songRepository; - private AlbumRepository $albumRepository; - private ArtistRepository $artistRepository; - public function __construct( - SongRepository $songRepository, - AlbumRepository $albumRepository, - ArtistRepository $artistRepository + private SongRepository $songRepository, + private AlbumRepository $albumRepository, + private ArtistRepository $artistRepository ) { - $this->songRepository = $songRepository; - $this->albumRepository = $albumRepository; - $this->artistRepository = $artistRepository; } /** @return array */ @@ -55,6 +48,6 @@ class SearchService return $this->songRepository ->search($keywords) ->get() - ->map(static fn (Song $song): string => $song->id); + ->map(static fn (Song $song): string => $song->id); // @phpstan-ignore-line } } diff --git a/app/Services/SmartPlaylistService.php b/app/Services/SmartPlaylistService.php index 9aff4b5c..bd780e5c 100644 --- a/app/Services/SmartPlaylistService.php +++ b/app/Services/SmartPlaylistService.php @@ -3,117 +3,39 @@ namespace App\Services; use App\Exceptions\NonSmartPlaylistException; -use App\Factories\SmartPlaylistRuleParameterFactory; use App\Models\Playlist; use App\Models\Song; use App\Models\User; use App\Values\SmartPlaylistRule; use App\Values\SmartPlaylistRuleGroup; +use Illuminate\Contracts\Auth\Guard; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class SmartPlaylistService { - private const USER_REQUIRING_RULE_PREFIXES = ['interactions.']; - - private SmartPlaylistRuleParameterFactory $parameterFactory; - - public function __construct(SmartPlaylistRuleParameterFactory $parameterFactory) + public function __construct(private Guard $auth) { - $this->parameterFactory = $parameterFactory; } - /** @return Collection|array */ - public function getSongs(Playlist $playlist): Collection + /** @return Collection|array */ + public function getSongs(Playlist $playlist, ?User $user = null): Collection { throw_unless($playlist->is_smart, NonSmartPlaylistException::create($playlist)); - $ruleGroups = $this->addRequiresUserRules($playlist->rule_groups, $playlist->user); + $query = Song::withMeta($user ?? $this->auth->user()); - return $this->buildQueryFromRules($ruleGroups)->get(); - } + $playlist->rule_groups->each(static function (SmartPlaylistRuleGroup $group, int $index) use ($query): void { + $clause = $index === 0 ? 'where' : 'orWhere'; - public function buildQueryFromRules(Collection $ruleGroups): Builder - { - $query = Song::query(); - - $ruleGroups->each(function (SmartPlaylistRuleGroup $group) use ($query): void { - $query->orWhere(function (Builder $subQuery) use ($group): void { - $group->rules->each(function (SmartPlaylistRule $rule) use ($subQuery): void { - $this->buildQueryForRule($subQuery, $rule); + $query->$clause(static function (Builder $subQuery) use ($group): void { + $group->rules->each(static function (SmartPlaylistRule $rule) use ($subQuery): void { + $subWhere = $rule->operator === SmartPlaylistRule::OPERATOR_IS_BETWEEN ? 'whereBetween' : 'where'; + $subQuery->$subWhere(...$rule->toCriteriaParameters()); }); }); }); - return $query; - } - - /** - * Some rules need to be driven by an additional "user" factor, for example play count, liked, or last played - * (basically everything related to interactions). - * For those, we create an additional "user_id" rule. - * - * @return Collection|array - */ - public function addRequiresUserRules(Collection $ruleGroups, User $user): Collection - { - return $ruleGroups->map(function (SmartPlaylistRuleGroup $group) use ($user): SmartPlaylistRuleGroup { - $clonedGroup = clone $group; - $additionalRules = collect(); - - $group->rules->each(function (SmartPlaylistRule $rule) use ($additionalRules, $user): void { - foreach (self::USER_REQUIRING_RULE_PREFIXES as $modelPrefix) { - if (starts_with($rule->model, $modelPrefix)) { - $additionalRules->add($this->createRequiresUserRule($user, $modelPrefix)); - } - } - }); - - // Make sure all those additional rules are unique. - $clonedGroup->rules = $clonedGroup->rules->merge($additionalRules->unique('model')->collect()); - - return $clonedGroup; - }); - } - - private function createRequiresUserRule(User $user, string $modelPrefix): SmartPlaylistRule - { - return SmartPlaylistRule::create([ - 'model' => $modelPrefix . 'user_id', - 'operator' => 'is', - 'value' => [$user->id], - ]); - } - - public function buildQueryForRule(Builder $query, SmartPlaylistRule $rule, ?string $model = null): Builder - { - if (!$model) { - $model = $rule->model; - } - - $fragments = explode('.', $model, 2); - - if (count($fragments) === 1) { - return $query->{$this->resolveWhereLogic($rule)}( - ...$this->parameterFactory->createParameters($model, $rule->operator, $rule->value) - ); - } - - // If the model is something like 'artist.name' or 'interactions.play_count', we have a subquery to deal with. - // We handle such a case with a recursive call which, in theory, should work with an unlimited level of nesting, - // though in practice we only have one level max. - return $query->whereHas( - $fragments[0], - fn (Builder $subQuery) => $this->buildQueryForRule($subQuery, $rule, $fragments[1]) - ); - } - - /** - * Resolve the logic of a (sub)query base on the configured operator. - * Basically, if the operator is "between," we use "whereBetween". Otherwise, it's "where". Simple. - */ - private function resolveWhereLogic(SmartPlaylistRule $rule): string - { - return $rule->operator === SmartPlaylistRule::OPERATOR_IS_BETWEEN ? 'whereBetween' : 'where'; + return $query->orderBy('songs.title')->get(); } } diff --git a/app/Services/SongService.php b/app/Services/SongService.php new file mode 100644 index 00000000..27bfd04a --- /dev/null +++ b/app/Services/SongService.php @@ -0,0 +1,84 @@ + */ + public function updateSongs(array $songIds, SongUpdateData $data): Collection + { + $updatedSongs = collect(); + + DB::transaction(function () use ($songIds, $data, $updatedSongs): void { + foreach ($songIds as $id) { + /** @var Song|null $song */ + $song = Song::with('album', 'album.artist', 'artist')->find($id); + + if ($song) { + $updatedSongs->push($this->updateSong($song, $data)); + } + } + }); + + return $updatedSongs; + } + + private function updateSong(Song $song, SongUpdateData $data): Song + { + $maybeSetAlbumArtist = static function (Album $album) use ($data): void { + if ($data->albumArtistName && $data->albumArtistName !== $album->artist->name) { + $album->artist_id = Artist::getOrCreate($data->albumArtistName)->id; + $album->save(); + } + }; + + $maybeSetAlbum = static function () use ($data, $song, $maybeSetAlbumArtist): void { + if ($data->albumName) { + if ($data->albumName !== $song->album->name) { + $album = Album::getOrCreate($song->artist, $data->albumName); + $song->album_id = $album->id; + + $maybeSetAlbumArtist($album); + } + } + }; + + if ($data->artistName) { + if ($song->artist->name !== $data->artistName) { + $artist = Artist::getOrCreate($data->artistName); + $song->artist_id = $artist->id; + + // Artist changed means album must be changed too. + $album = Album::getOrCreate($artist, $data->albumName ?: $song->album->name); + $song->album_id = $album->id; + + $maybeSetAlbumArtist($album); + } else { + $maybeSetAlbum(); + } + } else { + $maybeSetAlbum(); + } + + $song->title = $data->title ?? $song->title; // Empty string still has effects + $song->lyrics = $data->lyrics ?? $song->lyrics; // Empty string still has effects + $song->track = $data->track ?: $song->track; + $song->disc = $data->disc ?: $song->disc; + + $song->push(); + + return $this->songRepository->getOne($song->id); + } +} diff --git a/app/Services/SpotifyClient.php b/app/Services/SpotifyClient.php new file mode 100644 index 00000000..45299f7c --- /dev/null +++ b/app/Services/SpotifyClient.php @@ -0,0 +1,55 @@ +wrapped->setOptions(['return_assoc' => true]); + + try { + $this->setAccessToken(); + } catch (Throwable $e) { + $this->log->error('Failed to set Spotify access token', ['exception' => $e]); + } + } + } + + private function setAccessToken(): void + { + $token = $this->cache->get('spotify.access_token'); + + if (!$token) { + $this->session->requestCredentialsToken(); + $token = $this->session->getAccessToken(); + + // Spotify's tokens expire after 1 hour, so we'll cache them with some buffer to an extra call. + $this->cache->put('spotify.access_token', $token, 59 * 60); + } + + $this->wrapped->setAccessToken($token); + } + + public function __call(string $name, array $arguments): mixed + { + throw_unless(SpotifyService::enabled(), SpotifyIntegrationDisabledException::create()); + + return $this->wrapped->$name(...$arguments); + } +} diff --git a/app/Services/SpotifyService.php b/app/Services/SpotifyService.php new file mode 100644 index 00000000..158f5ca7 --- /dev/null +++ b/app/Services/SpotifyService.php @@ -0,0 +1,51 @@ +is_various || $artist->is_unknown) { + return null; + } + + return Arr::get( + $this->client->search($artist->name, 'artist', ['limit' => 1]), + 'artists.items.0.images.0.url' + ); + } + + public function tryGetAlbumCover(Album $album): ?string + { + if (!static::enabled()) { + return null; + } + + if ($album->is_unknown || $album->artist->is_unknown || $album->artist->is_various) { + return null; + } + + return Arr::get( + $this->client->search("{$album->name} artist:{$album->artist->name}", 'album', ['limit' => 1]), + 'albums.items.0.images.0.url' + ); + } +} diff --git a/app/Services/Streamers/S3Streamer.php b/app/Services/Streamers/S3Streamer.php index 4872b0ba..89f92b79 100644 --- a/app/Services/Streamers/S3Streamer.php +++ b/app/Services/Streamers/S3Streamer.php @@ -8,13 +8,9 @@ use Illuminate\Routing\Redirector; class S3Streamer extends Streamer implements ObjectStorageStreamerInterface { - private S3Service $s3Service; - - public function __construct(S3Service $s3Service) + public function __construct(private S3Service $s3Service) { parent::__construct(); - - $this->s3Service = $s3Service; } /** diff --git a/app/Services/TokenManager.php b/app/Services/TokenManager.php index 47af9148..3d9210b4 100644 --- a/app/Services/TokenManager.php +++ b/app/Services/TokenManager.php @@ -20,18 +20,12 @@ class TokenManager public function deleteTokenByPlainTextToken(string $plainTextToken): void { - $token = PersonalAccessToken::findToken($plainTextToken); - - if ($token) { - $token->delete(); - } + PersonalAccessToken::findToken($plainTextToken)?->delete(); } public function getUserFromPlainTextToken(string $plainTextToken): ?User { - $token = PersonalAccessToken::findToken($plainTextToken); - - return $token ? $token->tokenable : null; + return PersonalAccessToken::findToken($plainTextToken)?->tokenable; } public function refreshToken(User $user): NewAccessToken diff --git a/app/Services/UploadService.php b/app/Services/UploadService.php index 4496cd62..ae2238a1 100644 --- a/app/Services/UploadService.php +++ b/app/Services/UploadService.php @@ -14,11 +14,8 @@ class UploadService { private const UPLOAD_DIRECTORY = '__KOEL_UPLOADS__'; - private FileSynchronizer $fileSynchronizer; - - public function __construct(FileSynchronizer $fileSynchronizer) + public function __construct(private FileSynchronizer $fileSynchronizer) { - $this->fileSynchronizer = $fileSynchronizer; } public function handleUploadedFile(UploadedFile $file): Song @@ -27,12 +24,11 @@ class UploadService $file->move($this->getUploadDirectory(), $targetFileName); $targetPathName = $this->getUploadDirectory() . $targetFileName; - $this->fileSynchronizer->setFile($targetPathName); - $result = $this->fileSynchronizer->sync(MediaSyncService::APPLICABLE_TAGS); + $result = $this->fileSynchronizer->setFile($targetPathName)->sync(); - if ($result !== FileSynchronizer::SYNC_RESULT_SUCCESS) { + if ($result->isError()) { @unlink($targetPathName); - throw new SongUploadFailedException($this->fileSynchronizer->getSyncError()); + throw new SongUploadFailedException($result->error); } return $this->fileSynchronizer->getSong(); diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 00000000..05c0dd9d --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,45 @@ + $name, + 'email' => $email, + 'password' => $this->hash->make($plainTextPassword), + 'is_admin' => $isAdmin, + ]); + } + + public function updateUser(User $user, string $name, string $email, string|null $password, bool $isAdmin): User + { + $data = [ + 'name' => $name, + 'email' => $email, + 'is_admin' => $isAdmin, + ]; + + if ($password) { + $data['password'] = $this->hash->make($password); + } + + $user->update($data); + + return $user; + } + + public function deleteUser(User $user): void + { + $user->delete(); + } +} diff --git a/app/Services/Util.php b/app/Services/Util.php index f3bef2a4..d0ca216c 100644 --- a/app/Services/Util.php +++ b/app/Services/Util.php @@ -23,19 +23,14 @@ class Util return 'UTF-16LE'; } - switch (substr($str, 0, 3)) { - case UTF8_BOM: - return 'UTF-8'; + if (substr($str, 0, 3) === UTF8_BOM) { + return 'UTF-8'; } - switch (substr($str, 0, 4)) { - case UTF32_BIG_ENDIAN_BOM: - return 'UTF-32BE'; - - case UTF32_LITTLE_ENDIAN_BOM: - return 'UTF-32LE'; - } - - return null; + return match (substr($str, 0, 4)) { + UTF32_BIG_ENDIAN_BOM => 'UTF-32BE', + UTF32_LITTLE_ENDIAN_BOM => 'UTF-32LE', + default => null, + }; } } diff --git a/app/Services/V6/SearchService.php b/app/Services/V6/SearchService.php new file mode 100644 index 00000000..c7b29966 --- /dev/null +++ b/app/Services/V6/SearchService.php @@ -0,0 +1,63 @@ +user(); + + return ExcerptSearchResult::make( + $this->songRepository->getByIds( + Song::search($keywords)->get()->take($count)->pluck('id')->all(), + $scopedUser + ), + $this->artistRepository->getByIds( + Artist::search($keywords)->get()->take($count)->pluck('id')->all(), + $scopedUser + ), + $this->albumRepository->getByIds( + Album::search($keywords)->get()->take($count)->pluck('id')->all(), + $scopedUser + ), + ); + } + + /** @return Collection|array */ + public function searchSongs( + string $keywords, + ?User $scopedUser = null, + int $limit = self::DEFAULT_MAX_SONG_RESULT_COUNT + ): Collection { + return Song::search($keywords) + ->query(static function (Builder $builder) use ($scopedUser, $limit): void { + $builder->withMeta($scopedUser ?? auth()->user())->limit($limit); + }) + ->get(); + } +} diff --git a/app/Services/YouTubeService.php b/app/Services/YouTubeService.php index c0d5c7e5..652c4875 100644 --- a/app/Services/YouTubeService.php +++ b/app/Services/YouTubeService.php @@ -5,7 +5,7 @@ namespace App\Services; use App\Models\Song; use Throwable; -class YouTubeService extends AbstractApiClient implements ApiConsumerInterface +class YouTubeService extends ApiClient implements ApiConsumerInterface { /** * Determine if our application is using YouTube. diff --git a/app/Values/AlbumInformation.php b/app/Values/AlbumInformation.php new file mode 100644 index 00000000..3d9e9849 --- /dev/null +++ b/app/Values/AlbumInformation.php @@ -0,0 +1,52 @@ + '', 'full' => ''], + array $tracks = [] + ): self { + return new self($url, $cover, $wiki, $tracks); + } + + public static function fromLastFmData(object $data): self + { + return self::make( + url: $data->url, + cover: Arr::get($data->image, '0.#text'), + wiki: [ + 'summary' => isset($data->wiki) ? self::formatLastFmText($data->wiki->summary) : '', + 'full' => isset($data->wiki) ? self::formatLastFmText($data->wiki->content) : '', + ], + tracks: array_map(static fn ($track): array => [ + 'title' => $track->name, + 'length' => (int) $track->duration, + 'url' => $track->url, + ], $data->tracks?->track ?? []), + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'url' => $this->url, + 'cover' => $this->cover, + 'wiki' => $this->wiki, + 'tracks' => $this->tracks, + ]; + } +} diff --git a/app/Values/ArtistInformation.php b/app/Values/ArtistInformation.php new file mode 100644 index 00000000..68d75b92 --- /dev/null +++ b/app/Values/ArtistInformation.php @@ -0,0 +1,43 @@ + '', 'full' => ''] + ): self { + return new self($url, $image, $bio); + } + + public static function fromLastFmData(object $data): self + { + return self::make( + url: $data->url, + bio: [ + 'summary' => isset($data->bio) ? self::formatLastFmText($data->bio->summary) : '', + 'full' => isset($data->bio) ? self::formatLastFmText($data->bio->content) : '', + ], + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'url' => $this->url, + 'image' => $this->image, + 'bio' => $this->bio, + ]; + } +} diff --git a/app/Values/ExcerptSearchResult.php b/app/Values/ExcerptSearchResult.php new file mode 100644 index 00000000..b8d8dcc6 --- /dev/null +++ b/app/Values/ExcerptSearchResult.php @@ -0,0 +1,17 @@ +trackName = $trackName; - $this->artistName = $artistName; } public static function make(string $trackName, string $artistName): self { return new self($trackName, $artistName); } - - public function getTrackName(): string - { - return $this->trackName; - } - - public function getArtistName(): string - { - return $this->artistName; - } } diff --git a/app/Values/SmartPlaylistRule.php b/app/Values/SmartPlaylistRule.php index 316a76f4..05711267 100644 --- a/app/Values/SmartPlaylistRule.php +++ b/app/Values/SmartPlaylistRule.php @@ -33,17 +33,17 @@ final class SmartPlaylistRule implements Arrayable self::OPERATOR_NOT_IN_LAST, ]; - public const MODEL_TITLE = 'title'; - public const MODEL_ALBUM_NAME = 'album.name'; - public const MODEL_ARTIST_NAME = 'artist.name'; - public const MODEL_PLAY_COUNT = 'interactions.play_count'; - public const MODEL_LAST_PLAYED = 'interactions.updated_at'; - public const MODEL_USER_ID = 'interactions.user_id'; - public const MODEL_LENGTH = 'length'; - public const MODEL_DATE_ADDED = 'created_at'; - public const MODEL_DATE_MODIFIED = 'updated_at'; + private const MODEL_TITLE = 'title'; + private const MODEL_ALBUM_NAME = 'album.name'; + private const MODEL_ARTIST_NAME = 'artist.name'; + private const MODEL_PLAY_COUNT = 'interactions.play_count'; + private const MODEL_LAST_PLAYED = 'interactions.updated_at'; + private const MODEL_USER_ID = 'interactions.user_id'; + private const MODEL_LENGTH = 'length'; + private const MODEL_DATE_ADDED = 'created_at'; + private const MODEL_DATE_MODIFIED = 'updated_at'; - public const VALID_MODELS = [ + private const VALID_MODELS = [ self::MODEL_TITLE, self::MODEL_ALBUM_NAME, self::MODEL_ARTIST_NAME, @@ -54,6 +54,15 @@ final class SmartPlaylistRule implements Arrayable self::MODEL_DATE_MODIFIED, ]; + private const MODEL_COLUMN_MAP = [ + self::MODEL_TITLE => 'songs.title', + self::MODEL_ALBUM_NAME => 'albums.name', + self::MODEL_ARTIST_NAME => 'artists.name', + self::MODEL_LENGTH => 'songs.length', + self::MODEL_DATE_ADDED => 'songs.created_at', + self::MODEL_DATE_MODIFIED => 'songs.updated_at', + ]; + public ?int $id; public string $operator; public array $value; @@ -96,8 +105,7 @@ final class SmartPlaylistRule implements Arrayable ]; } - /** @param array|self $rule */ - public function equals($rule): bool + public function equals(array|self $rule): bool { if (is_array($rule)) { $rule = self::create($rule); @@ -107,4 +115,30 @@ final class SmartPlaylistRule implements Arrayable && !array_diff($this->value, $rule->value) && $this->model === $rule->model; } + + /** @return array */ + public function toCriteriaParameters(): array + { + $column = array_key_exists($this->model, self::MODEL_COLUMN_MAP) + ? self::MODEL_COLUMN_MAP[$this->model] + : $this->model; + + $resolvers = [ + self::OPERATOR_BEGINS_WITH => [$column, 'LIKE', "{$this->value[0]}%"], + self::OPERATOR_ENDS_WITH => [$column, 'LIKE', "%{$this->value[0]}"], + self::OPERATOR_IS => [$column, '=', $this->value[0]], + self::OPERATOR_IS_NOT => [$column, '<>', $this->value[0]], + self::OPERATOR_CONTAINS => [$column, 'LIKE', "%{$this->value[0]}%"], + self::OPERATOR_NOT_CONTAIN => [$column, 'NOT LIKE', "%{$this->value[0]}%"], + self::OPERATOR_IS_LESS_THAN => [$column, '<', $this->value[0]], + self::OPERATOR_IS_GREATER_THAN => [$column, '>', $this->value[0]], + self::OPERATOR_IS_BETWEEN => [$column, $this->value], + self::OPERATOR_NOT_IN_LAST => fn (): array => [$column, '<', now()->subDays($this->value[0])], + self::OPERATOR_IN_LAST => fn (): array => [$column, '>=', now()->subDays($this->value[0])], + ]; + + Assert::keyExists($resolvers, $this->operator); + + return is_callable($resolvers[$this->operator]) ? $resolvers[$this->operator]() : $resolvers[$this->operator]; + } } diff --git a/app/Values/SmartPlaylistRuleGroup.php b/app/Values/SmartPlaylistRuleGroup.php index 6c3113ea..e5156268 100644 --- a/app/Values/SmartPlaylistRuleGroup.php +++ b/app/Values/SmartPlaylistRuleGroup.php @@ -3,35 +3,32 @@ namespace App\Values; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Throwable; final class SmartPlaylistRuleGroup implements Arrayable { - public ?int $id; - - /** @var Collection|array */ - public Collection $rules; + private function __construct(public ?int $id, public Collection $rules) + { + } public static function tryCreate(array $jsonArray): ?self { try { return self::create($jsonArray); - } catch (Throwable $exception) { + } catch (Throwable) { return null; } } public static function create(array $jsonArray): self { - $group = new self(); - $group->id = $jsonArray['id'] ?? null; - - $group->rules = collect(array_map(static function (array $rawRuleConfig) { + $rules = collect(array_map(static function (array $rawRuleConfig) { return SmartPlaylistRule::create($rawRuleConfig); }, $jsonArray['rules'])); - return $group; + return new self(Arr::get($jsonArray, 'id'), $rules); } /** @return array */ diff --git a/app/Values/SongScanInformation.php b/app/Values/SongScanInformation.php new file mode 100644 index 00000000..7fb05e14 --- /dev/null +++ b/app/Values/SongScanInformation.php @@ -0,0 +1,91 @@ + */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'album' => $this->albumName, + 'artist' => $this->artistName, + 'albumartist' => $this->albumArtistName, + 'track' => $this->track, + 'disc' => $this->disc, + 'lyrics' => $this->lyrics, + 'length' => $this->length, + 'cover' => $this->cover, + 'path' => $this->path, + 'mtime' => $this->mTime, + ]; + } +} diff --git a/app/Values/SongUpdateData.php b/app/Values/SongUpdateData.php new file mode 100644 index 00000000..061113df --- /dev/null +++ b/app/Values/SongUpdateData.php @@ -0,0 +1,68 @@ +albumArtistName = $this->albumArtistName ?: $this->artistName; + } + + public static function fromRequest(SongUpdateRequest $request): self + { + return new self( + title: $request->input('data.title'), + artistName: $request->input('data.artist_name'), + albumName: $request->input('data.album_name'), + albumArtistName: $request->input('data.album_artist_name'), + track: (int) $request->input('data.track'), + disc: (int) $request->input('data.disc'), + lyrics: $request->input('data.lyrics'), + ); + } + + public static function make( + ?string $title, + ?string $artistName, + ?string $albumName, + ?string $albumArtistName, + ?int $track, + ?int $disc, + ?string $lyrics + ): self { + return new self( + $title, + $artistName, + $albumName, + $albumArtistName, + $track, + $disc, + $lyrics, + ); + } + + /** @return array */ + public function toArray(): array + { + return [ + 'title' => $this->title, + 'artist' => $this->artistName, + 'album' => $this->albumName, + 'album_artist' => $this->albumArtistName, + 'track' => $this->track, + 'disc' => $this->disc, + 'lyrics' => $this->lyrics, + ]; + } +} diff --git a/app/Values/SyncResult.php b/app/Values/SyncResult.php index e3ded592..c244ba28 100644 --- a/app/Values/SyncResult.php +++ b/app/Values/SyncResult.php @@ -2,34 +2,55 @@ namespace App\Values; -use Illuminate\Support\Collection; +use Webmozart\Assert\Assert; final class SyncResult { - /** @var Collection|array */ - public Collection $success; + public const TYPE_SUCCESS = 1; + public const TYPE_ERROR = 2; + public const TYPE_SKIPPED = 3; - /** @var Collection|array */ - public Collection $bad; - - /** @var Collection|array */ - public Collection $unmodified; - - private function __construct(Collection $success, Collection $bad, Collection $unmodified) + private function __construct(public string $path, public int $type, public ?string $error) { - $this->success = $success; - $this->bad = $bad; - $this->unmodified = $unmodified; + Assert::oneOf($type, [ + SyncResult::TYPE_SUCCESS, + SyncResult::TYPE_ERROR, + SyncResult::TYPE_SKIPPED, + ]); } - public static function init(): self + public static function success(string $path): self { - return new self(collect(), collect(), collect()); + return new self($path, self::TYPE_SUCCESS, null); } - /** @return Collection|array */ - public function validEntries(): Collection + public static function skipped(string $path): self { - return $this->success->merge($this->unmodified); + return new self($path, self::TYPE_SKIPPED, null); + } + + public static function error(string $path, ?string $error): self + { + return new self($path, self::TYPE_ERROR, $error); + } + + public function isSuccess(): bool + { + return $this->type === self::TYPE_SUCCESS; + } + + public function isSkipped(): bool + { + return $this->type === self::TYPE_SKIPPED; + } + + public function isError(): bool + { + return $this->type === self::TYPE_ERROR; + } + + public function isValid(): bool + { + return $this->isSuccess() || $this->isSkipped(); } } diff --git a/app/Values/SyncResultCollection.php b/app/Values/SyncResultCollection.php new file mode 100644 index 00000000..f4e73902 --- /dev/null +++ b/app/Values/SyncResultCollection.php @@ -0,0 +1,37 @@ + */ + public function valid(): Collection + { + return $this->filter(static fn (SyncResult $result): bool => $result->isValid()); + } + + /** @return Collection|array */ + public function success(): Collection + { + return $this->filter(static fn (SyncResult $result): bool => $result->isSuccess()); + } + + /** @return Collection|array */ + public function skipped(): Collection + { + return $this->filter(static fn (SyncResult $result): bool => $result->isSkipped()); + } + + /** @return Collection|array */ + public function error(): Collection + { + return $this->filter(static fn (SyncResult $result): bool => $result->isError()); + } +} diff --git a/app/Values/UserPreferences.php b/app/Values/UserPreferences.php index 1fd0676e..6c100a18 100644 --- a/app/Values/UserPreferences.php +++ b/app/Values/UserPreferences.php @@ -7,11 +7,8 @@ use JsonSerializable; final class UserPreferences implements Arrayable, JsonSerializable { - public ?string $lastFmSessionKey = null; - - private function __construct(?string $lastFmSessionKey) + private function __construct(public ?string $lastFmSessionKey = null) { - $this->lastFmSessionKey = $lastFmSessionKey; } public static function make(?string $lastFmSessionKey = null): self diff --git a/composer.json b/composer.json index 1faaf86a..a4414c69 100644 --- a/composer.json +++ b/composer.json @@ -1,106 +1,110 @@ { - "name": "phanan/koel", - "description": "Personal audio streaming service that works.", - "keywords": [ - "audio", - "stream", - "mp3" + "name": "phanan/koel", + "description": "Personal audio streaming service that works.", + "keywords": [ + "audio", + "stream", + "mp3" + ], + "license": "MIT", + "type": "project", + "require": { + "php": ">=8.0", + "laravel/framework": "^9.0", + "james-heinrich/getid3": "^1.9", + "guzzlehttp/guzzle": "^7.0.1", + "aws/aws-sdk-php-laravel": "^3.1", + "pusher/pusher-php-server": "^4.0", + "predis/predis": "~1.0", + "jackiedo/dotenv-editor": "^2.0", + "ext-exif": "*", + "ext-gd": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "ext-SimpleXML": "*", + "daverandom/resume": "^0.0.3", + "laravel/helpers": "^1.0", + "intervention/image": "^2.5", + "doctrine/dbal": "^3.0", + "lstrojny/functional-php": "^1.14", + "teamtnt/laravel-scout-tntsearch-driver": "^11.1", + "algolia/algoliasearch-client-php": "^3.3", + "laravel/ui": "^3.2", + "webmozart/assert": "^1.10", + "laravel/sanctum": "^2.15", + "laravel/scout": "^9.4", + "nunomaduro/collision": "^6.2", + "jwilsson/spotify-web-api-php": "^5.2", + "meilisearch/meilisearch-php": "^0.24.0", + "http-interop/http-factory-guzzle": "^1.2" + }, + "require-dev": { + "mockery/mockery": "~1.0", + "phpunit/phpunit": "^9.0", + "php-mock/php-mock-mockery": "^1.3", + "dms/phpunit-arraysubset-asserts": "^0.2.1", + "fakerphp/faker": "^1.13", + "slevomat/coding-standard": "^7.0", + "nunomaduro/larastan": "^2.1", + "laravel/tinker": "^2.7" + }, + "suggest": { + "ext-zip": "Allow downloading multiple songs as Zip archives" + }, + "autoload": { + "classmap": [ + "database" ], - "license": "MIT", - "type": "project", - "require": { - "php": ">=7.4", - "laravel/framework": "^8.42", - "james-heinrich/getid3": "^1.9", - "guzzlehttp/guzzle": "^7.0.1", - "aws/aws-sdk-php-laravel": "^3.1", - "pusher/pusher-php-server": "^4.0", - "predis/predis": "~1.0", - "jackiedo/dotenv-editor": "^1.0", - "ext-exif": "*", - "ext-fileinfo": "*", - "ext-json": "*", - "ext-SimpleXML": "*", - "daverandom/resume": "^0.0.3", - "laravel/helpers": "^1.0", - "intervention/image": "^2.5", - "doctrine/dbal": "^2.10", - "lstrojny/functional-php": "^1.14", - "teamtnt/laravel-scout-tntsearch-driver": "^11.1", - "algolia/algoliasearch-client-php": "^2.7", - "laravel/ui": "^3.2", - "webmozart/assert": "^1.10", - "laravel/sanctum": "^2.11" + "psr-4": { + "App\\": "app/", + "Tests\\": "tests/", + "Database\\Factories\\": "database/factories/", + "Database\\Seeders\\": "database/seeders/" }, - "require-dev": { - "facade/ignition": "^2.5", - "mockery/mockery": "~1.0", - "phpunit/phpunit": "^9.0", - "laravel/tinker": "^2.0", - "php-mock/php-mock-mockery": "^1.3", - "dms/phpunit-arraysubset-asserts": "^0.2.1", - "fakerphp/faker": "^1.13", - "slevomat/coding-standard": "^7.0", - "nunomaduro/larastan": "^0.6.11", - "nunomaduro/collision": "^5.3" - }, - "suggest": { - "ext-zip": "Allow downloading multiple songs as Zip archives" - }, - "autoload": { - "classmap": [ - "database" - ], - "psr-4": { - "App\\": "app/", - "Tests\\": "tests/", - "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" - }, - "files": [ - "app/Helpers.php" - ] - }, - "autoload-dev": { - "classmap": [ - "tests/TestCase.php" - ] - }, - "scripts": { - "post-autoload-dump": [ - "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", - "@php artisan package:discover" - ], - "post-install-cmd": [ - "@php artisan clear-compiled", - "@php artisan cache:clear", - "@php -r \"if (!file_exists('.env')) copy('.env.example', '.env');\"" - ], - "pre-update-cmd": [ - "@php artisan clear-compiled" - ], - "post-update-cmd": [ - "@php artisan cache:clear" - ], - "post-root-package-install": [ - "@php -r \"copy('.env.example', '.env');\"" - ], - "post-create-project-cmd": [ - "@php artisan key:generate" - ], - "test": "@php artisan test", - "coverage": "@php artisan test --coverage-clover=coverage.xml", - "cs": "phpcs --standard=ruleset.xml", - "cs:fix": "phpcbf --standard=ruleset.xml", - "analyze": "phpstan analyse --memory-limit 1G --configuration phpstan.neon.dist --ansi" - }, - "config": { - "preferred-install": "dist", - "optimize-autoloader": true, - "allow-plugins": { - "dealerdirect/phpcodesniffer-composer-installer": true - } - }, - "minimum-stability": "stable", - "prefer-stable": false + "files": [ + "app/Helpers.php" + ] + }, + "autoload-dev": { + "classmap": [ + "tests/TestCase.php" + ] + }, + "scripts": { + "post-autoload-dump": [ + "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", + "@php artisan package:discover" + ], + "post-install-cmd": [ + "@php artisan clear-compiled", + "@php artisan cache:clear", + "@php -r \"if (!file_exists('.env')) copy('.env.example', '.env');\"" + ], + "pre-update-cmd": [ + "@php artisan clear-compiled" + ], + "post-update-cmd": [ + "@php artisan cache:clear" + ], + "post-root-package-install": [ + "@php -r \"copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "@php artisan key:generate" + ], + "test": "@php artisan test", + "coverage": "@php artisan test --coverage-clover=coverage.xml", + "cs": "phpcs --standard=ruleset.xml", + "cs:fix": "phpcbf --standard=ruleset.xml", + "analyze": "phpstan analyse --memory-limit 1G --configuration phpstan.neon.dist --ansi" + }, + "config": { + "preferred-install": "dist", + "optimize-autoloader": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } + }, + "minimum-stability": "stable", + "prefer-stable": false } diff --git a/composer.lock b/composer.lock index a32c61e0..0ef6358f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,35 +4,35 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a3a41a7eccc0c230272c4319a31a11a4", + "content-hash": "310243edd4bc8d4f2184ff38520a52dd", "packages": [ { "name": "algolia/algoliasearch-client-php", - "version": "2.8.0", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/algolia/algoliasearch-client-php.git", - "reference": "d9781147ae433f5bdbfd902497d748d60e70d693" + "reference": "aa491a36579d8470c99c15064a79b6b4f83e85e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/algolia/algoliasearch-client-php/zipball/d9781147ae433f5bdbfd902497d748d60e70d693", - "reference": "d9781147ae433f5bdbfd902497d748d60e70d693", + "url": "https://api.github.com/repos/algolia/algoliasearch-client-php/zipball/aa491a36579d8470c99c15064a79b6b4f83e85e4", + "reference": "aa491a36579d8470c99c15064a79b6b4f83e85e4", "shasum": "" }, "require": { "ext-curl": "*", "ext-json": "*", "ext-mbstring": "*", - "php": "^5.3 || ^7.0 || ^8.0", + "php": "^7.2 || ^8.0", "psr/http-message": "^1.0", - "psr/log": "^1.0", - "psr/simple-cache": "^1.0" + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.0", "fzaninotto/faker": "^1.8", - "julienbourdeau/phpunit": "4.8.37", + "phpunit/phpunit": "^8.0 || ^9.0", "symfony/yaml": "^2.0 || ^4.0" }, "suggest": { @@ -48,13 +48,13 @@ } }, "autoload": { - "psr-4": { - "Algolia\\AlgoliaSearch\\": "src/" - }, "files": [ "src/Http/Psr7/functions.php", "src/functions.php" - ] + ], + "psr-4": { + "Algolia\\AlgoliaSearch\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -76,9 +76,9 @@ ], "support": { "issues": "https://github.com/algolia/algoliasearch-client-php/issues", - "source": "https://github.com/algolia/algoliasearch-client-php/tree/2.8.0" + "source": "https://github.com/algolia/algoliasearch-client-php/tree/3.3.0" }, - "time": "2021-04-07T16:50:58+00:00" + "time": "2022-07-06T14:08:05+00:00" }, { "name": "aws/aws-crt-php", @@ -132,16 +132,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.208.7", + "version": "3.231.15", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "41a800dd7cf5c4ac0ef9bf8db01e838ab6a3698c" + "reference": "ba379285d24b609a997bd8b40933d3e0a3826dfb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/41a800dd7cf5c4ac0ef9bf8db01e838ab6a3698c", - "reference": "41a800dd7cf5c4ac0ef9bf8db01e838ab6a3698c", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/ba379285d24b609a997bd8b40933d3e0a3826dfb", + "reference": "ba379285d24b609a997bd8b40933d3e0a3826dfb", "shasum": "" }, "require": { @@ -149,9 +149,9 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^5.3.3|^6.2.1|^7.0", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", "guzzlehttp/promises": "^1.4.0", - "guzzlehttp/psr7": "^1.7.0|^2.0", + "guzzlehttp/psr7": "^1.8.5 || ^2.3", "mtdowling/jmespath.php": "^2.6", "php": ">=5.5" }, @@ -159,6 +159,7 @@ "andrewsville/php-token-reflection": "^1.4", "aws/aws-php-sns-message-validator": "~1.0", "behat/behat": "~3.0", + "composer/composer": "^1.10.22", "doctrine/cache": "~1.4", "ext-dom": "*", "ext-openssl": "*", @@ -166,7 +167,7 @@ "ext-sockets": "*", "nette/neon": "^2.3", "paragonie/random_compat": ">= 2", - "phpunit/phpunit": "^4.8.35|^5.4.3", + "phpunit/phpunit": "^4.8.35 || ^5.6.3", "psr/cache": "^1.0", "psr/simple-cache": "^1.0", "sebastian/comparator": "^1.2.3" @@ -185,12 +186,12 @@ } }, "autoload": { - "psr-4": { - "Aws\\": "src/" - }, "files": [ "src/functions.php" - ] + ], + "psr-4": { + "Aws\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -217,27 +218,27 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.208.7" + "source": "https://github.com/aws/aws-sdk-php/tree/3.231.15" }, - "time": "2021-12-21T19:16:39+00:00" + "time": "2022-07-27T18:59:36+00:00" }, { "name": "aws/aws-sdk-php-laravel", - "version": "3.6.0", + "version": "3.7.0", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php-laravel.git", - "reference": "49bc5d90b1ebfb107d0b650fd49b41b241425a36" + "reference": "cfae1e4e770704cf546051c0ba3d480f0031c51f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php-laravel/zipball/49bc5d90b1ebfb107d0b650fd49b41b241425a36", - "reference": "49bc5d90b1ebfb107d0b650fd49b41b241425a36", + "url": "https://api.github.com/repos/aws/aws-sdk-php-laravel/zipball/cfae1e4e770704cf546051c0ba3d480f0031c51f", + "reference": "cfae1e4e770704cf546051c0ba3d480f0031c51f", "shasum": "" }, "require": { "aws/aws-sdk-php": "~3.0", - "illuminate/support": "^5.1 || ^6.0 || ^7.0 || ^8.0", + "illuminate/support": "^5.1 || ^6.0 || ^7.0 || ^8.0 || ^9.0", "php": ">=5.5.9" }, "require-dev": { @@ -274,7 +275,7 @@ "homepage": "http://aws.amazon.com" } ], - "description": "A simple Laravel 5/6/7/8 service provider for including the AWS SDK for PHP.", + "description": "A simple Laravel 5/6/7/8/9 service provider for including the AWS SDK for PHP.", "homepage": "http://aws.amazon.com/sdkforphp2", "keywords": [ "amazon", @@ -286,14 +287,15 @@ "laravel 6", "laravel 7", "laravel 8", + "laravel 9", "s3", "sdk" ], "support": { "issues": "https://github.com/aws/aws-sdk-php-laravel/issues", - "source": "https://github.com/aws/aws-sdk-php-laravel/tree/3.6.0" + "source": "https://github.com/aws/aws-sdk-php-laravel/tree/3.7.0" }, - "time": "2020-09-14T17:33:55+00:00" + "time": "2022-03-08T22:02:03+00:00" }, { "name": "brick/math", @@ -355,6 +357,72 @@ ], "time": "2021-08-15T20:50:18+00:00" }, + { + "name": "clue/stream-filter", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/clue/stream-filter.git", + "reference": "d6169430c7731d8509da7aecd0af756a5747b78e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/stream-filter/zipball/d6169430c7731d8509da7aecd0af756a5747b78e", + "reference": "d6169430c7731d8509da7aecd0af756a5747b78e", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "Clue\\StreamFilter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "A simple and modern approach to stream filtering in PHP", + "homepage": "https://github.com/clue/php-stream-filter", + "keywords": [ + "bucket brigade", + "callback", + "filter", + "php_user_filter", + "stream", + "stream_filter_append", + "stream_filter_register" + ], + "support": { + "issues": "https://github.com/clue/stream-filter/issues", + "source": "https://github.com/clue/stream-filter/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-02-21T13:15:14+00:00" + }, { "name": "daverandom/resume", "version": "v0.0.3", @@ -377,12 +445,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "DaveRandom\\Resume\\": "src/" - }, "files": [ "src/functions.php" - ] + ], + "psr-4": { + "DaveRandom\\Resume\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -478,16 +546,16 @@ }, { "name": "doctrine/cache", - "version": "2.1.1", + "version": "2.2.0", "source": { "type": "git", "url": "https://github.com/doctrine/cache.git", - "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce" + "reference": "1ca8f21980e770095a31456042471a57bc4c68fb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/cache/zipball/331b4d5dbaeab3827976273e9356b3b453c300ce", - "reference": "331b4d5dbaeab3827976273e9356b3b453c300ce", + "url": "https://api.github.com/repos/doctrine/cache/zipball/1ca8f21980e770095a31456042471a57bc4c68fb", + "reference": "1ca8f21980e770095a31456042471a57bc4c68fb", "shasum": "" }, "require": { @@ -497,18 +565,12 @@ "doctrine/common": ">2.2,<2.4" }, "require-dev": { - "alcaeus/mongo-php-adapter": "^1.1", "cache/integration-tests": "dev-master", - "doctrine/coding-standard": "^8.0", - "mongodb/mongodb": "^1.1", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", - "predis/predis": "~1.0", + "doctrine/coding-standard": "^9", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", "psr/cache": "^1.0 || ^2.0 || ^3.0", - "symfony/cache": "^4.4 || ^5.2 || ^6.0@dev", - "symfony/var-exporter": "^4.4 || ^5.2 || ^6.0@dev" - }, - "suggest": { - "alcaeus/mongo-php-adapter": "Required to use legacy MongoDB driver" + "symfony/cache": "^4.4 || ^5.4 || ^6", + "symfony/var-exporter": "^4.4 || ^5.4 || ^6" }, "type": "library", "autoload": { @@ -557,7 +619,7 @@ ], "support": { "issues": "https://github.com/doctrine/cache/issues", - "source": "https://github.com/doctrine/cache/tree/2.1.1" + "source": "https://github.com/doctrine/cache/tree/2.2.0" }, "funding": [ { @@ -573,39 +635,42 @@ "type": "tidelift" } ], - "time": "2021-07-17T14:49:29+00:00" + "time": "2022-05-20T20:07:39+00:00" }, { "name": "doctrine/dbal", - "version": "2.13.6", + "version": "3.3.7", "source": { "type": "git", "url": "https://github.com/doctrine/dbal.git", - "reference": "67ef6d0327ccbab1202b39e0222977a47ed3ef2f" + "reference": "9f79d4650430b582f4598fe0954ef4d52fbc0a8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/dbal/zipball/67ef6d0327ccbab1202b39e0222977a47ed3ef2f", - "reference": "67ef6d0327ccbab1202b39e0222977a47ed3ef2f", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/9f79d4650430b582f4598fe0954ef4d52fbc0a8a", + "reference": "9f79d4650430b582f4598fe0954ef4d52fbc0a8a", "shasum": "" }, "require": { - "doctrine/cache": "^1.0|^2.0", - "doctrine/deprecations": "^0.5.3", + "composer-runtime-api": "^2", + "doctrine/cache": "^1.11|^2.0", + "doctrine/deprecations": "^0.5.3|^1", "doctrine/event-manager": "^1.0", - "ext-pdo": "*", - "php": "^7.1 || ^8" + "php": "^7.3 || ^8.0", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" }, "require-dev": { "doctrine/coding-standard": "9.0.0", - "jetbrains/phpstorm-stubs": "2021.1", - "phpstan/phpstan": "1.2.0", - "phpunit/phpunit": "^7.5.20|^8.5|9.5.10", + "jetbrains/phpstorm-stubs": "2022.1", + "phpstan/phpstan": "1.7.13", + "phpstan/phpstan-strict-rules": "^1.2", + "phpunit/phpunit": "9.5.20", "psalm/plugin-phpunit": "0.16.1", - "squizlabs/php_codesniffer": "3.6.1", - "symfony/cache": "^4.4", - "symfony/console": "^2.0.5|^3.0|^4.0|^5.0", - "vimeo/psalm": "4.13.0" + "squizlabs/php_codesniffer": "3.7.0", + "symfony/cache": "^5.2|^6.0", + "symfony/console": "^2.7|^3.0|^4.0|^5.0|^6.0", + "vimeo/psalm": "4.23.0" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." @@ -616,7 +681,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\DBAL\\": "lib/Doctrine/DBAL" + "Doctrine\\DBAL\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -659,14 +724,13 @@ "queryobject", "sasql", "sql", - "sqlanywhere", "sqlite", "sqlserver", "sqlsrv" ], "support": { "issues": "https://github.com/doctrine/dbal/issues", - "source": "https://github.com/doctrine/dbal/tree/2.13.6" + "source": "https://github.com/doctrine/dbal/tree/3.3.7" }, "funding": [ { @@ -682,29 +746,29 @@ "type": "tidelift" } ], - "time": "2021-11-26T20:11:05+00:00" + "time": "2022-06-13T21:43:03+00:00" }, { "name": "doctrine/deprecations", - "version": "v0.5.3", + "version": "v1.0.0", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "9504165960a1f83cc1480e2be1dd0a0478561314" + "reference": "0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/9504165960a1f83cc1480e2be1dd0a0478561314", - "reference": "9504165960a1f83cc1480e2be1dd0a0478561314", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de", + "reference": "0e2a4f1f8cdfc7a92ec3b01c9334898c806b30de", "shasum": "" }, "require": { "php": "^7.1|^8.0" }, "require-dev": { - "doctrine/coding-standard": "^6.0|^7.0|^8.0", - "phpunit/phpunit": "^7.0|^8.0|^9.0", - "psr/log": "^1.0" + "doctrine/coding-standard": "^9", + "phpunit/phpunit": "^7.5|^8.5|^9.5", + "psr/log": "^1|^2|^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -723,40 +787,37 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/v0.5.3" + "source": "https://github.com/doctrine/deprecations/tree/v1.0.0" }, - "time": "2021-03-21T12:59:47+00:00" + "time": "2022-05-02T15:47:09+00:00" }, { "name": "doctrine/event-manager", - "version": "1.1.1", + "version": "1.1.2", "source": { "type": "git", "url": "https://github.com/doctrine/event-manager.git", - "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f" + "reference": "eb2ecf80e3093e8f3c2769ac838e27d8ede8e683" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/event-manager/zipball/41370af6a30faa9dc0368c4a6814d596e81aba7f", - "reference": "41370af6a30faa9dc0368c4a6814d596e81aba7f", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/eb2ecf80e3093e8f3c2769ac838e27d8ede8e683", + "reference": "eb2ecf80e3093e8f3c2769ac838e27d8ede8e683", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "doctrine/common": "<2.9@dev" + "doctrine/common": "<2.9" }, "require-dev": { - "doctrine/coding-standard": "^6.0", - "phpunit/phpunit": "^7.0" + "doctrine/coding-standard": "^9", + "phpstan/phpstan": "~1.4.10 || ^1.5.4", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.22" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, "autoload": { "psr-4": { "Doctrine\\Common\\": "lib/Doctrine/Common" @@ -803,7 +864,7 @@ ], "support": { "issues": "https://github.com/doctrine/event-manager/issues", - "source": "https://github.com/doctrine/event-manager/tree/1.1.x" + "source": "https://github.com/doctrine/event-manager/tree/1.1.2" }, "funding": [ { @@ -819,7 +880,7 @@ "type": "tidelift" } ], - "time": "2020-05-29T18:28:51+00:00" + "time": "2022-07-27T22:18:11+00:00" }, { "name": "doctrine/inflector", @@ -914,32 +975,28 @@ }, { "name": "doctrine/lexer", - "version": "1.2.1", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/doctrine/lexer.git", - "reference": "e864bbf5904cb8f5bb334f99209b48018522f042" + "reference": "c268e882d4dbdd85e36e4ad69e02dc284f89d229" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/lexer/zipball/e864bbf5904cb8f5bb334f99209b48018522f042", - "reference": "e864bbf5904cb8f5bb334f99209b48018522f042", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/c268e882d4dbdd85e36e4ad69e02dc284f89d229", + "reference": "c268e882d4dbdd85e36e4ad69e02dc284f89d229", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^6.0", - "phpstan/phpstan": "^0.11.8", - "phpunit/phpunit": "^8.2" + "doctrine/coding-standard": "^9.0", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.11" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.2.x-dev" - } - }, "autoload": { "psr-4": { "Doctrine\\Common\\Lexer\\": "lib/Doctrine/Common/Lexer" @@ -974,7 +1031,7 @@ ], "support": { "issues": "https://github.com/doctrine/lexer/issues", - "source": "https://github.com/doctrine/lexer/tree/1.2.1" + "source": "https://github.com/doctrine/lexer/tree/1.2.3" }, "funding": [ { @@ -990,33 +1047,33 @@ "type": "tidelift" } ], - "time": "2020-05-25T17:44:05+00:00" + "time": "2022-02-28T11:07:21+00:00" }, { "name": "dragonmantank/cron-expression", - "version": "v3.1.0", + "version": "v3.3.1", "source": { "type": "git", "url": "https://github.com/dragonmantank/cron-expression.git", - "reference": "7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c" + "reference": "be85b3f05b46c39bbc0d95f6c071ddff669510fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c", - "reference": "7a8c6e56ab3ffcc538d05e8155bb42269abf1a0c", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/be85b3f05b46c39bbc0d95f6c071ddff669510fa", + "reference": "be85b3f05b46c39bbc0d95f6c071ddff669510fa", "shasum": "" }, "require": { "php": "^7.2|^8.0", - "webmozart/assert": "^1.7.0" + "webmozart/assert": "^1.0" }, "replace": { "mtdowling/cron-expression": "^1.0" }, "require-dev": { "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12", - "phpstan/phpstan-webmozart-assert": "^0.12.7", + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-webmozart-assert": "^1.0", "phpunit/phpunit": "^7.0|^8.0|^9.0" }, "type": "library", @@ -1043,7 +1100,7 @@ ], "support": { "issues": "https://github.com/dragonmantank/cron-expression/issues", - "source": "https://github.com/dragonmantank/cron-expression/tree/v3.1.0" + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.3.1" }, "funding": [ { @@ -1051,31 +1108,31 @@ "type": "github" } ], - "time": "2020-11-24T19:55:57+00:00" + "time": "2022-01-18T15:43:28+00:00" }, { "name": "egulias/email-validator", - "version": "2.1.25", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/egulias/EmailValidator.git", - "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4" + "reference": "f88dcf4b14af14a98ad96b14b2b317969eab6715" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/0dbf5d78455d4d6a41d186da50adc1122ec066f4", - "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/f88dcf4b14af14a98ad96b14b2b317969eab6715", + "reference": "f88dcf4b14af14a98ad96b14b2b317969eab6715", "shasum": "" }, "require": { - "doctrine/lexer": "^1.0.1", - "php": ">=5.5", - "symfony/polyfill-intl-idn": "^1.10" + "doctrine/lexer": "^1.2", + "php": ">=7.2", + "symfony/polyfill-intl-idn": "^1.15" }, "require-dev": { - "dominicsayers/isemail": "^3.0.7", - "phpunit/phpunit": "^4.8.36|^7.5.15", - "satooshi/php-coveralls": "^1.0.1" + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^8.5.8|^9.3.3", + "vimeo/psalm": "^4" }, "suggest": { "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" @@ -1083,7 +1140,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1.x-dev" + "dev-master": "3.0.x-dev" } }, "autoload": { @@ -1111,7 +1168,7 @@ ], "support": { "issues": "https://github.com/egulias/EmailValidator/issues", - "source": "https://github.com/egulias/EmailValidator/tree/2.1.25" + "source": "https://github.com/egulias/EmailValidator/tree/3.2.1" }, "funding": [ { @@ -1119,7 +1176,202 @@ "type": "github" } ], - "time": "2020-12-29T14:50:06+00:00" + "time": "2022-06-18T20:57:19+00:00" + }, + { + "name": "facade/ignition-contracts", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/facade/ignition-contracts.git", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267", + "shasum": "" + }, + "require": { + "php": "^7.3|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v2.15.8", + "phpunit/phpunit": "^9.3.11", + "vimeo/psalm": "^3.17.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Facade\\IgnitionContracts\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://flareapp.io", + "role": "Developer" + } + ], + "description": "Solution contracts for Ignition", + "homepage": "https://github.com/facade/ignition-contracts", + "keywords": [ + "contracts", + "flare", + "ignition" + ], + "support": { + "issues": "https://github.com/facade/ignition-contracts/issues", + "source": "https://github.com/facade/ignition-contracts/tree/1.0.2" + }, + "time": "2020-10-16T08:27:54+00:00" + }, + { + "name": "filp/whoops", + "version": "2.14.5", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "a63e5e8f26ebbebf8ed3c5c691637325512eb0dc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/a63e5e8f26ebbebf8ed3c5c691637325512eb0dc", + "reference": "a63e5e8f26ebbebf8ed3c5c691637325512eb0dc", + "shasum": "" + }, + "require": { + "php": "^5.5.9 || ^7.0 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^0.9 || ^1.0", + "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.14.5" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2022-01-07T12:00:00+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "58571acbaa5f9f462c9c77e911700ac66f446d4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/58571acbaa5f9f462c9c77e911700ac66f446d4e", + "reference": "58571acbaa5f9f462c9c77e911700ac66f446d4e", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0", + "symfony/http-foundation": "^4.4|^5.4|^6" + }, + "require-dev": { + "phpstan/phpstan": "^1.4", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2022-02-20T15:07:15+00:00" }, { "name": "graham-campbell/result-type", @@ -1185,22 +1437,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.4.1", + "version": "7.4.5", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79" + "reference": "1dd98b0564cb3f6bd16ce683cb755f94c10fbd82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee0a041b1760e6a53d2a39c8c34115adc2af2c79", - "reference": "ee0a041b1760e6a53d2a39c8c34115adc2af2c79", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/1dd98b0564cb3f6bd16ce683cb755f94c10fbd82", + "reference": "1dd98b0564cb3f6bd16ce683cb755f94c10fbd82", "shasum": "" }, "require": { "ext-json": "*", "guzzlehttp/promises": "^1.5", - "guzzlehttp/psr7": "^1.8.3 || ^2.1", + "guzzlehttp/psr7": "^1.9 || ^2.4", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -1227,12 +1479,12 @@ } }, "autoload": { - "psr-4": { - "GuzzleHttp\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1289,7 +1541,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.4.1" + "source": "https://github.com/guzzle/guzzle/tree/7.4.5" }, "funding": [ { @@ -1305,7 +1557,7 @@ "type": "tidelift" } ], - "time": "2021-12-06T18:43:05+00:00" + "time": "2022-06-20T22:16:13+00:00" }, { "name": "guzzlehttp/promises", @@ -1334,12 +1586,12 @@ } }, "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - }, "files": [ "src/functions_include.php" - ] + ], + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1393,16 +1645,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.1.0", + "version": "2.4.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "089edd38f5b8abba6cb01567c2a8aaa47cec4c72" + "reference": "13388f00956b1503577598873fffb5ae994b5737" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/089edd38f5b8abba6cb01567c2a8aaa47cec4c72", - "reference": "089edd38f5b8abba6cb01567c2a8aaa47cec4c72", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/13388f00956b1503577598873fffb5ae994b5737", + "reference": "13388f00956b1503577598873fffb5ae994b5737", "shasum": "" }, "require": { @@ -1426,7 +1678,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "2.1-dev" + "dev-master": "2.4-dev" } }, "autoload": { @@ -1488,7 +1740,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.1.0" + "source": "https://github.com/guzzle/psr7/tree/2.4.0" }, "funding": [ { @@ -1504,20 +1756,78 @@ "type": "tidelift" } ], - "time": "2021-10-06T17:43:30+00:00" + "time": "2022-06-20T21:43:11+00:00" }, { - "name": "intervention/image", - "version": "2.7.1", + "name": "http-interop/http-factory-guzzle", + "version": "1.2.0", "source": { "type": "git", - "url": "https://github.com/Intervention/image.git", - "reference": "744ebba495319501b873a4e48787759c72e3fb8c" + "url": "https://github.com/http-interop/http-factory-guzzle.git", + "reference": "8f06e92b95405216b237521cc64c804dd44c4a81" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Intervention/image/zipball/744ebba495319501b873a4e48787759c72e3fb8c", - "reference": "744ebba495319501b873a4e48787759c72e3fb8c", + "url": "https://api.github.com/repos/http-interop/http-factory-guzzle/zipball/8f06e92b95405216b237521cc64c804dd44c4a81", + "reference": "8f06e92b95405216b237521cc64c804dd44c4a81", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^1.7||^2.0", + "php": ">=7.3", + "psr/http-factory": "^1.0" + }, + "provide": { + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "guzzlehttp/psr7": "Includes an HTTP factory starting in version 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Factory\\Guzzle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "An HTTP Factory using Guzzle PSR7", + "keywords": [ + "factory", + "http", + "psr-17", + "psr-7" + ], + "support": { + "issues": "https://github.com/http-interop/http-factory-guzzle/issues", + "source": "https://github.com/http-interop/http-factory-guzzle/tree/1.2.0" + }, + "time": "2021-07-21T13:50:14+00:00" + }, + { + "name": "intervention/image", + "version": "2.7.2", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "04be355f8d6734c826045d02a1079ad658322dad" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/04be355f8d6734c826045d02a1079ad658322dad", + "reference": "04be355f8d6734c826045d02a1079ad658322dad", "shasum": "" }, "require": { @@ -1560,8 +1870,8 @@ "authors": [ { "name": "Oliver Vogel", - "email": "oliver@olivervogel.com", - "homepage": "http://olivervogel.com/" + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" } ], "description": "Image handling and manipulation library with support for Laravel integration", @@ -1576,11 +1886,11 @@ ], "support": { "issues": "https://github.com/Intervention/image/issues", - "source": "https://github.com/Intervention/image/tree/2.7.1" + "source": "https://github.com/Intervention/image/tree/2.7.2" }, "funding": [ { - "url": "https://www.paypal.me/interventionphp", + "url": "https://paypal.me/interventionio", "type": "custom" }, { @@ -1588,29 +1898,33 @@ "type": "github" } ], - "time": "2021-12-16T16:49:26+00:00" + "time": "2022-05-21T17:30:32+00:00" }, { "name": "jackiedo/dotenv-editor", - "version": "1.2.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/JackieDo/Laravel-Dotenv-Editor.git", - "reference": "f93690a80915d51552931d9406d79b312da226b9" + "reference": "0971d876567b4bcf96078f8e55700b4726431704" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JackieDo/Laravel-Dotenv-Editor/zipball/f93690a80915d51552931d9406d79b312da226b9", - "reference": "f93690a80915d51552931d9406d79b312da226b9", + "url": "https://api.github.com/repos/JackieDo/Laravel-Dotenv-Editor/zipball/0971d876567b4bcf96078f8e55700b4726431704", + "reference": "0971d876567b4bcf96078f8e55700b4726431704", "shasum": "" }, "require": { - "illuminate/console": "^5.8|^6.0|^7.0|^8.0", - "illuminate/contracts": "^5.8|^6.0|^7.0|^8.0", - "illuminate/support": "^5.8|^6.0|^7.0|^8.0" + "illuminate/console": "^9.0|^8.0|7.0|^6.0|^5.8", + "illuminate/contracts": "^9.0|^8.0|7.0|^6.0|^5.8", + "illuminate/support": "^9.0|^8.0|7.0|^6.0|^5.8", + "jackiedo/path-helper": "^1.0" }, "type": "library", "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + }, "laravel": { "providers": [ "Jackiedo\\DotenvEditor\\DotenvEditorServiceProvider" @@ -1622,7 +1936,7 @@ }, "autoload": { "psr-4": { - "Jackiedo\\DotenvEditor\\": "src/Jackiedo/DotenvEditor" + "Jackiedo\\DotenvEditor\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1643,9 +1957,61 @@ ], "support": { "issues": "https://github.com/JackieDo/Laravel-Dotenv-Editor/issues", - "source": "https://github.com/JackieDo/Laravel-Dotenv-Editor/tree/1.2.0" + "source": "https://github.com/JackieDo/Laravel-Dotenv-Editor/tree/2.0.1" }, - "time": "2020-09-13T07:00:36+00:00" + "time": "2022-03-10T17:04:52+00:00" + }, + { + "name": "jackiedo/path-helper", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/JackieDo/Path-Helper.git", + "reference": "43179cfa17d01f94f4889f286430c2cec1071fb2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JackieDo/Path-Helper/zipball/43179cfa17d01f94f4889f286430c2cec1071fb2", + "reference": "43179cfa17d01f94f4889f286430c2cec1071fb2", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Jackiedo\\PathHelper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jackie Do", + "email": "anhvudo@gmail.com" + } + ], + "description": "Helper class for working with local paths in PHP", + "homepage": "https://github.com/JackieDo/path-helper", + "keywords": [ + "helper", + "helpers", + "library", + "path", + "paths", + "php" + ], + "support": { + "issues": "https://github.com/JackieDo/Path-Helper/issues", + "source": "https://github.com/JackieDo/Path-Helper/tree/v1.0.0" + }, + "time": "2022-03-07T20:28:08+00:00" }, { "name": "james-heinrich/getid3", @@ -1715,57 +2081,107 @@ "time": "2021-09-22T16:34:51+00:00" }, { - "name": "laravel/framework", - "version": "v8.77.1", + "name": "jwilsson/spotify-web-api-php", + "version": "5.2.0", "source": { "type": "git", - "url": "https://github.com/laravel/framework.git", - "reference": "994dbac5c6da856c77c81a411cff5b7d31519ca8" + "url": "https://github.com/jwilsson/spotify-web-api-php.git", + "reference": "0d6dc349669c3cf50cf39fe3c226ca438eec0489" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/994dbac5c6da856c77c81a411cff5b7d31519ca8", - "reference": "994dbac5c6da856c77c81a411cff5b7d31519ca8", + "url": "https://api.github.com/repos/jwilsson/spotify-web-api-php/zipball/0d6dc349669c3cf50cf39fe3c226ca438eec0489", + "reference": "0d6dc349669c3cf50cf39fe3c226ca438eec0489", "shasum": "" }, "require": { - "doctrine/inflector": "^1.4|^2.0", - "dragonmantank/cron-expression": "^3.0.2", - "egulias/email-validator": "^2.1.10", - "ext-json": "*", + "ext-curl": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^9.4", + "squizlabs/php_codesniffer": "^3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpotifyWebAPI\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wilsson", + "email": "jonathan.wilsson@gmail.com" + } + ], + "description": "A PHP wrapper for Spotify's Web API.", + "homepage": "https://github.com/jwilsson/spotify-web-api-php", + "keywords": [ + "spotify" + ], + "support": { + "issues": "https://github.com/jwilsson/spotify-web-api-php/issues", + "source": "https://github.com/jwilsson/spotify-web-api-php/tree/5.2.0" + }, + "time": "2022-07-16T07:32:37+00:00" + }, + { + "name": "laravel/framework", + "version": "v9.22.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "b3b3dd43b9899f23df6d1d3e5390bd4662947a46" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/b3b3dd43b9899f23df6d1d3e5390bd4662947a46", + "reference": "b3b3dd43b9899f23df6d1d3e5390bd4662947a46", + "shasum": "" + }, + "require": { + "doctrine/inflector": "^2.0", + "dragonmantank/cron-expression": "^3.1", + "egulias/email-validator": "^3.1", "ext-mbstring": "*", "ext-openssl": "*", + "fruitcake/php-cors": "^1.2", "laravel/serializable-closure": "^1.0", - "league/commonmark": "^1.3|^2.0.2", - "league/flysystem": "^1.1", + "league/commonmark": "^2.2", + "league/flysystem": "^3.0.16", "monolog/monolog": "^2.0", "nesbot/carbon": "^2.53.1", - "opis/closure": "^3.6", - "php": "^7.3|^8.0", - "psr/container": "^1.0", - "psr/log": "^1.0|^2.0", - "psr/simple-cache": "^1.0", + "nunomaduro/termwind": "^1.13", + "php": "^8.0.2", + "psr/container": "^1.1.1|^2.0.1", + "psr/log": "^1.0|^2.0|^3.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", "ramsey/uuid": "^4.2.2", - "swiftmailer/swiftmailer": "^6.3", - "symfony/console": "^5.4", - "symfony/error-handler": "^5.4", - "symfony/finder": "^5.4", - "symfony/http-foundation": "^5.4", - "symfony/http-kernel": "^5.4", - "symfony/mime": "^5.4", - "symfony/process": "^5.4", - "symfony/routing": "^5.4", - "symfony/var-dumper": "^5.4", + "symfony/console": "^6.0", + "symfony/error-handler": "^6.0", + "symfony/finder": "^6.0", + "symfony/http-foundation": "^6.0", + "symfony/http-kernel": "^6.0", + "symfony/mailer": "^6.0", + "symfony/mime": "^6.0", + "symfony/process": "^6.0", + "symfony/routing": "^6.0", + "symfony/var-dumper": "^6.0", "tijsverkoyen/css-to-inline-styles": "^2.2.2", - "vlucas/phpdotenv": "^5.2", - "voku/portable-ascii": "^1.4.8" + "vlucas/phpdotenv": "^5.4.1", + "voku/portable-ascii": "^2.0" }, "conflict": { "tightenco/collect": "<5.5.33" }, "provide": { - "psr/container-implementation": "1.0", - "psr/simple-cache-implementation": "1.0" + "psr/container-implementation": "1.1|2.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0" }, "replace": { "illuminate/auth": "self.version", @@ -1773,6 +2189,7 @@ "illuminate/bus": "self.version", "illuminate/cache": "self.version", "illuminate/collections": "self.version", + "illuminate/conditionable": "self.version", "illuminate/config": "self.version", "illuminate/console": "self.version", "illuminate/container": "self.version", @@ -1803,18 +2220,22 @@ "require-dev": { "aws/aws-sdk-php": "^3.198.1", "doctrine/dbal": "^2.13.3|^3.1.4", - "filp/whoops": "^2.14.3", - "guzzlehttp/guzzle": "^6.5.5|^7.0.1", - "league/flysystem-cached-adapter": "^1.0", + "fakerphp/faker": "^1.9.2", + "guzzlehttp/guzzle": "^7.2", + "league/flysystem-aws-s3-v3": "^3.0", + "league/flysystem-ftp": "^3.0", + "league/flysystem-sftp-v3": "^3.0", "mockery/mockery": "^1.4.4", - "orchestra/testbench-core": "^6.27", + "orchestra/testbench-core": "^7.1", "pda/pheanstalk": "^4.0", - "phpunit/phpunit": "^8.5.19|^9.5.8", - "predis/predis": "^1.1.9", - "symfony/cache": "^5.4" + "phpstan/phpstan": "^1.4.7", + "phpunit/phpunit": "^9.5.8", + "predis/predis": "^1.1.9|^2.0", + "symfony/cache": "^6.0" }, "suggest": { - "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage and SES mail driver (^3.198.1).", + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.198.1).", "brianium/paratest": "Required to run tests in parallel (^6.0).", "doctrine/dbal": "Required to rename columns and drop SQLite columns (^2.13.3|^3.1.4).", "ext-bcmath": "Required to use the multiple_of validation rule.", @@ -1826,27 +2247,29 @@ "ext-redis": "Required to use the Redis cache and queue drivers (^4.0|^5.0).", "fakerphp/faker": "Required to use the eloquent factory builder (^1.9.1).", "filp/whoops": "Required for friendly error pages in development (^2.14.3).", - "guzzlehttp/guzzle": "Required to use the HTTP Client, Mailgun mail driver and the ping methods on schedules (^6.5.5|^7.0.1).", + "guzzlehttp/guzzle": "Required to use the HTTP Client and the ping methods on schedules (^7.2).", "laravel/tinker": "Required to use the tinker console command (^2.0).", - "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^1.0).", - "league/flysystem-cached-adapter": "Required to use the Flysystem cache (^1.0).", - "league/flysystem-sftp": "Required to use the Flysystem SFTP driver (^1.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.0).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.0).", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.0).", "mockery/mockery": "Required to use mocking (^1.4.4).", "nyholm/psr7": "Required to use PSR-7 bridging features (^1.2).", "pda/pheanstalk": "Required to use the beanstalk queue driver (^4.0).", - "phpunit/phpunit": "Required to use assertions and run tests (^8.5.19|^9.5.8).", - "predis/predis": "Required to use the predis connector (^1.1.9).", + "phpunit/phpunit": "Required to use assertions and run tests (^9.5.8).", + "predis/predis": "Required to use the predis connector (^1.1.9|^2.0).", "psr/http-message": "Required to allow Storage::put to accept a StreamInterface (^1.0).", - "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^4.0|^5.0|^6.0|^7.0).", - "symfony/cache": "Required to PSR-6 cache bridge (^5.4).", - "symfony/filesystem": "Required to enable support for relative symbolic links (^5.4).", - "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0).", - "wildbit/swiftmailer-postmark": "Required to use Postmark mail driver (^3.0)." + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0|^7.0).", + "symfony/cache": "Required to PSR-6 cache bridge (^6.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^6.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^6.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^6.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^6.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^2.0)." }, "type": "library", "extra": { "branch-alias": { - "dev-master": "8.x-dev" + "dev-master": "9.x-dev" } }, "autoload": { @@ -1860,7 +2283,8 @@ "Illuminate\\": "src/Illuminate/", "Illuminate\\Support\\": [ "src/Illuminate/Macroable/", - "src/Illuminate/Collections/" + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/" ] } }, @@ -1884,24 +2308,24 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2021-12-21T20:22:29+00:00" + "time": "2022-07-26T16:16:33+00:00" }, { "name": "laravel/helpers", - "version": "v1.4.1", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/laravel/helpers.git", - "reference": "febb10d8daaf86123825de2cb87f789a3371f0ac" + "reference": "c28b0ccd799d58564c41a62395ac9511a1e72931" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/helpers/zipball/febb10d8daaf86123825de2cb87f789a3371f0ac", - "reference": "febb10d8daaf86123825de2cb87f789a3371f0ac", + "url": "https://api.github.com/repos/laravel/helpers/zipball/c28b0ccd799d58564c41a62395ac9511a1e72931", + "reference": "c28b0ccd799d58564c41a62395ac9511a1e72931", "shasum": "" }, "require": { - "illuminate/support": "~5.8.0|^6.0|^7.0|^8.0", + "illuminate/support": "~5.8.0|^6.0|^7.0|^8.0|^9.0", "php": "^7.1.3|^8.0" }, "require-dev": { @@ -1929,7 +2353,7 @@ }, { "name": "Dries Vints", - "email": "dries.vints@gmail.com" + "email": "dries@laravel.com" } ], "description": "Provides backwards compatibility for helpers in the latest Laravel release.", @@ -1938,34 +2362,35 @@ "laravel" ], "support": { - "source": "https://github.com/laravel/helpers/tree/v1.4.1" + "source": "https://github.com/laravel/helpers/tree/v1.5.0" }, - "time": "2021-02-16T15:27:11+00:00" + "time": "2022-01-12T15:58:51+00:00" }, { "name": "laravel/sanctum", - "version": "v2.13.0", + "version": "v2.15.1", "source": { "type": "git", "url": "https://github.com/laravel/sanctum.git", - "reference": "b4c07d0014b78430a3c827064217f811f0708eaa" + "reference": "31fbe6f85aee080c4dc2f9b03dc6dd5d0ee72473" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sanctum/zipball/b4c07d0014b78430a3c827064217f811f0708eaa", - "reference": "b4c07d0014b78430a3c827064217f811f0708eaa", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/31fbe6f85aee080c4dc2f9b03dc6dd5d0ee72473", + "reference": "31fbe6f85aee080c4dc2f9b03dc6dd5d0ee72473", "shasum": "" }, "require": { "ext-json": "*", - "illuminate/contracts": "^6.9|^7.0|^8.0", - "illuminate/database": "^6.9|^7.0|^8.0", - "illuminate/support": "^6.9|^7.0|^8.0", + "illuminate/console": "^6.9|^7.0|^8.0|^9.0", + "illuminate/contracts": "^6.9|^7.0|^8.0|^9.0", + "illuminate/database": "^6.9|^7.0|^8.0|^9.0", + "illuminate/support": "^6.9|^7.0|^8.0|^9.0", "php": "^7.2|^8.0" }, "require-dev": { "mockery/mockery": "^1.0", - "orchestra/testbench": "^4.0|^5.0|^6.0", + "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0", "phpunit/phpunit": "^8.0|^9.3" }, "type": "library", @@ -2004,41 +2429,41 @@ "issues": "https://github.com/laravel/sanctum/issues", "source": "https://github.com/laravel/sanctum" }, - "time": "2021-12-14T17:49:47+00:00" + "time": "2022-04-08T13:39:49+00:00" }, { "name": "laravel/scout", - "version": "v9.3.4", + "version": "v9.4.10", "source": { "type": "git", "url": "https://github.com/laravel/scout.git", - "reference": "abde06a179d9a12a6691abc0cf9176103ee4eaea" + "reference": "45c7222ccd8f5d8ee069a85deeef799b7dcd79fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/scout/zipball/abde06a179d9a12a6691abc0cf9176103ee4eaea", - "reference": "abde06a179d9a12a6691abc0cf9176103ee4eaea", + "url": "https://api.github.com/repos/laravel/scout/zipball/45c7222ccd8f5d8ee069a85deeef799b7dcd79fa", + "reference": "45c7222ccd8f5d8ee069a85deeef799b7dcd79fa", "shasum": "" }, "require": { - "illuminate/bus": "^8.0", - "illuminate/contracts": "^8.0", - "illuminate/database": "^8.0", - "illuminate/http": "^8.0", - "illuminate/pagination": "^8.0", - "illuminate/queue": "^8.0", - "illuminate/support": "^8.0", + "illuminate/bus": "^8.0|^9.0", + "illuminate/contracts": "^8.0|^9.0", + "illuminate/database": "^8.0|^9.0", + "illuminate/http": "^8.0|^9.0", + "illuminate/pagination": "^8.0|^9.0", + "illuminate/queue": "^8.0|^9.0", + "illuminate/support": "^8.0|^9.0", "php": "^7.3|^8.0" }, "require-dev": { "meilisearch/meilisearch-php": "^0.19", "mockery/mockery": "^1.0", - "orchestra/testbench": "^6.17", + "orchestra/testbench": "^6.17|^7.0", "phpunit/phpunit": "^9.3" }, "suggest": { - "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^2.2).", - "meilisearch/meilisearch-php": "Required to use the MeiliSearch engine (^0.17)." + "algolia/algoliasearch-client-php": "Required to use the Algolia engine (^3.2).", + "meilisearch/meilisearch-php": "Required to use the MeiliSearch engine (^0.23)." }, "type": "library", "extra": { @@ -2076,20 +2501,20 @@ "issues": "https://github.com/laravel/scout/issues", "source": "https://github.com/laravel/scout" }, - "time": "2021-12-23T13:16:05+00:00" + "time": "2022-07-19T14:34:57+00:00" }, { "name": "laravel/serializable-closure", - "version": "v1.0.5", + "version": "v1.2.0", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "25de3be1bca1b17d52ff0dc02b646c667ac7266c" + "reference": "09f0e9fb61829f628205b7c94906c28740ff9540" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/25de3be1bca1b17d52ff0dc02b646c667ac7266c", - "reference": "25de3be1bca1b17d52ff0dc02b646c667ac7266c", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/09f0e9fb61829f628205b7c94906c28740ff9540", + "reference": "09f0e9fb61829f628205b7c94906c28740ff9540", "shasum": "" }, "require": { @@ -2135,26 +2560,26 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2021-11-30T15:53:04+00:00" + "time": "2022-05-16T17:09:47+00:00" }, { "name": "laravel/ui", - "version": "v3.4.1", + "version": "v3.4.6", "source": { "type": "git", "url": "https://github.com/laravel/ui.git", - "reference": "9a1e52442dd238647905b98d773d59e438eb9f9d" + "reference": "65ec5c03f7fee2c8ecae785795b829a15be48c2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/ui/zipball/9a1e52442dd238647905b98d773d59e438eb9f9d", - "reference": "9a1e52442dd238647905b98d773d59e438eb9f9d", + "url": "https://api.github.com/repos/laravel/ui/zipball/65ec5c03f7fee2c8ecae785795b829a15be48c2c", + "reference": "65ec5c03f7fee2c8ecae785795b829a15be48c2c", "shasum": "" }, "require": { "illuminate/console": "^8.42|^9.0", "illuminate/filesystem": "^8.42|^9.0", - "illuminate/support": "^8.42|^9.0", + "illuminate/support": "^8.82|^9.0", "illuminate/validation": "^8.42|^9.0", "php": "^7.3|^8.0" }, @@ -2194,22 +2619,22 @@ "ui" ], "support": { - "source": "https://github.com/laravel/ui/tree/v3.4.1" + "source": "https://github.com/laravel/ui/tree/v3.4.6" }, - "time": "2021-12-22T10:40:50+00:00" + "time": "2022-05-20T13:38:08+00:00" }, { "name": "league/commonmark", - "version": "2.1.0", + "version": "2.3.4", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "819276bc54e83c160617d3ac0a436c239e479928" + "reference": "155ec1c95626b16fda0889cf15904d24890a60d5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/819276bc54e83c160617d3ac0a436c239e479928", - "reference": "819276bc54e83c160617d3ac0a436c239e479928", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/155ec1c95626b16fda0889cf15904d24890a60d5", + "reference": "155ec1c95626b16fda0889cf15904d24890a60d5", "shasum": "" }, "require": { @@ -2217,17 +2642,20 @@ "league/config": "^1.1.1", "php": "^7.4 || ^8.0", "psr/event-dispatcher": "^1.0", - "symfony/polyfill-php80": "^1.15" + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" }, "require-dev": { "cebe/markdown": "^1.0", "commonmark/cmark": "0.30.0", "commonmark/commonmark.js": "0.30.0", "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", "erusev/parsedown": "^1.0", "ext-json": "*", "github/gfm": "0.29.0", "michelf/php-markdown": "^1.4", + "nyholm/psr7": "^1.5", "phpstan/phpstan": "^0.12.88 || ^1.0.0", "phpunit/phpunit": "^9.5.5", "scrutinizer/ocular": "^1.8.1", @@ -2242,7 +2670,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.2-dev" + "dev-main": "2.4-dev" } }, "autoload": { @@ -2299,7 +2727,7 @@ "type": "tidelift" } ], - "time": "2021-12-05T18:25:20+00:00" + "time": "2022-07-17T16:25:47+00:00" }, { "name": "league/config", @@ -2385,54 +2813,48 @@ }, { "name": "league/flysystem", - "version": "1.1.9", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "094defdb4a7001845300334e7c1ee2335925ef99" + "reference": "ed0ecc7f9b5c2f4a9872185846974a808a3b052a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/094defdb4a7001845300334e7c1ee2335925ef99", - "reference": "094defdb4a7001845300334e7c1ee2335925ef99", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/ed0ecc7f9b5c2f4a9872185846974a808a3b052a", + "reference": "ed0ecc7f9b5c2f4a9872185846974a808a3b052a", "shasum": "" }, "require": { - "ext-fileinfo": "*", - "league/mime-type-detection": "^1.3", - "php": "^7.2.5 || ^8.0" + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" }, "conflict": { - "league/flysystem-sftp": "<1.0.6" + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "symfony/http-client": "<5.2" }, "require-dev": { - "phpspec/prophecy": "^1.11.1", - "phpunit/phpunit": "^8.5.8" - }, - "suggest": { - "ext-ftp": "Allows you to use FTP server storage", - "ext-openssl": "Allows you to use FTPS server storage", - "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", - "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", - "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", - "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", - "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", - "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", - "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", - "league/flysystem-webdav": "Allows you to use WebDAV storage", - "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter", - "spatie/flysystem-dropbox": "Allows you to use Dropbox storage", - "srmklive/flysystem-dropbox-v2": "Allows you to use Dropbox storage for PHP 5 applications" + "async-aws/s3": "^1.5", + "async-aws/simple-s3": "^1.0", + "aws/aws-sdk-php": "^3.198.1", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "microsoft/azure-storage-blob": "^1.1", + "phpseclib/phpseclib": "^2.0", + "phpstan/phpstan": "^0.12.26", + "phpunit/phpunit": "^9.5.11", + "sabre/dav": "^4.3.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.1-dev" - } - }, "autoload": { "psr-4": { - "League\\Flysystem\\": "src/" + "League\\Flysystem\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2442,53 +2864,55 @@ "authors": [ { "name": "Frank de Jonge", - "email": "info@frenky.net" + "email": "info@frankdejonge.nl" } ], - "description": "Filesystem abstraction: Many filesystems, one API.", + "description": "File storage abstraction for PHP", "keywords": [ - "Cloud Files", "WebDAV", - "abstraction", "aws", "cloud", - "copy.com", - "dropbox", - "file systems", + "file", "files", "filesystem", "filesystems", "ftp", - "rackspace", - "remote", "s3", "sftp", "storage" ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/1.1.9" + "source": "https://github.com/thephpleague/flysystem/tree/3.2.0" }, "funding": [ { "url": "https://offset.earth/frankdejonge", - "type": "other" + "type": "custom" + }, + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" } ], - "time": "2021-12-09T09:40:50+00:00" + "time": "2022-07-26T07:26:36+00:00" }, { "name": "league/mime-type-detection", - "version": "1.9.0", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "aa70e813a6ad3d1558fc927863d47309b4c23e69" + "reference": "ff6248ea87a9f116e78edd6002e39e5128a0d4dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/aa70e813a6ad3d1558fc927863d47309b4c23e69", - "reference": "aa70e813a6ad3d1558fc927863d47309b4c23e69", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ff6248ea87a9f116e78edd6002e39e5128a0d4dd", + "reference": "ff6248ea87a9f116e78edd6002e39e5128a0d4dd", "shasum": "" }, "require": { @@ -2519,7 +2943,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.9.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.11.0" }, "funding": [ { @@ -2531,7 +2955,7 @@ "type": "tidelift" } ], - "time": "2021-11-21T11:48:40+00:00" + "time": "2022-04-17T13:12:02+00:00" }, { "name": "lstrojny/functional-php", @@ -2557,9 +2981,6 @@ }, "type": "library", "autoload": { - "psr-4": { - "Functional\\": "src/Functional" - }, "files": [ "src/Functional/Ary.php", "src/Functional/Average.php", @@ -2654,7 +3075,10 @@ "src/Functional/With.php", "src/Functional/Zip.php", "src/Functional/ZipAll.php" - ] + ], + "psr-4": { + "Functional\\": "src/Functional" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2688,17 +3112,84 @@ "time": "2021-03-07T00:25:34+00:00" }, { - "name": "monolog/monolog", - "version": "2.3.5", + "name": "meilisearch/meilisearch-php", + "version": "v0.24.0", "source": { "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "fd4380d6fc37626e2f799f29d91195040137eba9" + "url": "https://github.com/meilisearch/meilisearch-php.git", + "reference": "072d43019b7ade2fe9592a078da99d1ab8521086" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/fd4380d6fc37626e2f799f29d91195040137eba9", - "reference": "fd4380d6fc37626e2f799f29d91195040137eba9", + "url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/072d43019b7ade2fe9592a078da99d1ab8521086", + "reference": "072d43019b7ade2fe9592a078da99d1ab8521086", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.4 || ^8.0", + "php-http/client-common": "^2.0", + "php-http/discovery": "^1.7", + "php-http/httplug": "^2.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "guzzlehttp/guzzle": "^7.1", + "http-interop/http-factory-guzzle": "^1.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client", + "http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle" + }, + "type": "library", + "autoload": { + "psr-4": { + "MeiliSearch\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Clementine Urquizar", + "email": "clementine@meilisearch.com" + } + ], + "description": "PHP wrapper for the Meilisearch API", + "keywords": [ + "api", + "client", + "instant", + "meilisearch", + "php", + "search" + ], + "support": { + "issues": "https://github.com/meilisearch/meilisearch-php/issues", + "source": "https://github.com/meilisearch/meilisearch-php/tree/v0.24.0" + }, + "time": "2022-07-11T15:50:51+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "720488632c590286b88b80e62aa3d3d551ad4a50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/720488632c590286b88b80e62aa3d3d551ad4a50", + "reference": "720488632c590286b88b80e62aa3d3d551ad4a50", "shasum": "" }, "require": { @@ -2711,18 +3202,22 @@ "require-dev": { "aws/aws-sdk-php": "^2.4.9 || ^3.0", "doctrine/couchdb": "~1.0@dev", - "elasticsearch/elasticsearch": "^7", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", "graylog2/gelf-php": "^1.4.2", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", "mongodb/mongodb": "^1.8", "php-amqplib/php-amqplib": "~2.4 || ^3", - "php-console/php-console": "^3.1.3", - "phpspec/prophecy": "^1.6.1", + "phpspec/prophecy": "^1.15", "phpstan/phpstan": "^0.12.91", - "phpunit/phpunit": "^8.5", - "predis/predis": "^1.1", - "rollbar/rollbar": "^1.3", - "ruflin/elastica": ">=0.90@dev", - "swiftmailer/swiftmailer": "^5.3|^6.0" + "phpunit/phpunit": "^8.5.14", + "predis/predis": "^1.1 || ^2.0", + "rollbar/rollbar": "^1.3 || ^2 || ^3", + "ruflin/elastica": "^7", + "swiftmailer/swiftmailer": "^5.3|^6.0", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" }, "suggest": { "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", @@ -2737,7 +3232,6 @@ "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", - "php-console/php-console": "Allow sending log messages to Google Chrome", "rollbar/rollbar": "Allow sending log messages to Rollbar", "ruflin/elastica": "Allow sending log messages to an Elastic Search server" }, @@ -2772,7 +3266,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.3.5" + "source": "https://github.com/Seldaek/monolog/tree/2.8.0" }, "funding": [ { @@ -2784,7 +3278,7 @@ "type": "tidelift" } ], - "time": "2021-10-01T21:08:31+00:00" + "time": "2022-07-24T11:55:47+00:00" }, { "name": "mtdowling/jmespath.php", @@ -2818,12 +3312,12 @@ } }, "autoload": { - "psr-4": { - "JmesPath\\": "src/" - }, "files": [ "src/JmesPath.php" - ] + ], + "psr-4": { + "JmesPath\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2849,16 +3343,16 @@ }, { "name": "nesbot/carbon", - "version": "2.55.2", + "version": "2.60.0", "source": { "type": "git", "url": "https://github.com/briannesbitt/Carbon.git", - "reference": "8c2a18ce3e67c34efc1b29f64fe61304368259a2" + "reference": "00a259ae02b003c563158b54fb6743252b638ea6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/8c2a18ce3e67c34efc1b29f64fe61304368259a2", - "reference": "8c2a18ce3e67c34efc1b29f64fe61304368259a2", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/00a259ae02b003c563158b54fb6743252b638ea6", + "reference": "00a259ae02b003c563158b54fb6743252b638ea6", "shasum": "" }, "require": { @@ -2873,10 +3367,12 @@ "doctrine/orm": "^2.7", "friendsofphp/php-cs-fixer": "^3.0", "kylekatarnls/multi-tester": "^2.0", + "ondrejmirtes/better-reflection": "*", "phpmd/phpmd": "^2.9", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^0.12.54", - "phpunit/phpunit": "^7.5.20 || ^8.5.14", + "phpstan/phpstan": "^0.12.99 || ^1.7.14", + "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", + "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", "squizlabs/php_codesniffer": "^3.4" }, "bin": [ @@ -2933,15 +3429,19 @@ }, "funding": [ { - "url": "https://opencollective.com/Carbon", - "type": "open_collective" + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", "type": "tidelift" } ], - "time": "2021-12-03T14:59:52+00:00" + "time": "2022-07-27T15:57:48+00:00" }, { "name": "nette/schema", @@ -3007,16 +3507,16 @@ }, { "name": "nette/utils", - "version": "v3.2.6", + "version": "v3.2.7", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "2f261e55bd6a12057442045bf2c249806abc1d02" + "reference": "0af4e3de4df9f1543534beab255ccf459e7a2c99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/2f261e55bd6a12057442045bf2c249806abc1d02", - "reference": "2f261e55bd6a12057442045bf2c249806abc1d02", + "url": "https://api.github.com/repos/nette/utils/zipball/0af4e3de4df9f1543534beab255ccf459e7a2c99", + "reference": "0af4e3de4df9f1543534beab255ccf459e7a2c99", "shasum": "" }, "require": { @@ -3086,44 +3586,54 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v3.2.6" + "source": "https://github.com/nette/utils/tree/v3.2.7" }, - "time": "2021-11-24T15:47:23+00:00" + "time": "2022-01-24T11:29:14+00:00" }, { - "name": "opis/closure", - "version": "3.6.2", + "name": "nunomaduro/collision", + "version": "v6.2.1", "source": { "type": "git", - "url": "https://github.com/opis/closure.git", - "reference": "06e2ebd25f2869e54a306dda991f7db58066f7f6" + "url": "https://github.com/nunomaduro/collision.git", + "reference": "5f058f7e39278b701e455b3c82ec5298cf001d89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opis/closure/zipball/06e2ebd25f2869e54a306dda991f7db58066f7f6", - "reference": "06e2ebd25f2869e54a306dda991f7db58066f7f6", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/5f058f7e39278b701e455b3c82ec5298cf001d89", + "reference": "5f058f7e39278b701e455b3c82ec5298cf001d89", "shasum": "" }, "require": { - "php": "^5.4 || ^7.0 || ^8.0" + "facade/ignition-contracts": "^1.0.2", + "filp/whoops": "^2.14.5", + "php": "^8.0.0", + "symfony/console": "^6.0.2" }, "require-dev": { - "jeremeamia/superclosure": "^2.0", - "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.0" + "brianium/paratest": "^6.4.1", + "laravel/framework": "^9.7", + "laravel/pint": "^0.2.1", + "nunomaduro/larastan": "^1.0.2", + "nunomaduro/mock-final-classes": "^1.1.0", + "orchestra/testbench": "^7.3.0", + "phpunit/phpunit": "^9.5.11" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.6.x-dev" + "dev-develop": "6.x-dev" + }, + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] } }, "autoload": { "psr-4": { - "Opis\\Closure\\": "src/" - }, - "files": [ - "functions.php" - ] + "NunoMaduro\\Collision\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3131,29 +3641,128 @@ ], "authors": [ { - "name": "Marius Sarca", - "email": "marius.sarca@gmail.com" - }, - { - "name": "Sorin Sarca", - "email": "sarca_sorin@hotmail.com" + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } ], - "description": "A library that can be used to serialize closures (anonymous functions) and arbitrary objects.", - "homepage": "https://opis.io/closure", + "description": "Cli error handling for console/command-line PHP applications.", "keywords": [ - "anonymous functions", - "closure", - "function", - "serializable", - "serialization", - "serialize" + "artisan", + "cli", + "command-line", + "console", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" ], "support": { - "issues": "https://github.com/opis/closure/issues", - "source": "https://github.com/opis/closure/tree/3.6.2" + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" }, - "time": "2021-04-09T13:42:10+00:00" + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2022-06-27T16:11:16+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "132a24bd3e8c559e7f14fa14ba1b83772a0f97f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/132a24bd3e8c559e7f14fa14ba1b83772a0f97f8", + "reference": "132a24bd3e8c559e7f14fa14ba1b83772a0f97f8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.0", + "symfony/console": "^5.3.0|^6.0.0" + }, + "require-dev": { + "ergebnis/phpstan-rules": "^1.0.", + "illuminate/console": "^8.0|^9.0", + "illuminate/support": "^8.0|^9.0", + "laravel/pint": "^0.2.0", + "pestphp/pest": "^1.21.0", + "pestphp/pest-plugin-mock": "^1.0", + "phpstan/phpstan": "^1.4.6", + "phpstan/phpstan-strict-rules": "^1.1.0", + "symfony/var-dumper": "^5.2.7|^6.0.0", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Its like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2022-07-01T15:06:55+00:00" }, { "name": "paragonie/random_compat", @@ -3207,16 +3816,16 @@ }, { "name": "paragonie/sodium_compat", - "version": "v1.17.0", + "version": "v1.17.1", "source": { "type": "git", "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "c59cac21abbcc0df06a3dd18076450ea4797b321" + "reference": "ac994053faac18d386328c91c7900f930acadf1e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/c59cac21abbcc0df06a3dd18076450ea4797b321", - "reference": "c59cac21abbcc0df06a3dd18076450ea4797b321", + "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/ac994053faac18d386328c91c7900f930acadf1e", + "reference": "ac994053faac18d386328c91c7900f930acadf1e", "shasum": "" }, "require": { @@ -3287,9 +3896,398 @@ ], "support": { "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v1.17.0" + "source": "https://github.com/paragonie/sodium_compat/tree/v1.17.1" }, - "time": "2021-08-10T02:43:50+00:00" + "time": "2022-03-23T19:32:04+00:00" + }, + { + "name": "php-http/client-common", + "version": "2.5.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/client-common.git", + "reference": "d135751167d57e27c74de674d6a30cef2dc8e054" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/client-common/zipball/d135751167d57e27c74de674d6a30cef2dc8e054", + "reference": "d135751167d57e27c74de674d6a30cef2dc8e054", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/httplug": "^2.0", + "php-http/message": "^1.6", + "php-http/message-factory": "^1.0", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.0", + "symfony/options-resolver": "~4.0.15 || ~4.1.9 || ^4.2.1 || ^5.0 || ^6.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "doctrine/instantiator": "^1.1", + "guzzlehttp/psr7": "^1.4", + "nyholm/psr7": "^1.2", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "phpspec/prophecy": "^1.10.2", + "phpunit/phpunit": "^7.5.15 || ^8.5 || ^9.3" + }, + "suggest": { + "ext-json": "To detect JSON responses with the ContentTypePlugin", + "ext-libxml": "To detect XML responses with the ContentTypePlugin", + "php-http/cache-plugin": "PSR-6 Cache plugin", + "php-http/logger-plugin": "PSR-3 Logger plugin", + "php-http/stopwatch-plugin": "Symfony Stopwatch plugin" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Client\\Common\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Common HTTP Client implementations and tools for HTTPlug", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "common", + "http", + "httplug" + ], + "support": { + "issues": "https://github.com/php-http/client-common/issues", + "source": "https://github.com/php-http/client-common/tree/2.5.0" + }, + "time": "2021-11-26T15:01:24+00:00" + }, + { + "name": "php-http/discovery", + "version": "1.14.3", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "31d8ee46d0215108df16a8527c7438e96a4d7735" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/31d8ee46d0215108df16a8527c7438e96a4d7735", + "reference": "31d8ee46d0215108df16a8527c7438e96a4d7735", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0" + }, + "require-dev": { + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1" + }, + "suggest": { + "php-http/message": "Allow to use Guzzle, Diactoros or Slim Framework factories" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds installed HTTPlug implementations and PSR-7 message factories", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.14.3" + }, + "time": "2022-07-11T14:04:40+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "f640739f80dfa1152533976e3c112477f69274eb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/f640739f80dfa1152533976e3c112477f69274eb", + "reference": "f640739f80dfa1152533976e3c112477f69274eb", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1", + "phpspec/phpspec": "^5.1 || ^6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.3.0" + }, + "time": "2022-02-21T09:52:22+00:00" + }, + { + "name": "php-http/message", + "version": "1.13.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/message.git", + "reference": "7886e647a30a966a1a8d1dad1845b71ca8678361" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message/zipball/7886e647a30a966a1a8d1dad1845b71ca8678361", + "reference": "7886e647a30a966a1a8d1dad1845b71ca8678361", + "shasum": "" + }, + "require": { + "clue/stream-filter": "^1.5", + "php": "^7.1 || ^8.0", + "php-http/message-factory": "^1.0.2", + "psr/http-message": "^1.0" + }, + "provide": { + "php-http/message-factory-implementation": "1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.6", + "ext-zlib": "*", + "guzzlehttp/psr7": "^1.0", + "laminas/laminas-diactoros": "^2.0", + "phpspec/phpspec": "^5.1 || ^6.3 || ^7.1", + "slim/slim": "^3.0" + }, + "suggest": { + "ext-zlib": "Used with compressor/decompressor streams", + "guzzlehttp/psr7": "Used with Guzzle PSR-7 Factories", + "laminas/laminas-diactoros": "Used with Diactoros Factories", + "slim/slim": "Used with Slim Framework PSR-7 implementation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.10-dev" + } + }, + "autoload": { + "files": [ + "src/filters.php" + ], + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "HTTP Message related tools", + "homepage": "http://php-http.org", + "keywords": [ + "http", + "message", + "psr-7" + ], + "support": { + "issues": "https://github.com/php-http/message/issues", + "source": "https://github.com/php-http/message/tree/1.13.0" + }, + "time": "2022-02-11T13:41:14+00:00" + }, + { + "name": "php-http/message-factory", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-http/message-factory.git", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/message-factory/zipball/a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "reference": "a478cb11f66a6ac48d8954216cfed9aa06a501a1", + "shasum": "" + }, + "require": { + "php": ">=5.4", + "psr/http-message": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Factory interfaces for PSR-7 HTTP Message", + "homepage": "http://php-http.org", + "keywords": [ + "factory", + "http", + "message", + "stream", + "uri" + ], + "support": { + "issues": "https://github.com/php-http/message-factory/issues", + "source": "https://github.com/php-http/message-factory/tree/master" + }, + "time": "2015-12-19T14:08:53+00:00" + }, + { + "name": "php-http/promise", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "4c4c1f9b7289a2ec57cde7f1e9762a5789506f88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/4c4c1f9b7289a2ec57cde7f1e9762a5789506f88", + "reference": "4c4c1f9b7289a2ec57cde7f1e9762a5789506f88", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2", + "phpspec/phpspec": "^5.1.2 || ^6.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.1.0" + }, + "time": "2020-07-07T09:29:14+00:00" }, { "name": "phpoption/phpoption", @@ -3364,16 +4362,16 @@ }, { "name": "predis/predis", - "version": "v1.1.9", + "version": "v1.1.10", "source": { "type": "git", "url": "https://github.com/predis/predis.git", - "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259" + "reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/predis/predis/zipball/c50c3393bb9f47fa012d0cdfb727a266b0818259", - "reference": "c50c3393bb9f47fa012d0cdfb727a266b0818259", + "url": "https://api.github.com/repos/predis/predis/zipball/a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e", + "reference": "a2fb02d738bedadcffdbb07efa3a5e7bd57f8d6e", "shasum": "" }, "require": { @@ -3418,7 +4416,7 @@ ], "support": { "issues": "https://github.com/predis/predis/issues", - "source": "https://github.com/predis/predis/tree/v1.1.9" + "source": "https://github.com/predis/predis/tree/v1.1.10" }, "funding": [ { @@ -3426,26 +4424,80 @@ "type": "github" } ], - "time": "2021-10-05T19:02:38+00:00" + "time": "2022-01-05T17:46:08+00:00" }, { - "name": "psr/container", - "version": "1.1.2", + "name": "psr/cache", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { "php": ">=7.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { "Psr\\Container\\": "src/" @@ -3472,9 +4524,9 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2021-11-05T16:50:12+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/event-dispatcher", @@ -3970,25 +5022,24 @@ }, { "name": "ramsey/uuid", - "version": "4.2.3", + "version": "4.3.1", "source": { "type": "git", "url": "https://github.com/ramsey/uuid.git", - "reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df" + "reference": "8505afd4fea63b81a85d3b7b53ac3cb8dc347c28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ramsey/uuid/zipball/fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df", - "reference": "fc9bb7fb5388691fd7373cd44dcb4d63bbcf24df", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/8505afd4fea63b81a85d3b7b53ac3cb8dc347c28", + "reference": "8505afd4fea63b81a85d3b7b53ac3cb8dc347c28", "shasum": "" }, "require": { "brick/math": "^0.8 || ^0.9", + "ext-ctype": "*", "ext-json": "*", - "php": "^7.2 || ^8.0", - "ramsey/collection": "^1.0", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-php80": "^1.14" + "php": "^8.0", + "ramsey/collection": "^1.0" }, "replace": { "rhumsaa/uuid": "self.version" @@ -4025,20 +5076,17 @@ }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "4.x-dev" - }, "captainhook": { "force-install": true } }, "autoload": { - "psr-4": { - "Ramsey\\Uuid\\": "src/" - }, "files": [ "src/functions.php" - ] + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -4052,7 +5100,7 @@ ], "support": { "issues": "https://github.com/ramsey/uuid/issues", - "source": "https://github.com/ramsey/uuid/tree/4.2.3" + "source": "https://github.com/ramsey/uuid/tree/4.3.1" }, "funding": [ { @@ -4064,126 +5112,46 @@ "type": "tidelift" } ], - "time": "2021-09-25T23:10:38+00:00" - }, - { - "name": "swiftmailer/swiftmailer", - "version": "v6.3.0", - "source": { - "type": "git", - "url": "https://github.com/swiftmailer/swiftmailer.git", - "reference": "8a5d5072dca8f48460fce2f4131fcc495eec654c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/8a5d5072dca8f48460fce2f4131fcc495eec654c", - "reference": "8a5d5072dca8f48460fce2f4131fcc495eec654c", - "shasum": "" - }, - "require": { - "egulias/email-validator": "^2.0|^3.1", - "php": ">=7.0.0", - "symfony/polyfill-iconv": "^1.0", - "symfony/polyfill-intl-idn": "^1.10", - "symfony/polyfill-mbstring": "^1.0" - }, - "require-dev": { - "mockery/mockery": "^1.0", - "symfony/phpunit-bridge": "^4.4|^5.4" - }, - "suggest": { - "ext-intl": "Needed to support internationalized email addresses" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "6.2-dev" - } - }, - "autoload": { - "files": [ - "lib/swift_required.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Chris Corbyn" - }, - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - } - ], - "description": "Swiftmailer, free feature-rich PHP mailer", - "homepage": "https://swiftmailer.symfony.com", - "keywords": [ - "email", - "mail", - "mailer" - ], - "support": { - "issues": "https://github.com/swiftmailer/swiftmailer/issues", - "source": "https://github.com/swiftmailer/swiftmailer/tree/v6.3.0" - }, - "funding": [ - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/swiftmailer/swiftmailer", - "type": "tidelift" - } - ], - "abandoned": "symfony/mailer", - "time": "2021-10-18T15:26:12+00:00" + "time": "2022-03-27T21:42:02+00:00" }, { "name": "symfony/console", - "version": "v5.4.1", + "version": "v6.0.10", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "9130e1a0fc93cb0faadca4ee917171bd2ca9e5f4" + "reference": "d8d41b93c16f1da2f2d4b9209b7de78c4d203642" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/9130e1a0fc93cb0faadca4ee917171bd2ca9e5f4", - "reference": "9130e1a0fc93cb0faadca4ee917171bd2ca9e5f4", + "url": "https://api.github.com/repos/symfony/console/zipball/d8d41b93c16f1da2f2d4b9209b7de78c4d203642", + "reference": "d8d41b93c16f1da2f2d4b9209b7de78c4d203642", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", + "php": ">=8.0.2", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.9", - "symfony/polyfill-php80": "^1.16", "symfony/service-contracts": "^1.1|^2|^3", - "symfony/string": "^5.1|^6.0" + "symfony/string": "^5.4|^6.0" }, "conflict": { - "psr/log": ">=3", - "symfony/dependency-injection": "<4.4", - "symfony/dotenv": "<5.1", - "symfony/event-dispatcher": "<4.4", - "symfony/lock": "<4.4", - "symfony/process": "<4.4" + "symfony/dependency-injection": "<5.4", + "symfony/dotenv": "<5.4", + "symfony/event-dispatcher": "<5.4", + "symfony/lock": "<5.4", + "symfony/process": "<5.4" }, "provide": { - "psr/log-implementation": "1.0|2.0" + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "psr/log": "^1|^2", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/event-dispatcher": "^4.4|^5.0|^6.0", - "symfony/lock": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0" + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/lock": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/var-dumper": "^5.4|^6.0" }, "suggest": { "psr/log": "For using the console logger", @@ -4223,7 +5191,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.1" + "source": "https://github.com/symfony/console/tree/v6.0.10" }, "funding": [ { @@ -4239,25 +5207,24 @@ "type": "tidelift" } ], - "time": "2021-12-09T11:22:43+00:00" + "time": "2022-06-26T13:01:22+00:00" }, { "name": "symfony/css-selector", - "version": "v5.4.0", + "version": "v6.0.3", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "44b933f98bb4b5220d10bed9ce5662f8c2d13dcc" + "reference": "1955d595c12c111629cc814d3f2a2ff13580508a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/44b933f98bb4b5220d10bed9ce5662f8c2d13dcc", - "reference": "44b933f98bb4b5220d10bed9ce5662f8c2d13dcc", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/1955d595c12c111629cc814d3f2a2ff13580508a", + "reference": "1955d595c12c111629cc814d3f2a2ff13580508a", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2" }, "type": "library", "autoload": { @@ -4289,7 +5256,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v5.4.0" + "source": "https://github.com/symfony/css-selector/tree/v6.0.3" }, "funding": [ { @@ -4305,29 +5272,29 @@ "type": "tidelift" } ], - "time": "2021-09-09T08:06:01+00:00" + "time": "2022-01-02T09:55:41+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.5.0", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8" + "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/6f981ee24cf69ee7ce9736146d1c57c2780598a8", - "reference": "6f981ee24cf69ee7ce9736146d1c57c2780598a8", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", + "reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.0.2" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.0-dev" }, "thanks": { "name": "symfony/contracts", @@ -4356,7 +5323,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.0" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.2" }, "funding": [ { @@ -4372,31 +5339,31 @@ "type": "tidelift" } ], - "time": "2021-07-12T14:48:14+00:00" + "time": "2022-01-02T09:55:41+00:00" }, { "name": "symfony/error-handler", - "version": "v5.4.1", + "version": "v6.0.9", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "1e3cb3565af49cd5f93e5787500134500a29f0d9" + "reference": "732ca203b3222cde3378d5ddf5e2883211acc53e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/1e3cb3565af49cd5f93e5787500134500a29f0d9", - "reference": "1e3cb3565af49cd5f93e5787500134500a29f0d9", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/732ca203b3222cde3378d5ddf5e2883211acc53e", + "reference": "732ca203b3222cde3378d5ddf5e2883211acc53e", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^4.4|^5.0|^6.0" + "symfony/var-dumper": "^5.4|^6.0" }, "require-dev": { "symfony/deprecation-contracts": "^2.1|^3", - "symfony/http-kernel": "^4.4|^5.0|^6.0", - "symfony/serializer": "^4.4|^5.0|^6.0" + "symfony/http-kernel": "^5.4|^6.0", + "symfony/serializer": "^5.4|^6.0" }, "bin": [ "Resources/bin/patch-type-declarations" @@ -4427,7 +5394,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v5.4.1" + "source": "https://github.com/symfony/error-handler/tree/v6.0.9" }, "funding": [ { @@ -4443,44 +5410,42 @@ "type": "tidelift" } ], - "time": "2021-12-01T15:04:08+00:00" + "time": "2022-05-23T10:32:42+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v5.4.0", + "version": "v6.0.9", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "27d39ae126352b9fa3be5e196ccf4617897be3eb" + "reference": "5c85b58422865d42c6eb46f7693339056db098a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/27d39ae126352b9fa3be5e196ccf4617897be3eb", - "reference": "27d39ae126352b9fa3be5e196ccf4617897be3eb", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/5c85b58422865d42c6eb46f7693339056db098a8", + "reference": "5c85b58422865d42c6eb46f7693339056db098a8", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/event-dispatcher-contracts": "^2|^3", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2", + "symfony/event-dispatcher-contracts": "^2|^3" }, "conflict": { - "symfony/dependency-injection": "<4.4" + "symfony/dependency-injection": "<5.4" }, "provide": { "psr/event-dispatcher-implementation": "1.0", - "symfony/event-dispatcher-implementation": "2.0" + "symfony/event-dispatcher-implementation": "2.0|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/expression-language": "^4.4|^5.0|^6.0", - "symfony/http-foundation": "^4.4|^5.0|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/error-handler": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", "symfony/service-contracts": "^1.1|^2|^3", - "symfony/stopwatch": "^4.4|^5.0|^6.0" + "symfony/stopwatch": "^5.4|^6.0" }, "suggest": { "symfony/dependency-injection": "", @@ -4512,7 +5477,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v6.0.9" }, "funding": [ { @@ -4528,24 +5493,24 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2022-05-05T16:45:52+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v2.5.0", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a" + "reference": "7bc61cc2db649b4637d331240c5346dcc7708051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/66bea3b09be61613cd3b4043a65a8ec48cfa6d2a", - "reference": "66bea3b09be61613cd3b4043a65a8ec48cfa6d2a", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7bc61cc2db649b4637d331240c5346dcc7708051", + "reference": "7bc61cc2db649b4637d331240c5346dcc7708051", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "psr/event-dispatcher": "^1" }, "suggest": { @@ -4554,7 +5519,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.0-dev" }, "thanks": { "name": "symfony/contracts", @@ -4591,7 +5556,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.0" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.0.2" }, "funding": [ { @@ -4607,26 +5572,24 @@ "type": "tidelift" } ], - "time": "2021-07-12T14:48:14+00:00" + "time": "2022-01-02T09:55:41+00:00" }, { "name": "symfony/finder", - "version": "v5.4.0", + "version": "v6.0.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "d2f29dac98e96a98be467627bd49c2efb1bc2590" + "reference": "af7edab28d17caecd1f40a9219fc646ae751c21f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/d2f29dac98e96a98be467627bd49c2efb1bc2590", - "reference": "d2f29dac98e96a98be467627bd49c2efb1bc2590", + "url": "https://api.github.com/repos/symfony/finder/zipball/af7edab28d17caecd1f40a9219fc646ae751c21f", + "reference": "af7edab28d17caecd1f40a9219fc646ae751c21f", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2" }, "type": "library", "autoload": { @@ -4654,7 +5617,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.0" + "source": "https://github.com/symfony/finder/tree/v6.0.8" }, "funding": [ { @@ -4670,33 +5633,32 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2022-04-15T08:07:58+00:00" }, { "name": "symfony/http-foundation", - "version": "v5.4.1", + "version": "v6.0.10", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "5dad3780023a707f4c24beac7d57aead85c1ce3c" + "reference": "47f2aa677a96ff3b79d2ed70052adf75b16824a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/5dad3780023a707f4c24beac7d57aead85c1ce3c", - "reference": "5dad3780023a707f4c24beac7d57aead85c1ce3c", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/47f2aa677a96ff3b79d2ed70052adf75b16824a9", + "reference": "47f2aa677a96ff3b79d2ed70052adf75b16824a9", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php80": "^1.16" + "symfony/polyfill-mbstring": "~1.1" }, "require-dev": { "predis/predis": "~1.0", - "symfony/cache": "^4.4|^5.0|^6.0", - "symfony/expression-language": "^4.4|^5.0|^6.0", - "symfony/mime": "^4.4|^5.0|^6.0" + "symfony/cache": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0" }, "suggest": { "symfony/mime": "To use the file extension guesser" @@ -4727,7 +5689,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v5.4.1" + "source": "https://github.com/symfony/http-foundation/tree/v6.0.10" }, "funding": [ { @@ -4743,67 +5705,64 @@ "type": "tidelift" } ], - "time": "2021-12-09T12:46:57+00:00" + "time": "2022-06-19T13:16:44+00:00" }, { "name": "symfony/http-kernel", - "version": "v5.4.1", + "version": "v6.0.10", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "2bdace75c9d6a6eec7e318801b7dc87a72375052" + "reference": "fa3e92a78c3f311573671961c7f7a2c5bce0f54d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2bdace75c9d6a6eec7e318801b7dc87a72375052", - "reference": "2bdace75c9d6a6eec7e318801b7dc87a72375052", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fa3e92a78c3f311573671961c7f7a2c5bce0f54d", + "reference": "fa3e92a78c3f311573671961c7f7a2c5bce0f54d", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/log": "^1|^2", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/event-dispatcher": "^5.0|^6.0", - "symfony/http-foundation": "^5.3.7|^6.0", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-php73": "^1.9", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2", + "psr/log": "^1|^2|^3", + "symfony/error-handler": "^5.4|^6.0", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", + "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/browser-kit": "<5.4", - "symfony/cache": "<5.0", - "symfony/config": "<5.0", - "symfony/console": "<4.4", - "symfony/dependency-injection": "<5.3", - "symfony/doctrine-bridge": "<5.0", - "symfony/form": "<5.0", - "symfony/http-client": "<5.0", - "symfony/mailer": "<5.0", - "symfony/messenger": "<5.0", - "symfony/translation": "<5.0", - "symfony/twig-bridge": "<5.0", - "symfony/validator": "<5.0", + "symfony/cache": "<5.4", + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/doctrine-bridge": "<5.4", + "symfony/form": "<5.4", + "symfony/http-client": "<5.4", + "symfony/mailer": "<5.4", + "symfony/messenger": "<5.4", + "symfony/translation": "<5.4", + "symfony/twig-bridge": "<5.4", + "symfony/validator": "<5.4", "twig/twig": "<2.13" }, "provide": { - "psr/log-implementation": "1.0|2.0" + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", "symfony/browser-kit": "^5.4|^6.0", - "symfony/config": "^5.0|^6.0", - "symfony/console": "^4.4|^5.0|^6.0", - "symfony/css-selector": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^5.3|^6.0", - "symfony/dom-crawler": "^4.4|^5.0|^6.0", - "symfony/expression-language": "^4.4|^5.0|^6.0", - "symfony/finder": "^4.4|^5.0|^6.0", + "symfony/config": "^5.4|^6.0", + "symfony/console": "^5.4|^6.0", + "symfony/css-selector": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/dom-crawler": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0", "symfony/http-client-contracts": "^1.1|^2|^3", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/routing": "^4.4|^5.0|^6.0", - "symfony/stopwatch": "^4.4|^5.0|^6.0", - "symfony/translation": "^4.4|^5.0|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/routing": "^5.4|^6.0", + "symfony/stopwatch": "^5.4|^6.0", + "symfony/translation": "^5.4|^6.0", "symfony/translation-contracts": "^1.1|^2|^3", "twig/twig": "^2.13|^3.0.4" }, @@ -4839,7 +5798,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v5.4.1" + "source": "https://github.com/symfony/http-kernel/tree/v6.0.10" }, "funding": [ { @@ -4855,42 +5814,114 @@ "type": "tidelift" } ], - "time": "2021-12-09T13:36:09+00:00" + "time": "2022-06-26T17:02:18+00:00" }, { - "name": "symfony/mime", - "version": "v5.4.0", + "name": "symfony/mailer", + "version": "v6.0.10", "source": { "type": "git", - "url": "https://github.com/symfony/mime.git", - "reference": "d4365000217b67c01acff407573906ff91bcfb34" + "url": "https://github.com/symfony/mailer.git", + "reference": "9b60de35f0b4eed09ee2b25195a478b86acd128d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/d4365000217b67c01acff407573906ff91bcfb34", - "reference": "d4365000217b67c01acff407573906ff91bcfb34", + "url": "https://api.github.com/repos/symfony/mailer/zipball/9b60de35f0b4eed09ee2b25195a478b86acd128d", + "reference": "9b60de35f0b4eed09ee2b25195a478b86acd128d", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", + "egulias/email-validator": "^2.1.10|^3", + "php": ">=8.0.2", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0", + "symfony/service-contracts": "^1.1|^2|^3" + }, + "conflict": { + "symfony/http-kernel": "<5.4" + }, + "require-dev": { + "symfony/http-client-contracts": "^1.1|^2|^3", + "symfony/messenger": "^5.4|^6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v6.0.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-06-19T12:07:20+00:00" + }, + { + "name": "symfony/mime", + "version": "v6.0.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "4de7886c66e0953f5d6edab3e49ceb751d01621c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/4de7886c66e0953f5d6edab3e49ceb751d01621c", + "reference": "4de7886c66e0953f5d6edab3e49ceb751d01621c", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", "symfony/polyfill-intl-idn": "^1.10", - "symfony/polyfill-mbstring": "^1.0", - "symfony/polyfill-php80": "^1.16" + "symfony/polyfill-mbstring": "^1.0" }, "conflict": { "egulias/email-validator": "~3.0.0", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/mailer": "<4.4" + "symfony/mailer": "<5.4" }, "require-dev": { "egulias/email-validator": "^2.1.10|^3.1", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/property-access": "^4.4|^5.1|^6.0", - "symfony/property-info": "^4.4|^5.1|^6.0", - "symfony/serializer": "^5.2|^6.0" + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/property-access": "^5.4|^6.0", + "symfony/property-info": "^5.4|^6.0", + "symfony/serializer": "^5.4|^6.0" }, "type": "library", "autoload": { @@ -4922,7 +5953,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v5.4.0" + "source": "https://github.com/symfony/mime/tree/v6.0.10" }, "funding": [ { @@ -4938,32 +5969,102 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2022-06-09T12:50:38+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.23.0", + "name": "symfony/options-resolver", + "version": "v6.0.3", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "51f7006670febe4cbcbae177cbffe93ff833250d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/46cd95797e9df938fdd2b03693b5fca5e64b01ce", - "reference": "46cd95797e9df938fdd2b03693b5fca5e64b01ce", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/51f7006670febe4cbcbae177cbffe93ff833250d", + "reference": "51f7006670febe4cbcbae177cbffe93ff833250d", + "shasum": "" + }, + "require": { + "php": ">=8.0.2", + "symfony/deprecation-contracts": "^2.1|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v6.0.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-01-02T09:55:41+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.26.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", "shasum": "" }, "require": { "php": ">=7.1" }, + "provide": { + "ext-ctype": "*" + }, "suggest": { "ext-ctype": "For best performance" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -4971,12 +6072,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5001,7 +6102,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.26.0" }, "funding": [ { @@ -5017,100 +6118,20 @@ "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" - }, - { - "name": "symfony/polyfill-iconv", - "version": "v1.23.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "63b5bb7db83e5673936d6e3b8b3e022ff6474933" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/63b5bb7db83e5673936d6e3b8b3e022ff6474933", - "reference": "63b5bb7db83e5673936d6e3b8b3e022ff6474933", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-iconv": "For best performance" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Iconv\\": "" - }, - "files": [ - "bootstrap.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Iconv extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "iconv", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.23.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-05-27T09:27:20+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.23.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "16880ba9c5ebe3642d1995ab866db29270b36535" + "reference": "433d05519ce6990bf3530fba6957499d327395c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/16880ba9c5ebe3642d1995ab866db29270b36535", - "reference": "16880ba9c5ebe3642d1995ab866db29270b36535", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/433d05519ce6990bf3530fba6957499d327395c2", + "reference": "433d05519ce6990bf3530fba6957499d327395c2", "shasum": "" }, "require": { @@ -5122,7 +6143,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5130,12 +6151,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5162,7 +6183,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.26.0" }, "funding": [ { @@ -5178,20 +6199,20 @@ "type": "tidelift" } ], - "time": "2021-05-27T12:26:48+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.23.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65" + "reference": "59a8d271f00dd0e4c2e518104cc7963f655a1aa8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/65bd267525e82759e7d8c4e8ceea44f398838e65", - "reference": "65bd267525e82759e7d8c4e8ceea44f398838e65", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/59a8d271f00dd0e4c2e518104cc7963f655a1aa8", + "reference": "59a8d271f00dd0e4c2e518104cc7963f655a1aa8", "shasum": "" }, "require": { @@ -5205,7 +6226,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5213,12 +6234,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Idn\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5249,7 +6270,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.26.0" }, "funding": [ { @@ -5265,20 +6286,20 @@ "type": "tidelift" } ], - "time": "2021-05-27T09:27:20+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.23.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" + "reference": "219aa369ceff116e673852dce47c3a41794c14bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd", "shasum": "" }, "require": { @@ -5290,7 +6311,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5298,12 +6319,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -5333,7 +6354,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0" }, "funding": [ { @@ -5349,32 +6370,35 @@ "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.23.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6" + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9174a3d80210dca8daa7f31fec659150bbeabfc6", - "reference": "9174a3d80210dca8daa7f31fec659150bbeabfc6", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", "shasum": "" }, "require": { "php": ">=7.1" }, + "provide": { + "ext-mbstring": "*" + }, "suggest": { "ext-mbstring": "For best performance" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5382,12 +6406,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5413,7 +6437,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.26.0" }, "funding": [ { @@ -5429,20 +6453,20 @@ "type": "tidelift" } ], - "time": "2021-05-27T12:26:48+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.23.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "9a142215a36a3888e30d0a9eeea9766764e96976" + "reference": "bf44a9fd41feaac72b074de600314a93e2ae78e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/9a142215a36a3888e30d0a9eeea9766764e96976", - "reference": "9a142215a36a3888e30d0a9eeea9766764e96976", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/bf44a9fd41feaac72b074de600314a93e2ae78e2", + "reference": "bf44a9fd41feaac72b074de600314a93e2ae78e2", "shasum": "" }, "require": { @@ -5451,7 +6475,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5459,12 +6483,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" - }, "files": [ "bootstrap.php" - ] + ], + "psr-4": { + "Symfony\\Polyfill\\Php72\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5489,7 +6513,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php72/tree/v1.26.0" }, "funding": [ { @@ -5505,99 +6529,20 @@ "type": "tidelift" } ], - "time": "2021-05-27T09:17:38+00:00" - }, - { - "name": "symfony/polyfill-php73", - "version": "v1.23.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fba8933c384d6476ab14fb7b8526e5287ca7e010", - "reference": "fba8933c384d6476ab14fb7b8526e5287ca7e010", - "shasum": "" - }, - "require": { - "php": ">=7.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.23-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "files": [ - "bootstrap.php" - ], - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php73/tree/v1.23.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.23.1", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be" + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/1100343ed1a92e3a38f9ae122fc0eb21602547be", - "reference": "1100343ed1a92e3a38f9ae122fc0eb21602547be", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", "shasum": "" }, "require": { @@ -5606,7 +6551,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5614,12 +6559,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -5651,7 +6596,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.23.1" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" }, "funding": [ { @@ -5667,20 +6612,20 @@ "type": "tidelift" } ], - "time": "2021-07-28T13:41:28+00:00" + "time": "2022-05-10T07:21:04+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.23.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "e66119f3de95efc359483f810c4c3e6436279436" + "reference": "13f6d1271c663dc5ae9fb843a8f16521db7687a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/e66119f3de95efc359483f810c4c3e6436279436", - "reference": "e66119f3de95efc359483f810c4c3e6436279436", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/13f6d1271c663dc5ae9fb843a8f16521db7687a1", + "reference": "13f6d1271c663dc5ae9fb843a8f16521db7687a1", "shasum": "" }, "require": { @@ -5689,7 +6634,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -5697,12 +6642,12 @@ } }, "autoload": { - "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, "files": [ "bootstrap.php" ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, "classmap": [ "Resources/stubs" ] @@ -5730,7 +6675,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.23.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.26.0" }, "funding": [ { @@ -5746,25 +6691,24 @@ "type": "tidelift" } ], - "time": "2021-05-21T13:25:03+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/process", - "version": "v5.4.0", + "version": "v6.0.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "5be20b3830f726e019162b26223110c8f47cf274" + "reference": "d074154ea8b1443a96391f6e39f9e547b2dd01b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5be20b3830f726e019162b26223110c8f47cf274", - "reference": "5be20b3830f726e019162b26223110c8f47cf274", + "url": "https://api.github.com/repos/symfony/process/zipball/d074154ea8b1443a96391f6e39f9e547b2dd01b9", + "reference": "d074154ea8b1443a96391f6e39f9e547b2dd01b9", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2" }, "type": "library", "autoload": { @@ -5792,7 +6736,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.0" + "source": "https://github.com/symfony/process/tree/v6.0.8" }, "funding": [ { @@ -5808,41 +6752,39 @@ "type": "tidelift" } ], - "time": "2021-11-28T15:25:38+00:00" + "time": "2022-04-12T16:11:42+00:00" }, { "name": "symfony/routing", - "version": "v5.4.0", + "version": "v6.0.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "9eeae93c32ca86746e5d38f3679e9569981038b1" + "reference": "74c40c9fc334acc601a32fcf4274e74fb3bac11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/9eeae93c32ca86746e5d38f3679e9569981038b1", - "reference": "9eeae93c32ca86746e5d38f3679e9569981038b1", + "url": "https://api.github.com/repos/symfony/routing/zipball/74c40c9fc334acc601a32fcf4274e74fb3bac11e", + "reference": "74c40c9fc334acc601a32fcf4274e74fb3bac11e", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2" }, "conflict": { "doctrine/annotations": "<1.12", - "symfony/config": "<5.3", - "symfony/dependency-injection": "<4.4", - "symfony/yaml": "<4.4" + "symfony/config": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" }, "require-dev": { "doctrine/annotations": "^1.12", "psr/log": "^1|^2|^3", - "symfony/config": "^5.3|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/expression-language": "^4.4|^5.0|^6.0", - "symfony/http-foundation": "^4.4|^5.0|^6.0", - "symfony/yaml": "^4.4|^5.0|^6.0" + "symfony/config": "^5.4|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/expression-language": "^5.4|^6.0", + "symfony/http-foundation": "^5.4|^6.0", + "symfony/yaml": "^5.4|^6.0" }, "suggest": { "symfony/config": "For using the all-in-one router or any loader", @@ -5882,7 +6824,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v5.4.0" + "source": "https://github.com/symfony/routing/tree/v6.0.8" }, "funding": [ { @@ -5898,26 +6840,25 @@ "type": "tidelift" } ], - "time": "2021-11-23T10:19:22+00:00" + "time": "2022-04-22T08:18:02+00:00" }, { "name": "symfony/service-contracts", - "version": "v2.5.0", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc" + "reference": "d78d39c1599bd1188b8e26bb341da52c3c6d8a66" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc", - "reference": "1ab11b933cd6bc5464b08e81e2c5b07dec58b0fc", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d78d39c1599bd1188b8e26bb341da52c3c6d8a66", + "reference": "d78d39c1599bd1188b8e26bb341da52c3c6d8a66", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1" + "php": ">=8.0.2", + "psr/container": "^2.0" }, "conflict": { "ext-psr": "<1.1|>=2" @@ -5928,7 +6869,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.0-dev" }, "thanks": { "name": "symfony/contracts", @@ -5965,7 +6906,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.0.2" }, "funding": [ { @@ -5981,47 +6922,46 @@ "type": "tidelift" } ], - "time": "2021-11-04T16:48:04+00:00" + "time": "2022-05-30T19:17:58+00:00" }, { "name": "symfony/string", - "version": "v5.4.0", + "version": "v6.0.10", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "9ffaaba53c61ba75a3c7a3a779051d1e9ec4fd2d" + "reference": "1b3adf02a0fc814bd9118d7fd68a097a599ebc27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/9ffaaba53c61ba75a3c7a3a779051d1e9ec4fd2d", - "reference": "9ffaaba53c61ba75a3c7a3a779051d1e9ec4fd2d", + "url": "https://api.github.com/repos/symfony/string/zipball/1b3adf02a0fc814bd9118d7fd68a097a599ebc27", + "reference": "1b3adf02a0fc814bd9118d7fd68a097a599ebc27", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.0.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "~1.15" + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/translation-contracts": ">=3.0" + "symfony/translation-contracts": "<2.0" }, "require-dev": { - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/http-client": "^4.4|^5.0|^6.0", - "symfony/translation-contracts": "^1.1|^2", - "symfony/var-exporter": "^4.4|^5.0|^6.0" + "symfony/error-handler": "^5.4|^6.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/translation-contracts": "^2.0|^3.0", + "symfony/var-exporter": "^5.4|^6.0" }, "type": "library", "autoload": { - "psr-4": { - "Symfony\\Component\\String\\": "" - }, "files": [ "Resources/functions.php" ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, "exclude-from-classmap": [ "/Tests/" ] @@ -6051,7 +6991,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.0" + "source": "https://github.com/symfony/string/tree/v6.0.10" }, "funding": [ { @@ -6067,52 +7007,50 @@ "type": "tidelift" } ], - "time": "2021-11-24T10:02:00+00:00" + "time": "2022-06-26T16:34:50+00:00" }, { "name": "symfony/translation", - "version": "v5.4.1", + "version": "v6.0.9", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "8c82cd35ed861236138d5ae1c78c0c7ebcd62107" + "reference": "9ba011309943955a3807b8236c17cff3b88f67b6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/8c82cd35ed861236138d5ae1c78c0c7ebcd62107", - "reference": "8c82cd35ed861236138d5ae1c78c0c7ebcd62107", + "url": "https://api.github.com/repos/symfony/translation/zipball/9ba011309943955a3807b8236c17cff3b88f67b6", + "reference": "9ba011309943955a3807b8236c17cff3b88f67b6", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", + "php": ">=8.0.2", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "^1.16", - "symfony/translation-contracts": "^2.3" + "symfony/translation-contracts": "^2.3|^3.0" }, "conflict": { - "symfony/config": "<4.4", - "symfony/console": "<5.3", - "symfony/dependency-injection": "<5.0", - "symfony/http-kernel": "<5.0", - "symfony/twig-bundle": "<5.0", - "symfony/yaml": "<4.4" + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-kernel": "<5.4", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" }, "provide": { - "symfony/translation-implementation": "2.3" + "symfony/translation-implementation": "2.3|3.0" }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^4.4|^5.0|^6.0", + "symfony/config": "^5.4|^6.0", "symfony/console": "^5.4|^6.0", - "symfony/dependency-injection": "^5.0|^6.0", - "symfony/finder": "^4.4|^5.0|^6.0", + "symfony/dependency-injection": "^5.4|^6.0", + "symfony/finder": "^5.4|^6.0", "symfony/http-client-contracts": "^1.1|^2.0|^3.0", - "symfony/http-kernel": "^5.0|^6.0", - "symfony/intl": "^4.4|^5.0|^6.0", + "symfony/http-kernel": "^5.4|^6.0", + "symfony/intl": "^5.4|^6.0", "symfony/polyfill-intl-icu": "^1.21", "symfony/service-contracts": "^1.1.2|^2|^3", - "symfony/yaml": "^4.4|^5.0|^6.0" + "symfony/yaml": "^5.4|^6.0" }, "suggest": { "psr/log-implementation": "To use logging capability in translator", @@ -6148,7 +7086,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v5.4.1" + "source": "https://github.com/symfony/translation/tree/v6.0.9" }, "funding": [ { @@ -6164,24 +7102,24 @@ "type": "tidelift" } ], - "time": "2021-12-05T20:33:52+00:00" + "time": "2022-05-06T14:27:17+00:00" }, { "name": "symfony/translation-contracts", - "version": "v2.5.0", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e" + "reference": "acbfbb274e730e5a0236f619b6168d9dedb3e282" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/d28150f0f44ce854e942b671fc2620a98aae1b1e", - "reference": "d28150f0f44ce854e942b671fc2620a98aae1b1e", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/acbfbb274e730e5a0236f619b6168d9dedb3e282", + "reference": "acbfbb274e730e5a0236f619b6168d9dedb3e282", "shasum": "" }, "require": { - "php": ">=7.2.5" + "php": ">=8.0.2" }, "suggest": { "symfony/translation-implementation": "" @@ -6189,7 +7127,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.0-dev" }, "thanks": { "name": "symfony/contracts", @@ -6226,7 +7164,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v2.5.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.0.2" }, "funding": [ { @@ -6242,36 +7180,35 @@ "type": "tidelift" } ], - "time": "2021-08-17T14:20:01+00:00" + "time": "2022-06-27T17:10:44+00:00" }, { "name": "symfony/var-dumper", - "version": "v5.4.1", + "version": "v6.0.9", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "2366ac8d8abe0c077844613c1a4f0c0a9f522dcc" + "reference": "ac81072464221e73ee994d12f0b8a2af4a9ed798" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/2366ac8d8abe0c077844613c1a4f0c0a9f522dcc", - "reference": "2366ac8d8abe0c077844613c1a4f0c0a9f522dcc", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/ac81072464221e73ee994d12f0b8a2af4a9ed798", + "reference": "ac81072464221e73ee994d12f0b8a2af4a9ed798", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.0.2", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { "phpunit/phpunit": "<5.4.3", - "symfony/console": "<4.4" + "symfony/console": "<5.4" }, "require-dev": { "ext-iconv": "*", - "symfony/console": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/uid": "^5.1|^6.0", + "symfony/console": "^5.4|^6.0", + "symfony/process": "^5.4|^6.0", + "symfony/uid": "^5.4|^6.0", "twig/twig": "^2.13|^3.0.4" }, "suggest": { @@ -6315,7 +7252,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v5.4.1" + "source": "https://github.com/symfony/var-dumper/tree/v6.0.9" }, "funding": [ { @@ -6331,31 +7268,31 @@ "type": "tidelift" } ], - "time": "2021-12-01T15:04:08+00:00" + "time": "2022-05-21T13:33:31+00:00" }, { "name": "teamtnt/laravel-scout-tntsearch-driver", - "version": "v11.5.0", + "version": "v11.6.0", "source": { "type": "git", "url": "https://github.com/teamtnt/laravel-scout-tntsearch-driver.git", - "reference": "ea962275ee5b977af81dccc138a0fa87d062492b" + "reference": "b98729b0c7179218c9a5e1445922a9313d45c487" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/teamtnt/laravel-scout-tntsearch-driver/zipball/ea962275ee5b977af81dccc138a0fa87d062492b", - "reference": "ea962275ee5b977af81dccc138a0fa87d062492b", + "url": "https://api.github.com/repos/teamtnt/laravel-scout-tntsearch-driver/zipball/b98729b0c7179218c9a5e1445922a9313d45c487", + "reference": "b98729b0c7179218c9a5e1445922a9313d45c487", "shasum": "" }, "require": { - "illuminate/bus": "~5.4|^6.0|^7.0|^8.0", - "illuminate/contracts": "~5.4|^6.0|^7.0|^8.0", - "illuminate/pagination": "~5.4|^6.0|^7.0|^8.0", - "illuminate/queue": "~5.4|^6.0|^7.0|^8.0", - "illuminate/support": "~5.4|^6.0|^7.0|^8.0", + "illuminate/bus": "~5.4|^6.0|^7.0|^8.0|^9.0", + "illuminate/contracts": "~5.4|^6.0|^7.0|^8.0|^9.0", + "illuminate/pagination": "~5.4|^6.0|^7.0|^8.0|^9.0", + "illuminate/queue": "~5.4|^6.0|^7.0|^8.0|^9.0", + "illuminate/support": "~5.4|^6.0|^7.0|^8.0|^9.0", "laravel/scout": "7.*|^8.0|^8.3|^9.0", "php": ">=7.1|^8", - "teamtnt/tntsearch": "2.7.0" + "teamtnt/tntsearch": "2.7.0|^2.8" }, "require-dev": { "mockery/mockery": "^1.0", @@ -6399,22 +7336,22 @@ ], "support": { "issues": "https://github.com/teamtnt/laravel-scout-tntsearch-driver/issues", - "source": "https://github.com/teamtnt/laravel-scout-tntsearch-driver/tree/v11.5.0" + "source": "https://github.com/teamtnt/laravel-scout-tntsearch-driver/tree/v11.6.0" }, - "time": "2021-06-04T12:00:35+00:00" + "time": "2022-02-25T10:32:29+00:00" }, { "name": "teamtnt/tntsearch", - "version": "v2.7.0", + "version": "v2.9.0", "source": { "type": "git", "url": "https://github.com/teamtnt/tntsearch.git", - "reference": "c7d0f67070ea22e835bb1416b85dee0f74780fdc" + "reference": "ccedae0cfe21f7831f2dd1f973cf8904dad42d8d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/teamtnt/tntsearch/zipball/c7d0f67070ea22e835bb1416b85dee0f74780fdc", - "reference": "c7d0f67070ea22e835bb1416b85dee0f74780fdc", + "url": "https://api.github.com/repos/teamtnt/tntsearch/zipball/ccedae0cfe21f7831f2dd1f973cf8904dad42d8d", + "reference": "ccedae0cfe21f7831f2dd1f973cf8904dad42d8d", "shasum": "" }, "require": { @@ -6429,12 +7366,12 @@ }, "type": "library", "autoload": { - "psr-4": { - "TeamTNT\\TNTSearch\\": "src" - }, "files": [ "helper/helpers.php" - ] + ], + "psr-4": { + "TeamTNT\\TNTSearch\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -6463,7 +7400,7 @@ ], "support": { "issues": "https://github.com/teamtnt/tntsearch/issues", - "source": "https://github.com/teamtnt/tntsearch/tree/v2.7.0" + "source": "https://github.com/teamtnt/tntsearch/tree/v2.9.0" }, "funding": [ { @@ -6479,7 +7416,7 @@ "type": "patreon" } ], - "time": "2021-03-11T15:26:17+00:00" + "time": "2022-02-22T10:35:34+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -6616,16 +7553,16 @@ }, { "name": "voku/portable-ascii", - "version": "1.5.6", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/voku/portable-ascii.git", - "reference": "80953678b19901e5165c56752d087fc11526017c" + "reference": "b56450eed252f6801410d810c8e1727224ae0743" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/80953678b19901e5165c56752d087fc11526017c", - "reference": "80953678b19901e5165c56752d087fc11526017c", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b56450eed252f6801410d810c8e1727224ae0743", + "reference": "b56450eed252f6801410d810c8e1727224ae0743", "shasum": "" }, "require": { @@ -6662,7 +7599,7 @@ ], "support": { "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/1.5.6" + "source": "https://github.com/voku/portable-ascii/tree/2.0.1" }, "funding": [ { @@ -6686,25 +7623,25 @@ "type": "tidelift" } ], - "time": "2020-11-12T00:07:28+00:00" + "time": "2022-03-08T17:03:00+00:00" }, { "name": "webmozart/assert", - "version": "1.10.0", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25" + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/6964c76c7804814a842473e0c8fd15bab0f18e25", - "reference": "6964c76c7804814a842473e0c8fd15bab0f18e25", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/11cb2199493b2f8a3b53e7f19068fc6aac760991", + "reference": "11cb2199493b2f8a3b53e7f19068fc6aac760991", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0", - "symfony/polyfill-ctype": "^1.8" + "ext-ctype": "*", + "php": "^7.2 || ^8.0" }, "conflict": { "phpstan/phpstan": "<0.12.20", @@ -6742,36 +7679,38 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.10.0" + "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, - "time": "2021-03-09T10:59:23+00:00" + "time": "2022-06-03T18:03:27+00:00" } ], "packages-dev": [ { - "name": "composer/ca-bundle", - "version": "1.3.1", + "name": "composer/class-map-generator", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/composer/ca-bundle.git", - "reference": "4c679186f2aca4ab6a0f1b0b9cf9252decb44d0b" + "url": "https://github.com/composer/class-map-generator.git", + "reference": "1e1cb2b791facb2dfe32932a7718cf2571187513" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/4c679186f2aca4ab6a0f1b0b9cf9252decb44d0b", - "reference": "4c679186f2aca4ab6a0f1b0b9cf9252decb44d0b", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/1e1cb2b791facb2dfe32932a7718cf2571187513", + "reference": "1e1cb2b791facb2dfe32932a7718cf2571187513", "shasum": "" }, "require": { - "ext-openssl": "*", - "ext-pcre": "*", - "php": "^5.3.2 || ^7.0 || ^8.0" + "composer/pcre": "^2 || ^3", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6" }, "require-dev": { - "phpstan/phpstan": "^0.12.55", - "psr/log": "^1.0", - "symfony/phpunit-bridge": "^4.2 || ^5", - "symfony/process": "^2.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0" + "phpstan/phpstan": "^1.6", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/filesystem": "^5.4 || ^6", + "symfony/phpunit-bridge": "^5" }, "type": "library", "extra": { @@ -6781,7 +7720,7 @@ }, "autoload": { "psr-4": { - "Composer\\CaBundle\\": "src" + "Composer\\ClassMapGenerator\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -6789,123 +7728,19 @@ "MIT" ], "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "Lets you find a path to the system CA bundle, and includes a fallback to the Mozilla CA bundle.", - "keywords": [ - "cabundle", - "cacert", - "certificate", - "ssl", - "tls" - ], - "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.3.1" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-10-28T20:44:15+00:00" - }, - { - "name": "composer/composer", - "version": "2.2.1", - "source": { - "type": "git", - "url": "https://github.com/composer/composer.git", - "reference": "bbc265e16561ab8e0f5e7cac395ea72640251f0c" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/bbc265e16561ab8e0f5e7cac395ea72640251f0c", - "reference": "bbc265e16561ab8e0f5e7cac395ea72640251f0c", - "shasum": "" - }, - "require": { - "composer/ca-bundle": "^1.0", - "composer/metadata-minifier": "^1.0", - "composer/pcre": "^1.0", - "composer/semver": "^3.0", - "composer/spdx-licenses": "^1.2", - "composer/xdebug-handler": "^2.0", - "justinrainbow/json-schema": "^5.2.11", - "php": "^5.3.2 || ^7.0 || ^8.0", - "psr/log": "^1.0 || ^2.0", - "react/promise": "^1.2 || ^2.7", - "seld/jsonlint": "^1.4", - "seld/phar-utils": "^1.0", - "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0", - "symfony/filesystem": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0" - }, - "require-dev": { - "phpspec/prophecy": "^1.10", - "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0" - }, - "suggest": { - "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", - "ext-zip": "Enabling the zip extension allows you to unzip archives", - "ext-zlib": "Allow gzip compression of HTTP requests" - }, - "bin": [ - "bin/composer" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.2-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\": "src/Composer" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "https://www.naderman.de" - }, { "name": "Jordi Boggiano", "email": "j.boggiano@seld.be", "homepage": "https://seld.be" } ], - "description": "Composer helps you declare, manage and install dependencies of PHP projects. It ensures you have the right stack everywhere.", - "homepage": "https://getcomposer.org/", + "description": "Utilities to scan PHP code and generate class maps.", "keywords": [ - "autoload", - "dependency", - "package" + "classmap" ], "support": { - "irc": "ircs://irc.libera.chat:6697/composer", - "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.2.1" + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.0.0" }, "funding": [ { @@ -6921,103 +7756,34 @@ "type": "tidelift" } ], - "time": "2021-12-22T21:21:31+00:00" - }, - { - "name": "composer/metadata-minifier", - "version": "1.0.0", - "source": { - "type": "git", - "url": "https://github.com/composer/metadata-minifier.git", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/metadata-minifier/zipball/c549d23829536f0d0e984aaabbf02af91f443207", - "reference": "c549d23829536f0d0e984aaabbf02af91f443207", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "composer/composer": "^2", - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\MetadataMinifier\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "Small utility library that handles metadata minification and expansion.", - "keywords": [ - "composer", - "compression" - ], - "support": { - "issues": "https://github.com/composer/metadata-minifier/issues", - "source": "https://github.com/composer/metadata-minifier/tree/1.0.0" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-04-07T13:37:33+00:00" + "time": "2022-06-19T11:31:27+00:00" }, { "name": "composer/pcre", - "version": "1.0.0", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "3d322d715c43a1ac36c7fe215fa59336265500f2" + "reference": "e300eb6c535192decd27a85bc72a9290f0d6b3bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/3d322d715c43a1ac36c7fe215fa59336265500f2", - "reference": "3d322d715c43a1ac36c7fe215fa59336265500f2", + "url": "https://api.github.com/repos/composer/pcre/zipball/e300eb6c535192decd27a85bc72a9290f0d6b3bd", + "reference": "e300eb6c535192decd27a85bc72a9290f0d6b3bd", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpstan/phpstan": "^1", + "phpstan/phpstan": "^1.3", "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^4.2 || ^5" + "symfony/phpunit-bridge": "^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.x-dev" + "dev-main": "3.x-dev" } }, "autoload": { @@ -7045,7 +7811,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/1.0.0" + "source": "https://github.com/composer/pcre/tree/3.0.0" }, "funding": [ { @@ -7061,258 +7827,31 @@ "type": "tidelift" } ], - "time": "2021-12-06T15:17:27+00:00" - }, - { - "name": "composer/semver", - "version": "3.2.6", - "source": { - "type": "git", - "url": "https://github.com/composer/semver.git", - "reference": "83e511e247de329283478496f7a1e114c9517506" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/semver/zipball/83e511e247de329283478496f7a1e114c9517506", - "reference": "83e511e247de329283478496f7a1e114c9517506", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^0.12.54", - "symfony/phpunit-bridge": "^4.2 || ^5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "3.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Semver\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "Semver library that offers utilities, version constraint parsing and validation.", - "keywords": [ - "semantic", - "semver", - "validation", - "versioning" - ], - "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/semver/issues", - "source": "https://github.com/composer/semver/tree/3.2.6" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-10-25T11:34:17+00:00" - }, - { - "name": "composer/spdx-licenses", - "version": "1.5.6", - "source": { - "type": "git", - "url": "https://github.com/composer/spdx-licenses.git", - "reference": "a30d487169d799745ca7280bc90fdfa693536901" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/spdx-licenses/zipball/a30d487169d799745ca7280bc90fdfa693536901", - "reference": "a30d487169d799745ca7280bc90fdfa693536901", - "shasum": "" - }, - "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpstan/phpstan": "^0.12.55", - "symfony/phpunit-bridge": "^4.2 || ^5" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Composer\\Spdx\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nils Adermann", - "email": "naderman@naderman.de", - "homepage": "http://www.naderman.de" - }, - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - }, - { - "name": "Rob Bast", - "email": "rob.bast@gmail.com", - "homepage": "http://robbast.nl" - } - ], - "description": "SPDX licenses list and validation library.", - "keywords": [ - "license", - "spdx", - "validator" - ], - "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/spdx-licenses/issues", - "source": "https://github.com/composer/spdx-licenses/tree/1.5.6" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-11-18T10:14:14+00:00" - }, - { - "name": "composer/xdebug-handler", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/composer/xdebug-handler.git", - "reference": "6555461e76962fd0379c444c46fd558a0fcfb65e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6555461e76962fd0379c444c46fd558a0fcfb65e", - "reference": "6555461e76962fd0379c444c46fd558a0fcfb65e", - "shasum": "" - }, - "require": { - "composer/pcre": "^1", - "php": "^5.3.2 || ^7.0 || ^8.0", - "psr/log": "^1 || ^2 || ^3" - }, - "require-dev": { - "phpstan/phpstan": "^1.0", - "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Composer\\XdebugHandler\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "John Stevenson", - "email": "john-stevenson@blueyonder.co.uk" - } - ], - "description": "Restarts a process without Xdebug.", - "keywords": [ - "Xdebug", - "performance" - ], - "support": { - "irc": "irc://irc.freenode.org/composer", - "issues": "https://github.com/composer/xdebug-handler/issues", - "source": "https://github.com/composer/xdebug-handler/tree/2.0.3" - }, - "funding": [ - { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" - } - ], - "time": "2021-12-08T13:07:32+00:00" + "time": "2022-02-25T20:21:48+00:00" }, { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v0.7.1", + "version": "v0.7.2", "source": { "type": "git", "url": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer.git", - "reference": "fe390591e0241955f22eb9ba327d137e501c771c" + "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/fe390591e0241955f22eb9ba327d137e501c771c", - "reference": "fe390591e0241955f22eb9ba327d137e501c771c", + "url": "https://api.github.com/repos/Dealerdirect/phpcodesniffer-composer-installer/zipball/1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", + "reference": "1c968e542d8843d7cd71de3c5c9c3ff3ad71a1db", "shasum": "" }, "require": { "composer-plugin-api": "^1.0 || ^2.0", "php": ">=5.3", - "squizlabs/php_codesniffer": "^2.0 || ^3.0 || ^4.0" + "squizlabs/php_codesniffer": "^2.0 || ^3.1.0 || ^4.0" }, "require-dev": { "composer/composer": "*", - "phpcompatibility/php-compatibility": "^9.0", - "sensiolabs/security-checker": "^4.1.0" + "php-parallel-lint/php-parallel-lint": "^1.3.1", + "phpcompatibility/php-compatibility": "^9.0" }, "type": "composer-plugin", "extra": { @@ -7333,6 +7872,10 @@ "email": "franck.nijhof@dealerdirect.com", "homepage": "http://www.frenck.nl", "role": "Developer / IT Manager" + }, + { + "name": "Contributors", + "homepage": "https://github.com/Dealerdirect/phpcodesniffer-composer-installer/graphs/contributors" } ], "description": "PHP_CodeSniffer Standards Composer Installer Plugin", @@ -7344,6 +7887,7 @@ "codesniffer", "composer", "installer", + "phpcbf", "phpcs", "plugin", "qa", @@ -7358,7 +7902,7 @@ "issues": "https://github.com/dealerdirect/phpcodesniffer-composer-installer/issues", "source": "https://github.com/dealerdirect/phpcodesniffer-composer-installer" }, - "time": "2020-12-07T18:04:37+00:00" + "time": "2022-02-04T12:51:07+00:00" }, { "name": "dms/phpunit-arraysubset-asserts", @@ -7407,29 +7951,30 @@ }, { "name": "doctrine/instantiator", - "version": "1.4.0", + "version": "1.4.1", "source": { "type": "git", "url": "https://github.com/doctrine/instantiator.git", - "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b" + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/d56bf6102915de5702778fe20f2de3b2fe570b5b", - "reference": "d56bf6102915de5702778fe20f2de3b2fe570b5b", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/10dcfce151b967d20fde1b34ae6640712c3891bc", + "reference": "10dcfce151b967d20fde1b34ae6640712c3891bc", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^8.0", + "doctrine/coding-standard": "^9", "ext-pdo": "*", "ext-phar": "*", - "phpbench/phpbench": "^0.13 || 1.0.0-alpha2", - "phpstan/phpstan": "^0.12", - "phpstan/phpstan-phpunit": "^0.12", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpbench/phpbench": "^0.16 || ^1", + "phpstan/phpstan": "^1.4", + "phpstan/phpstan-phpunit": "^1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "vimeo/psalm": "^4.22" }, "type": "library", "autoload": { @@ -7456,7 +8001,7 @@ ], "support": { "issues": "https://github.com/doctrine/instantiator/issues", - "source": "https://github.com/doctrine/instantiator/tree/1.4.0" + "source": "https://github.com/doctrine/instantiator/tree/1.4.1" }, "funding": [ { @@ -7472,216 +8017,20 @@ "type": "tidelift" } ], - "time": "2020-11-10T18:47:58+00:00" - }, - { - "name": "facade/flare-client-php", - "version": "1.9.1", - "source": { - "type": "git", - "url": "https://github.com/facade/flare-client-php.git", - "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/facade/flare-client-php/zipball/b2adf1512755637d0cef4f7d1b54301325ac78ed", - "reference": "b2adf1512755637d0cef4f7d1b54301325ac78ed", - "shasum": "" - }, - "require": { - "facade/ignition-contracts": "~1.0", - "illuminate/pipeline": "^5.5|^6.0|^7.0|^8.0", - "php": "^7.1|^8.0", - "symfony/http-foundation": "^3.3|^4.1|^5.0", - "symfony/mime": "^3.4|^4.0|^5.1", - "symfony/var-dumper": "^3.4|^4.0|^5.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.14", - "phpunit/phpunit": "^7.5.16", - "spatie/phpunit-snapshot-assertions": "^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, - "autoload": { - "psr-4": { - "Facade\\FlareClient\\": "src" - }, - "files": [ - "src/helpers.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Send PHP errors to Flare", - "homepage": "https://github.com/facade/flare-client-php", - "keywords": [ - "exception", - "facade", - "flare", - "reporting" - ], - "support": { - "issues": "https://github.com/facade/flare-client-php/issues", - "source": "https://github.com/facade/flare-client-php/tree/1.9.1" - }, - "funding": [ - { - "url": "https://github.com/spatie", - "type": "github" - } - ], - "time": "2021-09-13T12:16:46+00:00" - }, - { - "name": "facade/ignition", - "version": "2.17.4", - "source": { - "type": "git", - "url": "https://github.com/facade/ignition.git", - "reference": "95c80bd35ee6858e9e1439b2f6a698295eeb2070" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/facade/ignition/zipball/95c80bd35ee6858e9e1439b2f6a698295eeb2070", - "reference": "95c80bd35ee6858e9e1439b2f6a698295eeb2070", - "shasum": "" - }, - "require": { - "ext-curl": "*", - "ext-json": "*", - "ext-mbstring": "*", - "facade/flare-client-php": "^1.9.1", - "facade/ignition-contracts": "^1.0.2", - "illuminate/support": "^7.0|^8.0", - "monolog/monolog": "^2.0", - "php": "^7.2.5|^8.0", - "symfony/console": "^5.0", - "symfony/var-dumper": "^5.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^2.14", - "livewire/livewire": "^2.4", - "mockery/mockery": "^1.3", - "orchestra/testbench": "^5.0|^6.0", - "psalm/plugin-laravel": "^1.2" - }, - "suggest": { - "laravel/telescope": "^3.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - }, - "laravel": { - "providers": [ - "Facade\\Ignition\\IgnitionServiceProvider" - ], - "aliases": { - "Flare": "Facade\\Ignition\\Facades\\Flare" - } - } - }, - "autoload": { - "psr-4": { - "Facade\\Ignition\\": "src" - }, - "files": [ - "src/helpers.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A beautiful error page for Laravel applications.", - "homepage": "https://github.com/facade/ignition", - "keywords": [ - "error", - "flare", - "laravel", - "page" - ], - "support": { - "docs": "https://flareapp.io/docs/ignition-for-laravel/introduction", - "forum": "https://twitter.com/flareappio", - "issues": "https://github.com/facade/ignition/issues", - "source": "https://github.com/facade/ignition" - }, - "time": "2021-12-27T15:11:24+00:00" - }, - { - "name": "facade/ignition-contracts", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/facade/ignition-contracts.git", - "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/facade/ignition-contracts/zipball/3c921a1cdba35b68a7f0ccffc6dffc1995b18267", - "reference": "3c921a1cdba35b68a7f0ccffc6dffc1995b18267", - "shasum": "" - }, - "require": { - "php": "^7.3|^8.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^v2.15.8", - "phpunit/phpunit": "^9.3.11", - "vimeo/psalm": "^3.17.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "Facade\\IgnitionContracts\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Freek Van der Herten", - "email": "freek@spatie.be", - "homepage": "https://flareapp.io", - "role": "Developer" - } - ], - "description": "Solution contracts for Ignition", - "homepage": "https://github.com/facade/ignition-contracts", - "keywords": [ - "contracts", - "flare", - "ignition" - ], - "support": { - "issues": "https://github.com/facade/ignition-contracts/issues", - "source": "https://github.com/facade/ignition-contracts/tree/1.0.2" - }, - "time": "2020-10-16T08:27:54+00:00" + "time": "2022-03-03T08:28:38+00:00" }, { "name": "fakerphp/faker", - "version": "v1.17.0", + "version": "v1.20.0", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "b85e9d44eae8c52cca7aa0939483611f7232b669" + "reference": "37f751c67a5372d4e26353bd9384bc03744ec77b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/b85e9d44eae8c52cca7aa0939483611f7232b669", - "reference": "b85e9d44eae8c52cca7aa0939483611f7232b669", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/37f751c67a5372d4e26353bd9384bc03744ec77b", + "reference": "37f751c67a5372d4e26353bd9384bc03744ec77b", "shasum": "" }, "require": { @@ -7694,10 +8043,12 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", "ext-intl": "*", "symfony/phpunit-bridge": "^4.4 || ^5.2" }, "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", "ext-curl": "Required by Faker\\Provider\\Image to download images.", "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", @@ -7706,7 +8057,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "v1.17-dev" + "dev-main": "v1.20-dev" } }, "autoload": { @@ -7731,80 +8082,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.17.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.20.0" }, - "time": "2021-12-05T17:14:47+00:00" - }, - { - "name": "filp/whoops", - "version": "2.14.4", - "source": { - "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "f056f1fe935d9ed86e698905a957334029899895" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/f056f1fe935d9ed86e698905a957334029899895", - "reference": "f056f1fe935d9ed86e698905a957334029899895", - "shasum": "" - }, - "require": { - "php": "^5.5.9 || ^7.0 || ^8.0", - "psr/log": "^1.0.1 || ^2.0 || ^3.0" - }, - "require-dev": { - "mockery/mockery": "^0.9 || ^1.0", - "phpunit/phpunit": "^4.8.36 || ^5.7.27 || ^6.5.14 || ^7.5.20 || ^8.5.8 || ^9.3.3", - "symfony/var-dumper": "^2.6 || ^3.0 || ^4.0 || ^5.0" - }, - "suggest": { - "symfony/var-dumper": "Pretty print complex values better with var-dumper available", - "whoops/soap": "Formats errors as SOAP responses" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.7-dev" - } - }, - "autoload": { - "psr-4": { - "Whoops\\": "src/Whoops/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Filipe Dobreira", - "homepage": "https://github.com/filp", - "role": "Developer" - } - ], - "description": "php error handling for cool kids", - "homepage": "https://filp.github.io/whoops/", - "keywords": [ - "error", - "exception", - "handling", - "library", - "throwable", - "whoops" - ], - "support": { - "issues": "https://github.com/filp/whoops/issues", - "source": "https://github.com/filp/whoops/tree/2.14.4" - }, - "funding": [ - { - "url": "https://github.com/denis-sokolov", - "type": "github" - } - ], - "time": "2021-10-03T12:00:00+00:00" + "time": "2022-07-20T13:12:54+00:00" }, { "name": "hamcrest/hamcrest-php", @@ -7857,104 +8137,34 @@ }, "time": "2020-07-09T08:09:16+00:00" }, - { - "name": "justinrainbow/json-schema", - "version": "5.2.11", - "source": { - "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ab6744b7296ded80f8cc4f9509abbff393399aa", - "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" - }, - "bin": [ - "bin/validate-json" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" - } - ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/5.2.11" - }, - "time": "2021-07-22T09:24:00+00:00" - }, { "name": "laravel/tinker", - "version": "v2.6.3", + "version": "v2.7.2", "source": { "type": "git", "url": "https://github.com/laravel/tinker.git", - "reference": "a9ddee4761ec8453c584e393b393caff189a3e42" + "reference": "dff39b661e827dae6e092412f976658df82dbac5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/tinker/zipball/a9ddee4761ec8453c584e393b393caff189a3e42", - "reference": "a9ddee4761ec8453c584e393b393caff189a3e42", + "url": "https://api.github.com/repos/laravel/tinker/zipball/dff39b661e827dae6e092412f976658df82dbac5", + "reference": "dff39b661e827dae6e092412f976658df82dbac5", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0", - "illuminate/contracts": "^6.0|^7.0|^8.0", - "illuminate/support": "^6.0|^7.0|^8.0", + "illuminate/console": "^6.0|^7.0|^8.0|^9.0", + "illuminate/contracts": "^6.0|^7.0|^8.0|^9.0", + "illuminate/support": "^6.0|^7.0|^8.0|^9.0", "php": "^7.2.5|^8.0", - "psy/psysh": "^0.10.4", - "symfony/var-dumper": "^4.3.4|^5.0" + "psy/psysh": "^0.10.4|^0.11.1", + "symfony/var-dumper": "^4.3.4|^5.0|^6.0" }, "require-dev": { "mockery/mockery": "~1.3.3|^1.4.2", "phpunit/phpunit": "^8.5.8|^9.3.3" }, "suggest": { - "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0)." + "illuminate/database": "The Illuminate Database package (^6.0|^7.0|^8.0|^9.0)." }, "type": "library", "extra": { @@ -7991,22 +8201,22 @@ ], "support": { "issues": "https://github.com/laravel/tinker/issues", - "source": "https://github.com/laravel/tinker/tree/v2.6.3" + "source": "https://github.com/laravel/tinker/tree/v2.7.2" }, - "time": "2021-12-07T16:41:42+00:00" + "time": "2022-03-23T12:38:24+00:00" }, { "name": "mockery/mockery", - "version": "1.4.4", + "version": "1.5.0", "source": { "type": "git", "url": "https://github.com/mockery/mockery.git", - "reference": "e01123a0e847d52d186c5eb4b9bf58b0c6d00346" + "reference": "c10a5f6e06fc2470ab1822fa13fa2a7380f8fbac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mockery/mockery/zipball/e01123a0e847d52d186c5eb4b9bf58b0c6d00346", - "reference": "e01123a0e847d52d186c5eb4b9bf58b0c6d00346", + "url": "https://api.github.com/repos/mockery/mockery/zipball/c10a5f6e06fc2470ab1822fa13fa2a7380f8fbac", + "reference": "c10a5f6e06fc2470ab1822fa13fa2a7380f8fbac", "shasum": "" }, "require": { @@ -8063,40 +8273,44 @@ ], "support": { "issues": "https://github.com/mockery/mockery/issues", - "source": "https://github.com/mockery/mockery/tree/1.4.4" + "source": "https://github.com/mockery/mockery/tree/1.5.0" }, - "time": "2021-09-13T15:28:59+00:00" + "time": "2022-01-20T13:18:17+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.10.2", + "version": "1.11.0", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220" + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/776f831124e9c62e1a2c601ecc52e776d8bb7220", - "reference": "776f831124e9c62e1a2c601ecc52e776d8bb7220", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614", + "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3,<3.2.2" + }, "require-dev": { - "doctrine/collections": "^1.0", - "doctrine/common": "^2.6", - "phpunit/phpunit": "^7.1" + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", "autoload": { - "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - }, "files": [ "src/DeepCopy/deep_copy.php" - ] + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -8112,7 +8326,7 @@ ], "support": { "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.10.2" + "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0" }, "funding": [ { @@ -8120,20 +8334,20 @@ "type": "tidelift" } ], - "time": "2020-11-13T09:40:50+00:00" + "time": "2022-03-03T13:19:32+00:00" }, { "name": "nikic/php-parser", - "version": "v4.13.2", + "version": "v4.14.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "210577fe3cf7badcc5814d99455df46564f3c077" + "reference": "34bea19b6e03d8153165d8f30bba4c3be86184c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/210577fe3cf7badcc5814d99455df46564f3c077", - "reference": "210577fe3cf7badcc5814d99455df46564f3c077", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/34bea19b6e03d8153165d8f30bba4c3be86184c1", + "reference": "34bea19b6e03d8153165d8f30bba4c3be86184c1", "shasum": "" }, "require": { @@ -8174,137 +8388,52 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.13.2" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.14.0" }, - "time": "2021-11-30T19:35:32+00:00" - }, - { - "name": "nunomaduro/collision", - "version": "v5.10.0", - "source": { - "type": "git", - "url": "https://github.com/nunomaduro/collision.git", - "reference": "3004cfa49c022183395eabc6d0e5207dfe498d00" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/3004cfa49c022183395eabc6d0e5207dfe498d00", - "reference": "3004cfa49c022183395eabc6d0e5207dfe498d00", - "shasum": "" - }, - "require": { - "facade/ignition-contracts": "^1.0", - "filp/whoops": "^2.14.3", - "php": "^7.3 || ^8.0", - "symfony/console": "^5.0" - }, - "require-dev": { - "brianium/paratest": "^6.1", - "fideloper/proxy": "^4.4.1", - "fruitcake/laravel-cors": "^2.0.3", - "laravel/framework": "8.x-dev", - "nunomaduro/larastan": "^0.6.2", - "nunomaduro/mock-final-classes": "^1.0", - "orchestra/testbench": "^6.0", - "phpstan/phpstan": "^0.12.64", - "phpunit/phpunit": "^9.5.0" - }, - "type": "library", - "extra": { - "laravel": { - "providers": [ - "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" - ] - } - }, - "autoload": { - "psr-4": { - "NunoMaduro\\Collision\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nuno Maduro", - "email": "enunomaduro@gmail.com" - } - ], - "description": "Cli error handling for console/command-line PHP applications.", - "keywords": [ - "artisan", - "cli", - "command-line", - "console", - "error", - "handling", - "laravel", - "laravel-zero", - "php", - "symfony" - ], - "support": { - "issues": "https://github.com/nunomaduro/collision/issues", - "source": "https://github.com/nunomaduro/collision" - }, - "funding": [ - { - "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", - "type": "custom" - }, - { - "url": "https://github.com/nunomaduro", - "type": "github" - }, - { - "url": "https://www.patreon.com/nunomaduro", - "type": "patreon" - } - ], - "time": "2021-09-20T15:06:32+00:00" + "time": "2022-05-31T20:59:12+00:00" }, { "name": "nunomaduro/larastan", - "version": "v0.6.13", + "version": "v2.1.12", "source": { "type": "git", "url": "https://github.com/nunomaduro/larastan.git", - "reference": "7a047f7974e6e16d04ee038d86e2c5e6c59e9dfe" + "reference": "65cfc54fa195e509c2e2be119761552017d22a56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/larastan/zipball/7a047f7974e6e16d04ee038d86e2c5e6c59e9dfe", - "reference": "7a047f7974e6e16d04ee038d86e2c5e6c59e9dfe", + "url": "https://api.github.com/repos/nunomaduro/larastan/zipball/65cfc54fa195e509c2e2be119761552017d22a56", + "reference": "65cfc54fa195e509c2e2be119761552017d22a56", "shasum": "" }, "require": { - "composer/composer": "^1.0 || ^2.0", + "composer/class-map-generator": "^1.0", + "composer/pcre": "^3.0", "ext-json": "*", - "illuminate/console": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "illuminate/container": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "illuminate/contracts": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "illuminate/database": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "illuminate/http": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "illuminate/pipeline": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "illuminate/support": "^6.0 || ^7.0 || ^8.0 || ^9.0", - "mockery/mockery": "^0.9 || ^1.0", - "php": "^7.2 || ^8.0", - "phpstan/phpstan": "^0.12.65", - "symfony/process": "^4.3 || ^5.0" + "illuminate/console": "^9", + "illuminate/container": "^9", + "illuminate/contracts": "^9", + "illuminate/database": "^9", + "illuminate/http": "^9", + "illuminate/pipeline": "^9", + "illuminate/support": "^9", + "mockery/mockery": "^1.4.4", + "php": "^8.0.2", + "phpmyadmin/sql-parser": "^5.5", + "phpstan/phpstan": "^1.8.1" }, "require-dev": { - "orchestra/testbench": "^4.0 || ^5.0 || ^6.0 || ^7.0", - "phpunit/phpunit": "^7.3 || ^8.2 || ^9.3" + "nikic/php-parser": "^4.13.2", + "orchestra/testbench": "^7.0.0", + "phpunit/phpunit": "^9.5.11" }, "suggest": { - "orchestra/testbench": "^4.0 || ^5.0" + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench" }, "type": "phpstan-extension", "extra": { "branch-alias": { - "dev-master": "0.6-dev" + "dev-master": "2.0-dev" }, "phpstan": { "includes": [ @@ -8340,11 +8469,11 @@ ], "support": { "issues": "https://github.com/nunomaduro/larastan/issues", - "source": "https://github.com/nunomaduro/larastan/tree/v0.6.13" + "source": "https://github.com/nunomaduro/larastan/tree/v2.1.12" }, "funding": [ { - "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L", + "url": "https://www.paypal.com/paypalme/enunomaduro", "type": "custom" }, { @@ -8360,7 +8489,7 @@ "type": "patreon" } ], - "time": "2021-01-22T12:51:26+00:00" + "time": "2022-07-17T15:23:33+00:00" }, { "name": "phar-io/manifest", @@ -8424,16 +8553,16 @@ }, { "name": "phar-io/version", - "version": "3.1.0", + "version": "3.2.1", "source": { "type": "git", "url": "https://github.com/phar-io/version.git", - "reference": "bae7c545bef187884426f042434e561ab1ddb182" + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/bae7c545bef187884426f042434e561ab1ddb182", - "reference": "bae7c545bef187884426f042434e561ab1ddb182", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", "shasum": "" }, "require": { @@ -8469,22 +8598,22 @@ "description": "Library for handling version information and constraints", "support": { "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.1.0" + "source": "https://github.com/phar-io/version/tree/3.2.1" }, - "time": "2021-02-23T14:00:09+00:00" + "time": "2022-02-21T01:04:05+00:00" }, { "name": "php-mock/php-mock", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/php-mock/php-mock.git", - "reference": "a3142f257153b71c09bf9146ecf73430b3818b7c" + "reference": "9a55bd8ba40e6da2e97a866121d2c69dedd4952b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-mock/php-mock/zipball/a3142f257153b71c09bf9146ecf73430b3818b7c", - "reference": "a3142f257153b71c09bf9146ecf73430b3818b7c", + "url": "https://api.github.com/repos/php-mock/php-mock/zipball/9a55bd8ba40e6da2e97a866121d2c69dedd4952b", + "reference": "9a55bd8ba40e6da2e97a866121d2c69dedd4952b", "shasum": "" }, "require": { @@ -8538,7 +8667,7 @@ ], "support": { "issues": "https://github.com/php-mock/php-mock/issues", - "source": "https://github.com/php-mock/php-mock/tree/2.3.0" + "source": "https://github.com/php-mock/php-mock/tree/2.3.1" }, "funding": [ { @@ -8546,7 +8675,7 @@ "type": "github" } ], - "time": "2020-12-11T19:20:04+00:00" + "time": "2022-02-07T18:57:52+00:00" }, { "name": "php-mock/php-mock-integration", @@ -8775,16 +8904,16 @@ }, { "name": "phpdocumentor/type-resolver", - "version": "1.5.1", + "version": "1.6.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae" + "reference": "77a32518733312af16a44300404e945338981de3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/a12f7e301eb7258bb68acd89d4aefa05c2906cae", - "reference": "a12f7e301eb7258bb68acd89d4aefa05c2906cae", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/77a32518733312af16a44300404e945338981de3", + "reference": "77a32518733312af16a44300404e945338981de3", "shasum": "" }, "require": { @@ -8819,9 +8948,82 @@ "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", "support": { "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.5.1" + "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.1" }, - "time": "2021-10-02T14:08:47+00:00" + "time": "2022-03-15T21:29:03+00:00" + }, + { + "name": "phpmyadmin/sql-parser", + "version": "5.5.0", + "source": { + "type": "git", + "url": "https://github.com/phpmyadmin/sql-parser.git", + "reference": "8ab99cd0007d880f49f5aa1807033dbfa21b1cb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpmyadmin/sql-parser/zipball/8ab99cd0007d880f49f5aa1807033dbfa21b1cb5", + "reference": "8ab99cd0007d880f49f5aa1807033dbfa21b1cb5", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "symfony/polyfill-mbstring": "^1.3" + }, + "conflict": { + "phpmyadmin/motranslator": "<3.0" + }, + "require-dev": { + "phpmyadmin/coding-standard": "^3.0", + "phpmyadmin/motranslator": "^4.0 || ^5.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.2", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/php-code-coverage": "*", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psalm/plugin-phpunit": "^0.16.1", + "vimeo/psalm": "^4.11", + "zumba/json-serializer": "^3.0" + }, + "suggest": { + "ext-mbstring": "For best performance", + "phpmyadmin/motranslator": "Translate messages to your favorite locale" + }, + "bin": [ + "bin/highlight-query", + "bin/lint-query", + "bin/tokenize-query" + ], + "type": "library", + "autoload": { + "psr-4": { + "PhpMyAdmin\\SqlParser\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "The phpMyAdmin Team", + "email": "developers@phpmyadmin.net", + "homepage": "https://www.phpmyadmin.net/team/" + } + ], + "description": "A validating SQL lexer and parser with a focus on MySQL dialect.", + "homepage": "https://github.com/phpmyadmin/sql-parser", + "keywords": [ + "analysis", + "lexer", + "parser", + "sql" + ], + "support": { + "issues": "https://github.com/phpmyadmin/sql-parser/issues", + "source": "https://github.com/phpmyadmin/sql-parser" + }, + "time": "2021-12-09T04:31:52+00:00" }, { "name": "phpspec/prophecy", @@ -8892,35 +9094,31 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.2.0", + "version": "1.6.4", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "dbc093d7af60eff5cd575d2ed761b15ed40bd08e" + "reference": "135607f9ccc297d6923d49c2bcf309f509413215" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/dbc093d7af60eff5cd575d2ed761b15ed40bd08e", - "reference": "dbc093d7af60eff5cd575d2ed761b15ed40bd08e", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/135607f9ccc297d6923d49c2bcf309f509413215", + "reference": "135607f9ccc297d6923d49c2bcf309f509413215", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "php": "^7.2 || ^8.0" }, "require-dev": { "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.0", + "phpstan/phpstan": "^1.5", + "phpstan/phpstan-phpunit": "^1.1", "phpstan/phpstan-strict-rules": "^1.0", "phpunit/phpunit": "^9.5", "symfony/process": "^5.2" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, "autoload": { "psr-4": { "PHPStan\\PhpDocParser\\": [ @@ -8935,26 +9133,26 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.2.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.6.4" }, - "time": "2021-09-16T20:46:02+00:00" + "time": "2022-06-26T13:09:08+00:00" }, { "name": "phpstan/phpstan", - "version": "0.12.99", + "version": "1.8.2", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7" + "reference": "c53312ecc575caf07b0e90dee43883fdf90ca67c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b4d40f1d759942f523be267a1bab6884f46ca3f7", - "reference": "b4d40f1d759942f523be267a1bab6884f46ca3f7", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/c53312ecc575caf07b0e90dee43883fdf90ca67c", + "reference": "c53312ecc575caf07b0e90dee43883fdf90ca67c", "shasum": "" }, "require": { - "php": "^7.1|^8.0" + "php": "^7.2|^8.0" }, "conflict": { "phpstan/phpstan-shim": "*" @@ -8964,11 +9162,6 @@ "phpstan.phar" ], "type": "library", - "extra": { - "branch-alias": { - "dev-master": "0.12-dev" - } - }, "autoload": { "files": [ "bootstrap.php" @@ -8981,7 +9174,7 @@ "description": "PHPStan - PHP Static Analysis Tool", "support": { "issues": "https://github.com/phpstan/phpstan/issues", - "source": "https://github.com/phpstan/phpstan/tree/0.12.99" + "source": "https://github.com/phpstan/phpstan/tree/1.8.2" }, "funding": [ { @@ -9001,20 +9194,20 @@ "type": "tidelift" } ], - "time": "2021-09-12T20:09:55+00:00" + "time": "2022-07-20T09:57:31+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.10", + "version": "9.2.15", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687" + "reference": "2e9da11878c4202f97915c1cb4bb1ca318a63f5f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/d5850aaf931743067f4bfc1ae4cbd06468400687", - "reference": "d5850aaf931743067f4bfc1ae4cbd06468400687", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2e9da11878c4202f97915c1cb4bb1ca318a63f5f", + "reference": "2e9da11878c4202f97915c1cb4bb1ca318a63f5f", "shasum": "" }, "require": { @@ -9070,7 +9263,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.10" + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.15" }, "funding": [ { @@ -9078,7 +9271,7 @@ "type": "github" } ], - "time": "2021-12-05T09:12:13+00:00" + "time": "2022-03-07T09:28:20+00:00" }, { "name": "phpunit/php-file-iterator", @@ -9323,16 +9516,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.5.11", + "version": "9.5.21", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "2406855036db1102126125537adb1406f7242fdd" + "reference": "0e32b76be457de00e83213528f6bb37e2a38fcb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2406855036db1102126125537adb1406f7242fdd", - "reference": "2406855036db1102126125537adb1406f7242fdd", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/0e32b76be457de00e83213528f6bb37e2a38fcb1", + "reference": "0e32b76be457de00e83213528f6bb37e2a38fcb1", "shasum": "" }, "require": { @@ -9348,7 +9541,7 @@ "phar-io/version": "^3.0.2", "php": ">=7.3", "phpspec/prophecy": "^1.12.1", - "phpunit/php-code-coverage": "^9.2.7", + "phpunit/php-code-coverage": "^9.2.13", "phpunit/php-file-iterator": "^3.0.5", "phpunit/php-invoker": "^3.1.1", "phpunit/php-text-template": "^2.0.3", @@ -9362,11 +9555,10 @@ "sebastian/global-state": "^5.0.1", "sebastian/object-enumerator": "^4.0.3", "sebastian/resource-operations": "^3.0.3", - "sebastian/type": "^2.3.4", + "sebastian/type": "^3.0", "sebastian/version": "^3.0.2" }, "require-dev": { - "ext-pdo": "*", "phpspec/prophecy-phpunit": "^2.0.1" }, "suggest": { @@ -9383,11 +9575,11 @@ } }, "autoload": { - "classmap": [ - "src/" - ], "files": [ "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -9410,7 +9602,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.11" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.5.21" }, "funding": [ { @@ -9422,40 +9614,41 @@ "type": "github" } ], - "time": "2021-12-25T07:07:57+00:00" + "time": "2022-06-19T12:14:25+00:00" }, { "name": "psy/psysh", - "version": "v0.10.12", + "version": "v0.11.7", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a0d9981aa07ecfcbea28e4bfa868031cca121e7d" + "reference": "77fc7270031fbc28f9a7bea31385da5c4855cb7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a0d9981aa07ecfcbea28e4bfa868031cca121e7d", - "reference": "a0d9981aa07ecfcbea28e4bfa868031cca121e7d", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/77fc7270031fbc28f9a7bea31385da5c4855cb7a", + "reference": "77fc7270031fbc28f9a7bea31385da5c4855cb7a", "shasum": "" }, "require": { "ext-json": "*", "ext-tokenizer": "*", - "nikic/php-parser": "~4.0|~3.0|~2.0|~1.3", - "php": "^8.0 || ^7.0 || ^5.5.9", - "symfony/console": "~5.0|~4.0|~3.0|^2.4.2|~2.3.10", - "symfony/var-dumper": "~5.0|~4.0|~3.0|~2.7" + "nikic/php-parser": "^4.0 || ^3.1", + "php": "^8.0 || ^7.0.8", + "symfony/console": "^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.2", - "hoa/console": "3.17.*" + "bamarni/composer-bin-plugin": "^1.2" }, "suggest": { "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", "ext-pdo-sqlite": "The doc command requires SQLite to work.", "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well.", - "ext-readline": "Enables support for arrow-key history navigation, and showing and manipulating command history.", - "hoa/console": "A pure PHP readline implementation. You'll want this if your PHP install doesn't already support readline or libedit." + "ext-readline": "Enables support for arrow-key history navigation, and showing and manipulating command history." }, "bin": [ "bin/psysh" @@ -9463,7 +9656,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "0.10.x-dev" + "dev-main": "0.11.x-dev" } }, "autoload": { @@ -9495,59 +9688,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.10.12" + "source": "https://github.com/bobthecow/psysh/tree/v0.11.7" }, - "time": "2021-11-30T14:05:36+00:00" - }, - { - "name": "react/promise", - "version": "v2.8.0", - "source": { - "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/f3cff96a19736714524ca0dd1d4130de73dbbbc4", - "reference": "f3cff96a19736714524ca0dd1d4130de73dbbbc4", - "shasum": "" - }, - "require": { - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^7.0 || ^6.5 || ^5.7 || ^4.8.36" - }, - "type": "library", - "autoload": { - "psr-4": { - "React\\Promise\\": "src/" - }, - "files": [ - "src/functions_include.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com" - } - ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", - "keywords": [ - "promise", - "promises" - ], - "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v2.8.0" - }, - "time": "2020-05-12T15:16:56+00:00" + "time": "2022-07-07T13:49:11+00:00" }, { "name": "sebastian/cli-parser", @@ -9915,16 +10058,16 @@ }, { "name": "sebastian/environment", - "version": "5.1.3", + "version": "5.1.4", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "388b6ced16caa751030f6a69e588299fa09200ac" + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac", - "reference": "388b6ced16caa751030f6a69e588299fa09200ac", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7", + "reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7", "shasum": "" }, "require": { @@ -9966,7 +10109,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/environment/issues", - "source": "https://github.com/sebastianbergmann/environment/tree/5.1.3" + "source": "https://github.com/sebastianbergmann/environment/tree/5.1.4" }, "funding": [ { @@ -9974,7 +10117,7 @@ "type": "github" } ], - "time": "2020-09-28T05:52:38+00:00" + "time": "2022-04-03T09:37:03+00:00" }, { "name": "sebastian/exporter", @@ -10055,16 +10198,16 @@ }, { "name": "sebastian/global-state", - "version": "5.0.3", + "version": "5.0.5", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49" + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/23bd5951f7ff26f12d4e3242864df3e08dec4e49", - "reference": "23bd5951f7ff26f12d4e3242864df3e08dec4e49", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", "shasum": "" }, "require": { @@ -10107,7 +10250,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.3" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" }, "funding": [ { @@ -10115,7 +10258,7 @@ "type": "github" } ], - "time": "2021-06-11T13:31:12+00:00" + "time": "2022-02-14T08:28:10+00:00" }, { "name": "sebastian/lines-of-code", @@ -10406,28 +10549,28 @@ }, { "name": "sebastian/type", - "version": "2.3.4", + "version": "3.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914" + "reference": "b233b84bc4465aff7b57cf1c4bc75c86d00d6dad" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b8cd8a1c753c90bc1a0f5372170e3e489136f914", - "reference": "b8cd8a1c753c90bc1a0f5372170e3e489136f914", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/b233b84bc4465aff7b57cf1c4bc75c86d00d6dad", + "reference": "b233b84bc4465aff7b57cf1c4bc75c86d00d6dad", "shasum": "" }, "require": { "php": ">=7.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^9.5" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.3-dev" + "dev-master": "3.0-dev" } }, "autoload": { @@ -10450,7 +10593,7 @@ "homepage": "https://github.com/sebastianbergmann/type", "support": { "issues": "https://github.com/sebastianbergmann/type/issues", - "source": "https://github.com/sebastianbergmann/type/tree/2.3.4" + "source": "https://github.com/sebastianbergmann/type/tree/3.0.0" }, "funding": [ { @@ -10458,7 +10601,7 @@ "type": "github" } ], - "time": "2021-06-15T12:49:02+00:00" + "time": "2022-03-15T09:54:48+00:00" }, { "name": "sebastian/version", @@ -10513,145 +10656,34 @@ ], "time": "2020-09-28T06:39:44+00:00" }, - { - "name": "seld/jsonlint", - "version": "1.8.3", - "source": { - "type": "git", - "url": "https://github.com/Seldaek/jsonlint.git", - "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/jsonlint/zipball/9ad6ce79c342fbd44df10ea95511a1b24dee5b57", - "reference": "9ad6ce79c342fbd44df10ea95511a1b24dee5b57", - "shasum": "" - }, - "require": { - "php": "^5.3 || ^7.0 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0" - }, - "bin": [ - "bin/jsonlint" - ], - "type": "library", - "autoload": { - "psr-4": { - "Seld\\JsonLint\\": "src/Seld/JsonLint/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be", - "homepage": "http://seld.be" - } - ], - "description": "JSON Linter", - "keywords": [ - "json", - "linter", - "parser", - "validator" - ], - "support": { - "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.8.3" - }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", - "type": "tidelift" - } - ], - "time": "2020-11-11T09:19:24+00:00" - }, - { - "name": "seld/phar-utils", - "version": "1.2.0", - "source": { - "type": "git", - "url": "https://github.com/Seldaek/phar-utils.git", - "reference": "9f3452c93ff423469c0d56450431562ca423dcee" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/9f3452c93ff423469c0d56450431562ca423dcee", - "reference": "9f3452c93ff423469c0d56450431562ca423dcee", - "shasum": "" - }, - "require": { - "php": ">=5.3" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Seld\\PharUtils\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" - } - ], - "description": "PHAR file format utilities, for when PHP phars you up", - "keywords": [ - "phar" - ], - "support": { - "issues": "https://github.com/Seldaek/phar-utils/issues", - "source": "https://github.com/Seldaek/phar-utils/tree/1.2.0" - }, - "time": "2021-12-10T11:20:11+00:00" - }, { "name": "slevomat/coding-standard", - "version": "7.0.18", + "version": "7.2.1", "source": { "type": "git", "url": "https://github.com/slevomat/coding-standard.git", - "reference": "b81ac84f41a4797dc25c8ede1b0718e2a74be0fc" + "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/b81ac84f41a4797dc25c8ede1b0718e2a74be0fc", - "reference": "b81ac84f41a4797dc25c8ede1b0718e2a74be0fc", + "url": "https://api.github.com/repos/slevomat/coding-standard/zipball/aff06ae7a84e4534bf6f821dc982a93a5d477c90", + "reference": "aff06ae7a84e4534bf6f821dc982a93a5d477c90", "shasum": "" }, "require": { "dealerdirect/phpcodesniffer-composer-installer": "^0.6.2 || ^0.7", - "php": "^7.1 || ^8.0", - "phpstan/phpdoc-parser": "^1.0.0", - "squizlabs/php_codesniffer": "^3.6.1" + "php": "^7.2 || ^8.0", + "phpstan/phpdoc-parser": "^1.5.1", + "squizlabs/php_codesniffer": "^3.6.2" }, "require-dev": { - "phing/phing": "2.17.0", - "php-parallel-lint/php-parallel-lint": "1.3.1", - "phpstan/phpstan": "1.2.0", + "phing/phing": "2.17.3", + "php-parallel-lint/php-parallel-lint": "1.3.2", + "phpstan/phpstan": "1.4.10|1.7.1", "phpstan/phpstan-deprecation-rules": "1.0.0", - "phpstan/phpstan-phpunit": "1.0.0", - "phpstan/phpstan-strict-rules": "1.1.0", - "phpunit/phpunit": "7.5.20|8.5.21|9.5.10" + "phpstan/phpstan-phpunit": "1.0.0|1.1.1", + "phpstan/phpstan-strict-rules": "1.2.3", + "phpunit/phpunit": "7.5.20|8.5.21|9.5.20" }, "type": "phpcodesniffer-standard", "extra": { @@ -10671,7 +10703,7 @@ "description": "Slevomat Coding Standard for PHP_CodeSniffer complements Consistence Coding Standard by providing sniffs with additional checks.", "support": { "issues": "https://github.com/slevomat/coding-standard/issues", - "source": "https://github.com/slevomat/coding-standard/tree/7.0.18" + "source": "https://github.com/slevomat/coding-standard/tree/7.2.1" }, "funding": [ { @@ -10683,20 +10715,20 @@ "type": "tidelift" } ], - "time": "2021-12-07T17:19:06+00:00" + "time": "2022-05-25T10:58:12+00:00" }, { "name": "squizlabs/php_codesniffer", - "version": "3.6.2", + "version": "3.7.1", "source": { "type": "git", "url": "https://github.com/squizlabs/PHP_CodeSniffer.git", - "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a" + "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/5e4e71592f69da17871dba6e80dd51bce74a351a", - "reference": "5e4e71592f69da17871dba6e80dd51bce74a351a", + "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619", + "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619", "shasum": "" }, "require": { @@ -10739,71 +10771,7 @@ "source": "https://github.com/squizlabs/PHP_CodeSniffer", "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki" }, - "time": "2021-12-12T21:44:58+00:00" - }, - { - "name": "symfony/filesystem", - "version": "v5.4.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "731f917dc31edcffec2c6a777f3698c33bea8f01" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/731f917dc31edcffec2c6a777f3698c33bea8f01", - "reference": "731f917dc31edcffec2c6a777f3698c33bea8f01", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8", - "symfony/polyfill-php80": "^1.16" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Provides basic utilities for the filesystem", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.4.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2021-10-28T13:39:27+00:00" + "time": "2022-06-18T07:21:10+00:00" }, { "name": "theseer/tokenizer", @@ -10862,8 +10830,9 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.4", + "php": ">=8.0", "ext-exif": "*", + "ext-gd": "*", "ext-fileinfo": "*", "ext-json": "*", "ext-simplexml": "*" diff --git a/config/app.php b/config/app.php index 4e564b1a..a3de3d38 100644 --- a/config/app.php +++ b/config/app.php @@ -82,7 +82,7 @@ return [ | */ - 'key' => env('APP_KEY', 'SomeRandomStringWith32Characters'), + 'key' => env('APP_KEY'), 'cipher' => 'AES-256-CBC', diff --git a/config/koel.php b/config/koel.php index 090521f7..06076458 100644 --- a/config/koel.php +++ b/config/koel.php @@ -68,6 +68,21 @@ return [ 'endpoint' => 'https://ws.audioscrobbler.com/2.0', ], + /* + |-------------------------------------------------------------------------- + | Last.FM Integration + |-------------------------------------------------------------------------- + | + | See wiki on how to integrate with Last.FM + | + */ + + 'spotify' => [ + 'client_id' => env('SPOTIFY_CLIENT_ID'), + 'client_secret' => env('SPOTIFY_CLIENT_SECRET'), + ], + + /* |-------------------------------------------------------------------------- | CDN diff --git a/cypress/fixtures/info.get.200.json b/cypress/fixtures/song-info.get.200.json similarity index 100% rename from cypress/fixtures/info.get.200.json rename to cypress/fixtures/song-info.get.200.json diff --git a/cypress/integration/about.spec.ts b/cypress/integration/about.spec.ts index 499dd14f..3e0e1219 100644 --- a/cypress/integration/about.spec.ts +++ b/cypress/integration/about.spec.ts @@ -3,7 +3,7 @@ context('About Koel', () => { it('displays the About modal', () => { cy.findByTestId('about-btn').click() - cy.findByTestId('about-modal').should('be.visible').within(() => cy.get('[data-test=close-modal-btn]').click()) + cy.findByTestId('about-modal').should('be.visible').within(() => cy.findByTestId('close-modal-btn').click()) cy.findByTestId('about-modal').should('not.exist') }) }) diff --git a/cypress/integration/albums.spec.ts b/cypress/integration/albums.spec.ts index 9161f3bb..f3c32f92 100644 --- a/cypress/integration/albums.spec.ts +++ b/cypress/integration/albums.spec.ts @@ -1,4 +1,4 @@ -context('Albums', { scrollBehavior: false }, () => { +context.only('Albums', { scrollBehavior: false }, () => { beforeEach(() => { cy.$login() cy.$clickSidebarItem('Albums') @@ -6,28 +6,20 @@ context('Albums', { scrollBehavior: false }, () => { it('loads the list of albums', () => { cy.get('#albumsWrapper').within(() => { - cy.get('.screen-header') - .should('be.visible') - .and('contain.text', 'Albums') - - cy.get('[data-test=view-mode-thumbnail]') - .should('be.visible') - .and('have.class', 'active') - - cy.get('[data-test=view-mode-list]') - .should('be.visible') - .and('not.have.class', 'active') - cy.get('[data-test=album-card]').should('have.length', 7) + cy.get('.screen-header').should('be.visible').and('contain.text', 'Albums') + cy.findByTestId('view-mode-thumbnail').should('be.visible').and('have.class', 'active') + cy.findByTestId('view-mode-list').should('be.visible').and('not.have.class', 'active') + cy.findAllByTestId('album-card').should('have.length', 7) }) }) it('changes display mode', () => { cy.get('#albumsWrapper').should('be.visible').within(() => { - cy.get('[data-test=album-card]').should('have.length', 7) - cy.get('[data-test=view-mode-list]').click() - cy.get('[data-test=album-card].compact').should('have.length', 7) - cy.get('[data-test=view-mode-thumbnail]').click() - cy.get('[data-test=album-card].full').should('have.length', 7) + cy.findAllByTestId('album-card').should('have.length', 7) + cy.findByTestId('view-mode-list').click() + cy.get('[data-testid=album-card].compact').should('have.length', 7) + cy.findByTestId('view-mode-thumbnail').click() + cy.get('[data-testid=album-card].full').should('have.length', 7) }) }) @@ -35,7 +27,7 @@ context('Albums', { scrollBehavior: false }, () => { cy.$mockPlayback() cy.get('#albumsWrapper').within(() => { - cy.get('[data-test=album-card]:first-child .control-play') + cy.get('[data-testid=album-card]:first-child .control-play') .invoke('show') .click() }) @@ -50,37 +42,37 @@ context('Albums', { scrollBehavior: false }, () => { }) cy.get('#albumsWrapper').within(() => { - cy.get('[data-test=album-card]:first-child .name').click() + cy.get('[data-testid=album-card]:first-child .name').click() }) cy.get('#albumWrapper').within(() => { - cy.get('tr.song-item').should('have.length.at.least', 1) + cy.$getSongRows().should('have.length.at.least', 1) cy.get('.screen-header').within(() => { cy.findByText('Download All').should('be.visible') cy.findByText('Info').click() }) - cy.get('[data-test=album-info]').should('be.visible').within(() => { + cy.findByTestId('album-info').should('be.visible').within(() => { cy.findByText('Album full wiki').should('be.visible') cy.get('.cover').should('be.visible') - cy.get('[data-test=album-info-tracks]').should('be.visible').within(() => { - // out of 4 tracks, 3 are already available in Koel. The last one has a link to iTunes. + cy.findByTestId('album-info-tracks').should('be.visible').within(() => { + // out of 4 tracks, 3 are already available in Koel. The last one has a link to Apple Music. cy.get('li').should('have.length', 4) cy.get('li.available').should('have.length', 3) - cy.get('li:last-child a.view-on-itunes').should('be.visible') + cy.get('li:last-child a[title="Preview and buy this song on Apple Music"]').should('be.visible') }) }) - cy.get('[data-test=close-modal-btn]').click() - cy.get('[data-test=album-info]').should('not.exist') + cy.findByTestId('close-modal-btn').click() + cy.findByTestId('album-info').should('not.exist') }) }) it('invokes artist screen', () => { cy.get('#albumsWrapper').within(() => { - cy.get('[data-test=album-card]:first-child .artist').click() + cy.get('[data-testid=album-card]:first-child .artist').click() cy.url().should('contain', '/#!/artist/3') // rest of the assertions belong to the Artist spec }) diff --git a/cypress/integration/artists.spec.ts b/cypress/integration/artists.spec.ts index 6f649f58..27d3312c 100644 --- a/cypress/integration/artists.spec.ts +++ b/cypress/integration/artists.spec.ts @@ -6,28 +6,20 @@ context('Artists', { scrollBehavior: false }, () => { it('loads the list of artists', () => { cy.get('#artistsWrapper').within(() => { - cy.get('.screen-header') - .should('be.visible') - .and('contain.text', 'Artists') - - cy.get('[data-test=view-mode-thumbnail]') - .should('be.visible') - .and('have.class', 'active') - - cy.get('[data-test=view-mode-list]') - .should('be.visible') - .and('not.have.class', 'active') - cy.get('[data-test=artist-card]').should('have.length', 1) + cy.get('.screen-header').should('be.visible').and('contain.text', 'Artists') + cy.findByTestId('view-mode-thumbnail').should('be.visible').and('have.class', 'active') + cy.findByTestId('view-mode-list').should('be.visible').and('not.have.class', 'active') + cy.findAllByTestId('artist-card').should('have.length', 1) }) }) it('changes display mode', () => { cy.get('#artistsWrapper').should('be.visible').within(() => { - cy.get('[data-test=artist-card]').should('have.length', 1) - cy.get('[data-test=view-mode-list]').click() - cy.get('[data-test=artist-card].compact').should('have.length', 1) - cy.get('[data-test=view-mode-thumbnail]').click() - cy.get('[data-test=artist-card].full').should('have.length', 1) + cy.findAllByTestId('artist-card').should('have.length', 1) + cy.findByTestId('view-mode-list').click() + cy.get('[data-testid=artist-card].compact').should('have.length', 1) + cy.findByTestId('view-mode-thumbnail').click() + cy.get('[data-testid=artist-card].full').should('have.length', 1) }) }) @@ -35,7 +27,7 @@ context('Artists', { scrollBehavior: false }, () => { cy.$mockPlayback() cy.get('#artistsWrapper').within(() => { - cy.get('[data-test=artist-card]:first-child .control-play') + cy.get('[data-testid=artist-card]:first-child .control-play') .invoke('show') .click() }) @@ -50,25 +42,25 @@ context('Artists', { scrollBehavior: false }, () => { }) cy.get('#artistsWrapper').within(() => { - cy.get('[data-test=artist-card]:first-child .name').click() + cy.get('[data-testid=artist-card]:first-child .name').click() cy.url().should('contain', '/#!/artist/3') }) cy.get('#artistWrapper').within(() => { - cy.get('tr.song-item').should('have.length.at.least', 1) + cy.$getSongRows().should('have.length.at.least', 1) cy.get('.screen-header').within(() => { cy.findByText('Download All').should('be.visible') cy.findByText('Info').click() }) - cy.get('[data-test=artist-info]').should('be.visible').within(() => { + cy.findByTestId('artist-info').should('be.visible').within(() => { cy.findByText('Artist full bio').should('be.visible') cy.get('.cover').should('be.visible') }) - cy.get('[data-test=close-modal-btn]').click() - cy.get('[data-test=artist-info]').should('not.exist') + cy.findByTestId('close-modal-btn').click() + cy.findByTestId('artist-info').should('not.exist') }) }) }) diff --git a/cypress/integration/authentication.spec.ts b/cypress/integration/authentication.spec.ts index 5d4b473b..a71a2fd1 100644 --- a/cypress/integration/authentication.spec.ts +++ b/cypress/integration/authentication.spec.ts @@ -26,9 +26,7 @@ context('Authentication', () => { cy.visit('/') submitLoginForm() - cy.findByTestId('login-form') - .should('be.visible') - .and('have.class', 'error') + cy.findByTestId('login-form').should('be.visible').and('have.class', 'error') }) it('logs out', () => { diff --git a/cypress/integration/extra-panel.spec.ts b/cypress/integration/extra-panel.spec.ts index 29a182af..65ffe625 100644 --- a/cypress/integration/extra-panel.spec.ts +++ b/cypress/integration/extra-panel.spec.ts @@ -7,32 +7,32 @@ context('Extra Information Panel', () => { }) it('displays an option to add lyrics if blank', () => { - cy.fixture('info.get.200.json').then(data => { + cy.fixture('song-info.get.200.json').then(data => { data.lyrics = null - cy.intercept('GET', '/api/**/info', { + cy.intercept('/api/**/info', { statusCode: 200, body: data }) }) cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper tr.song-item:first-child').dblclick() + cy.$getSongRows().first().dblclick() cy.get('#extraPanelLyrics').should('be.visible').and('contain.text', 'No lyrics found.') - cy.get('#extraPanelLyrics [data-test=add-lyrics-btn]').click() + cy.findByTestId('add-lyrics-btn').click() cy.findByTestId('edit-song-form').should('be.visible').within(() => { cy.get('[name=lyrics]').should('have.focus') }) }) - it('displays the band information', () => { + it('displays the artist information', () => { cy.$shuffleSeveralSongs() cy.get('#extraTabArtist').click() cy.get('#extraPanelArtist').should('be.visible').within(() => { - cy.get('[data-test=artist-info]').should('be.visible') + cy.findByTestId('artist-info').should('be.visible') cy.findByText('Artist summary').should('be.visible') - cy.get('[data-test=more-btn]').click() + cy.findByTestId('more-btn').click() cy.findByText('Artist summary').should('not.exist') cy.findByText('Artist full bio').should('be.visible') }) @@ -42,9 +42,9 @@ context('Extra Information Panel', () => { cy.$shuffleSeveralSongs() cy.get('#extraTabAlbum').click() cy.get('#extraPanelAlbum').should('be.visible').within(() => { - cy.get('[data-test=album-info]').should('be.visible') + cy.findByTestId('album-info').should('be.visible') cy.findByText('Album summary').should('be.visible') - cy.get('[data-test=more-btn]').click() + cy.findByTestId('more-btn').click() cy.findByText('Album summary').should('not.exist') cy.findByText('Album full wiki').should('be.visible') }) diff --git a/cypress/integration/favorites.spec.ts b/cypress/integration/favorites.spec.ts index d0b21c1c..c0192fdf 100644 --- a/cypress/integration/favorites.spec.ts +++ b/cypress/integration/favorites.spec.ts @@ -8,12 +8,9 @@ context('Favorites', { scrollBehavior: false }, () => { .within(() => { cy.findByText('Songs You Love').should('be.visible') cy.findByText('Download All').should('be.visible') - cy.get('tr.song-item').should('have.length', 3) - .each(row => { - cy.wrap(row) - .get('[data-test=btn-like-liked]') - .should('be.visible') - }) + + cy.$getSongRows().should('have.length', 3) + .each(row => cy.wrap(row).findByTestId('btn-like-liked').should('be.visible')) }) }) @@ -26,10 +23,11 @@ context('Favorites', { scrollBehavior: false }, () => { cy.get('#songsWrapper') .within(() => { - cy.get('tr.song-item:first-child [data-test=like-btn]') - .within(() => cy.get('[data-test=btn-like-unliked]').should('be.visible')) - .click() - .within(() => cy.get('[data-test=btn-like-liked]').should('be.visible')) + cy.$getSongRows().first().within(() => { + cy.findByTestId('like-btn') + .within(() => cy.findByTestId('btn-like-unliked').should('be.visible')).click() + .within(() => cy.findByTestId('btn-like-liked').should('be.visible')) + }) }) cy.$assertFavoriteSongCount(4) @@ -44,30 +42,27 @@ context('Favorites', { scrollBehavior: false }, () => { cy.get('#songsWrapper') .within(() => { - cy.get('tr.song-item:first-child').click() - cy.get('[data-test=add-to-btn]').click() - cy.get('[data-test=add-to-menu]') - .should('be.visible') - .within(() => cy.findByText('Favorites').click()) - .should('not.be.visible') + cy.$getSongRows().first().click() + cy.findByTestId('add-to-btn').click() + cy.findByTestId('add-to-menu').should('be.visible') + .within(() => cy.findByText('Favorites').click()).should('not.be.visible') }) cy.$assertFavoriteSongCount(4) }) - it('deletes a favorite with Unlike button', () => { cy.intercept('POST', '/api/interaction/like', {}) cy.$clickSidebarItem('Favorites') cy.get('#favoritesWrapper') .within(() => { - cy.get('tr.song-item:first-child') - .should('contain.text', 'November') - .within(() => cy.get('[data-test=like-btn]').click()) + cy.$getSongRows().should('have.length', 3) + .first().should('contain.text', 'November') + .within(() => cy.findByTestId('like-btn').click()) - cy.get('tr.song-item').should('have.length', 2) - cy.get('tr.song-item:first-child').should('not.contain.text', 'November') + cy.$getSongRows().should('have.length', 2) + .first().should('not.contain.text', 'November') }) }) @@ -77,13 +72,12 @@ context('Favorites', { scrollBehavior: false }, () => { cy.get('#favoritesWrapper') .within(() => { - cy.get('tr.song-item:first-child') - .should('contain.text', 'November') - .click() - .type('{backspace}') + cy.$getSongRows().should('have.length', 3) + .first().should('contain.text', 'November') + .click().type('{backspace}') - cy.get('tr.song-item').should('have.length', 2) - cy.get('tr.song-item:first-child').should('not.contain.text', 'November') + cy.$getSongRows().should('have.length', 2) + .first().should('not.contain.text', 'November') }) }) }) diff --git a/cypress/integration/footer-pane.spec.ts b/cypress/integration/footer-pane.spec.ts index a8ec37c5..a1d58731 100644 --- a/cypress/integration/footer-pane.spec.ts +++ b/cypress/integration/footer-pane.spec.ts @@ -5,7 +5,7 @@ context('Footer Pane', () => { cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper tr.song-item:first-child').dblclick().within(function () { + cy.$getSongRows().first().dblclick().within(function () { cy.get('.title').invoke('text').as('title') cy.get('.album').invoke('text').as('album') cy.get('.artist').invoke('text').as('artist') diff --git a/cypress/integration/home.spec.ts b/cypress/integration/home.spec.ts index bde2e9fb..cd16b326 100644 --- a/cypress/integration/home.spec.ts +++ b/cypress/integration/home.spec.ts @@ -15,24 +15,19 @@ context('Home Screen', () => { ['.top-artist-list', 1], ['.top-album-list', 3] ], (selector: string, itemCount: number) => { - cy.get(selector) - .should('exist') - .find('li') - .should('have.length', itemCount) + cy.get(selector).should('exist') + .find('li').should('have.length', itemCount) }) }) it('has a link to view all recently-played songs', () => { - cy.findByTestId('home-view-all-recently-played-btn') - .click() - .url() - .should('contain', '/#!/recently-played') + cy.findByTestId('home-view-all-recently-played-btn').click().url().should('contain', '/#!/recently-played') }) it('a song item can be played', () => { cy.$mockPlayback() - cy.get('.top-song-list li:first-child [data-test=song-card]').within(() => { + cy.get('.top-song-list li:first-child [data-testid=song-card]').within(() => { cy.get('a.control').invoke('show').click() }).should('have.class', 'playing') cy.$assertPlaying() diff --git a/cypress/integration/other-controls.spec.ts b/cypress/integration/other-controls.spec.ts index 7af87a02..39107d2c 100644 --- a/cypress/integration/other-controls.spec.ts +++ b/cypress/integration/other-controls.spec.ts @@ -6,10 +6,10 @@ context('Other Controls', () => { }) it('likes/unlikes the current song', () => { - cy.$findInTestId('other-controls [data-test=like-btn]').as('like').click() - cy.get('#queueWrapper tr.song-item:first-child [data-test=btn-like-liked]').should('be.visible') + cy.$findInTestId('other-controls [data-testid=like-btn]').as('like').click() + cy.get('#queueWrapper .song-item:first-child [data-testid=btn-like-liked]').should('be.visible') cy.get('@like').click() - cy.get('#queueWrapper tr.song-item:first-child [data-test=btn-like-unliked]').should('be.visible') + cy.get('#queueWrapper .song-item:first-child [data-testid=btn-like-unliked]').should('be.visible') }) it('toggles the info panel', () => { @@ -21,10 +21,10 @@ context('Other Controls', () => { }) it('toggles the "sound bars" icon when a song is played/paused', () => { - cy.$findInTestId('other-controls [data-test=soundbars]').should('be.visible') + cy.$findInTestId('other-controls [data-testid=soundbars]').should('be.visible') cy.get('body').type(' ') cy.$assertNotPlaying() - cy.$findInTestId('other-controls [data-test=soundbars]').should('not.exist') + cy.$findInTestId('other-controls [data-testid=soundbars]').should('not.exist') }) it('toggles the visualizer', () => { diff --git a/cypress/integration/playlists.spec.ts b/cypress/integration/playlists.spec.ts index ae0c8d25..9c5b76b1 100644 --- a/cypress/integration/playlists.spec.ts +++ b/cypress/integration/playlists.spec.ts @@ -9,14 +9,8 @@ context('Playlists', () => { cy.$clickSidebarItem('Simple Playlist') cy.get('#playlistWrapper').within(() => { - cy.get('.heading-wrapper') - .should('be.visible') - .and('contain', 'Simple Playlist') - - cy.get('tr.song-item') - .should('be.visible') - .and('have.length', 3) - + cy.get('.heading-wrapper').should('be.visible').and('contain', 'Simple Playlist') + cy.$getSongRows().should('have.length', 3) cy.findByText('Download All').should('be.visible') ;['.btn-shuffle-all', '.btn-delete-playlist'].forEach(selector => cy.get(selector).should('be.visible')) }) @@ -30,11 +24,7 @@ context('Playlists', () => { cy.intercept('DELETE', '/api/playlist/1', {}) cy.$clickSidebarItem('Simple Playlist').as('menuItem') - - cy.get('#playlistWrapper .btn-delete-playlist') - .click() - .$confirm() - + cy.get('#playlistWrapper .btn-delete-playlist').click().$confirm() cy.url().should('contain', '/#!/home') cy.get('@menuItem').should('not.exist') }) @@ -46,11 +36,7 @@ context('Playlists', () => { cy.intercept('DELETE', '/api/playlist/2', {}) - cy.get('#sidebar') - .findByText('Smart Playlist') - .as('menuItem') - .rightclick() - + cy.get('#sidebar').findByText('Smart Playlist').as('menuItem').rightclick() cy.findByTestId('playlist-context-menu-delete-2').click() cy.$confirm() @@ -68,31 +54,18 @@ context('Playlists', () => { cy.findByTestId('sidebar-create-playlist-btn').click() cy.findByTestId('playlist-context-menu-create-simple').click() - cy.get('[name=create-simple-playlist-form] [name=name]') - .as('nameInput') - .should('be.visible') + cy.get('[name=create-simple-playlist-form] [name=name]').as('nameInput').should('be.visible') + cy.get('@nameInput').clear().type('A New Playlist{enter}') + cy.get('#sidebar').findByText('A New Playlist').should('exist').and('have.class', 'active') + cy.findByText('Playlist "A New Playlist" created.').should('be.visible') + cy.get('#playlistWrapper .heading-wrapper').should('be.visible').and('contain', 'A New Playlist') - cy.get('@nameInput') - .clear() - .type('A New Playlist{enter}') - - cy.get('#sidebar') - .findByText('A New Playlist') - .should('exist') - .and('have.class', 'active') - - cy.findByText('Created playlist "A New Playlist."').should('be.visible') - - cy.get('#playlistWrapper .heading-wrapper') - .should('be.visible') - .and('contain', 'A New Playlist') - - cy.get('#playlistWrapper [data-test=screen-placeholder]') + cy.get('#playlistWrapper [data-testid=screen-empty-state]') .should('be.visible') .and('contain', 'The playlist is currently empty.') }) - it('creates a playlist directly from a song list', () => { + it('adds songs into an existing playlist', () => { cy.intercept('/api/playlist/1/songs', { fixture: 'playlist-songs.get.200.json' }) @@ -104,9 +77,9 @@ context('Playlists', () => { cy.$clickSidebarItem('All Songs') cy.get('#songsWrapper').within(() => { - cy.$selectSongRange(1, 2) - cy.get('[data-test=add-to-btn]').click() - cy.get('[data-test=add-to-menu]') + cy.$selectSongRange(0, 1) + cy.findByTestId('add-to-btn').click() + cy.findByTestId('add-to-menu') .should('be.visible') .within(() => cy.findByText('Simple Playlist').click()) .should('not.be.visible') @@ -128,17 +101,14 @@ context('Playlists', () => { cy.$clickSidebarItem('All Songs') cy.get('#songsWrapper').within(() => { - cy.$selectSongRange(1, 3) - cy.get('[data-test=add-to-btn]').click() - cy.get('[data-test=new-playlist-name]').type('A New Playlist{enter}') + cy.$selectSongRange(0, 2) + cy.findByTestId('add-to-btn').click() + cy.findByTestId('new-playlist-name').type('A New Playlist{enter}') }) - cy.get('#sidebar') - .findByText('A New Playlist') - .should('exist') - .and('have.class', 'active') + cy.get('#sidebar').findByText('A New Playlist').should('exist').and('have.class', 'active') - cy.findByText('Created playlist "A New Playlist."').should('be.visible') + cy.findByText('Playlist "A New Playlist" created.').should('be.visible') cy.$assertPlaylistSongCount('A New Playlist', 3) }) @@ -148,28 +118,12 @@ context('Playlists', () => { fixture: 'playlist-songs.get.200.json' }) - cy.get('#sidebar') - .findByText('Simple Playlist') - .as('menuItem') - .dblclick() - - cy.findByTestId('inline-playlist-name-input') - .as('nameInput') - .should('be.focused') - - cy.get('@nameInput') - .clear() - .type('A New Name{enter}') - - cy.get('@menuItem') - .should('contain', 'A New Name') - .and('have.class', 'active') - - cy.findByText('Updated playlist "A New Name."').should('be.visible') - - cy.get('#playlistWrapper .heading-wrapper') - .should('be.visible') - .and('contain', 'A New Name') + cy.get('#sidebar').findByText('Simple Playlist').as('menuItem').dblclick() + cy.findByTestId('inline-playlist-name-input').as('nameInput').should('be.focused') + cy.get('@nameInput').clear().type('A New Name{enter}') + cy.get('@menuItem').should('contain', 'A New Name').and('have.class', 'active') + cy.findByText('Playlist "A New Name" updated.').should('be.visible') + cy.get('#playlistWrapper .heading-wrapper').should('be.visible').and('contain', 'A New Name') }) it('creates a smart playlist', () => { @@ -187,14 +141,9 @@ context('Playlists', () => { cy.findByTestId('create-smart-playlist-form') .should('be.visible') .within(() => { - cy.get('[name=name]') - .should('be.focused') - .type('My Smart Playlist') - + cy.get('[name=name]').should('be.focused').type('My Smart Playlist') cy.get('.btn-add-group').click() - // Add the first rule - cy.get('.btn-add-rule').click() cy.get('[name="model[]"]').select('Album') cy.get('[name="operator[]"]').select('is not') cy.wait(0) // the "value" text box is rendered asynchronously @@ -202,35 +151,30 @@ context('Playlists', () => { // Add a second rule cy.get('.btn-add-rule').click() - cy.get('[data-test=smart-playlist-rule-row]:nth-child(3) [name="model[]"]').select('Length') - cy.get('[data-test=smart-playlist-rule-row]:nth-child(3) [name="operator[]"]').select('is greater than') + cy.get('[data-testid=smart-playlist-rule-row]:nth-child(3) [name="model[]"]').select('Length') + cy.get('[data-testid=smart-playlist-rule-row]:nth-child(3) [name="operator[]"]').select('is greater than') cy.wait(0) - cy.get('[data-test=smart-playlist-rule-row]:nth-child(3) [name="value[]"]').type('180') + cy.get('[data-testid=smart-playlist-rule-row]:nth-child(3) [name="value[]"]').type('180') // Add another group (and rule) cy.get('.btn-add-group').click() - cy.get('[data-test=smart-playlist-rule-group]:nth-child(2) .btn-add-rule').click() - cy.wait(0) - cy.get('[data-test=smart-playlist-rule-group]:nth-child(2) [name="value[]"]').type('Whatever') + cy.get('[data-testid=smart-playlist-rule-group]:nth-child(2) [name="value[]"]').type('Whatever') // Remove a rule from the first group cy.get(` - [data-test=smart-playlist-rule-group]:first-child - [data-test=smart-playlist-rule-row]:nth-child(2) + [data-testid=smart-playlist-rule-group]:first-child + [data-testid=smart-playlist-rule-row]:nth-child(2) .remove-rule `).click() - cy.get('[data-test=smart-playlist-rule-group]:first-child [data-test=smart-playlist-rule-row]') + cy.get('[data-testid=smart-playlist-rule-group]:first-child [data-testid=smart-playlist-rule-row]') .should('have.length', 1) cy.findByText('Save').click() }) - cy.findByText('Created playlist "My Smart Playlist."').should('be.visible') - - cy.get('#playlistWrapper .heading-wrapper') - .should('be.visible') - .and('contain', 'My Smart Playlist') + cy.findByText('Playlist "My Smart Playlist" created.').should('be.visible') + cy.get('#playlistWrapper .heading-wrapper').should('be.visible').and('contain', 'My Smart Playlist') cy.$assertSidebarItemActive('My Smart Playlist') cy.$assertPlaylistSongCount('My Smart Playlist', 3) @@ -247,39 +191,28 @@ context('Playlists', () => { cy.intercept('PUT', '/api/playlist/2', {}) - cy.get('#sidebar') - .findByText('Smart Playlist') - .rightclick() - + cy.get('#sidebar').findByText('Smart Playlist').rightclick() cy.findByTestId('playlist-context-menu-edit-2').click() - cy.findByTestId('edit-smart-playlist-form') - .should('be.visible') - .within(() => { - cy.get('[name=name]') - .should('be.focused') - .and('contain.value', 'Smart Playlist') - .clear() - .type('A Different Name') + cy.findByTestId('edit-smart-playlist-form').should('be.visible').within(() => { + cy.get('[name=name]').should('be.focused').and('contain.value', 'Smart Playlist') + .clear().type('A Different Name') - cy.get('[data-test=smart-playlist-rule-group]').should('have.length', 2) + cy.get('[data-testid=smart-playlist-rule-group]').should('have.length', 2) - // Add another rule into the second group - cy.get('[data-test=smart-playlist-rule-group]:nth-child(2) .btn-add-rule').click() - cy.get('[data-test=smart-playlist-rule-row]:nth-child(3) [name="model[]"]').select('Album') - cy.get('[data-test=smart-playlist-rule-row]:nth-child(3) [name="operator[]"]').select('contains') - cy.wait(0) - cy.get('[data-test=smart-playlist-rule-row]:nth-child(3) [name="value[]"]').type('keyword') - cy.get('[data-test=smart-playlist-rule-group]:nth-child(2) [data-test=smart-playlist-rule-row]') - .should('have.length', 2) + // Add another rule into the second group + cy.get('[data-testid=smart-playlist-rule-group]:nth-child(2) .btn-add-rule').click() + cy.get('[data-testid=smart-playlist-rule-row]:nth-child(3) [name="model[]"]').select('Album') + cy.get('[data-testid=smart-playlist-rule-row]:nth-child(3) [name="operator[]"]').select('contains') + cy.wait(0) + cy.get('[data-testid=smart-playlist-rule-row]:nth-child(3) [name="value[]"]').type('keyword') + cy.get('[data-testid=smart-playlist-rule-group]:nth-child(2) [data-testid=smart-playlist-rule-row]') + .should('have.length', 2) - cy.findByText('Save').click() - }) + cy.findByText('Save').click() + }) - cy.findByText('Updated playlist "A Different Name."').should('be.visible') - - cy.get('#playlistWrapper .heading-wrapper') - .should('be.visible') - .and('contain', 'A Different Name') + cy.findByText('Playlist "A Different Name" updated.').should('be.visible') + cy.get('#playlistWrapper .heading-wrapper').should('be.visible').and('contain', 'A Different Name') }) }) diff --git a/cypress/integration/profile.spec.ts b/cypress/integration/profile.spec.ts index 6533118f..d8421f3e 100644 --- a/cypress/integration/profile.spec.ts +++ b/cypress/integration/profile.spec.ts @@ -76,7 +76,7 @@ context('Profiles & Preferences', () => { cy.$login() cy.$mockPlayback() cy.$clickSidebarItem('Current Queue') - cy.get('#queueWrapper').within(() => cy.findByText('shuffling all songs').click()) + cy.get('#queueWrapper').within(() => cy.findByTestId('shuffle-library').click()) cy.findByTestId('album-art-overlay').should('exist') cy.findByTestId('view-profile-link').click() diff --git a/cypress/integration/queuing.spec.ts b/cypress/integration/queuing.spec.ts index 537f99c2..480a1ca3 100644 --- a/cypress/integration/queuing.spec.ts +++ b/cypress/integration/queuing.spec.ts @@ -13,9 +13,9 @@ context('Queuing', { scrollBehavior: false }, () => { cy.get('#queueWrapper').within(() => { cy.findByText('Current Queue').should('be.visible') - cy.findByText('shuffling all songs').click() - cy.get('tr.song-item').should('have.length.at.least', MIN_SONG_ITEMS_SHOWN) - cy.get('tr.song-item:first-child').should('have.class', 'playing') + cy.findByTestId('shuffle-library').click() + cy.$getSongRows().should('have.length.at.least', MIN_SONG_ITEMS_SHOWN) + cy.get('@rows').first().should('have.class', 'playing') }) cy.$assertPlaying() @@ -26,12 +26,10 @@ context('Queuing', { scrollBehavior: false }, () => { cy.get('#queueWrapper').within(() => { cy.findByText('Current Queue').should('be.visible') - cy.findByText('shuffling all songs').click() - cy.get('tr.song-item').should('have.length.at.least', MIN_SONG_ITEMS_SHOWN) - cy.get('.screen-header [data-test=song-list-controls]') - .findByText('Clear') - .click() - cy.get('tr.song-item').should('have.length', 0) + cy.findByTestId('shuffle-library').click() + cy.$getSongRows().should('have.length.at.least', MIN_SONG_ITEMS_SHOWN) + cy.get('.screen-header [data-testid=song-list-controls]').findByText('Clear').click() + cy.$getSongRows().should('have.length', 0) }) }) @@ -39,34 +37,34 @@ context('Queuing', { scrollBehavior: false }, () => { cy.$clickSidebarItem('All Songs') cy.get('#songsWrapper').within(() => { - cy.get('.screen-header [data-test=btn-shuffle-all]').click() + cy.get('.screen-header [data-testid=btn-shuffle-all]').click() cy.url().should('contains', '/#!/queue') }) cy.get('#queueWrapper').within(() => { - cy.get('tr.song-item').should('have.length.at.least', MIN_SONG_ITEMS_SHOWN) - cy.get('tr.song-item:first-child').should('have.class', 'playing') + cy.$getSongRows().should('have.length.at.least', MIN_SONG_ITEMS_SHOWN) + .first().should('have.class', 'playing') }) cy.$assertPlaying() }) it('creates a queue from selected songs', () => { - cy.$shuffleSeveralSongs() + cy.$shuffleSeveralSongs(3) cy.get('#queueWrapper').within(() => { - cy.get('tr.song-item').should('have.length', 3) - cy.get('tr.song-item:first-child').should('have.class', 'playing') + cy.$getSongRows().should('have.length', 3) + .first().should('have.class', 'playing') }) }) it('deletes a song from queue', () => { - cy.$shuffleSeveralSongs() + cy.$shuffleSeveralSongs(3) cy.get('#queueWrapper').within(() => { - cy.get('tr.song-item').should('have.length', 3) - cy.get('tr.song-item:first-child').type('{backspace}') - cy.get('tr.song-item').should('have.length', 2) + cy.$getSongRows().should('have.length', 3) + cy.get('@rows').first().type('{backspace}') + cy.$getSongRows().should('have.length', 2) }) }) @@ -75,15 +73,15 @@ context('Queuing', { scrollBehavior: false }, () => { cy.$clickSidebarItem('All Songs') cy.get('#songsWrapper').within(function () { - cy.get('tr.song-item:nth-child(4) .title').invoke('text').as('title') - cy.get('tr.song-item:nth-child(4)').dblclick() + cy.$getSongRowAt(4).find('.title').invoke('text').as('title') + cy.$getSongRowAt(4).dblclick() }) cy.$clickSidebarItem('Current Queue') cy.get('#queueWrapper').within(function () { - cy.get('tr.song-item').should('have.length', 4) - cy.get(`tr.song-item:nth-child(2) .title`).should('have.text', this.title) - cy.get('tr.song-item:nth-child(2)').should('have.class', 'playing') + cy.$getSongRows().should('have.length', 4) + cy.$getSongRowAt(1).find('.title').should('have.text', this.title) + cy.$getSongRowAt(1).should('have.class', 'playing') }) cy.$assertPlaying() @@ -91,14 +89,14 @@ context('Queuing', { scrollBehavior: false }, () => { it('navigates through the queue', () => { cy.$shuffleSeveralSongs() - cy.get('#queueWrapper tr.song-item:nth-child(1)').should('have.class', 'playing') + cy.get('#queueWrapper .song-item:nth-child(1)').should('have.class', 'playing') - cy.findByTestId('play-next-btn').click({ force: true }) - cy.get('#queueWrapper tr.song-item:nth-child(2)').should('have.class', 'playing') + cy.findByTitle('Play next song').click({ force: true }) + cy.get('#queueWrapper .song-item:nth-child(2)').should('have.class', 'playing') cy.$assertPlaying() - cy.findByTestId('play-prev-btn').click({ force: true }) - cy.get('#queueWrapper tr.song-item:nth-child(1)').should('have.class', 'playing') + cy.findByTitle('Play previous song').click({ force: true }) + cy.get('#queueWrapper .song-item:nth-child(1)').should('have.class', 'playing') cy.$assertPlaying() }) @@ -118,7 +116,7 @@ context('Queuing', { scrollBehavior: false }, () => { cy.findByTestId('play-next-btn').click({ force: true }) cy.findByTestId('play-next-btn').click({ force: true }) - cy.get('#queueWrapper tr.song-item:nth-child(1)').should('have.class', 'playing') + cy.get('#queueWrapper .song-item:nth-child(1)').should('have.class', 'playing') cy.$assertPlaying() }) @@ -129,7 +127,7 @@ context('Queuing', { scrollBehavior: false }, () => { cy.$shuffleSeveralSongs() cy.findByTestId('play-next-btn').click({ force: true }) - cy.get('#queueWrapper tr.song-item:nth-child(2)').should('have.class', 'playing') + cy.get('#queueWrapper .song-item:nth-child(2)').should('have.class', 'playing') cy.$assertPlaying() }) }) diff --git a/cypress/integration/searching.spec.ts b/cypress/integration/searching.spec.ts index 4b424ee3..4aa47d01 100644 --- a/cypress/integration/searching.spec.ts +++ b/cypress/integration/searching.spec.ts @@ -6,7 +6,7 @@ context('Searching', () => { it('shows the search screen when search box receives focus', () => { cy.get('@searchInput').focus() - cy.get('#searchExcerptsWrapper').within(() => cy.get('[data-test=screen-placeholder]').should('be.visible')) + cy.get('#searchExcerptsWrapper').within(() => cy.findByTestId('screen-empty-state').should('be.visible')) }) it('performs an excerpt search', () => { @@ -17,9 +17,9 @@ context('Searching', () => { cy.get('@searchInput').type('foo') cy.get('#searchExcerptsWrapper').within(() => { - cy.$findInTestId('song-excerpts [data-test=song-card]').should('have.length', 6) - cy.$findInTestId('artist-excerpts [data-test=artist-card]').should('have.length', 1) - cy.$findInTestId('album-excerpts [data-test=album-card]').should('have.length', 3) + cy.$findInTestId('song-excerpts [data-testid=song-card]').should('have.length', 6) + cy.$findInTestId('artist-excerpts [data-testid=artist-card]').should('have.length', 1) + cy.$findInTestId('album-excerpts [data-testid=album-card]').should('have.length', 3) }) }) @@ -33,12 +33,12 @@ context('Searching', () => { }) cy.get('@searchInput').type('foo') - cy.get('#searchExcerptsWrapper [data-test=view-all-songs-btn]').click() + cy.get('#searchExcerptsWrapper [data-testid=view-all-songs-btn]').click() cy.url().should('contain', '/#!/search/songs/foo') cy.get('#songResultsWrapper').within(() => { cy.get('.screen-header').should('contain.text', 'Showing Songs for foo') - cy.get('tr.song-item').should('have.length', 7) + cy.get('.song-item').should('have.length', 7) }) }) @@ -54,7 +54,7 @@ context('Searching', () => { cy.get('@searchInput').type('foo') cy.wait('@search') - cy.get('#searchExcerptsWrapper [data-test=view-all-songs-btn]').should('not.exist') + cy.get('#searchExcerptsWrapper [data-testid=view-all-songs-btn]').should('not.exist') cy.findByTestId('song-excerpts').findByText('None found.').should('be.visible') }) }) diff --git a/cypress/integration/settings.spec.ts b/cypress/integration/settings.spec.ts index 007a83cf..fcd3fc43 100644 --- a/cypress/integration/settings.spec.ts +++ b/cypress/integration/settings.spec.ts @@ -2,7 +2,7 @@ context('Settings', () => { beforeEach(() => { cy.$login() cy.$clickSidebarItem('Settings') - cy.intercept('POST', '/api/settings', {}).as('save') + cy.intercept('PUT', '/api/settings', {}).as('save') }) it('rescans media', () => { diff --git a/cypress/integration/shortcut-keys.spec.ts b/cypress/integration/shortcut-keys.spec.ts index 7f6f8a11..2ad2c6ab 100644 --- a/cypress/integration/shortcut-keys.spec.ts +++ b/cypress/integration/shortcut-keys.spec.ts @@ -1,5 +1,8 @@ context('Shortcut Keys', () => { - beforeEach(() => cy.$login()) + beforeEach(() => { + cy.$login() + cy.$mockPlayback() + }) it('focus into Search input when F is pressed', () => { cy.get('body').type('f') @@ -8,7 +11,6 @@ context('Shortcut Keys', () => { it('shuffles all songs by default when Space is pressed', () => { cy.fixture('data.get.200.json').then(data => { - cy.$mockPlayback() cy.get('body').type(' ') cy.$assertSidebarItemActive('Current Queue') cy.$assertPlaying() @@ -17,7 +19,6 @@ context('Shortcut Keys', () => { }) it('toggles playback when Space is pressed', () => { - cy.$mockPlayback() cy.$shuffleSeveralSongs() cy.$assertPlaying() cy.get('body').type(' ') @@ -27,22 +28,20 @@ context('Shortcut Keys', () => { }) it('moves back and forward when K and J are pressed', () => { - cy.$mockPlayback() cy.$shuffleSeveralSongs() cy.get('body').type('j') - cy.get('#queueWrapper tr.song-item:nth-child(2)').should('have.class', 'playing') + cy.get('#queueWrapper .song-item:nth-child(2)').should('have.class', 'playing') cy.get('body').type('k') - cy.get('#queueWrapper tr.song-item:nth-child(1)').should('have.class', 'playing') + cy.get('#queueWrapper .song-item:nth-child(1)').should('have.class', 'playing') cy.$assertPlaying() }) it('toggles favorite when L is pressed', () => { cy.intercept('POST', '/api/interaction/like', {}) - cy.$mockPlayback() cy.$shuffleSeveralSongs() cy.get('body').type('l') - cy.get('#queueWrapper tr.song-item:first-child [data-test=btn-like-liked]').should('be.visible') + cy.get('#queueWrapper .song-item:first-child [data-testid=btn-like-liked]').should('be.visible') cy.get('body').type('l') - cy.get('#queueWrapper tr.song-item:first-child [data-test=btn-like-unliked]').should('be.visible') + cy.get('#queueWrapper .song-item:first-child [data-testid=btn-like-unliked]').should('be.visible') }) }) diff --git a/cypress/integration/sidebar.spec.ts b/cypress/integration/sidebar.spec.ts index 27871f2a..36af771a 100644 --- a/cypress/integration/sidebar.spec.ts +++ b/cypress/integration/sidebar.spec.ts @@ -23,30 +23,20 @@ context('Sidebar Functionalities', () => { } it('contains menu items', () => { - cy.on('uncaught:exception', err => !err.message.includes('Request failed')) - cy.$login() cy.$each(commonMenuItems, assertMenuItem) cy.$each(managementMenuItems, assertMenuItem) }) it('does not contain management items for non-admins', () => { - cy.on('uncaught:exception', err => !err.message.includes('Request failed')) - cy.$loginAsNonAdmin() cy.$each(commonMenuItems, assertMenuItem) - cy.$each(managementMenuItems, (text: string) => { - cy.get('#sidebar') - .findByText(text) - .should('not.exist') - }) + cy.$each(managementMenuItems, (text: string) => cy.get('#sidebar').findByText(text).should('not.exist')) }) it('does not have a YouTube item if YouTube is not used', () => { cy.$login({ useYouTube: false }) - cy.get('#sidebar') - .findByText('YouTube Video') - .should('not.exist') + cy.get('#sidebar').findByText('YouTube Video').should('not.exist') }) }) diff --git a/cypress/integration/song-context-menu.spec.ts b/cypress/integration/song-context-menu.spec.ts index b8dc75ab..27befb96 100644 --- a/cypress/integration/song-context-menu.spec.ts +++ b/cypress/integration/song-context-menu.spec.ts @@ -4,11 +4,7 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => { - cy.get('tr.song-item:first-child').dblclick() - cy.get('tr.song-item:first-child').should('have.class', 'playing') - }) - + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').dblclick().should('have.class', 'playing')) cy.$assertPlaying() }) @@ -17,11 +13,7 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => { - cy.get('tr.song-item:first-child') - .as('item') - .rightclick() - }) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').as('item').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Play').click()) cy.get('@item').should('have.class', 'playing') @@ -36,30 +28,26 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Go to Album').click()) - cy.get('#albumWrapper') - .should('be.visible') - .within(() => { - cy.get('.screen-header').should('be.visible') - cy.get('tr.song-item').should('have.length.at.least', 1) - }) + cy.get('#albumWrapper').should('be.visible').within(() => { + cy.get('.screen-header').should('be.visible') + cy.get('.song-item').should('have.length.at.least', 1) + }) }) it('invokes artist screen', () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Go to Artist').click()) - cy.get('#artistWrapper') - .should('be.visible') - .within(() => { - cy.get('.screen-header').should('be.visible') - cy.get('tr.song-item').should('have.length.at.least', 1) - }) + cy.get('#artistWrapper').should('be.visible').within(() => { + cy.get('.screen-header').should('be.visible') + cy.get('.song-item').should('have.length.at.least', 1) + }) }) ;([ @@ -74,24 +62,19 @@ context('Song Context Menu', { scrollBehavior: false }, () => { let songTitle cy.get('#songsWrapper').within(() => { - cy.get('tr.song-item:nth-child(4) .title') - .invoke('text') - .then(text => { - songTitle = text - }) - - cy.get('tr.song-item:nth-child(4)').rightclick() + cy.get('.song-item:nth-child(4) .title').invoke('text').then(text => (songTitle = text)) + cy.get('.song-item:nth-child(4)').rightclick() }) cy.findByTestId('song-context-menu').within(() => { - cy.findByText('Add To').click() - cy.findByText(config.menuItem).click() - }) + cy.findByText('Add To').click() + cy.findByText(config.menuItem).click() + }) cy.$clickSidebarItem('Current Queue') cy.get('#queueWrapper').within(() => { - cy.get('tr.song-item').should('have.length', 4) - cy.get(`tr.song-item:nth-child(${config.queuedPosition}) .title`).should('have.text', songTitle) + cy.get('.song-item').should('have.length', 4) + cy.get(`.song-item:nth-child(${config.queuedPosition}) .title`).should('have.text', songTitle) }) }) }) @@ -113,9 +96,9 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$assertPlaylistSongCount('Simple Playlist', 3) cy.get('#songsWrapper').within(() => { if (config.songCount > 1) { - cy.$selectSongRange(1, config.songCount).rightclick() + cy.$selectSongRange(0, config.songCount - 1).rightclick() } else { - cy.get('tr.song-item:first-child').rightclick() + cy.get('.song-item:first-child').rightclick() } }) @@ -132,13 +115,12 @@ context('Song Context Menu', { scrollBehavior: false }, () => { it('does not have smart playlists as target for adding songs', () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) - cy.findByTestId('song-context-menu') - .within(() => { - cy.findByText('Add To').click() - cy.findByText('Smart Playlist').should('not.exist') - }) + cy.findByTestId('song-context-menu').within(() => { + cy.findByText('Add To').click() + cy.findByText('Smart Playlist').should('not.exist') + }) }) it('adds a favorite song from context menu', () => { @@ -150,7 +132,7 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$clickSidebarItem('All Songs') cy.$assertFavoriteSongCount(3) - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => { cy.findByText('Add To').click() cy.findByText('Favorites').click() @@ -161,10 +143,10 @@ context('Song Context Menu', { scrollBehavior: false }, () => { it('initiates editing a song', () => { cy.intercept('/api/**/info', { - fixture: 'info.get.200.json' + fixture: 'song-info.get.200.json' }) - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Edit').click()) cy.findByTestId('edit-song-form').should('be.visible') }) @@ -175,7 +157,7 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Download').click()) cy.wait('@download') @@ -185,7 +167,7 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$login({ allowDownload: false }) cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Download').should('not.exist')) }) @@ -193,17 +175,17 @@ context('Song Context Menu', { scrollBehavior: false }, () => { cy.$loginAsNonAdmin() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Edit').should('not.exist')) }) - it("copies a song's URL", () => { + it('copies a song\'s URL', () => { cy.$login() cy.$clickSidebarItem('All Songs') - cy.window().then(window => cy.spy(window.document, 'execCommand').as('copy')); - cy.get('#songsWrapper').within(() => cy.get('tr.song-item:first-child').rightclick()) + cy.window().then(window => cy.spy(window.document, 'execCommand').as('copy')) + cy.get('#songsWrapper').within(() => cy.get('.song-item:first-child').rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Copy Shareable URL').click()) - cy.get('@copy').should('be.calledWithExactly', 'copy'); + cy.get('@copy').should('be.calledWithExactly', 'copy') }) }) diff --git a/cypress/integration/song-editing.spec.ts b/cypress/integration/song-editing.spec.ts index bad39b0c..68f0b481 100644 --- a/cypress/integration/song-editing.spec.ts +++ b/cypress/integration/song-editing.spec.ts @@ -1,7 +1,7 @@ context('Song Editing', { scrollBehavior: false }, () => { beforeEach(() => { - cy.intercept('/api/**/info', { - fixture: 'info.get.200.json' + cy.intercept('/api/song/**/info', { + fixture: 'song-info.get.200.json' }) cy.$login() @@ -13,7 +13,7 @@ context('Song Editing', { scrollBehavior: false }, () => { fixture: 'songs.put.200.json' }) - cy.get('#songsWrapper tr.song-item:first-child').rightclick() + cy.get('#songsWrapper .song-item:first-child').rightclick() cy.findByTestId('song-context-menu').within(() => cy.findByText('Edit').click()) cy.findByTestId('edit-song-form').within(() => { @@ -23,22 +23,19 @@ context('Song Editing', { scrollBehavior: false }, () => { cy.get('[name=title]').clear().type('New Title') cy.findByTestId('edit-song-lyrics-tab').click() - cy.get('textarea[name=lyrics]') - .should('be.visible') - .and('contain.value', 'Sample song lyrics') - .clear() - .type('New lyrics{enter}Supports multiline.') + cy.get('textarea[name=lyrics]').should('be.visible').and('contain.value', 'Sample song lyrics') + .clear().type('New lyrics{enter}Supports multiline.') cy.get('button[type=submit]').click() }) cy.findByText('Updated 1 song.').should('be.visible') cy.findByTestId('edit-song-form').should('not.exist') - cy.get('#songsWrapper tr.song-item:first-child .title').should('have.text', 'New Title') + cy.get('#songsWrapper .song-item:first-child .title').should('have.text', 'New Title') }) it('cancels editing', () => { - cy.get('#songsWrapper tr.song-item:first-child').rightclick() + cy.get('#songsWrapper .song-item:first-child').rightclick() cy.findByTestId('song-context-menu').within(() => cy.findByText('Edit').click()) cy.$findInTestId('edit-song-form .btn-cancel').click() @@ -50,10 +47,7 @@ context('Song Editing', { scrollBehavior: false }, () => { fixture: 'songs-multiple.put.200.json' }) - cy.get('#songsWrapper').within(() => { - cy.$selectSongRange(1, 3).rightclick() - }) - + cy.get('#songsWrapper').within(() => cy.$selectSongRange(0, 2).rightclick()) cy.findByTestId('song-context-menu').within(() => cy.findByText('Edit').click()) cy.findByTestId('edit-song-form').within(() => { @@ -64,14 +58,8 @@ context('Song Editing', { scrollBehavior: false }, () => { cy.get('textarea[name=lyrics]').should('not.exist') ;['3 songs selected', 'Mixed Albums'].forEach(text => cy.findByText(text).should('be.visible')) - cy.get('[name=album]').invoke('attr', 'placeholder').should('contain', 'No change') - - // Test the typeahead/auto-complete feature - cy.get('[name=album]').type('A') - cy.findByText('Abstract').click() - cy.get('[name=album]').should('contain.value', 'Abstract') - cy.get('[name=album]').type('{downArrow}{downArrow}{downArrow}{downArrow}{enter}') - cy.get('[name=album]').should('contain.value', 'The Wall') + cy.get('[name=album]').invoke('attr', 'placeholder').should('contain', 'Leave unchanged') + cy.get('[name=album]').type('The Wall') cy.get('button[type=submit]').click() }) @@ -79,8 +67,6 @@ context('Song Editing', { scrollBehavior: false }, () => { cy.findByText('Updated 3 songs.').should('be.visible') cy.findByTestId('edit-song-form').should('not.exist') - cy.get('#songsWrapper tr.song-item:nth-child(1) .album').should('have.text', 'The Wall') - cy.get('#songsWrapper tr.song-item:nth-child(2) .album').should('have.text', 'The Wall') - cy.get('#songsWrapper tr.song-item:nth-child(3) .album').should('have.text', 'The Wall') + ;[1, 2, 3].forEach(i => cy.get(`#songsWrapper .song-item:nth-child(${i}) .album`).should('have.text', 'The Wall')) }) }) diff --git a/cypress/integration/uploading.spec.ts b/cypress/integration/uploading.spec.ts index f8dadfac..ef1785f5 100644 --- a/cypress/integration/uploading.spec.ts +++ b/cypress/integration/uploading.spec.ts @@ -1,4 +1,6 @@ context('Uploading', () => { + let interceptCounter = 0 + beforeEach(() => { cy.$login() cy.$clickSidebarItem('Upload') @@ -12,20 +14,27 @@ context('Uploading', () => { .should('contain.text', 'Mendelssohn Violin Concerto in E minor, Op. 64') } + function selectFixtureFile (fileName = 'sample.mp3') { + // Cypress caches fixtures and apparently has a bug where consecutive fixture files yield an empty "type" + // which will fail our "audio type filter" (i.e. the file will not be considered an audio file). + // As a workaround, we pad the fixture file name with slashes to invalidate the cache. + // https://github.com/cypress-io/cypress/issues/4716#issuecomment-558305553 + cy.fixture(fileName.padStart(fileName.length + interceptCounter, '/')).as('file') + cy.get('[type=file]').selectFile('@file') + + interceptCounter++ + } + function executeFailedUpload () { cy.intercept('POST', '/api/upload', { statusCode: 413 }).as('failedUpload') - cy.get('[type=file]').attachFile('sample.mp3') - cy.get('[data-test=upload-item]') - .should('have.length', 1) - .and('be.visible') - + selectFixtureFile() + cy.findByTestId('upload-item').should('have.length', 1).and('be.visible') cy.wait('@failedUpload') - cy.get('[data-test=upload-item]').should('have.length', 1) - cy.get('[data-test=upload-item]:first-child').should('have.class', 'Errored') + cy.findByTestId('upload-item').should('have.length', 1).should('have.class', 'errored') } it('uploads songs', () => { @@ -34,13 +43,11 @@ context('Uploading', () => { }).as('upload') cy.get('#uploadWrapper').within(() => { - cy.get('[type=file]').attachFile('sample.mp3') - cy.get('[data-test=upload-item]') - .should('have.length', 1) - .and('be.visible') + selectFixtureFile() + cy.findByTestId('upload-item').should('have.length', 1).and('be.visible') cy.wait('@upload') - cy.get('[data-test=upload-item]').should('have.length', 0) + cy.findByTestId('upload-item').should('have.length', 0) }) assertResultsAddedToHomeScreen() @@ -54,9 +61,9 @@ context('Uploading', () => { fixture: 'upload.post.200.json' }).as('successfulUpload') - cy.get('[data-test=upload-item]:first-child [data-test=retry-upload-btn]').click() + cy.get('[data-testid=upload-item]:first-child').findByTitle('Retry').click() cy.wait('@successfulUpload') - cy.get('[data-test=upload-item]').should('have.length', 0) + cy.findByTestId('upload-item').should('have.length', 0) }) assertResultsAddedToHomeScreen() @@ -72,7 +79,7 @@ context('Uploading', () => { cy.findByTestId('upload-retry-all-btn').click() cy.wait('@successfulUpload') - cy.get('[data-test=upload-item]').should('have.length', 0) + cy.findByTestId('upload-item').should('have.length', 0) }) assertResultsAddedToHomeScreen() @@ -81,8 +88,8 @@ context('Uploading', () => { it('allows removing individual failed uploads', () => { cy.get('#uploadWrapper').within(() => { executeFailedUpload() - cy.get('[data-test=upload-item]:first-child [data-test=remove-upload-btn]').click() - cy.get('[data-test=upload-item]').should('have.length', 0) + cy.get('[data-testid=upload-item]:first-child').findByTitle('Remove').click() + cy.findByTestId('upload-item').should('have.length', 0) }) }) @@ -90,7 +97,7 @@ context('Uploading', () => { cy.get('#uploadWrapper').within(() => { executeFailedUpload() cy.findByTestId('upload-remove-all-btn').click() - cy.get('[data-test=upload-item]').should('have.length', 0) + cy.findByTestId('upload-item').should('have.length', 0) }) }) }) diff --git a/cypress/integration/users.spec.ts b/cypress/integration/users.spec.ts index b7d18584..bd6ce3f5 100644 --- a/cypress/integration/users.spec.ts +++ b/cypress/integration/users.spec.ts @@ -6,13 +6,11 @@ context('User Management', () => { it('shows the list of users', () => { cy.get('#usersWrapper').within(() => { - cy.get('[data-test=user-card]') - .should('have.length', 3) - .and('be.visible') + cy.findAllByTestId('user-card').should('have.length', 3).and('be.visible') - cy.get('[data-test=user-card].me').within(() => { - cy.get('[data-test=current-user-indicator]').should('be.visible') - cy.get('[data-test=admin-indicator]').should('be.visible') + cy.get('[data-testid=user-card].me').within(() => { + cy.findByTitle('This is you!').should('be.visible') + cy.findByTitle('User has admin privileges').should('be.visible') }) }) }) @@ -24,10 +22,8 @@ context('User Management', () => { cy.findByTestId('add-user-btn').click() cy.findByTestId('add-user-form').within(() => { - cy.get('[name=name]') - .should('be.focused') + cy.get('[name=name]').should('be.focused') .type('Charles') - cy.get('[name=email]').type('charles@koel.test') cy.get('[name=password]').type('a-secure-password') cy.get('[name=is_admin]').check() @@ -35,17 +31,17 @@ context('User Management', () => { }) cy.findByText('New user "Charles" created.').should('be.visible') - cy.get('#usersWrapper [data-test=user-card]').should('have.length', 4) + cy.findAllByTestId('user-card').should('have.length', 4) - cy.get('#usersWrapper [data-test=user-card]:first-child').within(() => { + cy.get('#usersWrapper [data-testid=user-card]:first-child').within(() => { cy.findByText('Charles').should('be.visible') cy.findByText('charles@koel.test').should('be.visible') - cy.get('[data-test=admin-indicator]').should('be.visible') + cy.findByTitle('User has admin privileges').should('be.visible') }) }) it('redirects to profile for current user', () => { - cy.get('#usersWrapper [data-test=user-card].me [data-test=edit-user-btn]').click({ force: true }) + cy.get('#usersWrapper [data-testid=user-card].me [data-testid=edit-user-btn]').click({ force: true }) cy.url().should('contain', '/#!/profile') }) @@ -54,19 +50,14 @@ context('User Management', () => { fixture: 'user.put.200.json' }) - cy.get('#usersWrapper [data-test=user-card]:nth-child(2) [data-test=edit-user-btn]').click({ force: true }) + cy.get('#usersWrapper [data-testid=user-card]:nth-child(2) [data-testid=edit-user-btn]').click({ force: true }) cy.findByTestId('edit-user-form').within(() => { - cy.get('[name=name]') - .should('be.focused') - .and('have.value', 'Alice') - .clear() - .type('Adriana') + cy.get('[name=name]').should('be.focused').and('have.value', 'Alice') + .clear().type('Adriana') - cy.get('[name=email]') - .should('have.value', 'alice@koel.test') - .clear() - .type('adriana@koel.test') + cy.get('[name=email]').should('have.value', 'alice@koel.test') + .clear().type('adriana@koel.test') cy.get('[name=password]').should('have.value', '') cy.get('[type=submit]').click() @@ -74,7 +65,7 @@ context('User Management', () => { cy.findByText('User profile updated.').should('be.visible') - cy.get('#usersWrapper [data-test=user-card]:nth-child(2)').within(() => { + cy.get('#usersWrapper [data-testid=user-card]:nth-child(2)').within(() => { cy.findByText('Adriana').should('be.visible') cy.findByText('adriana@koel.test').should('be.visible') }) @@ -83,9 +74,9 @@ context('User Management', () => { it('deletes a user', () => { cy.intercept('DELETE', '/api/user/2', {}) - cy.get('#usersWrapper [data-test=user-card]:nth-child(2) [data-test=delete-user-btn]').click({ force: true }) + cy.get('#usersWrapper [data-testid=user-card]:nth-child(2) [data-testid=delete-user-btn]').click({ force: true }) cy.$confirm() cy.findByText('User "Alice" deleted.').should('be.visible') - cy.get('#usersWrapper [data-test=user-card]').should('have.length', 2) + cy.get('#usersWrapper [data-testid=user-card]').should('have.length', 2) }) }) diff --git a/cypress/integration/youtube.spec.ts b/cypress/integration/youtube.spec.ts index a6958664..c23e905a 100644 --- a/cypress/integration/youtube.spec.ts +++ b/cypress/integration/youtube.spec.ts @@ -17,13 +17,13 @@ context('YouTube', () => { }) cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper tr.song-item:first-child').dblclick() + cy.get('#songsWrapper .song-item:first-child').dblclick() cy.get('#extra').within(() => { cy.get('#extraTabYouTube').click() - cy.get('[data-test=youtube-search-result]').should('have.length', 2) + cy.findAllByTestId('youtube-search-result').should('have.length', 2) cy.findByTestId('youtube-search-more-btn').click() - cy.get('[data-test=youtube-search-result]').should('have.length', 4) + cy.findAllByTestId('youtube-search-result').should('have.length', 4) }) }) @@ -31,11 +31,11 @@ context('YouTube', () => { cy.$mockPlayback() cy.$clickSidebarItem('All Songs') - cy.get('#songsWrapper tr.song-item:first-child').dblclick() + cy.get('#songsWrapper .song-item:first-child').dblclick() cy.get('#extra').within(() => { cy.get('#extraTabYouTube').click() - cy.get('[data-test=youtube-search-result]:nth-child(2)').click() + cy.get('[data-testid=youtube-search-result]:nth-child(2)').click() }) cy.url().should('contain', '/#!/youtube') diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 78f4a243..73280fba 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,9 +1,7 @@ import '@testing-library/cypress/add-commands' -import 'cypress-file-upload' -import Chainable = Cypress.Chainable import scrollBehaviorOptions = Cypress.scrollBehaviorOptions -Cypress.Commands.add('$login', (options: Partial = {}): Chainable => { +Cypress.Commands.add('$login', (options: Partial = {}) => { window.localStorage.setItem('api-token', 'mock-token') const mergedOptions = Object.assign({ @@ -18,7 +16,7 @@ Cypress.Commands.add('$login', (options: Partial = {}): Chainable< cy.fixture(mergedOptions.asAdmin ? 'data.get.200.json' : 'data-non-admin.get.200.json').then(data => { delete mergedOptions.asAdmin - cy.intercept('GET', 'api/data', { + cy.intercept('/api/data', { statusCode: 200, body: Object.assign(data, mergedOptions) }) @@ -30,7 +28,7 @@ Cypress.Commands.add('$login', (options: Partial = {}): Chainable< return win }) -Cypress.Commands.add('$loginAsNonAdmin', (options: Partial = {}): Chainable => { +Cypress.Commands.add('$loginAsNonAdmin', (options: Partial = {}) => { options.asAdmin = false return cy.$login(options) }) @@ -47,23 +45,19 @@ Cypress.Commands.add('$findInTestId', (selector: string) => { return cy.findByTestId(testId.trim()).find(rest.join(' ')) }) -Cypress.Commands.add('$clickSidebarItem', (sidebarItemText: string): Chainable => { - return cy.get('#sidebar') - .findByText(sidebarItemText) - .click() -}) +Cypress.Commands.add('$clickSidebarItem', (text: string) => cy.get('#sidebar').findByText(text).click()) Cypress.Commands.add('$mockPlayback', () => { - cy.intercept('GET', '/play/**?api_token=mock-token', { - fixture: 'sample.mp3' + cy.intercept('/play/**?api_token=mock-token', { + fixture: 'sample.mp3,null' }) - cy.intercept('GET', '/api/album/**/thumbnail', { + cy.intercept('/api/album/**/thumbnail', { fixture: 'album-thumbnail.get.200.json' }) - cy.intercept('GET', '/api/**/info', { - fixture: 'info.get.200.json' + cy.intercept('/api/song/**/info', { + fixture: 'song-info.get.200.json' }) }) @@ -72,32 +66,30 @@ Cypress.Commands.add('$shuffleSeveralSongs', (count = 3) => { cy.$clickSidebarItem('All Songs') cy.get('#songsWrapper').within(() => { - cy.get('tr.song-item:nth-child(1)').click() - cy.get(`tr.song-item:nth-child(${count})`).click({ - shiftKey: true - }) + cy.$getSongRowAt(0).click() + cy.$getSongRowAt(count - 1).click({ shiftKey: true }) - cy.get('.screen-header [data-test=btn-shuffle-selected]').click() + cy.get('.screen-header [data-testid=btn-shuffle-selected]').click() }) }) Cypress.Commands.add('$assertPlaylistSongCount', (name: string, count: number) => { cy.$clickSidebarItem(name) - cy.get('#playlistWrapper tr.song-item').should('have.length', count) + cy.get('#playlistWrapper .song-item').should('have.length', count) cy.go('back') }) Cypress.Commands.add('$assertFavoriteSongCount', (count: number) => { cy.$clickSidebarItem('Favorites') - cy.get('#favoritesWrapper').within(() => cy.get('tr.song-item').should('have.length', count)) + cy.get('#favoritesWrapper').within(() => cy.get('.song-item').should('have.length', count)) cy.go('back') }) Cypress.Commands.add( '$selectSongRange', - (start: number, end: number, scrollBehavior: scrollBehaviorOptions = false): Chainable => { - cy.get(`tr.song-item:nth-child(${start})`).click() - return cy.get(`tr.song-item:nth-child(${end})`).click({ + (start: number, end: number, scrollBehavior: scrollBehaviorOptions = false) => { + cy.$getSongRowAt(start).click() + return cy.$getSongRowAt(end).click({ scrollBehavior, shiftKey: true }) @@ -106,17 +98,18 @@ Cypress.Commands.add( Cypress.Commands.add('$assertPlaying', () => { cy.findByTestId('pause-btn').should('exist') cy.findByTestId('play-btn').should('not.exist') - cy.findByTestId('sound-bar-play').should('be.visible') + cy.$findInTestId('other-controls [data-testid=soundbars]').should('be.visible') }) Cypress.Commands.add('$assertNotPlaying', () => { cy.findByTestId('pause-btn').should('not.exist') cy.findByTestId('play-btn').should('exist') - cy.findByTestId('sound-bar-play').should('not.exist') + cy.$findInTestId('other-controls [data-testid=soundbars]').should('not.exist') }) Cypress.Commands.add('$assertSidebarItemActive', (text: string) => { - cy.get('#sidebar') - .findByText(text) - .should('have.class', 'active') + cy.get('#sidebar').findByText(text).should('have.class', 'active') }) + +Cypress.Commands.add('$getSongRows', () => cy.get('.song-item').as('rows')) +Cypress.Commands.add('$getSongRowAt', (position: number) => cy.$getSongRows().eq(position)) diff --git a/cypress/support/index.ts b/cypress/support/index.ts index d68db96d..7ec51094 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -16,5 +16,6 @@ // Import commands.js using ES2015 syntax: import './commands' -// Alternatively you can use CommonJS syntax: -// require('./commands') +// returning false here prevents Cypress from failing the test +// @see https://docs.cypress.io/api/events/catalog-of-events#Uncaught-Exceptions +Cypress.on('uncaught:exception', () => false) diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json index de35d0eb..8712d65c 100644 --- a/cypress/tsconfig.json +++ b/cypress/tsconfig.json @@ -2,8 +2,8 @@ "compilerOptions": { "target": "es5", "baseUrl": ".", - "lib": ["es5", "dom"], - "types": ["cypress", "@testing-library/cypress", "@types/node", "cypress-file-upload"] + "lib": ["es2021", "dom"], + "types": ["cypress", "@testing-library/cypress", "@types/node"] }, "include": [ "**/*.ts" diff --git a/cypress/types.d.ts b/cypress/types.d.ts index 7fe44f96..ffa836a1 100644 --- a/cypress/types.d.ts +++ b/cypress/types.d.ts @@ -21,11 +21,14 @@ declare namespace Cypress { $mockPlayback(): void /** - * Queue several songs from the All Song screen. + * Queue several songs from the "All Songs" screen. * @param count */ $shuffleSeveralSongs(count?: number): void + $getSongRows(): Chainable + $getSongRowAt(position: number): Chainable + $assertPlaylistSongCount(name: string, count: number): void $assertFavoriteSongCount(count: number): void $selectSongRange(start: number, end: number, scrollBehavior?: scrollBehaviorOptions): Chainable diff --git a/database/migrations/2017_04_29_025836_rename_contributing_artist_id.php b/database/migrations/2017_04_29_025836_rename_contributing_artist_id.php index 5eed9f98..6d032b3c 100644 --- a/database/migrations/2017_04_29_025836_rename_contributing_artist_id.php +++ b/database/migrations/2017_04_29_025836_rename_contributing_artist_id.php @@ -2,7 +2,6 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; class RenameContributingArtistId extends Migration @@ -10,27 +9,7 @@ class RenameContributingArtistId extends Migration public function up(): void { Schema::table('songs', static function (Blueprint $table): void { - if (DB::getDriverName() !== 'sqlite') { // @phpstan-ignore-line - $table->dropForeign(['contributing_artist_id']); - } - - $table->renameColumn('contributing_artist_id', 'artist_id'); - $table->foreign('artist_id')->references('id')->on('artists')->onDelete('cascade'); - }); - } - - /** - * Reverse the migrations. - * - */ - public function down(): void - { - Schema::table('songs', static function (Blueprint $table): void { - if (DB::getDriverName() !== 'sqlite') { // @phpstan-ignore-line - $table->dropForeign(['contributing_artist_id']); - } - - $table->renameColumn('artist_id', 'contributing_artist_id'); + $table->integer('artist_id')->unsigned()->nullable(); $table->foreign('artist_id')->references('id')->on('artists')->onDelete('cascade'); }); } diff --git a/database/migrations/2022_06_11_091750_add_indexes.php b/database/migrations/2022_06_11_091750_add_indexes.php new file mode 100644 index 00000000..9a48cb52 --- /dev/null +++ b/database/migrations/2022_06_11_091750_add_indexes.php @@ -0,0 +1,20 @@ +index('title'); + $table->index(['track', 'disc']); + }); + + Schema::table('albums', static function (Blueprint $table): void { + $table->index('name'); + }); + } +}; diff --git a/database/migrations/2022_07_05_085742_remove_default_album_covers.php b/database/migrations/2022_07_05_085742_remove_default_album_covers.php new file mode 100644 index 00000000..a6cab617 --- /dev/null +++ b/database/migrations/2022_07_05_085742_remove_default_album_covers.php @@ -0,0 +1,12 @@ +update(['cover' => null]); + } +}; diff --git a/database/migrations/2022_07_07_203511_convert_settings_to_json.php b/database/migrations/2022_07_07_203511_convert_settings_to_json.php new file mode 100644 index 00000000..561b18d3 --- /dev/null +++ b/database/migrations/2022_07_07_203511_convert_settings_to_json.php @@ -0,0 +1,15 @@ +each(static function (Setting $setting): void { + $setting->value = unserialize($setting->getRawOriginal('value')); + $setting->save(); + }); + } +}; diff --git a/database/migrations/2022_08_01_093952_use_uuids_for_song_ids.php b/database/migrations/2022_08_01_093952_use_uuids_for_song_ids.php new file mode 100644 index 00000000..0cd66ee9 --- /dev/null +++ b/database/migrations/2022_08_01_093952_use_uuids_for_song_ids.php @@ -0,0 +1,43 @@ +string('id', 36)->change(); + }); + + Schema::table('playlist_song', static function (Blueprint $table): void { + $table->string('song_id', 36)->change(); + + if (DB::getDriverName() !== 'sqlite') { + $table->dropForeign('playlist_song_song_id_foreign'); + } + + $table->foreign('song_id')->references('id')->on('songs')->cascadeOnDelete()->cascadeOnUpdate(); + }); + + Schema::table('interactions', static function (Blueprint $table): void { + $table->string('song_id', 36)->change(); + + if (DB::getDriverName() !== 'sqlite') { + $table->dropForeign('interactions_song_id_foreign'); + } + + $table->foreign('song_id')->references('id')->on('songs')->cascadeOnDelete()->cascadeOnUpdate(); + }); + + Song::all()->each(static function (Song $song): void { + $song->id = Str::uuid(); + $song->save(); + }); + } +}; diff --git a/database/seeders/AlbumTableSeeder.php b/database/seeders/AlbumTableSeeder.php index 1a67d480..49f9cf53 100644 --- a/database/seeders/AlbumTableSeeder.php +++ b/database/seeders/AlbumTableSeeder.php @@ -16,7 +16,6 @@ class AlbumTableSeeder extends Seeder ], [ 'artist_id' => Artist::UNKNOWN_ID, 'name' => Album::UNKNOWN_NAME, - 'cover' => Album::UNKNOWN_COVER, ]); self::maybeResetPgsqlSerialValue(); diff --git a/nginx.conf.example b/nginx.conf.example index 1213a9a7..d0e38331 100644 --- a/nginx.conf.example +++ b/nginx.conf.example @@ -8,6 +8,10 @@ server { gzip_types text/plain text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript application/json; gzip_comp_level 9; + location / { + try_files $uri $uri/ /index.php?$args; + } + location /media/ { internal; @@ -17,10 +21,6 @@ server { #error_log /var/log/nginx/koel.error.log; } - location / { - try_files $uri $uri/ /index.php?$args; - } - location ~ \.php$ { try_files $uri $uri/ /index.php?$args; diff --git a/package.json b/package.json index 876a94bf..bc64e621 100644 --- a/package.json +++ b/package.json @@ -13,42 +13,87 @@ "type": "git", "url": "https://github.com/koel/koel" }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.1.1", + "@fortawesome/free-brands-svg-icons": "^6.1.1", + "@fortawesome/free-regular-svg-icons": "^6.1.1", + "@fortawesome/free-solid-svg-icons": "^6.1.1", + "@fortawesome/vue-fontawesome": "^3.0.1", + "axios": "^0.21.1", + "blueimp-md5": "^2.3.0", + "compare-versions": "^3.5.1", + "ismobilejs": "^0.4.0", + "local-storage": "^2.0.0", + "lodash": "^4.17.19", + "nouislider": "^14.0.2", + "nprogress": "^0.2.0", + "plyr": "1.5.x", + "pusher-js": "^4.1.0", + "select": "^1.1.2", + "sketch-js": "^1.1.3", + "slugify": "^1.0.2", + "vue": "^3.2.32", + "vue-global-events": "^2.1.1", + "youtube-player": "^3.0.4" + }, "devDependencies": { - "@testing-library/cypress": "^7.0.6", - "@typescript-eslint/eslint-plugin": "^4.11.1", + "@babel/core": "^7.17.9", + "@babel/polyfill": "^7.8.7", + "@babel/preset-env": "^7.9.6", + "@faker-js/faker": "^6.2.0", + "@testing-library/cypress": "^8.0.2", + "@testing-library/vue": "^6.5.1", + "@types/axios": "^0.14.0", + "@types/blueimp-md5": "^2.7.0", + "@types/local-storage": "^1.4.0", + "@types/lodash": "^4.14.150", + "@types/nprogress": "^0.2.0", + "@types/pusher-js": "^4.2.2", + "@types/youtube-player": "^5.5.2", + "@typescript-eslint/eslint-plugin": "^5.22.0", "@typescript-eslint/parser": "^4.11.1", - "cross-env": "^3.2.3", - "cypress": "^7.3.0", - "cypress-file-upload": "^4.1.1", - "eslint": "^7.17.0", - "font-awesome": "^4.7.0", + "@vitejs/plugin-vue": "^2.3.1", + "@vue/test-utils": "^2.0.0-rc.21", + "cross-env": "^7.0.3", + "css-loader": "^0.28.7", + "cypress": "^9.5.4", + "eslint": "^8.14.0", + "eslint-plugin-import": "^2.20.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-standard": "^4.0.1", + "eslint-plugin-vue": "^8.7.1", + "factoria": "^4.0.0", + "file-loader": "^1.1.6", "husky": "^4.2.5", + "jest-serializer-vue": "^2.0.2", + "jsdom": "^19.0.0", "kill-port": "^1.6.1", - "laravel-mix": "^5.0.4", + "laravel-vite-plugin": "^0.2.4", "lint-staged": "^10.3.0", + "postcss": "^8.4.12", "resolve-url-loader": "^3.1.1", - "sass": "^1.26.5", - "sass-loader": "^8.0.2", - "start-server-and-test": "^1.11.7", - "ts-loader": "^7.0.1", - "typescript": "^3.8.3", - "vue-template-compiler": "^2.6.11", - "webpack": "^4.42.1", - "webpack-node-externals": "^1.6.0" + "sass": "^1.50.0", + "sass-loader": "^12.6.0", + "start-server-and-test": "^1.14.0", + "ts-loader": "^9.3.0", + "typescript": "^4.6.3", + "vite": "^2.9.13", + "vitest": "^0.10.0", + "vue-loader": "^16.2.0", + "webpack": "^5.72.0", + "webpack-node-externals": "^3.0.0" }, "scripts": { - "lint": "eslint ./cypress/**/*.ts", - "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", - "watch-poll": "yarn watch -- --watch-poll", - "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", - "dev": "start-test 'php artisan serve --port=8000 --quiet' :8000 hot", - "test:e2e": "kill-port 8080 && start-test dev :8080 'cypress open'", - "test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' :8080 'cypress run'", - "build": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", - "build-demo": "cross-env NODE_ENV=demo node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js -p", - "production": "yarn build" + "lint": "eslint ./resources/assets/js/**/*.ts --no-error-on-unmatched-pattern && eslint ./cypress/**/*.ts --no-error-on-unmatched-pattern", + "test:unit": "vitest", + "test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'", + "test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' http-get://localhost:8080/api/ping 'cypress run --browser chromium'", + "build": "vite build", + "build-demo": "cross-env VITE_KOEL_ENV=demo vite build", + "dev": "kill-port 8000 && start-test 'php artisan serve --port=8000 --quiet' http-get://localhost:8000/api/ping vite", + "prod": "npm run production" }, - "dependencies": {}, "husky": { "hooks": { "pre-commit": "lint-staged" @@ -58,7 +103,10 @@ "**/*.php": [ "composer cs" ], - "**/*.ts": [ + "resources/assets/**/*.ts": [ + "eslint" + ], + "cypress/**/*.ts": [ "eslint" ] } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2238969d..f6721d8a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -22,6 +22,8 @@ parameters: - '#Call to an undefined method Illuminate\\Filesystem\\FilesystemAdapter::getAdapter\(\)#' - '#Call to an undefined method Mockery\\ExpectationInterface|Mockery\\HigherOrderMessage::with\(\)#' - '#Call to an undefined method Laravel\\Scout\\Builder::with\(\)#' + - '#Call to an undefined method Illuminate\\Contracts\\Database\\Query\\Builder::isStandard\(\)#' + - '#Call to an undefined method Illuminate\\Contracts\\Database\\Query\\Builder::withMeta\(\)#' - '#should return App\\Models\\.*(\|null)? but returns Illuminate\\Database\\Eloquent\\Model(\|null)?#' # Laravel factories allow declaration of dynamic methods as "states" - '#Call to an undefined method Illuminate\\Database\\Eloquent\\Factories\\Factory::#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index e16dc0a9..81666f73 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -36,6 +36,7 @@ + @@ -44,6 +45,7 @@ + diff --git a/public/.gitignore b/public/.gitignore index b0f1985b..da39ce39 100644 --- a/public/.gitignore +++ b/public/.gitignore @@ -1,6 +1,15 @@ css fonts -img + +# Ignore all (generated) images under img, but keep the folder structure +img/* +!img/covers +img/covers/* +!img/covers/.gitkeep +!img/artists +img/artists/* +!img/artists/.gitkeep + images js manifest.json @@ -8,3 +17,4 @@ manifest-remote.json hot mix-manifest.json .user.ini +build diff --git a/public/img/.gitkeep b/public/img/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/img/artists/.gitkeep b/public/img/artists/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/public/img/covers/.gitkeep b/public/img/covers/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/resources/assets b/resources/assets deleted file mode 160000 index 853396f2..00000000 --- a/resources/assets +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 853396f2b17cfaa420de772d5534f8eb2fce5ff2 diff --git a/resources/assets/.gitignore b/resources/assets/.gitignore new file mode 100644 index 00000000..59339ab4 --- /dev/null +++ b/resources/assets/.gitignore @@ -0,0 +1,35 @@ +node_modules + +### Node ### +# Logs +logs +*.log +npm-debug.log*node_modules + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +### Sass ### +.sass-cache/ +*.css.map + +.nyc_output +*.swp +*.swo +*~ + +__coverage__ diff --git a/resources/assets/img/artists/.gitkeep b/resources/assets/img/artists/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/resources/assets/img/covers/default.svg b/resources/assets/img/covers/default.svg new file mode 100644 index 00000000..4c508545 --- /dev/null +++ b/resources/assets/img/covers/default.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/resources/assets/img/favicon.ico b/resources/assets/img/favicon.ico new file mode 100644 index 00000000..b06c15cc Binary files /dev/null and b/resources/assets/img/favicon.ico differ diff --git a/resources/assets/img/icon.png b/resources/assets/img/icon.png new file mode 100644 index 00000000..870359b2 Binary files /dev/null and b/resources/assets/img/icon.png differ diff --git a/resources/assets/img/itunes.svg b/resources/assets/img/itunes.svg new file mode 100755 index 00000000..5bc96dc9 --- /dev/null +++ b/resources/assets/img/itunes.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/resources/assets/img/logo.png b/resources/assets/img/logo.png new file mode 100644 index 00000000..b7fe2f74 Binary files /dev/null and b/resources/assets/img/logo.png differ diff --git a/resources/assets/img/logo.svg b/resources/assets/img/logo.svg new file mode 100644 index 00000000..025237de --- /dev/null +++ b/resources/assets/img/logo.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/resources/assets/img/themes/bg-cat.jpg b/resources/assets/img/themes/bg-cat.jpg new file mode 100644 index 00000000..fb4ab465 Binary files /dev/null and b/resources/assets/img/themes/bg-cat.jpg differ diff --git a/resources/assets/img/themes/bg-dawn.jpg b/resources/assets/img/themes/bg-dawn.jpg new file mode 100644 index 00000000..0ab16e13 Binary files /dev/null and b/resources/assets/img/themes/bg-dawn.jpg differ diff --git a/resources/assets/img/themes/bg-jungle.jpg b/resources/assets/img/themes/bg-jungle.jpg new file mode 100644 index 00000000..1cb22852 Binary files /dev/null and b/resources/assets/img/themes/bg-jungle.jpg differ diff --git a/resources/assets/img/themes/bg-mountains.jpg b/resources/assets/img/themes/bg-mountains.jpg new file mode 100644 index 00000000..dfb1db58 Binary files /dev/null and b/resources/assets/img/themes/bg-mountains.jpg differ diff --git a/resources/assets/img/themes/bg-nemo.jpg b/resources/assets/img/themes/bg-nemo.jpg new file mode 100644 index 00000000..f70bc4ae Binary files /dev/null and b/resources/assets/img/themes/bg-nemo.jpg differ diff --git a/resources/assets/img/themes/bg-pines.jpg b/resources/assets/img/themes/bg-pines.jpg new file mode 100644 index 00000000..81915a82 Binary files /dev/null and b/resources/assets/img/themes/bg-pines.jpg differ diff --git a/resources/assets/img/themes/bg-pop-culture.jpg b/resources/assets/img/themes/bg-pop-culture.jpg new file mode 100644 index 00000000..b7d1a379 Binary files /dev/null and b/resources/assets/img/themes/bg-pop-culture.jpg differ diff --git a/resources/assets/img/themes/bg-purple-waves.svg b/resources/assets/img/themes/bg-purple-waves.svg new file mode 100644 index 00000000..a6f11db1 --- /dev/null +++ b/resources/assets/img/themes/bg-purple-waves.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/assets/img/themes/bg-rose-petals.svg b/resources/assets/img/themes/bg-rose-petals.svg new file mode 100644 index 00000000..e3a5014c --- /dev/null +++ b/resources/assets/img/themes/bg-rose-petals.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/assets/img/themes/thumbnails/cat.jpg b/resources/assets/img/themes/thumbnails/cat.jpg new file mode 100644 index 00000000..52f5bef3 Binary files /dev/null and b/resources/assets/img/themes/thumbnails/cat.jpg differ diff --git a/resources/assets/img/themes/thumbnails/dawn.jpg b/resources/assets/img/themes/thumbnails/dawn.jpg new file mode 100644 index 00000000..dcface25 Binary files /dev/null and b/resources/assets/img/themes/thumbnails/dawn.jpg differ diff --git a/resources/assets/img/themes/thumbnails/jungle.jpg b/resources/assets/img/themes/thumbnails/jungle.jpg new file mode 100644 index 00000000..64338741 Binary files /dev/null and b/resources/assets/img/themes/thumbnails/jungle.jpg differ diff --git a/resources/assets/img/themes/thumbnails/mountains.jpg b/resources/assets/img/themes/thumbnails/mountains.jpg new file mode 100644 index 00000000..c99a9575 Binary files /dev/null and b/resources/assets/img/themes/thumbnails/mountains.jpg differ diff --git a/resources/assets/img/themes/thumbnails/nemo.jpg b/resources/assets/img/themes/thumbnails/nemo.jpg new file mode 100644 index 00000000..f2efa278 Binary files /dev/null and b/resources/assets/img/themes/thumbnails/nemo.jpg differ diff --git a/resources/assets/img/themes/thumbnails/pines.jpg b/resources/assets/img/themes/thumbnails/pines.jpg new file mode 100644 index 00000000..8637acb1 Binary files /dev/null and b/resources/assets/img/themes/thumbnails/pines.jpg differ diff --git a/resources/assets/img/themes/thumbnails/pop-culture.jpg b/resources/assets/img/themes/thumbnails/pop-culture.jpg new file mode 100644 index 00000000..73cb7cdd Binary files /dev/null and b/resources/assets/img/themes/thumbnails/pop-culture.jpg differ diff --git a/resources/assets/img/tile-wide.png b/resources/assets/img/tile-wide.png new file mode 100755 index 00000000..c5dd00e0 Binary files /dev/null and b/resources/assets/img/tile-wide.png differ diff --git a/resources/assets/img/tile.png b/resources/assets/img/tile.png new file mode 100755 index 00000000..507ab11c Binary files /dev/null and b/resources/assets/img/tile.png differ diff --git a/resources/assets/js/App.vue b/resources/assets/js/App.vue new file mode 100644 index 00000000..ad4cd063 --- /dev/null +++ b/resources/assets/js/App.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/resources/assets/js/__tests__/UnitTestCase.ts b/resources/assets/js/__tests__/UnitTestCase.ts new file mode 100644 index 00000000..9f9f95f7 --- /dev/null +++ b/resources/assets/js/__tests__/UnitTestCase.ts @@ -0,0 +1,131 @@ +import isMobile from 'ismobilejs' +import { isObject, mergeWith } from 'lodash' +import { cleanup, render, RenderOptions } from '@testing-library/vue' +import { afterEach, beforeEach, vi } from 'vitest' +import { clickaway, droppable, focus } from '@/directives' +import { defineComponent, nextTick } from 'vue' +import { commonStore, userStore } from '@/stores' +import factory from '@/__tests__/factory' +import { DialogBoxKey, MessageToasterKey } from '@/symbols' +import { DialogBoxStub, MessageToasterStub } from '@/__tests__/stubs' + +// A deep-merge function that +// - supports symbols as keys (_.merge doesn't) +// - supports Vue's Ref type without losing reactivity (deepmerge doesn't) +// Credit: https://stackoverflow.com/a/60598589/794641 +const deepMerge = (first: object, second: object) => { + return mergeWith(first, second, (a, b) => { + if (!isObject(b)) return b + + return Array.isArray(a) ? [...a, ...b] : { ...a, ...b } + }) +} + +export default abstract class UnitTestCase { + private backupMethods = new Map() + + public constructor () { + this.beforeEach() + this.afterEach() + this.test() + } + + protected beforeEach (cb?: Closure) { + beforeEach(() => { + commonStore.state.allow_download = true + commonStore.state.use_i_tunes = true + cb && cb() + }) + } + + protected afterEach (cb?: Closure) { + afterEach(() => { + cleanup() + this.restoreAllMocks() + isMobile.any = false + cb && cb() + }) + } + + protected actingAs (user?: User) { + userStore.state.current = user || factory('user') + return this + } + + protected actingAsAdmin () { + return this.actingAs(factory.states('admin')('user')) + } + + protected mock>> (obj: T, methodName: M, implementation?: any) { + const mock = vi.fn() + + if (implementation !== undefined) { + mock.mockImplementation(implementation instanceof Function ? implementation : () => implementation) + } + + this.backupMethods.set([obj, methodName], obj[methodName]) + + // @ts-ignore + obj[methodName] = mock + + return mock + } + + protected restoreAllMocks () { + this.backupMethods.forEach((fn, [obj, methodName]) => (obj[methodName] = fn)) + this.backupMethods = new Map() + } + + protected render (component: any, options: RenderOptions = {}) { + return render(component, deepMerge({ + global: { + directives: { + 'koel-clickaway': clickaway, + 'koel-focus': focus, + 'koel-droppable': droppable + }, + components: { + icon: this.stub('icon') + } + } + }, this.supplyRequiredProvides(options))) + } + + private supplyRequiredProvides (options: RenderOptions) { + options.global = options.global || {} + options.global.provide = options.global.provide || {} + + if (!options.global.provide?.hasOwnProperty(DialogBoxKey)) { + options.global.provide[DialogBoxKey] = DialogBoxStub + } + + if (!options.global.provide?.hasOwnProperty(MessageToasterKey)) { + options.global.provide[MessageToasterKey] = MessageToasterStub + } + + return options + } + + protected stub (testId = 'stub') { + return defineComponent({ + template: `
` + }) + } + + protected async tick (count = 1) { + for (let i = 0; i < count; ++i) { + await nextTick() + } + } + + protected setReadOnlyProperty (obj: T, prop: keyof T, value: any) { + return Object.defineProperties(obj, { + [prop]: { + value, + configurable: true + } + }) + } + + protected abstract test () +} diff --git a/resources/assets/js/__tests__/factory/albumFactory.ts b/resources/assets/js/__tests__/factory/albumFactory.ts new file mode 100644 index 00000000..2489b570 --- /dev/null +++ b/resources/assets/js/__tests__/factory/albumFactory.ts @@ -0,0 +1,27 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): Album => { + const length = faker.datatype.number({ min: 300 }) + + return { + type: 'albums', + artist_id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default + artist_name: faker.name.findName(), + song_count: faker.datatype.number(30), + id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default + name: faker.lorem.sentence(), + cover: faker.image.imageUrl(), + play_count: faker.datatype.number(), + length, + created_at: faker.date.past().toISOString() + } +} + +export const states: Record, 'type'>> = { + unknown: { + id: 1, + name: 'Unknown Album', + artist_id: 1, + artist_name: 'Unknown Artist' + } +} diff --git a/resources/assets/js/__tests__/factory/albumInfoFactory.ts b/resources/assets/js/__tests__/factory/albumInfoFactory.ts new file mode 100644 index 00000000..c356177e --- /dev/null +++ b/resources/assets/js/__tests__/factory/albumInfoFactory.ts @@ -0,0 +1,12 @@ +import { Faker } from '@faker-js/faker' +import factory from 'factoria' + +export default (faker: Faker): AlbumInfo => ({ + cover: faker.image.imageUrl(), + wiki: { + summary: faker.lorem.sentence(), + full: faker.lorem.sentences(4) + }, + tracks: factory('album-track', 8), + url: faker.internet.url() +}) diff --git a/resources/assets/js/__tests__/factory/albumTrackFactory.ts b/resources/assets/js/__tests__/factory/albumTrackFactory.ts new file mode 100644 index 00000000..3eb59b9a --- /dev/null +++ b/resources/assets/js/__tests__/factory/albumTrackFactory.ts @@ -0,0 +1,6 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): AlbumTrack => ({ + title: faker.lorem.sentence(), + length: faker.datatype.number({ min: 180, max: 1_800 }) +}) diff --git a/resources/assets/js/__tests__/factory/artistFactory.ts b/resources/assets/js/__tests__/factory/artistFactory.ts new file mode 100644 index 00000000..b666cab5 --- /dev/null +++ b/resources/assets/js/__tests__/factory/artistFactory.ts @@ -0,0 +1,28 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): Artist => { + const length = faker.datatype.number({ min: 300 }) + + return { + type: 'artists', + id: faker.datatype.number({ min: 3 }), // avoid Unknown and Various Artist by default + name: faker.name.findName(), + image: 'foo.jpg', + play_count: faker.datatype.number(), + album_count: faker.datatype.number({ max: 10 }), + song_count: faker.datatype.number({ max: 100 }), + length, + created_at: faker.date.past().toISOString() + } +} + +export const states: Record, 'type'>> = { + unknown: { + id: 1, + name: 'Unknown Artist' + }, + various: { + id: 2, + name: 'Various Artists' + } +} diff --git a/resources/assets/js/__tests__/factory/artistInfoFactory.ts b/resources/assets/js/__tests__/factory/artistInfoFactory.ts new file mode 100644 index 00000000..2583e49d --- /dev/null +++ b/resources/assets/js/__tests__/factory/artistInfoFactory.ts @@ -0,0 +1,10 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): ArtistInfo => ({ + image: faker.image.imageUrl(), + bio: { + summary: faker.lorem.sentence(), + full: faker.lorem.sentences(4) + }, + url: faker.internet.url() +}) diff --git a/resources/assets/js/__tests__/factory/index.ts b/resources/assets/js/__tests__/factory/index.ts new file mode 100644 index 00000000..c4865c80 --- /dev/null +++ b/resources/assets/js/__tests__/factory/index.ts @@ -0,0 +1,27 @@ +import factory from 'factoria' +import artistFactory, { states as artistStates } from '@/__tests__/factory/artistFactory' +import songFactory, { states as songStates } from '@/__tests__/factory/songFactory' +import albumFactory, { states as albumStates } from '@/__tests__/factory/albumFactory' +import interactionFactory from '@/__tests__/factory/interactionFactory' +import smartPlaylistRuleFactory from '@/__tests__/factory/smartPlaylistRuleFactory' +import smartPlaylistRuleGroupFactory from '@/__tests__/factory/smartPlaylistRuleGroupFactory' +import playlistFactory, { states as playlistStates } from '@/__tests__/factory/playlistFactory' +import userFactory, { states as userStates } from '@/__tests__/factory/userFactory' +import albumTrackFactory from '@/__tests__/factory/albumTrackFactory' +import albumInfoFactory from '@/__tests__/factory/albumInfoFactory' +import artistInfoFactory from '@/__tests__/factory/artistInfoFactory' +import youTubeVideoFactory from '@/__tests__/factory/youTubeVideoFactory' + +export default factory + .define('artist', faker => artistFactory(faker), artistStates) + .define('artist-info', faker => artistInfoFactory(faker)) + .define('album', faker => albumFactory(faker), albumStates) + .define('album-track', faker => albumTrackFactory(faker)) + .define('album-info', faker => albumInfoFactory(faker)) + .define('song', faker => songFactory(faker), songStates) + .define('interaction', faker => interactionFactory(faker)) + .define('video', faker => youTubeVideoFactory(faker)) + .define('smart-playlist-rule', faker => smartPlaylistRuleFactory(faker)) + .define('smart-playlist-rule-group', faker => smartPlaylistRuleGroupFactory(faker)) + .define('playlist', faker => playlistFactory(faker), playlistStates) + .define('user', faker => userFactory(faker), userStates) diff --git a/resources/assets/js/__tests__/factory/interactionFactory.ts b/resources/assets/js/__tests__/factory/interactionFactory.ts new file mode 100644 index 00000000..d36392a4 --- /dev/null +++ b/resources/assets/js/__tests__/factory/interactionFactory.ts @@ -0,0 +1,9 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): Interaction => ({ + type: 'interactions', + id: faker.datatype.number({ min: 1 }), + song_id: faker.datatype.uuid(), + liked: faker.datatype.boolean(), + play_count: faker.datatype.number({ min: 1 }) +}) diff --git a/resources/assets/js/__tests__/factory/playlistFactory.ts b/resources/assets/js/__tests__/factory/playlistFactory.ts new file mode 100644 index 00000000..91603d83 --- /dev/null +++ b/resources/assets/js/__tests__/factory/playlistFactory.ts @@ -0,0 +1,19 @@ +import factory from 'factoria' +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): Playlist => ({ + type: 'playlists', + id: faker.datatype.number(), + name: faker.random.word(), + is_smart: false, + rules: [] +}) + +export const states: Record Omit, 'type'>> = { + smart: faker => ({ + is_smart: true, + rules: [ + factory('smart-playlist-rule') + ] + }) +} diff --git a/resources/assets/js/__tests__/factory/smartPlaylistRuleFactory.ts b/resources/assets/js/__tests__/factory/smartPlaylistRuleFactory.ts new file mode 100644 index 00000000..71f2c877 --- /dev/null +++ b/resources/assets/js/__tests__/factory/smartPlaylistRuleFactory.ts @@ -0,0 +1,8 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): SmartPlaylistRule => ({ + id: faker.datatype.number(), + model: faker.random.arrayElement(['title', 'artist.name', 'album.name']), + operator: faker.random.arrayElement(['is', 'contains', 'isNot']), + value: [faker.random.word()] +}) diff --git a/resources/assets/js/__tests__/factory/smartPlaylistRuleGroupFactory.ts b/resources/assets/js/__tests__/factory/smartPlaylistRuleGroupFactory.ts new file mode 100644 index 00000000..9c38e3c0 --- /dev/null +++ b/resources/assets/js/__tests__/factory/smartPlaylistRuleGroupFactory.ts @@ -0,0 +1,7 @@ +import { Faker } from '@faker-js/faker' +import factory from 'factoria' + +export default (faker: Faker): SmartPlaylistRuleGroup => ({ + id: faker.datatype.number(), + rules: factory('smart-playlist-rule', 3) +}) diff --git a/resources/assets/js/__tests__/factory/songFactory.ts b/resources/assets/js/__tests__/factory/songFactory.ts new file mode 100644 index 00000000..5c8c01f3 --- /dev/null +++ b/resources/assets/js/__tests__/factory/songFactory.ts @@ -0,0 +1,35 @@ +import { Faker, faker } from '@faker-js/faker' + +const generate = (partOfCompilation = false): Song => { + const artistId = faker.datatype.number({ min: 3 }) + const artistName = faker.name.findName() + + return { + type: 'songs', + artist_id: artistId, + album_id: faker.datatype.number({ min: 2 }), // avoid Unknown Album by default + artist_name: artistName, + album_name: faker.lorem.sentence(), + album_artist_id: partOfCompilation ? artistId + 1 : artistId, + album_artist_name: partOfCompilation ? artistName : faker.name.findName(), + album_cover: faker.image.imageUrl(), + id: faker.datatype.uuid(), + title: faker.lorem.sentence(), + length: faker.datatype.number(), + track: faker.datatype.number(), + disc: faker.datatype.number({ min: 1, max: 2 }), + lyrics: faker.lorem.paragraph(), + play_count: faker.datatype.number(), + liked: faker.datatype.boolean(), + created_at: faker.date.past().toISOString(), + playback_state: 'Stopped' + } +} + +export default (faker: Faker): Song => { + return generate() +} + +export const states: Record> = { + partOfCompilation: generate(true) +} diff --git a/resources/assets/js/__tests__/factory/userFactory.ts b/resources/assets/js/__tests__/factory/userFactory.ts new file mode 100644 index 00000000..ac372045 --- /dev/null +++ b/resources/assets/js/__tests__/factory/userFactory.ts @@ -0,0 +1,18 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): User => ({ + type: 'users', + id: faker.datatype.number(), + name: faker.name.findName(), + email: faker.internet.email(), + password: faker.internet.password(), + is_admin: false, + avatar: 'https://gravatar.com/foo', + preferences: {} +}) + +export const states: Record, 'type'>> = { + admin: { + is_admin: true + } +} diff --git a/resources/assets/js/__tests__/factory/youTubeVideoFactory.ts b/resources/assets/js/__tests__/factory/youTubeVideoFactory.ts new file mode 100644 index 00000000..a1fd5916 --- /dev/null +++ b/resources/assets/js/__tests__/factory/youTubeVideoFactory.ts @@ -0,0 +1,16 @@ +import { Faker } from '@faker-js/faker' + +export default (faker: Faker): YouTubeVideo => ({ + id: { + videoId: faker.random.alphaNumeric(16) + }, + snippet: { + title: faker.lorem.sentence(), + description: faker.lorem.paragraph(), + thumbnails: { + default: { + url: faker.image.imageUrl() + } + } + } +}) diff --git a/resources/assets/js/__tests__/setup.ts b/resources/assets/js/__tests__/setup.ts new file mode 100644 index 00000000..f69d9ffe --- /dev/null +++ b/resources/assets/js/__tests__/setup.ts @@ -0,0 +1,17 @@ +import vueSnapshotSerializer from 'jest-serializer-vue' +import { expect, vi } from 'vitest' + +expect.addSnapshotSerializer(vueSnapshotSerializer) + +global.ResizeObserver = global.ResizeObserver || + vi.fn().mockImplementation(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + unobserve: vi.fn() + })) + +window.HTMLMediaElement.prototype.load = vi.fn() +window.HTMLMediaElement.prototype.play = vi.fn() +window.HTMLMediaElement.prototype.pause = vi.fn() + +window.BASE_URL = 'https://koel.test/' diff --git a/resources/assets/js/__tests__/stubs.ts b/resources/assets/js/__tests__/stubs.ts new file mode 100644 index 00000000..ec02819c --- /dev/null +++ b/resources/assets/js/__tests__/stubs.ts @@ -0,0 +1,19 @@ +import { Ref, ref } from 'vue' +import { noop } from '@/utils' +import MessageToaster from '@/components/ui/MessageToaster.vue' +import DialogBox from '@/components/ui/DialogBox.vue' + +export const MessageToasterStub: InstanceType> = ref({ + info: noop, + success: noop, + warning: noop, + error: noop +}) + +export const DialogBoxStub: InstanceType> = ref({ + info: noop, + success: noop, + warning: noop, + error: noop, + confirm: noop +}) diff --git a/resources/assets/js/app.ts b/resources/assets/js/app.ts new file mode 100644 index 00000000..f127cc18 --- /dev/null +++ b/resources/assets/js/app.ts @@ -0,0 +1,20 @@ +import 'plyr/dist/plyr.js' +import { createApp } from 'vue' +import { clickaway, droppable, focus } from '@/directives' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import App from './App.vue' + +createApp(App) + .component('icon', FontAwesomeIcon) + .directive('koel-focus', focus) + .directive('koel-clickaway', clickaway) + .directive('koel-droppable', droppable) + /** + * For Ancelot, the ancient cross of war + * for the holy town of Gods + * Gloria, gloria perpetua + * in this dawn of victory + */ + .mount('#app') + +navigator.serviceWorker?.register('./sw.js') diff --git a/resources/assets/js/components/album/AlbumCard.spec.ts b/resources/assets/js/components/album/AlbumCard.spec.ts new file mode 100644 index 00000000..b5bf51e6 --- /dev/null +++ b/resources/assets/js/components/album/AlbumCard.spec.ts @@ -0,0 +1,59 @@ +import { fireEvent } from '@testing-library/vue' +import { expect, it } from 'vitest' +import { downloadService, playbackService } from '@/services' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import AlbumCard from './AlbumCard.vue' +import { songStore } from '@/stores' + +let album: Album + +new class extends UnitTestCase { + private renderComponent () { + album = factory('album', { + name: 'IV', + play_count: 30, + song_count: 10, + length: 123 + }) + + return this.render(AlbumCard, { + props: { + album + } + }) + } + + protected test () { + it('renders', () => { + const { getByText, getByTestId } = this.renderComponent() + + expect(getByTestId('name').textContent).toBe('IV') + getByText(/^10 songs.+02:03.+30 plays$/) + getByTestId('shuffle-album') + getByTestId('download-album') + }) + + it('downloads', async () => { + const mock = this.mock(downloadService, 'fromAlbum') + const { getByTestId } = this.renderComponent() + + await fireEvent.click(getByTestId('download-album')) + + expect(mock).toHaveBeenCalledTimes(1) + }) + + it('shuffles', async () => { + const songs = factory('song', 10) + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + const shuffleMock = this.mock(playbackService, 'queueAndPlay').mockResolvedValue(void 0) + const { getByTestId } = this.renderComponent() + + await fireEvent.click(getByTestId('shuffle-album')) + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(album) + expect(shuffleMock).toHaveBeenCalledWith(songs, true) + }) + } +} diff --git a/resources/assets/js/components/album/AlbumCard.vue b/resources/assets/js/components/album/AlbumCard.vue new file mode 100644 index 00000000..97ce1856 --- /dev/null +++ b/resources/assets/js/components/album/AlbumCard.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/resources/assets/js/components/album/AlbumContextMenu.spec.ts b/resources/assets/js/components/album/AlbumContextMenu.spec.ts new file mode 100644 index 00000000..6422df13 --- /dev/null +++ b/resources/assets/js/components/album/AlbumContextMenu.spec.ts @@ -0,0 +1,102 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import { eventBus } from '@/utils' +import { downloadService, playbackService } from '@/services' +import { commonStore, songStore } from '@/stores' +import router from '@/router' +import AlbumContextMenu from './AlbumContextMenu.vue' + +let album: Album + +new class extends UnitTestCase { + private async renderComponent (_album?: Album) { + album = _album || factory('album', { + name: 'IV', + play_count: 30, + song_count: 10, + length: 123 + }) + + const rendered = this.render(AlbumContextMenu) + eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, album) + await this.tick(2) + + return rendered + } + + protected test () { + it('renders', async () => { + const { html } = await this.renderComponent() + expect(html()).toMatchSnapshot() + }) + + it('plays all', async () => { + const songs = factory('song', 10) + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + + const { getByText } = await this.renderComponent() + await getByText('Play All').click() + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(album) + expect(playMock).toHaveBeenCalledWith(songs) + }) + + it('shuffles all', async () => { + const songs = factory('song', 10) + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + + const { getByText } = await this.renderComponent() + await getByText('Shuffle All').click() + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(album) + expect(playMock).toHaveBeenCalledWith(songs, true) + }) + + it('downloads', async () => { + const mock = this.mock(downloadService, 'fromAlbum') + + const { getByText } = await this.renderComponent() + await getByText('Download').click() + + expect(mock).toHaveBeenCalledWith(album) + }) + + it('does not have an option to download if downloading is disabled', async () => { + commonStore.state.allow_download = false + const { queryByText } = await this.renderComponent() + + expect(queryByText('Download')).toBeNull() + }) + + it('goes to album', async () => { + const mock = this.mock(router, 'go') + const { getByText } = await this.renderComponent() + + await getByText('Go to Album').click() + + expect(mock).toHaveBeenCalledWith(`album/${album.id}`) + }) + + it('does not have an option to download or go to Unknown Album and Artist', async () => { + const { queryByTestId } = await this.renderComponent(factory.states('unknown')('album')) + + expect(queryByTestId('view-album')).toBeNull() + expect(queryByTestId('view-artist')).toBeNull() + expect(queryByTestId('download')).toBeNull() + }) + + it('goes to artist', async () => { + const mock = this.mock(router, 'go') + const { getByText } = await this.renderComponent() + + await getByText('Go to Artist').click() + + expect(mock).toHaveBeenCalledWith(`artist/${album.artist_id}`) + }) + } +} diff --git a/resources/assets/js/components/album/AlbumContextMenu.vue b/resources/assets/js/components/album/AlbumContextMenu.vue new file mode 100644 index 00000000..9e9fc7a3 --- /dev/null +++ b/resources/assets/js/components/album/AlbumContextMenu.vue @@ -0,0 +1,50 @@ + + + diff --git a/resources/assets/js/components/album/AlbumInfo.spec.ts b/resources/assets/js/components/album/AlbumInfo.spec.ts new file mode 100644 index 00000000..c3adfcaf --- /dev/null +++ b/resources/assets/js/components/album/AlbumInfo.spec.ts @@ -0,0 +1,82 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { mediaInfoService } from '@/services/mediaInfoService' +import { commonStore, songStore } from '@/stores' +import { fireEvent } from '@testing-library/vue' +import { playbackService } from '@/services' +import AlbumInfoComponent from './AlbumInfo.vue' + +let album: Album + +new class extends UnitTestCase { + private async renderComponent (mode: MediaInfoDisplayMode = 'aside', info?: AlbumInfo) { + commonStore.state.use_last_fm = true + + if (info === undefined) { + info = factory('album-info') + } + + album = factory('album', { name: 'IV' }) + const fetchMock = this.mock(mediaInfoService, 'fetchForAlbum').mockResolvedValue(info) + + const rendered = this.render(AlbumInfoComponent, { + props: { + album, + mode + }, + global: { + stubs: { + TrackList: this.stub() + } + } + }) + + await this.tick(1) + expect(fetchMock).toHaveBeenCalledWith(album) + + return rendered + } + + protected test () { + it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => { + const { getByTestId } = await this.renderComponent(mode) + + getByTestId('album-artist-thumbnail') + getByTestId('album-info-tracks') + + expect(getByTestId('album-info').classList.contains(mode)).toBe(true) + }) + + it('triggers showing full wiki for aside mode', async () => { + const { queryByTestId } = await this.renderComponent('aside') + expect(queryByTestId('full')).toBeNull() + + await fireEvent.click(queryByTestId('more-btn')) + + expect(queryByTestId('summary')).toBeNull() + expect(queryByTestId('full')).not.toBeNull() + }) + + it('shows full wiki for full mode', async () => { + const { queryByTestId } = await this.renderComponent('full') + + expect(queryByTestId('full')).not.toBeNull() + expect(queryByTestId('summary')).toBeNull() + expect(queryByTestId('more-btn')).toBeNull() + }) + + it('plays', async () => { + const songs = factory('song', 3) + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + const { getByTitle } = await this.renderComponent() + + await fireEvent.click(getByTitle('Play all songs in IV')) + await this.tick(2) + + expect(fetchMock).toHaveBeenCalledWith(album) + expect(playMock).toHaveBeenCalledWith(songs) + }) + } +} diff --git a/resources/assets/js/components/album/AlbumInfo.vue b/resources/assets/js/components/album/AlbumInfo.vue new file mode 100644 index 00000000..a2b4bbfd --- /dev/null +++ b/resources/assets/js/components/album/AlbumInfo.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/resources/assets/js/components/album/AlbumTrackList.spec.ts b/resources/assets/js/components/album/AlbumTrackList.spec.ts new file mode 100644 index 00000000..798da14e --- /dev/null +++ b/resources/assets/js/components/album/AlbumTrackList.spec.ts @@ -0,0 +1,26 @@ +import factory from '@/__tests__/factory' +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import AlbumTrackList from './AlbumTrackList.vue' +import { songStore } from '@/stores' + +new class extends UnitTestCase { + protected test () { + it('displays the tracks', async () => { + const album = factory('album') + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(factory('song', 5)) + + const { queryAllByTestId } = this.render(AlbumTrackList, { + props: { + album, + tracks: factory('album-track', 3) + } + }) + + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(album) + expect(queryAllByTestId('album-track-item')).toHaveLength(3) + }) + } +} diff --git a/resources/assets/js/components/album/AlbumTrackList.vue b/resources/assets/js/components/album/AlbumTrackList.vue new file mode 100644 index 00000000..495c65a2 --- /dev/null +++ b/resources/assets/js/components/album/AlbumTrackList.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/resources/assets/js/components/album/AlbumTrackListItem.spec.ts b/resources/assets/js/components/album/AlbumTrackListItem.spec.ts new file mode 100644 index 00000000..f16def8d --- /dev/null +++ b/resources/assets/js/components/album/AlbumTrackListItem.spec.ts @@ -0,0 +1,56 @@ +import { fireEvent } from '@testing-library/vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { queueStore, songStore } from '@/stores' +import { playbackService } from '@/services' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { SongsKey } from '@/symbols' +import { ref } from 'vue' +import AlbumTrackListItem from './AlbumTrackListItem.vue' + +new class extends UnitTestCase { + private renderComponent (matchedSong?: Song) { + const songsToMatchAgainst = factory('song', 10) + const album = factory('album') + + const track = factory('album-track', { + title: 'Fahrstuhl to Heaven', + length: 280 + }) + + const matchMock = this.mock(songStore, 'match', matchedSong) + + const rendered = this.render(AlbumTrackListItem, { + props: { + album, + track + }, + global: { + provide: { + [SongsKey]: [ref(songsToMatchAgainst)] + } + } + }) + + expect(matchMock).toHaveBeenCalledWith('Fahrstuhl to Heaven', songsToMatchAgainst) + + return rendered + } + + protected test () { + it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot()) + + it('plays', async () => { + const matchedSong = factory('song') + const queueMock = this.mock(queueStore, 'queueIfNotQueued') + const playMock = this.mock(playbackService, 'play') + + const { getByTitle } = this.renderComponent(matchedSong) + + await fireEvent.click(getByTitle('Click to play')) + + expect(queueMock).toHaveBeenNthCalledWith(1, matchedSong) + expect(playMock).toHaveBeenNthCalledWith(1, matchedSong) + }) + } +} diff --git a/resources/assets/js/components/album/AlbumTrackListItem.vue b/resources/assets/js/components/album/AlbumTrackListItem.vue new file mode 100644 index 00000000..2d39eae5 --- /dev/null +++ b/resources/assets/js/components/album/AlbumTrackListItem.vue @@ -0,0 +1,81 @@ + + + + + diff --git a/resources/assets/js/components/album/__snapshots__/AlbumContextMenu.spec.ts.snap b/resources/assets/js/components/album/__snapshots__/AlbumContextMenu.spec.ts.snap new file mode 100644 index 00000000..574cb999 --- /dev/null +++ b/resources/assets/js/components/album/__snapshots__/AlbumContextMenu.spec.ts.snap @@ -0,0 +1,15 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` + +`; diff --git a/resources/assets/js/components/album/__snapshots__/AlbumTrackListItem.spec.ts.snap b/resources/assets/js/components/album/__snapshots__/AlbumTrackListItem.spec.ts.snap new file mode 100644 index 00000000..9363b45d --- /dev/null +++ b/resources/assets/js/components/album/__snapshots__/AlbumTrackListItem.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
Fahrstuhl to Heaven + 04:40 +
+`; diff --git a/resources/assets/js/components/artist/ArtistCard.spec.ts b/resources/assets/js/components/artist/ArtistCard.spec.ts new file mode 100644 index 00000000..84190532 --- /dev/null +++ b/resources/assets/js/components/artist/ArtistCard.spec.ts @@ -0,0 +1,80 @@ +import { fireEvent } from '@testing-library/vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { downloadService, playbackService } from '@/services' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { commonStore, songStore } from '@/stores' +import ArtistCard from './ArtistCard.vue' + +let artist: Artist + +new class extends UnitTestCase { + protected beforeEach () { + super.beforeEach(() => { + artist = factory('artist', { + name: 'Led Zeppelin', + album_count: 4, + play_count: 124, + song_count: 16 + }) + }) + } + + protected test () { + it('renders', () => { + const { getByText, getByTestId } = this.render(ArtistCard, { + props: { + artist + } + }) + + expect(getByTestId('name').textContent).toBe('Led Zeppelin') + getByText(/^4 albums\s+•\s+16 songs.+124 plays$/) + getByTestId('shuffle-artist') + getByTestId('download-artist') + }) + + it('downloads', async () => { + const mock = this.mock(downloadService, 'fromArtist') + + const { getByTestId } = this.render(ArtistCard, { + props: { + artist + } + }) + + await fireEvent.click(getByTestId('download-artist')) + expect(mock).toHaveBeenCalledOnce() + }) + + it('does not have an option to download if downloading is disabled', async () => { + commonStore.state.allow_download = false + + const { queryByTestId } = this.render(ArtistCard, { + props: { + artist + } + }) + + expect(queryByTestId('download-artist')).toBeNull() + }) + + it('shuffles', async () => { + const songs = factory('song', 16) + const fetchMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + + const { getByTestId } = this.render(ArtistCard, { + props: { + artist + } + }) + + await fireEvent.click(getByTestId('shuffle-artist')) + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(artist) + expect(playMock).toHaveBeenCalled(songs, true) + }) + } +} diff --git a/resources/assets/js/components/artist/ArtistCard.vue b/resources/assets/js/components/artist/ArtistCard.vue new file mode 100644 index 00000000..afde9361 --- /dev/null +++ b/resources/assets/js/components/artist/ArtistCard.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/resources/assets/js/components/artist/ArtistContextMenu.spec.ts b/resources/assets/js/components/artist/ArtistContextMenu.spec.ts new file mode 100644 index 00000000..fae93777 --- /dev/null +++ b/resources/assets/js/components/artist/ArtistContextMenu.spec.ts @@ -0,0 +1,99 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import { eventBus } from '@/utils' +import { downloadService, playbackService } from '@/services' +import { commonStore, songStore } from '@/stores' +import router from '@/router' +import ArtistContextMenu from './ArtistContextMenu.vue' + +let artist: Artist + +new class extends UnitTestCase { + private async renderComponent (_artist?: Artist) { + artist = _artist || factory('artist', { + name: 'Accept', + play_count: 30, + song_count: 10, + length: 123 + }) + + const rendered = this.render(ArtistContextMenu) + eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, artist) + await this.tick(2) + + return rendered + } + + protected test () { + it('renders', async () => { + const { html } = await this.renderComponent() + expect(html()).toMatchSnapshot() + }) + + it('plays all', async () => { + const songs = factory('song', 10) + const fetchMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + + const { getByText } = await this.renderComponent() + await getByText('Play All').click() + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(artist) + expect(playMock).toHaveBeenCalledWith(songs) + }) + + it('shuffles all', async () => { + const songs = factory('song', 10) + const fetchMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + + const { getByText } = await this.renderComponent() + await getByText('Shuffle All').click() + await this.tick() + + expect(fetchMock).toHaveBeenCalledWith(artist) + expect(playMock).toHaveBeenCalledWith(songs, true) + }) + + it('downloads', async () => { + const mock = this.mock(downloadService, 'fromArtist') + + const { getByText } = await this.renderComponent() + await getByText('Download').click() + + expect(mock).toHaveBeenCalledWith(artist) + }) + + it('does not have an option to download if downloading is disabled', async () => { + commonStore.state.allow_download = false + const { queryByText } = await this.renderComponent() + + expect(queryByText('Download')).toBeNull() + }) + + it('goes to artist', async () => { + const mock = this.mock(router, 'go') + const { getByText } = await this.renderComponent() + + await getByText('Go to Artist').click() + + expect(mock).toHaveBeenCalledWith(`artist/${artist.id}`) + }) + + it('does not have an option to download or go to Unknown Artist', async () => { + const { queryByTestId } = await this.renderComponent(factory.states('unknown')('artist')) + + expect(queryByTestId('view-artist')).toBeNull() + expect(queryByTestId('download')).toBeNull() + }) + + it('does not have an option to download or go to Various Artist', async () => { + const { queryByTestId } = await this.renderComponent(factory.states('various')('artist')) + + expect(queryByTestId('view-artist')).toBeNull() + expect(queryByTestId('download')).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/artist/ArtistContextMenu.vue b/resources/assets/js/components/artist/ArtistContextMenu.vue new file mode 100644 index 00000000..9f9cf568 --- /dev/null +++ b/resources/assets/js/components/artist/ArtistContextMenu.vue @@ -0,0 +1,49 @@ + + + diff --git a/resources/assets/js/components/artist/ArtistInfo.spec.ts b/resources/assets/js/components/artist/ArtistInfo.spec.ts new file mode 100644 index 00000000..15e9fee2 --- /dev/null +++ b/resources/assets/js/components/artist/ArtistInfo.spec.ts @@ -0,0 +1,76 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { mediaInfoService } from '@/services/mediaInfoService' +import { commonStore, songStore } from '@/stores' +import { fireEvent } from '@testing-library/vue' +import { playbackService } from '@/services' +import ArtistInfoComponent from './ArtistInfo.vue' + +let artist: Album + +new class extends UnitTestCase { + private async renderComponent (mode: MediaInfoDisplayMode = 'aside', info?: ArtistInfo) { + commonStore.state.use_last_fm = true + + if (info === undefined) { + info = factory('artist-info') + } + + artist = factory('artist', { name: 'Led Zeppelin' }) + const fetchMock = this.mock(mediaInfoService, 'fetchForArtist').mockResolvedValue(info) + + const rendered = this.render(ArtistInfoComponent, { + props: { + artist, + mode + } + }) + + await this.tick(1) + expect(fetchMock).toHaveBeenCalledWith(artist) + + return rendered + } + + protected test () { + it.each<[MediaInfoDisplayMode]>([['aside'], ['full']])('renders in %s mode', async (mode) => { + const { getByTestId } = await this.renderComponent(mode) + + getByTestId('album-artist-thumbnail') + + expect(getByTestId('artist-info').classList.contains(mode)).toBe(true) + }) + + it('triggers showing full bio for aside mode', async () => { + const { queryByTestId } = await this.renderComponent('aside') + expect(queryByTestId('full')).toBeNull() + + await fireEvent.click(queryByTestId('more-btn')) + + expect(queryByTestId('summary')).toBeNull() + expect(queryByTestId('full')).not.toBeNull() + }) + + it('shows full bio for full mode', async () => { + const { queryByTestId } = await this.renderComponent('full') + + expect(queryByTestId('full')).not.toBeNull() + expect(queryByTestId('summary')).toBeNull() + expect(queryByTestId('more-btn')).toBeNull() + }) + + it('plays', async () => { + const songs = factory('song', 3) + const fetchMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + const { getByTitle } = await this.renderComponent() + + await fireEvent.click(getByTitle('Play all songs by Led Zeppelin')) + await this.tick(2) + + expect(fetchMock).toHaveBeenCalledWith(artist) + expect(playMock).toHaveBeenCalledWith(songs) + }) + } +} diff --git a/resources/assets/js/components/artist/ArtistInfo.vue b/resources/assets/js/components/artist/ArtistInfo.vue new file mode 100644 index 00000000..e8b668c9 --- /dev/null +++ b/resources/assets/js/components/artist/ArtistInfo.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/resources/assets/js/components/artist/__snapshots__/ArtistContextMenu.spec.ts.snap b/resources/assets/js/components/artist/__snapshots__/ArtistContextMenu.spec.ts.snap new file mode 100644 index 00000000..54677f8f --- /dev/null +++ b/resources/assets/js/components/artist/__snapshots__/ArtistContextMenu.spec.ts.snap @@ -0,0 +1,14 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` + +`; diff --git a/resources/assets/js/components/auth/LoginForm.spec.ts b/resources/assets/js/components/auth/LoginForm.spec.ts new file mode 100644 index 00000000..48d8ebb2 --- /dev/null +++ b/resources/assets/js/components/auth/LoginForm.spec.ts @@ -0,0 +1,36 @@ +import { fireEvent } from '@testing-library/vue' +import { expect, it, SpyInstanceFn } from 'vitest' +import { userStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import LoginFrom from './LoginForm.vue' + +new class extends UnitTestCase { + private async submitForm (loginMock: SpyInstanceFn) { + const rendered = this.render(LoginFrom) + + await fireEvent.update(rendered.getByPlaceholderText('Email Address'), 'john@doe.com') + await fireEvent.update(rendered.getByPlaceholderText('Password'), 'secret') + await fireEvent.submit(rendered.getByTestId('login-form')) + + expect(loginMock).toHaveBeenCalledWith('john@doe.com', 'secret') + + return rendered + } + + protected test () { + it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot()) + + it('logs in', async () => { + expect((await this.submitForm(this.mock(userStore, 'login'))).emitted().loggedin).toBeTruthy() + }) + + it('fails to log in', async () => { + const mock = this.mock(userStore, 'login').mockRejectedValue(new Error('Unauthenticated')) + const { getByTestId, emitted } = await this.submitForm(mock) + await this.tick() + + expect(emitted().loggedin).toBeFalsy() + expect(getByTestId('login-form').classList.contains('error')).toBe(true) + }) + } +} diff --git a/resources/assets/js/components/auth/LoginForm.vue b/resources/assets/js/components/auth/LoginForm.vue new file mode 100644 index 00000000..b31d69d2 --- /dev/null +++ b/resources/assets/js/components/auth/LoginForm.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap b/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap new file mode 100644 index 00000000..ed59b3f0 --- /dev/null +++ b/resources/assets/js/components/auth/__snapshots__/LoginForm.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+ +
+`; diff --git a/resources/assets/js/components/layout/AppHeader.spec.ts b/resources/assets/js/components/layout/AppHeader.spec.ts new file mode 100644 index 00000000..e51933e7 --- /dev/null +++ b/resources/assets/js/components/layout/AppHeader.spec.ts @@ -0,0 +1,57 @@ +import isMobile from 'ismobilejs' +import { expect, it } from 'vitest' +import { fireEvent, waitFor } from '@testing-library/vue' +import { eventBus } from '@/utils' +import compareVersions from 'compare-versions' +import UnitTestCase from '@/__tests__/UnitTestCase' +import AppHeader from './AppHeader.vue' +import SearchForm from '@/components/ui/SearchForm.vue' + +new class extends UnitTestCase { + protected test () { + it('toggles sidebar (mobile only)', async () => { + isMobile.any = true + const { getByTitle } = this.render(AppHeader) + const mock = this.mock(eventBus, 'emit') + + await fireEvent.click(getByTitle('Show or hide the sidebar')) + + expect(mock).toHaveBeenCalledWith('TOGGLE_SIDEBAR') + }) + + it('toggles search form (mobile only)', async () => { + isMobile.any = true + + const { getByTitle, getByRole, queryByRole } = this.render(AppHeader, { + global: { + stubs: { + SearchForm + } + } + }) + + expect(await queryByRole('search')).toBeNull() + + await fireEvent.click(getByTitle('Show or hide the search form')) + await waitFor(() => getByRole('search')) + }) + + it.each([[true, true, true], [false, true, false], [true, false, false], [false, false, false]])( + 'announces a new version has new version: %s, is admin: %s, should announce: %s', + async (hasNewVersion, isAdmin, announcing) => { + this.mock(compareVersions, 'compare', hasNewVersion) + + if (isAdmin) { + this.actingAsAdmin() + } else { + this.actingAs() + } + + const { queryAllByTestId } = this.render(AppHeader) + + expect(queryAllByTestId('new-version')).toHaveLength(announcing ? 1 : 0) + } + ) + } +} + diff --git a/resources/assets/js/components/layout/AppHeader.vue b/resources/assets/js/components/layout/AppHeader.vue new file mode 100644 index 00000000..2ad6210a --- /dev/null +++ b/resources/assets/js/components/layout/AppHeader.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/resources/assets/js/components/layout/ModalWrapper.spec.ts b/resources/assets/js/components/layout/ModalWrapper.spec.ts new file mode 100644 index 00000000..bf9895ea --- /dev/null +++ b/resources/assets/js/components/layout/ModalWrapper.spec.ts @@ -0,0 +1,25 @@ +import factory from '@/__tests__/factory' +import { eventBus } from '@/utils' +import { it } from 'vitest' +import { EventName } from '@/config' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ModalWrapper from './ModalWrapper.vue' + +new class extends UnitTestCase { + protected test () { + it.each<[string, EventName, User | Song | Playlist | any]>([ + ['add-user-form', 'MODAL_SHOW_ADD_USER_FORM', undefined], + ['edit-user-form', 'MODAL_SHOW_EDIT_USER_FORM', factory('user')], + ['edit-song-form', 'MODAL_SHOW_EDIT_SONG_FORM', [factory('song')]], + ['create-smart-playlist-form', 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM', undefined], + ['edit-smart-playlist-form', 'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM', factory('playlist')], + ['about-koel', 'MODAL_SHOW_ABOUT_KOEL', undefined] + ])('shows %s modal', async (modalName: string, eventName: EventName, eventParams?: any) => { + const { findByTestId } = this.render(ModalWrapper) + + eventBus.emit(eventName, eventParams) + + findByTestId(modalName) + }) + } +} diff --git a/resources/assets/js/components/layout/ModalWrapper.vue b/resources/assets/js/components/layout/ModalWrapper.vue new file mode 100644 index 00000000..210cff59 --- /dev/null +++ b/resources/assets/js/components/layout/ModalWrapper.vue @@ -0,0 +1,120 @@ + + + + + diff --git a/resources/assets/js/components/layout/app-footer/FooterExtraControls.spec.ts b/resources/assets/js/components/layout/app-footer/FooterExtraControls.spec.ts new file mode 100644 index 00000000..a4b99eb4 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/FooterExtraControls.spec.ts @@ -0,0 +1,33 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { preferenceStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import FooterExtraControls from './FooterExtraControls.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => { + preferenceStore.state.showExtraPanel = true + + expect(this.render(FooterExtraControls, { + props: { + song: factory('song', { + playback_state: 'Playing', + title: 'Fahrstuhl to Heaven', + artist_name: 'Led Zeppelin', + artist_id: 3, + album_name: 'Led Zeppelin IV', + album_id: 4, + liked: false + }) + }, + global: { + stubs: { + RepeatModeSwitch: this.stub('RepeatModeSwitch'), + Volume: this.stub('Volume') + } + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue b/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue new file mode 100644 index 00000000..438bcb19 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/FooterExtraControls.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/resources/assets/js/components/layout/app-footer/FooterMiddlePane.spec.ts b/resources/assets/js/components/layout/app-footer/FooterMiddlePane.spec.ts new file mode 100644 index 00000000..6bfd4bd2 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/FooterMiddlePane.spec.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import FooterMiddlePane from './FooterMiddlePane.vue' + +new class extends UnitTestCase { + protected test () { + it('renders without a song', () => expect(this.render(FooterMiddlePane).html()).toMatchSnapshot()) + + it('renders with a song', () => { + expect(this.render(FooterMiddlePane, { + props: { + song: factory('song', { + title: 'Fahrstuhl to Heaven', + artist_name: 'Led Zeppelin', + artist_id: 3, + album_name: 'Led Zeppelin IV', + album_id: 4 + }) + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/layout/app-footer/FooterMiddlePane.vue b/resources/assets/js/components/layout/app-footer/FooterMiddlePane.vue new file mode 100644 index 00000000..4845e920 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/FooterMiddlePane.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/resources/assets/js/components/layout/app-footer/FooterPlayerControl.spec.ts b/resources/assets/js/components/layout/app-footer/FooterPlayerControl.spec.ts new file mode 100644 index 00000000..b8557598 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/FooterPlayerControl.spec.ts @@ -0,0 +1,42 @@ +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import { playbackService } from '@/services' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import FooterPlayerControls from './FooterPlayerControls.vue' + +new class extends UnitTestCase { + protected test () { + it.each<[string, string, MethodOf]>([ + ['plays next song', 'Play next song', 'playNext'], + ['plays previous song', 'Play previous song', 'playPrev'], + ['plays/resumes current song', 'Play or resume', 'toggle'] + ])('%s', async (_: string, title: string, playbackMethod: MethodOf) => { + const mock = this.mock(playbackService, playbackMethod) + + const { getByTitle } = this.render(FooterPlayerControls, { + props: { + song: factory('song') + } + }) + + await fireEvent.click(getByTitle(title)) + expect(mock).toHaveBeenCalled() + }) + + it('pauses the current song', async () => { + const mock = this.mock(playbackService, 'toggle') + + const { getByTitle } = this.render(FooterPlayerControls, { + props: { + song: factory('song', { + playback_state: 'Playing' + }) + } + }) + + await fireEvent.click(getByTitle('Pause')) + expect(mock).toHaveBeenCalled() + }) + } +} diff --git a/resources/assets/js/components/layout/app-footer/FooterPlayerControls.vue b/resources/assets/js/components/layout/app-footer/FooterPlayerControls.vue new file mode 100644 index 00000000..0044b879 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/FooterPlayerControls.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap new file mode 100644 index 00000000..a4865514 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterExtraControls.spec.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+
+


+
+
+`; diff --git a/resources/assets/js/components/layout/app-footer/__snapshots__/FooterMiddlePane.spec.ts.snap b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterMiddlePane.spec.ts.snap new file mode 100644 index 00000000..40418558 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/__snapshots__/FooterMiddlePane.spec.ts.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1 + +exports[`renders with a song 1`] = ` +
+
+

Fahrstuhl to Heaven

+

Led ZeppelinLed Zeppelin IV

+
+
+
+`; + +exports[`renders without a song 1`] = ` +
+
+ +
+
+
+`; diff --git a/resources/assets/js/components/layout/app-footer/index.vue b/resources/assets/js/components/layout/app-footer/index.vue new file mode 100644 index 00000000..e90bc0d0 --- /dev/null +++ b/resources/assets/js/components/layout/app-footer/index.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/resources/assets/js/components/layout/main-wrapper/ExtraPanel.spec.ts b/resources/assets/js/components/layout/main-wrapper/ExtraPanel.spec.ts new file mode 100644 index 00000000..3b4c9d82 --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/ExtraPanel.spec.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import factory from '@/__tests__/factory' +import { commonStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ExtraPanel from './ExtraPanel.vue' + +new class extends UnitTestCase { + private renderComponent () { + return this.render(ExtraPanel, { + props: { + song: factory('song') + }, + global: { + stubs: { + LyricsPane: this.stub(), + AlbumInfo: this.stub(), + ArtistInfo: this.stub(), + YouTubeVideoList: this.stub() + } + } + }) + } + + protected test () { + it('has a YouTube tab if using YouTube ', () => { + commonStore.state.use_you_tube = true + this.renderComponent().getByTestId('extra-tab-youtube') + }) + + it('does not have a YouTube tab if not using YouTube', () => { + commonStore.state.use_you_tube = false + expect(this.renderComponent().queryByTestId('extra-tab-youtube')).toBeNull() + }) + + it.each([['extra-tab-lyrics'], ['extra-tab-album'], ['extra-tab-artist']])('switches to "%s" tab', async (id) => { + const { getByTestId, container } = this.renderComponent() + + await fireEvent.click(getByTestId(id)) + + expect(container.querySelector('[aria-selected=true]')).toBe(getByTestId(id)) + }) + } +} diff --git a/resources/assets/js/components/layout/main-wrapper/ExtraPanel.vue b/resources/assets/js/components/layout/main-wrapper/ExtraPanel.vue new file mode 100644 index 00000000..7ca6252d --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/ExtraPanel.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/resources/assets/js/components/layout/main-wrapper/MainContent.spec.ts b/resources/assets/js/components/layout/main-wrapper/MainContent.spec.ts new file mode 100644 index 00000000..fc6e2db9 --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/MainContent.spec.ts @@ -0,0 +1,60 @@ +import { waitFor } from '@testing-library/vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { eventBus } from '@/utils' +import { albumStore, preferenceStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import MainContent from '@/components/layout/main-wrapper/MainContent.vue' +import AlbumArtOverlay from '@/components/ui/AlbumArtOverlay.vue' + +new class extends UnitTestCase { + protected test () { + it('has a translucent overlay per album', async () => { + this.mock(albumStore, 'fetchThumbnail').mockResolvedValue('https://foo/bar.jpg') + + const { getByTestId } = this.render(MainContent, { + global: { + stubs: { + AlbumArtOverlay + } + } + }) + + eventBus.emit('SONG_STARTED', factory('song')) + + await waitFor(() => getByTestId('album-art-overlay')) + }) + + it('does not have a translucent over if configured not so', async () => { + preferenceStore.state.showAlbumArtOverlay = false + + const { queryByTestId } = this.render(MainContent, { + global: { + stubs: { + AlbumArtOverlay + } + } + }) + + eventBus.emit('SONG_STARTED', factory('song')) + + await waitFor(() => expect(queryByTestId('album-art-overlay')).toBeNull()) + }) + + it('toggles visualizer', async () => { + const { getByTestId, queryByTestId } = this.render(MainContent, { + global: { + stubs: { + Visualizer: this.stub('visualizer') + } + } + }) + + eventBus.emit('TOGGLE_VISUALIZER') + await waitFor(() => getByTestId('visualizer')) + + eventBus.emit('TOGGLE_VISUALIZER') + await waitFor(() => expect(queryByTestId('visualizer')).toBeNull()) + }) + } +} diff --git a/resources/assets/js/components/layout/main-wrapper/MainContent.vue b/resources/assets/js/components/layout/main-wrapper/MainContent.vue new file mode 100644 index 00000000..36ef6866 --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/MainContent.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/resources/assets/js/components/layout/main-wrapper/Sidebar.spec.ts b/resources/assets/js/components/layout/main-wrapper/Sidebar.spec.ts new file mode 100644 index 00000000..8a76361a --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/Sidebar.spec.ts @@ -0,0 +1,8 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' + +new class extends UnitTestCase { + protected test () { + it('has already been tested in the integration suite', () => expect('😄').toBeTruthy()) + } +} diff --git a/resources/assets/js/components/layout/main-wrapper/Sidebar.vue b/resources/assets/js/components/layout/main-wrapper/Sidebar.vue new file mode 100644 index 00000000..c63860d2 --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/Sidebar.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/resources/assets/js/components/layout/main-wrapper/index.vue b/resources/assets/js/components/layout/main-wrapper/index.vue new file mode 100644 index 00000000..bd2cb621 --- /dev/null +++ b/resources/assets/js/components/layout/main-wrapper/index.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/assets/js/components/meta/AboutKoelModal.spec.ts b/resources/assets/js/components/meta/AboutKoelModal.spec.ts new file mode 100644 index 00000000..3655be7a --- /dev/null +++ b/resources/assets/js/components/meta/AboutKoelModal.spec.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest' +import { commonStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import AboutKoelModel from './AboutKoelModal.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', async () => { + commonStore.state.current_version = 'v0.0.0' + commonStore.state.latest_version = 'v0.0.0' + + expect(this.render(AboutKoelModel).html()).toMatchSnapshot() + }) + + it('shows new version', () => { + commonStore.state.current_version = 'v1.0.0' + commonStore.state.latest_version = 'v1.0.1' + this.actingAsAdmin().render(AboutKoelModel).findByTestId('new-version-about') + }) + + it('shows demo notation', () => { + import.meta.env.VITE_KOEL_ENV = 'demo' + this.render(AboutKoelModel).findByTestId('demo-credits') + }) + } +} diff --git a/resources/assets/js/components/meta/AboutKoelModal.vue b/resources/assets/js/components/meta/AboutKoelModal.vue new file mode 100644 index 00000000..2acced75 --- /dev/null +++ b/resources/assets/js/components/meta/AboutKoelModal.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/resources/assets/js/components/meta/SupportKoel.spec.ts b/resources/assets/js/components/meta/SupportKoel.spec.ts new file mode 100644 index 00000000..7cabfbb0 --- /dev/null +++ b/resources/assets/js/components/meta/SupportKoel.spec.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import { eventBus } from '@/utils' +import { preferenceStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import SupportKoel from './SupportKoel.vue' + +new class extends UnitTestCase { + protected beforeEach () { + super.beforeEach(() => vi.useFakeTimers()); + } + + protected afterEach () { + super.afterEach(() => { + vi.useRealTimers() + preferenceStore.state.supportBarNoBugging = false + }) + } + + private async renderComponent () { + const result = this.render(SupportKoel) + eventBus.emit('KOEL_READY') + + vi.advanceTimersByTime(30 * 60 * 1000) + await this.tick() + + return result + } + + protected test () { + it('shows after a delay', async () => { + expect((await this.renderComponent()).html()).toMatchSnapshot() + }) + + it('does not show if user so demands', async () => { + preferenceStore.state.supportBarNoBugging = true + expect((await this.renderComponent()).queryByTestId('support-bar')).toBeNull() + }) + + it('hides', async () => { + const { getByTestId, queryByTestId } = await this.renderComponent() + + await fireEvent.click(getByTestId('hide-support-koel')) + + expect(await queryByTestId('support-bar')).toBeNull() + }) + + it('hides and does not bug again', async () => { + const { getByTestId, queryByTestId } = await this.renderComponent() + + await fireEvent.click(getByTestId('stop-support-koel-bugging')) + + expect(await queryByTestId('btn-stop-support-koel-bugging')).toBeNull() + expect(preferenceStore.state.supportBarNoBugging).toBe(true) + }) + } +} diff --git a/resources/assets/js/components/meta/SupportKoel.vue b/resources/assets/js/components/meta/SupportKoel.vue new file mode 100644 index 00000000..260dfe2c --- /dev/null +++ b/resources/assets/js/components/meta/SupportKoel.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/resources/assets/js/components/meta/__snapshots__/AboutKoelModal.spec.ts.snap b/resources/assets/js/components/meta/__snapshots__/AboutKoelModal.spec.ts.snap new file mode 100644 index 00000000..40f30df9 --- /dev/null +++ b/resources/assets/js/components/meta/__snapshots__/AboutKoelModal.spec.ts.snap @@ -0,0 +1,18 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+
+

About Koel

+
+
+ +

v0.0.0

+ +

Made with ❤️ by Phan An and quite a few awesome  contributors.

+ +

Loving Koel? Please consider supporting its development via GitHub Sponsors and/or OpenCollective.

+
+
+
+`; diff --git a/resources/assets/js/components/meta/__snapshots__/SupportKoel.spec.ts.snap b/resources/assets/js/components/meta/__snapshots__/SupportKoel.spec.ts.snap new file mode 100644 index 00000000..3cb93002 --- /dev/null +++ b/resources/assets/js/components/meta/__snapshots__/SupportKoel.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1 + +exports[`shows after a delay 1`] = ` +
+

Loving Koel? Please consider supporting its development via GitHub Sponsors and/or OpenCollective.

+
+`; diff --git a/resources/assets/js/components/playlist/CreateNewPlaylistContextMenu.vue b/resources/assets/js/components/playlist/CreateNewPlaylistContextMenu.vue new file mode 100644 index 00000000..8ddfc1f7 --- /dev/null +++ b/resources/assets/js/components/playlist/CreateNewPlaylistContextMenu.vue @@ -0,0 +1,20 @@ + + + diff --git a/resources/assets/js/components/playlist/PlaylistContextMenu.vue b/resources/assets/js/components/playlist/PlaylistContextMenu.vue new file mode 100644 index 00000000..3cecf55b --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistContextMenu.vue @@ -0,0 +1,26 @@ + + + diff --git a/resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts b/resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts new file mode 100644 index 00000000..834363cc --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts @@ -0,0 +1,56 @@ +import factory from '@/__tests__/factory' +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import { playlistStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import PlaylistNameEditor from './PlaylistNameEditor.vue' + +let playlist: Playlist + +new class extends UnitTestCase { + private renderComponent () { + playlist = factory('playlist', { + id: 99, + name: 'Foo' + }) + + return this.render(PlaylistNameEditor, { + props: { + playlist + } + }).getByRole('textbox') + } + + protected test () { + it('updates a playlist name on blur', async () => { + const updateMock = this.mock(playlistStore, 'update') + const input = this.renderComponent() + + await fireEvent.update(input, 'Bar') + await fireEvent.blur(input) + + expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Bar' }) + }) + + it('updates a playlist name on enter', async () => { + const updateMock = this.mock(playlistStore, 'update') + const input = this.renderComponent() + + await fireEvent.update(input, 'Bar') + await fireEvent.keyUp(input, { key: 'Enter' }) + + expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Bar' }) + }) + + it('cancels updating on esc', async () => { + const updateMock = this.mock(playlistStore, 'update') + const input = this.renderComponent() + + await fireEvent.update(input, 'Bar') + await fireEvent.keyUp(input, { key: 'Esc' }) + + expect(input.value).toBe('Foo') + expect(updateMock).not.toHaveBeenCalled() + }) + } +} diff --git a/resources/assets/js/components/playlist/PlaylistNameEditor.vue b/resources/assets/js/components/playlist/PlaylistNameEditor.vue new file mode 100644 index 00000000..6a3b34c7 --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistNameEditor.vue @@ -0,0 +1,62 @@ + + + diff --git a/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts b/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts new file mode 100644 index 00000000..b0f3c1ef --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts @@ -0,0 +1,62 @@ +import factory from '@/__tests__/factory' +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import UnitTestCase from '@/__tests__/UnitTestCase' +import PlaylistSidebarItem from '@/components/playlist/PlaylistSidebarItem.vue' + +new class extends UnitTestCase { + renderComponent (playlist: Record, type: PlaylistType = 'playlist') { + return this.render(PlaylistSidebarItem, { + props: { + playlist, + type + }, + global: { + stubs: { + NameEditor: this.stub('name-editor') + } + } + }) + } + + protected test () { + it('edits the name of a standard playlist', async () => { + const { getByTestId, queryByTestId } = this.renderComponent(factory('playlist', { + id: 99, + name: 'A Standard Playlist' + })) + + expect(await queryByTestId('name-editor')).toBeNull() + + await fireEvent.dblClick(getByTestId('playlist-sidebar-item')) + + getByTestId('name-editor') + }) + + it('does not allow editing the name of the "Favorites" playlist', async () => { + const { getByTestId, queryByTestId } = this.renderComponent({ + name: 'Favorites', + songs: [] + }, 'favorites') + + expect(await queryByTestId('name-editor')).toBeNull() + + await fireEvent.dblClick(getByTestId('playlist-sidebar-item')) + + expect(await queryByTestId('name-editor')).toBeNull() + }) + + it('does not allow editing the name of the "Recently Played" playlist', async () => { + const { getByTestId, queryByTestId } = this.renderComponent({ + name: 'Recently Played', + songs: [] + }, 'recently-played') + + expect(await queryByTestId('name-editor')).toBeNull() + + await fireEvent.dblClick(getByTestId('playlist-sidebar-item')) + + expect(await queryByTestId('name-editor')).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/playlist/PlaylistSidebarItem.vue b/resources/assets/js/components/playlist/PlaylistSidebarItem.vue new file mode 100644 index 00000000..feed3ef3 --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistSidebarItem.vue @@ -0,0 +1,175 @@ + + + + + diff --git a/resources/assets/js/components/playlist/PlaylistSidebarList.spec.ts b/resources/assets/js/components/playlist/PlaylistSidebarList.spec.ts new file mode 100644 index 00000000..5cc71bdd --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistSidebarList.spec.ts @@ -0,0 +1,30 @@ +import { it } from 'vitest' +import { playlistStore } from '@/stores' +import factory from '@/__tests__/factory' +import PlaylistSidebarList from './PlaylistSidebarList.vue' +import PlaylistSidebarItem from './PlaylistSidebarItem.vue' +import UnitTestCase from '@/__tests__/UnitTestCase' + +new class extends UnitTestCase { + protected test () { + it('renders all playlists', () => { + playlistStore.state.playlists = [ + factory('playlist', { name: 'Foo Playlist' }), + factory('playlist', { name: 'Bar Playlist' }), + factory('playlist', { name: 'Smart Playlist', is_smart: true }) + ] + + const { getByText } = this.render(PlaylistSidebarList, { + global: { + stubs: { + PlaylistSidebarItem + } + } + }) + + ;['Favorites', 'Recently Played', 'Foo Playlist', 'Bar Playlist', 'Smart Playlist'].forEach(t => getByText(t)) + }) + + // other functionalities are handled by E2E + } +} diff --git a/resources/assets/js/components/playlist/PlaylistSidebarList.vue b/resources/assets/js/components/playlist/PlaylistSidebarList.vue new file mode 100644 index 00000000..1520013c --- /dev/null +++ b/resources/assets/js/components/playlist/PlaylistSidebarList.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue new file mode 100644 index 00000000..92254fac --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue @@ -0,0 +1,87 @@ + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistEditForm.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistEditForm.vue new file mode 100644 index 00000000..d801333c --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistEditForm.vue @@ -0,0 +1,105 @@ + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue new file mode 100644 index 00000000..8030efd3 --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRule.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRule.vue new file mode 100644 index 00000000..04449c59 --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRule.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRuleGroup.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRuleGroup.vue new file mode 100644 index 00000000..1b757d4e --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRuleGroup.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRuleInput.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRuleInput.vue new file mode 100644 index 00000000..e96cf641 --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistRuleInput.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/resources/assets/js/components/playlist/smart-playlist/useSmartPlaylistForm.ts b/resources/assets/js/components/playlist/smart-playlist/useSmartPlaylistForm.ts new file mode 100644 index 00000000..5b7db401 --- /dev/null +++ b/resources/assets/js/components/playlist/smart-playlist/useSmartPlaylistForm.ts @@ -0,0 +1,34 @@ +import { ref } from 'vue' +import { playlistStore } from '@/stores' + +import Btn from '@/components/ui/Btn.vue' +import FormBase from '@/components/playlist/smart-playlist/SmartPlaylistFormBase.vue' +import RuleGroup from '@/components/playlist/smart-playlist/SmartPlaylistRuleGroup.vue' +import SoundBars from '@/components/ui/SoundBars.vue' + +export const useSmartPlaylistForm = (initialRuleGroups: SmartPlaylistRuleGroup[] = []) => { + const collectedRuleGroups = ref(initialRuleGroups) + const loading = ref(false) + + const addGroup = () => collectedRuleGroups.value.push(playlistStore.createEmptySmartPlaylistRuleGroup()) + + const onGroupChanged = (data: SmartPlaylistRuleGroup) => { + const changedGroup = Object.assign(collectedRuleGroups.value.find(g => g.id === data.id), data) + + // Remove empty group + if (changedGroup.rules.length === 0) { + collectedRuleGroups.value = collectedRuleGroups.value.filter(group => group.id !== changedGroup.id) + } + } + + return { + Btn, + FormBase, + RuleGroup, + SoundBars, + collectedRuleGroups, + loading, + addGroup, + onGroupChanged + } +} diff --git a/resources/assets/js/components/profile-preferences/LastfmIntegration.spec.ts b/resources/assets/js/components/profile-preferences/LastfmIntegration.spec.ts new file mode 100644 index 00000000..1ec95b3b --- /dev/null +++ b/resources/assets/js/components/profile-preferences/LastfmIntegration.spec.ts @@ -0,0 +1,8 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' + +new class extends UnitTestCase { + protected test () { + it('is already covered by E2E', () => expect('🤞').toBeTruthy()) + } +} diff --git a/resources/assets/js/components/profile-preferences/LastfmIntegration.vue b/resources/assets/js/components/profile-preferences/LastfmIntegration.vue new file mode 100644 index 00000000..be2ec622 --- /dev/null +++ b/resources/assets/js/components/profile-preferences/LastfmIntegration.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/resources/assets/js/components/profile-preferences/PreferencesForm.spec.ts b/resources/assets/js/components/profile-preferences/PreferencesForm.spec.ts new file mode 100644 index 00000000..161494e9 --- /dev/null +++ b/resources/assets/js/components/profile-preferences/PreferencesForm.spec.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest' +import isMobile from 'ismobilejs' +import UnitTestCase from '@/__tests__/UnitTestCase' +import PreferencesForm from './PreferencesForm.vue' + +new class extends UnitTestCase { + protected test () { + it('has "Transcode on mobile" option for mobile users', () => { + isMobile.phone = true + const { getByLabelText } = this.render(PreferencesForm) + getByLabelText('Convert and play media at 128kbps on mobile') + }) + + it('does not have "Transcode on mobile" option for non-mobile users', async () => { + isMobile.phone = false + const { queryByLabelText } = this.render(PreferencesForm) + expect(await queryByLabelText('Convert and play media at 128kbps on mobile')).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/profile-preferences/PreferencesForm.vue b/resources/assets/js/components/profile-preferences/PreferencesForm.vue new file mode 100644 index 00000000..294e1397 --- /dev/null +++ b/resources/assets/js/components/profile-preferences/PreferencesForm.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/resources/assets/js/components/profile-preferences/ProfileForm.vue b/resources/assets/js/components/profile-preferences/ProfileForm.vue new file mode 100644 index 00000000..070264c6 --- /dev/null +++ b/resources/assets/js/components/profile-preferences/ProfileForm.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/resources/assets/js/components/profile-preferences/ThemeCard.spec.ts b/resources/assets/js/components/profile-preferences/ThemeCard.spec.ts new file mode 100644 index 00000000..07a213b4 --- /dev/null +++ b/resources/assets/js/components/profile-preferences/ThemeCard.spec.ts @@ -0,0 +1,31 @@ +import UnitTestCase from '@/__tests__/UnitTestCase' +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import ThemeCard from './ThemeCard.vue' + +const theme: Theme = { + id: 'sample', + thumbnailColor: '#f00' +} + +new class extends UnitTestCase { + private renderComponent () { + return this.render(ThemeCard, { + props: { + theme + } + }) + } + + protected test () { + it('renders', () => { + expect(this.renderComponent().html()).toMatchSnapshot() + }) + + it('emits an event when selected', async () => { + const { emitted, getByTestId } = this.renderComponent() + await fireEvent.click(getByTestId('theme-card-sample')) + expect(emitted().selected[0]).toEqual([theme]) + }) + } +} diff --git a/resources/assets/js/components/profile-preferences/ThemeCard.vue b/resources/assets/js/components/profile-preferences/ThemeCard.vue new file mode 100644 index 00000000..967879eb --- /dev/null +++ b/resources/assets/js/components/profile-preferences/ThemeCard.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/resources/assets/js/components/profile-preferences/ThemeList.spec.ts b/resources/assets/js/components/profile-preferences/ThemeList.spec.ts new file mode 100644 index 00000000..05c036ad --- /dev/null +++ b/resources/assets/js/components/profile-preferences/ThemeList.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { themeStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import themes from '@/themes' +import ThemeList from './ThemeList.vue' + +new class extends UnitTestCase { + protected test () { + it('displays all themes', () => { + themeStore.init() + expect(this.render(ThemeList).getAllByTestId('theme-card').length).toEqual(themes.length) + }) + } +} diff --git a/resources/assets/js/components/profile-preferences/ThemeList.vue b/resources/assets/js/components/profile-preferences/ThemeList.vue new file mode 100644 index 00000000..730fe1d9 --- /dev/null +++ b/resources/assets/js/components/profile-preferences/ThemeList.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/resources/assets/js/components/profile-preferences/__snapshots__/ThemeCard.spec.ts.snap b/resources/assets/js/components/profile-preferences/__snapshots__/ThemeCard.spec.ts.snap new file mode 100644 index 00000000..71cc442e --- /dev/null +++ b/resources/assets/js/components/profile-preferences/__snapshots__/ThemeCard.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+
Sample
+
+`; diff --git a/resources/assets/js/components/screens/AlbumListScreen.spec.ts b/resources/assets/js/components/screens/AlbumListScreen.spec.ts new file mode 100644 index 00000000..516d0b0d --- /dev/null +++ b/resources/assets/js/components/screens/AlbumListScreen.spec.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { albumStore, preferenceStore } from '@/stores' +import { eventBus } from '@/utils' +import { fireEvent, waitFor } from '@testing-library/vue' +import AlbumListScreen from './AlbumListScreen.vue' + +new class extends UnitTestCase { + protected beforeEach () { + super.beforeEach(() => this.mock(albumStore, 'paginate')) + } + + private renderComponent () { + albumStore.state.albums = factory('album', 9) + return this.render(AlbumListScreen) + } + + protected test () { + it('renders', () => { + expect(this.renderComponent().getAllByTestId('album-card')).toHaveLength(9) + }) + + it.each<[ArtistAlbumViewMode]>([['list'], ['thumbnails']])('sets layout from preferences', async (mode) => { + preferenceStore.albumsViewMode = mode + + const { getByTestId } = this.renderComponent() + eventBus.emit('LOAD_MAIN_CONTENT', 'Albums') + + await waitFor(() => expect(getByTestId('album-list').classList.contains(`as-${mode}`)).toBe(true)) + }) + + it('switches layout', async () => { + const { getByTestId, getByTitle } = this.renderComponent() + + await fireEvent.click(getByTitle('View as list')) + await waitFor(() => expect(getByTestId('album-list').classList.contains(`as-list`)).toBe(true)) + + await fireEvent.click(getByTitle('View as thumbnails')) + await waitFor(() => expect(getByTestId('album-list').classList.contains(`as-thumbnails`)).toBe(true)) + }) + } +} diff --git a/resources/assets/js/components/screens/AlbumListScreen.vue b/resources/assets/js/components/screens/AlbumListScreen.vue new file mode 100644 index 00000000..f0db1ce3 --- /dev/null +++ b/resources/assets/js/components/screens/AlbumListScreen.vue @@ -0,0 +1,83 @@ + + + + + +` diff --git a/resources/assets/js/components/screens/AlbumScreen.spec.ts b/resources/assets/js/components/screens/AlbumScreen.spec.ts new file mode 100644 index 00000000..b2d64271 --- /dev/null +++ b/resources/assets/js/components/screens/AlbumScreen.spec.ts @@ -0,0 +1,94 @@ +import { fireEvent, waitFor } from '@testing-library/vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { albumStore, commonStore, songStore } from '@/stores' +import { downloadService } from '@/services' +import router from '@/router' +import { eventBus } from '@/utils' +import CloseModalBtn from '@/components/ui/BtnCloseModal.vue' +import AlbumScreen from './AlbumScreen.vue' + +let album: Album + +new class extends UnitTestCase { + protected async renderComponent () { + commonStore.state.use_last_fm = true + + album = factory('album', { + id: 42, + name: 'Led Zeppelin IV', + artist_id: 123, + artist_name: 'Led Zeppelin', + song_count: 10, + length: 1_603 + }) + + const resolveAlbumMock = this.mock(albumStore, 'resolve').mockResolvedValue(album) + + const songs = factory('song', 13) + const fetchSongsMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + + const rendered = this.render(AlbumScreen, { + props: { + album: 42 + }, + global: { + stubs: { + CloseModalBtn, + AlbumInfo: this.stub('album-info'), + SongList: this.stub('song-list') + } + } + }) + + await waitFor(() => { + expect(resolveAlbumMock).toHaveBeenCalledWith(album.id) + expect(fetchSongsMock).toHaveBeenCalledWith(album.id) + }) + + await this.tick(2) + + return rendered + } + + protected test () { + it('renders', async () => { + const { html } = await this.renderComponent() + expect(html()).toMatchSnapshot() + }) + + it('shows and hides info', async () => { + const { getByTitle, getByTestId, queryByTestId, html } = await this.renderComponent() + expect(queryByTestId('album-info')).toBeNull() + + await fireEvent.click(getByTitle('View album information')) + expect(queryByTestId('album-info')).not.toBeNull() + + await fireEvent.click(getByTestId('close-modal-btn')) + expect(queryByTestId('album-info')).toBeNull() + }) + + it('downloads', async () => { + const downloadMock = this.mock(downloadService, 'fromAlbum') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Download All')) + + expect(downloadMock).toHaveBeenCalledWith(album) + }) + + it('goes back to list if album is deleted', async () => { + const goMock = this.mock(router, 'go') + const byIdMock = this.mock(albumStore, 'byId', null) + await this.renderComponent() + + eventBus.emit('SONGS_UPDATED') + + await waitFor(() => { + expect(byIdMock).toHaveBeenCalledWith(album.id) + expect(goMock).toHaveBeenCalledWith('albums') + }) + }) + } +} diff --git a/resources/assets/js/components/screens/AlbumScreen.vue b/resources/assets/js/components/screens/AlbumScreen.vue new file mode 100644 index 00000000..febd9a84 --- /dev/null +++ b/resources/assets/js/components/screens/AlbumScreen.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/resources/assets/js/components/screens/AllSongsScreen.spec.ts b/resources/assets/js/components/screens/AllSongsScreen.spec.ts new file mode 100644 index 00000000..6addcc25 --- /dev/null +++ b/resources/assets/js/components/screens/AllSongsScreen.spec.ts @@ -0,0 +1,53 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { commonStore, queueStore, songStore } from '@/stores' +import { fireEvent, waitFor } from '@testing-library/vue' +import { eventBus } from '@/utils' +import { playbackService } from '@/services' +import router from '@/router' +import AllSongsScreen from './AllSongsScreen.vue' + +new class extends UnitTestCase { + private async renderComponent () { + commonStore.state.song_count = 420 + commonStore.state.song_length = 123_456 + songStore.state.songs = factory('song', 20) + const fetchMock = this.mock(songStore, 'paginate').mockResolvedValue(2) + + const rendered = this.render(AllSongsScreen, { + global: { + stubs: { + SongList: this.stub('song-list') + } + } + }) + + eventBus.emit('LOAD_MAIN_CONTENT', 'Songs') + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('title', 'asc', 1)) + return rendered + } + + protected test () { + it('renders', async () => { + const { html } = await this.renderComponent() + await waitFor(() => expect(html()).toMatchSnapshot()) + }) + + it('shuffles', async () => { + const queueMock = this.mock(queueStore, 'fetchRandom') + const playMock = this.mock(playbackService, 'playFirstInQueue') + const goMock = this.mock(router, 'go') + const { getByTitle } = await this.renderComponent() + + await fireEvent.click(getByTitle('Shuffle all songs')) + + await waitFor(() => { + expect(queueMock).toHaveBeenCalled() + expect(playMock).toHaveBeenCalled() + expect(goMock).toHaveBeenCalledWith('queue') + }) + }) + } +} diff --git a/resources/assets/js/components/screens/AllSongsScreen.vue b/resources/assets/js/components/screens/AllSongsScreen.vue new file mode 100644 index 00000000..788390f8 --- /dev/null +++ b/resources/assets/js/components/screens/AllSongsScreen.vue @@ -0,0 +1,112 @@ + + + diff --git a/resources/assets/js/components/screens/ArtistListScreen.spec.ts b/resources/assets/js/components/screens/ArtistListScreen.spec.ts new file mode 100644 index 00000000..9739df01 --- /dev/null +++ b/resources/assets/js/components/screens/ArtistListScreen.spec.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { artistStore, preferenceStore } from '@/stores' +import { eventBus } from '@/utils' +import { fireEvent, waitFor } from '@testing-library/vue' +import ArtistListScreen from './ArtistListScreen.vue' + +new class extends UnitTestCase { + protected beforeEach () { + super.beforeEach(() => this.mock(artistStore, 'paginate')) + } + + private renderComponent () { + artistStore.state.artists = factory('artist', 9) + return this.render(ArtistListScreen) + } + + protected test () { + it('renders', () => { + expect(this.renderComponent().getAllByTestId('artist-card')).toHaveLength(9) + }) + + it.each<[ArtistAlbumViewMode]>([['list'], ['thumbnails']])('sets layout from preferences', async (mode) => { + preferenceStore.artistsViewMode = mode + + const { getByTestId } = this.renderComponent() + eventBus.emit('LOAD_MAIN_CONTENT', 'Artists') + + await waitFor(() => expect(getByTestId('artist-list').classList.contains(`as-${mode}`)).toBe(true)) + }) + + it('switches layout', async () => { + const { getByTestId, getByTitle } = this.renderComponent() + + await fireEvent.click(getByTitle('View as list')) + await waitFor(() => expect(getByTestId('artist-list').classList.contains(`as-list`)).toBe(true)) + + await fireEvent.click(getByTitle('View as thumbnails')) + await waitFor(() => expect(getByTestId('artist-list').classList.contains(`as-thumbnails`)).toBe(true)) + }) + } +} diff --git a/resources/assets/js/components/screens/ArtistListScreen.vue b/resources/assets/js/components/screens/ArtistListScreen.vue new file mode 100644 index 00000000..25fbd522 --- /dev/null +++ b/resources/assets/js/components/screens/ArtistListScreen.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/resources/assets/js/components/screens/ArtistScreen.spec.ts b/resources/assets/js/components/screens/ArtistScreen.spec.ts new file mode 100644 index 00000000..ca7523be --- /dev/null +++ b/resources/assets/js/components/screens/ArtistScreen.spec.ts @@ -0,0 +1,93 @@ +import { fireEvent, waitFor } from '@testing-library/vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { artistStore, commonStore, songStore } from '@/stores' +import { downloadService } from '@/services' +import router from '@/router' +import { eventBus } from '@/utils' +import CloseModalBtn from '@/components/ui/BtnCloseModal.vue' +import ArtistScreen from './ArtistScreen.vue' + +let artist: Artist + +new class extends UnitTestCase { + protected async renderComponent () { + commonStore.state.use_last_fm = true + + artist = factory('artist', { + id: 42, + name: 'Led Zeppelin', + album_count: 12, + song_count: 53, + length: 40_603 + }) + + const resolveArtistMock = this.mock(artistStore, 'resolve').mockResolvedValue(artist) + + const songs = factory('song', 13) + const fetchSongsMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + + const rendered = this.render(ArtistScreen, { + props: { + artist: 42 + }, + global: { + stubs: { + CloseModalBtn, + ArtistInfo: this.stub('artist-info'), + SongList: this.stub('song-list') + } + } + }) + + await waitFor(() => { + expect(resolveArtistMock).toHaveBeenCalledWith(artist.id) + expect(fetchSongsMock).toHaveBeenCalledWith(artist.id) + }) + + await this.tick(2) + + return rendered + } + + protected test () { + it('renders', async () => { + const { html } = await this.renderComponent() + expect(html()).toMatchSnapshot() + }) + + it('shows and hides info', async () => { + const { getByTitle, getByTestId, queryByTestId } = await this.renderComponent() + expect(queryByTestId('artist-info')).toBeNull() + + await fireEvent.click(getByTitle('View artist information')) + expect(queryByTestId('artist-info')).not.toBeNull() + + await fireEvent.click(getByTestId('close-modal-btn')) + expect(queryByTestId('artist-info')).toBeNull() + }) + + it('downloads', async () => { + const downloadMock = this.mock(downloadService, 'fromArtist') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Download All')) + + expect(downloadMock).toHaveBeenCalledWith(artist) + }) + + it('goes back to list if artist is deleted', async () => { + const goMock = this.mock(router, 'go') + const byIdMock = this.mock(artistStore, 'byId', null) + await this.renderComponent() + + eventBus.emit('SONGS_UPDATED') + + await waitFor(() => { + expect(byIdMock).toHaveBeenCalledWith(artist.id) + expect(goMock).toHaveBeenCalledWith('artists') + }) + }) + } +} diff --git a/resources/assets/js/components/screens/ArtistScreen.vue b/resources/assets/js/components/screens/ArtistScreen.vue new file mode 100644 index 00000000..eb5ea99c --- /dev/null +++ b/resources/assets/js/components/screens/ArtistScreen.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/resources/assets/js/components/screens/FavoritesScreen.spec.ts b/resources/assets/js/components/screens/FavoritesScreen.spec.ts new file mode 100644 index 00000000..d9b47d38 --- /dev/null +++ b/resources/assets/js/components/screens/FavoritesScreen.spec.ts @@ -0,0 +1,39 @@ +import { waitFor } from '@testing-library/vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { favoriteStore } from '@/stores' +import FavoritesScreen from './FavoritesScreen.vue' +import { eventBus } from '@/utils' + +new class extends UnitTestCase { + private async renderComponent () { + const fetchMock = this.mock(favoriteStore, 'fetch') + const rendered = this.render(FavoritesScreen) + + eventBus.emit('LOAD_MAIN_CONTENT', 'Favorites') + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + + return rendered + } + + protected test () { + it('renders a list of favorites', async () => { + favoriteStore.state.songs = factory('song', 13) + const { queryByTestId } = await this.renderComponent() + + await waitFor(() => { + expect(queryByTestId('screen-empty-state')).toBeNull() + expect(queryByTestId('song-list')).not.toBeNull() + }) + }) + + it('shows empty state', async () => { + favoriteStore.state.songs = [] + const { queryByTestId } = await this.renderComponent() + + expect(queryByTestId('screen-empty-state')).not.toBeNull() + expect(queryByTestId('song-list')).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/screens/FavoritesScreen.vue b/resources/assets/js/components/screens/FavoritesScreen.vue new file mode 100644 index 00000000..4c81cf34 --- /dev/null +++ b/resources/assets/js/components/screens/FavoritesScreen.vue @@ -0,0 +1,116 @@ + + + diff --git a/resources/assets/js/components/screens/HomeScreen.spec.ts b/resources/assets/js/components/screens/HomeScreen.spec.ts new file mode 100644 index 00000000..55f07884 --- /dev/null +++ b/resources/assets/js/components/screens/HomeScreen.spec.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import HomeScreen from './HomeScreen.vue' +import { commonStore } from '@/stores' + +new class extends UnitTestCase { + protected test () { + it('renders an empty state if no songs found', () => { + commonStore.state.song_length = 0 + this.render(HomeScreen).getByTestId('screen-empty-state') + }) + + it('renders overview components if applicable', () => { + commonStore.state.song_length = 100 + const { getByTestId, queryByTestId } = this.render(HomeScreen) + + ;[ + 'most-played-songs', + 'recently-played-songs', + 'recently-added-albums', + 'recently-added-songs', + 'most-played-artists', + 'most-played-albums' + ].forEach(getByTestId) + + expect(queryByTestId('screen-empty-state')).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/screens/HomeScreen.vue b/resources/assets/js/components/screens/HomeScreen.vue new file mode 100644 index 00000000..8c3ecef4 --- /dev/null +++ b/resources/assets/js/components/screens/HomeScreen.vue @@ -0,0 +1,125 @@ + + + + + diff --git a/resources/assets/js/components/screens/PlaylistScreen.spec.ts b/resources/assets/js/components/screens/PlaylistScreen.spec.ts new file mode 100644 index 00000000..1cc6e721 --- /dev/null +++ b/resources/assets/js/components/screens/PlaylistScreen.spec.ts @@ -0,0 +1,65 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { eventBus } from '@/utils' +import { fireEvent, getByTestId, waitFor } from '@testing-library/vue' +import { songStore } from '@/stores' +import { downloadService } from '@/services' +import PlaylistScreen from './PlaylistScreen.vue' + +let playlist: Playlist + +new class extends UnitTestCase { + private async renderComponent (songs: Song[]) { + playlist = playlist || factory('playlist') + const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs) + + const rendered = this.render(PlaylistScreen) + eventBus.emit('LOAD_MAIN_CONTENT', 'Playlist', playlist) + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist)) + + return { rendered, fetchMock } + } + + protected test () { + it('renders the playlist', async () => { + const { getByTestId, queryByTestId } = (await this.renderComponent(factory('song', 10))).rendered + + await waitFor(() => { + getByTestId('song-list') + expect(queryByTestId('screen-empty-state')).toBeNull() + }) + }) + + it('displays the empty state if playlist is empty', async () => { + const { getByTestId, queryByTestId } = (await this.renderComponent([])).rendered + + await waitFor(() => { + getByTestId('screen-empty-state') + expect(queryByTestId('song-list')).toBeNull() + }) + }) + + it('downloads the playlist', async () => { + const downloadMock = this.mock(downloadService, 'fromPlaylist') + const { getByText } = (await this.renderComponent(factory('song', 10))).rendered + + await this.tick() + await fireEvent.click(getByText('Download All')) + + await waitFor(() => expect(downloadMock).toHaveBeenCalledWith(playlist)) + }) + + it('deletes the playlist', async () => { + const { getByTitle } = (await this.renderComponent([])).rendered + + // mock *after* rendering to not tamper with "LOAD_MAIN_CONTENT" emission + const emitMock = this.mock(eventBus, 'emit') + + await fireEvent.click(getByTitle('Delete this playlist')) + + await waitFor(() => expect(emitMock).toHaveBeenCalledWith('PLAYLIST_DELETE', playlist)) + }) + } +} diff --git a/resources/assets/js/components/screens/PlaylistScreen.vue b/resources/assets/js/components/screens/PlaylistScreen.vue new file mode 100644 index 00000000..72ce7be3 --- /dev/null +++ b/resources/assets/js/components/screens/PlaylistScreen.vue @@ -0,0 +1,138 @@ + + + diff --git a/resources/assets/js/components/screens/ProfileScreen.vue b/resources/assets/js/components/screens/ProfileScreen.vue new file mode 100644 index 00000000..5f73e4e5 --- /dev/null +++ b/resources/assets/js/components/screens/ProfileScreen.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/resources/assets/js/components/screens/QueueScreen.spec.ts b/resources/assets/js/components/screens/QueueScreen.spec.ts new file mode 100644 index 00000000..e92a8a0e --- /dev/null +++ b/resources/assets/js/components/screens/QueueScreen.spec.ts @@ -0,0 +1,60 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { commonStore, queueStore } from '@/stores' +import { fireEvent, waitFor } from '@testing-library/vue' +import { playbackService } from '@/services' +import QueueScreen from './QueueScreen.vue' + +new class extends UnitTestCase { + private renderComponent (songs: Song[]) { + queueStore.state.songs = songs + + return this.render(QueueScreen, { + global: { + stubs: { + SongList: this.stub('song-list') + } + } + }) + } + + protected test () { + it('renders the queue', () => { + const { queryByTestId } = this.renderComponent(factory('song', 3)) + + expect(queryByTestId('song-list')).toBeTruthy() + expect(queryByTestId('screen-empty-state')).toBeNull() + }) + + it('renders an empty state if no songs queued', () => { + const { queryByTestId } = this.renderComponent([]) + + expect(queryByTestId('song-list')).toBeNull() + expect(queryByTestId('screen-empty-state')).toBeTruthy() + }) + + it('has an option to plays some random songs if the library is not empty', async () => { + commonStore.state.song_count = 300 + const fetchRandomMock = this.mock(queueStore, 'fetchRandom') + const playMock = this.mock(playbackService, 'playFirstInQueue') + + const { getByText } = this.renderComponent([]) + await fireEvent.click(getByText('playing some random songs')) + + await waitFor(() => { + expect(fetchRandomMock).toHaveBeenCalled() + expect(playMock).toHaveBeenCalled() + }) + }) + + it('Shuffles all', async () => { + const songs = factory('song', 3) + const { getByTitle } = this.renderComponent(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + + await fireEvent.click(getByTitle('Shuffle all songs')) + await waitFor(() => expect(playMock).toHaveBeenCalledWith(songs, true)) + }) + } +} diff --git a/resources/assets/js/components/screens/QueueScreen.vue b/resources/assets/js/components/screens/QueueScreen.vue new file mode 100644 index 00000000..417dfdbd --- /dev/null +++ b/resources/assets/js/components/screens/QueueScreen.vue @@ -0,0 +1,132 @@ + + + diff --git a/resources/assets/js/components/screens/RecentlyPlayedScreen.spec.ts b/resources/assets/js/components/screens/RecentlyPlayedScreen.spec.ts new file mode 100644 index 00000000..2191d9e3 --- /dev/null +++ b/resources/assets/js/components/screens/RecentlyPlayedScreen.spec.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { recentlyPlayedStore } from '@/stores' +import { eventBus } from '@/utils' +import { waitFor } from '@testing-library/vue' +import RecentlyPlayedScreen from './RecentlyPlayedScreen.vue' + +new class extends UnitTestCase { + private async renderComponent (songs: Song[]) { + recentlyPlayedStore.state.songs = songs + const fetchMock = this.mock(recentlyPlayedStore, 'fetch') + + const rendered = this.render(RecentlyPlayedScreen, { + global: { + stubs: { + SongList: this.stub('song-list') + } + } + }) + + eventBus.emit('LOAD_MAIN_CONTENT', 'RecentlyPlayed') + + await waitFor(() => expect(fetchMock).toHaveBeenCalled()) + + return rendered + } + + protected test () { + it('displays the songs', async () => { + const { queryByTestId } = await this.renderComponent(factory('song', 3)) + + expect(queryByTestId('song-list')).toBeTruthy() + expect(queryByTestId('screen-empty-state')).toBeNull() + }) + + it('displays the empty state', async () => { + const { queryByTestId } = await this.renderComponent([]) + + expect(queryByTestId('song-list')).toBeNull() + expect(queryByTestId('screen-empty-state')).toBeTruthy() + }) + } +} diff --git a/resources/assets/js/components/screens/RecentlyPlayedScreen.vue b/resources/assets/js/components/screens/RecentlyPlayedScreen.vue new file mode 100644 index 00000000..946c1624 --- /dev/null +++ b/resources/assets/js/components/screens/RecentlyPlayedScreen.vue @@ -0,0 +1,83 @@ + + + diff --git a/resources/assets/js/components/screens/SettingsScreen.spec.ts b/resources/assets/js/components/screens/SettingsScreen.spec.ts new file mode 100644 index 00000000..41e82ebf --- /dev/null +++ b/resources/assets/js/components/screens/SettingsScreen.spec.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import SettingsScreen from './SettingsScreen.vue' +import { settingStore } from '@/stores' +import { fireEvent, waitFor } from '@testing-library/vue' +import router from '@/router' +import { DialogBoxStub } from '@/__tests__/stubs' + +new class extends UnitTestCase { + protected test () { + it('renders', () => expect(this.render(SettingsScreen).html()).toMatchSnapshot()) + + it('submits the settings form', async () => { + const updateMock = this.mock(settingStore, 'update') + const goMock = this.mock(router, 'go') + + settingStore.state.media_path = '' + const { getByLabelText, getByText } = this.render(SettingsScreen) + + await fireEvent.update(getByLabelText('Media Path'), '/media') + await fireEvent.click(getByText('Scan')) + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledWith({ media_path: '/media' }) + expect(goMock).toHaveBeenCalledWith('home') + }) + }) + + it('confirms upon media path change', async () => { + const updateMock = this.mock(settingStore, 'update') + const goMock = this.mock(router, 'go') + const confirmMock = this.mock(DialogBoxStub.value, 'confirm') + + settingStore.state.media_path = '/old' + const { getByLabelText, getByText } = this.render(SettingsScreen) + + await fireEvent.update(getByLabelText('Media Path'), '/new') + await fireEvent.click(getByText('Scan')) + + await waitFor(() => { + expect(updateMock).not.toHaveBeenCalled() + expect(goMock).not.toHaveBeenCalled() + expect(confirmMock).toHaveBeenCalled() + }) + }) + } +} diff --git a/resources/assets/js/components/screens/SettingsScreen.vue b/resources/assets/js/components/screens/SettingsScreen.vue new file mode 100644 index 00000000..d6f45027 --- /dev/null +++ b/resources/assets/js/components/screens/SettingsScreen.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/resources/assets/js/components/screens/UploadScreen.vue b/resources/assets/js/components/screens/UploadScreen.vue new file mode 100644 index 00000000..ef41edbb --- /dev/null +++ b/resources/assets/js/components/screens/UploadScreen.vue @@ -0,0 +1,166 @@ + + + + + diff --git a/resources/assets/js/components/screens/UserList.spec.ts b/resources/assets/js/components/screens/UserList.spec.ts new file mode 100644 index 00000000..f4d035e5 --- /dev/null +++ b/resources/assets/js/components/screens/UserList.spec.ts @@ -0,0 +1,10 @@ +import { it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' + +new class extends UnitTestCase { + protected test () { + it('displays a list of users', () => { + + }) + } +} diff --git a/resources/assets/js/components/screens/UserListScreen.vue b/resources/assets/js/components/screens/UserListScreen.vue new file mode 100644 index 00000000..32fad1f3 --- /dev/null +++ b/resources/assets/js/components/screens/UserListScreen.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/resources/assets/js/components/screens/YouTubeScreen.vue b/resources/assets/js/components/screens/YouTubeScreen.vue new file mode 100644 index 00000000..c8ee7d01 --- /dev/null +++ b/resources/assets/js/components/screens/YouTubeScreen.vue @@ -0,0 +1,70 @@ + + + + + diff --git a/resources/assets/js/components/screens/__snapshots__/AlbumScreen.spec.ts.snap b/resources/assets/js/components/screens/__snapshots__/AlbumScreen.spec.ts.snap new file mode 100644 index 00000000..6eab35bc --- /dev/null +++ b/resources/assets/js/components/screens/__snapshots__/AlbumScreen.spec.ts.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+ +
+ +
+
+

Led Zeppelin IV + +

Led Zeppelin10 songs26:43Info Download All +
+
+ +
+
+

+ +
+`; diff --git a/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap b/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap new file mode 100644 index 00000000..3f959715 --- /dev/null +++ b/resources/assets/js/components/screens/__snapshots__/AllSongsScreen.spec.ts.snap @@ -0,0 +1,161 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+
+
+
+
+
+
+

All Songs + +

420 songs34:17:36 +
+
+ +
+
+

+
+`; + +exports[`renders 2`] = ` +
+
+
+
+
+
+
+

All Songs + +

420 songs34:17:36 +
+
+ +
+
+

+
+`; + +exports[`renders 3`] = ` +
+
+
+
+
+
+
+

All Songs + +

420 songs34:17:36 +
+
+ +
+
+

+
+`; + +exports[`renders 4`] = ` +
+
+
+
+
+
+
+

All Songs + +

420 songs34:17:36 +
+
+ +
+
+

+
+`; + +exports[`renders 5`] = ` +
+
+ +
+
+

All Songs + +

420 songs34:17:36 +
+
+ +
+
+

+
+`; diff --git a/resources/assets/js/components/screens/__snapshots__/ArtistScreen.spec.ts.snap b/resources/assets/js/components/screens/__snapshots__/ArtistScreen.spec.ts.snap new file mode 100644 index 00000000..ffbf4472 --- /dev/null +++ b/resources/assets/js/components/screens/__snapshots__/ArtistScreen.spec.ts.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+ +
+ +
+
+

Led Zeppelin + +

12 albums53 songs11:16:43Info Download All +
+
+ +
+
+

+ +
+`; diff --git a/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap b/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap new file mode 100644 index 00000000..c2ad0258 --- /dev/null +++ b/resources/assets/js/components/screens/__snapshots__/SettingsScreen.spec.ts.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +
+
+ +
+
+

Settings

+
+
+
+
+
+

The absolute path to the server directory containing your media. Koel will scan this directory for songs and extract any available information.
Scanning may take a while, especially if you have a lot of songs, so be patient.

+
+
+
+
+`; diff --git a/resources/assets/js/components/screens/home/MostPlayedAlbums.spec.ts b/resources/assets/js/components/screens/home/MostPlayedAlbums.spec.ts new file mode 100644 index 00000000..7793572b --- /dev/null +++ b/resources/assets/js/components/screens/home/MostPlayedAlbums.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { overviewStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import MostPlayedAlbums from './MostPlayedAlbums.vue' + +new class extends UnitTestCase { + protected test () { + it('displays the albums', () => { + overviewStore.state.mostPlayedAlbums = factory('album', 6) + expect(this.render(MostPlayedAlbums).getAllByTestId('album-card')).toHaveLength(6) + }) + } +} diff --git a/resources/assets/js/components/screens/home/MostPlayedAlbums.vue b/resources/assets/js/components/screens/home/MostPlayedAlbums.vue new file mode 100644 index 00000000..96d31d9d --- /dev/null +++ b/resources/assets/js/components/screens/home/MostPlayedAlbums.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/assets/js/components/screens/home/MostPlayedArtists.spec.ts b/resources/assets/js/components/screens/home/MostPlayedArtists.spec.ts new file mode 100644 index 00000000..b6645483 --- /dev/null +++ b/resources/assets/js/components/screens/home/MostPlayedArtists.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { overviewStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import MostPlayedArtists from './MostPlayedArtists.vue' + +new class extends UnitTestCase { + protected test () { + it('displays the artists', () => { + overviewStore.state.mostPlayedArtists = factory('artist', 6) + expect(this.render(MostPlayedArtists).getAllByTestId('artist-card')).toHaveLength(6) + }) + } +} diff --git a/resources/assets/js/components/screens/home/MostPlayedArtists.vue b/resources/assets/js/components/screens/home/MostPlayedArtists.vue new file mode 100644 index 00000000..1cce6769 --- /dev/null +++ b/resources/assets/js/components/screens/home/MostPlayedArtists.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/assets/js/components/screens/home/MostPlayedSongs.spec.ts b/resources/assets/js/components/screens/home/MostPlayedSongs.spec.ts new file mode 100644 index 00000000..d48b1a02 --- /dev/null +++ b/resources/assets/js/components/screens/home/MostPlayedSongs.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { overviewStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import MostPlayedSongs from './MostPlayedSongs.vue' + +new class extends UnitTestCase { + protected test () { + it('displays the songs', () => { + overviewStore.state.mostPlayedSongs = factory('song', 6) + expect(this.render(MostPlayedSongs).getAllByTestId('song-card')).toHaveLength(6) + }) + } +} diff --git a/resources/assets/js/components/screens/home/MostPlayedSongs.vue b/resources/assets/js/components/screens/home/MostPlayedSongs.vue new file mode 100644 index 00000000..24bdd8ba --- /dev/null +++ b/resources/assets/js/components/screens/home/MostPlayedSongs.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/assets/js/components/screens/home/RecentlyAddedAlbums.spec.ts b/resources/assets/js/components/screens/home/RecentlyAddedAlbums.spec.ts new file mode 100644 index 00000000..953dd4f8 --- /dev/null +++ b/resources/assets/js/components/screens/home/RecentlyAddedAlbums.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { overviewStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import RecentlyAddedAlbums from './RecentlyAddedAlbums.vue' + +new class extends UnitTestCase { + protected test () { + it('displays the albums', () => { + overviewStore.state.recentlyAddedAlbums = factory('album', 6) + expect(this.render(RecentlyAddedAlbums).getAllByTestId('album-card')).toHaveLength(6) + }) + } +} diff --git a/resources/assets/js/components/screens/home/RecentlyAddedAlbums.vue b/resources/assets/js/components/screens/home/RecentlyAddedAlbums.vue new file mode 100644 index 00000000..c43b9ef2 --- /dev/null +++ b/resources/assets/js/components/screens/home/RecentlyAddedAlbums.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/assets/js/components/screens/home/RecentlyAddedSongs.spec.ts b/resources/assets/js/components/screens/home/RecentlyAddedSongs.spec.ts new file mode 100644 index 00000000..b8b9ba66 --- /dev/null +++ b/resources/assets/js/components/screens/home/RecentlyAddedSongs.spec.ts @@ -0,0 +1,14 @@ +import { expect, it } from 'vitest' +import { overviewStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import RecentlyAddedSongs from './RecentlyAddedSongs.vue' + +new class extends UnitTestCase { + protected test () { + it('displays the songs', () => { + overviewStore.state.recentlyAddedSongs = factory('song', 6) + expect(this.render(RecentlyAddedSongs).getAllByTestId('song-card')).toHaveLength(6) + }) + } +} diff --git a/resources/assets/js/components/screens/home/RecentlyAddedSongs.vue b/resources/assets/js/components/screens/home/RecentlyAddedSongs.vue new file mode 100644 index 00000000..bbc9cc0b --- /dev/null +++ b/resources/assets/js/components/screens/home/RecentlyAddedSongs.vue @@ -0,0 +1,30 @@ + + + diff --git a/resources/assets/js/components/screens/home/RecentlyPlayedSongs.spec.ts b/resources/assets/js/components/screens/home/RecentlyPlayedSongs.spec.ts new file mode 100644 index 00000000..05e2d498 --- /dev/null +++ b/resources/assets/js/components/screens/home/RecentlyPlayedSongs.spec.ts @@ -0,0 +1,25 @@ +import { expect, it } from 'vitest' +import { recentlyPlayedStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import RecentlyPlayedSongs from './RecentlyPlayedSongs.vue' +import { fireEvent } from '@testing-library/vue' +import router from '@/router' + +new class extends UnitTestCase { + protected test () { + it('displays the songs', () => { + recentlyPlayedStore.excerptState.songs = factory('song', 6) + expect(this.render(RecentlyPlayedSongs).getAllByTestId('song-card')).toHaveLength(6) + }) + + it('goes to dedicated screen', async () => { + const mock = this.mock(router, 'go') + const { getByTestId } = this.render(RecentlyPlayedSongs) + + await fireEvent.click(getByTestId('home-view-all-recently-played-btn')) + + expect(mock).toHaveBeenCalledWith('recently-played') + }) + } +} diff --git a/resources/assets/js/components/screens/home/RecentlyPlayedSongs.vue b/resources/assets/js/components/screens/home/RecentlyPlayedSongs.vue new file mode 100644 index 00000000..37fa70d3 --- /dev/null +++ b/resources/assets/js/components/screens/home/RecentlyPlayedSongs.vue @@ -0,0 +1,48 @@ + + + diff --git a/resources/assets/js/components/screens/search/SearchExcerptsScreen.spec.ts b/resources/assets/js/components/screens/search/SearchExcerptsScreen.spec.ts new file mode 100644 index 00000000..1be80451 --- /dev/null +++ b/resources/assets/js/components/screens/search/SearchExcerptsScreen.spec.ts @@ -0,0 +1,19 @@ +import { waitFor } from '@testing-library/vue' +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { searchStore } from '@/stores' +import { eventBus } from '@/utils' +import SearchExceptsScreen from './SearchExcerptsScreen.vue' + +new class extends UnitTestCase { + protected test () { + it('executes searching when the search keyword is changed', async () => { + const mock = this.mock(searchStore, 'excerptSearch') + this.render(SearchExceptsScreen) + + eventBus.emit('SEARCH_KEYWORDS_CHANGED', 'search me') + + await waitFor(() => expect(mock).toHaveBeenCalledWith('search me')) + }) + } +} diff --git a/resources/assets/js/components/screens/search/SearchExcerptsScreen.vue b/resources/assets/js/components/screens/search/SearchExcerptsScreen.vue new file mode 100644 index 00000000..f5645a6a --- /dev/null +++ b/resources/assets/js/components/screens/search/SearchExcerptsScreen.vue @@ -0,0 +1,141 @@ + + + + + diff --git a/resources/assets/js/components/screens/search/SearchSongResultsScreen.spec.ts b/resources/assets/js/components/screens/search/SearchSongResultsScreen.spec.ts new file mode 100644 index 00000000..cab26eb2 --- /dev/null +++ b/resources/assets/js/components/screens/search/SearchSongResultsScreen.spec.ts @@ -0,0 +1,21 @@ +import { expect, it } from 'vitest' +import { searchStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import SearchSongResultsScreen from './SearchSongResultsScreen.vue' + +new class extends UnitTestCase { + protected test () { + it('searches for prop query on created', () => { + const resetResultMock = this.mock(searchStore, 'resetSongResultState') + const searchMock = this.mock(searchStore, 'songSearch') + this.render(SearchSongResultsScreen, { + props: { + q: 'search me' + } + }) + + expect(resetResultMock).toHaveBeenCalled() + expect(searchMock).toHaveBeenCalledWith('search me') + }) + } +} diff --git a/resources/assets/js/components/screens/search/SearchSongResultsScreen.vue b/resources/assets/js/components/screens/search/SearchSongResultsScreen.vue new file mode 100644 index 00000000..c61a2c61 --- /dev/null +++ b/resources/assets/js/components/screens/search/SearchSongResultsScreen.vue @@ -0,0 +1,72 @@ + + + diff --git a/resources/assets/js/components/song/AddToMenu.spec.ts b/resources/assets/js/components/song/AddToMenu.spec.ts new file mode 100644 index 00000000..dd77ef75 --- /dev/null +++ b/resources/assets/js/components/song/AddToMenu.spec.ts @@ -0,0 +1,93 @@ +import { clone } from 'lodash' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { favoriteStore, playlistStore, queueStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import Btn from '@/components/ui/Btn.vue' +import AddToMenu from './AddToMenu.vue' +import { arrayify } from '@/utils' +import { fireEvent } from '@testing-library/vue' + +let songs: Song[] + +const config: AddToMenuConfig = { + queue: true, + favorites: true, + playlists: true, + newPlaylist: true +} + +new class extends UnitTestCase { + private renderComponent (customConfig: Partial = {}) { + songs = factory('song', 5) + + return this.render(AddToMenu, { + props: { + songs, + config: Object.assign(clone(config), customConfig), + showing: true + }, + global: { + stubs: { + Btn + } + } + }) + } + + protected test () { + it('renders', () => { + playlistStore.state.playlists = [ + factory('playlist', { name: 'Foo' }), + factory('playlist', { name: 'Bar' }), + factory('playlist', { name: 'Baz' }) + ] + + expect(this.renderComponent().html()).toMatchSnapshot() + }) + + it.each<[keyof AddToMenuConfig, string | string[]]>([ + ['queue', ['queue-after-current', 'queue-bottom', 'queue-top', 'queue']], + ['favorites', 'add-to-favorites'], + ['playlists', 'add-to-playlist'], + ['newPlaylist', 'new-playlist'] + ])('renders disabling %s config', (configKey: keyof AddToMenuConfig, testIds: string | string[]) => { + const { queryByTestId } = this.renderComponent({ [configKey]: false }) + arrayify(testIds).forEach(async (id) => expect(await queryByTestId(id)).toBeNull()) + }) + + it.each<[string, string, MethodOf]>([ + ['after current', 'queue-after-current', 'queueAfterCurrent'], + ['to top', 'queue-top', 'queueToTop'], + ['to bottom', 'queue-bottom', 'queue'] + ])('queues songs %s', async (_: string, testId: string, queueMethod: MethodOf) => { + queueStore.state.songs = factory('song', 5) + queueStore.state.current = queueStore.state.songs[1] + const mock = this.mock(queueStore, queueMethod) + const { getByTestId } = this.renderComponent() + + await fireEvent.click(getByTestId(testId)) + + expect(mock).toHaveBeenCalledWith(songs) + }) + + it('adds songs to Favorites', async () => { + const mock = this.mock(favoriteStore, 'like') + const { getByTestId } = this.renderComponent() + + await fireEvent.click(getByTestId('add-to-favorites')) + + expect(mock).toHaveBeenCalledWith(songs) + }) + + it('adds songs to existing playlist', async () => { + const mock = this.mock(playlistStore, 'addSongs') + playlistStore.state.playlists = factory('playlist', 3) + const { getAllByTestId } = this.renderComponent() + + await fireEvent.click(getAllByTestId('add-to-playlist')[1]) + + expect(mock).toHaveBeenCalledWith(playlistStore.state.playlists[1], songs) + }) + } +} diff --git a/resources/assets/js/components/song/AddToMenu.vue b/resources/assets/js/components/song/AddToMenu.vue new file mode 100644 index 00000000..80f7663b --- /dev/null +++ b/resources/assets/js/components/song/AddToMenu.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/resources/assets/js/components/song/SongCard.spec.ts b/resources/assets/js/components/song/SongCard.spec.ts new file mode 100644 index 00000000..f0875b71 --- /dev/null +++ b/resources/assets/js/components/song/SongCard.spec.ts @@ -0,0 +1,52 @@ +import factory from '@/__tests__/factory' +import { queueStore } from '@/stores' +import { playbackService } from '@/services' +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import UnitTestCase from '@/__tests__/UnitTestCase' +import SongCard from './SongCard.vue' + +let song: Song + +new class extends UnitTestCase { + private renderComponent (playbackState: PlaybackState = 'Stopped') { + song = factory('song', { + playback_state: playbackState, + play_count: 10, + title: 'Foo bar' + }) + + return this.render(SongCard, { + props: { + song, + topPlayCount: 42 + } + }) + } + + protected test () { + it('queues and plays', async () => { + const queueMock = this.mock(queueStore, 'queueIfNotQueued') + const playMock = this.mock(playbackService, 'play') + const { getByTestId } = this.renderComponent() + + await fireEvent.dblClick(getByTestId('song-card')) + + expect(queueMock).toHaveBeenCalledWith(song) + expect(playMock).toHaveBeenCalledWith(song) + }) + + it.each<[PlaybackState, MethodOf]>([ + ['Stopped', 'play'], + ['Playing', 'pause'], + ['Paused', 'resume'] + ])('if state is currently "%s", %ss', async (state: PlaybackState, method: MethodOf) => { + const mock = this.mock(playbackService, method) + const { getByTestId } = this.renderComponent(state) + + await fireEvent.click(getByTestId('play-control')) + + expect(mock).toHaveBeenCalled() + }) + } +} diff --git a/resources/assets/js/components/song/SongCard.vue b/resources/assets/js/components/song/SongCard.vue new file mode 100644 index 00000000..bb17fcf0 --- /dev/null +++ b/resources/assets/js/components/song/SongCard.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/resources/assets/js/components/song/SongContextMenu.spec.ts b/resources/assets/js/components/song/SongContextMenu.spec.ts new file mode 100644 index 00000000..a757a503 --- /dev/null +++ b/resources/assets/js/components/song/SongContextMenu.spec.ts @@ -0,0 +1,183 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { arrayify, eventBus } from '@/utils' +import { fireEvent } from '@testing-library/vue' +import router from '@/router' +import { downloadService, playbackService } from '@/services' +import { favoriteStore, playlistStore, queueStore } from '@/stores' +import { MessageToasterStub } from '@/__tests__/stubs' +import SongContextMenu from './SongContextMenu.vue' + +let songs: Song[] + +new class extends UnitTestCase { + protected beforeEach () { + super.beforeEach(() => queueStore.clear()) + } + + private async renderComponent (_songs?: Song | Song[]) { + songs = arrayify(_songs || factory('song', 5)) + + const rendered = this.render(SongContextMenu) + eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 69 }, songs) + await this.tick(2) + + return rendered + } + + private fillQueue () { + queueStore.state.songs = factory('song', 5) + queueStore.state.current = queueStore.state.songs[0] + } + + protected test () { + it('quques and plays', async () => { + const queueMock = this.mock(queueStore, 'queueIfNotQueued') + const playMock = this.mock(playbackService, 'play') + const song = factory('song', { playback_state: 'Stopped' }) + const { getByText } = await this.renderComponent(song) + + await fireEvent.click(getByText('Play')) + + expect(queueMock).toHaveBeenCalledWith(song) + expect(playMock).toHaveBeenCalledWith(song) + }) + + it('pauses playback', async () => { + const pauseMock = this.mock(playbackService, 'pause') + const { getByText } = await this.renderComponent(factory('song', { playback_state: 'Playing' })) + + await fireEvent.click(getByText('Pause')) + + expect(pauseMock).toHaveBeenCalled() + }) + + it('resumes playback', async () => { + const resumeMock = this.mock(playbackService, 'resume') + const { getByText } = await this.renderComponent(factory('song', { playback_state: 'Paused' })) + + await fireEvent.click(getByText('Play')) + + expect(resumeMock).toHaveBeenCalled() + }) + + it('goes to album details screen', async () => { + const goMock = this.mock(router, 'go') + const { getByText } = await this.renderComponent(factory('song')) + + await fireEvent.click(getByText('Go to Album')) + + expect(goMock).toHaveBeenCalledWith(`album/${songs[0].album_id}`) + }) + + it('goes to artist details screen', async () => { + const goMock = this.mock(router, 'go') + const { getByText } = await this.renderComponent(factory('song')) + + await fireEvent.click(getByText('Go to Artist')) + + expect(goMock).toHaveBeenCalledWith(`artist/${songs[0].artist_id}`) + }) + + it('downloads', async () => { + const downloadMock = this.mock(downloadService, 'fromSongs') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Download')) + + expect(downloadMock).toHaveBeenCalledWith(songs) + }) + + it('queues', async () => { + const queueMock = this.mock(queueStore, 'queue') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Queue')) + + expect(queueMock).toHaveBeenCalledWith(songs) + }) + + it('queues after current song', async () => { + this.fillQueue() + const queueMock = this.mock(queueStore, 'queueAfterCurrent') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('After Current Song')) + + expect(queueMock).toHaveBeenCalledWith(songs) + }) + + it('queues to bottom', async () => { + this.fillQueue() + const queueMock = this.mock(queueStore, 'queue') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Bottom of Queue')) + + expect(queueMock).toHaveBeenCalledWith(songs) + }) + + it('queues to top', async () => { + this.fillQueue() + const queueMock = this.mock(queueStore, 'queueToTop') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Top of Queue')) + + expect(queueMock).toHaveBeenCalledWith(songs) + }) + + it('adds to favorite', async () => { + const likeMock = this.mock(favoriteStore, 'like') + const { getByText } = await this.renderComponent() + + await fireEvent.click(getByText('Favorites')) + + expect(likeMock).toHaveBeenCalledWith(songs) + }) + + it('lists and adds to existing playlist', async () => { + playlistStore.state.playlists = factory('playlist', 3) + const addMock = this.mock(playlistStore, 'addSongs') + this.mock(MessageToasterStub.value, 'success') + const { queryByText, getByText } = await this.renderComponent() + + playlistStore.state.playlists.forEach(playlist => queryByText(playlist.name)) + + await fireEvent.click(getByText(playlistStore.state.playlists[0].name)) + + expect(addMock).toHaveBeenCalledWith(playlistStore.state.playlists[0], songs) + }) + + it('does not list smart playlists', async () => { + playlistStore.state.playlists = factory('playlist', 3) + playlistStore.state.playlists.push(factory.states('smart')('playlist', { name: 'My Smart Playlist' })) + + const { queryByText } = await this.renderComponent() + + expect(queryByText('My Smart Playlist')).toBeNull() + }) + + it('allows edit songs if current user is admin', async () => { + const { getByText } = await this.actingAsAdmin().renderComponent() + + // mock after render to ensure that the component is mounted properly + const emitMock = this.mock(eventBus, 'emit') + await fireEvent.click(getByText('Edit')) + + expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', songs) + }) + + it('does not allow edit songs if current user is not admin', async () => { + const { queryByText } = await this.actingAs().renderComponent() + expect(queryByText('Edit')).toBeNull() + }) + + it('has an option to copy shareable URL', async () => { + const { getByText } = await this.renderComponent(factory('song')) + + getByText('Copy Shareable URL') + }) + } +} diff --git a/resources/assets/js/components/song/SongContextMenu.vue b/resources/assets/js/components/song/SongContextMenu.vue new file mode 100644 index 00000000..469c701b --- /dev/null +++ b/resources/assets/js/components/song/SongContextMenu.vue @@ -0,0 +1,110 @@ + + + diff --git a/resources/assets/js/components/song/SongEditForm.spec.ts b/resources/assets/js/components/song/SongEditForm.spec.ts new file mode 100644 index 00000000..53c9f635 --- /dev/null +++ b/resources/assets/js/components/song/SongEditForm.spec.ts @@ -0,0 +1,110 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { arrayify } from '@/utils' +import { EditSongFormInitialTabKey, SongsKey } from '@/symbols' +import { ref } from 'vue' +import { fireEvent } from '@testing-library/vue' +import { songStore } from '@/stores' +import { MessageToasterStub } from '@/__tests__/stubs' +import SongEditForm from './SongEditForm.vue' + +let songs: Song[] + +new class extends UnitTestCase { + private async renderComponent (_songs: Song | Song[], initialTab: EditSongFormTabName = 'details') { + songs = arrayify(_songs) + + const rendered = this.render(SongEditForm, { + global: { + provide: { + [SongsKey]: [ref(songs)], + [EditSongFormInitialTabKey]: [ref(initialTab)] + } + } + }) + + await this.tick() + + return rendered + } + + protected test () { + it('edits a single song', async () => { + const updateMock = this.mock(songStore, 'update') + const alertMock = this.mock(MessageToasterStub.value, 'success') + + const { html, getByTestId, getByRole } = await this.renderComponent(factory('song', { + title: 'Rocket to Heaven', + artist_name: 'Led Zeppelin', + album_name: 'IV', + album_cover: 'https://example.co/album.jpg' + })) + + expect(html()).toMatchSnapshot() + + await fireEvent.update(getByTestId('title-input'), 'Highway to Hell') + await fireEvent.update(getByTestId('artist-input'), 'AC/DC') + await fireEvent.update(getByTestId('albumArtist-input'), 'AC/DC') + await fireEvent.update(getByTestId('album-input'), 'Back in Black') + await fireEvent.update(getByTestId('disc-input'), '1') + await fireEvent.update(getByTestId('track-input'), '10') + await fireEvent.update(getByTestId('lyrics-input'), 'I\'m gonna make him an offer he can\'t refuse') + + await fireEvent.click(getByRole('button', { name: 'Update' })) + + expect(updateMock).toHaveBeenCalledWith(songs, { + title: 'Highway to Hell', + album_name: 'Back in Black', + artist_name: 'AC/DC', + album_artist_name: 'AC/DC', + lyrics: 'I\'m gonna make him an offer he can\'t refuse', + track: 10, + disc: 1 + }) + + expect(alertMock).toHaveBeenCalledWith('Updated 1 song.') + }) + + it('edits multiple songs', async () => { + const updateMock = this.mock(songStore, 'update') + const alertMock = this.mock(MessageToasterStub.value, 'success') + + const { html, getByTestId, getByRole, queryByTestId } = await this.renderComponent(factory('song', 3)) + + expect(html()).toMatchSnapshot() + expect(queryByTestId('title-input')).toBeNull() + expect(queryByTestId('lyrics-input')).toBeNull() + + await fireEvent.update(getByTestId('artist-input'), 'AC/DC') + await fireEvent.update(getByTestId('albumArtist-input'), 'AC/DC') + await fireEvent.update(getByTestId('album-input'), 'Back in Black') + await fireEvent.update(getByTestId('disc-input'), '1') + await fireEvent.update(getByTestId('track-input'), '10') + + await fireEvent.click(getByRole('button', { name: 'Update' })) + + expect(updateMock).toHaveBeenCalledWith(songs, { + album_name: 'Back in Black', + artist_name: 'AC/DC', + album_artist_name: 'AC/DC', + track: 10, + disc: 1 + }) + + expect(alertMock).toHaveBeenCalledWith('Updated 3 songs.') + }) + + it('displays artist name if all songs have the same artist', async () => { + const { getByTestId } = await this.renderComponent(factory('song', { + artist_id: 1000, + artist_name: 'Led Zeppelin', + album_id: 1001, + album_name: 'IV' + }, 4)) + + expect(getByTestId('displayed-artist-name').textContent).toBe('Led Zeppelin') + expect(getByTestId('displayed-album-name').textContent).toBe('IV') + }) + } +} diff --git a/resources/assets/js/components/song/SongEditForm.vue b/resources/assets/js/components/song/SongEditForm.vue new file mode 100644 index 00000000..71426b23 --- /dev/null +++ b/resources/assets/js/components/song/SongEditForm.vue @@ -0,0 +1,336 @@ +