feat(plus): song visibility behaviors for collaborative playlists

This commit is contained in:
Phan An 2024-01-25 17:21:26 +01:00
parent e874c80b26
commit 5c5c538478
75 changed files with 892 additions and 375 deletions

View file

@ -0,0 +1,16 @@
<?php
namespace App\Events;
use App\Models\PlaylistCollaborationToken;
use App\Models\User;
use Illuminate\Queue\SerializesModels;
class NewPlaylistCollaboratorJoined extends Event
{
use SerializesModels;
public function __construct(public User $collaborator, public PlaylistCollaborationToken $token)
{
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class CannotRemoveOwnerFromPlaylistException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class KoelPlusRequiredException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class NotAPlaylistCollaboratorException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class PlaylistCollaborationTokenExpiredException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class SmartPlaylistsAreNotCollaborativeException extends Exception
{
}

View file

@ -2,24 +2,22 @@
namespace App\Http\Controllers\API\PlaylistCollaboration;
use App\Facades\License;
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\NotAPlaylistCollaboratorException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistCollaboration\PlaylistCollaboratorDestroyRequest;
use App\Models\Playlist;
use App\Models\User;
use App\Repositories\UserRepository;
use App\Services\PlaylistCollaborationService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class PlaylistCollaboratorController extends Controller
{
/** @param User $user */
public function __construct(
private PlaylistCollaborationService $service,
private UserRepository $userRepository,
private ?Authenticatable $user
) {
public function __construct(private PlaylistCollaborationService $service, private UserRepository $userRepository)
{
}
public function index(Playlist $playlist)
@ -36,20 +34,16 @@ class PlaylistCollaboratorController extends Controller
/** @var User $collaborator */
$collaborator = $this->userRepository->getOne($request->collaborator);
abort_unless(License::isPlus(), Response::HTTP_FORBIDDEN, 'This feature is only available for Plus users.');
abort_if(
$collaborator->is($this->user),
Response::HTTP_FORBIDDEN,
'You cannot remove yourself from your own playlist.'
);
abort_unless(
$playlist->hasCollaborator($collaborator),
Response::HTTP_NOT_FOUND,
'This user is not a collaborator of this playlist.'
);
try {
$this->service->removeCollaborator($playlist, $collaborator);
return response()->noContent();
} catch (KoelPlusRequiredException) {
abort(Response::HTTP_FORBIDDEN, 'This feature is only available for Plus users.');
} catch (CannotRemoveOwnerFromPlaylistException) {
abort(Response::HTTP_FORBIDDEN, 'You cannot remove yourself from your own playlist.');
} catch (NotAPlaylistCollaboratorException) {
abort(Response::HTTP_NOT_FOUND, 'This user is not a collaborator of this playlist.');
}
}
}

View file

@ -14,7 +14,9 @@ use App\Repositories\SongRepository;
use App\Services\PlaylistService;
use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class PlaylistSongController extends Controller
{
@ -36,11 +38,7 @@ class PlaylistSongController extends Controller
$this->authorize('collaborate', $playlist);
$songs = $this->songRepository->getByStandardPlaylist($playlist, $this->user);
return License::isPlus()
? CollaborativeSongResource::collection($songs)
: SongResource::collection($songs);
return self::createResourceCollection($this->songRepository->getByStandardPlaylist($playlist, $this->user));
}
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
@ -50,11 +48,15 @@ class PlaylistSongController extends Controller
$this->authorize('collaborate', $playlist);
$songs = $this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user);
$songs->each(fn ($song) => $this->authorize('access', $song));
$this->playlistService->addSongsToPlaylist($playlist, $songs, $this->user);
return self::createResourceCollection($this->playlistService->addSongsToPlaylist($playlist, $songs, $this->user));
}
return response()->noContent();
private static function createResourceCollection(Collection $songs): ResourceCollection
{
return License::isPlus()
? CollaborativeSongResource::collection($songs)
: SongResource::collection($songs);
}
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)

View file

@ -17,8 +17,6 @@ class PrivatizeSongsController extends Controller
$songs = Song::query()->findMany($request->songs);
$songs->each(fn ($song) => $this->authorize('own', $song));
$songService->privatizeSongs($songs);
return response()->noContent();
return response()->json($songService->markSongsAsPrivate($songs));
}
}

View file

@ -17,7 +17,7 @@ class PublicizeSongsController extends Controller
$songs = Song::query()->findMany($request->songs);
$songs->each(fn ($song) => $this->authorize('own', $song));
$songService->publicizeSongs($songs);
$songService->markSongsAsPublic($songs);
return response()->noContent();
}

View file

