From 52dd323c968cfbbe18070d11a89cc623794cbaa9 Mon Sep 17 00:00:00 2001 From: Phan An Date: Wed, 10 Aug 2022 16:56:01 +0200 Subject: [PATCH] feat: support playlist folders (closes #1476) --- .../Controllers/API/PlaylistController.php | 4 +- .../API/PlaylistSongController.php | 6 +- .../Download/PlaylistController.php | 2 +- .../Controllers/V6/API/DataController.php | 7 +- .../Controllers/V6/API/PlaylistController.php | 60 +++++++ .../V6/API/PlaylistFolderController.php | 41 +++++ .../API/PlaylistFolderPlaylistController.php | 35 ++++ .../V6/API/PlaylistSongController.php | 10 +- .../V6/Requests/AddSongsToPlaylistRequest.php | 4 +- .../PlaylistFolderPlaylistDestroyRequest.php | 23 +++ .../PlaylistFolderPlaylistStoreRequest.php | 23 +++ .../Requests/PlaylistFolderStoreRequest.php | 19 +++ .../Requests/PlaylistFolderUpdateRequest.php | 19 +++ .../Requests/API/PlaylistUpdateRequest.php | 4 + app/Http/Resources/PlaylistFolderResource.php | 26 +++ app/Http/Resources/PlaylistResource.php | 29 ++++ app/Models/Playlist.php | 19 ++- app/Models/PlaylistFolder.php | 43 +++++ app/Models/User.php | 8 + app/Policies/PlaylistFolderPolicy.php | 14 ++ app/Policies/PlaylistPolicy.php | 2 +- app/Providers/AuthServiceProvider.php | 6 +- app/Repositories/PlaylistFolderRepository.php | 10 ++ app/Rules/AllPlaylistsBelongToUser.php | 25 +++ app/Services/PlaylistFolderService.php | 32 ++++ app/Services/PlaylistService.php | 10 ++ database/factories/PlaylistFolderFactory.php | 21 +++ ..._08_10_075423_support_playlist_folders.php | 27 +++ resources/assets/js/App.vue | 3 + .../assets/js/components/album/AlbumCard.vue | 2 +- .../js/components/album/AlbumContextMenu.vue | 2 +- .../assets/js/components/album/AlbumInfo.vue | 2 +- .../js/components/artist/ArtistCard.vue | 2 +- .../js/components/artist/ArtistInfo.vue | 2 +- .../js/components/layout/ModalWrapper.vue | 42 +++-- .../layout/app-footer/FooterMiddlePane.vue | 4 +- .../layout/main-wrapper/Sidebar.vue | 67 ++++---- .../js/components/meta/AboutKoelModal.spec.ts | 1 + .../js/components/meta/AboutKoelModal.vue | 2 +- .../playlist/CreateNewPlaylistContextMenu.vue | 21 ++- .../playlist/CreatePlaylistFolderForm.vue | 72 ++++++++ .../playlist/CreatePlaylistForm.vue | 72 ++++++++ .../playlist/EditPlaylistFolderForm.vue | 75 +++++++++ .../components/playlist/EditPlaylistForm.vue | 75 +++++++++ .../playlist/PlaylistContextMenu.vue | 14 +- .../playlist/PlaylistFolderContextMenu.vue | 52 ++++++ .../playlist/PlaylistFolderSidebarItem.vue | 129 +++++++++++++++ .../playlist/PlaylistNameEditor.spec.ts | 56 ------- .../playlist/PlaylistNameEditor.vue | 62 ------- .../playlist/PlaylistSidebarItem.spec.ts | 7 +- .../playlist/PlaylistSidebarItem.vue | 51 +----- .../playlist/PlaylistSidebarList.vue | 58 ++----- ...teForm.vue => CreateSmartPlaylistForm.vue} | 3 +- ...EditForm.vue => EditSmartPlaylistForm.vue} | 11 +- .../smart-playlist/SmartPlaylistFormBase.vue | 4 +- ...gEditForm.spec.ts => EditSongForm.spec.ts} | 4 +- .../{SongEditForm.vue => EditSongForm.vue} | 1 + .../assets/js/components/ui/BtnGroup.vue | 6 +- .../js/components/ui/ScreenEmptyState.vue | 2 +- .../assets/js/components/ui/ScreenHeader.vue | 4 +- ...serAddForm.spec.ts => AddUserForm.spec.ts} | 4 +- .../user/{UserAddForm.vue => AddUserForm.vue} | 4 +- ...rEditForm.spec.ts => EditUserForm.spec.ts} | 4 +- .../{UserEditForm.vue => EditUserForm.vue} | 7 +- resources/assets/js/config/events.ts | 9 +- resources/assets/js/stores/commonStore.ts | 5 +- resources/assets/js/stores/index.ts | 1 + .../assets/js/stores/playlistFolderStore.ts | 49 ++++++ resources/assets/js/stores/playlistStore.ts | 6 +- resources/assets/js/stores/songStore.ts | 14 +- resources/assets/js/symbols.ts | 1 + resources/assets/js/types.d.ts | 53 ++---- resources/assets/js/utils/dragAndDrop.ts | 20 ++- routes/api.v6.php | 16 +- tests/Feature/V6/DataTest.php | 1 + tests/Feature/V6/PlaylistFolderTest.php | 50 ++++++ tests/Feature/V6/PlaylistTest.php | 155 ++++++++++++++++++ .../Services/PlaylistFolderServiceTest.php | 32 ++++ 78 files changed, 1494 insertions(+), 374 deletions(-) create mode 100644 app/Http/Controllers/V6/API/PlaylistController.php create mode 100644 app/Http/Controllers/V6/API/PlaylistFolderController.php create mode 100644 app/Http/Controllers/V6/API/PlaylistFolderPlaylistController.php create mode 100644 app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistDestroyRequest.php create mode 100644 app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistStoreRequest.php create mode 100644 app/Http/Controllers/V6/Requests/PlaylistFolderStoreRequest.php create mode 100644 app/Http/Controllers/V6/Requests/PlaylistFolderUpdateRequest.php create mode 100644 app/Http/Resources/PlaylistFolderResource.php create mode 100644 app/Http/Resources/PlaylistResource.php create mode 100644 app/Models/PlaylistFolder.php create mode 100644 app/Policies/PlaylistFolderPolicy.php create mode 100644 app/Repositories/PlaylistFolderRepository.php create mode 100644 app/Rules/AllPlaylistsBelongToUser.php create mode 100644 app/Services/PlaylistFolderService.php create mode 100644 database/factories/PlaylistFolderFactory.php create mode 100644 database/migrations/2022_08_10_075423_support_playlist_folders.php create mode 100644 resources/assets/js/components/playlist/CreatePlaylistFolderForm.vue create mode 100644 resources/assets/js/components/playlist/CreatePlaylistForm.vue create mode 100644 resources/assets/js/components/playlist/EditPlaylistFolderForm.vue create mode 100644 resources/assets/js/components/playlist/EditPlaylistForm.vue create mode 100644 resources/assets/js/components/playlist/PlaylistFolderContextMenu.vue create mode 100644 resources/assets/js/components/playlist/PlaylistFolderSidebarItem.vue delete mode 100644 resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts delete mode 100644 resources/assets/js/components/playlist/PlaylistNameEditor.vue rename resources/assets/js/components/playlist/smart-playlist/{SmartPlaylistCreateForm.vue => CreateSmartPlaylistForm.vue} (95%) rename resources/assets/js/components/playlist/smart-playlist/{SmartPlaylistEditForm.vue => EditSmartPlaylistForm.vue} (93%) rename resources/assets/js/components/song/{SongEditForm.spec.ts => EditSongForm.spec.ts} (97%) rename resources/assets/js/components/song/{SongEditForm.vue => EditSongForm.vue} (99%) rename resources/assets/js/components/user/{UserAddForm.spec.ts => AddUserForm.spec.ts} (91%) rename resources/assets/js/components/user/{UserAddForm.vue => AddUserForm.vue} (95%) rename resources/assets/js/components/user/{UserEditForm.spec.ts => EditUserForm.spec.ts} (92%) rename resources/assets/js/components/user/{UserEditForm.vue => EditUserForm.vue} (92%) create mode 100644 resources/assets/js/stores/playlistFolderStore.ts create mode 100644 tests/Feature/V6/PlaylistFolderTest.php create mode 100644 tests/Feature/V6/PlaylistTest.php create mode 100644 tests/Unit/Services/PlaylistFolderServiceTest.php diff --git a/app/Http/Controllers/API/PlaylistController.php b/app/Http/Controllers/API/PlaylistController.php index c62de6c4b5..c3ed668b86 100644 --- a/app/Http/Controllers/API/PlaylistController.php +++ b/app/Http/Controllers/API/PlaylistController.php @@ -42,7 +42,7 @@ public function store(PlaylistStoreRequest $request) public function update(PlaylistUpdateRequest $request, Playlist $playlist) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); $playlist->update($request->only('name', 'rules')); @@ -51,7 +51,7 @@ public function update(PlaylistUpdateRequest $request, Playlist $playlist) public function destroy(Playlist $playlist) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); $playlist->delete(); diff --git a/app/Http/Controllers/API/PlaylistSongController.php b/app/Http/Controllers/API/PlaylistSongController.php index ddc6459b4e..93a4f49ae8 100644 --- a/app/Http/Controllers/API/PlaylistSongController.php +++ b/app/Http/Controllers/API/PlaylistSongController.php @@ -16,13 +16,13 @@ class PlaylistSongController extends Controller public function __construct( private SmartPlaylistService $smartPlaylistService, private PlaylistService $playlistService, - private Authenticatable $user + private ?Authenticatable $user ) { } public function index(Playlist $playlist) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); return response()->json( $playlist->is_smart @@ -34,7 +34,7 @@ public function index(Playlist $playlist) /** @deprecated */ public function update(PlaylistSongUpdateRequest $request, Playlist $playlist) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); abort_if($playlist->is_smart, 403, 'A smart playlist cannot be populated manually.'); diff --git a/app/Http/Controllers/Download/PlaylistController.php b/app/Http/Controllers/Download/PlaylistController.php index 8d6b84ad0e..ee6854eebc 100644 --- a/app/Http/Controllers/Download/PlaylistController.php +++ b/app/Http/Controllers/Download/PlaylistController.php @@ -14,7 +14,7 @@ public function __construct(private DownloadService $downloadService) public function show(Playlist $playlist) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); return response()->download($this->downloadService->from($playlist)); } diff --git a/app/Http/Controllers/V6/API/DataController.php b/app/Http/Controllers/V6/API/DataController.php index 1584edd0ae..26cd2f443d 100644 --- a/app/Http/Controllers/V6/API/DataController.php +++ b/app/Http/Controllers/V6/API/DataController.php @@ -3,9 +3,10 @@ namespace App\Http\Controllers\V6\API; use App\Http\Controllers\Controller; +use App\Http\Resources\PlaylistFolderResource; +use App\Http\Resources\PlaylistResource; use App\Http\Resources\UserResource; use App\Models\User; -use App\Repositories\PlaylistRepository; use App\Repositories\SettingRepository; use App\Repositories\SongRepository; use App\Services\ApplicationInformationService; @@ -20,7 +21,6 @@ class DataController extends Controller public function __construct( private ITunesService $iTunesService, private SettingRepository $settingRepository, - private PlaylistRepository $playlistRepository, private SongRepository $songRepository, private ApplicationInformationService $applicationInformationService, private ?Authenticatable $user @@ -31,7 +31,8 @@ public function index() { return response()->json([ 'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [], - 'playlists' => $this->playlistRepository->getAllByCurrentUser(), + 'playlists' => PlaylistResource::collection($this->user->playlists), + 'playlist_folders' => PlaylistFolderResource::collection($this->user->playlist_folders), 'current_user' => UserResource::make($this->user, true), 'use_last_fm' => LastfmService::used(), 'use_you_tube' => YouTubeService::enabled(), diff --git a/app/Http/Controllers/V6/API/PlaylistController.php b/app/Http/Controllers/V6/API/PlaylistController.php new file mode 100644 index 0000000000..689bdc87a1 --- /dev/null +++ b/app/Http/Controllers/V6/API/PlaylistController.php @@ -0,0 +1,60 @@ +user->playlists); + } + + public function store(PlaylistStoreRequest $request) + { + $playlist = $this->playlistService->createPlaylist( + $request->name, + $this->user, + Arr::wrap($request->songs), + $request->rules + ); + + return PlaylistResource::make($playlist); + } + + public function update(PlaylistUpdateRequest $request, Playlist $playlist) + { + $this->authorize('own', $playlist); + + return PlaylistResource::make( + $this->playlistService->updatePlaylist( + $playlist, + $request->name, + Arr::wrap($request->rules) + ) + ); + } + + public function destroy(Playlist $playlist) + { + $this->authorize('own', $playlist); + + $playlist->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/V6/API/PlaylistFolderController.php b/app/Http/Controllers/V6/API/PlaylistFolderController.php new file mode 100644 index 0000000000..dbe1aa6818 --- /dev/null +++ b/app/Http/Controllers/V6/API/PlaylistFolderController.php @@ -0,0 +1,41 @@ +service->createFolder($this->user, $request->name)); + } + + public function update(PlaylistFolder $playlistFolder, PlaylistFolderUpdateRequest $request) + { + $this->authorize('own', $playlistFolder); + + return PlaylistFolderResource::make($this->service->updateFolder($playlistFolder, $request->name)); + } + + public function destroy(PlaylistFolder $playlistFolder) + { + $this->authorize('own', $playlistFolder); + + $playlistFolder->delete(); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/V6/API/PlaylistFolderPlaylistController.php b/app/Http/Controllers/V6/API/PlaylistFolderPlaylistController.php new file mode 100644 index 0000000000..10b6be11bb --- /dev/null +++ b/app/Http/Controllers/V6/API/PlaylistFolderPlaylistController.php @@ -0,0 +1,35 @@ +authorize('own', $playlistFolder); + + $this->service->addPlaylistsToFolder($playlistFolder, Arr::wrap($request->playlists)); + + return response()->noContent(); + } + + public function destroy(PlaylistFolder $playlistFolder, PlaylistFolderPlaylistDestroyRequest $request) + { + $this->authorize('own', $playlistFolder); + + $this->service->movePlaylistsToRootLevel(Arr::wrap($request->playlists)); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/V6/API/PlaylistSongController.php b/app/Http/Controllers/V6/API/PlaylistSongController.php index fc80713a77..22e24466ec 100644 --- a/app/Http/Controllers/V6/API/PlaylistSongController.php +++ b/app/Http/Controllers/V6/API/PlaylistSongController.php @@ -27,7 +27,7 @@ public function __construct( public function index(Playlist $playlist) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); return SongResource::collection( $playlist->is_smart @@ -36,9 +36,9 @@ public function index(Playlist $playlist) ); } - public function add(Playlist $playlist, AddSongsToPlaylistRequest $request) + public function store(Playlist $playlist, AddSongsToPlaylistRequest $request) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN); @@ -47,9 +47,9 @@ public function add(Playlist $playlist, AddSongsToPlaylistRequest $request) return response()->noContent(); } - public function remove(Playlist $playlist, RemoveSongsFromPlaylistRequest $request) + public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request) { - $this->authorize('owner', $playlist); + $this->authorize('own', $playlist); abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN); diff --git a/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php b/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php index 5008c1b8e9..29f026754d 100644 --- a/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php +++ b/app/Http/Controllers/V6/Requests/AddSongsToPlaylistRequest.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers\V6\Requests; use App\Http\Requests\API\Request; +use App\Models\Song; +use Illuminate\Validation\Rule; /** * @property-read array $songs @@ -14,7 +16,7 @@ public function rules(): array { return [ 'songs' => 'required|array', - 'songs.*' => 'exists:songs,id', + 'songs.*' => [Rule::exists(Song::class, 'id')], ]; } } diff --git a/app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistDestroyRequest.php b/app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistDestroyRequest.php new file mode 100644 index 0000000000..b3a6a2ac2d --- /dev/null +++ b/app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistDestroyRequest.php @@ -0,0 +1,23 @@ +|int $playlists + */ +class PlaylistFolderPlaylistDestroyRequest extends Request +{ + /** @return array */ + public function rules(): array + { + return [ + 'playlists' => ['required', 'array', new AllPlaylistsBelongToUser($this->user())], + 'playlists.*' => [Rule::exists(Playlist::class, 'id')], + ]; + } +} diff --git a/app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistStoreRequest.php b/app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistStoreRequest.php new file mode 100644 index 0000000000..8055ea2395 --- /dev/null +++ b/app/Http/Controllers/V6/Requests/PlaylistFolderPlaylistStoreRequest.php @@ -0,0 +1,23 @@ +|int $playlists + */ +class PlaylistFolderPlaylistStoreRequest extends Request +{ + /** @return array */ + public function rules(): array + { + return [ + 'playlists' => ['required', 'array', new AllPlaylistsBelongToUser($this->user())], + 'playlists.*' => [Rule::exists(Playlist::class, 'id')], + ]; + } +} diff --git a/app/Http/Controllers/V6/Requests/PlaylistFolderStoreRequest.php b/app/Http/Controllers/V6/Requests/PlaylistFolderStoreRequest.php new file mode 100644 index 0000000000..3338ce6844 --- /dev/null +++ b/app/Http/Controllers/V6/Requests/PlaylistFolderStoreRequest.php @@ -0,0 +1,19 @@ + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:191', + ]; + } +} diff --git a/app/Http/Controllers/V6/Requests/PlaylistFolderUpdateRequest.php b/app/Http/Controllers/V6/Requests/PlaylistFolderUpdateRequest.php new file mode 100644 index 0000000000..b048de75b4 --- /dev/null +++ b/app/Http/Controllers/V6/Requests/PlaylistFolderUpdateRequest.php @@ -0,0 +1,19 @@ + */ + public function rules(): array + { + return [ + 'name' => 'required|string|max:191', + ]; + } +} diff --git a/app/Http/Requests/API/PlaylistUpdateRequest.php b/app/Http/Requests/API/PlaylistUpdateRequest.php index c97db53e75..665bc63ec9 100644 --- a/app/Http/Requests/API/PlaylistUpdateRequest.php +++ b/app/Http/Requests/API/PlaylistUpdateRequest.php @@ -4,6 +4,10 @@ use App\Rules\ValidSmartPlaylistRulePayload; +/** + * @property-read $name + * @property-read array $rules + */ class PlaylistUpdateRequest extends Request { /** @return array */ diff --git a/app/Http/Resources/PlaylistFolderResource.php b/app/Http/Resources/PlaylistFolderResource.php new file mode 100644 index 0000000000..29f5f2720b --- /dev/null +++ b/app/Http/Resources/PlaylistFolderResource.php @@ -0,0 +1,26 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'playlist_folders', + 'id' => $this->folder->id, + 'name' => $this->folder->name, + 'user_id' => $this->folder->user_id, + 'created_at' => $this->folder->created_at, + ]; + } +} diff --git a/app/Http/Resources/PlaylistResource.php b/app/Http/Resources/PlaylistResource.php new file mode 100644 index 0000000000..f728bde5a0 --- /dev/null +++ b/app/Http/Resources/PlaylistResource.php @@ -0,0 +1,29 @@ + */ + public function toArray($request): array + { + return [ + 'type' => 'playlists', + 'id' => $this->playlist->id, + 'name' => $this->playlist->name, + 'folder_id' => $this->playlist->folder_id, + 'user_id' => $this->playlist->user_id, + 'is_smart' => $this->playlist->is_smart, + 'rules' => $this->playlist->rules, + 'created_at' => $this->playlist->created_at, + ]; + } +} diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php index 0059dfb06f..3d97e3f411 100644 --- a/app/Models/Playlist.php +++ b/app/Models/Playlist.php @@ -4,6 +4,7 @@ use App\Casts\SmartPlaylistRulesCast; use App\Values\SmartPlaylistRuleGroup; +use Carbon\Carbon; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -13,14 +14,17 @@ use Laravel\Scout\Searchable; /** - * @property int $user_id - * @property Collection|array $songs * @property int $id + * @property string $name + * @property bool $is_smart + * @property int $user_id + * @property User $user + * @property ?string $folder_id + * @property ?PlaylistFolder $folder + * @property Collection|array $songs * @property Collection|array $rule_groups * @property Collection|array $rules - * @property bool $is_smart - * @property string $name - * @property user $user + * @property Carbon $created_at */ class Playlist extends Model { @@ -46,6 +50,11 @@ public function user(): BelongsTo return $this->belongsTo(User::class); } + public function folder(): BelongsTo + { + return $this->belongsTo(PlaylistFolder::class); + } + protected function isSmart(): Attribute { return Attribute::get(fn (): bool => $this->rule_groups->isNotEmpty()); diff --git a/app/Models/PlaylistFolder.php b/app/Models/PlaylistFolder.php new file mode 100644 index 0000000000..adff52a30e --- /dev/null +++ b/app/Models/PlaylistFolder.php @@ -0,0 +1,43 @@ + $playlists + * @property int $user_id + * @property Carbon $created_at + */ +class PlaylistFolder extends Model +{ + use HasFactory; + + public $incrementing = false; + protected $keyType = 'string'; + protected $guarded = ['id']; + + protected static function booted(): void + { + static::creating(static fn (self $folder) => $folder->id = Str::uuid()->toString()); + } + + public function playlists(): HasMany + { + return $this->hasMany(Playlist::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 79c1075615..30fd18d9eb 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ use App\Casts\UserPreferencesCast; use App\Values\UserPreferences; use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; @@ -20,6 +21,8 @@ * @property string $email * @property string $password * @property-read string $avatar + * @property Collection|array $playlists + * @property Collection|array $playlist_folders */ class User extends Authenticatable { @@ -41,6 +44,11 @@ public function playlists(): HasMany return $this->hasMany(Playlist::class); } + public function playlist_folders(): HasMany // @phpcs:ignore + { + return $this->hasMany(PlaylistFolder::class); + } + public function interactions(): HasMany { return $this->hasMany(Interaction::class); diff --git a/app/Policies/PlaylistFolderPolicy.php b/app/Policies/PlaylistFolderPolicy.php new file mode 100644 index 0000000000..1c5f0396a7 --- /dev/null +++ b/app/Policies/PlaylistFolderPolicy.php @@ -0,0 +1,14 @@ +user->is($user); + } +} diff --git a/app/Policies/PlaylistPolicy.php b/app/Policies/PlaylistPolicy.php index 085d99f20b..e43462e9db 100644 --- a/app/Policies/PlaylistPolicy.php +++ b/app/Policies/PlaylistPolicy.php @@ -7,7 +7,7 @@ class PlaylistPolicy { - public function owner(User $user, Playlist $playlist): bool + public function own(User $user, Playlist $playlist): bool { return $playlist->user->is($user); } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 086d6ce92b..6334a0dbf7 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -3,7 +3,9 @@ namespace App\Providers; use App\Models\Playlist; +use App\Models\PlaylistFolder; use App\Models\User; +use App\Policies\PlaylistFolderPolicy; use App\Policies\PlaylistPolicy; use App\Policies\UserPolicy; use App\Services\TokenManager; @@ -17,11 +19,9 @@ class AuthServiceProvider extends ServiceProvider protected $policies = [ Playlist::class => PlaylistPolicy::class, User::class => UserPolicy::class, + PlaylistFolder::class => PlaylistFolderPolicy::class, ]; - /** - * Register any application authentication / authorization services. - */ public function boot(): void { $this->registerPolicies(); diff --git a/app/Repositories/PlaylistFolderRepository.php b/app/Repositories/PlaylistFolderRepository.php new file mode 100644 index 0000000000..05cb3d2050 --- /dev/null +++ b/app/Repositories/PlaylistFolderRepository.php @@ -0,0 +1,10 @@ + $value */ + public function passes($attribute, $value): bool + { + return array_diff(Arr::wrap($value), $this->user->playlists->pluck('id')->toArray()) === []; + } + + public function message(): string + { + return 'Not all playlists belong to the user'; + } +} diff --git a/app/Services/PlaylistFolderService.php b/app/Services/PlaylistFolderService.php new file mode 100644 index 0000000000..f152fd3766 --- /dev/null +++ b/app/Services/PlaylistFolderService.php @@ -0,0 +1,32 @@ +playlist_folders()->create(['name' => $name]); + } + + public function updateFolder(PlaylistFolder $folder, string $name): PlaylistFolder + { + $folder->update(['name' => $name]); + + return $folder; + } + + public function addPlaylistsToFolder(PlaylistFolder $folder, array $playlistIds): void + { + Playlist::query()->whereIn('id', $playlistIds)->update(['folder_id' => $folder->id]); + } + + public function movePlaylistsToRootLevel(array $playlistIds): void + { + Playlist::query()->whereIn('id', $playlistIds)->update(['folder_id' => null]); + } +} diff --git a/app/Services/PlaylistService.php b/app/Services/PlaylistService.php index d7eb09b17f..ec48cb47a0 100644 --- a/app/Services/PlaylistService.php +++ b/app/Services/PlaylistService.php @@ -22,6 +22,16 @@ public function createPlaylist(string $name, User $user, array $songs, ?array $r return $playlist; } + public function updatePlaylist(Playlist $playlist, string $name, array $rules): Playlist + { + $playlist->update([ + 'name' => $name, + 'rules' => $rules, + ]); + + return $playlist; + } + public function addSongsToPlaylist(Playlist $playlist, array $songIds): void { $playlist->songs()->syncWithoutDetaching($songIds); diff --git a/database/factories/PlaylistFolderFactory.php b/database/factories/PlaylistFolderFactory.php new file mode 100644 index 0000000000..15826dcad2 --- /dev/null +++ b/database/factories/PlaylistFolderFactory.php @@ -0,0 +1,21 @@ + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'name' => $this->faker->name, + ]; + } +} diff --git a/database/migrations/2022_08_10_075423_support_playlist_folders.php b/database/migrations/2022_08_10_075423_support_playlist_folders.php new file mode 100644 index 0000000000..5252807489 --- /dev/null +++ b/database/migrations/2022_08_10_075423_support_playlist_folders.php @@ -0,0 +1,27 @@ +string('id', 36)->primary(); + $table->string('name'); + $table->unsignedInteger('user_id'); + $table->timestamps(); + $table->foreign('user_id')->references('id')->on('users')->cascadeOnUpdate()->cascadeOnDelete(); + }); + + Schema::table('playlists', static function (Blueprint $table): void { + $table->string('folder_id', 36)->nullable(); + $table->foreign('folder_id') + ->references('id')->on('playlist_folders') + ->cascadeOnUpdate() + ->nullOnDelete(); + }); + } +}; diff --git a/resources/assets/js/App.vue b/resources/assets/js/App.vue index 510561596b..fe4ece04b7 100644 --- a/resources/assets/js/App.vue +++ b/resources/assets/js/App.vue @@ -19,6 +19,8 @@ + + @@ -39,6 +41,7 @@ import MainWrapper from '@/components/layout/main-wrapper/index.vue' import Overlay from '@/components/ui/Overlay.vue' import AlbumContextMenu from '@/components/album/AlbumContextMenu.vue' import ArtistContextMenu from '@/components/artist/ArtistContextMenu.vue' +import PlaylistFolderContextMenu from '@/components/playlist/PlaylistFolderContextMenu.vue' import SongContextMenu from '@/components/song/SongContextMenu.vue' import DialogBox from '@/components/ui/DialogBox.vue' import MessageToaster from '@/components/ui/MessageToaster.vue' diff --git a/resources/assets/js/components/album/AlbumCard.vue b/resources/assets/js/components/album/AlbumCard.vue index 97ce185614..cb9f5f9aca 100644 --- a/resources/assets/js/components/album/AlbumCard.vue +++ b/resources/assets/js/components/album/AlbumCard.vue @@ -80,6 +80,6 @@ const dragStart = (event: DragEvent) => startDragging(event, album.value, 'Album const requestContextMenu = (event: MouseEvent) => eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', event, album.value) - diff --git a/resources/assets/js/components/album/AlbumContextMenu.vue b/resources/assets/js/components/album/AlbumContextMenu.vue index 9e9fc7a3ac..be5f8e19ee 100644 --- a/resources/assets/js/components/album/AlbumContextMenu.vue +++ b/resources/assets/js/components/album/AlbumContextMenu.vue @@ -45,6 +45,6 @@ const download = () => trigger(() => downloadService.fromAlbum(album.value)) eventBus.on('ALBUM_CONTEXT_MENU_REQUESTED', async (e: MouseEvent, _album: Album) => { album.value = _album - open(e.pageY, e.pageX, { album }) + await open(e.pageY, e.pageX, { album }) }) diff --git a/resources/assets/js/components/album/AlbumInfo.vue b/resources/assets/js/components/album/AlbumInfo.vue index a2b4bbfd39..ebe7dba015 100644 --- a/resources/assets/js/components/album/AlbumInfo.vue +++ b/resources/assets/js/components/album/AlbumInfo.vue @@ -63,7 +63,7 @@ const showFull = computed(() => !showSummary.value) const play = async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value)) - diff --git a/resources/assets/js/components/artist/ArtistInfo.vue b/resources/assets/js/components/artist/ArtistInfo.vue index e8b668c97d..e672707694 100644 --- a/resources/assets/js/components/artist/ArtistInfo.vue +++ b/resources/assets/js/components/artist/ArtistInfo.vue @@ -59,7 +59,7 @@ const showFull = computed(() => !showSummary.value) const play = async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value)) - diff --git a/resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts b/resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts deleted file mode 100644 index 834363cc28..0000000000 --- a/resources/assets/js/components/playlist/PlaylistNameEditor.spec.ts +++ /dev/null @@ -1,56 +0,0 @@ -import factory from '@/__tests__/factory' -import { expect, it } from 'vitest' -import { fireEvent } from '@testing-library/vue' -import { playlistStore } from '@/stores' -import UnitTestCase from '@/__tests__/UnitTestCase' -import PlaylistNameEditor from './PlaylistNameEditor.vue' - -let playlist: Playlist - -new class extends UnitTestCase { - private renderComponent () { - playlist = factory('playlist', { - id: 99, - name: 'Foo' - }) - - return this.render(PlaylistNameEditor, { - props: { - playlist - } - }).getByRole('textbox') - } - - protected test () { - it('updates a playlist name on blur', async () => { - const updateMock = this.mock(playlistStore, 'update') - const input = this.renderComponent() - - await fireEvent.update(input, 'Bar') - await fireEvent.blur(input) - - expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Bar' }) - }) - - it('updates a playlist name on enter', async () => { - const updateMock = this.mock(playlistStore, 'update') - const input = this.renderComponent() - - await fireEvent.update(input, 'Bar') - await fireEvent.keyUp(input, { key: 'Enter' }) - - expect(updateMock).toHaveBeenCalledWith(playlist, { name: 'Bar' }) - }) - - it('cancels updating on esc', async () => { - const updateMock = this.mock(playlistStore, 'update') - const input = this.renderComponent() - - await fireEvent.update(input, 'Bar') - await fireEvent.keyUp(input, { key: 'Esc' }) - - expect(input.value).toBe('Foo') - expect(updateMock).not.toHaveBeenCalled() - }) - } -} diff --git a/resources/assets/js/components/playlist/PlaylistNameEditor.vue b/resources/assets/js/components/playlist/PlaylistNameEditor.vue deleted file mode 100644 index 6a3b34c7fe..0000000000 --- a/resources/assets/js/components/playlist/PlaylistNameEditor.vue +++ /dev/null @@ -1,62 +0,0 @@ - - - diff --git a/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts b/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts index b0f3c1eff4..415b2ceafc 100644 --- a/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts +++ b/resources/assets/js/components/playlist/PlaylistSidebarItem.spec.ts @@ -2,7 +2,7 @@ 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' +import PlaylistSidebarItem from './PlaylistSidebarItem.vue' new class extends UnitTestCase { renderComponent (playlist: Record, type: PlaylistType = 'playlist') { @@ -10,11 +10,6 @@ new class extends UnitTestCase { props: { playlist, type - }, - global: { - stubs: { - NameEditor: this.stub('name-editor') - } } }) } diff --git a/resources/assets/js/components/playlist/PlaylistSidebarItem.vue b/resources/assets/js/components/playlist/PlaylistSidebarItem.vue index feed3ef382..6ae7e3308e 100644 --- a/resources/assets/js/components/playlist/PlaylistSidebarItem.vue +++ b/resources/assets/js/components/playlist/PlaylistSidebarItem.vue @@ -1,8 +1,9 @@ @@ -100,13 +72,5 @@ const toggleContextMenu = async (event: MouseEvent) => { transform: rotate(135deg); } } - - form.create { - padding: 8px 16px; - - input[type="text"] { - width: 100%; - } - } } diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue b/resources/assets/js/components/playlist/smart-playlist/CreateSmartPlaylistForm.vue similarity index 95% rename from resources/assets/js/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue rename to resources/assets/js/components/playlist/smart-playlist/CreateSmartPlaylistForm.vue index 92254fac2d..00047f5435 100644 --- a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistCreateForm.vue +++ b/resources/assets/js/components/playlist/smart-playlist/CreateSmartPlaylistForm.vue @@ -9,8 +9,7 @@
- - +
diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistEditForm.vue b/resources/assets/js/components/playlist/smart-playlist/EditSmartPlaylistForm.vue similarity index 93% rename from resources/assets/js/components/playlist/smart-playlist/SmartPlaylistEditForm.vue rename to resources/assets/js/components/playlist/smart-playlist/EditSmartPlaylistForm.vue index d801333c15..16dccb9849 100644 --- a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistEditForm.vue +++ b/resources/assets/js/components/playlist/smart-playlist/EditSmartPlaylistForm.vue @@ -9,10 +9,13 @@
- +
diff --git a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue index 8030efd389..211858aa71 100644 --- a/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue +++ b/resources/assets/js/components/playlist/smart-playlist/SmartPlaylistFormBase.vue @@ -7,12 +7,12 @@ -