{{ song.lyrics }}+
+ + No lyrics found. + + to add lyrics. + + No lyrics available. Are you listening to Bach? +
+ +, 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 = factoryPlay All +Shuffle All + +Go to Album +Go to Artist + + +Download + + +('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.name }} + +
+ ++ ++ + + + + + + ++ ++ + + + ('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 = factoryTrack Listing
+ ++
+- +
++ ('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 @@ + + + {{ track.title }} ++ + + + + 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`] = ` ++ {{ fmtLength }} + 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 = factoryPlay All +Shuffle All + + +Go to Artist + + + +Download + + +('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 @@ + ++ {{ artist.name }} + +
+ ++ ++ + + + + + + ++ + + ++ + + + + + 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 @@ + +Koel
+ ++ + + + + + +++ + ++ + + + + 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+ ++{{ song.title }}
+ + + ++ ++]>([ + ['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`] = ` ++ + + + + + + + + + + + + ++`; + +exports[`renders without a song 1`] = ` +++Fahrstuhl to Heaven
+ + +++`; 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
++ + + ++ ++ +{{ currentVersion }}
+ ++ + A new Koel version is available ({{ latestVersion }}). + +
+ + + ++ Demo music provided by + Bensound. +
+ ++ 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`] = ` + +`; 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 @@ + ++ +About Koel
++ + + +v0.0.0
+ + + +Loving Koel? Please consider supporting its development via GitHub Sponsors and/or OpenCollective.
++ + + + 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 @@ + +New Playlist +New Smart Playlist ++ + + + 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 = factoryEdit +Delete +('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 }} + + + + + + {{ playlist.name }} + + + + + + ('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 @@ + ++ Playlists +
+ + + ++ +
+ ++ + + + + + + + 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 @@ + ++ + + + + + + ++ + + {{ valueSuffix }} + + ++ + + + + 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 + ++ + + ++ Rule + (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 @@ + +Last.fm Integration
+ +++ ++ This installation of Koel integrates with Last.fm. + + It appears that you have connected your Last.fm account as well – Perfect! + + It appears that you haven’t connected to your Last.fm account though. +
++ Connecting Koel and your Last.fm account enables such exciting features as + scrobbling. +
+ ++++ This installation of Koel has no Last.fm integration. + + Visit + Koel’s Wiki + for a quick how-to. + + + Try politely asking an administrator to enable it. + +
+++ + + + + 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 @@ + +{{ name }}++ + + + + + 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`] = ` +Theme
++
+- +
++ ++`; 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 = factorySample+('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+ Albums + + + ++ + + +++ + + + + + ('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+ + + {{ album.name }} + + ++ + + + + + + {{ album.artist_name }} + {{ album.artist_name }} + {{ pluralize(album.song_count, 'song') }} + {{ secondsToHis(album.length) }} + Info + + + Download All + + + + + + + + + + + ++ +++ ('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+ All Songs + + ++ + + + + + + {{ pluralize(totalSongCount, 'song') }} + {{ totalDuration }} + + + + + + + + ('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+ Artists + + + ++ + + +++ + + + + + ('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+ + + {{ artist.name }} + + ++ + + + + + + {{ pluralize(artist.album_count, 'album') }} + {{ pluralize(artist.song_count, 'song') }} + {{ secondsToHis(artist.length) }} + Info + + + Download All + + + + + + + + + + + ++ +++ ('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 @@ + ++ Songs You Love + + ++ + + + + + + {{ pluralize(songs.length, 'song') }} + {{ duration }} + + + Download All + + + + + + + + + + + + ++ + No favorites yet. + + Click the + + icon to mark a song as favorite. + + + + + + + + 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{{ greeting }} + ++++ + + + ++ + No songs found. + + {{ isAdmin ? 'Have you set up your library yet?' : 'Contact your administrator to set up your library.' }} + + ++ ++ + ++ ++ + + + + + + ('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 @@ + ++ {{ playlist.name }} + + ++ + + + + + + {{ pluralize(songs.length, 'song') }} + {{ duration }} + + Download All + + + + + + + + + + + + ++ + + + No songs match the playlist's + criteria. + + + The playlist is currently empty. + + Drag songs into its name in the sidebar or use the "Add To…" button to fill it up. + + + + + + + + + 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(factoryProfile & Preferences + ++++ + + + ('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+ Current Queue + + ++ + + + + + + {{ pluralize(songs.length, 'song') }} + {{ duration }} + + + + + + + + + + + ++ + + No songs queued. + + How about + playing some random songs? + + ('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 @@ + ++ Recently Played + + ++ + + + + + + {{ pluralize(songs.length, 'song') }} + {{ duration }} + + + + + + + + + + + ++ + No songs recently played. + Start playing to populate this playlist. + + + + + + + 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 @@ + +Settings + + ++ + + + + + 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 @@ + ++ Upload Media + + + + ++ + ++ ++ Retry All + + ++ Remove Failed + ++++ +++ ++ + + ++ + + {{ canDropFolders ? 'Drop files or folders to upload' : 'Drop files to upload' }} + + + + or click here to select songs + + + + + + ++ + No media path set. + + + + + + + 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 @@ + ++ Users + + ++ + + + + ++ ++ Add + +++
+- +
++ + + + + + + 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`] = ` +{{ title }} + ++++ + ++ + YouTube videos will be played here. + Start a video playback from the right sidebar. + + + +`; 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`] = ` ++ + + +++Led Zeppelin IV + +
+++ +
+ ++ +`; + +exports[`renders 2`] = ` ++ +++++++All Songs + +
+++ +
++ +`; + +exports[`renders 3`] = ` ++ +++++++All Songs + +
+++ +
++ +`; + +exports[`renders 4`] = ` ++ +++++++All Songs + +
+++ +
++ +`; + +exports[`renders 5`] = ` ++ +++++++All Songs + +
+++ +
++ +`; 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`] = ` ++ + + +++All Songs + +
+++ +
++ + +`; 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`] = ` ++ + + +++Led Zeppelin + +
+++ +
+ ++ +`; 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+ + + ++ +++Settings
+('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 = factoryTop Albums
+ ++
+ +- +
++ +
+- +
++ No albums found.
+ +('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 = factoryTop Artists
+ ++
+ +- +
++ +
+- +
++ No artists found.
+ +('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 = factoryMost Played
++
+ +- +
++ +
+- +
++ You don’t seem to have been playing.
+ +('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 = factoryNew Albums
+ ++
+ +- +
++ +
+- +
++ No albums added yet.
+ +('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 = factoryNew Songs
++
+ +- +
++ +
+- +
++ No songs added so far.
+ +('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 @@ + ++ Recently Played +
+ ++ View All + ++
+ +- +
++ +
+- +
++ No songs played as of late.
+ ++ + + + + + 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 @@ + ++ Searching for {{ q }} + Search + + +++++ ++ + ++ Songs +
++ View All + ++
+ +- +
++ +
+- +
++ None found.
+ ++ + +Artists
++
+ +- +
++ +
+- +
++ None found.
+ ++ +Albums
++
+ +- +
++ +
+- +
++ None found.
+ ++ + ++ + Find songs, artists, and albums, + all in one place. + + + + + 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 for {{ decodedQ }} + + ++ + + + + + + {{ pluralize(songs.length, 'song') }} + {{ duration }} + + + + + + + + = {}) { + 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+ + +Add {{ pluralize(songs.length, 'song') }} to
+ ++ + +
+- + After Current Song +
+- + Bottom of Queue +
+- Top of Queue
+ +- Queue
+ + +- + Favorites +
+ + +- + {{ playlist.name }} +
+ ++ +or create a new playlist
+ + +('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.title }}
++ {{ song.artist_name }} + - {{ pluralize(song.play_count, 'play') }} +
++ ('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+ Pause + Play + +Go to Album +Go to Artist + ++ Add To + + +Edit +Download ++ Copy Shareable URL + +('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 @@ + + ++ + + + + diff --git a/resources/assets/js/components/song/SongLikeButton.spec.ts b/resources/assets/js/components/song/SongLikeButton.spec.ts new file mode 100644 index 00000000..6be048db --- /dev/null +++ b/resources/assets/js/components/song/SongLikeButton.spec.ts @@ -0,0 +1,28 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { fireEvent } from '@testing-library/vue' +import { favoriteStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import SongLikeButton from './SongLikeButton.vue' + +new class extends UnitTestCase { + protected test () { + it.each<[boolean, string]>([ + [true, 'btn-like-liked'], + [false, 'btn-like-unliked'] + ])('likes or unlikes', async (liked: boolean, testId: string) => { + const mock = this.mock(favoriteStore, 'toggleOne') + const song = factory+ + ('song', { liked }) + + const { getByTestId } = this.render(SongLikeButton, { + props: { + song + } + }) + + await fireEvent.click(getByTestId(testId)) + + expect(mock).toHaveBeenCalledWith(song) + }) + } +} diff --git a/resources/assets/js/components/song/SongLikeButton.vue b/resources/assets/js/components/song/SongLikeButton.vue new file mode 100644 index 00000000..8d848ac7 --- /dev/null +++ b/resources/assets/js/components/song/SongLikeButton.vue @@ -0,0 +1,28 @@ + + + + + + + diff --git a/resources/assets/js/components/song/SongList.spec.ts b/resources/assets/js/components/song/SongList.spec.ts new file mode 100644 index 00000000..341f87a7 --- /dev/null +++ b/resources/assets/js/components/song/SongList.spec.ts @@ -0,0 +1,69 @@ +import { expect, it } from 'vitest' +import { ref } from 'vue' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { arrayify } from '@/utils' +import { + SelectedSongsKey, + SongListConfigKey, + SongListSortFieldKey, + SongListSortOrderKey, + SongListTypeKey, + SongsKey +} from '@/symbols' +import SongList from './SongList.vue' +import { fireEvent } from '@testing-library/vue' + +let songs: Song[] + +new class extends UnitTestCase { + private renderComponent ( + _songs: Song | Song[], + type: SongListType = 'all-songs', + config: Partial = {}, + selectedSongs: Song[] = [], + sortField: SongListSortField = 'title', + sortOrder: SortOrder = 'asc' + ) { + songs = arrayify(_songs) + + return this.render(SongList, { + global: { + stubs: { + VirtualScroller: this.stub('virtual-scroller') + }, + provide: { + [SongsKey]: [ref(songs)], + [SelectedSongsKey]: [ref(selectedSongs), value => selectedSongs = value], + [SongListTypeKey]: [ref(type)], + [SongListConfigKey]: [config], + [SongListSortFieldKey]: [ref(sortField), value => sortField = value], + [SongListSortOrderKey]: [ref(sortOrder), value => sortOrder = value] + } + } + }) + } + + protected test () { + it('renders', async () => { + const { html } = this.renderComponent(factory ('song', 5)) + expect(html()).toMatchSnapshot() + }) + + it.each([ + ['track', 'header-track-number'], + ['title', 'header-title'], + ['album_name', 'header-album'], + ['length', 'header-length'], + ['artist_name', 'header-artist'] + ])('sorts by %s upon %s clicked', async (field: SongListSortField, testId: string) => { + const { getByTestId, emitted } = this.renderComponent(factory ('song', 5)) + + await fireEvent.click(getByTestId(testId)) + expect(emitted().sort[0]).toBeTruthy([field, 'asc']) + + await fireEvent.click(getByTestId(testId)) + expect(emitted().sort[0]).toBeTruthy([field, 'desc']) + }) + } +} diff --git a/resources/assets/js/components/song/SongList.vue b/resources/assets/js/components/song/SongList.vue new file mode 100644 index 00000000..d206258a --- /dev/null +++ b/resources/assets/js/components/song/SongList.vue @@ -0,0 +1,461 @@ + + ++ + + + + diff --git a/resources/assets/js/components/song/SongListControls.spec.ts b/resources/assets/js/components/song/SongListControls.spec.ts new file mode 100644 index 00000000..29dc0962 --- /dev/null +++ b/resources/assets/js/components/song/SongListControls.spec.ts @@ -0,0 +1,88 @@ +import { take } from 'lodash' +import { ref } from 'vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { fireEvent } from '@testing-library/vue' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { SelectedSongsKey, SongsKey } from '@/symbols' +import SongListControls from './SongListControls.vue' + +new class extends UnitTestCase { + private renderComponent (selectedSongCount = 1, config: Partial+ + # ++ ++ + + + Title + + + + + Artist + + + + + Album + + + + + + + + + + + + + ++ = {}) { + const songs = factory ('song', 5) + + return this.render(SongListControls, { + props: { + config + }, + global: { + provide: { + [SongsKey]: [ref(songs)], + [SelectedSongsKey]: [ref(take(songs, selectedSongCount))] + } + } + }) + } + + protected test () { + it.each([[0], [1]])('shuffles all if %s songs are selected', async (selectedCount: number) => { + const { emitted, getByTitle } = this.renderComponent(selectedCount) + + await fireEvent.click(getByTitle('Shuffle all songs')) + + expect(emitted().playAll[0]).toEqual([true]) + }) + + it.each([[0], [1]])('plays all if %s songs are selected with Alt pressed', async (selectedCount: number) => { + const { emitted, getByTitle } = this.renderComponent(selectedCount) + + await fireEvent.keyDown(window, { key: 'Alt' }) + await fireEvent.click(getByTitle('Play all songs')) + + expect(emitted().playAll[0]).toEqual([false]) + }) + + it('shuffles selected if more than one song are selected', async () => { + const { emitted, getByTitle } = this.renderComponent(2) + + await fireEvent.click(getByTitle('Shuffle selected songs')) + + expect(emitted().playSelected[0]).toEqual([true]) + }) + + it('plays selected if more than one song are selected with Alt pressed', async () => { + const { emitted, getByTitle } = this.renderComponent(2) + + await fireEvent.keyDown(window, { key: 'Alt' }) + await fireEvent.click(getByTitle('Play selected songs')) + + expect(emitted().playSelected[0]).toEqual([false]) + }) + + it('toggles Add To menu', async () => { + const { getByTitle, getByTestId } = this.renderComponent() + + await fireEvent.click(getByTitle('Add selected songs to…')) + expect(getByTestId('add-to-menu').style.display).toBe('') + + await fireEvent.click(getByTitle('Cancel')) + expect(getByTestId('add-to-menu').style.display).toBe('none') + }) + + it('clears queue', async () => { + const { emitted, getByTitle } = this.renderComponent(0, { clearQueue: true }) + + await fireEvent.click(getByTitle('Clear current queue')) + + expect(emitted().clearQueue).toBeTruthy() + }) + + it('deletes current playlist', async () => { + const { emitted, getByTitle } = this.renderComponent(0, { deletePlaylist: true }) + + await fireEvent.click(getByTitle('Delete this playlist')) + + expect(emitted().deletePlaylist).toBeTruthy() + }) + } +} diff --git a/resources/assets/js/components/song/SongListControls.vue b/resources/assets/js/components/song/SongListControls.vue new file mode 100644 index 00000000..904ea956 --- /dev/null +++ b/resources/assets/js/components/song/SongListControls.vue @@ -0,0 +1,172 @@ + + ++ + + + + diff --git a/resources/assets/js/components/song/SongListItem.spec.ts b/resources/assets/js/components/song/SongListItem.spec.ts new file mode 100644 index 00000000..b32f4d06 --- /dev/null +++ b/resources/assets/js/components/song/SongListItem.spec.ts @@ -0,0 +1,50 @@ +import { without } from 'lodash' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { queueStore } from '@/stores' +import { playbackService } from '@/services' +import { fireEvent } from '@testing-library/vue' +import SongListItem from './SongListItem.vue' +import UnitTestCase from '@/__tests__/UnitTestCase' + +let row: SongRow + +new class extends UnitTestCase { + private renderComponent (columns: SongListColumn[] = ['track', 'title', 'artist', 'album', 'length']) { + row = { + song: factory+ + + + ++ + ++ All + + + + + ++ Selected + + + ++ All + + + + + ++ Selected + + {{ showingAddToMenu ? 'Cancel' : 'Add To…' }} + + +Clear + ++ + ++ Playlist + + ('song'), + selected: false + } + + return this.render(SongListItem, { + props: { + item: row, + columns + } + }) + } + + protected test () { + it('plays on double click', async () => { + const queueMock = this.mock(queueStore, 'queueIfNotQueued') + const playMock = this.mock(playbackService, 'play') + const { getByTestId } = this.renderComponent() + + await fireEvent.dblClick(getByTestId('song-item')) + + expect(queueMock).toHaveBeenCalledWith(row.song) + expect(playMock).toHaveBeenCalledWith(row.song) + }) + + it.each<[SongListColumn, string]>([ + ['track', '.track-number'], + ['title', '.title'], + ['artist', '.artist'], + ['album', '.album'], + ['length', '.time'] + ])('does not render %s if so configure', async (column: SongListColumn, selector: string) => { + const { container } = this.renderComponent(without(['track', 'title', 'artist', 'album', 'length'], column)) + expect(container.querySelector(selector)).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/song/SongListItem.vue b/resources/assets/js/components/song/SongListItem.vue new file mode 100644 index 00000000..daf555ba --- /dev/null +++ b/resources/assets/js/components/song/SongListItem.vue @@ -0,0 +1,90 @@ + + + ++ + + + + diff --git a/resources/assets/js/components/song/__snapshots__/AddToMenu.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/AddToMenu.spec.ts.snap new file mode 100644 index 00000000..582c4c3f --- /dev/null +++ b/resources/assets/js/components/song/__snapshots__/AddToMenu.spec.ts.snap @@ -0,0 +1,20 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` ++ {{ song.track || '' }} + + {{ song.title }} + {{ song.artist_name }} + {{ song.album_name }} + {{ fmtLength }} + + + + + + + + ++`; diff --git a/resources/assets/js/components/song/__snapshots__/SongEditForm.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/SongEditForm.spec.ts.snap new file mode 100644 index 00000000..3989007c --- /dev/null +++ b/resources/assets/js/components/song/__snapshots__/SongEditForm.spec.ts.snap @@ -0,0 +1,71 @@ +// Vitest Snapshot v1 + +exports[`edits a single song 1`] = ` ++ +Add 5 songs to
++
+- Queue
+- Favorites
+- Foo
+- Bar
+- Baz
++ +or create a new playlist
+ ++ ++`; + +exports[`edits multiple songs 1`] = ` ++ ++`; diff --git a/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap b/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap new file mode 100644 index 00000000..5a078bfe --- /dev/null +++ b/resources/assets/js/components/song/__snapshots__/SongList.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` +++`; diff --git a/resources/assets/js/components/ui/AlbumArtOverlay.spec.ts b/resources/assets/js/components/ui/AlbumArtOverlay.spec.ts new file mode 100644 index 00000000..1e19e9e1 --- /dev/null +++ b/resources/assets/js/components/ui/AlbumArtOverlay.spec.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import { albumStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import AlbumArtOverlay from './AlbumArtOverlay.vue' +import { waitFor } from '@testing-library/vue' + +let albumId: number + +new class extends UnitTestCase { + private async renderComponent () { + albumId = 42 + + const rendered = this.render(AlbumArtOverlay, { + props: { + album: albumId + } + }) + + await this.tick() + + return rendered + } + + protected test () { + it('fetches and displays the album thumbnail', async () => { + const fetchMock = this.mock(albumStore, 'fetchThumbnail').mockResolvedValue('https://localhost/thumb.jpg') + + const { html } = await this.renderComponent() + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(albumId) + expect(html()).toMatchSnapshot() + }) + }) + + it('displays nothing if fetching fails', async () => { + const fetchMock = this.mock(albumStore, 'fetchThumbnail').mockRejectedValue(new Error()) + + const { html } = await this.renderComponent() + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(albumId) + expect(html()).toMatchSnapshot() + }) + }) + } +} diff --git a/resources/assets/js/components/ui/AlbumArtOverlay.vue b/resources/assets/js/components/ui/AlbumArtOverlay.vue new file mode 100644 index 00000000..5c102309 --- /dev/null +++ b/resources/assets/js/components/ui/AlbumArtOverlay.vue @@ -0,0 +1,37 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/AlbumArtistThumbnail.spec.ts b/resources/assets/js/components/ui/AlbumArtistThumbnail.spec.ts new file mode 100644 index 00000000..bf8cc41b --- /dev/null +++ b/resources/assets/js/components/ui/AlbumArtistThumbnail.spec.ts @@ -0,0 +1,105 @@ +import { orderBy } from 'lodash' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import { fireEvent, waitFor } from '@testing-library/vue' +import { queueStore, songStore } from '@/stores' +import { playbackService } from '@/services' +import Thumbnail from './AlbumArtistThumbnail.vue' + +let album: Album +let artist: Artist + +new class extends UnitTestCase { + private renderForAlbum () { + album = factory# Title
Artist Album
+('album', { + name: 'IV', + cover: 'https://localhost/album.jpg' + }) + + return this.render(Thumbnail, { + props: { + entity: album + } + }) + } + + private renderForArtist () { + artist = factory ('artist', { + name: 'Led Zeppelin', + image: 'https://localhost/blimp.jpg' + }) + + return this.render(Thumbnail, { + props: { + entity: artist + } + }) + } + + protected test () { + it('renders for album', () => { + expect(this.renderForAlbum().html()).toMatchSnapshot() + }) + + it('renders for artist', () => { + expect(this.renderForArtist().html()).toMatchSnapshot() + }) + + it('plays album', async () => { + const songs = factory ('song', 10) + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + const { getByRole } = this.renderForAlbum() + + await fireEvent.click(getByRole('button')) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(album) + expect(playMock).toHaveBeenCalledWith(songs) + }) + }) + + it('queues album', async () => { + const songs = factory ('song', 10) + const fetchMock = this.mock(songStore, 'fetchForAlbum').mockResolvedValue(songs) + const queueMock = this.mock(queueStore, 'queue') + const { getByRole } = this.renderForAlbum() + + await fireEvent.click(getByRole('button'), { altKey: true }) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(album) + expect(queueMock).toHaveBeenCalledWith(orderBy(songs, ['disc', 'track'])) + }) + }) + + it('plays artist', async () => { + const songs = factory ('song', 10) + const fetchMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + const playMock = this.mock(playbackService, 'queueAndPlay') + const { getByRole } = this.renderForArtist() + + await fireEvent.click(getByRole('button')) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(artist) + expect(playMock).toHaveBeenCalledWith(songs) + }) + }) + + it('queues artist', async () => { + const songs = factory ('song', 10) + const fetchMock = this.mock(songStore, 'fetchForArtist').mockResolvedValue(songs) + const queueMock = this.mock(queueStore, 'queue') + const { getByRole } = this.renderForArtist() + + await fireEvent.click(getByRole('button'), { altKey: true }) + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith(artist) + expect(queueMock).toHaveBeenCalledWith(orderBy(songs, ['album_id', 'disc', 'track'])) + }) + }) + } +} diff --git a/resources/assets/js/components/ui/AlbumArtistThumbnail.vue b/resources/assets/js/components/ui/AlbumArtistThumbnail.vue new file mode 100644 index 00000000..44ce5018 --- /dev/null +++ b/resources/assets/js/components/ui/AlbumArtistThumbnail.vue @@ -0,0 +1,238 @@ + + + + {{ buttonLabel }} + + + + + + + + diff --git a/resources/assets/js/components/ui/AppleMusicButton.spec.ts b/resources/assets/js/components/ui/AppleMusicButton.spec.ts new file mode 100644 index 00000000..08a0619f --- /dev/null +++ b/resources/assets/js/components/ui/AppleMusicButton.spec.ts @@ -0,0 +1,15 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import AppleMusicButton from './AppleMusicButton.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => { + expect(this.render(AppleMusicButton, { + props: { + url: 'https://music.apple.com/buy-nao' + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/AppleMusicButton.vue b/resources/assets/js/components/ui/AppleMusicButton.vue new file mode 100644 index 00000000..7658cfa5 --- /dev/null +++ b/resources/assets/js/components/ui/AppleMusicButton.vue @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/resources/assets/js/components/ui/Btn.spec.ts b/resources/assets/js/components/ui/Btn.spec.ts new file mode 100644 index 00000000..a9ccd93a --- /dev/null +++ b/resources/assets/js/components/ui/Btn.spec.ts @@ -0,0 +1,15 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import Btn from './Btn.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => { + expect(this.render(Btn, { + slots: { + default: 'Click Me Nao' + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/Btn.vue b/resources/assets/js/components/ui/Btn.vue new file mode 100644 index 00000000..31487755 --- /dev/null +++ b/resources/assets/js/components/ui/Btn.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/resources/assets/js/components/ui/BtnCloseModal.spec.ts b/resources/assets/js/components/ui/BtnCloseModal.spec.ts new file mode 100644 index 00000000..0414d28e --- /dev/null +++ b/resources/assets/js/components/ui/BtnCloseModal.spec.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import UnitTestCase from '@/__tests__/UnitTestCase' +import BtnCloseModal from './BtnCloseModal.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => expect(this.render(BtnCloseModal).html()).toMatchSnapshot()) + + it('emits the event', async () => { + const { emitted, getByRole } = this.render(BtnCloseModal) + + await fireEvent.click(getByRole('button')) + + expect(emitted().click).toBeTruthy() + }) + } +} diff --git a/resources/assets/js/components/ui/BtnCloseModal.vue b/resources/assets/js/components/ui/BtnCloseModal.vue new file mode 100644 index 00000000..71132127 --- /dev/null +++ b/resources/assets/js/components/ui/BtnCloseModal.vue @@ -0,0 +1,33 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/BtnGroup.spec.ts b/resources/assets/js/components/ui/BtnGroup.spec.ts new file mode 100644 index 00000000..1498e79c --- /dev/null +++ b/resources/assets/js/components/ui/BtnGroup.spec.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import BtnGroup from './BtnGroup.vue' +import Btn from './Btn.vue' + +new class extends UnitTestCase { + private renderButtonToSlot (text: string) { + return this.render(Btn, { + slots: { + default: text + } + }).html() + } + + protected test () { + it('renders', () => { + expect(this.render(BtnGroup, { + slots: { + default: ['Green', 'Orange', 'Blue'].map(text => this.renderButtonToSlot(text)) + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/BtnGroup.vue b/resources/assets/js/components/ui/BtnGroup.vue new file mode 100644 index 00000000..3b821b73 --- /dev/null +++ b/resources/assets/js/components/ui/BtnGroup.vue @@ -0,0 +1,37 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/BtnScrollToTop.spec.ts b/resources/assets/js/components/ui/BtnScrollToTop.spec.ts new file mode 100644 index 00000000..9349c62f --- /dev/null +++ b/resources/assets/js/components/ui/BtnScrollToTop.spec.ts @@ -0,0 +1,22 @@ +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import { $ } from '@/utils' +import UnitTestCase from '@/__tests__/UnitTestCase' +import BtnScrollToTop from './BtnScrollToTop.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => { + expect(this.render(BtnScrollToTop).html()).toMatchSnapshot() + }) + + it('scrolls to top', async () => { + const mock = this.mock($, 'scrollTo') + const { getByTitle } = this.render(BtnScrollToTop) + + await fireEvent.click(getByTitle('Scroll to top')) + + expect(mock).toHaveBeenCalled() + }) + } +} diff --git a/resources/assets/js/components/ui/BtnScrollToTop.vue b/resources/assets/js/components/ui/BtnScrollToTop.vue new file mode 100644 index 00000000..d0b0141d --- /dev/null +++ b/resources/assets/js/components/ui/BtnScrollToTop.vue @@ -0,0 +1,51 @@ + + + + + + + + + diff --git a/resources/assets/js/components/ui/CheckBox.spec.ts b/resources/assets/js/components/ui/CheckBox.spec.ts new file mode 100644 index 00000000..d73923e0 --- /dev/null +++ b/resources/assets/js/components/ui/CheckBox.spec.ts @@ -0,0 +1,24 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import CheckBox from './CheckBox.vue' +import { fireEvent } from '@testing-library/vue' + +new class extends UnitTestCase { + protected test () { + it('renders unchecked state', () => expect(this.render(CheckBox).html()).toMatchSnapshot()) + + it('renders checked state', () => expect(this.render(CheckBox, { + props: { + modelValue: true + } + }).html()).toMatchSnapshot()) + + it('emits the input event', async () => { + const { getByRole, emitted } = this.render(CheckBox) + + await fireEvent.input(getByRole('checkbox')) + + expect(emitted()['update:modelValue']).toBeTruthy() + }) + } +} diff --git a/resources/assets/js/components/ui/CheckBox.vue b/resources/assets/js/components/ui/CheckBox.vue new file mode 100644 index 00000000..ee3c37ad --- /dev/null +++ b/resources/assets/js/components/ui/CheckBox.vue @@ -0,0 +1,37 @@ + + + ++ + + + + + diff --git a/resources/assets/js/components/ui/ContextMenuBase.vue b/resources/assets/js/components/ui/ContextMenuBase.vue new file mode 100644 index 00000000..b6c0334a --- /dev/null +++ b/resources/assets/js/components/ui/ContextMenuBase.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/resources/assets/js/components/ui/DialogBox.vue b/resources/assets/js/components/ui/DialogBox.vue new file mode 100644 index 00000000..41b086d4 --- /dev/null +++ b/resources/assets/js/components/ui/DialogBox.vue @@ -0,0 +1,161 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/Equalizer.vue b/resources/assets/js/components/ui/Equalizer.vue new file mode 100644 index 00000000..a3d93f12 --- /dev/null +++ b/resources/assets/js/components/ui/Equalizer.vue @@ -0,0 +1,348 @@ + + ++ + + + + diff --git a/resources/assets/js/components/ui/LyricsPane.spec.ts b/resources/assets/js/components/ui/LyricsPane.spec.ts new file mode 100644 index 00000000..e3cf9f2a --- /dev/null +++ b/resources/assets/js/components/ui/LyricsPane.spec.ts @@ -0,0 +1,53 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import { eventBus } from '@/utils' +import { fireEvent } from '@testing-library/vue' +import LyricsPane from './LyricsPane.vue' +import Magnifier from '@/components/ui/Magnifier.vue' + +new class extends UnitTestCase { + private renderComponent (song?: Song) { + song = song || factory+ +++ + + + + + + +20 + 0 + -20 + + + + + + ++('song', { + lyrics: 'Foo bar baz qux' + }) + + return this.render(LyricsPane, { + props: { + song + }, + global: { + stubs: { + Magnifier + } + } + }) + } + + protected test () { + it('renders', () => { + expect(this.renderComponent().html()).toMatchSnapshot() + }) + + it('provides a button to add lyrics if current user is admin', async () => { + const song = factory ('song', { + lyrics: null + }) + + const mock = this.mock(eventBus, 'emit') + const { getByTestId } = this.actingAsAdmin().renderComponent(song) + + await fireEvent.click(getByTestId('add-lyrics-btn')) + + expect(mock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_SONG_FORM', song, 'lyrics') + }) + + it('does not have a button to add lyrics if current user is not an admin', async () => { + const { queryByTestId } = this.actingAs().renderComponent(factory ('song', { + lyrics: null + })) + + expect(await queryByTestId('add-lyrics-btn')).toBeNull() + }) + } +} diff --git a/resources/assets/js/components/ui/LyricsPane.vue b/resources/assets/js/components/ui/LyricsPane.vue new file mode 100644 index 00000000..b53cfb84 --- /dev/null +++ b/resources/assets/js/components/ui/LyricsPane.vue @@ -0,0 +1,71 @@ + + + + + + + + diff --git a/resources/assets/js/components/ui/Magnifier.spec.ts b/resources/assets/js/components/ui/Magnifier.spec.ts new file mode 100644 index 00000000..eee21a6e --- /dev/null +++ b/resources/assets/js/components/ui/Magnifier.spec.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent } from '@testing-library/vue' +import Magnifier from './Magnifier.vue' + +new class extends UnitTestCase { + protected test () { + it('renders and functions', async () => { + const { getByTitle, html, emitted } = this.render(Magnifier) + + await fireEvent.click(getByTitle('Zoom in')) + expect(emitted()['in']).toBeTruthy() + + await fireEvent.click(getByTitle('Zoom out')) + expect(emitted().out).toBeTruthy() + + expect(html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/Magnifier.vue b/resources/assets/js/components/ui/Magnifier.vue new file mode 100644 index 00000000..031e04e3 --- /dev/null +++ b/resources/assets/js/components/ui/Magnifier.vue @@ -0,0 +1,49 @@ + + + + + + + + + + diff --git a/resources/assets/js/components/ui/MessageToast.spec.ts b/resources/assets/js/components/ui/MessageToast.spec.ts new file mode 100644 index 00000000..eb072418 --- /dev/null +++ b/resources/assets/js/components/ui/MessageToast.spec.ts @@ -0,0 +1,40 @@ +import { expect, it, vi } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import MessageToast from './MessageToast.vue' +import { fireEvent } from '@testing-library/vue' + +new class extends UnitTestCase { + private renderComponent () { + return this.render(MessageToast, { + props: { + message: { + id: 101, + type: 'success', + message: 'Everything is fine', + timeout: 5 + } + } + }) + } + + protected test () { + it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot()) + + it('dismisses upon click', async () => { + const { emitted, getByTitle } = this.renderComponent() + await fireEvent.click(getByTitle('Click to dismiss')) + + expect(emitted().dismiss).toBeTruthy() + }) + + it('dismisses upon timeout', async () => { + vi.useFakeTimers() + + const { emitted } = this.renderComponent() + vi.advanceTimersByTime(5000) + expect(emitted().dismiss).toBeTruthy() + + vi.useRealTimers() + }) + } +} diff --git a/resources/assets/js/components/ui/MessageToast.vue b/resources/assets/js/components/ui/MessageToast.vue new file mode 100644 index 00000000..618abec0 --- /dev/null +++ b/resources/assets/js/components/ui/MessageToast.vue @@ -0,0 +1,115 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/MessageToaster.vue b/resources/assets/js/components/ui/MessageToaster.vue new file mode 100644 index 00000000..23373f49 --- /dev/null +++ b/resources/assets/js/components/ui/MessageToaster.vue @@ -0,0 +1,64 @@ + ++ ++++{{ song.lyrics }}++ + + No lyrics found. + + to add lyrics. + + No lyrics available. Are you listening to Bach? +
+ ++ + + + + + diff --git a/resources/assets/js/components/ui/Overlay.spec.ts b/resources/assets/js/components/ui/Overlay.spec.ts new file mode 100644 index 00000000..7c0f6692 --- /dev/null +++ b/resources/assets/js/components/ui/Overlay.spec.ts @@ -0,0 +1,45 @@ +import UnitTestCase from '@/__tests__/UnitTestCase' +import { expect, it } from 'vitest' +import { eventBus } from '@/utils' +import { waitFor } from '@testing-library/vue' +import SoundBars from '@/components/ui/SoundBars.vue' +import Overlay from './Overlay.vue' + +new class extends UnitTestCase { + private async renderComponent (type: OverlayState['type'] = 'loading') { + const rendered = this.render(Overlay, { + global: { + stubs: { + SoundBars + } + } + }) + + eventBus.emit('SHOW_OVERLAY', { + type, + message: 'Look at me now' + }) + + await this.tick() + + return rendered + } + + protected test () { + it.each<[OverlayState['type']]>([ + ['loading'], + ['success'], + ['info'], + ['warning'], + ['error'] + ])('renders %s type', async (type) => expect((await this.renderComponent(type)).html()).toMatchSnapshot()) + + it('closes', async () => { + const { queryByTestId } = await this.renderComponent() + expect(queryByTestId('overlay')).not.toBeNull() + + eventBus.emit('HIDE_OVERLAY') + await waitFor(() => expect(queryByTestId('overlay')).toBeNull()) + }) + } +} diff --git a/resources/assets/js/components/ui/Overlay.vue b/resources/assets/js/components/ui/Overlay.vue new file mode 100644 index 00000000..d74fcb4b --- /dev/null +++ b/resources/assets/js/components/ui/Overlay.vue @@ -0,0 +1,79 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/RepeatModeSwitch.spec.ts b/resources/assets/js/components/ui/RepeatModeSwitch.spec.ts new file mode 100644 index 00000000..7dafcde1 --- /dev/null +++ b/resources/assets/js/components/ui/RepeatModeSwitch.spec.ts @@ -0,0 +1,20 @@ +import { expect, it } from 'vitest' +import { preferenceStore } from '@/stores' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent } from '@testing-library/vue' +import { playbackService } from '@/services' +import RepeatModeSwitch from './RepeatModeSwitch.vue' + +new class extends UnitTestCase { + protected test () { + it('changes mode', async () => { + const mock = this.mock(playbackService, 'changeRepeatMode') + preferenceStore.state.repeatMode = 'NO_REPEAT' + const { getByRole } = this.render(RepeatModeSwitch) + + await fireEvent.click(getByRole('button')) + + expect(mock).toHaveBeenCalledOnce() + }) + } +} diff --git a/resources/assets/js/components/ui/RepeatModeSwitch.vue b/resources/assets/js/components/ui/RepeatModeSwitch.vue new file mode 100644 index 00000000..db9ba935 --- /dev/null +++ b/resources/assets/js/components/ui/RepeatModeSwitch.vue @@ -0,0 +1,49 @@ + + + + + + + diff --git a/resources/assets/js/components/ui/ScreenControlsToggle.spec.ts b/resources/assets/js/components/ui/ScreenControlsToggle.spec.ts new file mode 100644 index 00000000..5ecd54bc --- /dev/null +++ b/resources/assets/js/components/ui/ScreenControlsToggle.spec.ts @@ -0,0 +1,18 @@ +import isMobile from 'ismobilejs' +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ScreenControlsToggle from './ScreenControlsToggle.vue' + +new class extends UnitTestCase { + protected test () { + it('renders and emits an event on mobile', async () => { + isMobile.phone = true + const { emitted, getByTestId } = this.render(ScreenControlsToggle) + + await fireEvent.click(getByTestId('controls-toggle')) + + expect(emitted().toggleControls).toBeTruthy() + }) + } +} diff --git a/resources/assets/js/components/ui/ScreenControlsToggle.vue b/resources/assets/js/components/ui/ScreenControlsToggle.vue new file mode 100644 index 00000000..75b3f0b4 --- /dev/null +++ b/resources/assets/js/components/ui/ScreenControlsToggle.vue @@ -0,0 +1,21 @@ + + ++ ++ + + + + + + diff --git a/resources/assets/js/components/ui/ScreenEmptyState.spec.ts b/resources/assets/js/components/ui/ScreenEmptyState.spec.ts new file mode 100644 index 00000000..ff8913d7 --- /dev/null +++ b/resources/assets/js/components/ui/ScreenEmptyState.spec.ts @@ -0,0 +1,16 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ScreenEmptyState from './ScreenEmptyState.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => { + expect(this.render(ScreenEmptyState, { + slots: { + icon: '', + default: 'Nothing here' + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/ScreenEmptyState.vue b/resources/assets/js/components/ui/ScreenEmptyState.vue new file mode 100644 index 00000000..dbf06886 --- /dev/null +++ b/resources/assets/js/components/ui/ScreenEmptyState.vue @@ -0,0 +1,61 @@ + + ++ + + diff --git a/resources/assets/js/components/ui/ScreenHeader.spec.ts b/resources/assets/js/components/ui/ScreenHeader.spec.ts new file mode 100644 index 00000000..a75ab833 --- /dev/null +++ b/resources/assets/js/components/ui/ScreenHeader.spec.ts @@ -0,0 +1,18 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ScreenHeader from './ScreenHeader.vue' + +new class extends UnitTestCase { + protected test () { + it('renders', () => { + expect(this.render(ScreenHeader, { + slots: { + default: 'This Header', + meta: '++ +++Placeholder text goes here. +Some meta
', + controls: '', + thumbnail: '' + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/ScreenHeader.vue b/resources/assets/js/components/ui/ScreenHeader.vue new file mode 100644 index 00000000..d920f6f0 --- /dev/null +++ b/resources/assets/js/components/ui/ScreenHeader.vue @@ -0,0 +1,147 @@ + ++ + + + + + + + diff --git a/resources/assets/js/components/ui/SearchForm.spec.ts b/resources/assets/js/components/ui/SearchForm.spec.ts new file mode 100644 index 00000000..33c149be --- /dev/null +++ b/resources/assets/js/components/ui/SearchForm.spec.ts @@ -0,0 +1,36 @@ +import { expect, it } from 'vitest' +import router from '@/router' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent } from '@testing-library/vue' +import { eventBus } from '@/utils' +import SearchForm from './SearchForm.vue' + +new class extends UnitTestCase { + protected test () { + it('sets focus into search box when requested', async () => { + const { getByRole } = this.render(SearchForm) + + eventBus.emit('FOCUS_SEARCH_FIELD') + + expect(getByRole('searchbox')).toBe(document.activeElement) + }) + + it('goes to search screen when search box is focused', async () => { + const mock = this.mock(router, 'go') + const { getByRole } = this.render(SearchForm) + + await fireEvent.focus(getByRole('searchbox')) + + expect(mock).toHaveBeenCalledWith('search') + }) + + it('emits an event when search query is changed', async () => { + const mock = this.mock(eventBus, 'emit') + const { getByRole } = this.render(SearchForm) + + await fireEvent.update(getByRole('searchbox'), 'hey') + + expect(mock).toHaveBeenCalledWith('SEARCH_KEYWORDS_CHANGED', 'hey') + }) + } +} diff --git a/resources/assets/js/components/ui/SearchForm.vue b/resources/assets/js/components/ui/SearchForm.vue new file mode 100644 index 00000000..70a99498 --- /dev/null +++ b/resources/assets/js/components/ui/SearchForm.vue @@ -0,0 +1,80 @@ + ++ +++ ++
+ ++ + + ++ + + + + diff --git a/resources/assets/js/components/ui/SoundBars.vue b/resources/assets/js/components/ui/SoundBars.vue new file mode 100644 index 00000000..c3c137a5 --- /dev/null +++ b/resources/assets/js/components/ui/SoundBars.vue @@ -0,0 +1,56 @@ + + + + + + + + + diff --git a/resources/assets/js/components/ui/ThumbnailStack.spec.ts b/resources/assets/js/components/ui/ThumbnailStack.spec.ts new file mode 100644 index 00000000..bb411da3 --- /dev/null +++ b/resources/assets/js/components/ui/ThumbnailStack.spec.ts @@ -0,0 +1,36 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ThumbnailStack from './ThumbnailStack.vue' + +new class extends UnitTestCase { + protected test () { + it('displays 4 thumbnails at most', () => { + const { getAllByTestId } = this.render(ThumbnailStack, { + props: { + thumbnails: [ + 'https://via.placeholder.com/150', + 'https://via.placeholder.com/150?foo', + 'https://via.placeholder.com/150?bar', + 'https://via.placeholder.com/150?baz', + 'https://via.placeholder.com/150?qux' + ] + } + }) + + expect(getAllByTestId('thumbnail')).toHaveLength(4) + }) + + it('displays the first thumbnail if less than 4 are provided', () => { + const { getAllByTestId } = this.render(ThumbnailStack, { + props: { + thumbnails: [ + 'https://via.placeholder.com/150', + 'https://via.placeholder.com/150?foo' + ] + } + }) + + expect(getAllByTestId('thumbnail')).toHaveLength(1) + }) + } +} diff --git a/resources/assets/js/components/ui/ThumbnailStack.vue b/resources/assets/js/components/ui/ThumbnailStack.vue new file mode 100644 index 00000000..d11c29c0 --- /dev/null +++ b/resources/assets/js/components/ui/ThumbnailStack.vue @@ -0,0 +1,49 @@ + ++ ++ + + + + diff --git a/resources/assets/js/components/ui/TooltipIcon.vue b/resources/assets/js/components/ui/TooltipIcon.vue new file mode 100644 index 00000000..76e1a455 --- /dev/null +++ b/resources/assets/js/components/ui/TooltipIcon.vue @@ -0,0 +1,11 @@ + ++ + + diff --git a/resources/assets/js/components/ui/ViewModeSwitch.spec.ts b/resources/assets/js/components/ui/ViewModeSwitch.spec.ts new file mode 100644 index 00000000..08037057 --- /dev/null +++ b/resources/assets/js/components/ui/ViewModeSwitch.spec.ts @@ -0,0 +1,15 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import ViewModeSwitch from './ViewModeSwitch.vue' + +new class extends UnitTestCase { + protected test () { + it.each<[ArtistAlbumViewMode]>([['thumbnails'], ['list']])('renders %s mode', mode => { + expect(this.render(ViewModeSwitch, { + props: { + modelValue: mode + } + }).html()).toMatchSnapshot() + }) + } +} diff --git a/resources/assets/js/components/ui/ViewModeSwitch.vue b/resources/assets/js/components/ui/ViewModeSwitch.vue new file mode 100644 index 00000000..3c3f2f23 --- /dev/null +++ b/resources/assets/js/components/ui/ViewModeSwitch.vue @@ -0,0 +1,68 @@ + + + + + + + + + + + diff --git a/resources/assets/js/components/ui/VirtualScroller.vue b/resources/assets/js/components/ui/VirtualScroller.vue new file mode 100644 index 00000000..be312a0d --- /dev/null +++ b/resources/assets/js/components/ui/VirtualScroller.vue @@ -0,0 +1,81 @@ + + ++ + + + + diff --git a/resources/assets/js/components/ui/Visualizer.vue b/resources/assets/js/components/ui/Visualizer.vue new file mode 100644 index 00000000..6c35c4f3 --- /dev/null +++ b/resources/assets/js/components/ui/Visualizer.vue @@ -0,0 +1,62 @@ + ++++ +++ + ++ + + + + diff --git a/resources/assets/js/components/ui/Volume.spec.ts b/resources/assets/js/components/ui/Volume.spec.ts new file mode 100644 index 00000000..22135bbd --- /dev/null +++ b/resources/assets/js/components/ui/Volume.spec.ts @@ -0,0 +1,33 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent } from '@testing-library/vue' +import { playbackService, socketService } from '@/services' +import Volume from './Volume.vue' + +new class extends UnitTestCase { + protected test () { + it('mutes and unmutes', async () => { + const muteMock = this.mock(playbackService, 'mute') + const unmuteMock = this.mock(playbackService, 'unmute') + const { getByRole } = this.render(Volume) + + await fireEvent.click(getByRole('button')) + expect(muteMock).toHaveBeenCalledOnce() + + await fireEvent.click(getByRole('button')) + expect(unmuteMock).toHaveBeenCalledOnce() + }) + + it('sets and broadcasts volume', async () => { + const setVolumeMock = this.mock(playbackService, 'setVolume') + const broadCastMock = this.mock(socketService, 'broadcast') + const { getByRole } = this.render(Volume) + + await fireEvent.update(getByRole('slider'), '4.2') + await fireEvent.change(getByRole('slider')) + + expect(setVolumeMock).toHaveBeenCalledWith(4.2) + expect(broadCastMock).toHaveBeenCalledWith('SOCKET_VOLUME_CHANGED', 4.2) + }) + } +} diff --git a/resources/assets/js/components/ui/Volume.vue b/resources/assets/js/components/ui/Volume.vue new file mode 100644 index 00000000..aca71813 --- /dev/null +++ b/resources/assets/js/components/ui/Volume.vue @@ -0,0 +1,106 @@ + + ++ + + + + + + + + + diff --git a/resources/assets/js/components/ui/YouTubeVideoItem.spec.ts b/resources/assets/js/components/ui/YouTubeVideoItem.spec.ts new file mode 100644 index 00000000..e2c28bb6 --- /dev/null +++ b/resources/assets/js/components/ui/YouTubeVideoItem.spec.ts @@ -0,0 +1,44 @@ +import { expect, it } from 'vitest' +import { fireEvent } from '@testing-library/vue' +import { youTubeService } from '@/services' +import UnitTestCase from '@/__tests__/UnitTestCase' +import YouTubeVideoItem from './YouTubeVideoItem.vue' + +let video: YouTubeVideo + +new class extends UnitTestCase { + private renderComponent () { + video = { + id: { + videoId: 'cLgJQ8Zj3AA' + }, + snippet: { + title: 'Guess what it is', + description: 'From the LA Opening Gala 2014: John Williams Celebration', + thumbnails: { + default: { + url: 'https://i.ytimg.com/an_webp/cLgJQ8Zj3AA/mqdefault_6s.webp' + } + } + } + } + return this.render(YouTubeVideoItem, { + props: { + video + } + }) + } + + protected test () { + it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot()) + + it('plays', async () => { + const mock = this.mock(youTubeService, 'play') + const { getByRole } = this.renderComponent() + + await fireEvent.click(getByRole('button')) + + expect(mock).toHaveBeenCalledWith(video) + }) + } +} diff --git a/resources/assets/js/components/ui/YouTubeVideoItem.vue b/resources/assets/js/components/ui/YouTubeVideoItem.vue new file mode 100644 index 00000000..d5b4ad25 --- /dev/null +++ b/resources/assets/js/components/ui/YouTubeVideoItem.vue @@ -0,0 +1,49 @@ + + + + + + + + + + diff --git a/resources/assets/js/components/ui/YouTubeVideoList.spec.ts b/resources/assets/js/components/ui/YouTubeVideoList.spec.ts new file mode 100644 index 00000000..251069c3 --- /dev/null +++ b/resources/assets/js/components/ui/YouTubeVideoList.spec.ts @@ -0,0 +1,48 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { youTubeService } from '@/services' +import { fireEvent, waitFor } from '@testing-library/vue' +import Btn from '@/components/ui/Btn.vue' +import YouTubeVideo from '@/components/ui/YouTubeVideoItem.vue' +import YouTubeVideoList from './YouTubeVideoList.vue' + +new class extends UnitTestCase { + protected test () { + it('functions', async () => { + const song = factory ('song') + + const searchMock = this.mock(youTubeService, 'searchVideosBySong').mockResolvedValueOnce({ + nextPageToken: 'foo', + items: factory ('video', 5) + }).mockResolvedValueOnce({ + nextPageToken: 'bar', + items: factory ('video', 3) + }) + + const { getAllByTestId, getByRole } = this.render(YouTubeVideoList, { + props: { + song + }, + global: { + stubs: { + Btn, + YouTubeVideo + } + } + }) + + await waitFor(() => { + expect(searchMock).toHaveBeenNthCalledWith(1, song, '') + expect(getAllByTestId('youtube-video')).toHaveLength(5) + }) + + await fireEvent.click(getByRole('button', { name: 'Load More' })) + + await waitFor(() => { + expect(searchMock).toHaveBeenNthCalledWith(2, song, 'foo') + expect(getAllByTestId('youtube-video')).toHaveLength(8) + }) + }) + } +} diff --git a/resources/assets/js/components/ui/YouTubeVideoList.vue b/resources/assets/js/components/ui/YouTubeVideoList.vue new file mode 100644 index 00000000..8e516be9 --- /dev/null +++ b/resources/assets/js/components/ui/YouTubeVideoList.vue @@ -0,0 +1,64 @@ + + + ++ + + + + diff --git a/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap new file mode 100644 index 00000000..a5356c49 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/AlbumArtOverlay.spec.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1 + +exports[`displays nothing if fetching fails 1`] = ``; + +exports[`fetches and displays the album thumbnail 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap new file mode 100644 index 00000000..c62345ed --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/AlbumArtistThumbnail.spec.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1 + +exports[`renders for album 1`] = `Play all songs in the album IV`; + +exports[`renders for artist 1`] = `Play all songs by Led Zeppelin`; diff --git a/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap new file mode 100644 index 00000000..49106549 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/AppleMusicButton.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` + +`; diff --git a/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap new file mode 100644 index 00000000..5c059abe --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/Btn.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap new file mode 100644 index 00000000..adb184a4 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/BtnCloseModal.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap new file mode 100644 index 00000000..d0949021 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/BtnGroup.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap new file mode 100644 index 00000000..96070b99 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/BtnScrollToTop.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = `+
+- +
++ Load More + + +Loading…
+`; diff --git a/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap new file mode 100644 index 00000000..411377b4 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/CheckBox.spec.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1 + +exports[`renders checked state 1`] = `
`; + +exports[`renders unchecked state 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap new file mode 100644 index 00000000..63fa6c30 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/LyricsPane.spec.ts.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` ++ +`; diff --git a/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap new file mode 100644 index 00000000..18187a26 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/Magnifier.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders and functions 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap new file mode 100644 index 00000000..69c30733 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/MessageToast.spec.ts.snap @@ -0,0 +1,8 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` + +`; diff --git a/resources/assets/js/components/ui/__snapshots__/Overlay.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/Overlay.spec.ts.snap new file mode 100644 index 00000000..de4dc247 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/Overlay.spec.ts.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1 + +exports[`renders error type 1`] = ` + +`; + +exports[`renders info type 1`] = ` + +`; + +exports[`renders loading type 1`] = ` + +`; + +exports[`renders success type 1`] = ` + +`; + +exports[`renders warning type 1`] = ` + +`; diff --git a/resources/assets/js/components/ui/__snapshots__/ScreenEmptyState.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/ScreenEmptyState.spec.ts.snap new file mode 100644 index 00000000..2de0f949 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/ScreenEmptyState.spec.ts.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` ++++ +Foo bar baz qux++`; diff --git a/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap new file mode 100644 index 00000000..e8b2b1bf --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/ScreenHeader.spec.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` ++ +Nothing here++ + +`; diff --git a/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap new file mode 100644 index 00000000..71e1c605 --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/ViewModeSwitch.spec.ts.snap @@ -0,0 +1,5 @@ +// Vitest Snapshot v1 + +exports[`renders list mode 1`] = ``; + +exports[`renders thumbnails mode 1`] = ``; diff --git a/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap b/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap new file mode 100644 index 00000000..cad83a5b --- /dev/null +++ b/resources/assets/js/components/ui/__snapshots__/YouTubeVideoItem.spec.ts.snap @@ -0,0 +1,10 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = ` + + + +`; diff --git a/resources/assets/js/components/ui/skeletons/ArtistAlbumCardSkeleton.vue b/resources/assets/js/components/ui/skeletons/ArtistAlbumCardSkeleton.vue new file mode 100644 index 00000000..0427d9b6 --- /dev/null +++ b/resources/assets/js/components/ui/skeletons/ArtistAlbumCardSkeleton.vue @@ -0,0 +1,65 @@ + ++ +++ +This Header
++ + + + + + + + + diff --git a/resources/assets/js/components/ui/skeletons/ScreenHeaderSkeleton.vue b/resources/assets/js/components/ui/skeletons/ScreenHeaderSkeleton.vue new file mode 100644 index 00000000..6562e867 --- /dev/null +++ b/resources/assets/js/components/ui/skeletons/ScreenHeaderSkeleton.vue @@ -0,0 +1,53 @@ + ++ + + + + + diff --git a/resources/assets/js/components/ui/skeletons/SongCardSkeleton.vue b/resources/assets/js/components/ui/skeletons/SongCardSkeleton.vue new file mode 100644 index 00000000..dbb19bc0 --- /dev/null +++ b/resources/assets/js/components/ui/skeletons/SongCardSkeleton.vue @@ -0,0 +1,51 @@ + ++ + + + ++ + + + + diff --git a/resources/assets/js/components/ui/skeletons/SongListSkeleton.vue b/resources/assets/js/components/ui/skeletons/SongListSkeleton.vue new file mode 100644 index 00000000..93a7f495 --- /dev/null +++ b/resources/assets/js/components/ui/skeletons/SongListSkeleton.vue @@ -0,0 +1,102 @@ + ++ ++ + ++++ + + diff --git a/resources/assets/js/components/ui/upload/UploadItem.spec.ts b/resources/assets/js/components/ui/upload/UploadItem.spec.ts new file mode 100644 index 00000000..47459fe0 --- /dev/null +++ b/resources/assets/js/components/ui/upload/UploadItem.spec.ts @@ -0,0 +1,59 @@ +import { expect, it } from 'vitest' +import { UploadFile, UploadStatus } from '@/config' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent } from '@testing-library/vue' +import { uploadService } from '@/services' +import Btn from '@/components/ui/Btn.vue' +import UploadItem from './UploadItem.vue' + +let file: UploadFile + +new class extends UnitTestCase { + private renderComponent (status: UploadStatus) { + file = { + status, + file: new File([], 'sample.mp3'), + id: 'x-file', + message: '', + name: 'Sample Track', + progress: 42 + } + + return this.render(UploadItem, { + props: { + file + }, + global: { + stubs: { + Btn + } + } + }) + } + + protected test () { + it('renders', () => expect(this.renderComponent('Canceled').html()).toMatchSnapshot()) + + it.each<[UploadStatus]>([['Canceled'], ['Errored']])('allows retrying when %s', async (status) => { + const mock = this.mock(uploadService, 'retry') + const { getByTitle } = this.renderComponent(status) + + await fireEvent.click(getByTitle('Retry')) + + expect(mock).toHaveBeenCalled() + }) + + it.each<[UploadStatus]>([ + ['Uploaded'], + ['Errored'], + ['Canceled']] + )('allows removal if not uploading', async (status) => { + const mock = this.mock(uploadService, 'remove') + const { getByTitle } = this.renderComponent(status) + + await fireEvent.click(getByTitle('Remove')) + + expect(mock).toHaveBeenCalled() + }) + } +} diff --git a/resources/assets/js/components/ui/upload/UploadItem.vue b/resources/assets/js/components/ui/upload/UploadItem.vue new file mode 100644 index 00000000..65ce559e --- /dev/null +++ b/resources/assets/js/components/ui/upload/UploadItem.vue @@ -0,0 +1,96 @@ + ++ + + + + + + + + + + + + + + + +++ + + + + + + + + + + + + + + + + + +++ + + {{ file.name }} + ++ + + + + diff --git a/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap b/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap new file mode 100644 index 00000000..e425a947 --- /dev/null +++ b/resources/assets/js/components/ui/upload/__snapshots__/UploadItem.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = `+ ++ + + + ++ Sample Track`; diff --git a/resources/assets/js/components/user/UserAddForm.spec.ts b/resources/assets/js/components/user/UserAddForm.spec.ts new file mode 100644 index 00000000..deaeb10d --- /dev/null +++ b/resources/assets/js/components/user/UserAddForm.spec.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent, waitFor } from '@testing-library/vue' +import { userStore } from '@/stores' +import UserAddForm from './UserAddForm.vue' +import { MessageToasterStub } from '@/__tests__/stubs' + +new class extends UnitTestCase { + protected test () { + it('creates a new user', async () => { + const storeMock = this.mock(userStore, 'store') + const alertMock = this.mock(MessageToasterStub.value, 'success') + + const { getByLabelText, getByRole } = this.render(UserAddForm) + + await fireEvent.update(getByLabelText('Name'), 'John Doe') + await fireEvent.update(getByLabelText('Email'), 'john@doe.com') + await fireEvent.update(getByLabelText('Password'), 'secret-password') + await fireEvent.click(getByRole('checkbox')) + await fireEvent.click(getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(storeMock).toHaveBeenCalledWith({ + name: 'John Doe', + email: 'john@doe.com', + password: 'secret-password', + is_admin: true + }) + + expect(alertMock).toHaveBeenCalledWith('New user "John Doe" created.') + }) + }) + } +} diff --git a/resources/assets/js/components/user/UserAddForm.vue b/resources/assets/js/components/user/UserAddForm.vue new file mode 100644 index 00000000..ae79317c --- /dev/null +++ b/resources/assets/js/components/user/UserAddForm.vue @@ -0,0 +1,110 @@ + +++ + + + + diff --git a/resources/assets/js/components/user/UserBadge.spec.ts b/resources/assets/js/components/user/UserBadge.spec.ts new file mode 100644 index 00000000..682914b6 --- /dev/null +++ b/resources/assets/js/components/user/UserBadge.spec.ts @@ -0,0 +1,27 @@ +import UnitTestCase from '@/__tests__/UnitTestCase' +import factory from '@/__tests__/factory' +import { expect, it } from 'vitest' +import { eventBus } from '@/utils' +import { fireEvent } from '@testing-library/vue' +import UserBadge from './UserBadge.vue' + +new class extends UnitTestCase { + private renderComponent () { + return this.actingAs(factory+ + ('user', { + name: 'John Doe' + })).render(UserBadge) + } + + protected test () { + it('renders', () => expect(this.renderComponent().html()).toMatchSnapshot()) + + it('logs out', async () => { + const emitMock = this.mock(eventBus, 'emit') + const { getByTestId } = this.renderComponent() + + await fireEvent.click(getByTestId('btn-logout')) + + expect(emitMock).toHaveBeenCalledWith('LOG_OUT') + }) + } +} diff --git a/resources/assets/js/components/user/UserBadge.vue b/resources/assets/js/components/user/UserBadge.vue new file mode 100644 index 00000000..b3a7d3fa --- /dev/null +++ b/resources/assets/js/components/user/UserBadge.vue @@ -0,0 +1,78 @@ + + + + + {{ currentUser.name }} + + + + + + + + + + + diff --git a/resources/assets/js/components/user/UserCard.spec.ts b/resources/assets/js/components/user/UserCard.spec.ts new file mode 100644 index 00000000..c0dd5d5e --- /dev/null +++ b/resources/assets/js/components/user/UserCard.spec.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { fireEvent } from '@testing-library/vue' +import router from '@/router' +import { eventBus } from '@/utils' +import UserCard from './UserCard.vue' + +new class extends UnitTestCase { + private renderComponent (user: User) { + return this.render(UserCard, { + props: { + user + } + }) + } + + protected test () { + it('has different behaviors for current user', () => { + const user = factory ('user') + const { getByTitle, getByText } = this.actingAs(user).renderComponent(user) + + getByTitle('This is you!') + getByText('Your Profile') + }) + + it('edits user', async () => { + const user = factory ('user') + const emitMock = this.mock(eventBus, 'emit') + const { getByText } = this.renderComponent(user) + + await fireEvent.click(getByText('Edit')) + + expect(emitMock).toHaveBeenCalledWith('MODAL_SHOW_EDIT_USER_FORM', user) + }) + + it('redirects to Profile screen if edit current user', async () => { + const mock = this.mock(router, 'go') + const user = factory ('user') + const { getByText } = this.actingAs(user).renderComponent(user) + + await fireEvent.click(getByText('Your Profile')) + + expect(mock).toHaveBeenCalledWith('profile') + }) + } +} diff --git a/resources/assets/js/components/user/UserCard.vue b/resources/assets/js/components/user/UserCard.vue new file mode 100644 index 00000000..42253448 --- /dev/null +++ b/resources/assets/js/components/user/UserCard.vue @@ -0,0 +1,114 @@ + + + + + + + + + + diff --git a/resources/assets/js/components/user/UserEditForm.spec.ts b/resources/assets/js/components/user/UserEditForm.spec.ts new file mode 100644 index 00000000..5e486f7f --- /dev/null +++ b/resources/assets/js/components/user/UserEditForm.spec.ts @@ -0,0 +1,43 @@ +import { ref } from 'vue' +import { expect, it } from 'vitest' +import factory from '@/__tests__/factory' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { UserKey } from '@/symbols' +import { fireEvent, waitFor } from '@testing-library/vue' +import { userStore } from '@/stores' +import UserEditForm from './UserEditForm.vue' +import { MessageToasterStub } from '@/__tests__/stubs' + +new class extends UnitTestCase { + protected test () { + it('edits a user', async () => { + const updateMock = this.mock(userStore, 'update') + const alertMock = this.mock(MessageToasterStub.value, 'success') + + const user = ref(factory+ ++ {{ user.name }} +
+ ++ + {{ user.email }}
+ + +('user', { name: 'John Doe' })) + + const { getByLabelText, getByRole } = this.render(UserEditForm, { + global: { + provide: { + [UserKey]: [user] + } + } + }) + + await fireEvent.update(getByLabelText('Name'), 'Jane Doe') + await fireEvent.update(getByLabelText('Password'), 'new-password-duck') + await fireEvent.click(getByRole('button', { name: 'Update' })) + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledWith(user.value, { + name: 'Jane Doe', + email: user.value.email, + is_admin: user.value.is_admin, + password: 'new-password-duck' + }) + + expect(alertMock).toHaveBeenCalledWith('User profile updated.') + }) + }) + } +} diff --git a/resources/assets/js/components/user/UserEditForm.vue b/resources/assets/js/components/user/UserEditForm.vue new file mode 100644 index 00000000..e9786e41 --- /dev/null +++ b/resources/assets/js/components/user/UserEditForm.vue @@ -0,0 +1,114 @@ + + ++ + + + + diff --git a/resources/assets/js/components/user/__snapshots__/UserBadge.spec.ts.snap b/resources/assets/js/components/user/__snapshots__/UserBadge.spec.ts.snap new file mode 100644 index 00000000..a7586f99 --- /dev/null +++ b/resources/assets/js/components/user/__snapshots__/UserBadge.spec.ts.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1 + +exports[`renders 1`] = `John Doe+ +
`; diff --git a/resources/assets/js/components/utils/EventListeners.vue b/resources/assets/js/components/utils/EventListeners.vue new file mode 100644 index 00000000..e8358e97 --- /dev/null +++ b/resources/assets/js/components/utils/EventListeners.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/resources/assets/js/components/utils/HotkeyListener.vue b/resources/assets/js/components/utils/HotkeyListener.vue new file mode 100644 index 00000000..8aad2484 --- /dev/null +++ b/resources/assets/js/components/utils/HotkeyListener.vue @@ -0,0 +1,97 @@ + ++ + + diff --git a/resources/assets/js/composables/index.ts b/resources/assets/js/composables/index.ts new file mode 100644 index 00000000..99413290 --- /dev/null +++ b/resources/assets/js/composables/index.ts @@ -0,0 +1,7 @@ +export * from './useInfiniteScroll' +export * from './useSongList' +export * from './useSongMenuMethods' +export * from './useContextMenu' +export * from './useAuthorization' +export * from './useNewVersionNotification' +export * from './useThirdPartyServices' diff --git a/resources/assets/js/composables/useAuthorization.ts b/resources/assets/js/composables/useAuthorization.ts new file mode 100644 index 00000000..b517a406 --- /dev/null +++ b/resources/assets/js/composables/useAuthorization.ts @@ -0,0 +1,9 @@ +import { computed, toRef } from 'vue' +import { userStore } from '@/stores' + +export const useAuthorization = () => { + const currentUser = toRef(userStore.state, 'current') + const isAdmin = computed(() => currentUser.value?.is_admin) + + return { currentUser, isAdmin } +} diff --git a/resources/assets/js/composables/useContextMenu.ts b/resources/assets/js/composables/useContextMenu.ts new file mode 100644 index 00000000..23c09e26 --- /dev/null +++ b/resources/assets/js/composables/useContextMenu.ts @@ -0,0 +1,31 @@ +import { reactive, ref } from 'vue' +import ContextMenuBase from '@/components/ui/ContextMenuBase.vue' + +export type ContextMenuContext = Record + +export const useContextMenu = () => { + const base = ref >() + + const context = reactive ({}) + + const open = async (top: number, left: number, ctx: ContextMenuContext = {}) => { + Object.assign(context, ctx) + await base.value?.open(top, left, ctx) + } + + const close = () => base.value?.close() + + const trigger = (func: Closure) => { + close() + func() + } + + return { + ContextMenuBase, + base, + context, + open, + close, + trigger + } +} diff --git a/resources/assets/js/composables/useInfiniteScroll.ts b/resources/assets/js/composables/useInfiniteScroll.ts new file mode 100644 index 00000000..a1ec6505 --- /dev/null +++ b/resources/assets/js/composables/useInfiniteScroll.ts @@ -0,0 +1,39 @@ +import { ref } from 'vue' +import ToTopButton from '@/components/ui/BtnScrollToTop.vue' + +export const useInfiniteScroll = (loadMore: Closure) => { + const scroller = ref () + + const scrolling = ({ target }: { target: HTMLElement }) => { + // Here we check if the user has scrolled to the end of the wrapper (or 32px to the end). + // If that's true, load more items. + if (target.scrollTop + target.clientHeight >= target.scrollHeight - 32) { + loadMore() + } + } + + let tries = 0 + const MAX_TRIES = 5 + + const makeScrollable = async () => { + const container = scroller.value + + if (!container) { + window.setTimeout(() => makeScrollable(), 200) + return + } + + if (container.scrollHeight <= container.clientHeight && tries < MAX_TRIES) { + tries++ + await loadMore() + window.setTimeout(() => makeScrollable(), 200) + } + } + + return { + ToTopButton, + scroller, + scrolling, + makeScrollable + } +} diff --git a/resources/assets/js/composables/useNewVersionNotification.ts b/resources/assets/js/composables/useNewVersionNotification.ts new file mode 100644 index 00000000..d4de1f68 --- /dev/null +++ b/resources/assets/js/composables/useNewVersionNotification.ts @@ -0,0 +1,25 @@ +import compareVersions from 'compare-versions' +import { computed, toRef } from 'vue' +import { commonStore } from '@/stores' +import { useAuthorization } from '@/composables/useAuthorization' + +export const useNewVersionNotification = () => { + const { isAdmin } = useAuthorization() + + const latestVersion = toRef(commonStore.state, 'latest_version') + const currentVersion = toRef(commonStore.state, 'current_version') + + const hasNewVersion = computed(() => compareVersions.compare(latestVersion.value, currentVersion.value, '>')) + const shouldNotifyNewVersion = computed(() => isAdmin.value && hasNewVersion.value) + + const latestVersionReleaseUrl = computed(() => { + return `https://github.com/koel/koel/releases/tag/${latestVersion.value}` + }) + + return { + shouldNotifyNewVersion, + currentVersion, + latestVersion, + latestVersionReleaseUrl + } +} diff --git a/resources/assets/js/composables/useSongList.ts b/resources/assets/js/composables/useSongList.ts new file mode 100644 index 00000000..4881452b --- /dev/null +++ b/resources/assets/js/composables/useSongList.ts @@ -0,0 +1,128 @@ +import { orderBy, sampleSize, take } from 'lodash' +import isMobile from 'ismobilejs' +import { computed, reactive, Ref, ref } from 'vue' +import { playbackService } from '@/services' +import { queueStore, songStore } from '@/stores' +import router from '@/router' + +import { + SelectedSongsKey, + SongListConfigKey, + SongListSortFieldKey, + SongListSortOrderKey, + SongListTypeKey, + SongsKey +} from '@/symbols' + +import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue' +import SongList from '@/components/song/SongList.vue' +import SongListControls from '@/components/song/SongListControls.vue' +import ThumbnailStack from '@/components/ui/ThumbnailStack.vue' +import { provideReadonly } from '@/utils' + +export const useSongList = ( + songs: Ref , + type: SongListType, + config: Partial = {} +) => { + const songList = ref >() + + const isPhone = isMobile.phone + const selectedSongs = ref ([]) + const showingControls = ref(false) + const headerLayout = ref ('expanded') + + const onScrollBreakpoint = (direction: 'up' | 'down') => { + headerLayout.value = direction === 'down' ? 'collapsed' : 'expanded' + } + + const duration = computed(() => songStore.getFormattedLength(songs.value)) + + const thumbnails = computed(() => { + const songsWithCover = songs.value.filter(song => song.album_cover) + const sampleCovers = sampleSize(songsWithCover, 20).map(song => song.album_cover) + return take(Array.from(new Set(sampleCovers)), 4) + }) + + const getSongsToPlay = (): Song[] => songList.value.getAllSongsWithSort() + const playAll = (shuffle: boolean) => playbackService.queueAndPlay(getSongsToPlay(), shuffle) + const playSelected = (shuffle: boolean) => playbackService.queueAndPlay(selectedSongs.value, shuffle) + const toggleControls = () => (showingControls.value = !showingControls.value) + + const onPressEnter = async (event: KeyboardEvent) => { + if (selectedSongs.value.length === 1) { + queueStore.queueIfNotQueued(selectedSongs.value[0]) + await playbackService.play(selectedSongs.value[0]) + return + } + + // • Only Enter: Queue songs to bottom + // • Shift+Enter: Queues song to top + // • Cmd/Ctrl+Enter: Queues song to bottom and play the first selected song + // • Cmd/Ctrl+Shift+Enter: Queue songs to top and play the first queued song + event.shiftKey ? queueStore.queueToTop(selectedSongs.value) : queueStore.queue(selectedSongs.value) + + if (event.ctrlKey || event.metaKey) { + await playbackService.play(selectedSongs.value[0]) + } + + router.go('/queue') + } + + const sortField = ref (((): SongListSortField | null => { + if (type === 'album' || type === 'artist') return 'track' + if (type === 'search-results') return null + return config.sortable ? 'title' : null + })()) + + const sortOrder = ref ('asc') + + const sort = (by: SongListSortField | null = sortField.value, order: SortOrder = sortOrder.value) => { + if (!by) return + + sortField.value = by + sortOrder.value = order + + let sortFields: SongListSortField[] = [by] + + if (by === 'track') { + sortFields.push('disc', 'title') + } else if (by === 'album_name') { + sortFields.push('artist_name', 'track', 'disc', 'title') + } else if (by === 'artist_name') { + sortFields.push('album_name', 'track', 'disc', 'title') + } + + songs.value = orderBy(songs.value, sortFields, order) + } + + provideReadonly(SongListTypeKey, type) + provideReadonly(SongsKey, songs, false) + provideReadonly(SelectedSongsKey, selectedSongs, false) + provideReadonly(SongListConfigKey, reactive(config)) + provideReadonly(SongListSortFieldKey, sortField) + provideReadonly(SongListSortOrderKey, sortOrder) + + return { + SongList, + SongListControls, + ControlsToggle, + ThumbnailStack, + songs, + headerLayout, + sortField, + sortOrder, + duration, + thumbnails, + songList, + selectedSongs, + showingControls, + isPhone, + onPressEnter, + playAll, + playSelected, + toggleControls, + onScrollBreakpoint, + sort + } +} diff --git a/resources/assets/js/composables/useSongMenuMethods.ts b/resources/assets/js/composables/useSongMenuMethods.ts new file mode 100644 index 00000000..e277093a --- /dev/null +++ b/resources/assets/js/composables/useSongMenuMethods.ts @@ -0,0 +1,48 @@ +import { Ref } from 'vue' +import { favoriteStore, playlistStore, queueStore } from '@/stores' +import { pluralize, requireInjection } from '@/utils' +import { DialogBoxKey, MessageToasterKey } from '@/symbols' + +export const useSongMenuMethods = (songs: Ref , close: Closure) => { + const toaster = requireInjection(MessageToasterKey) + const dialog = requireInjection(DialogBoxKey) + + const queueSongsAfterCurrent = () => { + close() + queueStore.queueAfterCurrent(songs.value) + } + + const queueSongsToBottom = () => { + close() + queueStore.queue(songs.value) + } + + const queueSongsToTop = () => { + close() + queueStore.queueToTop(songs.value) + } + + const addSongsToFavorite = async () => { + close() + await favoriteStore.like(songs.value) + } + + const addSongsToExistingPlaylist = async (playlist: Playlist) => { + close() + + try { + await playlistStore.addSongs(playlist, songs.value) + toaster.value.success(`Added ${pluralize(songs.value.length, 'song')} into "${playlist.name}."`) + } catch (error) { + dialog.value.error('Something went wrong. Please try again.', 'Error') + } + } + + return { + queueSongsAfterCurrent, + queueSongsToBottom, + queueSongsToTop, + addSongsToFavorite, + addSongsToExistingPlaylist + } +} diff --git a/resources/assets/js/composables/useThirdPartyServices.ts b/resources/assets/js/composables/useThirdPartyServices.ts new file mode 100644 index 00000000..648089e2 --- /dev/null +++ b/resources/assets/js/composables/useThirdPartyServices.ts @@ -0,0 +1,14 @@ +import { toRef } from 'vue' +import { commonStore } from '@/stores' + +export const useThirdPartyServices = () => { + const useLastfm = toRef(commonStore.state, 'use_last_fm') + const useYouTube = toRef(commonStore.state, 'use_you_tube') + const useAppleMusic = toRef(commonStore.state, 'use_i_tunes') + + return { + useLastfm, + useYouTube, + useAppleMusic + } +} diff --git a/resources/assets/js/config/acceptedMediaTypes.ts b/resources/assets/js/config/acceptedMediaTypes.ts new file mode 100644 index 00000000..ec0baffd --- /dev/null +++ b/resources/assets/js/config/acceptedMediaTypes.ts @@ -0,0 +1,8 @@ +export const acceptedMediaTypes = [ + 'audio/flac', + 'audio/mp3', + 'audio/mpeg', + 'audio/ogg', + 'audio/x-flac', + 'audio/x-aac' +] diff --git a/resources/assets/js/config/events.ts b/resources/assets/js/config/events.ts new file mode 100644 index 00000000..ba5b004c --- /dev/null +++ b/resources/assets/js/config/events.ts @@ -0,0 +1,44 @@ +export type EventName = + 'KOEL_READY' + | 'LOAD_MAIN_CONTENT' + | 'LOG_OUT' + | 'TOGGLE_SIDEBAR' + | 'SHOW_OVERLAY' + | 'HIDE_OVERLAY' + | 'FOCUS_SEARCH_FIELD' + | 'PLAY_YOUTUBE_VIDEO' + | 'INIT_EQUALIZER' + | 'TOGGLE_VISUALIZER' + | 'SEARCH_KEYWORDS_CHANGED' + | 'SONG_CONTEXT_MENU_REQUESTED' + | 'ALBUM_CONTEXT_MENU_REQUESTED' + | 'ARTIST_CONTEXT_MENU_REQUESTED' + | 'CONTEXT_MENU_OPENED' + | 'MODAL_SHOW_CREATE_SMART_PLAYLIST_FORM' + | 'MODAL_SHOW_EDIT_SMART_PLAYLIST_FORM' + | 'MODAL_SHOW_ADD_USER_FORM' + | 'MODAL_SHOW_EDIT_USER_FORM' + | 'MODAL_SHOW_EDIT_SONG_FORM' + | 'MODAL_SHOW_ABOUT_KOEL' + | 'PLAYLIST_DELETE' + | 'SMART_PLAYLIST_UPDATED' + | 'SONG_STARTED' + | 'SONGS_UPDATED' + | 'SONG_QUEUED_FROM_ROUTE' + + // upload-related + | 'SONG_UPLOADED' + | 'UPLOAD_QUEUE_FINISHED' + + // socket events + | 'SOCKET_TOGGLE_PLAYBACK' + | 'SOCKET_TOGGLE_FAVORITE' + | 'SOCKET_PLAY_NEXT' + | 'SOCKET_PLAY_PREV' + | 'SOCKET_PLAYBACK_STOPPED' + | 'SOCKET_GET_STATUS' + | 'SOCKET_STATUS' + | 'SOCKET_GET_CURRENT_SONG' + | 'SOCKET_SONG' + | 'SOCKET_SET_VOLUME' + | 'SOCKET_VOLUME_CHANGED' diff --git a/resources/assets/js/config/index.ts b/resources/assets/js/config/index.ts new file mode 100644 index 00000000..cfdf7862 --- /dev/null +++ b/resources/assets/js/config/index.ts @@ -0,0 +1,3 @@ +export * from './events' +export * from './upload.types' +export * from './acceptedMediaTypes' diff --git a/resources/assets/js/config/smart-playlist/inputTypes.ts b/resources/assets/js/config/smart-playlist/inputTypes.ts new file mode 100644 index 00000000..222ea966 --- /dev/null +++ b/resources/assets/js/config/smart-playlist/inputTypes.ts @@ -0,0 +1,21 @@ +import { + is, + isNot, + contains, + notContain, + beginsWith, + endsWith, + isBetween, + isGreaterThan, + isLessThan, + inLast, + notInLast +} from '@/config/smart-playlist/operators' + +const inputTypes: SmartPlaylistInputTypes = { + text: [is, isNot, contains, notContain, beginsWith, endsWith], + number: [is, isNot, isGreaterThan, isLessThan, isBetween], + date: [is, isNot, inLast, notInLast, isBetween] +} + +export default inputTypes diff --git a/resources/assets/js/config/smart-playlist/models.ts b/resources/assets/js/config/smart-playlist/models.ts new file mode 100644 index 00000000..eb56fc26 --- /dev/null +++ b/resources/assets/js/config/smart-playlist/models.ts @@ -0,0 +1,47 @@ +const models: SmartPlaylistModel[] = [ + { + name: 'title', + type: 'text', + label: 'Title' + }, { + name: 'album.name', + type: 'text', + label: 'Album' + }, { + name: 'artist.name', + type: 'text', + label: 'Artist' + // }, { + // name: 'genre', + // type: 'text', + // label: 'Genre' + }, { + // name: 'bit_rate', + // type: 'number', + // label: 'Bit Rate', + // unit: 'kbps' + // }, { + name: 'interactions.play_count', + type: 'number', + label: 'Plays' + }, { + name: 'interactions.updated_at', + type: 'date', + label: 'Last Played' + }, { + name: 'length', + type: 'number', + label: 'Length', + unit: 'seconds' + }, { + name: 'created_at', + type: 'date', + label: 'Date Added' + }, { + name: 'updated_at', + type: 'date', + label: 'Date Modified' + } +] + +export default models diff --git a/resources/assets/js/config/smart-playlist/operators.ts b/resources/assets/js/config/smart-playlist/operators.ts new file mode 100644 index 00000000..d775a871 --- /dev/null +++ b/resources/assets/js/config/smart-playlist/operators.ts @@ -0,0 +1,63 @@ +export const is: SmartPlaylistOperator = { + operator: 'is', + label: 'is' +} + +export const isNot: SmartPlaylistOperator = { + operator: 'isNot', + label: 'is not' +} + +export const contains: SmartPlaylistOperator = { + operator: 'contains', + label: 'contains' +} + +export const notContain: SmartPlaylistOperator = { + operator: 'notContain', + label: 'does not contain' +} + +export const isBetween: SmartPlaylistOperator = { + operator: 'isBetween', + label: 'is between', + inputs: 2 +} + +export const isGreaterThan: SmartPlaylistOperator = { + operator: 'isGreaterThan', + label: 'is greater than' +} + +export const isLessThan: SmartPlaylistOperator = { + operator: 'isLessThan', + label: 'is less than' +} + +export const beginsWith: SmartPlaylistOperator = { + operator: 'beginsWith', + label: 'begins with' +} + +export const endsWith: SmartPlaylistOperator = { + operator: 'endsWith', + label: 'ends with' +} + +export const inLast: SmartPlaylistOperator = { + operator: 'inLast', + label: 'in the last', + type: 'number', // overriding + unit: 'days' +} + +export const notInLast: SmartPlaylistOperator = { + operator: 'notInLast', + label: 'not in the last', + type: 'number', // overriding + unit: 'days' +} + +export default [ + is, isNot, contains, notContain, isBetween, isGreaterThan, isLessThan, beginsWith, endsWith, inLast, notInLast +] diff --git a/resources/assets/js/config/upload.types.ts b/resources/assets/js/config/upload.types.ts new file mode 100644 index 00000000..5b04b1e2 --- /dev/null +++ b/resources/assets/js/config/upload.types.ts @@ -0,0 +1,15 @@ +export type UploadStatus = + | 'Ready' + | 'Uploading' + | 'Uploaded' + | 'Canceled' + | 'Errored' + +export interface UploadFile { + id: string + file: File + status: UploadStatus + name: string + progress: number + message?: string +} diff --git a/resources/assets/js/directives/clickaway.ts b/resources/assets/js/directives/clickaway.ts new file mode 100644 index 00000000..5988c103 --- /dev/null +++ b/resources/assets/js/directives/clickaway.ts @@ -0,0 +1,7 @@ +import { Directive } from 'vue' + +export const clickaway: Directive = { + created (el: HTMLElement, binding) { + document.addEventListener('click', (e: MouseEvent) => el.contains(e.target as Node) || binding.value()) + } +} diff --git a/resources/assets/js/directives/droppable.ts b/resources/assets/js/directives/droppable.ts new file mode 100644 index 00000000..3396e628 --- /dev/null +++ b/resources/assets/js/directives/droppable.ts @@ -0,0 +1,23 @@ +import { Directive } from 'vue' + +export const droppable: Directive = { + created: (el: HTMLElement, binding) => { + el.addEventListener('dragenter', (event: DragEvent) => { + event.preventDefault() + el.classList.add('droppable') + event.dataTransfer!.dropEffect = 'move' + + return false + }) + + el.addEventListener('dragover', (event: DragEvent) => event.preventDefault()) + el.addEventListener('dragleave', () => el.classList.remove('droppable')) + + el.addEventListener('drop', (event: DragEvent) => { + event.preventDefault() + event.stopPropagation() + el.classList.remove('droppable') + binding.value(event) + }) + } +} diff --git a/resources/assets/js/directives/focus.ts b/resources/assets/js/directives/focus.ts new file mode 100644 index 00000000..9e82424d --- /dev/null +++ b/resources/assets/js/directives/focus.ts @@ -0,0 +1,8 @@ +import { Directive } from 'vue' + +/** + * A simple directive to set focus into an input field when it's shown. + */ +export const focus: Directive = { + mounted: (el: HTMLElement) => el.focus() +} diff --git a/resources/assets/js/directives/index.ts b/resources/assets/js/directives/index.ts new file mode 100644 index 00000000..500f6e72 --- /dev/null +++ b/resources/assets/js/directives/index.ts @@ -0,0 +1,3 @@ +export * from './droppable' +export * from './clickaway' +export * from './focus' diff --git a/resources/assets/js/env.d.ts b/resources/assets/js/env.d.ts new file mode 100644 index 00000000..13f15417 --- /dev/null +++ b/resources/assets/js/env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_KOEL_ENV: 'demo' | undefined +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/resources/assets/js/remote/App.vue b/resources/assets/js/remote/App.vue new file mode 100644 index 00000000..8baa3521 --- /dev/null +++ b/resources/assets/js/remote/App.vue @@ -0,0 +1,453 @@ + + + ++ + + + + diff --git a/resources/assets/js/remote/app.ts b/resources/assets/js/remote/app.ts new file mode 100644 index 00000000..b4d98f57 --- /dev/null +++ b/resources/assets/js/remote/app.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { clickaway } from '@/directives' +import App from './App.vue' + +createApp(App) + .component('icon', FontAwesomeIcon) + .directive('koel-clickaway', clickaway) + .mount('#app') diff --git a/resources/assets/js/router.ts b/resources/assets/js/router.ts new file mode 100644 index 00000000..a774df32 --- /dev/null +++ b/resources/assets/js/router.ts @@ -0,0 +1,73 @@ +import { eventBus, loadMainView, use } from '@/utils' +import { playlistStore, userStore } from '@/stores' + +class Router { + routes: Record+ + + + + + ++ ++++++{{ song.title }}
+{{ song.artist_name }}
+{{ song.album_name }}
+No song is playing.
+ + +++++Searching for Koel…
+ ++ No active Koel instance found. + Rescan +
++++ + + constructor () { + this.routes = { + '/home': () => loadMainView('Home'), + '/queue': () => loadMainView('Queue'), + '/songs': () => loadMainView('Songs'), + '/albums': () => loadMainView('Albums'), + '/artists': () => loadMainView('Artists'), + '/favorites': () => loadMainView('Favorites'), + '/recently-played': () => loadMainView('RecentlyPlayed'), + '/search': () => loadMainView('Search.Excerpt'), + '/search/songs/(.+)': (q: string) => loadMainView('Search.Songs', q), + '/upload': () => userStore.current.is_admin && loadMainView('Upload'), + '/settings': () => userStore.current.is_admin && loadMainView('Settings'), + '/users': () => userStore.current.is_admin && loadMainView('Users'), + '/youtube': () => loadMainView('YouTube'), + '/visualizer': () => loadMainView('Visualizer'), + '/profile': () => loadMainView('Profile'), + '/album/(\\d+)': (id: string) => loadMainView('Album', parseInt(id)), + '/artist/(\\d+)': (id: string) => loadMainView('Artist', parseInt(id)), + '/playlist/(\\d+)': (id: number) => use(playlistStore.byId(~~id), playlist => loadMainView('Playlist', playlist)), + '/song/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})': (id: string) => { + eventBus.emit('SONG_QUEUED_FROM_ROUTE', id) + loadMainView('Queue') + } + } + + window.addEventListener('popstate', () => this.resolveRoute(), true) + } + + public resolveRoute () { + if (!window.location.hash) { + return this.go('home') + } + + Object.keys(this.routes).forEach(route => { + const matches = window.location.hash.match(new RegExp(`^#!${route}$`)) + + if (matches) { + const [, ...params] = matches + this.routes[route](...params) + } + }) + } + + /** + * Navigate to a (relative, hash-bang'ed) path. + */ + public go (path: string | number) { + if (typeof path === 'number') { + window.history.go(path) + return + } + + if (!path.startsWith('/')) { + path = `/${path}` + } + + if (!path.startsWith('/#!')) { + path = `/#!${path}` + } + + path = path.substring(1, path.length) + document.location.href = `${document.location.origin}${document.location.pathname}${path}` + } +} + +export default new Router() diff --git a/resources/assets/js/services/audioService.ts b/resources/assets/js/services/audioService.ts new file mode 100644 index 00000000..2c03b604 --- /dev/null +++ b/resources/assets/js/services/audioService.ts @@ -0,0 +1,29 @@ +export const audioService = { + context: null as unknown as AudioContext, + source: null as unknown as MediaElementAudioSourceNode, + element: null as unknown as HTMLMediaElement, + + init (element: HTMLMediaElement) { + const AudioContext = window.AudioContext || + window.webkitAudioContext || + window.mozAudioContext || + window.oAudioContext || + window.msAudioContext + + this.context = new AudioContext() + this.source = this.context.createMediaElementSource(element) + this.element = element + }, + + getContext () { + return this.context + }, + + getSource () { + return this.source + }, + + getElement () { + return this.element + } +} diff --git a/resources/assets/js/services/authService.spec.ts b/resources/assets/js/services/authService.spec.ts new file mode 100644 index 00000000..94f02c30 --- /dev/null +++ b/resources/assets/js/services/authService.spec.ts @@ -0,0 +1,31 @@ +import UnitTestCase from '@/__tests__/UnitTestCase' +import { localStorageService } from '@/services/localStorageService' +import { authService } from '@/services/authService' +import { expect, it } from 'vitest' + +new class extends UnitTestCase { + protected test () { + it('gets the token', () => { + const mock = this.mock(localStorageService, 'get') + authService.getToken() + expect(mock).toHaveBeenCalledWith('api-token') + }) + + it.each([['foo', true], [null, false]])('checks if the token exists', (token, exists) => { + this.mock(localStorageService, 'get', token) + expect(authService.hasToken()).toBe(exists) + }) + + it('sets the token', () => { + const mock = this.mock(localStorageService, 'set') + authService.setToken('foo') + expect(mock).toHaveBeenCalledWith('api-token', 'foo') + }) + + it('destroys the token', () => { + const mock = this.mock(localStorageService, 'remove') + authService.destroy() + expect(mock).toHaveBeenCalledWith('api-token') + }) + } +} diff --git a/resources/assets/js/services/authService.ts b/resources/assets/js/services/authService.ts new file mode 100644 index 00000000..77863aec --- /dev/null +++ b/resources/assets/js/services/authService.ts @@ -0,0 +1,14 @@ +import { localStorageService } from '@/services' + +const STORAGE_KEY = 'api-token' + +export const authService = { + getToken: () => localStorageService.get (STORAGE_KEY), + + hasToken () { + return Boolean(this.getToken()) + }, + + setToken: (token: string) => localStorageService.set(STORAGE_KEY, token), + destroy: () => localStorageService.remove(STORAGE_KEY) +} diff --git a/resources/assets/js/services/cache.spec.ts b/resources/assets/js/services/cache.spec.ts new file mode 100644 index 00000000..90687e56 --- /dev/null +++ b/resources/assets/js/services/cache.spec.ts @@ -0,0 +1,57 @@ +import { expect, it, vi } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { Cache } from '@/services/cache' + +let cache: Cache + +new class extends UnitTestCase { + protected beforeEach () { + super.beforeEach(() => cache = new Cache()) + } + + protected afterEach () { + super.afterEach(() => vi.useRealTimers()) + } + + protected test () { + it('sets and gets a value', () => { + cache.set('foo', 'bar') + expect(cache.get('foo')).toBe('bar') + }) + + it('invalidates an entry after set time', () => { + vi.useFakeTimers() + cache.set('foo', 'bar', 999) + expect(cache.has('foo')).toBe(true) + + vi.advanceTimersByTime(1000 * 1000) + expect(cache.has('foo')).toBe(false) + }) + + it('removes an entry', () => { + cache.set('foo', 'bar') + cache.remove('foo') + expect(cache.get('foo')).toBeUndefined() + }) + + it('checks an entry\'s presence', () => { + cache.set('foo', 'bar') + expect(cache.hit('foo')).toBe(true) + expect(cache.has('foo')).toBe(true) + expect(cache.miss('foo')).toBe(false) + + cache.remove('foo') + expect(cache.hit('foo')).toBe(false) + expect(cache.has('foo')).toBe(false) + expect(cache.miss('foo')).toBe(true) + }) + + it('remembers a value', async () => { + const resolver = vi.fn().mockResolvedValue('bar') + expect(cache.has('foo')).toBe(false) + + expect(await cache.remember('foo', resolver)).toBe('bar') + expect(cache.get('foo')).toBe('bar') + }) + } +} diff --git a/resources/assets/js/services/cache.ts b/resources/assets/js/services/cache.ts new file mode 100644 index 00000000..c7ee7d32 --- /dev/null +++ b/resources/assets/js/services/cache.ts @@ -0,0 +1,58 @@ +const DEFAULT_EXPIRATION_TIME = 1000 * 60 * 60 * 24 // 1 day + +export class Cache { + private storage = new Map () + + private static normalizeKey (key: any) { + return typeof key === 'object' ? JSON.stringify(key) : key + } + + public has (key: any) { + return this.hit(Cache.normalizeKey(key)) + } + + public get (key: any) { + return this.storage.get(Cache.normalizeKey(key))?.value as T + } + + public set (key: any, value: any, seconds: number = DEFAULT_EXPIRATION_TIME) { + this.storage.set(Cache.normalizeKey(key), { + value, + expires: Date.now() + seconds * 1000 + }) + } + + public hit (key: any) { + return !this.miss(Cache.normalizeKey(key)) + } + + public miss (key: any) { + key = Cache.normalizeKey(key) + + if (!this.storage.has(key)) return true + const { expires } = this.storage.get(key)! + + if (expires < Date.now()) { + this.storage.delete(key) + return true + } + + return false + } + + public remove (key: any) { + this.storage.delete(Cache.normalizeKey(key)) + } + + async remember (key: any, resolver: Closure, seconds: number = DEFAULT_EXPIRATION_TIME) { + key = Cache.normalizeKey(key) + + this.hit(key) || this.set(key, await resolver(), seconds) + return this.get (key) + } +} + +export const cache = new Cache() diff --git a/resources/assets/js/services/downloadService.spec.ts b/resources/assets/js/services/downloadService.spec.ts new file mode 100644 index 00000000..321dc4b0 --- /dev/null +++ b/resources/assets/js/services/downloadService.spec.ts @@ -0,0 +1,50 @@ +import { favoriteStore } from '@/stores' +import factory from '@/__tests__/factory' +import { expect, it } from 'vitest' +import UnitTestCase from '@/__tests__/UnitTestCase' +import { downloadService } from './downloadService' + +new class extends UnitTestCase { + protected test () { + it('downloads songs', () => { + const mock = this.mock(downloadService, 'trigger') + downloadService.fromSongs([factory ('song', { id: 'foo' }), factory ('song', { id: 'bar' })]) + + expect(mock).toHaveBeenCalledWith('songs?songs[]=bar&songs[]=foo&') + }) + + it('downloads all by artist', () => { + const mock = this.mock(downloadService, 'trigger') + downloadService.fromArtist(factory ('artist', { id: 42 })) + + expect(mock).toHaveBeenCalledWith('artist/42') + }) + + it('downloads all in album', () => { + const mock = this.mock(downloadService, 'trigger') + downloadService.fromAlbum(factory ('album', { id: 42 })) + + expect(mock).toHaveBeenCalledWith('album/42') + }) + + it('downloads a playlist', () => { + const mock = this.mock(downloadService, 'trigger') + const playlist = factory ('playlist', { id: 42 }) + + downloadService.fromPlaylist(playlist) + + expect(mock).toHaveBeenCalledWith('playlist/42') + }) + + it.each<[Song[], boolean]>([[[], false], [factory ('song', 5), true]])( + 'downloads favorites if available', + (songs, triggered) => { + const mock = this.mock(downloadService, 'trigger') + favoriteStore.all = songs + + downloadService.fromFavorites() + + triggered ? expect(mock).toHaveBeenCalledWith('favorites') : expect(mock).not.toHaveBeenCalled() + }) + } +} diff --git a/resources/assets/js/services/downloadService.ts b/resources/assets/js/services/downloadService.ts new file mode 100644 index 00000000..981fb8a0 --- /dev/null +++ b/resources/assets/js/services/downloadService.ts @@ -0,0 +1,44 @@ +import { favoriteStore } from '@/stores' +import { authService } from '@/services' +import { arrayify } from '@/utils' + +export const downloadService = { + fromSongs (songs: Song | Song[]) { + const query = arrayify(songs).reduce((q, song) => `songs[]=${song.id}&${q}`, '') + this.trigger(`songs?${query}`) + }, + + fromAlbum (album: Album) { + this.trigger(`album/${album.id}`) + }, + + fromArtist (artist: Artist) { + this.trigger(`artist/${artist.id}`) + }, + + fromPlaylist (playlist: Playlist) { + this.trigger(`playlist/${playlist.id}`) + }, + + fromFavorites () { + if (favoriteStore.all.length) { + this.trigger('favorites') + } + }, + + /** + * Build a download link using a segment and trigger it. + * + * @param {string} uri The uri segment, corresponding to the song(s), + * artist, playlist, or album. + */ + trigger: (uri: string) => { + const sep = uri.includes('?') ? '&' : '?' + const url = `${window.BASE_URL}download/${uri}${sep}api_token=${authService.getToken()}` + + const iframe = document.createElement('iframe') + iframe.style.display = 'none' + iframe.setAttribute('src', url) + document.body.appendChild(iframe) + } +} diff --git a/resources/assets/js/services/httpService.ts b/resources/assets/js/services/httpService.ts new file mode 100644 index 00000000..d7bc33e2 --- /dev/null +++ b/resources/assets/js/services/httpService.ts @@ -0,0 +1,83 @@ +import Axios, { AxiosInstance, Method } from 'axios' +import NProgress from 'nprogress' +import { eventBus } from '@/utils' +import { authService } from '@/services' + +class Http { + client: AxiosInstance + + private static setProgressBar () { + NProgress.start() + } + + private static hideProgressBar () { + NProgress.done(true) + } + + public request (method: Method, url: string, data: Record