@ -8,7 +8,7 @@ use App\Http\Requests\API\AcceptUserInvitationRequest;
use App\Http\Requests\API\GetUserInvitationRequest;
use App\Http\Requests\API\InviteUserRequest;
use App\Http\Requests\API\RevokeUserInvitationRequest;
use App\Http\Resources\UserResource;
use App\Http\Resources\UserProspectResource;
use App\Models\User;
use App\Services\AuthenticationService;
use App\Services\UserInvitationService;
@ -37,13 +37,13 @@ class UserInvitationController extends Controller
$this->invitor
);
return UserResource::collection($invitees);
return UserProspectResource::collection($invitees);
}
public function get(GetUserInvitationRequest $request)
{
try {
return UserResource::make($this->invitationService->getUserProspectByToken($request->token));
return UserProspectResource::make($this->invitationService->getUserProspectByToken($request->token));
} catch (InvitationNotFoundException) {
abort(Response::HTTP_NOT_FOUND, 'The invitation token is invalid.');
}

View file

@ -7,6 +7,35 @@ use Illuminate\Http\Resources\Json\JsonResource;
class AlbumResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'artist_id',
'artist_name',
'cover',
'created_at',
];
public const PAGINATION_JSON_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function __construct(private Album $album)
{
parent::__construct($album);

View file

@ -7,6 +7,33 @@ use Illuminate\Http\Resources\Json\JsonResource;
class ArtistResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'image',
'created_at',
];
public const PAGINATION_JSON_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function __construct(private Artist $artist)
{
parent::__construct($artist);

View file

@ -7,6 +7,14 @@ use Carbon\Carbon;
class CollaborativeSongResource extends SongResource
{
public const JSON_STRUCTURE = SongResource::JSON_STRUCTURE + [
'collaboration' => [
'user' => PlaylistCollaboratorResource::JSON_STRUCTURE,
'added_at',
'fmt_added_at',
],
];
/** @return array<mixed> */
public function toArray($request): array
{

View file

@ -7,6 +7,18 @@ use Illuminate\Http\Resources\Json\JsonResource;
class ExcerptSearchResource extends JsonResource
{
public const JSON_STRUCTURE = [
'songs' => [
SongResource::JSON_STRUCTURE,
],
'artists' => [
ArtistResource::JSON_STRUCTURE,
],
'albums' => [
AlbumResource::JSON_STRUCTURE,
],
];
public function __construct(private ExcerptSearchResult $result)
{
parent::__construct($result);

View file

@ -7,6 +7,13 @@ use Illuminate\Http\Resources\Json\JsonResource;
class GenreResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'name',
'song_count',
'length',
];
public function __construct(private Genre $genre)
{
parent::__construct($genre);

View file

@ -7,6 +7,16 @@ use Illuminate\Http\Resources\Json\JsonResource;
class InteractionResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'songId',
'song_id',
'liked',
'playCount',
'play_count',
];
public function __construct(private Interaction $interaction)
{
parent::__construct($interaction);

View file

@ -7,6 +7,11 @@ use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistCollaborationTokenResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'token',
];
public function __construct(private PlaylistCollaborationToken $token)
{
parent::__construct($token);

View file

@ -7,6 +7,13 @@ use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistCollaboratorResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'avatar',
];
public function __construct(private PlaylistCollaborator $collaborator)
{
parent::__construct($collaborator);

View file

@ -7,6 +7,14 @@ use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistFolderResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'user_id',
'created_at',
];
public function __construct(private PlaylistFolder $folder)
{
parent::__construct($folder);

View file

@ -2,12 +2,23 @@
namespace App\Http\Resources;
use App\Facades\License;
use App\Models\Playlist;
use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'folder_id',
'user_id',
'is_smart',
'rules',
'own_songs_only',
'created_at',
];
public function __construct(private Playlist $playlist)
{
parent::__construct($playlist);
@ -23,8 +34,8 @@ class PlaylistResource extends JsonResource
'folder_id' => $this->playlist->folder_id,
'user_id' => $this->playlist->user_id,
'is_smart' => $this->playlist->is_smart,
'is_collaborative' => $this->playlist->is_collaborative,
'rules' => $this->playlist->rules,
'collaborators' => License::isPlus() ? UserResource::collection($this->playlist->collaborators) : [],
'own_songs_only' => $this->playlist->own_songs_only,
'created_at' => $this->playlist->created_at,
];

View file

@ -7,6 +7,14 @@ use Illuminate\Http\Resources\Json\JsonResource;
class QueueStateResource extends JsonResource
{
public const JSON_STRUCTURE = [
'songs' => [
SongResource::JSON_STRUCTURE,
],
'current_song',
'playback_position',
];
public function __construct(private QueueState $state)
{
parent::__construct($state);

View file

@ -7,6 +7,48 @@ use Illuminate\Http\Resources\Json\JsonResource;
class SongResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'title',
'lyrics',
'album_id',
'album_name',
'artist_id',
'artist_name',
'album_artist_id',
'album_artist_name',
'album_cover',
'length',
'liked',
'play_count',
'track',
'genre',
'year',
'disc',
'is_public',
'created_at',
];
public const PAGINATION_JSON_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function __construct(protected Song $song)
{
parent::__construct($song);

View file

@ -0,0 +1,38 @@
<?php
namespace App\Http\Resources;
use App\Models\User;
use Illuminate\Http\Resources\Json\JsonResource;
class UserProspectResource extends JsonResource
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'email',
'avatar',
'is_admin',
'is_prospect',
];
public function __construct(private User $user)
{
parent::__construct($user);
}
/** @return array<mixed> */
public function toArray($request): array
{
return [
'type' => 'users',
'id' => $this->user->id,
'name' => $this->user->name,
'email' => $this->user->email,
'avatar' => $this->user->avatar,
'is_admin' => $this->user->is_admin,
'is_prospect' => $this->user->is_prospect,
];
}
}

View file

@ -7,7 +7,18 @@ use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function __construct(private User $user, private bool $includePreferences = false)
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'email',
'avatar',
'is_admin',
'preferences',
'is_prospect',
];
public function __construct(private User $user)
{
parent::__construct($user);
}
@ -22,7 +33,7 @@ class UserResource extends JsonResource
'email' => $this->user->email,
'avatar' => $this->user->avatar,
'is_admin' => $this->user->is_admin,
'preferences' => $this->when($this->includePreferences, $this->user->preferences),
'preferences' => $this->user->preferences,
'is_prospect' => $this->user->is_prospect,
];
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Listeners;
use App\Events\NewPlaylistCollaboratorJoined;
use App\Services\PlaylistService;
use Illuminate\Contracts\Queue\ShouldQueue;
class MakePlaylistSongsPublic implements ShouldQueue
{
public function __construct(private PlaylistService $service)
{
}
public function handle(NewPlaylistCollaboratorJoined $event): void
{
$this->service->makePlaylistSongsPublic($event->token->playlist);
}
}

View file

@ -30,6 +30,7 @@ use Laravel\Scout\Searchable;
* @property Carbon $created_at
* @property bool $own_songs_only
* @property Collection|array<array-key, User> $collaborators
* @property-read bool $is_collaborative
*/
class Playlist extends Model
{
@ -144,7 +145,9 @@ class Playlist extends Model
protected function isCollaborative(): Attribute
{
return Attribute::get(fn (): bool => LicenseFacade::isPlus() && $this->collaborators->isNotEmpty());
return Attribute::get(fn (): bool => !$this->is_smart &&
LicenseFacade::isPlus()
&& $this->collaborators->isNotEmpty());
}
/** @return array<mixed> */

View file

@ -4,6 +4,7 @@ namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
@ -12,10 +13,13 @@ use Illuminate\Support\Str;
* @property string $token
* @property Carbon $created_at
* @property-read bool $expired
* @property string $playlist_id
* @property Playlist $playlist
*/
class PlaylistCollaborationToken extends Model
{
use HasFactory;
protected static function booted(): void
{
static::creating(static function (PlaylistCollaborationToken $token): void {

View file

@ -6,11 +6,13 @@ use App\Events\LibraryChanged;
use App\Events\MediaScanCompleted;
use App\Events\MultipleSongsLiked;
use App\Events\MultipleSongsUnliked;
use App\Events\NewPlaylistCollaboratorJoined;
use App\Events\PlaybackStarted;
use App\Events\SongLikeToggled;
use App\Listeners\DeleteNonExistingRecordsPostSync;
use App\Listeners\LoveMultipleTracksOnLastfm;
use App\Listeners\LoveTrackOnLastfm;
use App\Listeners\MakePlaylistSongsPublic;
use App\Listeners\PruneLibrary;
use App\Listeners\UnloveMultipleTracksOnLastfm;
use App\Listeners\UpdateLastfmNowPlaying;
@ -46,6 +48,10 @@ class EventServiceProvider extends BaseServiceProvider
DeleteNonExistingRecordsPostSync::class,
WriteSyncLog::class,
],
NewPlaylistCollaboratorJoined::class => [
MakePlaylistSongsPublic::class,
],
];
public function boot(): void

View file

@ -195,7 +195,6 @@ class SongRepository extends Repository
})
->where('playlists.id', $playlist->id)
->orderBy('songs.title')
->logSql()
->get();
}
@ -228,6 +227,35 @@ class SongRepository extends Repository
return $inThatOrder ? $songs->orderByArray($ids) : $songs;
}
/**
* Gets several songs, but also includes collaborative information.
*
* @return Collection|array<array-key, Song>
*/
public function getManyInCollaborativeContext(array $ids, ?User $scopedUser = null): Collection
{
/** @var ?User $scopedUser */
$scopedUser ??= $this->auth->user();
return Song::query()
->accessibleBy($scopedUser)
->withMetaFor($scopedUser)
->when(License::isPlus(), static function (SongBuilder $query): SongBuilder {
return
$query->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
->join('users as collaborators', 'playlist_song.user_id', '=', 'collaborators.id')
->addSelect(
'collaborators.id as collaborator_id',
'collaborators.name as collaborator_name',
'collaborators.email as collaborator_email',
'playlist_song.created_at as added_at'
);
})
->whereIn('songs.id', $ids)
->get();
}
/** @param string $id */
public function getOne($id, ?User $scopedUser = null): Song
{

View file

@ -2,6 +2,12 @@
namespace App\Services;
use App\Events\NewPlaylistCollaboratorJoined;
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
use App\Exceptions\KoelPlusRequiredException;
use App\Exceptions\NotAPlaylistCollaboratorException;
use App\Exceptions\PlaylistCollaborationTokenExpiredException;
use App\Exceptions\SmartPlaylistsAreNotCollaborativeException;
use App\Facades\License;
use App\Models\Playlist;
use App\Models\PlaylistCollaborationToken;
@ -9,14 +15,14 @@ use App\Models\User;
use App\Values\PlaylistCollaborator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Webmozart\Assert\Assert;
class PlaylistCollaborationService
{
public function createToken(Playlist $playlist): PlaylistCollaborationToken
{
self::assertKoelPlus();
Assert::false($playlist->is_smart, 'Smart playlists are not collaborative.');
throw_if($playlist->is_smart, SmartPlaylistsAreNotCollaborativeException::class);
return $playlist->collaborationTokens()->create();
}
@ -28,7 +34,7 @@ class PlaylistCollaborationService
/** @var PlaylistCollaborationToken $collaborationToken */
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();
Assert::false($collaborationToken->expired, 'The token has expired.');
throw_if($collaborationToken->expired, PlaylistCollaborationTokenExpiredException::class);
if ($collaborationToken->playlist->ownedBy($user)) {
return $collaborationToken->playlist;
@ -36,6 +42,10 @@ class PlaylistCollaborationService
$collaborationToken->playlist->addCollaborator($user);
// Now that we have at least one external collaborator, the songs in the playlist should be made public.
// Here we dispatch an event for that to happen.
event(new NewPlaylistCollaboratorJoined($user, $collaborationToken));
return $collaborationToken->playlist;
}
@ -55,6 +65,9 @@ class PlaylistCollaborationService
{
self::assertKoelPlus();
throw_if($user->is($playlist->user), CannotRemoveOwnerFromPlaylistException::class);
throw_if(!$playlist->hasCollaborator($user), NotAPlaylistCollaboratorException::class);
DB::transaction(static function () use ($playlist, $user): void {
$playlist->collaborators()->detach($user);
$playlist->songs()->wherePivot('user_id', $user->id)->detach();
@ -63,6 +76,6 @@ class PlaylistCollaborationService
private static function assertKoelPlus(): void
{
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
throw_unless(License::isPlus(), KoelPlusRequiredException::class);
}
}

View file

@ -8,6 +8,7 @@ use App\Models\Playlist;
use App\Models\PlaylistFolder as Folder;
use App\Models\Song;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
@ -16,6 +17,10 @@ use Webmozart\Assert\Assert;
class PlaylistService
{
public function __construct(private SongRepository $songRepository)
{
}
public function createPlaylist(
string $name,
User $user,
@ -78,16 +83,29 @@ class PlaylistService
return $playlist;
}
public function addSongsToPlaylist(Playlist $playlist, Collection|Song|array $songs, User $user): void
public function addSongsToPlaylist(Playlist $playlist, Collection|Song|array $songs, User $user): Collection
{
$playlist->addSongs(
Collection::wrap($songs)->filter(static fn ($song): bool => !$playlist->songs->contains($song)),
$user
);
return DB::transaction(function () use ($playlist, $songs, $user) {
$songs = Collection::wrap($songs);
$playlist->addSongs($songs->filter(static fn ($song): bool => !$playlist->songs->contains($song)), $user);
// if the playlist is collaborative, make the songs public
if ($playlist->is_collaborative) {
$this->makePlaylistSongsPublic($playlist);
}
// we want a fresh copy of the songs with the possibly updated visibility
return $this->songRepository->getManyInCollaborativeContext(ids: $songs->pluck('id')->all(), scopedUser: $user);
});
}
public function removeSongsFromPlaylist(Playlist $playlist, Collection|Song|array $songs): void
{
$playlist->removeSongs($songs);
}
public function makePlaylistSongsPublic(Playlist $playlist): void
{
$playlist->songs()->where('is_public', false)->update(['is_public' => true]);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Services;
use App\Events\LibraryChanged;
use App\Facades\License;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
@ -82,14 +83,36 @@ class SongService
return $this->songRepository->getOne($song->id);
}
public function publicizeSongs(Collection $songs): void
public function markSongsAsPublic(Collection $songs): void
{
Song::query()->whereIn('id', $songs->pluck('id'))->update(['is_public' => true]);
}
public function privatizeSongs(Collection $songs): void
/** @return array<string> IDs of songs that are marked as private */
public function markSongsAsPrivate(Collection $songs): array
{
Song::query()->whereIn('id', $songs->pluck('id'))->update(['is_public' => false]);
if (License::isPlus()) {
/**
* @var Collection|array<array-key, Song> $collaborativeSongs
* Songs that are in collaborative playlists and can't be marked as private as a result
*/
$collaborativeSongs = Song::query()
->whereIn('songs.id', $songs->pluck('id'))
->join('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
->join('playlist_collaborators', 'playlist_song.playlist_id', '=', 'playlist_collaborators.playlist_id')
->select('songs.id')
->distinct()
->pluck('songs.id')
->all();
$applicableSongIds = $songs->whereNotIn('id', $collaborativeSongs)->pluck('id')->all();
} else {
$applicableSongIds = $songs->pluck('id')->all();
}
Song::query()->whereIn('id', $applicableSongIds)->update(['is_public' => false]);
return $applicableSongIds;
}
/**

View file

@ -9,6 +9,22 @@ final class AlbumInformation implements Arrayable
{
use FormatsLastFmText;
public const JSON_STRUCTURE = [
'url',
'cover',
'wiki' => [
'summary',
'full',
],
'tracks' => [
'*' => [
'title',
'length',
'url',
],
],
];
private function __construct(public ?string $url, public ?string $cover, public array $wiki, public array $tracks)
{
}

View file

@ -8,6 +8,15 @@ final class ArtistInformation implements Arrayable
{
use FormatsLastFmText;
public const JSON_STRUCTURE = [
'url',
'image',
'bio' => [
'summary',
'full',
],
];
private function __construct(public ?string $url, public ?string $image, public array $bio)
{
}

View file

@ -0,0 +1,17 @@
<?php
namespace Database\Factories;
use App\Models\Playlist;
use Illuminate\Database\Eloquent\Factories\Factory;
class PlaylistCollaborationTokenFactory extends Factory
{
/** @return array<mixed> */
public function definition(): array
{
return [
'playlist_id' => Playlist::factory(),
];
}
}

View file

@ -2,7 +2,6 @@
namespace Database\Factories;
use App\Models\Playlist;
use App\Models\User;
use App\Values\SmartPlaylistRule;
use App\Values\SmartPlaylistRuleGroup;
@ -12,8 +11,6 @@ use Illuminate\Support\Str;
class PlaylistFactory extends Factory
{
protected $model = Playlist::class;
/** @return array<mixed> */
public function definition(): array
{

View file

@ -10,7 +10,7 @@ export default (faker: Faker): Playlist => ({
is_smart: false,
rules: [],
own_songs_only: false,
collaborators: []
is_collaborative: false,
})
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {

View file

@ -14,7 +14,7 @@
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width />
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width />
<Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
<Icon v-else-if="list.collaborators.length" :icon="faUsers" fixed-width />
<Icon v-else-if="list.is_collaborative" :icon="faUsers" fixed-width />
<Icon v-else :icon="faFileLines" fixed-width />
<span>{{ list.name }}</span>
</a>

View file

@ -6,9 +6,9 @@
<main>
<p class="intro text-secondary">
Collaborative playlists allow multiple users to contribute. Please note that songs added to a collaborative
playlist are made accessible to all users, and you cannot make a song private as long as its still a part of a
collaborative playlist.
Collaborative playlists allow multiple users to contribute. <br>
Please note that songs added to a collaborative playlist are made accessible to all users,
and you cannot mark a song as private if its still part of a collaborative playlist.
</p>
<section class="collaborators">
@ -38,8 +38,10 @@
title="This is you!"
/>
</span>
<span v-if="user.id === playlist.user_id" class="role text-secondary">Owner</span>
<span v-else class="role text-secondary">Contributor</span>
<span class="role text-secondary">
<span v-if="user.id === playlist.user_id" class="owner">Owner</span>
<span v-else class="contributor">Contributor</span>
</span>
<span v-if="canManageCollaborators" class="actions">
<Btn v-if="user.id !== playlist.user_id" small red @click.prevent="removeCollaborator(user)">
Remove
@ -106,7 +108,7 @@ const inviteCollaborators = async () => {
const removeCollaborator = async (collaborator: PlaylistCollaborator) => {
const deadSure = await showConfirmDialog(
`Remove ${collaborator.name} as a collaborator? This will remove their contributed songs as well.`
`Remove ${collaborator.name} as a collaborator? This will remove their contributions as well.`
)
if (!deadSure) return
@ -178,10 +180,7 @@ h2 {
span {
display: inline-block;
min-width: 0;
}
.avatar {
display: flex;
line-height: 1;
}
.name {
@ -192,6 +191,12 @@ h2 {
text-align: right;
flex: 0 0 96px;
text-transform: uppercase;
span {
padding: 3px 4px;
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, .2);
}
}
.actions {

View file

@ -13,13 +13,14 @@
<script setup lang="ts">
import { computed, toRefs } from 'vue'
import UserAvatar from '@/components/user/UserAvatar.vue'
const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props)
const props = defineProps<{ collaborators: PlaylistCollaborator[] }>()
const { collaborators } = toRefs(props)
const displayedCollaborators = computed(() => playlist.value.collaborators.slice(0, 3))
const remainderCount = computed(() => playlist.value.collaborators.length - displayedCollaborators.value.length)
const displayedCollaborators = computed(() => collaborators.value.slice(0, 3))
const remainderCount = computed(() => collaborators.value.length - displayedCollaborators.value.length)
</script>
<style scoped lang="scss">

View file

@ -8,8 +8,8 @@
<ThumbnailStack :thumbnails="thumbnails" />
</template>
<template v-if="songs.length || playlist.collaborators.length" #meta>
<CollaboratorsBadge :playlist="playlist" v-if="playlist.collaborators.length" />
<template v-if="songs.length || playlist.is_collaborative" #meta>
<CollaboratorsBadge :collaborators="collaborators" v-if="collaborators.length" />
<span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span>
<a
@ -30,7 +30,7 @@
@filter="applyFilter"
@play-all="playAll"
@play-selected="playSelected"
@refresh="fetchSongs(true)"
@refresh="fetchDetails(true)"
/>
</template>
</ScreenHeader>
@ -68,9 +68,9 @@
import { faFile } from '@fortawesome/free-regular-svg-icons'
import { differenceBy } from 'lodash'
import { ref, toRef, watch } from 'vue'
import { eventBus, pluralize } from '@/utils'
import { eventBus, logger, pluralize } from '@/utils'
import { commonStore, playlistStore, songStore } from '@/stores'
import { downloadService } from '@/services'
import { downloadService, playlistCollaborationService } from '@/services'
import { usePlaylistManagement, useRouter, useSongList, useAuthorization, useSongListControls } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
@ -116,14 +116,23 @@ const download = () => downloadService.fromPlaylist(playlist.value!)
const editPlaylist = () => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!)
const removeSelected = async () => await removeSongsFromPlaylist(playlist.value!, selectedSongs.value)
let collaborators = ref<PlaylistCollaborator[]>([])
const fetchSongs = async (refresh = false) => {
const fetchDetails = async (refresh = false) => {
if (loading.value) return
loading.value = true
songs.value = await songStore.fetchForPlaylist(playlist.value!, refresh)
loading.value = false
try {
[songs.value, collaborators.value] = await Promise.all([
songStore.fetchForPlaylist(playlist.value!, refresh),
playlistCollaborationService.getCollaborators(playlist.value!),
])
sort()
} catch (e) {
logger.error(e)
} finally {
loading.value = false
}
}
watch(playlistId, async id => {
@ -135,8 +144,8 @@ watch(playlistId, async id => {
listConfig.collaborative = false
if (playlist.value) {
await fetchSongs()
listConfig.collaborative = playlist.value.collaborators.length > 0
await fetchDetails()
listConfig.collaborative = playlist.value.is_collaborative
controlsConfig.deletePlaylist = playlist.value.user_id === currentUser.value?.id
} else {
await triggerNotFound()
@ -146,8 +155,8 @@ watch(playlistId, async id => {
onScreenActivated('Playlist', () => (playlistId.value = getRouteParam('id')!))
eventBus
.on('PLAYLIST_UPDATED', async ({ id }) => id === playlistId.value && await fetchSongs())
.on('PLAYLIST_COLLABORATOR_REMOVED', async ({ id }) => id === playlistId.value && await fetchSongs())
.on('PLAYLIST_UPDATED', async ({ id }) => id === playlistId.value && await fetchDetails())
.on('PLAYLIST_COLLABORATOR_REMOVED', async ({ id }) => id === playlistId.value && await fetchDetails())
.on('PLAYLIST_SONGS_REMOVED', async ({ id }, removed) => {
if (id !== playlistId.value) return
songs.value = differenceBy(songs.value, removed, 'id')

View file

@ -327,7 +327,7 @@ new class extends UnitTestCase {
await this.be(user).renderComponent(songs)
const privatizeMock = this.mock(songStore, 'privatize')
await this.user.click(screen.getByText('Make Private'))
await this.user.click(screen.getByText('Mark as Private'))
expect(privatizeMock).toHaveBeenCalledWith(songs)
})
@ -344,7 +344,7 @@ new class extends UnitTestCase {
await this.be(user).renderComponent(songs)
const publicizeMock = this.mock(songStore, 'publicize')
await this.user.click(screen.getByText('Make Public'))
await this.user.click(screen.getByText('Unmark as Private'))
expect(publicizeMock).toHaveBeenCalledWith(songs)
})
@ -361,8 +361,8 @@ new class extends UnitTestCase {
await this.be(user).renderComponent(songs)
expect(screen.queryByText('Make Public')).toBeNull()
expect(screen.queryByText('Make Private')).toBeNull()
expect(screen.queryByText('Unmark as Private')).toBeNull()
expect(screen.queryByText('Mark as Private')).toBeNull()
})
it('has both options to make public and private if songs have mixed visibilities', async () => {
@ -379,8 +379,8 @@ new class extends UnitTestCase {
await this.be(owner).renderComponent(songs)
screen.getByText('Make Public')
screen.getByText('Make Private')
screen.getByText('Unmark as Private')
screen.getByText('Mark as Private')
})
it('does not have an option to make songs public or private oin Community edition', async () => {
@ -392,8 +392,8 @@ new class extends UnitTestCase {
await this.be(owner).renderComponent(songs)
expect(screen.queryByText('Make Public')).toBeNull()
expect(screen.queryByText('Make Private')).toBeNull()
expect(screen.queryByText('Unmark as Private')).toBeNull()
expect(screen.queryByText('Mark as Private')).toBeNull()
})
}
}

View file

@ -80,7 +80,7 @@ import {
useSongMenuMethods
} from '@/composables'
const { toastSuccess } = useMessageToaster()
const { toastSuccess, toastError, toastWarning } = useMessageToaster()
const { showConfirmDialog } = useDialogBox()
const { go, getRouteParam, isCurrentScreen } = useRouter()
const { isAdmin, currentUser } = useAuthorization()
@ -115,12 +115,23 @@ const normalPlaylists = computed(() => playlists.value.filter(({ is_smart }) =>
const makePublic = () => trigger(async () => {
await songStore.publicize(songs.value)
toastSuccess(`Made ${pluralize(songs.value, 'song')} public to everyone.`)
toastSuccess(`Unmarked ${pluralize(songs.value, 'song')} as private.`)
})
const makePrivate = () => trigger(async () => {
await songStore.privatize(songs.value)
toastSuccess(`Removed public access to ${pluralize(songs.value, 'song')}.`)
const privatizedIds = await songStore.privatize(songs.value)
if (!privatizedIds.length) {
toastError('Songs cannot be marked as private if theypart of a collaborative playlist.')
return
}
if (privatizedIds.length < songs.value.length) {
toastWarning('Some songs cannot be marked as private as theyre part of a collaborative playlist.')
return
}
toastSuccess(`Marked ${pluralize(songs.value, 'song')} as private.`)
})
const canBeShared = computed(() => !isPlus.value || songs.value[0].is_public)
@ -136,19 +147,19 @@ const visibilityActions = computed(() => {
if (visibilities.length === 2) {
return [
{
label: 'Make Public',
label: 'Unmark as Private',
handler: makePublic
},
{
label: 'Make Private',
label: 'Mark as Private',
handler: makePrivate
}
]
}
return visibilities[0]
? [{ label: 'Make Private', handler: makePrivate }]
: [{ label: 'Make Public', handler: makePublic }]
? [{ label: 'Mark as Private', handler: makePrivate }]
: [{ label: 'Unmark as Private', handler: makePublic }]
})
const canBeRemovedFromPlaylist = computed(() => {

View file

@ -4,6 +4,7 @@ import { logger, uuid } from '@/utils'
import { cache, http } from '@/services'
import models from '@/config/smart-playlist/models'
import operators from '@/config/smart-playlist/operators'
import { songStore } from '@/stores/songStore'
type CreatePlaylistRequestData = {
name: Playlist['name']
@ -94,7 +95,11 @@ export const playlistStore = {
return playlist
}
await http.post(`playlists/${playlist.id}/songs`, { songs: songs.map(song => song.id) })
const updatedSongs = await http.post<Song[]>(`playlists/${playlist.id}/songs`, {
songs: songs.map(song => song.id)
})
songStore.syncWithVault(updatedSongs)
cache.remove(['playlist.songs', playlist.id])
return playlist

View file

@ -222,7 +222,7 @@ new class extends UnitTestCase {
it('fetches for playlist', async () => {
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
const playlist = factory<Playlist>('playlist', { id: '966268ea-935d-4f63-a84e-180385376a78' })
const getMock = this.mock(http, 'get').mockResolvedValueOnce(songs)
const syncMock = this.mock(songStore, 'syncWithVault', songs)
@ -235,7 +235,7 @@ new class extends UnitTestCase {
it('fetches for playlist with cache', async () => {
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
const playlist = factory<Playlist>('playlist', { id: '966268ea-935d-4f63-a84e-180385376a78' })
cache.set(['playlist.songs', playlist.id], songs)
const getMock = this.mock(http, 'get')
@ -248,7 +248,7 @@ new class extends UnitTestCase {
it('fetches for playlist discarding cache', async () => {
const songs = factory<Song>('song', 3)
const playlist = factory<Playlist>('playlist', { id: 42 })
const playlist = factory<Playlist>('playlist', { id: '966268ea-935d-4f63-a84e-180385376a78' })
cache.set(['playlist.songs', playlist.id], songs)
const getMock = this.mock(http, 'get').mockResolvedValueOnce([])

View file

@ -174,7 +174,7 @@ export const songStore = {
))
},
async fetchForPlaylist (playlist: Playlist | string, refresh = false) {
async fetchForPlaylist (playlist: Playlist | Playlist['id'], refresh = false) {
const id = typeof playlist === 'string' ? playlist : playlist.id
if (refresh) {
@ -197,7 +197,7 @@ export const songStore = {
return uniqBy(songs, 'id')
},
async paginateForGenre (genre: Genre | string, params: GenreSongListPaginateParams) {
async paginateForGenre (genre: Genre | Genre['name'], params: GenreSongListPaginateParams) {
const name = typeof genre === 'string' ? genre : genre.name
const resource = await http.get<PaginatorResource>(`genres/${name}/songs?${new URLSearchParams(params).toString()}`)
const songs = this.syncWithVault(resource.data)
@ -208,7 +208,7 @@ export const songStore = {
}
},
async fetchRandomForGenre (genre: Genre | string, limit = 500) {
async fetchRandomForGenre (genre: Genre | Genre['name'], limit = 500) {
const name = typeof genre === 'string' ? genre : genre.name
return this.syncWithVault(await http.get<Song[]>(`genres/${name}/songs/random?limit=${limit}`))
},
@ -251,10 +251,15 @@ export const songStore = {
},
async privatize (songs: Song[]) {
await http.put('songs/privatize', {
songs: songs.map(song => song.id)
const privatizedIds = await http.put<Song['id'][]>('songs/privatize', {
songs: songs.map(({ id }) => id)
})
songs.forEach(song => song.is_public = false)
privatizedIds.forEach(id => {
const song = this.byId(id)
song && (song.is_public = false)
})
return privatizedIds
}
}

View file

@ -232,9 +232,9 @@ interface Playlist {
name: string
folder_id: PlaylistFolder['id'] | null
is_smart: boolean
is_collaborative: boolean
rules: SmartPlaylistRuleGroup[]
own_songs_only: boolean
collaborators: PlaylistCollaborator[]
}
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList

View file

@ -10,22 +10,6 @@ use Tests\TestCase;
class AlbumInformationTest extends TestCase
{
private const JSON_STRUCTURE = [
'url',
'cover',
'wiki' => [
'summary',
'full',
],
'tracks' => [
'*' => [
'title',
'length',
'url',
],
],
];
public function testGet(): void
{
config(['koel.lastfm.key' => 'foo']);
@ -59,7 +43,7 @@ class AlbumInformationTest extends TestCase
));
$this->getAs('api/albums/' . $album->id . '/information')
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(AlbumInformation::JSON_STRUCTURE);
}
public function testGetWithoutLastfmStillReturnsValidStructure(): void
@ -71,6 +55,6 @@ class AlbumInformationTest extends TestCase
$album = Album::factory()->create();
$this->getAs('api/albums/' . $album->id . '/information')
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(AlbumInformation::JSON_STRUCTURE);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Album;
use App\Models\Song;
use Tests\TestCase;
@ -16,6 +17,6 @@ class AlbumSongTest extends TestCase
Song::factory(5)->for($album)->create();
$this->getAs('api/albums/' . $album->id . '/songs')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
}

View file

@ -2,46 +2,18 @@
namespace Tests\Feature;
use App\Http\Resources\AlbumResource;
use App\Models\Album;
use Tests\TestCase;
class AlbumTest extends TestCase
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'artist_id',
'artist_name',
'cover',
'created_at',
];
private const JSON_COLLECTION_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function testIndex(): void
{
Album::factory(10)->create();
$this->getAs('api/albums')
->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
->assertJsonStructure(AlbumResource::PAGINATION_JSON_STRUCTURE);
}
public function testShow(): void
@ -50,6 +22,6 @@ class AlbumTest extends TestCase
$album = Album::factory()->create();
$this->getAs('api/albums/' . $album->id)
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(AlbumResource::JSON_STRUCTURE);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\AlbumResource;
use App\Models\Album;
use App\Models\Artist;
use Tests\TestCase;
@ -16,6 +17,6 @@ class ArtistAlbumTest extends TestCase
Album::factory(5)->for($artist)->create();
$this->getAs('api/artists/' . $artist->id . '/albums')
->assertJsonStructure(['*' => AlbumTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => AlbumResource::JSON_STRUCTURE]);
}
}

View file

@ -10,15 +10,6 @@ use Tests\TestCase;
class ArtistInformationTest extends TestCase
{
private const JSON_STRUCTURE = [
'url',
'image',
'bio' => [
'summary',
'full',
],
];
public function testGet(): void
{
config(['koel.lastfm.key' => 'foo']);
@ -40,7 +31,7 @@ class ArtistInformationTest extends TestCase
));
$this->getAs('api/artists/' . $artist->id . '/information')
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(ArtistInformation::JSON_STRUCTURE);
}
public function testGetWithoutLastfmStillReturnsValidStructure(): void
@ -52,6 +43,6 @@ class ArtistInformationTest extends TestCase
$artist = Artist::factory()->create();
$this->getAs('api/artists/' . $artist->id . '/information')
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(ArtistInformation::JSON_STRUCTURE);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Artist;
use App\Models\Song;
use Tests\TestCase;
@ -16,6 +17,6 @@ class ArtistSongTest extends TestCase
Song::factory(5)->for($artist)->create();
$this->getAs('api/artists/' . $artist->id . '/songs')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
}

View file

@ -2,44 +2,18 @@
namespace Tests\Feature;
use App\Http\Resources\ArtistResource;
use App\Models\Artist;
use Tests\TestCase;
class ArtistTest extends TestCase
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'image',
'created_at',
];
private const JSON_COLLECTION_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function testIndex(): void
{
Artist::factory(10)->create();
$this->getAs('api/artists')
->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
->assertJsonStructure(ArtistResource::PAGINATION_JSON_STRUCTURE);
}
public function testShow(): void
@ -48,6 +22,6 @@ class ArtistTest extends TestCase
$artist = Artist::factory()->create();
$this->getAs('api/artists/' . $artist->id)
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(ArtistResource::JSON_STRUCTURE);
}
}

View file

@ -2,6 +2,9 @@
namespace Tests\Feature;
use App\Http\Resources\AlbumResource;
use App\Http\Resources\ArtistResource;
use App\Http\Resources\SongResource;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
@ -22,9 +25,9 @@ class ExcerptSearchTest extends TestCase
$this->getAs('api/search?q=foo')
->assertJsonStructure([
'songs' => ['*' => SongTest::JSON_STRUCTURE],
'artists' => ['*' => ArtistTest::JSON_STRUCTURE],
'albums' => ['*' => AlbumTest::JSON_STRUCTURE],
'songs' => ['*' => SongResource::JSON_STRUCTURE],
'artists' => ['*' => ArtistResource::JSON_STRUCTURE],
'albums' => ['*' => AlbumResource::JSON_STRUCTURE],
]);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Interaction;
use Tests\TestCase;
@ -15,6 +16,6 @@ class FavoriteSongTest extends TestCase
Interaction::factory(5)->for($user)->create(['liked' => true]);
$this->getAs('api/songs/favorite', $user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
}

View file

@ -2,19 +2,14 @@
namespace Tests\Feature;
use App\Http\Resources\GenreResource;
use App\Http\Resources\SongResource;
use App\Models\Song;
use App\Values\Genre;
use Tests\TestCase;
class GenreTest extends TestCase
{
private const JSON_STRUCTURE = [
'type',
'name',
'song_count',
'length',
];
public function testGetAllGenres(): void
{
Song::factory()->count(5)->create(['genre' => 'Rock']);
@ -22,7 +17,7 @@ class GenreTest extends TestCase
Song::factory()->count(10)->create(['genre' => '']);
$this->getAs('api/genres')
->assertJsonStructure(['*' => self::JSON_STRUCTURE])
->assertJsonStructure(['*' => GenreResource::JSON_STRUCTURE])
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5])
->assertJsonFragment(['name' => 'Pop', 'song_count' => 2])
->assertJsonFragment(['name' => Genre::NO_GENRE, 'song_count' => 10]);
@ -33,7 +28,7 @@ class GenreTest extends TestCase
Song::factory()->count(5)->create(['genre' => 'Rock']);
$this->getAs('api/genres/Rock')
->assertJsonStructure(self::JSON_STRUCTURE)
->assertJsonStructure(GenreResource::JSON_STRUCTURE)
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5]);
}
@ -47,7 +42,7 @@ class GenreTest extends TestCase
Song::factory()->count(5)->create(['genre' => 'Rock']);
$this->getAs('api/genres/Rock/songs')
->assertJsonStructure(SongTest::JSON_COLLECTION_STRUCTURE);
->assertJsonStructure(SongResource::PAGINATION_JSON_STRUCTURE);
}
public function testGetRandomSongsInGenre(): void
@ -55,6 +50,6 @@ class GenreTest extends TestCase
Song::factory()->count(5)->create(['genre' => 'Rock']);
$this->getAs('api/genres/Rock/songs/random?limit=500')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace Tests\Feature\KoelPlus;
use App\Http\Resources\PlaylistCollaborationTokenResource;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist;
use App\Models\PlaylistCollaborationToken;
use Tests\PlusTestCase;
use function Tests\create_user;
class PlaylistCollaborationTest extends PlusTestCase
{
public function testCreatePlaylistCollaborationToken(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$this->postAs("api/playlists/$playlist->id/collaborators/invite", [], $playlist->user)
->assertJsonStructure(PlaylistCollaborationTokenResource::JSON_STRUCTURE);
}
public function testAcceptPlaylistCollaborationViaToken(): void
{
/** @var PlaylistCollaborationToken $token */
$token = PlaylistCollaborationToken::factory()->create();
$user = create_user();
$this->postAs('api/playlists/collaborators/accept', ['token' => $token->token], $user)
->assertJsonStructure(PlaylistResource::JSON_STRUCTURE);
self::assertTrue($token->playlist->hasCollaborator($user));
}
}

View file

@ -2,33 +2,15 @@
namespace Tests\Feature\KoelPlus;
use App\Http\Resources\CollaborativeSongResource;
use App\Models\Playlist;
use App\Models\Song;
use Tests\Feature\SongTest as CommunitySongTest;
use Tests\PlusTestCase;
use function Tests\create_user;
class PlaylistSongTest extends PlusTestCase
{
private array $songJsonStructure;
public function setUp(): void
{
parent::setUp();
$this->songJsonStructure = CommunitySongTest::JSON_STRUCTURE + [
'collaboration' => [
'user' => [
'avatar',
'name',
],
'added_at',
'fmt_added_at',
],
];
}
public function testGetSongsInCollaborativePlaylist(): void
{
/** @var Playlist $playlist */
@ -40,7 +22,7 @@ class PlaylistSongTest extends PlusTestCase
$this->getAs("api/playlists/$playlist->id/songs", $collaborator)
->assertSuccessful()
->assertJsonStructure(['*' => $this->songJsonStructure])
->assertJsonStructure(['*' => CollaborativeSongResource::JSON_STRUCTURE])
->assertJsonCount(3);
}
@ -59,7 +41,7 @@ class PlaylistSongTest extends PlusTestCase
$this->getAs("api/playlists/$playlist->id/songs", $collaborator)
->assertSuccessful()
->assertJsonStructure(['*' => $this->songJsonStructure])
->assertJsonStructure(['*' => CollaborativeSongResource::JSON_STRUCTURE])
->assertJsonCount(3)
->assertJsonMissing(['id' => $privateSong->id]);
}
@ -75,7 +57,8 @@ class PlaylistSongTest extends PlusTestCase
$this->postAs("api/playlists/$playlist->id/songs", ['songs' => $songs->pluck('id')->all()], $collaborator)
->assertSuccessful();
self::assertArraySubset($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
$playlist->refresh();
$songs->each(static fn (Song $song) => self::assertTrue($playlist->songs->contains($song)));
}
public function testCollaboratorCanRemoveSongs(): void
@ -90,6 +73,7 @@ class PlaylistSongTest extends PlusTestCase
$this->deleteAs("api/playlists/$playlist->id/songs", ['songs' => $songs->pluck('id')->all()], $collaborator)
->assertSuccessful();
self::assertEmpty($playlist->refresh()->songs);
$playlist->refresh();
$songs->each(static fn (Song $song) => self::assertFalse($playlist->songs->contains($song)));
}
}

View file

@ -2,9 +2,9 @@
namespace Tests\Feature\KoelPlus;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist;
use App\Values\SmartPlaylistRule;
use Tests\Feature\PlaylistTest as BasePlaylistTest;
use Tests\PlusTestCase;
use function Tests\create_user;
@ -30,7 +30,7 @@ class PlaylistTest extends PlusTestCase
],
],
'own_songs_only' => true,
], $user)->assertJsonStructure(BasePlaylistTest::JSON_STRUCTURE);
], $user)->assertJsonStructure(PlaylistResource::JSON_STRUCTURE);
/** @var Playlist $playlist */
$playlist = Playlist::query()->latest()->first();
@ -54,7 +54,7 @@ class PlaylistTest extends PlusTestCase
'own_songs_only' => true,
'rules' => $playlist->rules->toArray(),
], $playlist->user)
->assertJsonStructure(BasePlaylistTest::JSON_STRUCTURE);
->assertJsonStructure(PlaylistResource::JSON_STRUCTURE);
$playlist->refresh();

View file

@ -39,7 +39,7 @@ class SongVisibilityTest extends PlusTestCase
/** @var Collection<Song> $externalSongs */
$externalSongs = Song::factory(3)->for($anotherUser, 'owner')->public()->create();
// We can't make private songs that are not ours.
// We can't Mark as Private songs that are not ours.
$this->putAs('api/songs/privatize', ['songs' => $externalSongs->pluck('id')->toArray()], $currentUser)
->assertForbidden();

View file

@ -2,6 +2,9 @@
namespace Tests\Feature;
use App\Http\Resources\AlbumResource;
use App\Http\Resources\ArtistResource;
use App\Http\Resources\SongResource;
use App\Models\Interaction;
use Tests\TestCase;
@ -17,12 +20,12 @@ class OverviewTest extends TestCase
$this->getAs('api/overview', $user)
->assertJsonStructure([
'most_played_songs' => ['*' => SongTest::JSON_STRUCTURE],
'recently_played_songs' => ['*' => SongTest::JSON_STRUCTURE],
'recently_added_albums' => ['*' => AlbumTest::JSON_STRUCTURE],
'recently_added_songs' => ['*' => SongTest::JSON_STRUCTURE],
'most_played_artists' => ['*' => ArtistTest::JSON_STRUCTURE],
'most_played_albums' => ['*' => AlbumTest::JSON_STRUCTURE],
'most_played_songs' => ['*' => SongResource::JSON_STRUCTURE],
'recently_played_songs' => ['*' => SongResource::JSON_STRUCTURE],
'recently_added_albums' => ['*' => AlbumResource::JSON_STRUCTURE],
'recently_added_songs' => ['*' => SongResource::JSON_STRUCTURE],
'most_played_artists' => ['*' => ArtistResource::JSON_STRUCTURE],
'most_played_albums' => ['*' => AlbumResource::JSON_STRUCTURE],
]);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\PlaylistFolderResource;
use App\Models\PlaylistFolder;
use Tests\TestCase;
@ -9,21 +10,13 @@ use function Tests\create_user;
class PlaylistFolderTest extends TestCase
{
private const JSON_STRUCTURE = [
'type',
'id',
'name',
'user_id',
'created_at',
];
public function testListing(): void
{
$user = create_user();
PlaylistFolder::factory()->for($user)->count(3)->create();
$this->getAs('api/playlist-folders', $user)
->assertJsonStructure(['*' => self::JSON_STRUCTURE])
->assertJsonStructure(['*' => PlaylistFolderResource::JSON_STRUCTURE])
->assertJsonCount(3, '*');
}
@ -32,7 +25,7 @@ class PlaylistFolderTest extends TestCase
$user = create_user();
$this->postAs('api/playlist-folders', ['name' => 'Classical'], $user)
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(PlaylistFolderResource::JSON_STRUCTURE);
$this->assertDatabaseHas(PlaylistFolder::class, ['name' => 'Classical', 'user_id' => $user->id]);
}
@ -43,7 +36,7 @@ class PlaylistFolderTest extends TestCase
$folder = PlaylistFolder::factory()->create(['name' => 'Metal']);
$this->patchAs('api/playlist-folders/' . $folder->id, ['name' => 'Classical'], $folder->user)
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(PlaylistFolderResource::JSON_STRUCTURE);
self::assertSame('Classical', $folder->fresh()->name);
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Playlist;
use App\Models\Song;
use Illuminate\Support\Collection;
@ -18,7 +19,7 @@ class PlaylistSongTest extends TestCase
$playlist->addSongs(Song::factory(5)->create());
$this->getAs("api/playlists/$playlist->id/songs", $playlist->user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
public function testGetSmartPlaylist(): void
@ -43,7 +44,7 @@ class PlaylistSongTest extends TestCase
]);
$this->getAs("api/playlists/$playlist->id/songs", $playlist->user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
public function testNonOwnerCannotAccessPlaylist(): void

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist;
use App\Models\Song;
use App\Values\SmartPlaylistRule;
@ -12,25 +13,13 @@ use function Tests\create_user;
class PlaylistTest extends TestCase
{
public const JSON_STRUCTURE = [
'type',
'id',
'name',
'folder_id',
'user_id',
'is_smart',
'rules',
'own_songs_only',
'created_at',
];
public function testListing(): void
{
$user = create_user();
Playlist::factory()->for($user)->count(3)->create();
$this->getAs('api/playlists', $user)
->assertJsonStructure(['*' => self::JSON_STRUCTURE])
->assertJsonStructure(['*' => PlaylistResource::JSON_STRUCTURE])
->assertJsonCount(3, '*');
}
@ -46,7 +35,7 @@ class PlaylistTest extends TestCase
'songs' => $songs->pluck('id')->all(),
'rules' => [],
], $user)
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(PlaylistResource::JSON_STRUCTURE);
/** @var Playlist $playlist */
$playlist = Playlist::query()->latest()->first();
@ -75,7 +64,7 @@ class PlaylistTest extends TestCase
'rules' => [$rule->toArray()],
],
],
], $user)->assertJsonStructure(self::JSON_STRUCTURE);
], $user)->assertJsonStructure(PlaylistResource::JSON_STRUCTURE);
/** @var Playlist $playlist */
$playlist = Playlist::query()->latest()->first();
@ -124,7 +113,7 @@ class PlaylistTest extends TestCase
$playlist = Playlist::factory()->create(['name' => 'Foo']);
$this->putAs("api/playlists/$playlist->id", ['name' => 'Bar'], $playlist->user)
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(PlaylistResource::JSON_STRUCTURE);
self::assertSame('Bar', $playlist->refresh()->name);
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\QueueState;
use App\Models\Song;
use Tests\TestCase;
@ -12,7 +13,7 @@ class QueueTest extends TestCase
{
public const QUEUE_STATE_JSON_STRUCTURE = [
'current_song',
'songs' => ['*' => SongTest::JSON_STRUCTURE],
'songs' => ['*' => SongResource::JSON_STRUCTURE],
'playback_position',
];
@ -81,11 +82,11 @@ class QueueTest extends TestCase
Song::factory(10)->create();
$this->getAs('api/queue/fetch?order=rand&limit=5')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE])
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE])
->assertJsonCount(5, '*');
$this->getAs('api/queue/fetch?order=asc&sort=title&limit=5')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE])
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE])
->assertJsonCount(5, '*');
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Interaction;
use Tests\TestCase;
@ -16,6 +17,6 @@ class RecentlyPlayedSongTest extends TestCase
Interaction::factory(5)->for($user)->create();
$this->getAs('api/songs/recently-played', $user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Song;
use Tests\TestCase;
@ -12,6 +13,6 @@ class SongSearchTest extends TestCase
Song::factory(10)->create(['title' => 'A Foo Song']);
$this->getAs('api/search/songs?q=foo')
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
->assertJsonStructure(['*' => SongResource::JSON_STRUCTURE]);
}
}

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\SongResource;
use App\Models\Album;
use App\Models\Artist;
use App\Models\Song;
@ -12,54 +13,12 @@ use function Tests\create_admin;
class SongTest extends TestCase
{
public const JSON_STRUCTURE = [
'type',
'id',
'title',
'lyrics',
'album_id',
'album_name',
'artist_id',
'artist_name',
'album_artist_id',
'album_artist_name',
'album_cover',
'length',
'liked',
'play_count',
'track',
'genre',
'year',
'disc',
'is_public',
'created_at',
];
public const JSON_COLLECTION_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function testIndex(): void
{
Song::factory(10)->create();
$this->getAs('api/songs')->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
$this->getAs('api/songs?sort=title&order=desc')->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
$this->getAs('api/songs')->assertJsonStructure(SongResource::PAGINATION_JSON_STRUCTURE);
$this->getAs('api/songs?sort=title&order=desc')->assertJsonStructure(SongResource::PAGINATION_JSON_STRUCTURE);
}
public function testShow(): void
@ -67,7 +26,7 @@ class SongTest extends TestCase
/** @var Song $song */
$song = Song::factory()->create();
$this->getAs('api/songs/' . $song->id)->assertJsonStructure(self::JSON_STRUCTURE);
$this->getAs('api/songs/' . $song->id)->assertJsonStructure(SongResource::JSON_STRUCTURE);
}
public function testDelete(): void

