feat: support adding collaborative playlists into usr own folder

This commit is contained in:
Phan An 2024-03-27 10:53:05 +01:00
parent a11eaeb809
commit 9b7759a492
17 changed files with 284 additions and 60 deletions

View file

@ -28,7 +28,7 @@ class PlaylistFolderPlaylistController extends Controller
{
$this->authorize('own', $playlistFolder);
$this->service->movePlaylistsToRootLevel(Arr::wrap($request->playlists));
$this->service->movePlaylistsToRootLevel($playlistFolder, Arr::wrap($request->playlists));
return response()->noContent();
}

View file

@ -3,7 +3,7 @@
namespace App\Http\Requests\API;
use App\Models\Playlist;
use App\Rules\AllPlaylistsBelongTo;
use App\Rules\AllPlaylistsAreAccessibleBy;
use Illuminate\Validation\Rule;
/**
@ -18,7 +18,7 @@ class PlaylistFolderPlaylistDestroyRequest extends Request
'playlists' => [
'required',
'array',
new AllPlaylistsBelongTo($this->user()),
new AllPlaylistsAreAccessibleBy($this->user()),
Rule::exists(Playlist::class, 'id'),
],
];

View file

@ -3,7 +3,7 @@
namespace App\Http\Requests\API;
use App\Models\Playlist;
use App\Rules\AllPlaylistsBelongTo;
use App\Rules\AllPlaylistsAreAccessibleBy;
use Illuminate\Validation\Rule;
/**
@ -18,7 +18,7 @@ class PlaylistFolderPlaylistStoreRequest extends Request
'playlists' => [
'required',
'array',
new AllPlaylistsBelongTo($this->user()),
new AllPlaylistsAreAccessibleBy($this->user()),
Rule::exists(Playlist::class, 'id'),
],
];

View file

@ -3,6 +3,7 @@
namespace App\Http\Resources;
use App\Models\Playlist;
use App\Models\User;
use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistResource extends JsonResource
@ -27,11 +28,14 @@ class PlaylistResource extends JsonResource
/** @return array<mixed> */
public function toArray($request): array
{
/** @var User $user */
$user = $request->user() ?? $this->playlist->user;
return [
'type' => 'playlists',
'id' => $this->playlist->id,
'name' => $this->playlist->name,
'folder_id' => $this->playlist->folder_id,
'folder_id' => $this->playlist->getFolderId($user),
'user_id' => $this->playlist->user_id,
'is_smart' => $this->playlist->is_smart,
'is_collaborative' => $this->playlist->is_collaborative,

View file

@ -24,8 +24,6 @@ use LogicException;
* @property bool $is_smart
* @property int $user_id
* @property User $user
* @property ?string $folder_id
* @property ?PlaylistFolder $folder
* @property Collection|array<array-key, Song> $songs
* @property array<string> $song_ids
* @property ?SmartPlaylistRuleGroupCollection $rule_groups
@ -36,6 +34,7 @@ use LogicException;
* @property-read bool $is_collaborative
* @property-read ?string $cover The playlist cover's URL
* @property-read ?string $cover_path
* @property-read Collection|array<array-key, PlaylistFolder> $folders
*/
class Playlist extends Model
{
@ -53,7 +52,7 @@ class Playlist extends Model
public $incrementing = false;
protected $keyType = 'string';
protected $appends = ['is_smart'];
protected $with = ['collaborators'];
protected $with = ['user', 'collaborators', 'folders'];
protected static function booted(): void
{
@ -74,9 +73,9 @@ class Playlist extends Model
return $this->belongsTo(User::class);
}
public function folder(): BelongsTo
public function folders(): BelongsToMany
{
return $this->belongsTo(PlaylistFolder::class);
return $this->belongsToMany(PlaylistFolder::class, null, null, 'folder_id');
}
public function collaborationTokens(): HasMany
@ -128,7 +127,19 @@ class Playlist extends Model
public function inFolder(PlaylistFolder $folder): bool
{
return $this->folder_id === $folder->id;
return $this->folders->contains($folder);
}
public function getFolder(?User $contextUser = null): ?PlaylistFolder
{
return $this->folders->firstWhere(
fn (PlaylistFolder $folder) => $folder->user->is($contextUser ?? $this->user)
);
}
public function getFolderId(?User $user = null): ?string
{
return $this->getFolder($user)?->id;
}
public function addCollaborator(User $user): void
@ -138,11 +149,9 @@ class Playlist extends Model
}
}
public function hasCollaborator(User $user): bool
public function hasCollaborator(User $collaborator): bool
{
return $this->collaborators->contains(static function (User $collaborator) use ($user): bool {
return $collaborator->is($user);
});
return $this->collaborators->contains(static fn (User $user): bool => $collaborator->is($user));
}
/**

View file

@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Support\Str;
/**
@ -31,9 +31,9 @@ class PlaylistFolder extends Model
static::creating(static fn (self $folder) => $folder->id = Str::uuid()->toString());
}
public function playlists(): HasMany
public function playlists(): BelongsToMany
{
return $this->hasMany(Playlist::class, 'folder_id');
return $this->belongsToMany(Playlist::class, null, 'folder_id');
}
public function user(): BelongsTo

View file

@ -12,10 +12,21 @@ class PlaylistRepository extends Repository
/** @return array<array-key, Playlist>|Collection<Playlist> */
public function getAllAccessibleByUser(User $user): Collection
{
$ownPlaylists = Playlist::query()
->where('playlists.user_id', $user->id)
->leftJoin('playlist_playlist_folder', 'playlists.id', '=', 'playlist_playlist_folder.playlist_id')
->get(['playlists.*', 'playlist_playlist_folder.folder_id']);
if (License::isCommunity()) {
return $user->playlists;
return $ownPlaylists;
}
return $user->playlists->merge($user->collaboratedPlaylists);
$collaboratedPlaylists = Playlist::query()
->join('playlist_collaborators', 'playlists.id', '=', 'playlist_collaborators.playlist_id')
->where('playlist_collaborators.user_id', $user->id)
->join('playlist_playlist_folder', 'playlists.id', '=', 'playlist_playlist_folder.playlist_id')
->get(['playlists.*', 'playlist_playlist_folder.folder_id']);
return $ownPlaylists->merge($collaboratedPlaylists);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Rules;
use App\Facades\License;
use App\Models\User;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Arr;
final class AllPlaylistsAreAccessibleBy implements Rule
{
public function __construct(private User $user)
{
}
/** @param array<int> $value */
public function passes($attribute, $value): bool
{
$accessiblePlaylists = $this->user->playlists;
if (License::isPlus()) {
$accessiblePlaylists = $accessiblePlaylists->merge($this->user->collaboratedPlaylists);
}
return array_diff(Arr::wrap($value), $accessiblePlaylists->pluck('id')->toArray()) === [];
}
public function message(): string
{
return License::isPlus()
? 'Not all playlists are accessible by the user'
: 'Not all playlists belong to the user';
}
}

View file

@ -1,25 +0,0 @@
<?php
namespace App\Rules;
use App\Models\User;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Support\Arr;
final class AllPlaylistsBelongTo implements Rule
{
public function __construct(private User $user)
{
}
/** @param array<int> $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';
}
}

View file

@ -2,7 +2,6 @@
namespace App\Services;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
@ -22,11 +21,11 @@ class PlaylistFolderService
public function addPlaylistsToFolder(PlaylistFolder $folder, array $playlistIds): void
{
Playlist::query()->whereIn('id', $playlistIds)->update(['folder_id' => $folder->id]);
$folder->playlists()->attach($playlistIds);
}
public function movePlaylistsToRootLevel(array $playlistIds): void
public function movePlaylistsToRootLevel(PlaylistFolder $folder, array $playlistIds): void
{
Playlist::query()->whereIn('id', $playlistIds)->update(['folder_id' => null]);
$folder->playlists()->detach($playlistIds);
}
}

View file

@ -47,9 +47,10 @@ class PlaylistService
'name' => $name,
'rules' => $ruleGroups,
'own_songs_only' => $ownSongsOnly,
'folder_id' => $folder?->id,
]);
$folder?->playlists()->attach($playlist);
if (!$playlist->is_smart && $songs) {
$playlist->addSongs($songs, $user);
}
@ -77,10 +78,11 @@ class PlaylistService
$playlist->update([
'name' => $name,
'rules' => $ruleGroups,
'folder_id' => $folder?->id,
'own_songs_only' => $ownSongsOnly,
]);
$folder?->playlists()->syncWithoutDetaching($playlist);
return $playlist;
}

View file

@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('playlist_playlist_folder', static function (Blueprint $table): void {
$table->string('folder_id', 36)->nullable(false);
$table->string('playlist_id', 36)->nullable(false);
});
Schema::table('playlist_playlist_folder', static function (Blueprint $table): void {
$table->foreign('folder_id')
->references('id')
->on('playlist_folders')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->foreign('playlist_id')
->references('id')
->on('playlists')
->cascadeOnDelete()
->cascadeOnUpdate();
$table->unique(['folder_id', 'playlist_id']);
});
DB::table('playlists')->whereNotNull('folder_id')->get()->each(static function ($playlist): void {
DB::table('playlist_playlist_folder')->insert([
'folder_id' => $playlist->folder_id,
'playlist_id' => $playlist->id,
]);
});
Schema::table('playlists', static function (Blueprint $table): void {
if (DB::getDriverName() !== 'sqlite') {
$table->dropForeign('playlists_folder_id_foreign');
}
$table->dropColumn('folder_id');
});
}
};

View file

@ -0,0 +1,74 @@
<?php
namespace Tests\Feature\KoelPlus;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use Tests\PlusTestCase;
use function Tests\create_user;
class PlaylistFolderTest extends PlusTestCase
{
public function testCollaboratorPuttingPlaylistIntoTheirFolder(): void
{
$collaborator = create_user();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$playlist->addCollaborator($collaborator);
/** @var PlaylistFolder $ownerFolder */
$ownerFolder = PlaylistFolder::factory()->for($playlist->user)->create();
$ownerFolder->playlists()->attach($playlist->id);
self::assertTrue($playlist->refresh()->getFolder($playlist->user)?->is($ownerFolder));
/** @var PlaylistFolder $collaboratorFolder */
$collaboratorFolder = PlaylistFolder::factory()->for($collaborator)->create();
self::assertNull($playlist->getFolder($collaborator));
$this->postAs(
"api/playlist-folders/$collaboratorFolder->id/playlists",
['playlists' => [$playlist->id]],
$collaborator
)
->assertSuccessful();
self::assertTrue($playlist->fresh()->getFolder($collaborator)?->is($collaboratorFolder));
// Verify the playlist is in the owner's folder too
self::assertTrue($playlist->fresh()->getFolder($playlist->user)?->is($ownerFolder));
}
public function testCollaboratorMovingPlaylistToRootLevel(): void
{
$collaborator = create_user();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$playlist->addCollaborator($collaborator);
self::assertNull($playlist->getFolder($playlist->user));
/** @var PlaylistFolder $ownerFolder */
$ownerFolder = PlaylistFolder::factory()->for($playlist->user)->create();
$ownerFolder->playlists()->attach($playlist->id);
self::assertTrue($playlist->refresh()->getFolder($playlist->user)?->is($ownerFolder));
/** @var PlaylistFolder $collaboratorFolder */
$collaboratorFolder = PlaylistFolder::factory()->for($collaborator)->create();
$collaboratorFolder->playlists()->attach($playlist->id);
self::assertTrue($playlist->refresh()->getFolder($collaborator)?->is($collaboratorFolder));
$this->deleteAs(
"api/playlist-folders/$collaboratorFolder->id/playlists",
['playlists' => [$playlist->id]],
$collaborator
)
->assertSuccessful();
self::assertNull($playlist->fresh()->getFolder($collaborator));
// Verify the playlist is still in the owner's folder
self::assertTrue($playlist->getFolder($playlist->user)?->is($ownerFolder));
}
}

View file

@ -39,7 +39,7 @@ class PlaylistTest extends PlusTestCase
self::assertTrue($playlist->ownedBy($user));
self::assertTrue($playlist->is_smart);
self::assertCount(1, $playlist->rule_groups);
self::assertNull($playlist->folder_id);
self::assertNull($playlist->getFolderId());
self::assertTrue($rule->equals($playlist->rule_groups[0]->rules[0]));
self::assertTrue($playlist->own_songs_only);
}

