feat(plust): playlist collaboration

This commit is contained in:
Phan An 2024-01-18 12:13:05 +01:00
parent fb6f975067
commit 9dc23f319e
115 changed files with 1211 additions and 420 deletions

View file

@ -2,7 +2,6 @@
namespace App\Facades; namespace App\Facades;
use App\Services\License\FakePlusLicenseService;
use Illuminate\Support\Facades\Facade; use Illuminate\Support\Facades\Facade;
/** /**
@ -16,9 +15,4 @@ class License extends Facade
{ {
return 'License'; return 'License';
} }
public static function fakePlusLicense(): void
{
self::swap(app(FakePlusLicenseService::class));
}
} }

View file

@ -71,3 +71,8 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed
{ {
return !value($condition) ? attempt($callback, $log) : null; return !value($condition) ? attempt($callback, $log) : null;
} }
function gravatar(string $email, int $size = 192): string
{
return sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($email));
}

View file

@ -8,6 +8,7 @@ use App\Http\Resources\PlaylistResource;
use App\Http\Resources\QueueStateResource; use App\Http\Resources\QueueStateResource;
use App\Http\Resources\UserResource; use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use App\Repositories\PlaylistRepository;
use App\Repositories\SettingRepository; use App\Repositories\SettingRepository;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;
use App\Services\ApplicationInformationService; use App\Services\ApplicationInformationService;
@ -26,6 +27,7 @@ class FetchInitialDataController extends Controller
ITunesService $iTunesService, ITunesService $iTunesService,
SettingRepository $settingRepository, SettingRepository $settingRepository,
SongRepository $songRepository, SongRepository $songRepository,
PlaylistRepository $playlistRepository,
ApplicationInformationService $applicationInformationService, ApplicationInformationService $applicationInformationService,
QueueService $queueService, QueueService $queueService,
LicenseServiceInterface $licenseService, LicenseServiceInterface $licenseService,
@ -35,7 +37,7 @@ class FetchInitialDataController extends Controller
return response()->json([ return response()->json([
'settings' => $user->is_admin ? $settingRepository->getAllAsKeyValueArray() : [], 'settings' => $user->is_admin ? $settingRepository->getAllAsKeyValueArray() : [],
'playlists' => PlaylistResource::collection($user->playlists), 'playlists' => PlaylistResource::collection($playlistRepository->getAllAccessibleByUser($user)),
'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders), 'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders),
'current_user' => UserResource::make($user, true), 'current_user' => UserResource::make($user, true),
'uses_last_fm' => LastfmService::used(), 'uses_last_fm' => LastfmService::used(),

View file

@ -8,6 +8,7 @@ use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest;
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest; use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
use App\Services\S3Service; use App\Services\S3Service;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class SongController extends Controller class SongController extends Controller
{ {
@ -17,19 +18,19 @@ class SongController extends Controller
public function put(PutSongRequest $request) public function put(PutSongRequest $request)
{ {
$artist = array_get($request->tags, 'artist', ''); $artist = Arr::get($request->tags, 'artist', '');
$song = $this->s3Service->createSongEntry( $song = $this->s3Service->createSongEntry(
$request->bucket, $request->bucket,
$request->key, $request->key,
$artist, $artist,
array_get($request->tags, 'album'), Arr::get($request->tags, 'album'),
trim(array_get($request->tags, 'albumartist')), trim(Arr::get($request->tags, 'albumartist')),
array_get($request->tags, 'cover'), Arr::get($request->tags, 'cover'),
trim(array_get($request->tags, 'title', '')), trim(Arr::get($request->tags, 'title', '')),
(int) array_get($request->tags, 'duration', 0), (int) Arr::get($request->tags, 'duration', 0),
(int) array_get($request->tags, 'track'), (int) Arr::get($request->tags, 'track'),
(string) array_get($request->tags, 'lyrics', '') (string) Arr::get($request->tags, 'lyrics', '')
); );
return response()->json($song); return response()->json($song);

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\API\PlaylistCollaboration;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistResource;
use App\Models\User;
use App\Services\PlaylistCollaborationService;
use Illuminate\Contracts\Auth\Authenticatable;
class AcceptPlaylistCollaborationController extends Controller
{
/** @param User $user */
public function __invoke(PlaylistCollaborationService $service, Authenticatable $user)
{
$playlist = $service->acceptUsingToken(request()->input('token'), $user);
return PlaylistResource::make($playlist);
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\API\PlaylistCollaboration;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistCollaborationTokenResource;
use App\Models\Playlist;
use App\Services\PlaylistCollaborationService;
use Illuminate\Contracts\Auth\Authenticatable;
class CreatePlaylistCollaborationTokenController extends Controller
{
public function __invoke(
Playlist $playlist,
PlaylistCollaborationService $collaborationService,
Authenticatable $user
) {
$this->authorize('invite-collaborators', $playlist);
return PlaylistCollaborationTokenResource::make($collaborationService->createToken($playlist));
}
}

View file

@ -12,6 +12,7 @@ use App\Models\Playlist;
use App\Models\PlaylistFolder; use App\Models\PlaylistFolder;
use App\Models\User; use App\Models\User;
use App\Repositories\PlaylistFolderRepository; use App\Repositories\PlaylistFolderRepository;
use App\Repositories\PlaylistRepository;
use App\Services\PlaylistService; use App\Services\PlaylistService;
use App\Values\SmartPlaylistRuleGroupCollection; use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
@ -23,6 +24,7 @@ class PlaylistController extends Controller
/** @param User $user */ /** @param User $user */
public function __construct( public function __construct(
private PlaylistService $playlistService, private PlaylistService $playlistService,
private PlaylistRepository $playlistRepository,
private PlaylistFolderRepository $folderRepository, private PlaylistFolderRepository $folderRepository,
private ?Authenticatable $user private ?Authenticatable $user
) { ) {
@ -30,7 +32,7 @@ class PlaylistController extends Controller
public function index() public function index()
{ {
return PlaylistResource::collection($this->user->playlists); return PlaylistResource::collection($this->playlistRepository->getAllAccessibleByUser($this->user));
} }
public function store(PlaylistStoreRequest $request) public function store(PlaylistStoreRequest $request)

View file

@ -2,9 +2,11 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Facades\License;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\API\AddSongsToPlaylistRequest; use App\Http\Requests\API\AddSongsToPlaylistRequest;
use App\Http\Requests\API\RemoveSongsFromPlaylistRequest; use App\Http\Requests\API\RemoveSongsFromPlaylistRequest;
use App\Http\Resources\CollaborativeSongResource;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\User; use App\Models\User;
@ -27,35 +29,39 @@ class PlaylistSongController extends Controller
public function index(Playlist $playlist) public function index(Playlist $playlist)
{ {
$this->authorize('own', $playlist); if ($playlist->is_smart) {
$this->authorize('own', $playlist);
return SongResource::collection($this->smartPlaylistService->getSongs($playlist, $this->user));
}
return SongResource::collection( $this->authorize('collaborate', $playlist);
$playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist, $this->user) $songs = $this->songRepository->getByStandardPlaylist($playlist, $this->user);
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
); return License::isPlus()
? CollaborativeSongResource::collection($songs)
: SongResource::collection($songs);
} }
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request) public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
{ {
$this->authorize('own', $playlist); abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
$this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user) $this->authorize('collaborate', $playlist);
->each(fn ($song) => $this->authorize('access', $song));
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN); $songs = $this->songRepository->getMany(ids: $request->songs, scopedUser: $this->user);
$songs->each(fn ($song) => $this->authorize('access', $song));
$this->playlistService->addSongsToPlaylist($playlist, $request->songs); $this->playlistService->addSongsToPlaylist($playlist, $songs, $this->user);
return response()->noContent(); return response()->noContent();
} }
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request) public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
{ {
$this->authorize('own', $playlist); abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN); $this->authorize('collaborate', $playlist);
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs); $this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
return response()->noContent(); return response()->noContent();

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use Carbon\Carbon;
class CollaborativeSongResource extends SongResource
{
/** @return array<mixed> */
public function toArray($request): array
{
return array_merge(parent::toArray($request), [
'collaboration' => [
'user' => [
'avatar' => gravatar($this->song->collaborator_email),
'name' => $this->song->collaborator_name,
],
'added_at' => $this->song->added_at,
'fmt_added_at' => $this->song->added_at ? Carbon::make($this->song->added_at)->diffForHumans() : null,
],
]);
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace App\Http\Resources;
use App\Models\PlaylistCollaborationToken;
use Illuminate\Http\Resources\Json\JsonResource;
class PlaylistCollaborationTokenResource extends JsonResource
{
public function __construct(private PlaylistCollaborationToken $token)
{
parent::__construct($token);
}
/** @return array<mixed> */
public function toArray($request): array
{
return [
'type' => 'playlist_collaboration_tokens',
'token' => $this->token->token,
];
}
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Resources; namespace App\Http\Resources;
use App\Facades\License;
use App\Models\Playlist; use App\Models\Playlist;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@ -23,6 +24,7 @@ class PlaylistResource extends JsonResource
'user_id' => $this->playlist->user_id, 'user_id' => $this->playlist->user_id,
'is_smart' => $this->playlist->is_smart, 'is_smart' => $this->playlist->is_smart,
'rules' => $this->playlist->rules, 'rules' => $this->playlist->rules,
'collaborators' => License::isPlus() ? UserResource::collection($this->playlist->collaborators) : [],
'own_songs_only' => $this->playlist->own_songs_only, 'own_songs_only' => $this->playlist->own_songs_only,
'created_at' => $this->playlist->created_at, 'created_at' => $this->playlist->created_at,
]; ];

View file

@ -7,7 +7,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
class SongResource extends JsonResource class SongResource extends JsonResource
{ {
public function __construct(private Song $song) public function __construct(protected Song $song)
{ {
parent::__construct($song); parent::__construct($song);
} }

View file

@ -3,6 +3,7 @@
namespace App\Models; namespace App\Models;
use App\Casts\SmartPlaylistRulesCast; use App\Casts\SmartPlaylistRulesCast;
use App\Facades\License as LicenseFacade;
use App\Values\SmartPlaylistRuleGroupCollection; use App\Values\SmartPlaylistRuleGroupCollection;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@ -10,11 +11,13 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Laravel\Scout\Searchable; use Laravel\Scout\Searchable;
/** /**
* @property int $id * @property string $id
* @property string $name * @property string $name
* @property bool $is_smart * @property bool $is_smart
* @property int $user_id * @property int $user_id
@ -26,6 +29,7 @@ use Laravel\Scout\Searchable;
* @property ?SmartPlaylistRuleGroupCollection $rules * @property ?SmartPlaylistRuleGroupCollection $rules
* @property Carbon $created_at * @property Carbon $created_at
* @property bool $own_songs_only * @property bool $own_songs_only
* @property Collection|array<array-key, User> $collaborators
*/ */
class Playlist extends Model class Playlist extends Model
{ {
@ -33,18 +37,28 @@ class Playlist extends Model
use HasFactory; use HasFactory;
protected $hidden = ['user_id', 'created_at', 'updated_at']; protected $hidden = ['user_id', 'created_at', 'updated_at'];
protected $guarded = ['id']; protected $guarded = [];
protected $casts = [ protected $casts = [
'rules' => SmartPlaylistRulesCast::class, 'rules' => SmartPlaylistRulesCast::class,
'own_songs_only' => 'bool', 'own_songs_only' => 'bool',
]; ];
public $incrementing = false;
protected $keyType = 'string';
protected $appends = ['is_smart']; protected $appends = ['is_smart'];
protected $with = ['collaborators'];
protected static function booted(): void
{
static::creating(static function (Playlist $playlist): void {
$playlist->id ??= Str::uuid()->toString();
});
}
public function songs(): BelongsToMany public function songs(): BelongsToMany
{ {
return $this->belongsToMany(Song::class); return $this->belongsToMany(Song::class)->withTimestamps();
} }
public function user(): BelongsTo public function user(): BelongsTo
@ -57,6 +71,16 @@ class Playlist extends Model
return $this->belongsTo(PlaylistFolder::class); return $this->belongsTo(PlaylistFolder::class);
} }
public function collaborationTokens(): HasMany
{
return $this->hasMany(PlaylistCollaborationToken::class);
}
public function collaborators(): BelongsToMany
{
return $this->belongsToMany(User::class, 'playlist_collaborators')->withTimestamps();
}
protected function isSmart(): Attribute protected function isSmart(): Attribute
{ {
return Attribute::get(fn (): bool => (bool) $this->rule_groups?->isNotEmpty()); return Attribute::get(fn (): bool => (bool) $this->rule_groups?->isNotEmpty());
@ -68,6 +92,61 @@ class Playlist extends Model
return Attribute::get(fn () => $this->rules); return Attribute::get(fn () => $this->rules);
} }
public function ownedBy(User $user): bool
{
return $this->user_id === $user->id;
}
public function inFolder(PlaylistFolder $folder): bool
{
return $this->folder_id === $folder->id;
}
public function addCollaborator(User $user): void
{
if (!$this->hasCollaborator($user)) {
$this->collaborators()->attach($user);
}
}
public function hasCollaborator(User $user): bool
{
return $this->collaborators->contains(static function (User $collaborator) use ($user): bool {
return $collaborator->is($user);
});
}
/**
* @param Collection|array<array-key, Song>|Song|array<string> $songs
*/
public function addSongs(Collection|Song|array $songs, ?User $collaborator = null): void
{
$collaborator ??= $this->user;
if (!is_array($songs)) {
$songs = Collection::wrap($songs)->pluck('id')->all();
}
$this->songs()->attach($songs, ['user_id' => $collaborator->id]);
}
/**
* @param Collection|array<array-key, Song>|Song|array<string> $songs
*/
public function removeSongs(Collection|Song|array $songs): void
{
if (!is_array($songs)) {
$songs = Collection::wrap($songs)->pluck('id')->all();
}
$this->songs()->detach($songs);
}
protected function isCollaborative(): Attribute
{
return Attribute::get(fn (): bool => LicenseFacade::isPlus() && $this->collaborators->isNotEmpty());
}
/** @return array<mixed> */ /** @return array<mixed> */
public function toSearchableArray(): array public function toSearchableArray(): array
{ {

View file

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
/**
* @property string $token
* @property Carbon $created_at
* @property-read bool $expired
* @property Playlist $playlist
*/
class PlaylistCollaborationToken extends Model
{
protected static function booted(): void
{
static::creating(static function (PlaylistCollaborationToken $token): void {
$token->token ??= Str::uuid()->toString();
});
}
public function playlist(): BelongsTo
{
return $this->belongsTo(Playlist::class);
}
protected function expired(): Attribute
{
return Attribute::get(fn (): bool => $this->created_at->addDays(7)->isPast());
}
}

View file

@ -40,4 +40,9 @@ class PlaylistFolder extends Model
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);
} }
public function ownedBy(User $user): bool
{
return $this->user_id === $user->id;
}
} }