View file

@ -2,6 +2,7 @@
namespace Tests\Feature;
use App\Http\Resources\UserProspectResource;
use App\Mail\UserInvite;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
@ -12,8 +13,6 @@ use function Tests\create_admin;
class UserInvitationTest extends TestCase
{
private const JSON_STRUCTURE = ['id', 'name', 'email', 'is_admin'];
public function testInvite(): void
{
Mail::fake();
@ -23,7 +22,7 @@ class UserInvitationTest extends TestCase
'is_admin' => true,
], create_admin())
->assertSuccessful()
->assertJsonStructure(['*' => self::JSON_STRUCTURE]);
->assertJsonStructure(['*' => UserProspectResource::JSON_STRUCTURE]);
Mail::assertQueued(UserInvite::class, 2);
}
@ -44,7 +43,7 @@ class UserInvitationTest extends TestCase
$this->get("api/invitations?token=$prospect->invitation_token")
->assertSuccessful()
->assertJsonStructure(self::JSON_STRUCTURE);
->assertJsonStructure(UserProspectResource::JSON_STRUCTURE);
}
public function testRevoke(): void

View file

@ -0,0 +1,126 @@
<?php
namespace Tests\Integration\KoelPlus\Services;
use App\Events\NewPlaylistCollaboratorJoined;
use App\Exceptions\CannotRemoveOwnerFromPlaylistException;
use App\Exceptions\NotAPlaylistCollaboratorException;
use App\Exceptions\PlaylistCollaborationTokenExpiredException;
use App\Exceptions\SmartPlaylistsAreNotCollaborativeException;
use App\Models\Playlist;
use App\Models\PlaylistCollaborationToken;
use App\Services\PlaylistCollaborationService;
use Tests\PlusTestCase;
use function Tests\create_user;
class PlaylistCollaborationServiceTest extends PlusTestCase
{
private PlaylistCollaborationService $service;
public function setUp(): void
{
parent::setUp();
$this->service = app(PlaylistCollaborationService::class);
}
public function testCreateToken(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$token = $this->service->createToken($playlist);
self::assertNotNull($token->token);
self::assertFalse($token->expired);
self::assertSame($playlist->id, $token->playlist_id);
}
public function testCreateTokenFailsIfPlaylistIsSmart(): void
{
$this->expectException(SmartPlaylistsAreNotCollaborativeException::class);
/** @var Playlist $playlist */
$playlist = Playlist::factory()->smart()->create();
$this->service->createToken($playlist);
}
public function testAcceptUsingToken(): void
{
$this->expectsEvents(NewPlaylistCollaboratorJoined::class);
/** @var PlaylistCollaborationToken $token */
$token = PlaylistCollaborationToken::factory()->create();
$user = create_user();
self::assertFalse($token->playlist->collaborators->contains($user));
$this->service->acceptUsingToken($token->token, $user);
self::assertTrue($token->refresh()->playlist->collaborators->contains($user));
}
public function testFailsToAcceptExpiredToken(): void
{
$this->expectException(PlaylistCollaborationTokenExpiredException::class);
$this->doesntExpectEvents(NewPlaylistCollaboratorJoined::class);
/** @var PlaylistCollaborationToken $token */
$token = PlaylistCollaborationToken::factory()->create();
$user = create_user();
$this->travel(8)->days();
$this->service->acceptUsingToken($token->token, $user);
self::assertFalse($token->refresh()->playlist->collaborators->contains($user));
}
public function testGetCollaborators(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$user = create_user();
$playlist->addCollaborator($user);
$collaborators = $this->service->getCollaborators($playlist->refresh());
self::assertEqualsCanonicalizing([$playlist->user_id, $user->id], $collaborators->pluck('id')->toArray());
}
public function testRemoveCollaborator(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$user = create_user();
$playlist->addCollaborator($user);
self::assertTrue($playlist->refresh()->hasCollaborator($user));
$this->service->removeCollaborator($playlist, $user);
self::assertFalse($playlist->refresh()->hasCollaborator($user));
}
public function testCannotRemoveNonExistingCollaborator(): void
{
$this->expectException(NotAPlaylistCollaboratorException::class);
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$user = create_user();
$this->service->removeCollaborator($playlist, $user);
}
public function testCannotRemoveOwner(): void
{
$this->expectException(CannotRemoveOwnerFromPlaylistException::class);
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$this->service->removeCollaborator($playlist, $playlist->user);
}
}