View file

@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Http\Resources\PlaylistFolderResource;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use Tests\TestCase;
@ -73,4 +74,68 @@ class PlaylistFolderTest extends TestCase
self::assertModelExists($folder);
}
public function testMovingPlaylistToFolder(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($folder->user)->create();
self::assertNull($playlist->getFolderId($folder->user));
$this->postAs("api/playlist-folders/$folder->id/playlists", ['playlists' => [$playlist->id]], $folder->user)
->assertSuccessful();
self::assertTrue($playlist->fresh()->getFolder($folder->user)->is($folder));
}
public function testUnauthorizedMovingPlaylistToFolderIsNotAllowed(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($folder->user)->create();
self::assertNull($playlist->getFolderId($folder->user));
$this->postAs("api/playlist-folders/$folder->id/playlists", ['playlists' => [$playlist->id]])
->assertUnprocessable();
self::assertNull($playlist->fresh()->getFolder($folder->user));
}
public function testMovingPlaylistToRootLevel(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($folder->user)->create();
$folder->playlists()->attach($playlist->id);
self::assertTrue($playlist->refresh()->getFolder($folder->user)->is($folder));
$this->deleteAs("api/playlist-folders/$folder->id/playlists", ['playlists' => [$playlist->id]], $folder->user)
->assertSuccessful();
self::assertNull($playlist->fresh()->getFolder($folder->user));
}
public function testUnauthorizedMovingPlaylistToRootLevelIsNotAllowed(): void
{
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($folder->user)->create();
$folder->playlists()->attach($playlist->id);
self::assertTrue($playlist->refresh()->getFolder($folder->user)->is($folder));
$this->deleteAs("api/playlist-folders/$folder->id/playlists", ['playlists' => [$playlist->id]])
->assertUnprocessable();
self::assertTrue($playlist->refresh()->getFolder($folder->user)->is($folder));
}
}