View file

@ -37,6 +37,11 @@ use Laravel\Scout\Searchable;
* @property int $owner_id * @property int $owner_id
* @property bool $is_public * @property bool $is_public
* @property User $owner * @property User $owner
*
* // The following are only available for collaborative playlists
* @property-read ?string $collaborator_email The email of the user who added the song to the playlist
* @property-read ?string $collaborator_name The name of the user who added the song to the playlist
* @property-read ?string $added_at The date the song was added to the playlist
*/ */
class Song extends Model class Song extends Model
{ {

View file

@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
@ -32,6 +33,7 @@ use Laravel\Sanctum\PersonalAccessToken;
* @property ?string $invitation_token * @property ?string $invitation_token
* @property ?Carbon $invited_at * @property ?Carbon $invited_at
* @property-read bool $is_prospect * @property-read bool $is_prospect
* @property Collection|array<array-key, Playlist> $collaboratedPlaylists
*/ */
class User extends Authenticatable class User extends Authenticatable
{ {
@ -58,6 +60,11 @@ class User extends Authenticatable
return $this->hasMany(Playlist::class); return $this->hasMany(Playlist::class);
} }
public function collaboratedPlaylists(): BelongsToMany
{
return $this->belongsToMany(Playlist::class, 'playlist_collaborators')->withTimestamps();
}
public function playlist_folders(): HasMany // @phpcs:ignore public function playlist_folders(): HasMany // @phpcs:ignore
{ {
return $this->hasMany(PlaylistFolder::class); return $this->hasMany(PlaylistFolder::class);
@ -70,9 +77,7 @@ class User extends Authenticatable
protected function avatar(): Attribute protected function avatar(): Attribute
{ {
return Attribute::get( return Attribute::get(fn (): string => gravatar($this->email));
fn () => sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($this->email))
);
} }
/** /**

View file

@ -9,6 +9,6 @@ class PlaylistFolderPolicy
{ {
public function own(User $user, PlaylistFolder $folder): bool public function own(User $user, PlaylistFolder $folder): bool
{ {
return $folder->user->is($user); return $folder->ownedBy($user);
} }
} }

View file

@ -2,6 +2,7 @@
namespace App\Policies; namespace App\Policies;
use App\Facades\License;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\User; use App\Models\User;
@ -9,11 +10,29 @@ class PlaylistPolicy
{ {
public function own(User $user, Playlist $playlist): bool public function own(User $user, Playlist $playlist): bool
{ {
return $playlist->user->is($user); return $playlist->ownedBy($user);
} }
public function download(User $user, Playlist $playlist): bool public function download(User $user, Playlist $playlist): bool
{ {
return $this->own($user, $playlist); return $this->own($user, $playlist);
} }
public function inviteCollaborators(User $user, Playlist $playlist): bool
{
return $this->own($user, $playlist) && !$playlist->is_smart && License::isPlus();
}
public function collaborate(User $user, Playlist $playlist): bool
{
if ($this->own($user, $playlist)) {
return true;
}
if (!License::isPlus()) {
return false;
}
return $playlist->hasCollaborator($user);
}
} }

View file

@ -0,0 +1,21 @@
<?php
namespace App\Repositories;
use App\Facades\License;
use App\Models\Playlist;
use App\Models\User;
use Illuminate\Support\Collection;
class PlaylistRepository extends Repository
{
/** @return array<array-key, Playlist>|Collection<Playlist> */
public function getAllAccessibleByUser(User $user): Collection
{
if (License::isCommunity()) {
return $user->playlists;
}
return $user->playlists->merge($user->collaboratedPlaylists);
}
}

View file

@ -3,6 +3,7 @@
namespace App\Repositories; namespace App\Repositories;
use App\Builders\SongBuilder; use App\Builders\SongBuilder;
use App\Facades\License;
use App\Models\Album; use App\Models\Album;
use App\Models\Artist; use App\Models\Artist;
use App\Models\Playlist; use App\Models\Playlist;
@ -182,8 +183,18 @@ class SongRepository extends Repository
->withMetaFor($scopedUser) ->withMetaFor($scopedUser)
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id') ->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id') ->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
->when(License::isPlus(), static function (SongBuilder $query): SongBuilder {
return
$query->join('users as collaborators', 'playlist_song.user_id', '=', 'collaborators.id')
->addSelect(
'collaborators.name as collaborator_name',
'collaborators.email as collaborator_email',
'playlist_song.created_at as added_at'
);
})
->where('playlists.id', $playlist->id) ->where('playlists.id', $playlist->id)
->orderBy('songs.title') ->orderBy('songs.title')
->logSql()
->get(); ->get();
} }

View file

@ -0,0 +1,38 @@
<?php
namespace App\Services;
use App\Facades\License;
use App\Models\Playlist;
use App\Models\PlaylistCollaborationToken;
use App\Models\User;
use Webmozart\Assert\Assert;
class PlaylistCollaborationService
{
public function createToken(Playlist $playlist): PlaylistCollaborationToken
{
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
Assert::false($playlist->is_smart, 'Smart playlists are not collaborative.');
return $playlist->collaborationTokens()->create();
}
public function acceptUsingToken(string $token, User $user): Playlist
{
Assert::true(License::isPlus(), 'Playlist collaboration is only available with Koel Plus.');
/** @var PlaylistCollaborationToken $collaborationToken */
$collaborationToken = PlaylistCollaborationToken::query()->where('token', $token)->firstOrFail();
Assert::false($collaborationToken->expired, 'The token has expired.');
if ($collaborationToken->playlist->ownedBy($user)) {
return $collaborationToken->playlist;
}
$collaborationToken->playlist->addCollaborator($user);
return $collaborationToken->playlist;
}
}

View file

@ -2,11 +2,14 @@
namespace App\Services; namespace App\Services;
use App\Exceptions\PlaylistBothSongsAndRulesProvidedException;
use App\Facades\License; use App\Facades\License;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistFolder as Folder; use App\Models\PlaylistFolder as Folder;
use App\Models\Song;
use App\Models\User; use App\Models\User;
use App\Values\SmartPlaylistRuleGroupCollection; use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use InvalidArgumentException; use InvalidArgumentException;
use Webmozart\Assert\Assert; use Webmozart\Assert\Assert;
@ -22,9 +25,11 @@ class PlaylistService
bool $ownSongsOnly = false bool $ownSongsOnly = false
): Playlist { ): Playlist {
if ($folder) { if ($folder) {
Assert::true($user->is($folder->user), 'The playlist folder does not belong to the user'); Assert::true($folder->ownedBy($user), 'The playlist folder does not belong to the user');
} }
throw_if($songs && $ruleGroups, new PlaylistBothSongsAndRulesProvidedException());
throw_if($ownSongsOnly && (!$ruleGroups || !License::isPlus()), new InvalidArgumentException( throw_if($ownSongsOnly && (!$ruleGroups || !License::isPlus()), new InvalidArgumentException(
'"Own songs only" option only works with smart playlists and Plus license.' '"Own songs only" option only works with smart playlists and Plus license.'
)); ));
@ -40,7 +45,7 @@ class PlaylistService
]); ]);
if (!$playlist->is_smart && $songs) { if (!$playlist->is_smart && $songs) {
$playlist->songs()->sync($songs); $playlist->addSongs($songs, $user);
} }
return $playlist; return $playlist;
@ -56,7 +61,7 @@ class PlaylistService
bool $ownSongsOnly = false bool $ownSongsOnly = false
): Playlist { ): Playlist {
if ($folder) { if ($folder) {
Assert::true($playlist->user->is($folder->user), 'The playlist folder does not belong to the user'); Assert::true($playlist->ownedBy($folder->user), 'The playlist folder does not belong to the user');
} }
throw_if($ownSongsOnly && (!$playlist->is_smart || !License::isPlus()), new InvalidArgumentException( throw_if($ownSongsOnly && (!$playlist->is_smart || !License::isPlus()), new InvalidArgumentException(
@ -73,13 +78,16 @@ class PlaylistService
return $playlist; return $playlist;
} }
public function addSongsToPlaylist(Playlist $playlist, array $songIds): void public function addSongsToPlaylist(Playlist $playlist, Collection|Song|array $songs, User $user): void
{ {
$playlist->songs()->syncWithoutDetaching($songIds); $playlist->addSongs(
Collection::wrap($songs)->filter(static fn ($song): bool => !$playlist->songs->contains($song)),
$user
);
} }
public function removeSongsFromPlaylist(Playlist $playlist, array $songIds): void public function removeSongsFromPlaylist(Playlist $playlist, Collection|Song|array $songs): void
{ {
$playlist->songs()->detach($songIds); $playlist->removeSongs($songs);
} }
} }

View file

@ -36,7 +36,7 @@ return new class extends Migration
}); });
Song::all()->each(static function (Song $song): void { Song::all()->each(static function (Song $song): void {
$song->id = Str::uuid(); $song->id = Str::uuid()->toString();
$song->save(); $song->save();
}); });

View file

@ -19,7 +19,8 @@ return new class extends Migration
Schema::table('playlists', static function (Blueprint $table): void { Schema::table('playlists', static function (Blueprint $table): void {
$table->string('folder_id', 36)->nullable(); $table->string('folder_id', 36)->nullable();
$table->foreign('folder_id') $table->foreign('folder_id')
->references('id')->on('playlist_folders') ->references('id')
->on('playlist_folders')
->cascadeOnUpdate() ->cascadeOnUpdate()
->nullOnDelete(); ->nullOnDelete();
}); });

View file

@ -0,0 +1,44 @@
<?php
use App\Models\Playlist;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration {
public function up(): void
{
Schema::disableForeignKeyConstraints();
if (DB::getDriverName() !== 'sqlite') {
Schema::table('playlist_song', static function (Blueprint $table): void {
$table->dropForeign(['playlist_id']);
});
}
Schema::table('playlists', static function (Blueprint $table): void {
$table->string('id', 36)->change();
});
Schema::table('playlist_song', static function (Blueprint $table): void {
$table->string('playlist_id', 36)->change();
$table->foreign('playlist_id')->references('id')->on('playlists')->cascadeOnDelete()->cascadeOnUpdate();
});
Playlist::all()->each(static function (Playlist $playlist): void {
$oldId = $playlist->id;
$newId = Str::uuid()->toString();
$playlist->id = $newId;
$playlist->save();
DB::table('playlist_song')->where('playlist_id', $oldId)->update([
'playlist_id' => $newId,
]);
});
Schema::enableForeignKeyConstraints();
}
};

View file

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('playlist_collaborators', static function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('user_id');
$table->string('playlist_id', 36);
$table->timestamps();
});
Schema::table('playlist_collaborators', static function (Blueprint $table): void {
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
$table->foreign('playlist_id')->references('id')->on('playlists')->cascadeOnDelete()->cascadeOnUpdate();
});
}
};

View file

@ -0,0 +1,34 @@
<?php
use App\Models\Playlist;
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::disableForeignKeyConstraints();
Schema::table('playlist_song', static function (Blueprint $table): void {
$table->unsignedInteger('user_id')->nullable()->index();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete()->cascadeOnUpdate();
$table->timestamps();
});
Playlist::query()->get()->each(static function (Playlist $playlist): void {
DB::table('playlist_song')->where('playlist_id', $playlist->id)->update([
'user_id' => $playlist->user_id,
'created_at' => now(),
'updated_at' => now(),
]);
});
Schema::table('playlist_song', static function (Blueprint $table): void {
$table->unsignedInteger('user_id')->nullable(false)->change();
});
Schema::enableForeignKeyConstraints();
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('playlist_collaboration_tokens', static function (Blueprint $table): void {
$table->id();
$table->string('playlist_id', 36)->nullable(false);
$table->string('token', 36)->unique();
$table->timestamps();
});
Schema::table('playlist_collaboration_tokens', static function (Blueprint $table): void {
$table->foreign('playlist_id')->references('id')->on('playlists')->cascadeOnDelete()->cascadeOnUpdate();
});
}
};

View file

@ -20,7 +20,7 @@ export default factory
.define('album', faker => albumFactory(faker), albumStates) .define('album', faker => albumFactory(faker), albumStates)
.define('album-track', faker => albumTrackFactory(faker)) .define('album-track', faker => albumTrackFactory(faker))
.define('album-info', faker => albumInfoFactory(faker)) .define('album-info', faker => albumInfoFactory(faker))
.define('song', faker => songFactory(faker), songStates) .define('song', () => songFactory(), songStates)
.define('interaction', faker => interactionFactory(faker)) .define('interaction', faker => interactionFactory(faker))
.define('genre', faker => genreFactory(faker)) .define('genre', faker => genreFactory(faker))
.define('video', faker => youTubeVideoFactory(faker)) .define('video', faker => youTubeVideoFactory(faker))

View file

@ -3,12 +3,14 @@ import { Faker } from '@faker-js/faker'
export default (faker: Faker): Playlist => ({ export default (faker: Faker): Playlist => ({
type: 'playlists', type: 'playlists',
id: faker.datatype.number(), user_id: faker.datatype.number({ min: 1, max: 1000 }),
id: faker.datatype.uuid(),
folder_id: faker.datatype.uuid(), folder_id: faker.datatype.uuid(),
name: faker.random.word(), name: faker.random.word(),
is_smart: false, is_smart: false,
rules: [], rules: [],
ownSongsOnly: false own_songs_only: false,
collaborators: []
}) })
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = { export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {

View file

@ -1,4 +1,4 @@
import { Faker, faker } from '@faker-js/faker' import { faker } from '@faker-js/faker'
import { genres } from '@/config' import { genres } from '@/config'
const generate = (partOfCompilation = false): Song => { const generate = (partOfCompilation = false): Song => {

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
@ -68,7 +69,7 @@ new class extends UnitTestCase {
}) })
it('goes to album', async () => { it('goes to album', async () => {
const mock = this.mock(this.router, 'go') const mock = this.mock(Router, 'go')
await this.renderComponent() await this.renderComponent()
await this.user.click(screen.getByText('Go to Album')) await this.user.click(screen.getByText('Go to Album'))
@ -85,7 +86,7 @@ new class extends UnitTestCase {
}) })
it('goes to artist', async () => { it('goes to artist', async () => {
const mock = this.mock(this.router, 'go') const mock = this.mock(Router, 'go')
await this.renderComponent() await this.renderComponent()
await this.user.click(screen.getByText('Go to Artist')) await this.user.click(screen.getByText('Go to Artist'))

View file

@ -39,7 +39,6 @@ import { mediaInfoService, playbackService } from '@/services'
import { useRouter, useThirdPartyServices } from '@/composables' import { useRouter, useThirdPartyServices } from '@/composables'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue' import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
import { defaultCover } from '@/utils'
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue')) const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))

View file

@ -1,11 +1,12 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { downloadService, playbackService } from '@/services' import { downloadService, playbackService } from '@/services'
import { commonStore, songStore } from '@/stores' import { commonStore, songStore } from '@/stores'
import ArtistContextMenu from './ArtistContextMenu.vue'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import ArtistContextMenu from './ArtistContextMenu.vue'
let artist: Artist let artist: Artist
@ -16,7 +17,7 @@ new class extends UnitTestCase {
}) })
const rendered = this.render(ArtistContextMenu) const rendered = this.render(ArtistContextMenu)
eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 }, artist) eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, artist)
await this.tick(2) await this.tick(2)
return rendered return rendered
@ -68,7 +69,7 @@ new class extends UnitTestCase {
}) })
it('goes to artist', async () => { it('goes to artist', async () => {
const mock = this.mock(this.router, 'go') const mock = this.mock(Router, 'go')
await this.renderComponent() await this.renderComponent()
await screen.getByText('Go to Artist').click() await screen.getByText('Go to Artist').click()

View file

@ -1,7 +1,7 @@
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import { expect, it, Mock } from 'vitest' import { expect, it, Mock } from 'vitest'
import { userStore } from '@/stores'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { authService } from '@/services'
import LoginFrom from './LoginForm.vue' import LoginFrom from './LoginForm.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
@ -21,11 +21,11 @@ new class extends UnitTestCase {
it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot()) it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot())
it('logs in', async () => { it('logs in', async () => {
expect((await this.submitForm(this.mock(userStore, 'login'))).emitted().loggedin).toBeTruthy() expect((await this.submitForm(this.mock(authService, 'login'))).emitted().loggedin).toBeTruthy()
}) })
it('fails to log in', async () => { it('fails to log in', async () => {
const mock = this.mock(userStore, 'login').mockRejectedValue(new Error('Unauthenticated')) const mock = this.mock(authService, 'login').mockRejectedValue(new Error('Unauthenticated'))
const { emitted } = await this.submitForm(mock) const { emitted } = await this.submitForm(mock)
await this.tick() await this.tick()

View file

@ -13,8 +13,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { ref } from 'vue'
import { userStore } from '@/stores'
import { isDemo } from '@/utils' import { isDemo } from '@/utils'
import { authService } from '@/services'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/Btn.vue'
import PasswordField from '@/components/ui/PasswordField.vue' import PasswordField from '@/components/ui/PasswordField.vue'
@ -33,7 +33,7 @@ const emit = defineEmits<{ (e: 'loggedin'): void }>()
const login = async () => { const login = async () => {
try { try {
await userStore.login(email.value, password.value) await authService.login(email.value, password.value)
failed.value = false failed.value = false
// Reset the password so that the next login will have this field empty. // Reset the password so that the next login will have this field empty.

View file

@ -14,6 +14,7 @@
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width /> <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="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.is_smart" :icon="faWandMagicSparkles" fixed-width />
<Icon v-else-if="list.collaborators.length" :icon="faUsers" fixed-width />
<Icon v-else :icon="faFileLines" fixed-width /> <Icon v-else :icon="faFileLines" fixed-width />
<span>{{ list.name }}</span> <span>{{ list.name }}</span>
</a> </a>
@ -21,7 +22,13 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { faClockRotateLeft, faFileLines, faHeart, faWandMagicSparkles } from '@fortawesome/free-solid-svg-icons' import {
faClockRotateLeft,
faFileLines,
faHeart,
faUsers,
faWandMagicSparkles
} from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRefs } from 'vue' import { computed, ref, toRefs } from 'vue'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { favoriteStore } from '@/stores' import { favoriteStore } from '@/stores'
@ -110,7 +117,7 @@ onRouteChanged(route => {
break break
case 'Playlist': case 'Playlist':
active.value = (list.value as Playlist).id === parseInt(route.params!.id) active.value = (list.value as Playlist).id === route.params!.id
break break
default: default:

View file

@ -0,0 +1,48 @@
<template>
<div>
<ul>
<li v-for="user in displayedCollaborators">
<UserAvatar :user="user" width="24" />
</li>
</ul>
<span v-if="remainderCount" class="more">
+{{ remainderCount }} more
</span>
</div>
</template>
<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 displayedCollaborators = computed(() => playlist.value.collaborators.slice(0, 3))
const remainderCount = computed(() => playlist.value.collaborators.length - displayedCollaborators.value.length)
</script>
<style scoped lang="scss">
div {
display: inline-block;
vertical-align: middle;
}
ul {
display: inline-block;
vertical-align: middle;
}
li {
display: inline-block;
vertical-align: middle;
}
li + li {
margin-left: -.3rem;
}
.more {
margin-left: .3rem;
}
</style>

View file

@ -1,16 +1,21 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { screen, waitFor } from '@testing-library/vue' import { screen, waitFor } from '@testing-library/vue'
import { songStore } from '@/stores' import { songStore, userStore } from '@/stores'
import { playbackService } from '@/services' import { playbackService, playlistCollaborationService } from '@/services'
import { queueStore } from '@/stores' import { queueStore } from '@/stores'
import { MessageToasterStub } from '@/__tests__/stubs' import { MessageToasterStub } from '@/__tests__/stubs'
import PlaylistContextMenu from './PlaylistContextMenu.vue' import PlaylistContextMenu from './PlaylistContextMenu.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
private async renderComponent (playlist: Playlist) { private async renderComponent (playlist: Playlist, user: User | null = null) {
userStore.state.current = user || factory<User>('user', {
id: playlist.user_id
})
this.render(PlaylistContextMenu) this.render(PlaylistContextMenu)
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist) eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist)
await this.tick(2) await this.tick(2)
@ -52,7 +57,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs) const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(playlist) await this.renderComponent(playlist)
await this.user.click(screen.getByText('Play')) await this.user.click(screen.getByText('Play'))
@ -68,7 +73,7 @@ new class extends UnitTestCase {
const playlist = factory<Playlist>('playlist') const playlist = factory<Playlist>('playlist')
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([]) const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const warnMock = this.mock(MessageToasterStub.value, 'warning') const warnMock = this.mock(MessageToasterStub.value, 'warning')
await this.renderComponent(playlist) await this.renderComponent(playlist)
@ -88,7 +93,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs) const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(playlist) await this.renderComponent(playlist)
await this.user.click(screen.getByText('Shuffle')) await this.user.click(screen.getByText('Shuffle'))
@ -104,7 +109,7 @@ new class extends UnitTestCase {
const playlist = factory<Playlist>('playlist') const playlist = factory<Playlist>('playlist')
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([]) const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const warnMock = this.mock(MessageToasterStub.value, 'warning') const warnMock = this.mock(MessageToasterStub.value, 'warning')
await this.renderComponent(playlist) await this.renderComponent(playlist)
@ -135,5 +140,33 @@ new class extends UnitTestCase {
expect(toastMock).toHaveBeenCalledWith('Playlist added to queue.') expect(toastMock).toHaveBeenCalledWith('Playlist added to queue.')
}) })
}) })
it('does not have an option to edit or delete if the playlist is not owned by the current user', async () => {
const user = factory<User>('user')
const playlist = factory<Playlist>('playlist', {
user_id: user.id + 1
})
await this.renderComponent(playlist, user)
expect(screen.queryByText('Edit…')).toBeNull()
expect(screen.queryByText('Delete')).toBeNull()
})
it('invites collaborators', async () => {
this.enablePlusEdition()
const playlist = factory<Playlist>('playlist')
await this.renderComponent(playlist)
const createInviteLinkMock = this.mock(playlistCollaborationService, 'createInviteLink')
.mockResolvedValue('https://koel.app/invite/123')
await this.user.click(screen.getByText('Invite Collaborators'))
await waitFor(async () => {
expect(createInviteLinkMock).toHaveBeenCalledWith(playlist)
expect(await navigator.clipboard.readText()).equal('https://koel.app/invite/123')
})
})
} }
} }

View file

@ -3,25 +3,34 @@
<li @click="play">Play</li> <li @click="play">Play</li>
<li @click="shuffle">Shuffle</li> <li @click="shuffle">Shuffle</li>
<li @click="addToQueue">Add to Queue</li> <li @click="addToQueue">Add to Queue</li>
<li class="separator" /> <template v-if="canInviteCollaborators">
<li @click="edit">Edit</li> <li class="separator"></li>
<li @click="destroy">Delete</li> <li @click="inviteCollaborators">Invite Collaborators</li>
<li class="separator"></li>
</template>
<li v-if="ownedByCurrentUser" @click="edit">Edit</li>
<li v-if="ownedByCurrentUser" @click="destroy">Delete</li>
</ContextMenuBase> </ContextMenuBase>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { computed, ref } from 'vue'
import { eventBus } from '@/utils' import { copyText, eventBus } from '@/utils'
import { useContextMenu, useMessageToaster, useRouter } from '@/composables' import { useAuthorization, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
import { playbackService } from '@/services' import { playbackService, playlistCollaborationService } from '@/services'
import { songStore, queueStore } from '@/stores' import { songStore, queueStore } from '@/stores'
const { base, ContextMenuBase, open, trigger } = useContextMenu() const { base, ContextMenuBase, open, trigger } = useContextMenu()
const { go } = useRouter() const { go } = useRouter()
const { toastWarning, toastSuccess } = useMessageToaster() const { toastWarning, toastSuccess } = useMessageToaster()
const { isPlus } = useKoelPlus()
const { currentUser } = useAuthorization()
const playlist = ref<Playlist>() const playlist = ref<Playlist>()
const ownedByCurrentUser = computed(() => playlist.value?.user_id === currentUser.value?.id)
const canInviteCollaborators = computed(() => ownedByCurrentUser.value && isPlus.value && !playlist.value?.is_smart)
const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!)) const edit = () => trigger(() => eventBus.emit('MODAL_SHOW_EDIT_PLAYLIST_FORM', playlist.value!))
const destroy = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value!)) const destroy = () => trigger(() => eventBus.emit('PLAYLIST_DELETE', playlist.value!))
@ -58,6 +67,12 @@ const addToQueue = () => trigger(async () => {
} }
}) })
const inviteCollaborators = () => trigger(async () => {
const link = await playlistCollaborationService.createInviteLink(playlist.value!)
await copyText(link)
toastSuccess('Link copied to clipboard. Share it with your friends!')
})
eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event, _playlist) => { eventBus.on('PLAYLIST_CONTEXT_MENU_REQUESTED', async (event, _playlist) => {
playlist.value = _playlist playlist.value = _playlist
await open(event.pageY, event.pageX) await open(event.pageY, event.pageX)

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
@ -41,7 +42,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs) const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(folder) await this.renderComponent(folder)
await this.user.click(screen.getByText('Play All')) await this.user.click(screen.getByText('Play All'))
@ -58,7 +59,7 @@ new class extends UnitTestCase {
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([]) const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const warnMock = this.mock(MessageToasterStub.value, 'warning') const warnMock = this.mock(MessageToasterStub.value, 'warning')
await this.renderComponent(folder) await this.renderComponent(folder)
@ -78,7 +79,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs) const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(folder) await this.renderComponent(folder)
await this.user.click(screen.getByText('Shuffle All')) await this.user.click(screen.getByText('Shuffle All'))
@ -103,7 +104,7 @@ new class extends UnitTestCase {
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([]) const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
const queueMock = this.mock(playbackService, 'queueAndPlay') const queueMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const warnMock = this.mock(MessageToasterStub.value, 'warning') const warnMock = this.mock(MessageToasterStub.value, 'warning')
await this.renderComponent(folder) await this.renderComponent(folder)

View file

@ -1,8 +1,8 @@
import factory from 'factoria' import factory from 'factoria'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { screen, waitFor } from '@testing-library/vue' import { screen } from '@testing-library/vue'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { userStore } from '@/stores' import { authService } from '@/services'
import { MessageToasterStub } from '@/__tests__/stubs' import { MessageToasterStub } from '@/__tests__/stubs'
import ProfileForm from './ProfileForm.vue' import ProfileForm from './ProfileForm.vue'
@ -13,7 +13,7 @@ new class extends UnitTestCase {
protected test () { protected test () {
it('updates profile', async () => { it('updates profile', async () => {
const updateMock = this.mock(userStore, 'updateProfile') const updateMock = this.mock(authService, 'updateProfile')
const alertMock = this.mock(MessageToasterStub.value, 'success') const alertMock = this.mock(MessageToasterStub.value, 'success')
await this.renderComponent(factory<User>('user')) await this.renderComponent(factory<User>('user'))

View file

@ -55,7 +55,8 @@
<script lang="ts" setup> <script lang="ts" setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { UpdateCurrentProfileData, userStore } from '@/stores' import { userStore } from '@/stores'
import { authService, UpdateCurrentProfileData } from '@/services'
import { isDemo, logger, parseValidationError } from '@/utils' import { isDemo, logger, parseValidationError } from '@/utils'
import { useDialogBox, useMessageToaster } from '@/composables' import { useDialogBox, useMessageToaster } from '@/composables'
@ -85,7 +86,7 @@ const update = async () => {
} }
try { try {
await userStore.updateProfile(Object.assign({}, profile.value)) await authService.updateProfile(Object.assign({}, profile.value))
profile.value.current_password = null profile.value.current_password = null
delete profile.value.new_password delete profile.value.new_password
toastSuccess('Profile updated.') toastSuccess('Profile updated.')

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { screen, waitFor } from '@testing-library/vue' import { screen, waitFor } from '@testing-library/vue'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
@ -59,7 +60,7 @@ new class extends UnitTestCase {
}) })
it('goes back to list if album is deleted', async () => { it('goes back to list if album is deleted', async () => {
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const byIdMock = this.mock(albumStore, 'byId', null) const byIdMock = this.mock(albumStore, 'byId', null)
await this.renderComponent() await this.renderComponent()

View file

@ -30,6 +30,7 @@
<template #controls> <template #controls>
<SongListControls <SongListControls
v-if="songs.length && (!isPhone || showingControls)" v-if="songs.length && (!isPhone || showingControls)"
:config="config"
@filter="applyFilter" @filter="applyFilter"
@play-all="playAll" @play-all="playAll"
@play-selected="playSelected" @play-selected="playSelected"
@ -92,7 +93,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
import { eventBus, logger, pluralize } from '@/utils' import { eventBus, logger, pluralize } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores' import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { useDialogBox, useRouter, useSongList } from '@/composables' import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue' import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
@ -119,7 +120,6 @@ let info = ref<ArtistInfo | null>()
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
headerLayout, headerLayout,
songList, songList,
@ -134,6 +134,8 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(songs) } = useSongList(songs)
const { SongListControls, config } = useSongListControls('Album')
const useLastfm = toRef(commonStore.state, 'uses_last_fm') const useLastfm = toRef(commonStore.state, 'uses_last_fm')
const allowDownload = toRef(commonStore.state, 'allows_download') const allowDownload = toRef(commonStore.state, 'allows_download')

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
@ -45,7 +46,7 @@ new class extends UnitTestCase {
it('shuffles', async () => { it('shuffles', async () => {
const queueMock = this.mock(queueStore, 'fetchRandom') const queueMock = this.mock(queueStore, 'fetchRandom')
const playMock = this.mock(playbackService, 'playFirstInQueue') const playMock = this.mock(playbackService, 'playFirstInQueue')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent() await this.renderComponent()
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.')) await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))

View file

@ -17,6 +17,7 @@
<div class="controls"> <div class="controls">
<SongListControls <SongListControls
v-if="totalSongCount && (!isPhone || showingControls)" v-if="totalSongCount && (!isPhone || showingControls)"
:config="config"
@play-all="playAll" @play-all="playAll"
@play-selected="playSelected" @play-selected="playSelected"
/> />
@ -62,7 +63,7 @@ import { computed, ref, toRef, watch } from 'vue'
import { logger, pluralize, secondsToHumanReadable } from '@/utils' import { logger, pluralize, secondsToHumanReadable } from '@/utils'
import { commonStore, queueStore, songStore } from '@/stores' import { commonStore, queueStore, songStore } from '@/stores'
import { localStorageService, playbackService } from '@/services' import { localStorageService, playbackService } from '@/services'
import { useMessageToaster, useKoelPlus, useRouter, useSongList } from '@/composables' import { useMessageToaster, useKoelPlus, useRouter, useSongList, useSongListControls } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue' import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
@ -74,7 +75,6 @@ const totalDuration = computed(() => secondsToHumanReadable(commonStore.state.so
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -89,6 +89,8 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(toRef(songStore.state, 'songs')) } = useSongList(toRef(songStore.state, 'songs'))
const { SongListControls, config } = useSongListControls('Songs')
const { toastError } = useMessageToaster() const { toastError } = useMessageToaster()
const { go, onScreenActivated } = useRouter() const { go, onScreenActivated } = useRouter()
const { isPlus } = useKoelPlus() const { isPlus } = useKoelPlus()

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { screen, waitFor } from '@testing-library/vue' import { screen, waitFor } from '@testing-library/vue'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
@ -57,7 +58,7 @@ new class extends UnitTestCase {
}) })
it('goes back to list if artist is deleted', async () => { it('goes back to list if artist is deleted', async () => {
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const byIdMock = this.mock(artistStore, 'byId', null) const byIdMock = this.mock(artistStore, 'byId', null)
await this.renderComponent() await this.renderComponent()

View file

@ -29,6 +29,7 @@
<template #controls> <template #controls>
<SongListControls <SongListControls
v-if="songs.length && (!isPhone || showingControls)" v-if="songs.length && (!isPhone || showingControls)"
:config="config"
@filter="applyFilter" @filter="applyFilter"
@play-all="playAll" @play-all="playAll"
@play-selected="playSelected" @play-selected="playSelected"
@ -88,7 +89,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
import { eventBus, logger, pluralize } from '@/utils' import { eventBus, logger, pluralize } from '@/utils'
import { albumStore, artistStore, commonStore, songStore } from '@/stores' import { albumStore, artistStore, commonStore, songStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { useDialogBox, useRouter, useSongList, useThirdPartyServices } from '@/composables' import { useDialogBox, useRouter, useSongList, useSongListControls, useThirdPartyServices } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue' import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
@ -115,7 +116,6 @@ let info = ref<ArtistInfo | undefined | null>()
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
headerLayout, headerLayout,
songList, songList,
@ -130,6 +130,8 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(songs) } = useSongList(songs)
const { SongListControls, config } = useSongListControls('Artist')
const { useLastfm } = useThirdPartyServices() const { useLastfm } = useThirdPartyServices()
const allowDownload = toRef(commonStore.state, 'allows_download') const allowDownload = toRef(commonStore.state, 'allows_download')

View file

@ -26,6 +26,7 @@
<template #controls> <template #controls>
<SongListControls <SongListControls
v-if="songs.length && (!isPhone || showingControls)" v-if="songs.length && (!isPhone || showingControls)"
:config="config"
@filter="applyFilter" @filter="applyFilter"
@play-all="playAll" @play-all="playAll"
@play-selected="playSelected" @play-selected="playSelected"
@ -63,7 +64,7 @@ import { faHeart } from '@fortawesome/free-regular-svg-icons'
import { pluralize } from '@/utils' import { pluralize } from '@/utils'
import { commonStore, favoriteStore } from '@/stores' import { commonStore, favoriteStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { useRouter, useSongList } from '@/composables' import { useRouter, useSongList, useSongListControls } from '@/composables'
import { nextTick, ref, toRef } from 'vue' import { nextTick, ref, toRef } from 'vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
@ -72,7 +73,6 @@ import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -91,6 +91,8 @@ const {
sort sort
} = useSongList(toRef(favoriteStore.state, 'songs')) } = useSongList(toRef(favoriteStore.state, 'songs'))
const { SongListControls, config } = useSongListControls('Favorites')
const allowDownload = toRef(commonStore.state, 'allows_download') const allowDownload = toRef(commonStore.state, 'allows_download')
const download = () => downloadService.fromFavorites() const download = () => downloadService.fromFavorites()

View file

@ -14,7 +14,12 @@
</template> </template>
<template #controls> <template #controls>
<SongListControls v-if="!isPhone || showingControls" @play-all="playAll" @play-selected="playSelected" /> <SongListControls
v-if="!isPhone || showingControls"
:config="config"
@play-all="playAll"
@play-selected="playSelected"
/>
</template> </template>
</ScreenHeader> </ScreenHeader>
<ScreenHeaderSkeleton v-else /> <ScreenHeaderSkeleton v-else />
@ -45,7 +50,7 @@ import { faTags } from '@fortawesome/free-solid-svg-icons'
import { eventBus, logger, pluralize, secondsToHumanReadable } from '@/utils' import { eventBus, logger, pluralize, secondsToHumanReadable } from '@/utils'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { genreStore, songStore } from '@/stores' import { genreStore, songStore } from '@/stores'
import { useDialogBox, useRouter, useSongList } from '@/composables' import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
@ -54,7 +59,6 @@ import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -68,6 +72,8 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(ref<Song[]>([])) } = useSongList(ref<Song[]>([]))
const { SongListControls, config } = useSongListControls('Genre')
const { showErrorDialog } = useDialogBox() const { showErrorDialog } = useDialogBox()
const { getRouteParam, go, onRouteChanged } = useRouter() const { getRouteParam, go, onRouteChanged } = useRouter()

View file

@ -21,7 +21,7 @@ new class extends UnitTestCase {
await this.router.activateRoute({ await this.router.activateRoute({
path: `playlists/${playlist.id}`, path: `playlists/${playlist.id}`,
screen: 'Playlist' screen: 'Playlist'
}, { id: playlist.id.toString() }) }, { id: playlist.id })
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false)) await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))

View file

@ -8,7 +8,8 @@
<ThumbnailStack :thumbnails="thumbnails" /> <ThumbnailStack :thumbnails="thumbnails" />
</template> </template>
<template v-if="songs.length" #meta> <template v-if="songs.length || playlist.collaborators.length" #meta>
<CollaboratorsBadge :playlist="playlist" v-if="playlist.collaborators.length" />
<span>{{ pluralize(songs, 'song') }}</span> <span>{{ pluralize(songs, 'song') }}</span>
<span>{{ duration }}</span> <span>{{ duration }}</span>
<a <a
@ -70,26 +71,22 @@ import { ref, toRef, watch } from 'vue'
import { eventBus, pluralize } from '@/utils' import { eventBus, pluralize } from '@/utils'
import { commonStore, playlistStore, songStore } from '@/stores' import { commonStore, playlistStore, songStore } from '@/stores'
import { downloadService } from '@/services' import { downloadService } from '@/services'
import { usePlaylistManagement, useRouter, useSongList } from '@/composables' import { usePlaylistManagement, useRouter, useSongList, useAuthorization, useSongListControls } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue' import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
import CollaboratorsBadge from '@/components/playlist/CollaboratorsBadge.vue'
const { onRouteChanged, triggerNotFound, getRouteParam, onScreenActivated } = useRouter() const { currentUser } = useAuthorization()
const { triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
const playlistId = ref<number>() const playlistId = ref<string>()
const playlist = ref<Playlist>() const playlist = ref<Playlist>()
const loading = ref(false) const loading = ref(false)
const controlsConfig: Partial<SongListControlsConfig> = {
deletePlaylist: true,
refresh: true
}
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -105,9 +102,11 @@ const {
playSelected, playSelected,
applyFilter, applyFilter,
onScrollBreakpoint, onScrollBreakpoint,
sort sort,
config: listConfig
} = useSongList(ref<Song[]>([])) } = useSongList(ref<Song[]>([]))
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
const { removeSongsFromPlaylist } = usePlaylistManagement() const { removeSongsFromPlaylist } = usePlaylistManagement()
const allowDownload = toRef(commonStore.state, 'allows_download') const allowDownload = toRef(commonStore.state, 'allows_download')
@ -131,10 +130,20 @@ watch(playlistId, async id => {
if (!id) return if (!id) return
playlist.value = playlistStore.byId(id) playlist.value = playlistStore.byId(id)
playlist.value ? await fetchSongs() : await triggerNotFound()
// reset this config value to its default to not cause rows to be mal-rendered
listConfig.collaborative = false
if (playlist.value) {
await fetchSongs()
listConfig.collaborative = playlist.value.collaborators.length > 0
controlsConfig.deletePlaylist = playlist.value.user_id === currentUser.value?.id
} else {
await triggerNotFound()
}
}) })
onScreenActivated('Playlist', async () => (playlistId.value = parseInt(getRouteParam('id')!))) onScreenActivated('Playlist', () => (playlistId.value = getRouteParam('id')!))
eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs()) eventBus.on('PLAYLIST_UPDATED', async updated => updated.id === playlistId.value && await fetchSongs())
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => { .on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {

View file

@ -16,6 +16,7 @@
<template #controls> <template #controls>
<SongListControls <SongListControls
v-if="songs.length && (!isPhone || showingControls)" v-if="songs.length && (!isPhone || showingControls)"
:config="config"
@filter="applyFilter" @filter="applyFilter"
@clear-queue="clearQueue" @clear-queue="clearQueue"
@play-all="playAll" @play-all="playAll"
@ -50,11 +51,11 @@
<script lang="ts" setup> <script lang="ts" setup>
import { faCoffee } from '@fortawesome/free-solid-svg-icons' import { faCoffee } from '@fortawesome/free-solid-svg-icons'
import { computed, ref, toRef } from 'vue' import { computed, ref, toRef, watch } from 'vue'
import { logger, pluralize } from '@/utils' import { logger, pluralize } from '@/utils'
import { commonStore, queueStore, songStore } from '@/stores' import { commonStore, queueStore, songStore } from '@/stores'
import { localStorageService as storage, playbackService } from '@/services' import { localStorageService as storage, playbackService } from '@/services'
import { useDialogBox, useRouter, useSongList } from '@/composables' import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue' import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
@ -65,7 +66,6 @@ const { showErrorDialog } = useDialogBox()
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -81,6 +81,8 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(toRef(queueStore.state, 'songs')) } = useSongList(toRef(queueStore.state, 'songs'))
const { SongListControls, config } = useSongListControls('Queue')
const loading = ref(false) const loading = ref(false)
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0) const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
@ -111,7 +113,6 @@ const removeSelected = () => {
if (!selectedSongs.value.length) return if (!selectedSongs.value.length) return
const currentSongId = queueStore.current?.id const currentSongId = queueStore.current?.id
queueStore.unqueue(selectedSongs.value) queueStore.unqueue(selectedSongs.value)
if (currentSongId && selectedSongs.value.find(song => song.id === currentSongId)) { if (currentSongId && selectedSongs.value.find(song => song.id === currentSongId)) {

View file

@ -16,6 +16,7 @@
<template #controls> <template #controls>
<SongListControls <SongListControls
v-if="songs.length && (!isPhone || showingControls)" v-if="songs.length && (!isPhone || showingControls)"
:config="config"
@filter="applyFilter" @filter="applyFilter"
@play-all="playAll" @play-all="playAll"
@play-selected="playSelected" @play-selected="playSelected"
@ -41,7 +42,7 @@
import { faClock } from '@fortawesome/free-regular-svg-icons' import { faClock } from '@fortawesome/free-regular-svg-icons'
import { pluralize } from '@/utils' import { pluralize } from '@/utils'
import { recentlyPlayedStore } from '@/stores' import { recentlyPlayedStore } from '@/stores'
import { useRouter, useSongList } from '@/composables' import { useRouter, useSongList, useSongListControls } from '@/composables'
import { ref, toRef } from 'vue' import { ref, toRef } from 'vue'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
@ -52,7 +53,6 @@ const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs')
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -69,6 +69,8 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(recentlyPlayedSongs) } = useSongList(recentlyPlayedSongs)
const { SongListControls, config } = useSongListControls('RecentlyPlayed')
let initialized = false let initialized = false
let loading = ref(false) let loading = ref(false)

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { settingStore } from '@/stores' import { settingStore } from '@/stores'
@ -11,7 +12,7 @@ new class extends UnitTestCase {
it('submits the settings form', async () => { it('submits the settings form', async () => {
const updateMock = this.mock(settingStore, 'update') const updateMock = this.mock(settingStore, 'update')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
settingStore.state.media_path = '' settingStore.state.media_path = ''
this.render(SettingsScreen) this.render(SettingsScreen)
@ -27,7 +28,7 @@ new class extends UnitTestCase {
it('confirms upon media path change', async () => { it('confirms upon media path change', async () => {
const updateMock = this.mock(settingStore, 'update') const updateMock = this.mock(settingStore, 'update')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
const confirmMock = this.mock(DialogBoxStub.value, 'confirm') const confirmMock = this.mock(DialogBoxStub.value, 'confirm')
settingStore.state.media_path = '/old' settingStore.state.media_path = '/old'

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
@ -19,7 +20,7 @@ new class extends UnitTestCase {
}) })
it('goes to dedicated screen', async () => { it('goes to dedicated screen', async () => {
const mock = this.mock(this.router, 'go') const mock = this.mock(Router, 'go')
this.render(RecentlyPlayedSongs) this.render(RecentlyPlayedSongs)
await this.user.click(screen.getByRole('button', { name: 'View All' })) await this.user.click(screen.getByRole('button', { name: 'View All' }))

View file

@ -16,6 +16,7 @@
<template #controls> <template #controls>
<SongListControls <SongListControls
v-if="songs.length && (!isPhone || showingControls)" v-if="songs.length && (!isPhone || showingControls)"
:config="config"
@filter="applyFilter" @filter="applyFilter"
@play-all="playAll" @play-all="playAll"
@play-selected="playSelected" @play-selected="playSelected"
@ -31,7 +32,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref, toRef } from 'vue' import { computed, onMounted, ref, toRef } from 'vue'
import { searchStore } from '@/stores' import { searchStore } from '@/stores'
import { useRouter, useSongList } from '@/composables' import { useRouter, useSongList, useSongListControls } from '@/composables'
import { pluralize } from '@/utils' import { pluralize } from '@/utils'
import ScreenHeader from '@/components/ui/ScreenHeader.vue' import ScreenHeader from '@/components/ui/ScreenHeader.vue'
@ -42,7 +43,6 @@ const q = ref('')
const { const {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
headerLayout, headerLayout,
@ -60,6 +60,7 @@ const {
onScrollBreakpoint onScrollBreakpoint
} = useSongList(toRef(searchStore.state, 'songs')) } = useSongList(toRef(searchStore.state, 'songs'))
const { SongListControls, config } = useSongListControls('Search.Songs')
const decodedQ = computed(() => decodeURIComponent(q.value)) const decodedQ = computed(() => decodeURIComponent(q.value))
const loading = ref(false) const loading = ref(false)

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
@ -62,7 +63,7 @@ new class extends UnitTestCase {
}) })
it('goes to album details screen', async () => { it('goes to album details screen', async () => {
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(factory<Song>('song')) await this.renderComponent(factory<Song>('song'))
await this.user.click(screen.getByText('Go to Album')) await this.user.click(screen.getByText('Go to Album'))
@ -71,7 +72,7 @@ new class extends UnitTestCase {
}) })
it('goes to artist details screen', async () => { it('goes to artist details screen', async () => {
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.renderComponent(factory<Song>('song')) await this.renderComponent(factory<Song>('song'))
await this.user.click(screen.getByText('Go to Artist')) await this.user.click(screen.getByText('Go to Artist'))

View file

@ -151,7 +151,7 @@ const visibilityActions = computed(() => {
const canBeRemovedFromPlaylist = computed(() => { const canBeRemovedFromPlaylist = computed(() => {
if (!isCurrentScreen('Playlist')) return false if (!isCurrentScreen('Playlist')) return false
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!)) const playlist = playlistStore.byId(getRouteParam('id')!)
return playlist && !playlist.is_smart return playlist && !playlist.is_smart
}) })
@ -183,7 +183,7 @@ const viewArtistDetails = (artistId: number) => trigger(() => go(`artist/${artis
const download = () => trigger(() => downloadService.fromSongs(songs.value)) const download = () => trigger(() => downloadService.fromSongs(songs.value))
const removeFromPlaylist = () => trigger(async () => { const removeFromPlaylist = () => trigger(async () => {
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!)) const playlist = playlistStore.byId(getRouteParam('id')!)
if (!playlist) return if (!playlist) return
await removeSongsFromPlaylist(playlist, songs.value) await removeSongsFromPlaylist(playlist, songs.value)
@ -192,8 +192,8 @@ const removeFromPlaylist = () => trigger(async () => {
const removeFromQueue = () => trigger(() => queueStore.unqueue(songs.value)) const removeFromQueue = () => trigger(() => queueStore.unqueue(songs.value))
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(songs.value)) const removeFromFavorites = () => trigger(() => favoriteStore.unlike(songs.value))
const copyUrl = () => trigger(() => { const copyUrl = () => trigger(async () => {
copyText(songStore.getShareableUrl(songs.value[0])) await copyText(songStore.getShareableUrl(songs.value[0]))
toastSuccess('URL copied to clipboard.') toastSuccess('URL copied to clipboard.')
}) })

View file

@ -48,6 +48,10 @@
<Icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight" /> <Icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight" />
</template> </template>
</span> </span>
<template v-if="config.collaborative">
<span class="collaborator">User</span>
<span class="added-at">Added</span>
</template>
<span <span
class="time" class="time"
data-testid="header-length" data-testid="header-length"
@ -77,7 +81,7 @@
:key="item.song.id" :key="item.song.id"
:item="item" :item="item"
draggable="true" draggable="true"
@click="rowClicked(item, $event)" @click="onClick(item, $event)"
@dragleave="onDragLeave" @dragleave="onDragLeave"
@dragstart="onDragStart(item, $event)" @dragstart="onDragStart(item, $event)"
@dragenter.prevent="onDragEnter" @dragenter.prevent="onDragEnter"
@ -137,7 +141,7 @@ const songRows = ref<SongRow[]>([])
watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true }) watch(songRows, () => setSelectedSongs(songRows.value.filter(row => row.selected).map(row => row.song)), { deep: true })
const filteredSongRows = computed(() => { const filteredSongRows = computed<SongRow[]>(() => {
const keywords = filterKeywords.value.trim().toLowerCase() const keywords = filterKeywords.value.trim().toLowerCase()
if (!keywords) { if (!keywords) {
@ -223,7 +227,7 @@ const clearSelection = () => songRows.value.forEach(row => (row.selected = false
const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows() const handleA = (event: KeyboardEvent) => (event.ctrlKey || event.metaKey) && selectAllRows()
const getAllSongsWithSort = () => songRows.value.map(row => row.song) const getAllSongsWithSort = () => songRows.value.map(row => row.song)
const rowClicked = (row: SongRow, event: MouseEvent) => { const onClick = (row: SongRow, event: MouseEvent) => {
// If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection. // If we're on a touch device, or if Ctrl/Cmd key is pressed, just toggle selection.
if (isMobile.any) { if (isMobile.any) {
toggleRow(row) toggleRow(row)
@ -262,11 +266,12 @@ const selectRowsBetween = (first: SongRow, second: SongRow) => {
} }
} }
const onDragStart = (row: SongRow, event: DragEvent) => { const onDragStart = async (row: SongRow, event: DragEvent) => {
// If the user is dragging an unselected row, clear the current selection. // If the user is dragging an unselected row, clear the current selection.
if (!row.selected) { if (!row.selected) {
clearSelection() clearSelection()
row.selected = true row.selected = true
await nextTick()
} }
// Add "dragging" class to the wrapper so that we can disable pointer events on child elements. // Add "dragging" class to the wrapper so that we can disable pointer events on child elements.
@ -373,8 +378,20 @@ onMounted(() => render())
flex-basis: 27%; flex-basis: 27%;
} }
&.collaborator {
flex-basis: 72px;
text-align: center;
}
&.added-at {
flex-basis: 144px;
text-align: left;
}
&.extra { &.extra {
flex-basis: 36px; flex-basis: 36px;
text-align: center;
} }
&.play { &.play {
@ -388,10 +405,6 @@ onMounted(() => render())
&.title-artist { &.title-artist {
flex: 1; flex: 1;
} }
&.extra {
text-align: center;
}
} }
.song-list-header { .song-list-header {
@ -452,8 +465,8 @@ onMounted(() => render())
width: 200%; width: 200%;
} }
.song-item :is(.track-number, .album, .time), .song-item :is(.track-number, .album, .time, .added-at),
.song-list-header :is(.track-number, .album, .time) { .song-list-header :is(.track-number, .album, .time, .added-at) {
display: none; display: none;
} }

View file

@ -1,4 +1,4 @@
import { take } from 'lodash' import { merge, take } from 'lodash'
import { ref } from 'vue' import { ref } from 'vue'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
@ -8,13 +8,18 @@ import { screen } from '@testing-library/vue'
import SongListControls from './SongListControls.vue' import SongListControls from './SongListControls.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
private renderComponent (selectedSongCount = 1, screen: ScreenName = 'Songs') { private renderComponent (selectedSongCount = 1, configOverrides: Partial<SongListControlsConfig> = {}) {
const songs = factory<Song>('song', 5) const songs = factory<Song>('song', 5)
const config: SongListControlsConfig = merge({
this.router.activateRoute({ addTo: {
screen, queue: true,
path: '_' favorites: true
}) },
clearQueue: true,
deletePlaylist: true,
refresh: true,
filter: true
}, configOverrides)
return this.render(SongListControls, { return this.render(SongListControls, {
global: { global: {
@ -22,6 +27,9 @@ new class extends UnitTestCase {
[<symbol>SongsKey]: [ref(songs)], [<symbol>SongsKey]: [ref(songs)],
[<symbol>SelectedSongsKey]: [ref(take(songs, selectedSongCount))] [<symbol>SelectedSongsKey]: [ref(take(songs, selectedSongCount))]
} }
},
props: {
config
} }
}) })
} }
@ -64,7 +72,7 @@ new class extends UnitTestCase {
}) })
it('clears queue', async () => { it('clears queue', async () => {
const { emitted } = this.renderComponent(0, 'Queue') const { emitted } = this.renderComponent(0)
await this.user.click(screen.getByTitle('Clear current queue')) await this.user.click(screen.getByTitle('Clear current queue'))
@ -72,7 +80,7 @@ new class extends UnitTestCase {
}) })
it('deletes current playlist', async () => { it('deletes current playlist', async () => {
const { emitted } = this.renderComponent(0, 'Playlist') const { emitted } = this.renderComponent(0)
await this.user.click(screen.getByTitle('Delete this playlist')) await this.user.click(screen.getByTitle('Delete this playlist'))

View file

@ -1,5 +1,5 @@
<template> <template>
<div ref="el" class="song-list-controls" data-testid="song-list-controls"> <div ref="el" class="song-list-controls" data-testid="song-list-controls" v-if="config">
<div class="wrapper"> <div class="wrapper">
<BtnGroup uppercased> <BtnGroup uppercased>
<template v-if="altPressed"> <template v-if="altPressed">
@ -97,10 +97,10 @@
<script lang="ts" setup> <script lang="ts" setup>
import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons' import { faPlay, faRandom, faRotateRight, faTrashCan } from '@fortawesome/free-solid-svg-icons'
import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, Ref, ref, watch } from 'vue' import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, Ref, ref, toRef, watch } from 'vue'
import { SelectedSongsKey, SongsKey } from '@/symbols' import { SelectedSongsKey, SongsKey } from '@/symbols'
import { requireInjection } from '@/utils' import { requireInjection } from '@/utils'
import { useFloatingUi, useSongListControls } from '@/composables' import { useFloatingUi } from '@/composables'
import AddToMenu from '@/components/song/AddToMenu.vue' import AddToMenu from '@/components/song/AddToMenu.vue'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/Btn.vue'
@ -108,7 +108,8 @@ import BtnGroup from '@/components/ui/BtnGroup.vue'
const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue')) const SongListFilter = defineAsyncComponent(() => import('@/components/song/SongListFilter.vue'))
const config = useSongListControls().getSongListControlsConfig() const props = defineProps<{ config: SongListControlsConfig }>()
const config = toRef(props, 'config')
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey) const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
const [selectedSongs] = requireInjection(SelectedSongsKey) const [selectedSongs] = requireInjection(SelectedSongsKey)

View file

@ -25,6 +25,12 @@
</span> </span>
</span> </span>
<span class="album">{{ song.album_name }}</span> <span class="album">{{ song.album_name }}</span>
<template v-if="config.collaborative">
<span class="collaborator">
<UserAvatar :user="collaborator" width="24" />
</span>
<span class="added-at" :title="song.collaboration.added_at">{{ song.collaboration.fmt_added_at }}</span>
</template>
<span class="time">{{ fmtLength }}</span> <span class="time">{{ fmtLength }}</span>
<span class="extra"> <span class="extra">
<LikeButton :song="song" /> <LikeButton :song="song" />
@ -37,13 +43,16 @@ import { faSquareUpRight } from '@fortawesome/free-solid-svg-icons'
import { computed, toRefs } from 'vue' import { computed, toRefs } from 'vue'
import { playbackService } from '@/services' import { playbackService } from '@/services'
import { queueStore } from '@/stores' import { queueStore } from '@/stores'
import { secondsToHis } from '@/utils' import { requireInjection, secondsToHis } from '@/utils'
import { useAuthorization } from '@/composables' import { useAuthorization, useKoelPlus } from '@/composables'
import { useKoelPlus } from '@/composables' import { SongListConfigKey } from '@/symbols'
import LikeButton from '@/components/song/SongLikeButton.vue' import LikeButton from '@/components/song/SongLikeButton.vue'
import SoundBars from '@/components/ui/SoundBars.vue' import SoundBars from '@/components/ui/SoundBars.vue'
import SongThumbnail from '@/components/song/SongThumbnail.vue' import SongThumbnail from '@/components/song/SongThumbnail.vue'
import UserAvatar from '@/components/user/UserAvatar.vue'
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
const { currentUser } = useAuthorization() const { currentUser } = useAuthorization()
const { isPlus } = useKoelPlus() const { isPlus } = useKoelPlus()
@ -51,11 +60,15 @@ const { isPlus } = useKoelPlus()
const props = defineProps<{ item: SongRow }>() const props = defineProps<{ item: SongRow }>()
const { item } = toRefs(props) const { item } = toRefs(props)
const song = computed(() => item.value.song) const song = computed<Song | CollaborativeSong>(() => item.value.song)
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!)) const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id) const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
const fmtLength = secondsToHis(song.value.length) const fmtLength = secondsToHis(song.value.length)
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => {
return (song.value as CollaborativeSong).collaboration.user;
})
const play = () => { const play = () => {
queueStore.queueIfNotQueued(song.value) queueStore.queueIfNotQueued(song.value)
playbackService.play(song.value) playbackService.play(song.value)

View file

@ -2,6 +2,8 @@
exports[`renders 1`] = ` exports[`renders 1`] = `
<div class="song-list-wrap" data-testid="song-list" tabindex="0"> <div class="song-list-wrap" data-testid="song-list" tabindex="0">
<div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc"></span></div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]"> <div class="sortable song-list-header"><span class="track-number" data-testid="header-track-number" role="button" title="Sort by track number"> # <!--v-if--><!--v-if--></span><span class="title-artist" data-testid="header-title" role="button" title="Sort by title"> Title <br data-testid="Icon" icon="[object Object]" class="text-highlight"><!--v-if--></span><span class="album" data-testid="header-album" role="button" title="Sort by album"> Album <!--v-if--><!--v-if--></span>
<!--v-if--><span class="time" data-testid="header-length" role="button" title="Sort by song duration"> Time <!--v-if--><!--v-if--></span><span class="extra"><br data-testid="song-list-sorter" field="title" order="asc"></span>
</div><br data-testid="virtual-scroller" item-height="64" items="[object Object],[object Object],[object Object],[object Object],[object Object]">
</div> </div>
`; `;

View file

@ -1,3 +1,7 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = `<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span></div>`; exports[`renders 1`] = `
<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span>
<!--v-if--><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span>
</div>
`;

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { ref } from 'vue' import { ref } from 'vue'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
@ -29,27 +30,27 @@ new class extends UnitTestCase {
expect(toggleMock).toHaveBeenCalled() expect(toggleMock).toHaveBeenCalled()
}) })
it.each<[ScreenName, MethodOf<typeof songStore>]>([ it.each<[ScreenName, MethodOf<typeof songStore>, string|number]>([
['Album', 'fetchForAlbum'], ['Album', 'fetchForAlbum', 42],
['Artist', 'fetchForArtist'], ['Artist', 'fetchForArtist', 42],
['Playlist', 'fetchForPlaylist'] ['Playlist', 'fetchForPlaylist', '71d8cd40-20d4-4b17-b460-d30fe5bb7b66']
])('initiates playback for %s screen', async (screenName, fetchMethod) => { ])('initiates playback for %s screen', async (screenName, fetchMethod, id) => {
commonStore.state.song_count = 10 commonStore.state.song_count = 10
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(songStore, fetchMethod).mockResolvedValue(songs) const fetchMock = this.mock(songStore, fetchMethod).mockResolvedValue(songs)
const playMock = this.mock(playbackService, 'queueAndPlay') const playMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.router.activateRoute({ await this.router.activateRoute({
screen: screenName, screen: screenName,
path: '_' path: '_'
}, { id: '42' }) }, { id: String(id) })
this.renderComponent() this.renderComponent()
await this.user.click(screen.getByRole('button')) await this.user.click(screen.getByRole('button'))
await waitFor(() => { await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith(42) expect(fetchMock).toHaveBeenCalledWith(id)
expect(playMock).toHaveBeenCalledWith(songs) expect(playMock).toHaveBeenCalledWith(songs)
expect(goMock).toHaveBeenCalledWith('queue') expect(goMock).toHaveBeenCalledWith('queue')
}) })
@ -67,7 +68,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(store, fetchMethod).mockResolvedValue(songs) const fetchMock = this.mock(store, fetchMethod).mockResolvedValue(songs)
const playMock = this.mock(playbackService, 'queueAndPlay') const playMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.router.activateRoute({ await this.router.activateRoute({
screen: screenName, screen: screenName,
@ -89,7 +90,7 @@ new class extends UnitTestCase {
const songs = factory<Song>('song', 3) const songs = factory<Song>('song', 3)
const fetchMock = this.mock(queueStore, 'fetchRandom').mockResolvedValue(songs) const fetchMock = this.mock(queueStore, 'fetchRandom').mockResolvedValue(songs)
const playMock = this.mock(playbackService, 'queueAndPlay') const playMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.router.activateRoute({ await this.router.activateRoute({
screen: screenName, screen: screenName,
@ -110,7 +111,7 @@ new class extends UnitTestCase {
commonStore.state.song_count = 0 commonStore.state.song_count = 0
const playMock = this.mock(playbackService, 'queueAndPlay') const playMock = this.mock(playbackService, 'queueAndPlay')
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
await this.router.activateRoute({ await this.router.activateRoute({
screen: 'Songs', screen: 'Songs',

View file

@ -35,7 +35,7 @@ const initiatePlayback = async () => {
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!)) songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
break break
case 'Playlist': case 'Playlist':
songs = await songStore.fetchForPlaylist(parseInt(getRouteParam('id')!)) songs = await songStore.fetchForPlaylist(getRouteParam('id')!)
break break
case 'Favorites': case 'Favorites':
songs = await favoriteStore.fetch() songs = await favoriteStore.fetch()

View file

@ -7,22 +7,19 @@
href="/#/profile" href="/#/profile"
title="Profile and preferences" title="Profile and preferences"
> >
<img :alt="`Avatar of ${currentUser.name}`" :src="currentUser.avatar"> <UserAvatar :user="currentUser" width="40"/>
</a> </a>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useAuthorization } from '@/composables' import { useAuthorization } from '@/composables'
import UserAvatar from '@/components/user/UserAvatar.vue'
const { currentUser } = useAuthorization() const { currentUser } = useAuthorization()
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
img { img {
display: block;
width: 39px;
aspect-ratio: 1/1;
border-radius: 50%;
padding: 2px; padding: 2px;
border: 1px solid rgba(255, 255, 255, .1); border: 1px solid rgba(255, 255, 255, .1);
transition: border .2s ease-in-out; transition: border .2s ease-in-out;

View file

@ -67,6 +67,7 @@ header.screen-header {
.meta { .meta {
display: block; display: block;
margin-top: .2rem;
} }
main { main {

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { screen } from '@testing-library/vue' import { screen } from '@testing-library/vue'
@ -15,7 +16,7 @@ new class extends UnitTestCase {
}) })
it('goes to search screen when search box is focused', async () => { it('goes to search screen when search box is focused', async () => {
const mock = this.mock(this.router, 'go') const mock = this.mock(Router, 'go')
this.render(SearchForm) this.render(SearchForm)
await this.user.click(screen.getByRole('searchbox')) await this.user.click(screen.getByRole('searchbox'))
@ -33,7 +34,7 @@ new class extends UnitTestCase {
}) })
it('goes to the search screen if the form is submitted', async () => { it('goes to the search screen if the form is submitted', async () => {
const goMock = this.mock(this.router, 'go') const goMock = this.mock(Router, 'go')
this.render(SearchForm) this.render(SearchForm)
await this.type(screen.getByRole('searchbox'), 'hey') await this.type(screen.getByRole('searchbox'), 'hey')

View file

@ -1,3 +1,3 @@
// Vitest Snapshot v1 // Vitest Snapshot v1
exports[`renders 1`] = `<a data-v-663f2e50="" class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences"><img data-v-663f2e50="" alt="Avatar of John Doe" src="https://example.com/avatar.jpg"></a>`; exports[`renders 1`] = `<a data-v-663f2e50="" class="view-profile" data-testid="view-profile-link" href="/#/profile" title="Profile and preferences"><img data-v-f835091e="" data-v-663f2e50="" alt="Avatar of John Doe" src="https://example.com/avatar.jpg" title="John Doe" width="40"></a>`;

View file

@ -0,0 +1,18 @@
<template>
<img :alt="`Avatar of ${user.name}`" :src="user.avatar" :title="user.name">
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
const props = defineProps<{ user: Pick<User, 'name' | 'avatar'> }>()
const { user } = toRefs(props)
</script>
<style scoped lang="scss">
img {
border-radius: 50%;
aspect-ratio: 1/1;
background: var(--color-bg-primary);
}
</style>

View file

@ -1,3 +1,4 @@
import Router from '@/router'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
@ -5,8 +6,8 @@ import { screen } from '@testing-library/vue'
import { eventBus } from '@/utils' import { eventBus } from '@/utils'
import { userStore } from '@/stores' import { userStore } from '@/stores'
import { DialogBoxStub } from '@/__tests__/stubs' import { DialogBoxStub } from '@/__tests__/stubs'
import UserCard from './UserCard.vue'
import { invitationService } from '@/services' import { invitationService } from '@/services'
import UserCard from './UserCard.vue'
new class extends UnitTestCase { new class extends UnitTestCase {
private renderComponent (user: User) { private renderComponent (user: User) {
@ -37,7 +38,7 @@ new class extends UnitTestCase {
}) })
it('redirects to Profile screen if edit current user', async () => { it('redirects to Profile screen if edit current user', async () => {
const mock = this.mock(this.router, 'go') const mock = this.mock(Router, 'go')
const user = factory<User>('user') const user = factory<User>('user')
this.be(user).renderComponent(user) this.be(user).renderComponent(user)

View file

@ -1,6 +1,6 @@
<template> <template>
<article :class="{ me: isCurrentUser }" class="user-card"> <article :class="{ me: isCurrentUser }" class="user-card">
<img :alt="`${user.name}'s avatar`" :src="user.avatar" height="80" width="80"> <UserAvatar :user="user" width="80" />
<main> <main>
<h1> <h1>
@ -41,6 +41,7 @@ import { useAuthorization, useDialogBox, useMessageToaster, useRouter } from '@/
import { eventBus, parseValidationError } from '@/utils' import { eventBus, parseValidationError } from '@/utils'
import Btn from '@/components/ui/Btn.vue' import Btn from '@/components/ui/Btn.vue'
import UserAvatar from '@/components/user/UserAvatar.vue'
const props = defineProps<{ user: User }>() const props = defineProps<{ user: User }>()
const { user } = toRefs(props) const { user } = toRefs(props)

View file

@ -4,7 +4,7 @@
import { defineComponent, onMounted } from 'vue' import { defineComponent, onMounted } from 'vue'
import { authService } from '@/services' import { authService } from '@/services'
import { playlistFolderStore, playlistStore, userStore } from '@/stores' import { playlistFolderStore, playlistStore } from '@/stores'
import { eventBus, forceReloadWindow } from '@/utils' import { eventBus, forceReloadWindow } from '@/utils'
import { useDialogBox, useMessageToaster, useRouter } from '@/composables' import { useDialogBox, useMessageToaster, useRouter } from '@/composables'
@ -33,8 +33,7 @@ export const GlobalEventListeners = defineComponent({
go('home') go('home')
} }
}).on('LOG_OUT', async () => { }).on('LOG_OUT', async () => {
await userStore.logout() await authService.logout()
authService.destroy()
forceReloadWindow() forceReloadWindow()
}) })

View file

@ -108,7 +108,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
switch (getDragType(event)) { switch (getDragType(event)) {
case 'playlist': case 'playlist':
return playlistStore return playlistStore
.byId(parseInt(event.dataTransfer!.getData('application/x-koel.playlist'))) as T | undefined .byId(event.dataTransfer!.getData('application/x-koel.playlist')) as T | undefined
default: default:
return return
} }
@ -133,7 +133,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
const artist = await artistStore.resolve(<number>data) const artist = await artistStore.resolve(<number>data)
return artist ? await songStore.fetchForArtist(artist) : <Song[]>[] return artist ? await songStore.fetchForArtist(artist) : <Song[]>[]
case 'playlist': case 'playlist':
const playlist = playlistStore.byId(<number>data) const playlist = playlistStore.byId(<string>data)
return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[] return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[]
case 'playlist-folder': case 'playlist-folder':
const folder = playlistFolderStore.byId(<string>data) const folder = playlistFolderStore.byId(<string>data)

View file

@ -21,7 +21,7 @@ export const useRouter = () => {
getCurrentScreen, getCurrentScreen,
isCurrentScreen, isCurrentScreen,
onScreenActivated, onScreenActivated,
go: router.go.bind(router), go: Router.go,
onRouteChanged: router.onRouteChanged.bind(router), onRouteChanged: router.onRouteChanged.bind(router),
resolveRoute: router.resolve.bind(router), resolveRoute: router.resolve.bind(router),
triggerNotFound: router.triggerNotFound.bind(router) triggerNotFound: router.triggerNotFound.bind(router)

View file

@ -17,12 +17,11 @@ import {
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue' import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
import SongList from '@/components/song/SongList.vue' import SongList from '@/components/song/SongList.vue'
import SongListControls from '@/components/song/SongListControls.vue'
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue' import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
export const useSongList = ( export const useSongList = (
songs: Ref<Song[]>, songs: Ref<Song[]>,
config: Partial<SongListConfig> = { sortable: true, reorderable: true } config: Partial<SongListConfig> = { sortable: true, reorderable: true, collaborative: false }
) => { ) => {
const filterKeywords = ref('') const filterKeywords = ref('')
config = reactive(config) config = reactive(config)
@ -124,10 +123,10 @@ export const useSongList = (
return { return {
SongList, SongList,
SongListControls,
ControlsToggle, ControlsToggle,
ThumbnailStack, ThumbnailStack,
songs, songs,
config,
headerLayout, headerLayout,
sortField, sortField,
sortOrder, sortOrder,

View file

@ -1,28 +1,22 @@
import { useRouter } from '@/composables' import { merge } from 'lodash'
import { reactive } from 'vue'
export const useSongListControls = () => { import SongListControls from '@/components/song/SongListControls.vue'
const { isCurrentScreen } = useRouter()
const getSongListControlsConfig = () => { export const useSongListControls = (
const config: SongListControlsConfig = { screen: ScreenName,
play: true, configOverrides: Partial<SongListControlsConfig> | (() => Partial<SongListControlsConfig>) = {}
addTo: { ) => {
queue: true, const defaults: SongListControlsConfig = {
favorites: true play: true,
}, addTo: {
clearQueue: false, queue: screen !== 'Queue',
deletePlaylist: false, favorites: screen !== 'Favorites',
refresh: false, },
filter: false clearQueue: screen === 'Queue',
} deletePlaylist: screen === 'Playlist',
refresh: screen === 'Playlist',
config.clearQueue = isCurrentScreen('Queue') filter: [
config.addTo.queue = !isCurrentScreen('Queue')
config.addTo.favorites = !isCurrentScreen('Favorites')
config.deletePlaylist = isCurrentScreen('Playlist')
config.refresh = isCurrentScreen('Playlist')
config.filter = isCurrentScreen(
'Queue', 'Queue',
'Artist', 'Artist',
'Album', 'Album',
@ -30,12 +24,13 @@ export const useSongListControls = () => {
'RecentlyPlayed', 'RecentlyPlayed',
'Playlist', 'Playlist',
'Search.Songs' 'Search.Songs'
) ].includes(screen)
return config
} }
const config = merge(defaults, typeof configOverrides === 'function' ? configOverrides() : configOverrides)
return { return {
getSongListControlsConfig SongListControls,
config: reactive(config)
} }
} }

View file

@ -1,7 +1,11 @@
import { Route } from '@/router' import { Route } from '@/router'
import { userStore } from '@/stores' import { userStore } from '@/stores'
import { localStorageService } from '@/services' import { localStorageService, playlistCollaborationService } from '@/services'
import { useUpload } from '@/composables' import { useUpload } from '@/composables'
import { forceReloadWindow, logger } from '@/utils'
import Router from '@/router'
const UUID_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
export const routes: Route[] = [ export const routes: Route[] = [
{ {
@ -80,9 +84,23 @@ export const routes: Route[] = [
screen: 'Artist' screen: 'Artist'
}, },
{ {
path: '/playlist/(?<id>\\d+)', path: `/playlist/(?<id>${UUID_REGEX})`,
screen: 'Playlist' screen: 'Playlist'
}, },
{
path: `/playlist/collaborate/(?<id>${UUID_REGEX})`,
screen: 'Blank',
onResolve: async params => {
try {
const playlist = await playlistCollaborationService.acceptInvite(params.id)
Router.go(`/playlist/${playlist.id}`, true)
return true
} catch (e) {
logger.error(e)
return false
}
}
},
{ {
path: '/genres', path: '/genres',
screen: 'Genres' screen: 'Genres'
@ -96,7 +114,7 @@ export const routes: Route[] = [
screen: 'Visualizer' screen: 'Visualizer'
}, },
{ {
path: '/song/(?<id>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', path: `/song/(?<id>${UUID_REGEX})`,
screen: 'Queue', screen: 'Queue',
redirect: () => 'queue', redirect: () => 'queue',
onResolve: params => { onResolve: params => {
@ -105,7 +123,7 @@ export const routes: Route[] = [
} }
}, },
{ {
path: '/invitation/accept/(?<token>[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})', path: `/invitation/accept/(?<token>${UUID_REGEX})`,
screen: 'Invitation.Accept' screen: 'Invitation.Accept'
} }
] ]

View file

@ -132,7 +132,7 @@ const onUserLoggedIn = async () => {
const init = async () => { const init = async () => {
try { try {
userStore.init(await userStore.getProfile()) userStore.init(await authService.getProfile())
await socketService.init() await socketService.init()

View file

@ -1,7 +1,8 @@
import { ref, Ref, watch } from 'vue' import { ref, Ref, watch } from 'vue'
import { forceReloadWindow } from '@/utils'
type RouteParams = Record<string, string> type RouteParams = Record<string, string>
type ResolveHook = (params: RouteParams) => boolean | void type ResolveHook = (params: RouteParams) => Promise<boolean | void> | boolean | void
type RedirectHook = (params: RouteParams) => Route | string type RedirectHook = (params: RouteParams) => Route | string
export type Route = { export type Route = {
@ -44,7 +45,7 @@ export default class Router {
public async resolve () { public async resolve () {
if (!location.hash || location.hash === '#/' || location.hash === '#!/') { if (!location.hash || location.hash === '#/' || location.hash === '#!/') {
return this.go(this.homeRoute.path) return Router.go(this.homeRoute.path)
} }
const matched = this.tryMatchRoute() const matched = this.tryMatchRoute()
@ -54,13 +55,13 @@ export default class Router {
return this.triggerNotFound() return this.triggerNotFound()
} }
if (route.onResolve?.(params) === false) { if ((await route.onResolve?.(params)) === false) {
return this.triggerNotFound() return this.triggerNotFound()
} }
if (route.redirect) { if (route.redirect) {
const to = route.redirect(params) const to = route.redirect(params)
return typeof to === 'string' ? this.go(to) : this.activateRoute(to, params) return typeof to === 'string' ? Router.go(to) : this.activateRoute(to, params)
} }
return this.activateRoute(route, params) return this.activateRoute(route, params)
@ -96,7 +97,7 @@ export default class Router {
this.$currentRoute.value.params = params this.$currentRoute.value.params = params
} }
public go (path: string | number) { public static go (path: string | number, reload = false) {
if (typeof path === 'number') { if (typeof path === 'number') {
history.go(path) history.go(path)
return return
@ -112,5 +113,7 @@ export default class Router {
path = path.substring(1, path.length) path = path.substring(1, path.length)
location.assign(`${location.origin}${location.pathname}${path}`) location.assign(`${location.origin}${location.pathname}${path}`)
reload && forceReloadWindow()
} }
} }

View file

@ -1,7 +1,8 @@
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import { localStorageService } from '@/services/localStorageService'
import { expect, it } from 'vitest' import { expect, it } from 'vitest'
import { authService } from './authService' import { authService, http, localStorageService, UpdateCurrentProfileData } from '@/services'
import factory from '@/__tests__/factory'
import { userStore } from '@/stores'
new class extends UnitTestCase { new class extends UnitTestCase {
protected test () { protected test () {
@ -27,5 +28,58 @@ new class extends UnitTestCase {
authService.destroy() authService.destroy()
expect(mock).toHaveBeenCalledWith('api-token') expect(mock).toHaveBeenCalledWith('api-token')
}) })
it('logs in', async () => {
const postMock = this.mock(http, 'post').mockResolvedValue({
'audio-token': 'foo',
token: 'bar'
})
await authService.login('john@doe.com', 'curry-wurst')
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
})
it('logs out', async () => {
const deleteMock = this.mock(http, 'delete')
await authService.logout()
expect(deleteMock).toHaveBeenCalledWith('me')
})
it('gets profile', async () => {
const getMock = this.mock(http, 'get')
await authService.getProfile()
expect(getMock).toHaveBeenCalledWith('me')
})
it('updates profile', async () => {
userStore.state.current = factory<User>('user', {
id: 1,
name: 'John Doe',
email: 'john@doe.com'
})
const updated = factory<User>('user', {
id: 1,
name: 'Jane Doe',
email: 'jane@doe.com'
})
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
const data: UpdateCurrentProfileData = {
current_password: 'curry-wurst',
name: 'Jane Doe',
email: 'jane@doe.com'
}
await authService.updateProfile(data)
expect(putMock).toHaveBeenCalledWith('me', data)
expect(userStore.current.name).toBe('Jane Doe')
expect(userStore.current.email).toBe('jane@doe.com')
})
} }
} }

View file

@ -1,9 +1,42 @@
import { localStorageService } from '@/services' import { merge } from 'lodash'
import { http, localStorageService } from '@/services'
import { userStore } from '@/stores'
export interface UpdateCurrentProfileData {
current_password: string | null
name: string
email: string
avatar?: string
new_password?: string
}
interface CompositeToken {
'audio-token': string
'token': string
}
const API_TOKEN_STORAGE_KEY = 'api-token' const API_TOKEN_STORAGE_KEY = 'api-token'
const AUDIO_TOKEN_STORAGE_KEY = 'audio-token' const AUDIO_TOKEN_STORAGE_KEY = 'audio-token'
export const authService = { export const authService = {
async login (email: string, password: string) {
const token = await http.post<CompositeToken>('me', { email, password })
this.setAudioToken(token['audio-token'])
this.setApiToken(token.token)
},
async logout () {
await http.delete('me')
this.destroy()
},
getProfile: async () => await http.get<User>('me'),
updateProfile: async (data: UpdateCurrentProfileData) => {
merge(userStore.current, (await http.put<User>('me', data)))
},
getApiToken: () => localStorageService.get(API_TOKEN_STORAGE_KEY), getApiToken: () => localStorageService.get(API_TOKEN_STORAGE_KEY),
hasApiToken () { hasApiToken () {

View file

@ -29,7 +29,7 @@ class Http {
return (await this.request<T>('get', url)).data return (await this.request<T>('get', url)).data
} }
public async post<T> (url: string, data: Record<string, any>, onUploadProgress?: any) { public async post<T> (url: string, data: Record<string, any> = {}, onUploadProgress?: any) {
return (await this.request<T>('post', url, data, onUploadProgress)).data return (await this.request<T>('post', url, data, onUploadProgress)).data
} }
@ -61,13 +61,11 @@ class Http {
this.silent || this.hideLoadingIndicator() this.silent || this.hideLoadingIndicator()
this.silent = false this.silent = false
// …get the tokens from the header or response data if exist, and save them. // …get the tokens from the header if exist, and save them
const token = response.headers.authorization || response.data.token // This occurs during user updating password.
const token = response.headers.authorization
token && authService.setApiToken(token) token && authService.setApiToken(token)
const audioToken = response.data['audio-token']
audioToken && authService.setAudioToken(audioToken)
return response return response
}, error => { }, error => {
this.silent || this.hideLoadingIndicator() this.silent || this.hideLoadingIndicator()

View file

@ -13,3 +13,4 @@ export * from './socketListener'
export * from './volumeManager' export * from './volumeManager'
export * from './invitationService' export * from './invitationService'
export * from './plusService' export * from './plusService'
export * from './playlistCollaborationService'

View file

@ -89,7 +89,7 @@ class PlaybackService {
await this.restart() await this.restart()
} }
private async setNowPlayingMeta(song) { private async setNowPlayingMeta(song: Song) {
document.title = `${song.title} ♫ Koel` document.title = `${song.title} ♫ Koel`
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`) this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)

View file

@ -0,0 +1,16 @@
import { http } from '@/services'
export const playlistCollaborationService = {
async createInviteLink (playlist: Playlist) {
if (playlist.is_smart) {
throw Error('Smart playlists are not collaborative.')
}
const token = (await http.post<{ token: string }>(`playlists/${playlist.id}/collaborators/invite`)).token
return `${window.location.origin}/#/playlist/collaborate/${token}`
},
async acceptInvite (token: string) {
return http.post<Playlist>(`playlists/collaborators/accept`, { token })
}
}

View file

@ -52,7 +52,7 @@ export const playlistStore = {
}) })
}, },
byId (id: number) { byId (id: string) {
return this.state.playlists.find(playlist => playlist.id === id) return this.state.playlists.find(playlist => playlist.id === id)
}, },

View file

@ -9,6 +9,8 @@ export const queueStore = {
songs: [] songs: []
}), }),
i: 0,
init (savedState: QueueState) { init (savedState: QueueState) {
// don't set this.all here, as it would trigger saving state // don't set this.all here, as it would trigger saving state
this.state.songs = songStore.syncWithVault(savedState.songs) this.state.songs = songStore.syncWithVault(savedState.songs)

View file

@ -174,8 +174,8 @@ export const songStore = {
)) ))
}, },
async fetchForPlaylist (playlist: Playlist | number, refresh = false) { async fetchForPlaylist (playlist: Playlist | string, refresh = false) {
const id = typeof playlist === 'number' ? playlist : playlist.id const id = typeof playlist === 'string' ? playlist : playlist.id
if (refresh) { if (refresh) {
cache.remove(['playlist.songs', id]) cache.remove(['playlist.songs', id])

View file

@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
import UnitTestCase from '@/__tests__/UnitTestCase' import UnitTestCase from '@/__tests__/UnitTestCase'
import factory from '@/__tests__/factory' import factory from '@/__tests__/factory'
import { http } from '@/services' import { http } from '@/services'
import { CreateUserData, UpdateCurrentProfileData, UpdateUserData, userStore } from '.' import { CreateUserData, UpdateUserData, userStore } from '.'
const currentUser = factory<User>('user', { const currentUser = factory<User>('user', {
id: 1, id: 1,
@ -50,49 +50,6 @@ new class extends UnitTestCase {
expect(userStore.byId(2)).toEqual(user) expect(userStore.byId(2)).toEqual(user)
}) })
it('logs in', async () => {
const postMock = this.mock(http, 'post')
await userStore.login('john@doe.com', 'curry-wurst')
expect(postMock).toHaveBeenCalledWith('me', { email: 'john@doe.com', password: 'curry-wurst' })
})
it('logs out', async () => {
const deleteMock = this.mock(http, 'delete')
await userStore.logout()
expect(deleteMock).toHaveBeenCalledWith('me')
})
it('gets profile', async () => {
const getMock = this.mock(http, 'get')
await userStore.getProfile()
expect(getMock).toHaveBeenCalledWith('me')
})
it('updates profile', async () => {
const updated = factory<User>('user', {
id: 1,
name: 'Jane Doe',
email: 'jane@doe.com'
})
const putMock = this.mock(http, 'put').mockResolvedValue(updated)
const data: UpdateCurrentProfileData = {
current_password: 'curry-wurst',
name: 'Jane Doe',
email: 'jane@doe.com'
}
await userStore.updateProfile(data)
expect(putMock).toHaveBeenCalledWith('me', data)
expect(userStore.current.name).toBe('Jane Doe')
expect(userStore.current.email).toBe('jane@doe.com')
})
it('creates a user', async () => { it('creates a user', async () => {
const data: CreateUserData = { const data: CreateUserData = {
is_admin: false, is_admin: false,

View file

@ -2,15 +2,6 @@ import { differenceBy, merge } from 'lodash'
import { http } from '@/services' import { http } from '@/services'
import { reactive } from 'vue' import { reactive } from 'vue'
import { arrayify } from '@/utils' import { arrayify } from '@/utils'
import { UnwrapNestedRefs } from '@vue/reactivity'
export interface UpdateCurrentProfileData {
current_password: string | null
name: string
email: string
avatar?: string
new_password?: string
}
interface UserFormData { interface UserFormData {
name: string name: string
@ -27,7 +18,7 @@ export interface UpdateUserData extends UserFormData {
} }
export const userStore = { export const userStore = {
vault: new Map<number, UnwrapNestedRefs<User>>(), vault: new Map<number, User>(),
state: reactive({ state: reactive({
users: [] as User[], users: [] as User[],
@ -61,14 +52,6 @@ export const userStore = {
return this.state.current return this.state.current
}, },
login: async (email: string, password: string) => await http.post<User>('me', { email, password }),
logout: async () => await http.delete('me'),
getProfile: async () => await http.get<User>('me'),
async updateProfile (data: UpdateCurrentProfileData) {
merge(this.current, (await http.put<User>('me', data)))
},
async store (data: CreateUserData) { async store (data: CreateUserData) {
const user = await http.post<User>('users', data) const user = await http.post<User>('users', data)
this.add(user) this.add(user)

View file

@ -155,6 +155,14 @@ interface Song {
deleted?: boolean deleted?: boolean
} }
interface CollaborativeSong extends Song {
collaboration: {
user: Pick<User, 'name' | 'avatar'>
added_at: string | null
fmt_added_at: string | null
}
}
interface QueueState { interface QueueState {
type: 'queue-states' type: 'queue-states'
songs: Song[] songs: Song[]
@ -217,12 +225,14 @@ interface PlaylistFolder {
interface Playlist { interface Playlist {
type: 'playlists' type: 'playlists'
readonly id: number readonly id: string
readonly user_id: User['id']
name: string name: string
folder_id: PlaylistFolder['id'] | null folder_id: PlaylistFolder['id'] | null
is_smart: boolean is_smart: boolean
rules: SmartPlaylistRuleGroup[] rules: SmartPlaylistRuleGroup[]
own_songs_only: boolean own_songs_only: boolean
collaborators: User[]
} }
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
@ -302,7 +312,7 @@ interface EqualizerPreset {
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused' declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
declare type ScreenName = declare type ScreenName =
| 'Home' | 'Home'
| 'Default' | 'Default' | 'Blank'
| 'Queue' | 'Queue'
| 'Songs' | 'Songs'
| 'Albums' | 'Albums'
@ -333,7 +343,6 @@ interface AddToMenuConfig {
} }
interface SongListControlsConfig { interface SongListControlsConfig {
play: boolean
addTo: AddToMenuConfig addTo: AddToMenuConfig
clearQueue: boolean clearQueue: boolean
deletePlaylist: boolean deletePlaylist: boolean
@ -369,6 +378,7 @@ type RepeatMode = 'NO_REPEAT' | 'REPEAT_ALL' | 'REPEAT_ONE'
interface SongListConfig { interface SongListConfig {
sortable: boolean sortable: boolean
reorderable: boolean reorderable: boolean
collaborative: boolean
} }
type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'> type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>

View file

@ -17,19 +17,23 @@ export const forceReloadWindow = (): void => {
window.location.reload() window.location.reload()
} }
export const copyText = (text: string): void => { export const copyText = async (text: string) => {
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea') try {
await navigator.clipboard.writeText(text)
} catch (e) {
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea')
if (!copyArea) { if (!copyArea) {
copyArea = document.createElement('textarea') copyArea = document.createElement('textarea')
copyArea.id = 'copyArea' copyArea.id = 'copyArea'
document.body.appendChild(copyArea) document.body.appendChild(copyArea)
}
copyArea.style.top = `${window.scrollY || document.documentElement.scrollTop}px`
copyArea.value = text
select(copyArea)
document.execCommand('copy')
} }
copyArea.style.top = `${window.scrollY || document.documentElement.scrollTop}px`
copyArea.value = text
select(copyArea)
document.execCommand('copy')
} }
export const isDemo = () => { export const isDemo = () => {

View file

@ -24,6 +24,8 @@ use App\Http\Controllers\API\GenreController;
use App\Http\Controllers\API\GenreSongController; use App\Http\Controllers\API\GenreSongController;
use App\Http\Controllers\API\LikeMultipleSongsController; use App\Http\Controllers\API\LikeMultipleSongsController;
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController; use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
use App\Http\Controllers\API\PlaylistCollaboration\AcceptPlaylistCollaborationController;
use App\Http\Controllers\API\PlaylistCollaboration\CreatePlaylistCollaborationTokenController;
use App\Http\Controllers\API\PlaylistController; use App\Http\Controllers\API\PlaylistController;
use App\Http\Controllers\API\PlaylistFolderController; use App\Http\Controllers\API\PlaylistFolderController;
use App\Http\Controllers\API\PlaylistFolderPlaylistController; use App\Http\Controllers\API\PlaylistFolderPlaylistController;
@ -163,7 +165,12 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::put('songs/publicize', PublicizeSongsController::class); Route::put('songs/publicize', PublicizeSongsController::class);
Route::put('songs/privatize', PrivatizeSongsController::class); Route::put('songs/privatize', PrivatizeSongsController::class);
// License routes
Route::post('licenses/activate', ActivateLicenseController::class); Route::post('licenses/activate', ActivateLicenseController::class);
// Playlist collaboration routes
Route::post('playlists/{playlist}/collaborators/invite', CreatePlaylistCollaborationTokenController::class);
Route::post('playlists/collaborators/accept', AcceptPlaylistCollaborationController::class);
}); });
// Object-storage (S3) routes // Object-storage (S3) routes

View file

@ -129,7 +129,7 @@ class DownloadTest extends TestCase
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->for($user)->create(); $playlist = Playlist::factory()->for($user)->create();
$playlist->songs()->attach($songs); $playlist->addSongs($songs);
$this->downloadService $this->downloadService
->shouldReceive('getDownloadablePath') ->shouldReceive('getDownloadablePath')

View file

@ -33,7 +33,7 @@ class InitialDataTest extends TestCase
'short_key', 'short_key',
'customer_name', 'customer_name',
'customer_email', 'customer_email',
'store_url', 'product_id',
], ],
]); ]);
} }

Some files were not shown because too many files have changed in this diff Show more