View file

@ -2,37 +2,43 @@
namespace Tests\Integration\KoelPlus\Services;
use App\Facades\License;
use App\Models\Playlist;
use App\Models\Song;
use App\Services\License\FakePlusLicenseService;
use Tests\Integration\Services\SmartPlaylistServiceTest as BaseSmartPlaylistServiceTest;
use App\Services\SmartPlaylistService;
use Illuminate\Support\Str;
use Tests\PlusTestCase;
use function Tests\create_user;
class SmartPlaylistServiceTest extends BaseSmartPlaylistServiceTest
class SmartPlaylistServiceTest extends PlusTestCase
{
private SmartPlaylistService $service;
public function setUp(): void
{
parent::setUp();
License::swap(app()->make(FakePlusLicenseService::class));
$this->service = app(SmartPlaylistService::class);
}
public function testOwnSongsOnlyOption(): void
{
$owner = create_user();
$matches = Song::factory()->count(3)->for($owner, 'owner')->create(['title' => 'Foo Something']);
Song::factory()->count(2)->create(['title' => 'Foo Something']);
Song::factory()->count(3)->create(['title' => 'Bar Something']);
$this->assertMatchesAgainstRules(
matches: $matches,
rules: [
[
'id' => 'aaf61bc3-3bdf-4fa4-b9f3-f7f7838ed502',
/** @var Playlist $playlist */
$playlist = Playlist::factory()
->for($owner)
->create([
'rules' => [
[
'id' => '70b08372-b733-4fe2-aedb-639f77120d6d',
'id' => Str::uuid()->toString(),
'rules' => [
[
'id' => Str::uuid()->toString(),
'model' => 'title',
'operator' => 'is',
'value' => ['Foo Something'],
@ -40,8 +46,12 @@ class SmartPlaylistServiceTest extends BaseSmartPlaylistServiceTest
],
],
],
owner: $owner,
ownSongsOnly: true
'own_songs_only' => true,
]);
self::assertEqualsCanonicalizing(
$matches->pluck('id')->all(),
$this->service->getSongs($playlist, $owner)->pluck('id')->all()
);
}
}

View file

@ -538,15 +538,9 @@ class SmartPlaylistServiceTest extends TestCase
Collection $matches,
array $rules,
?User $owner = null,
bool $ownSongsOnly = false
): void {
/** @var Playlist $playlist */
$playlist = Playlist::factory()
->for($owner ?? create_admin())
->create([
'rules' => $rules,
'own_songs_only' => $ownSongsOnly,
]);
$playlist = Playlist::factory()->for($owner ?? create_admin())->create(['rules' => $rules]);
self::assertEqualsCanonicalizing(
$matches->pluck('id')->all(),

View file

@ -0,0 +1,31 @@
<?php
namespace Tests\Unit\Listeners;
use App\Events\NewPlaylistCollaboratorJoined;
use App\Listeners\MakePlaylistSongsPublic;
use App\Models\PlaylistCollaborationToken;
use App\Services\PlaylistService;
use Mockery;
use Tests\TestCase;
use function Tests\create_user;
class MakePlaylistSongsPublicTest extends TestCase
{
public function testHandle(): void
{
$collaborator = create_user();
/** @var PlaylistCollaborationToken $token */
$token = PlaylistCollaborationToken::factory()->create();
$service = Mockery::mock(PlaylistService::class);
$service->shouldReceive('makePlaylistSongsPublic')
->with($token->playlist)
->once();
(new MakePlaylistSongsPublic($service))->handle(new NewPlaylistCollaboratorJoined($collaborator, $token));
}
}