View file

@ -42,7 +42,7 @@ class PlaylistTest extends TestCase
self::assertSame('Foo Bar', $playlist->name);
self::assertTrue($playlist->ownedBy($user));
self::assertNull($playlist->folder_id);
self::assertNull($playlist->getFolder());
self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
}
@ -73,7 +73,7 @@ class PlaylistTest extends TestCase
self::assertTrue($playlist->ownedBy($user));
self::assertTrue($playlist->is_smart);
self::assertCount(1, $playlist->rule_groups);
self::assertNull($playlist->folder_id);
self::assertNull($playlist->getFolder());
self::assertTrue($rule->equals($playlist->rule_groups[0]->rules[0]));
}

View file

@ -45,11 +45,13 @@ class PlaylistFolderServiceTest extends TestCase
public function testAddPlaylistsToFolder(): void
{
$user = create_user();
/** @var Collection|array<array-key, Playlist> $playlists */
$playlists = Playlist::factory()->count(3)->create();
$playlists = Playlist::factory()->for($user)->count(3)->create();
/** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create();
$folder = PlaylistFolder::factory()->for($user)->create();
$this->service->addPlaylistsToFolder($folder, $playlists->pluck('id')->all());
@ -62,12 +64,13 @@ class PlaylistFolderServiceTest extends TestCase
$folder = PlaylistFolder::factory()->create();
/** @var Collection|array<array-key, Playlist> $playlists */
$playlists = Playlist::factory()->count(3)->for($folder, 'folder')->create();
$playlists = Playlist::factory()->count(3)->create();
$folder->playlists()->attach($playlists->pluck('id')->all());
$this->service->movePlaylistsToRootLevel($playlists->pluck('id')->all());
$this->service->movePlaylistsToRootLevel($folder, $playlists->pluck('id')->all());
self::assertCount(0, $folder->playlists);
$playlists->each(static fn (Playlist $playlist) => self::assertNull($playlist->refresh()->folder_id));
$playlists->each(static fn (Playlist $playlist) => self::assertNull($playlist->refresh()->getFolder()));
}
}