mirror of
https://github.com/koel/koel
synced 2024-11-24 05:03:05 +00:00
feat(plust): playlist collaboration
This commit is contained in:
parent
fb6f975067
commit
9dc23f319e
115 changed files with 1211 additions and 420 deletions
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Facades;
|
||||
|
||||
use App\Services\License\FakePlusLicenseService;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
|
||||
/**
|
||||
|
@ -16,9 +15,4 @@ class License extends Facade
|
|||
{
|
||||
return 'License';
|
||||
}
|
||||
|
||||
public static function fakePlusLicense(): void
|
||||
{
|
||||
self::swap(app(FakePlusLicenseService::class));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,3 +71,8 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed
|
|||
{
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ use App\Http\Resources\PlaylistResource;
|
|||
use App\Http\Resources\QueueStateResource;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\PlaylistRepository;
|
||||
use App\Repositories\SettingRepository;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Services\ApplicationInformationService;
|
||||
|
@ -26,6 +27,7 @@ class FetchInitialDataController extends Controller
|
|||
ITunesService $iTunesService,
|
||||
SettingRepository $settingRepository,
|
||||
SongRepository $songRepository,
|
||||
PlaylistRepository $playlistRepository,
|
||||
ApplicationInformationService $applicationInformationService,
|
||||
QueueService $queueService,
|
||||
LicenseServiceInterface $licenseService,
|
||||
|
@ -35,7 +37,7 @@ class FetchInitialDataController extends Controller
|
|||
|
||||
return response()->json([
|
||||
'settings' => $user->is_admin ? $settingRepository->getAllAsKeyValueArray() : [],
|
||||
'playlists' => PlaylistResource::collection($user->playlists),
|
||||
'playlists' => PlaylistResource::collection($playlistRepository->getAllAccessibleByUser($user)),
|
||||
'playlist_folders' => PlaylistFolderResource::collection($user->playlist_folders),
|
||||
'current_user' => UserResource::make($user, true),
|
||||
'uses_last_fm' => LastfmService::used(),
|
||||
|
|
|
@ -8,6 +8,7 @@ use App\Http\Requests\API\ObjectStorage\S3\PutSongRequest;
|
|||
use App\Http\Requests\API\ObjectStorage\S3\RemoveSongRequest;
|
||||
use App\Services\S3Service;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class SongController extends Controller
|
||||
{
|
||||
|
@ -17,19 +18,19 @@ class SongController extends Controller
|
|||
|
||||
public function put(PutSongRequest $request)
|
||||
{
|
||||
$artist = array_get($request->tags, 'artist', '');
|
||||
$artist = Arr::get($request->tags, 'artist', '');
|
||||
|
||||
$song = $this->s3Service->createSongEntry(
|
||||
$request->bucket,
|
||||
$request->key,
|
||||
$artist,
|
||||
array_get($request->tags, 'album'),
|
||||
trim(array_get($request->tags, 'albumartist')),
|
||||
array_get($request->tags, 'cover'),
|
||||
trim(array_get($request->tags, 'title', '')),
|
||||
(int) array_get($request->tags, 'duration', 0),
|
||||
(int) array_get($request->tags, 'track'),
|
||||
(string) array_get($request->tags, 'lyrics', '')
|
||||
Arr::get($request->tags, 'album'),
|
||||
trim(Arr::get($request->tags, 'albumartist')),
|
||||
Arr::get($request->tags, 'cover'),
|
||||
trim(Arr::get($request->tags, 'title', '')),
|
||||
(int) Arr::get($request->tags, 'duration', 0),
|
||||
(int) Arr::get($request->tags, 'track'),
|
||||
(string) Arr::get($request->tags, 'lyrics', '')
|
||||
);
|
||||
|
||||
return response()->json($song);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ use App\Models\Playlist;
|
|||
use App\Models\PlaylistFolder;
|
||||
use App\Models\User;
|
||||
use App\Repositories\PlaylistFolderRepository;
|
||||
use App\Repositories\PlaylistRepository;
|
||||
use App\Services\PlaylistService;
|
||||
use App\Values\SmartPlaylistRuleGroupCollection;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
@ -23,6 +24,7 @@ class PlaylistController extends Controller
|
|||
/** @param User $user */
|
||||
public function __construct(
|
||||
private PlaylistService $playlistService,
|
||||
private PlaylistRepository $playlistRepository,
|
||||
private PlaylistFolderRepository $folderRepository,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
|
@ -30,7 +32,7 @@ class PlaylistController extends Controller
|
|||
|
||||
public function index()
|
||||
{
|
||||
return PlaylistResource::collection($this->user->playlists);
|
||||
return PlaylistResource::collection($this->playlistRepository->getAllAccessibleByUser($this->user));
|
||||
}
|
||||
|
||||
public function store(PlaylistStoreRequest $request)
|
||||
|
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\AddSongsToPlaylistRequest;
|
||||
use App\Http\Requests\API\RemoveSongsFromPlaylistRequest;
|
||||
use App\Http\Resources\CollaborativeSongResource;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
|
@ -27,35 +29,39 @@ class PlaylistSongController extends Controller
|
|||
|
||||
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(
|
||||
$playlist->is_smart
|
||||
? $this->smartPlaylistService->getSongs($playlist, $this->user)
|
||||
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
|
||||
);
|
||||
$this->authorize('collaborate', $playlist);
|
||||
|
||||
$songs = $this->songRepository->getByStandardPlaylist($playlist, $this->user);
|
||||
|
||||
return License::isPlus()
|
||||
? CollaborativeSongResource::collection($songs)
|
||||
: SongResource::collection($songs);
|
||||
}
|
||||
|
||||
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)
|
||||
->each(fn ($song) => $this->authorize('access', $song));
|
||||
$this->authorize('collaborate', $playlist);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
|
||||
{
|
||||
$this->authorize('own', $playlist);
|
||||
|
||||
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
|
||||
|
||||
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'Smart playlist content is automatically generated');
|
||||
|
||||
$this->authorize('collaborate', $playlist);
|
||||
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
|
||||
|
||||
return response()->noContent();
|
||||
|
|
23
app/Http/Resources/CollaborativeSongResource.php
Normal file
23
app/Http/Resources/CollaborativeSongResource.php
Normal 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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
23
app/Http/Resources/PlaylistCollaborationTokenResource.php
Normal file
23
app/Http/Resources/PlaylistCollaborationTokenResource.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Models\Playlist;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
|
@ -23,6 +24,7 @@ class PlaylistResource extends JsonResource
|
|||
'user_id' => $this->playlist->user_id,
|
||||
'is_smart' => $this->playlist->is_smart,
|
||||
'rules' => $this->playlist->rules,
|
||||
'collaborators' => License::isPlus() ? UserResource::collection($this->playlist->collaborators) : [],
|
||||
'own_songs_only' => $this->playlist->own_songs_only,
|
||||
'created_at' => $this->playlist->created_at,
|
||||
];
|
||||
|
|
|
@ -7,7 +7,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||
|
||||
class SongResource extends JsonResource
|
||||
{
|
||||
public function __construct(private Song $song)
|
||||
public function __construct(protected Song $song)
|
||||
{
|
||||
parent::__construct($song);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Casts\SmartPlaylistRulesCast;
|
||||
use App\Facades\License as LicenseFacade;
|
||||
use App\Values\SmartPlaylistRuleGroupCollection;
|
||||
use Carbon\Carbon;
|
||||
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\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property bool $is_smart
|
||||
* @property int $user_id
|
||||
|
@ -26,6 +29,7 @@ use Laravel\Scout\Searchable;
|
|||
* @property ?SmartPlaylistRuleGroupCollection $rules
|
||||
* @property Carbon $created_at
|
||||
* @property bool $own_songs_only
|
||||
* @property Collection|array<array-key, User> $collaborators
|
||||
*/
|
||||
class Playlist extends Model
|
||||
{
|
||||
|
@ -33,18 +37,28 @@ class Playlist extends Model
|
|||
use HasFactory;
|
||||
|
||||
protected $hidden = ['user_id', 'created_at', 'updated_at'];
|
||||
protected $guarded = ['id'];
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'rules' => SmartPlaylistRulesCast::class,
|
||||
'own_songs_only' => 'bool',
|
||||
];
|
||||
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
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
|
||||
{
|
||||
return $this->belongsToMany(Song::class);
|
||||
return $this->belongsToMany(Song::class)->withTimestamps();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
|
@ -57,6 +71,16 @@ class Playlist extends Model
|
|||
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
|
||||
{
|
||||
return Attribute::get(fn (): bool => (bool) $this->rule_groups?->isNotEmpty());
|
||||
|
@ -68,6 +92,61 @@ class Playlist extends Model
|
|||
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> */
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
|
|
35
app/Models/PlaylistCollaborationToken.php
Normal file
35
app/Models/PlaylistCollaborationToken.php
Normal 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());
|
||||
}
|
||||
}
|
|
@ -40,4 +40,9 @@ class PlaylistFolder extends Model
|
|||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function ownedBy(User $user): bool
|
||||
{
|
||||
return $this->user_id === $user->id;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -37,6 +37,11 @@ use Laravel\Scout\Searchable;
|
|||
* @property int $owner_id
|
||||
* @property bool $is_public
|
||||
* @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
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
|
|||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
@ -32,6 +33,7 @@ use Laravel\Sanctum\PersonalAccessToken;
|
|||
* @property ?string $invitation_token
|
||||
* @property ?Carbon $invited_at
|
||||
* @property-read bool $is_prospect
|
||||
* @property Collection|array<array-key, Playlist> $collaboratedPlaylists
|
||||
*/
|
||||
class User extends Authenticatable
|
||||
{
|
||||
|
@ -58,6 +60,11 @@ class User extends Authenticatable
|
|||
return $this->hasMany(Playlist::class);
|
||||
}
|
||||
|
||||
public function collaboratedPlaylists(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Playlist::class, 'playlist_collaborators')->withTimestamps();
|
||||
}
|
||||
|
||||
public function playlist_folders(): HasMany // @phpcs:ignore
|
||||
{
|
||||
return $this->hasMany(PlaylistFolder::class);
|
||||
|
@ -70,9 +77,7 @@ class User extends Authenticatable
|
|||
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
fn () => sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($this->email))
|
||||
);
|
||||
return Attribute::get(fn (): string => gravatar($this->email));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -9,6 +9,6 @@ class PlaylistFolderPolicy
|
|||
{
|
||||
public function own(User $user, PlaylistFolder $folder): bool
|
||||
{
|
||||
return $folder->user->is($user);
|
||||
return $folder->ownedBy($user);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Facades\License;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
|
||||
|
@ -9,11 +10,29 @@ class PlaylistPolicy
|
|||
{
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
21
app/Repositories/PlaylistRepository.php
Normal file
21
app/Repositories/PlaylistRepository.php
Normal 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);
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Repositories;
|
||||
|
||||
use App\Builders\SongBuilder;
|
||||
use App\Facades\License;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Playlist;
|
||||
|
@ -182,8 +183,18 @@ class SongRepository extends Repository
|
|||
->withMetaFor($scopedUser)
|
||||
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_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)
|
||||
->orderBy('songs.title')
|
||||
->logSql()
|
||||
->get();
|
||||
}
|
||||
|
||||
|
|
38
app/Services/PlaylistCollaborationService.php
Normal file
38
app/Services/PlaylistCollaborationService.php
Normal 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;
|
||||
}
|
||||
}
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Exceptions\PlaylistBothSongsAndRulesProvidedException;
|
||||
use App\Facades\License;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistFolder as Folder;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Values\SmartPlaylistRuleGroupCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
@ -22,9 +25,11 @@ class PlaylistService
|
|||
bool $ownSongsOnly = false
|
||||
): Playlist {
|
||||
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(
|
||||
'"Own songs only" option only works with smart playlists and Plus license.'
|
||||
));
|
||||
|
@ -40,7 +45,7 @@ class PlaylistService
|
|||
]);
|
||||
|
||||
if (!$playlist->is_smart && $songs) {
|
||||
$playlist->songs()->sync($songs);
|
||||
$playlist->addSongs($songs, $user);
|
||||
}
|
||||
|
||||
return $playlist;
|
||||
|
@ -56,7 +61,7 @@ class PlaylistService
|
|||
bool $ownSongsOnly = false
|
||||
): Playlist {
|
||||
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(
|
||||
|
@ -73,13 +78,16 @@ class PlaylistService
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ return new class extends Migration
|
|||
});
|
||||
|
||||
Song::all()->each(static function (Song $song): void {
|
||||
$song->id = Str::uuid();
|
||||
$song->id = Str::uuid()->toString();
|
||||
$song->save();
|
||||
});
|
||||
|
||||
|
|
|
@ -19,7 +19,8 @@ return new class extends Migration
|
|||
Schema::table('playlists', static function (Blueprint $table): void {
|
||||
$table->string('folder_id', 36)->nullable();
|
||||
$table->foreign('folder_id')
|
||||
->references('id')->on('playlist_folders')
|
||||
->references('id')
|
||||
->on('playlist_folders')
|
||||
->cascadeOnUpdate()
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
}
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
|
@ -20,7 +20,7 @@ export default factory
|
|||
.define('album', faker => albumFactory(faker), albumStates)
|
||||
.define('album-track', faker => albumTrackFactory(faker))
|
||||
.define('album-info', faker => albumInfoFactory(faker))
|
||||
.define('song', faker => songFactory(faker), songStates)
|
||||
.define('song', () => songFactory(), songStates)
|
||||
.define('interaction', faker => interactionFactory(faker))
|
||||
.define('genre', faker => genreFactory(faker))
|
||||
.define('video', faker => youTubeVideoFactory(faker))
|
||||
|
|
|
@ -3,12 +3,14 @@ import { Faker } from '@faker-js/faker'
|
|||
|
||||
export default (faker: Faker): Playlist => ({
|
||||
type: 'playlists',
|
||||
id: faker.datatype.number(),
|
||||
user_id: faker.datatype.number({ min: 1, max: 1000 }),
|
||||
id: faker.datatype.uuid(),
|
||||
folder_id: faker.datatype.uuid(),
|
||||
name: faker.random.word(),
|
||||
is_smart: false,
|
||||
rules: [],
|
||||
ownSongsOnly: false
|
||||
own_songs_only: false,
|
||||
collaborators: []
|
||||
})
|
||||
|
||||
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Faker, faker } from '@faker-js/faker'
|
||||
import { faker } from '@faker-js/faker'
|
||||
import { genres } from '@/config'
|
||||
|
||||
const generate = (partOfCompilation = false): Song => {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
|
@ -68,7 +69,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('goes to album', async () => {
|
||||
const mock = this.mock(this.router, 'go')
|
||||
const mock = this.mock(Router, 'go')
|
||||
await this.renderComponent()
|
||||
|
||||
await this.user.click(screen.getByText('Go to Album'))
|
||||
|
@ -85,7 +86,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('goes to artist', async () => {
|
||||
const mock = this.mock(this.router, 'go')
|
||||
const mock = this.mock(Router, 'go')
|
||||
await this.renderComponent()
|
||||
|
||||
await this.user.click(screen.getByText('Go to Artist'))
|
||||
|
|
|
@ -39,7 +39,6 @@ import { mediaInfoService, playbackService } from '@/services'
|
|||
import { useRouter, useThirdPartyServices } from '@/composables'
|
||||
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import { defaultCover } from '@/utils'
|
||||
|
||||
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { eventBus } from '@/utils'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { commonStore, songStore } from '@/stores'
|
||||
import ArtistContextMenu from './ArtistContextMenu.vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import ArtistContextMenu from './ArtistContextMenu.vue'
|
||||
|
||||
let artist: Artist
|
||||
|
||||
|
@ -16,7 +17,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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)
|
||||
|
||||
return rendered
|
||||
|
@ -68,7 +69,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('goes to artist', async () => {
|
||||
const mock = this.mock(this.router, 'go')
|
||||
const mock = this.mock(Router, 'go')
|
||||
await this.renderComponent()
|
||||
|
||||
await screen.getByText('Go to Artist').click()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { screen } from '@testing-library/vue'
|
||||
import { expect, it, Mock } from 'vitest'
|
||||
import { userStore } from '@/stores'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { authService } from '@/services'
|
||||
import LoginFrom from './LoginForm.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
|
@ -21,11 +21,11 @@ new class extends UnitTestCase {
|
|||
it('renders', () => expect(this.render(LoginFrom).html()).toMatchSnapshot())
|
||||
|
||||
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 () => {
|
||||
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)
|
||||
await this.tick()
|
||||
|
||||
|
|
|
@ -13,8 +13,8 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { userStore } from '@/stores'
|
||||
import { isDemo } from '@/utils'
|
||||
import { authService } from '@/services'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import PasswordField from '@/components/ui/PasswordField.vue'
|
||||
|
@ -33,7 +33,7 @@ const emit = defineEmits<{ (e: 'loggedin'): void }>()
|
|||
|
||||
const login = async () => {
|
||||
try {
|
||||
await userStore.login(email.value, password.value)
|
||||
await authService.login(email.value, password.value)
|
||||
failed.value = false
|
||||
|
||||
// Reset the password so that the next login will have this field empty.
|
||||
|
|
|
@ -14,6 +14,7 @@
|
|||
<Icon v-if="isRecentlyPlayedList(list)" :icon="faClockRotateLeft" class="text-green" fixed-width />
|
||||
<Icon v-else-if="isFavoriteList(list)" :icon="faHeart" class="text-maroon" fixed-width />
|
||||
<Icon v-else-if="list.is_smart" :icon="faWandMagicSparkles" fixed-width />
|
||||
<Icon v-else-if="list.collaborators.length" :icon="faUsers" fixed-width />
|
||||
<Icon v-else :icon="faFileLines" fixed-width />
|
||||
<span>{{ list.name }}</span>
|
||||
</a>
|
||||
|
@ -21,7 +22,13 @@
|
|||
</template>
|
||||
|
||||
<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 { eventBus } from '@/utils'
|
||||
import { favoriteStore } from '@/stores'
|
||||
|
@ -110,7 +117,7 @@ onRouteChanged(route => {
|
|||
break
|
||||
|
||||
case 'Playlist':
|
||||
active.value = (list.value as Playlist).id === parseInt(route.params!.id)
|
||||
active.value = (list.value as Playlist).id === route.params!.id
|
||||
break
|
||||
|
||||
default:
|
||||
|
|
|
@ -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>
|
|
@ -1,16 +1,21 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { eventBus } from '@/utils'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { songStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import { songStore, userStore } from '@/stores'
|
||||
import { playbackService, playlistCollaborationService } from '@/services'
|
||||
import { queueStore } from '@/stores'
|
||||
import { MessageToasterStub } from '@/__tests__/stubs'
|
||||
import PlaylistContextMenu from './PlaylistContextMenu.vue'
|
||||
|
||||
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)
|
||||
eventBus.emit('PLAYLIST_CONTEXT_MENU_REQUESTED', { pageX: 420, pageY: 42 } as MouseEvent, playlist)
|
||||
await this.tick(2)
|
||||
|
@ -52,7 +57,7 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
||||
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.user.click(screen.getByText('Play'))
|
||||
|
@ -68,7 +73,7 @@ new class extends UnitTestCase {
|
|||
const playlist = factory<Playlist>('playlist')
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
|
||||
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')
|
||||
|
||||
await this.renderComponent(playlist)
|
||||
|
@ -88,7 +93,7 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue(songs)
|
||||
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.user.click(screen.getByText('Shuffle'))
|
||||
|
@ -104,7 +109,7 @@ new class extends UnitTestCase {
|
|||
const playlist = factory<Playlist>('playlist')
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylist').mockResolvedValue([])
|
||||
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')
|
||||
|
||||
await this.renderComponent(playlist)
|
||||
|
@ -135,5 +140,33 @@ new class extends UnitTestCase {
|
|||
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')
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,25 +3,34 @@
|
|||
<li @click="play">Play</li>
|
||||
<li @click="shuffle">Shuffle</li>
|
||||
<li @click="addToQueue">Add to Queue</li>
|
||||
<li class="separator" />
|
||||
<li @click="edit">Edit…</li>
|
||||
<li @click="destroy">Delete</li>
|
||||
<template v-if="canInviteCollaborators">
|
||||
<li class="separator"></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>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { useContextMenu, useMessageToaster, useRouter } from '@/composables'
|
||||
import { playbackService } from '@/services'
|
||||
import { computed, ref } from 'vue'
|
||||
import { copyText, eventBus } from '@/utils'
|
||||
import { useAuthorization, useContextMenu, useMessageToaster, useKoelPlus, useRouter } from '@/composables'
|
||||
import { playbackService, playlistCollaborationService } from '@/services'
|
||||
import { songStore, queueStore } from '@/stores'
|
||||
|
||||
const { base, ContextMenuBase, open, trigger } = useContextMenu()
|
||||
const { go } = useRouter()
|
||||
const { toastWarning, toastSuccess } = useMessageToaster()
|
||||
const { isPlus } = useKoelPlus()
|
||||
const { currentUser } = useAuthorization()
|
||||
|
||||
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 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) => {
|
||||
playlist.value = _playlist
|
||||
await open(event.pageY, event.pageX)
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { eventBus } from '@/utils'
|
||||
|
@ -41,7 +42,7 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
|
||||
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.user.click(screen.getByText('Play All'))
|
||||
|
@ -58,7 +59,7 @@ new class extends UnitTestCase {
|
|||
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
|
||||
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')
|
||||
|
||||
await this.renderComponent(folder)
|
||||
|
@ -78,7 +79,7 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue(songs)
|
||||
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.user.click(screen.getByText('Shuffle All'))
|
||||
|
@ -103,7 +104,7 @@ new class extends UnitTestCase {
|
|||
|
||||
const fetchMock = this.mock(songStore, 'fetchForPlaylistFolder').mockResolvedValue([])
|
||||
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')
|
||||
|
||||
await this.renderComponent(folder)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import factory from 'factoria'
|
||||
import { expect, it } from 'vitest'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { userStore } from '@/stores'
|
||||
import { authService } from '@/services'
|
||||
import { MessageToasterStub } from '@/__tests__/stubs'
|
||||
import ProfileForm from './ProfileForm.vue'
|
||||
|
||||
|
@ -13,7 +13,7 @@ new class extends UnitTestCase {
|
|||
|
||||
protected test () {
|
||||
it('updates profile', async () => {
|
||||
const updateMock = this.mock(userStore, 'updateProfile')
|
||||
const updateMock = this.mock(authService, 'updateProfile')
|
||||
const alertMock = this.mock(MessageToasterStub.value, 'success')
|
||||
|
||||
await this.renderComponent(factory<User>('user'))
|
||||
|
|
|
@ -55,7 +55,8 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
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 { useDialogBox, useMessageToaster } from '@/composables'
|
||||
|
||||
|
@ -85,7 +86,7 @@ const update = async () => {
|
|||
}
|
||||
|
||||
try {
|
||||
await userStore.updateProfile(Object.assign({}, profile.value))
|
||||
await authService.updateProfile(Object.assign({}, profile.value))
|
||||
profile.value.current_password = null
|
||||
delete profile.value.new_password
|
||||
toastSuccess('Profile updated.')
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
|
@ -59,7 +60,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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)
|
||||
await this.renderComponent()
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
<template #controls>
|
||||
<SongListControls
|
||||
v-if="songs.length && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@filter="applyFilter"
|
||||
@play-all="playAll"
|
||||
@play-selected="playSelected"
|
||||
|
@ -92,7 +93,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
|
|||
import { eventBus, logger, pluralize } from '@/utils'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
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 AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
@ -119,7 +120,6 @@ let info = ref<ArtistInfo | null>()
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
headerLayout,
|
||||
songList,
|
||||
|
@ -134,6 +134,8 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(songs)
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Album')
|
||||
|
||||
const useLastfm = toRef(commonStore.state, 'uses_last_fm')
|
||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
|
@ -45,7 +46,7 @@ new class extends UnitTestCase {
|
|||
it('shuffles', async () => {
|
||||
const queueMock = this.mock(queueStore, 'fetchRandom')
|
||||
const playMock = this.mock(playbackService, 'playFirstInQueue')
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
await this.renderComponent()
|
||||
|
||||
await this.user.click(screen.getByTitle('Shuffle all. Press Alt/⌥ to change mode.'))
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
<div class="controls">
|
||||
<SongListControls
|
||||
v-if="totalSongCount && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@play-all="playAll"
|
||||
@play-selected="playSelected"
|
||||
/>
|
||||
|
@ -62,7 +63,7 @@ import { computed, ref, toRef, watch } from 'vue'
|
|||
import { logger, pluralize, secondsToHumanReadable } from '@/utils'
|
||||
import { commonStore, queueStore, songStore } from '@/stores'
|
||||
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 SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
|
@ -74,7 +75,6 @@ const totalDuration = computed(() => secondsToHumanReadable(commonStore.state.so
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -89,6 +89,8 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(toRef(songStore.state, 'songs'))
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Songs')
|
||||
|
||||
const { toastError } = useMessageToaster()
|
||||
const { go, onScreenActivated } = useRouter()
|
||||
const { isPlus } = useKoelPlus()
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
|
@ -57,7 +58,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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)
|
||||
await this.renderComponent()
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
<template #controls>
|
||||
<SongListControls
|
||||
v-if="songs.length && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@filter="applyFilter"
|
||||
@play-all="playAll"
|
||||
@play-selected="playSelected"
|
||||
|
@ -88,7 +89,7 @@ import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
|
|||
import { eventBus, logger, pluralize } from '@/utils'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
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 ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
|
@ -115,7 +116,6 @@ let info = ref<ArtistInfo | undefined | null>()
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
headerLayout,
|
||||
songList,
|
||||
|
@ -130,6 +130,8 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(songs)
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Artist')
|
||||
|
||||
const { useLastfm } = useThirdPartyServices()
|
||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
<template #controls>
|
||||
<SongListControls
|
||||
v-if="songs.length && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@filter="applyFilter"
|
||||
@play-all="playAll"
|
||||
@play-selected="playSelected"
|
||||
|
@ -63,7 +64,7 @@ import { faHeart } from '@fortawesome/free-regular-svg-icons'
|
|||
import { pluralize } from '@/utils'
|
||||
import { commonStore, favoriteStore } from '@/stores'
|
||||
import { downloadService } from '@/services'
|
||||
import { useRouter, useSongList } from '@/composables'
|
||||
import { useRouter, useSongList, useSongListControls } from '@/composables'
|
||||
import { nextTick, ref, toRef } from 'vue'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
|
@ -72,7 +73,6 @@ import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -91,6 +91,8 @@ const {
|
|||
sort
|
||||
} = useSongList(toRef(favoriteStore.state, 'songs'))
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Favorites')
|
||||
|
||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||
|
||||
const download = () => downloadService.fromFavorites()
|
||||
|
|
|
@ -14,7 +14,12 @@
|
|||
</template>
|
||||
|
||||
<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>
|
||||
</ScreenHeader>
|
||||
<ScreenHeaderSkeleton v-else />
|
||||
|
@ -45,7 +50,7 @@ import { faTags } from '@fortawesome/free-solid-svg-icons'
|
|||
import { eventBus, logger, pluralize, secondsToHumanReadable } from '@/utils'
|
||||
import { playbackService } from '@/services'
|
||||
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 ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
|
@ -54,7 +59,6 @@ import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -68,6 +72,8 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(ref<Song[]>([]))
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Genre')
|
||||
|
||||
const { showErrorDialog } = useDialogBox()
|
||||
const { getRouteParam, go, onRouteChanged } = useRouter()
|
||||
|
||||
|
|
|
@ -21,7 +21,7 @@ new class extends UnitTestCase {
|
|||
await this.router.activateRoute({
|
||||
path: `playlists/${playlist.id}`,
|
||||
screen: 'Playlist'
|
||||
}, { id: playlist.id.toString() })
|
||||
}, { id: playlist.id })
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(playlist, false))
|
||||
|
||||
|
|
|
@ -8,7 +8,8 @@
|
|||
<ThumbnailStack :thumbnails="thumbnails" />
|
||||
</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>{{ duration }}</span>
|
||||
<a
|
||||
|
@ -70,26 +71,22 @@ import { ref, toRef, watch } from 'vue'
|
|||
import { eventBus, pluralize } from '@/utils'
|
||||
import { commonStore, playlistStore, songStore } from '@/stores'
|
||||
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 ScreenEmptyState from '@/components/ui/ScreenEmptyState.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 loading = ref(false)
|
||||
|
||||
const controlsConfig: Partial<SongListControlsConfig> = {
|
||||
deletePlaylist: true,
|
||||
refresh: true
|
||||
}
|
||||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -105,9 +102,11 @@ const {
|
|||
playSelected,
|
||||
applyFilter,
|
||||
onScrollBreakpoint,
|
||||
sort
|
||||
sort,
|
||||
config: listConfig
|
||||
} = useSongList(ref<Song[]>([]))
|
||||
|
||||
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
||||
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
||||
|
||||
const allowDownload = toRef(commonStore.state, 'allows_download')
|
||||
|
@ -131,10 +130,20 @@ watch(playlistId, async id => {
|
|||
if (!id) return
|
||||
|
||||
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())
|
||||
.on('PLAYLIST_SONGS_REMOVED', async (playlist, removed) => {
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
<template #controls>
|
||||
<SongListControls
|
||||
v-if="songs.length && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@filter="applyFilter"
|
||||
@clear-queue="clearQueue"
|
||||
@play-all="playAll"
|
||||
|
@ -50,11 +51,11 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
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 { commonStore, queueStore, songStore } from '@/stores'
|
||||
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 ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
|
@ -65,7 +66,6 @@ const { showErrorDialog } = useDialogBox()
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -81,6 +81,8 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(toRef(queueStore.state, 'songs'))
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Queue')
|
||||
|
||||
const loading = ref(false)
|
||||
const libraryNotEmpty = computed(() => commonStore.state.song_count > 0)
|
||||
|
||||
|
@ -111,7 +113,6 @@ const removeSelected = () => {
|
|||
if (!selectedSongs.value.length) return
|
||||
|
||||
const currentSongId = queueStore.current?.id
|
||||
|
||||
queueStore.unqueue(selectedSongs.value)
|
||||
|
||||
if (currentSongId && selectedSongs.value.find(song => song.id === currentSongId)) {
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
<template #controls>
|
||||
<SongListControls
|
||||
v-if="songs.length && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@filter="applyFilter"
|
||||
@play-all="playAll"
|
||||
@play-selected="playSelected"
|
||||
|
@ -41,7 +42,7 @@
|
|||
import { faClock } from '@fortawesome/free-regular-svg-icons'
|
||||
import { pluralize } from '@/utils'
|
||||
import { recentlyPlayedStore } from '@/stores'
|
||||
import { useRouter, useSongList } from '@/composables'
|
||||
import { useRouter, useSongList, useSongListControls } from '@/composables'
|
||||
import { ref, toRef } from 'vue'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
|
@ -52,7 +53,6 @@ const recentlyPlayedSongs = toRef(recentlyPlayedStore.state, 'songs')
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -69,6 +69,8 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(recentlyPlayedSongs)
|
||||
|
||||
const { SongListControls, config } = useSongListControls('RecentlyPlayed')
|
||||
|
||||
let initialized = false
|
||||
let loading = ref(false)
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { settingStore } from '@/stores'
|
||||
|
@ -11,7 +12,7 @@ new class extends UnitTestCase {
|
|||
|
||||
it('submits the settings form', async () => {
|
||||
const updateMock = this.mock(settingStore, 'update')
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
|
||||
settingStore.state.media_path = ''
|
||||
this.render(SettingsScreen)
|
||||
|
@ -27,7 +28,7 @@ new class extends UnitTestCase {
|
|||
|
||||
it('confirms upon media path change', async () => {
|
||||
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')
|
||||
|
||||
settingStore.state.media_path = '/old'
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
|
@ -19,7 +20,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('goes to dedicated screen', async () => {
|
||||
const mock = this.mock(this.router, 'go')
|
||||
const mock = this.mock(Router, 'go')
|
||||
this.render(RecentlyPlayedSongs)
|
||||
|
||||
await this.user.click(screen.getByRole('button', { name: 'View All' }))
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
<template #controls>
|
||||
<SongListControls
|
||||
v-if="songs.length && (!isPhone || showingControls)"
|
||||
:config="config"
|
||||
@filter="applyFilter"
|
||||
@play-all="playAll"
|
||||
@play-selected="playSelected"
|
||||
|
@ -31,7 +32,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref, toRef } from 'vue'
|
||||
import { searchStore } from '@/stores'
|
||||
import { useRouter, useSongList } from '@/composables'
|
||||
import { useRouter, useSongList, useSongListControls } from '@/composables'
|
||||
import { pluralize } from '@/utils'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
|
@ -42,7 +43,6 @@ const q = ref('')
|
|||
|
||||
const {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
headerLayout,
|
||||
|
@ -60,6 +60,7 @@ const {
|
|||
onScrollBreakpoint
|
||||
} = useSongList(toRef(searchStore.state, 'songs'))
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Search.Songs')
|
||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||
const loading = ref(false)
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
|
@ -62,7 +63,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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.user.click(screen.getByText('Go to Album'))
|
||||
|
@ -71,7 +72,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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.user.click(screen.getByText('Go to Artist'))
|
||||
|
|
|
@ -151,7 +151,7 @@ const visibilityActions = computed(() => {
|
|||
|
||||
const canBeRemovedFromPlaylist = computed(() => {
|
||||
if (!isCurrentScreen('Playlist')) return false
|
||||
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!))
|
||||
const playlist = playlistStore.byId(getRouteParam('id')!)
|
||||
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 removeFromPlaylist = () => trigger(async () => {
|
||||
const playlist = playlistStore.byId(parseInt(getRouteParam('id')!))
|
||||
const playlist = playlistStore.byId(getRouteParam('id')!)
|
||||
if (!playlist) return
|
||||
|
||||
await removeSongsFromPlaylist(playlist, songs.value)
|
||||
|
@ -192,8 +192,8 @@ const removeFromPlaylist = () => trigger(async () => {
|
|||
const removeFromQueue = () => trigger(() => queueStore.unqueue(songs.value))
|
||||
const removeFromFavorites = () => trigger(() => favoriteStore.unlike(songs.value))
|
||||
|
||||
const copyUrl = () => trigger(() => {
|
||||
copyText(songStore.getShareableUrl(songs.value[0]))
|
||||
const copyUrl = () => trigger(async () => {
|
||||
await copyText(songStore.getShareableUrl(songs.value[0]))
|
||||
toastSuccess('URL copied to clipboard.')
|
||||
})
|
||||
|
||||
|
|
|
@ -48,6 +48,10 @@
|
|||
<Icon v-if="sortField === 'album_name' && sortOrder === 'desc'" :icon="faCaretUp" class="text-highlight" />
|
||||
</template>
|
||||
</span>
|
||||
<template v-if="config.collaborative">
|
||||
<span class="collaborator">User</span>
|
||||
<span class="added-at">Added</span>
|
||||
</template>
|
||||
<span
|
||||
class="time"
|
||||
data-testid="header-length"
|
||||
|
@ -77,7 +81,7 @@
|
|||
:key="item.song.id"
|
||||
:item="item"
|
||||
draggable="true"
|
||||
@click="rowClicked(item, $event)"
|
||||
@click="onClick(item, $event)"
|
||||
@dragleave="onDragLeave"
|
||||
@dragstart="onDragStart(item, $event)"
|
||||
@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 })
|
||||
|
||||
const filteredSongRows = computed(() => {
|
||||
const filteredSongRows = computed<SongRow[]>(() => {
|
||||
const keywords = filterKeywords.value.trim().toLowerCase()
|
||||
|
||||
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 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 (isMobile.any) {
|
||||
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 (!row.selected) {
|
||||
clearSelection()
|
||||
row.selected = true
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
// 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%;
|
||||
}
|
||||
|
||||
&.collaborator {
|
||||
flex-basis: 72px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
&.added-at {
|
||||
flex-basis: 144px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&.extra {
|
||||
flex-basis: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.play {
|
||||
|
@ -388,10 +405,6 @@ onMounted(() => render())
|
|||
&.title-artist {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
&.extra {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.song-list-header {
|
||||
|
@ -452,8 +465,8 @@ onMounted(() => render())
|
|||
width: 200%;
|
||||
}
|
||||
|
||||
.song-item :is(.track-number, .album, .time),
|
||||
.song-list-header :is(.track-number, .album, .time) {
|
||||
.song-item :is(.track-number, .album, .time, .added-at),
|
||||
.song-list-header :is(.track-number, .album, .time, .added-at) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { take } from 'lodash'
|
||||
import { merge, take } from 'lodash'
|
||||
import { ref } from 'vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
|
@ -8,13 +8,18 @@ import { screen } from '@testing-library/vue'
|
|||
import SongListControls from './SongListControls.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent (selectedSongCount = 1, screen: ScreenName = 'Songs') {
|
||||
private renderComponent (selectedSongCount = 1, configOverrides: Partial<SongListControlsConfig> = {}) {
|
||||
const songs = factory<Song>('song', 5)
|
||||
|
||||
this.router.activateRoute({
|
||||
screen,
|
||||
path: '_'
|
||||
})
|
||||
const config: SongListControlsConfig = merge({
|
||||
addTo: {
|
||||
queue: true,
|
||||
favorites: true
|
||||
},
|
||||
clearQueue: true,
|
||||
deletePlaylist: true,
|
||||
refresh: true,
|
||||
filter: true
|
||||
}, configOverrides)
|
||||
|
||||
return this.render(SongListControls, {
|
||||
global: {
|
||||
|
@ -22,6 +27,9 @@ new class extends UnitTestCase {
|
|||
[<symbol>SongsKey]: [ref(songs)],
|
||||
[<symbol>SelectedSongsKey]: [ref(take(songs, selectedSongCount))]
|
||||
}
|
||||
},
|
||||
props: {
|
||||
config
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -64,7 +72,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('clears queue', async () => {
|
||||
const { emitted } = this.renderComponent(0, 'Queue')
|
||||
const { emitted } = this.renderComponent(0)
|
||||
|
||||
await this.user.click(screen.getByTitle('Clear current queue'))
|
||||
|
||||
|
@ -72,7 +80,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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'))
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<BtnGroup uppercased>
|
||||
<template v-if="altPressed">
|
||||
|
@ -97,10 +97,10 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
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 { requireInjection } from '@/utils'
|
||||
import { useFloatingUi, useSongListControls } from '@/composables'
|
||||
import { useFloatingUi } from '@/composables'
|
||||
|
||||
import AddToMenu from '@/components/song/AddToMenu.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 config = useSongListControls().getSongListControlsConfig()
|
||||
const props = defineProps<{ config: SongListControlsConfig }>()
|
||||
const config = toRef(props, 'config')
|
||||
|
||||
const [songs] = requireInjection<[Ref<Song[]>]>(SongsKey)
|
||||
const [selectedSongs] = requireInjection(SelectedSongsKey)
|
||||
|
|
|
@ -25,6 +25,12 @@
|
|||
</span>
|
||||
</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="extra">
|
||||
<LikeButton :song="song" />
|
||||
|
@ -37,13 +43,16 @@ import { faSquareUpRight } from '@fortawesome/free-solid-svg-icons'
|
|||
import { computed, toRefs } from 'vue'
|
||||
import { playbackService } from '@/services'
|
||||
import { queueStore } from '@/stores'
|
||||
import { secondsToHis } from '@/utils'
|
||||
import { useAuthorization } from '@/composables'
|
||||
import { useKoelPlus } from '@/composables'
|
||||
import { requireInjection, secondsToHis } from '@/utils'
|
||||
import { useAuthorization, useKoelPlus } from '@/composables'
|
||||
import { SongListConfigKey } from '@/symbols'
|
||||
|
||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||
import SoundBars from '@/components/ui/SoundBars.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 { isPlus } = useKoelPlus()
|
||||
|
@ -51,11 +60,15 @@ const { isPlus } = useKoelPlus()
|
|||
const props = defineProps<{ item: SongRow }>()
|
||||
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 external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
|
||||
const fmtLength = secondsToHis(song.value.length)
|
||||
|
||||
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => {
|
||||
return (song.value as CollaborativeSong).collaboration.user;
|
||||
})
|
||||
|
||||
const play = () => {
|
||||
queueStore.queueIfNotQueued(song.value)
|
||||
playbackService.play(song.value)
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
exports[`renders 1`] = `
|
||||
<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>
|
||||
`;
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
// 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>
|
||||
`;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { ref } from 'vue'
|
||||
import { expect, it } from 'vitest'
|
||||
|
@ -29,27 +30,27 @@ new class extends UnitTestCase {
|
|||
expect(toggleMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each<[ScreenName, MethodOf<typeof songStore>]>([
|
||||
['Album', 'fetchForAlbum'],
|
||||
['Artist', 'fetchForArtist'],
|
||||
['Playlist', 'fetchForPlaylist']
|
||||
])('initiates playback for %s screen', async (screenName, fetchMethod) => {
|
||||
it.each<[ScreenName, MethodOf<typeof songStore>, string|number]>([
|
||||
['Album', 'fetchForAlbum', 42],
|
||||
['Artist', 'fetchForArtist', 42],
|
||||
['Playlist', 'fetchForPlaylist', '71d8cd40-20d4-4b17-b460-d30fe5bb7b66']
|
||||
])('initiates playback for %s screen', async (screenName, fetchMethod, id) => {
|
||||
commonStore.state.song_count = 10
|
||||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(songStore, fetchMethod).mockResolvedValue(songs)
|
||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
|
||||
await this.router.activateRoute({
|
||||
screen: screenName,
|
||||
path: '_'
|
||||
}, { id: '42' })
|
||||
}, { id: String(id) })
|
||||
|
||||
this.renderComponent()
|
||||
|
||||
await this.user.click(screen.getByRole('button'))
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(42)
|
||||
expect(fetchMock).toHaveBeenCalledWith(id)
|
||||
expect(playMock).toHaveBeenCalledWith(songs)
|
||||
expect(goMock).toHaveBeenCalledWith('queue')
|
||||
})
|
||||
|
@ -67,7 +68,7 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(store, fetchMethod).mockResolvedValue(songs)
|
||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
|
||||
await this.router.activateRoute({
|
||||
screen: screenName,
|
||||
|
@ -89,7 +90,7 @@ new class extends UnitTestCase {
|
|||
const songs = factory<Song>('song', 3)
|
||||
const fetchMock = this.mock(queueStore, 'fetchRandom').mockResolvedValue(songs)
|
||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
|
||||
await this.router.activateRoute({
|
||||
screen: screenName,
|
||||
|
@ -110,7 +111,7 @@ new class extends UnitTestCase {
|
|||
commonStore.state.song_count = 0
|
||||
|
||||
const playMock = this.mock(playbackService, 'queueAndPlay')
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
|
||||
await this.router.activateRoute({
|
||||
screen: 'Songs',
|
||||
|
|
|
@ -35,7 +35,7 @@ const initiatePlayback = async () => {
|
|||
songs = await songStore.fetchForArtist(parseInt(getRouteParam('id')!))
|
||||
break
|
||||
case 'Playlist':
|
||||
songs = await songStore.fetchForPlaylist(parseInt(getRouteParam('id')!))
|
||||
songs = await songStore.fetchForPlaylist(getRouteParam('id')!)
|
||||
break
|
||||
case 'Favorites':
|
||||
songs = await favoriteStore.fetch()
|
||||
|
|
|
@ -7,22 +7,19 @@
|
|||
href="/#/profile"
|
||||
title="Profile and preferences"
|
||||
>
|
||||
<img :alt="`Avatar of ${currentUser.name}`" :src="currentUser.avatar">
|
||||
<UserAvatar :user="currentUser" width="40"/>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useAuthorization } from '@/composables'
|
||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||
|
||||
const { currentUser } = useAuthorization()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
img {
|
||||
display: block;
|
||||
width: 39px;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
border: 1px solid rgba(255, 255, 255, .1);
|
||||
transition: border .2s ease-in-out;
|
||||
|
|
|
@ -67,6 +67,7 @@ header.screen-header {
|
|||
|
||||
.meta {
|
||||
display: block;
|
||||
margin-top: .2rem;
|
||||
}
|
||||
|
||||
main {
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
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 () => {
|
||||
const mock = this.mock(this.router, 'go')
|
||||
const mock = this.mock(Router, 'go')
|
||||
this.render(SearchForm)
|
||||
|
||||
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 () => {
|
||||
const goMock = this.mock(this.router, 'go')
|
||||
const goMock = this.mock(Router, 'go')
|
||||
this.render(SearchForm)
|
||||
|
||||
await this.type(screen.getByRole('searchbox'), 'hey')
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// 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>`;
|
||||
|
|
18
resources/assets/js/components/user/UserAvatar.vue
Normal file
18
resources/assets/js/components/user/UserAvatar.vue
Normal 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>
|
|
@ -1,3 +1,4 @@
|
|||
import Router from '@/router'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
|
@ -5,8 +6,8 @@ import { screen } from '@testing-library/vue'
|
|||
import { eventBus } from '@/utils'
|
||||
import { userStore } from '@/stores'
|
||||
import { DialogBoxStub } from '@/__tests__/stubs'
|
||||
import UserCard from './UserCard.vue'
|
||||
import { invitationService } from '@/services'
|
||||
import UserCard from './UserCard.vue'
|
||||
|
||||
new class extends UnitTestCase {
|
||||
private renderComponent (user: User) {
|
||||
|
@ -37,7 +38,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
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')
|
||||
this.be(user).renderComponent(user)
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<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>
|
||||
<h1>
|
||||
|
@ -41,6 +41,7 @@ import { useAuthorization, useDialogBox, useMessageToaster, useRouter } from '@/
|
|||
import { eventBus, parseValidationError } from '@/utils'
|
||||
|
||||
import Btn from '@/components/ui/Btn.vue'
|
||||
import UserAvatar from '@/components/user/UserAvatar.vue'
|
||||
|
||||
const props = defineProps<{ user: User }>()
|
||||
const { user } = toRefs(props)
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { defineComponent, onMounted } from 'vue'
|
||||
import { authService } from '@/services'
|
||||
import { playlistFolderStore, playlistStore, userStore } from '@/stores'
|
||||
import { playlistFolderStore, playlistStore } from '@/stores'
|
||||
import { eventBus, forceReloadWindow } from '@/utils'
|
||||
import { useDialogBox, useMessageToaster, useRouter } from '@/composables'
|
||||
|
||||
|
@ -33,8 +33,7 @@ export const GlobalEventListeners = defineComponent({
|
|||
go('home')
|
||||
}
|
||||
}).on('LOG_OUT', async () => {
|
||||
await userStore.logout()
|
||||
authService.destroy()
|
||||
await authService.logout()
|
||||
forceReloadWindow()
|
||||
})
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
|||
switch (getDragType(event)) {
|
||||
case 'playlist':
|
||||
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:
|
||||
return
|
||||
}
|
||||
|
@ -133,7 +133,7 @@ export const useDroppable = (acceptedTypes: DraggableType[]) => {
|
|||
const artist = await artistStore.resolve(<number>data)
|
||||
return artist ? await songStore.fetchForArtist(artist) : <Song[]>[]
|
||||
case 'playlist':
|
||||
const playlist = playlistStore.byId(<number>data)
|
||||
const playlist = playlistStore.byId(<string>data)
|
||||
return playlist ? await songStore.fetchForPlaylist(playlist) : <Song[]>[]
|
||||
case 'playlist-folder':
|
||||
const folder = playlistFolderStore.byId(<string>data)
|
||||
|
|
|
@ -21,7 +21,7 @@ export const useRouter = () => {
|
|||
getCurrentScreen,
|
||||
isCurrentScreen,
|
||||
onScreenActivated,
|
||||
go: router.go.bind(router),
|
||||
go: Router.go,
|
||||
onRouteChanged: router.onRouteChanged.bind(router),
|
||||
resolveRoute: router.resolve.bind(router),
|
||||
triggerNotFound: router.triggerNotFound.bind(router)
|
||||
|
|
|
@ -17,12 +17,11 @@ import {
|
|||
|
||||
import ControlsToggle from '@/components/ui/ScreenControlsToggle.vue'
|
||||
import SongList from '@/components/song/SongList.vue'
|
||||
import SongListControls from '@/components/song/SongListControls.vue'
|
||||
import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||
|
||||
export const useSongList = (
|
||||
songs: Ref<Song[]>,
|
||||
config: Partial<SongListConfig> = { sortable: true, reorderable: true }
|
||||
config: Partial<SongListConfig> = { sortable: true, reorderable: true, collaborative: false }
|
||||
) => {
|
||||
const filterKeywords = ref('')
|
||||
config = reactive(config)
|
||||
|
@ -124,10 +123,10 @@ export const useSongList = (
|
|||
|
||||
return {
|
||||
SongList,
|
||||
SongListControls,
|
||||
ControlsToggle,
|
||||
ThumbnailStack,
|
||||
songs,
|
||||
config,
|
||||
headerLayout,
|
||||
sortField,
|
||||
sortOrder,
|
||||
|
|
|
@ -1,28 +1,22 @@
|
|||
import { useRouter } from '@/composables'
|
||||
import { merge } from 'lodash'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const useSongListControls = () => {
|
||||
const { isCurrentScreen } = useRouter()
|
||||
import SongListControls from '@/components/song/SongListControls.vue'
|
||||
|
||||
const getSongListControlsConfig = () => {
|
||||
const config: SongListControlsConfig = {
|
||||
play: true,
|
||||
addTo: {
|
||||
queue: true,
|
||||
favorites: true
|
||||
},
|
||||
clearQueue: false,
|
||||
deletePlaylist: false,
|
||||
refresh: false,
|
||||
filter: false
|
||||
}
|
||||
|
||||
config.clearQueue = isCurrentScreen('Queue')
|
||||
config.addTo.queue = !isCurrentScreen('Queue')
|
||||
config.addTo.favorites = !isCurrentScreen('Favorites')
|
||||
config.deletePlaylist = isCurrentScreen('Playlist')
|
||||
config.refresh = isCurrentScreen('Playlist')
|
||||
|
||||
config.filter = isCurrentScreen(
|
||||
export const useSongListControls = (
|
||||
screen: ScreenName,
|
||||
configOverrides: Partial<SongListControlsConfig> | (() => Partial<SongListControlsConfig>) = {}
|
||||
) => {
|
||||
const defaults: SongListControlsConfig = {
|
||||
play: true,
|
||||
addTo: {
|
||||
queue: screen !== 'Queue',
|
||||
favorites: screen !== 'Favorites',
|
||||
},
|
||||
clearQueue: screen === 'Queue',
|
||||
deletePlaylist: screen === 'Playlist',
|
||||
refresh: screen === 'Playlist',
|
||||
filter: [
|
||||
'Queue',
|
||||
'Artist',
|
||||
'Album',
|
||||
|
@ -30,12 +24,13 @@ export const useSongListControls = () => {
|
|||
'RecentlyPlayed',
|
||||
'Playlist',
|
||||
'Search.Songs'
|
||||
)
|
||||
|
||||
return config
|
||||
].includes(screen)
|
||||
}
|
||||
|
||||
const config = merge(defaults, typeof configOverrides === 'function' ? configOverrides() : configOverrides)
|
||||
|
||||
return {
|
||||
getSongListControlsConfig
|
||||
SongListControls,
|
||||
config: reactive(config)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
import { Route } from '@/router'
|
||||
import { userStore } from '@/stores'
|
||||
import { localStorageService } from '@/services'
|
||||
import { localStorageService, playlistCollaborationService } from '@/services'
|
||||
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[] = [
|
||||
{
|
||||
|
@ -80,9 +84,23 @@ export const routes: Route[] = [
|
|||
screen: 'Artist'
|
||||
},
|
||||
{
|
||||
path: '/playlist/(?<id>\\d+)',
|
||||
path: `/playlist/(?<id>${UUID_REGEX})`,
|
||||
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',
|
||||
screen: 'Genres'
|
||||
|
@ -96,7 +114,7 @@ export const routes: Route[] = [
|
|||
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',
|
||||
redirect: () => 'queue',
|
||||
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'
|
||||
}
|
||||
]
|
||||
|
|
|
@ -132,7 +132,7 @@ const onUserLoggedIn = async () => {
|
|||
|
||||
const init = async () => {
|
||||
try {
|
||||
userStore.init(await userStore.getProfile())
|
||||
userStore.init(await authService.getProfile())
|
||||
|
||||
await socketService.init()
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { ref, Ref, watch } from 'vue'
|
||||
import { forceReloadWindow } from '@/utils'
|
||||
|
||||
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
|
||||
|
||||
export type Route = {
|
||||
|
@ -44,7 +45,7 @@ export default class Router {
|
|||
|
||||
public async resolve () {
|
||||
if (!location.hash || location.hash === '#/' || location.hash === '#!/') {
|
||||
return this.go(this.homeRoute.path)
|
||||
return Router.go(this.homeRoute.path)
|
||||
}
|
||||
|
||||
const matched = this.tryMatchRoute()
|
||||
|
@ -54,13 +55,13 @@ export default class Router {
|
|||
return this.triggerNotFound()
|
||||
}
|
||||
|
||||
if (route.onResolve?.(params) === false) {
|
||||
if ((await route.onResolve?.(params)) === false) {
|
||||
return this.triggerNotFound()
|
||||
}
|
||||
|
||||
if (route.redirect) {
|
||||
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)
|
||||
|
@ -96,7 +97,7 @@ export default class Router {
|
|||
this.$currentRoute.value.params = params
|
||||
}
|
||||
|
||||
public go (path: string | number) {
|
||||
public static go (path: string | number, reload = false) {
|
||||
if (typeof path === 'number') {
|
||||
history.go(path)
|
||||
return
|
||||
|
@ -112,5 +113,7 @@ export default class Router {
|
|||
|
||||
path = path.substring(1, path.length)
|
||||
location.assign(`${location.origin}${location.pathname}${path}`)
|
||||
|
||||
reload && forceReloadWindow()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { localStorageService } from '@/services/localStorageService'
|
||||
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 {
|
||||
protected test () {
|
||||
|
@ -27,5 +28,58 @@ new class extends UnitTestCase {
|
|||
authService.destroy()
|
||||
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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 AUDIO_TOKEN_STORAGE_KEY = 'audio-token'
|
||||
|
||||
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),
|
||||
|
||||
hasApiToken () {
|
||||
|
|
|
@ -29,7 +29,7 @@ class Http {
|
|||
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
|
||||
}
|
||||
|
||||
|
@ -61,13 +61,11 @@ class Http {
|
|||
this.silent || this.hideLoadingIndicator()
|
||||
this.silent = false
|
||||
|
||||
// …get the tokens from the header or response data if exist, and save them.
|
||||
const token = response.headers.authorization || response.data.token
|
||||
// …get the tokens from the header if exist, and save them
|
||||
// This occurs during user updating password.
|
||||
const token = response.headers.authorization
|
||||
token && authService.setApiToken(token)
|
||||
|
||||
const audioToken = response.data['audio-token']
|
||||
audioToken && authService.setAudioToken(audioToken)
|
||||
|
||||
return response
|
||||
}, error => {
|
||||
this.silent || this.hideLoadingIndicator()
|
||||
|
|
|
@ -13,3 +13,4 @@ export * from './socketListener'
|
|||
export * from './volumeManager'
|
||||
export * from './invitationService'
|
||||
export * from './plusService'
|
||||
export * from './playlistCollaborationService'
|
||||
|
|
|
@ -89,7 +89,7 @@ class PlaybackService {
|
|||
await this.restart()
|
||||
}
|
||||
|
||||
private async setNowPlayingMeta(song) {
|
||||
private async setNowPlayingMeta(song: Song) {
|
||||
document.title = `${song.title} ♫ Koel`
|
||||
this.player.media.setAttribute('title', `${song.artist_name} - ${song.title}`)
|
||||
|
||||
|
|
16
resources/assets/js/services/playlistCollaborationService.ts
Normal file
16
resources/assets/js/services/playlistCollaborationService.ts
Normal 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 })
|
||||
}
|
||||
}
|
|
@ -52,7 +52,7 @@ export const playlistStore = {
|
|||
})
|
||||
},
|
||||
|
||||
byId (id: number) {
|
||||
byId (id: string) {
|
||||
return this.state.playlists.find(playlist => playlist.id === id)
|
||||
},
|
||||
|
||||
|
|
|
@ -9,6 +9,8 @@ export const queueStore = {
|
|||
songs: []
|
||||
}),
|
||||
|
||||
i: 0,
|
||||
|
||||
init (savedState: QueueState) {
|
||||
// don't set this.all here, as it would trigger saving state
|
||||
this.state.songs = songStore.syncWithVault(savedState.songs)
|
||||
|
|
|
@ -174,8 +174,8 @@ export const songStore = {
|
|||
))
|
||||
},
|
||||
|
||||
async fetchForPlaylist (playlist: Playlist | number, refresh = false) {
|
||||
const id = typeof playlist === 'number' ? playlist : playlist.id
|
||||
async fetchForPlaylist (playlist: Playlist | string, refresh = false) {
|
||||
const id = typeof playlist === 'string' ? playlist : playlist.id
|
||||
|
||||
if (refresh) {
|
||||
cache.remove(['playlist.songs', id])
|
||||
|
|
|
@ -2,7 +2,7 @@ import { expect, it } from 'vitest'
|
|||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { http } from '@/services'
|
||||
import { CreateUserData, UpdateCurrentProfileData, UpdateUserData, userStore } from '.'
|
||||
import { CreateUserData, UpdateUserData, userStore } from '.'
|
||||
|
||||
const currentUser = factory<User>('user', {
|
||||
id: 1,
|
||||
|
@ -50,49 +50,6 @@ new class extends UnitTestCase {
|
|||
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 () => {
|
||||
const data: CreateUserData = {
|
||||
is_admin: false,
|
||||
|
|
|
@ -2,15 +2,6 @@ import { differenceBy, merge } from 'lodash'
|
|||
import { http } from '@/services'
|
||||
import { reactive } from 'vue'
|
||||
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 {
|
||||
name: string
|
||||
|
@ -27,7 +18,7 @@ export interface UpdateUserData extends UserFormData {
|
|||
}
|
||||
|
||||
export const userStore = {
|
||||
vault: new Map<number, UnwrapNestedRefs<User>>(),
|
||||
vault: new Map<number, User>(),
|
||||
|
||||
state: reactive({
|
||||
users: [] as User[],
|
||||
|
@ -61,14 +52,6 @@ export const userStore = {
|
|||
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) {
|
||||
const user = await http.post<User>('users', data)
|
||||
this.add(user)
|
||||
|
|
16
resources/assets/js/types.d.ts
vendored
16
resources/assets/js/types.d.ts
vendored
|
@ -155,6 +155,14 @@ interface Song {
|
|||
deleted?: boolean
|
||||
}
|
||||
|
||||
interface CollaborativeSong extends Song {
|
||||
collaboration: {
|
||||
user: Pick<User, 'name' | 'avatar'>
|
||||
added_at: string | null
|
||||
fmt_added_at: string | null
|
||||
}
|
||||
}
|
||||
|
||||
interface QueueState {
|
||||
type: 'queue-states'
|
||||
songs: Song[]
|
||||
|
@ -217,12 +225,14 @@ interface PlaylistFolder {
|
|||
|
||||
interface Playlist {
|
||||
type: 'playlists'
|
||||
readonly id: number
|
||||
readonly id: string
|
||||
readonly user_id: User['id']
|
||||
name: string
|
||||
folder_id: PlaylistFolder['id'] | null
|
||||
is_smart: boolean
|
||||
rules: SmartPlaylistRuleGroup[]
|
||||
own_songs_only: boolean
|
||||
collaborators: User[]
|
||||
}
|
||||
|
||||
type PlaylistLike = Playlist | FavoriteList | RecentlyPlayedList
|
||||
|
@ -302,7 +312,7 @@ interface EqualizerPreset {
|
|||
declare type PlaybackState = 'Stopped' | 'Playing' | 'Paused'
|
||||
declare type ScreenName =
|
||||
| 'Home'
|
||||
| 'Default'
|
||||
| 'Default' | 'Blank'
|
||||
| 'Queue'
|
||||
| 'Songs'
|
||||
| 'Albums'
|
||||
|
@ -333,7 +343,6 @@ interface AddToMenuConfig {
|
|||
}
|
||||
|
||||
interface SongListControlsConfig {
|
||||
play: boolean
|
||||
addTo: AddToMenuConfig
|
||||
clearQueue: boolean
|
||||
deletePlaylist: boolean
|
||||
|
@ -369,6 +378,7 @@ type RepeatMode = 'NO_REPEAT' | 'REPEAT_ALL' | 'REPEAT_ONE'
|
|||
interface SongListConfig {
|
||||
sortable: boolean
|
||||
reorderable: boolean
|
||||
collaborative: boolean
|
||||
}
|
||||
|
||||
type SongListSortField = keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>
|
||||
|
|
|
@ -17,19 +17,23 @@ export const forceReloadWindow = (): void => {
|
|||
window.location.reload()
|
||||
}
|
||||
|
||||
export const copyText = (text: string): void => {
|
||||
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea')
|
||||
export const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (e) {
|
||||
let copyArea = document.querySelector<HTMLTextAreaElement>('#copyArea')
|
||||
|
||||
if (!copyArea) {
|
||||
copyArea = document.createElement('textarea')
|
||||
copyArea.id = 'copyArea'
|
||||
document.body.appendChild(copyArea)
|
||||
if (!copyArea) {
|
||||
copyArea = document.createElement('textarea')
|
||||
copyArea.id = '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 = () => {
|
||||
|
|
|
@ -24,6 +24,8 @@ use App\Http\Controllers\API\GenreController;
|
|||
use App\Http\Controllers\API\GenreSongController;
|
||||
use App\Http\Controllers\API\LikeMultipleSongsController;
|
||||
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\PlaylistFolderController;
|
||||
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/privatize', PrivatizeSongsController::class);
|
||||
|
||||
// License routes
|
||||
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
|
||||
|
|
|
@ -129,7 +129,7 @@ class DownloadTest extends TestCase
|
|||
/** @var Playlist $playlist */
|
||||
$playlist = Playlist::factory()->for($user)->create();
|
||||
|
||||
$playlist->songs()->attach($songs);
|
||||
$playlist->addSongs($songs);
|
||||
|
||||
$this->downloadService
|
||||
->shouldReceive('getDownloadablePath')
|
||||
|
|
|
@ -33,7 +33,7 @@ class InitialDataTest extends TestCase
|
|||
'short_key',
|
||||
'customer_name',
|
||||
'customer_email',
|
||||
'store_url',
|
||||
'product_id',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue