mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat!: make app progressive
This commit is contained in:
parent
a89595289a
commit
fbbe434204
208 changed files with 4977 additions and 7768 deletions
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Services\LibraryManager;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class PruneLibraryCommand extends Command
|
||||
|
@ -10,9 +10,17 @@ class PruneLibraryCommand extends Command
|
|||
protected $signature = 'koel:prune';
|
||||
protected $description = 'Remove empty artists and albums';
|
||||
|
||||
public function handle(): void
|
||||
public function __construct(private LibraryManager $libraryManager)
|
||||
{
|
||||
event(new LibraryChanged());
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->libraryManager->prune();
|
||||
|
||||
$this->info('Empty artists and albums removed.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
class MediaCacheObsolete extends Event
|
||||
{
|
||||
}
|
|
@ -8,14 +8,14 @@ class BatchLikeController extends Controller
|
|||
{
|
||||
public function store(BatchInteractionRequest $request)
|
||||
{
|
||||
$interactions = $this->interactionService->batchLike((array) $request->songs, $this->currentUser);
|
||||
$interactions = $this->interactionService->batchLike((array) $request->songs, $this->user);
|
||||
|
||||
return response()->json($interactions);
|
||||
}
|
||||
|
||||
public function destroy(BatchInteractionRequest $request)
|
||||
{
|
||||
$this->interactionService->batchUnlike((array) $request->songs, $this->currentUser);
|
||||
$this->interactionService->batchUnlike((array) $request->songs, $this->user);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
|
|
@ -12,11 +12,11 @@ class Controller extends BaseController
|
|||
protected InteractionService $interactionService;
|
||||
|
||||
/** @var User */
|
||||
protected ?Authenticatable $currentUser = null;
|
||||
protected ?Authenticatable $user = null;
|
||||
|
||||
public function __construct(InteractionService $interactionService, ?Authenticatable $currentUser)
|
||||
{
|
||||
$this->interactionService = $interactionService;
|
||||
$this->currentUser = $currentUser;
|
||||
$this->user = $currentUser;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,6 @@ class LikeController extends Controller
|
|||
{
|
||||
public function store(SongLikeRequest $request)
|
||||
{
|
||||
return response()->json($this->interactionService->toggleLike($request->song, $this->currentUser));
|
||||
return response()->json($this->interactionService->toggleLike($request->song, $this->user));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ class PlayCountController extends Controller
|
|||
{
|
||||
public function store(StorePlayCountRequest $request)
|
||||
{
|
||||
$interaction = $this->interactionService->increasePlayCount($request->song, $this->currentUser);
|
||||
$interaction = $this->interactionService->increasePlayCount($request->song, $this->user);
|
||||
event(new SongStartedPlaying($interaction->song, $interaction->user));
|
||||
|
||||
return response()->json($interaction);
|
||||
|
|
|
@ -2,26 +2,24 @@
|
|||
|
||||
namespace App\Http\Controllers\API\Interaction;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Repositories\InteractionRepository;
|
||||
use App\Services\InteractionService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class RecentlyPlayedController extends Controller
|
||||
{
|
||||
private InteractionRepository $interactionRepository;
|
||||
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
InteractionService $interactionService,
|
||||
InteractionRepository $interactionRepository,
|
||||
?Authenticatable $currentUser
|
||||
protected InteractionService $interactionService,
|
||||
protected InteractionRepository $interactionRepository,
|
||||
protected ?Authenticatable $user
|
||||
) {
|
||||
parent::__construct($interactionService, $currentUser);
|
||||
|
||||
$this->interactionRepository = $interactionRepository;
|
||||
parent::__construct($interactionService, $user);
|
||||
}
|
||||
|
||||
public function index(?int $count = null)
|
||||
{
|
||||
return response()->json($this->interactionRepository->getRecentlyPlayed($this->currentUser, $count));
|
||||
return response()->json($this->interactionRepository->getRecentlyPlayed($this->user, $count));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,40 +3,36 @@
|
|||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Requests\API\ProfileUpdateRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use App\Services\TokenManager;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Contracts\Hashing\Hasher as Hash;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
private Hash $hash;
|
||||
private TokenManager $tokenManager;
|
||||
|
||||
/** @var User */
|
||||
private ?Authenticatable $currentUser;
|
||||
|
||||
public function __construct(Hash $hash, TokenManager $tokenManager, ?Authenticatable $currentUser)
|
||||
{
|
||||
$this->hash = $hash;
|
||||
$this->tokenManager = $tokenManager;
|
||||
$this->currentUser = $currentUser;
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private Hasher $hash,
|
||||
private TokenManager $tokenManager,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function show()
|
||||
{
|
||||
return response()->json($this->currentUser);
|
||||
return UserResource::make($this->user);
|
||||
}
|
||||
|
||||
public function update(ProfileUpdateRequest $request)
|
||||
{
|
||||
if (config('koel.misc.demo')) {
|
||||
return response()->json();
|
||||
return response()->noContent();
|
||||
}
|
||||
|
||||
throw_unless(
|
||||
$this->hash->check($request->current_password, $this->currentUser->password),
|
||||
$this->hash->check($request->current_password, $this->user->password),
|
||||
ValidationException::withMessages(['current_password' => 'Invalid current password'])
|
||||
);
|
||||
|
||||
|
@ -46,12 +42,14 @@ class ProfileController extends Controller
|
|||
$data['password'] = $this->hash->make($request->new_password);
|
||||
}
|
||||
|
||||
$this->currentUser->update($data);
|
||||
$this->user->update($data);
|
||||
|
||||
$responseData = $request->new_password
|
||||
? ['token' => $this->tokenManager->refreshToken($this->currentUser)->plainTextToken]
|
||||
: [];
|
||||
$response = UserResource::make($this->user)->response();
|
||||
|
||||
return response()->json($responseData);
|
||||
if ($request->new_password) {
|
||||
$response->header('Authorization', $this->tokenManager->refreshToken($this->user)->plainTextToken);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,29 +3,46 @@
|
|||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Requests\API\SongUpdateRequest;
|
||||
use App\Models\Song;
|
||||
use App\Http\Resources\AlbumResource;
|
||||
use App\Http\Resources\ArtistResource;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Services\LibraryManager;
|
||||
use App\Services\SongService;
|
||||
use App\Values\SongUpdateData;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class SongController extends Controller
|
||||
{
|
||||
private ArtistRepository $artistRepository;
|
||||
private AlbumRepository $albumRepository;
|
||||
|
||||
public function __construct(ArtistRepository $artistRepository, AlbumRepository $albumRepository)
|
||||
{
|
||||
$this->artistRepository = $artistRepository;
|
||||
$this->albumRepository = $albumRepository;
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private SongService $songService,
|
||||
private AlbumRepository $albumRepository,
|
||||
private ArtistRepository $artistRepository,
|
||||
private LibraryManager $libraryManager,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function update(SongUpdateRequest $request)
|
||||
{
|
||||
$updatedSongs = Song::updateInfo($request->songs, $request->data);
|
||||
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
|
||||
$albums = $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->toArray(), $this->user);
|
||||
|
||||
$artists = $this->artistRepository->getByIds(
|
||||
array_merge(
|
||||
$updatedSongs->pluck('artist_id')->all(),
|
||||
$updatedSongs->pluck('album_artist_id')->all()
|
||||
)
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'artists' => $this->artistRepository->getByIds($updatedSongs->pluck('artist_id')->all()),
|
||||
'albums' => $this->albumRepository->getByIds($updatedSongs->pluck('album_id')->all()),
|
||||
'songs' => $updatedSongs,
|
||||
'songs' => SongResource::collection($updatedSongs),
|
||||
'albums' => AlbumResource::collection($albums),
|
||||
'artists' => ArtistResource::collection($artists),
|
||||
'removed' => $this->libraryManager->prune(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,35 +2,41 @@
|
|||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Events\MediaCacheObsolete;
|
||||
use App\Exceptions\MediaPathNotSetException;
|
||||
use App\Exceptions\SongUploadFailedException;
|
||||
use App\Http\Requests\API\UploadRequest;
|
||||
use App\Http\Resources\AlbumResource;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Services\UploadService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class UploadController extends Controller
|
||||
{
|
||||
private UploadService $uploadService;
|
||||
/** @param User $user */
|
||||
public function __invoke(
|
||||
UploadService $uploadService,
|
||||
AlbumRepository $albumRepository,
|
||||
SongRepository $songRepository,
|
||||
UploadRequest $request,
|
||||
Authenticatable $user
|
||||
) {
|
||||
$this->authorize('admin', User::class);
|
||||
|
||||
public function __construct(UploadService $uploadService)
|
||||
{
|
||||
$this->uploadService = $uploadService;
|
||||
}
|
||||
|
||||
public function store(UploadRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$song = $this->uploadService->handleUploadedFile($request->file);
|
||||
$song = $songRepository->getOne($uploadService->handleUploadedFile($request->file)->id);
|
||||
|
||||
return response()->json([
|
||||
'song' => SongResource::make($song),
|
||||
'album' => AlbumResource::make($albumRepository->getOne($song->album_id)),
|
||||
]);
|
||||
} catch (MediaPathNotSetException $e) {
|
||||
abort(Response::HTTP_FORBIDDEN, $e->getMessage());
|
||||
} catch (SongUploadFailedException $e) {
|
||||
abort(Response::HTTP_BAD_REQUEST, $e->getMessage());
|
||||
}
|
||||
|
||||
event(new MediaCacheObsolete());
|
||||
|
||||
return response()->json($song->load('album', 'artist'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,46 +4,53 @@ namespace App\Http\Controllers\API;
|
|||
|
||||
use App\Http\Requests\API\UserStoreRequest;
|
||||
use App\Http\Requests\API\UserUpdateRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Hashing\Hasher as Hash;
|
||||
use App\Repositories\UserRepository;
|
||||
use App\Services\UserService;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
private Hash $hash;
|
||||
|
||||
public function __construct(Hash $hash)
|
||||
public function __construct(private UserRepository $userRepository, private UserService $userService)
|
||||
{
|
||||
$this->hash = $hash;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->authorize('admin', User::class);
|
||||
|
||||
return UserResource::collection($this->userRepository->getAll());
|
||||
}
|
||||
|
||||
public function store(UserStoreRequest $request)
|
||||
{
|
||||
return response()->json(User::create([
|
||||
'name' => $request->name,
|
||||
'email' => $request->email,
|
||||
'password' => $this->hash->make($request->password),
|
||||
'is_admin' => $request->is_admin,
|
||||
]));
|
||||
$this->authorize('admin', User::class);
|
||||
|
||||
return UserResource::make($this->userService->createUser(
|
||||
$request->name,
|
||||
$request->email,
|
||||
$request->password,
|
||||
$request->get('is_admin') ?: false
|
||||
));
|
||||
}
|
||||
|
||||
public function update(UserUpdateRequest $request, User $user)
|
||||
{
|
||||
$data = $request->only('name', 'email', 'is_admin');
|
||||
$this->authorize('admin', User::class);
|
||||
|
||||
if ($request->password) {
|
||||
$data['password'] = $this->hash->make($request->password);
|
||||
}
|
||||
|
||||
$user->update($data);
|
||||
|
||||
return response()->json($user);
|
||||
return UserResource::make($this->userService->updateUser(
|
||||
$user,
|
||||
$request->name,
|
||||
$request->email,
|
||||
$request->password,
|
||||
$request->get('is_admin') ?: false
|
||||
));
|
||||
}
|
||||
|
||||
public function destroy(User $user)
|
||||
{
|
||||
$this->authorize('destroy', $user);
|
||||
|
||||
$user->delete();
|
||||
$this->userService->deleteUser($user);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
|
|
40
app/Http/Controllers/V6/API/AlbumController.php
Normal file
40
app/Http/Controllers/V6/API/AlbumController.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\AlbumResource;
|
||||
use App\Models\Album;
|
||||
use App\Models\User;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Services\MediaInformationService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class AlbumController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private AlbumRepository $albumRepository,
|
||||
private MediaInformationService $informationService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$pagination = Album::withMeta($this->user)
|
||||
->isStandard()
|
||||
->orderBy('albums.name')
|
||||
->simplePaginate(21);
|
||||
|
||||
return AlbumResource::collection($pagination);
|
||||
}
|
||||
|
||||
public function show(Album $album)
|
||||
{
|
||||
$album = $this->albumRepository->getOne($album->id, $this->user);
|
||||
$album->information = $this->informationService->getAlbumInformation($album);
|
||||
|
||||
return AlbumResource::make($album);
|
||||
}
|
||||
}
|
23
app/Http/Controllers/V6/API/AlbumSongController.php
Normal file
23
app/Http/Controllers/V6/API/AlbumSongController.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\Album;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class AlbumSongController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Album $album)
|
||||
{
|
||||
return SongResource::collection($this->songRepository->getByAlbum($album, $this->user));
|
||||
}
|
||||
}
|
40
app/Http/Controllers/V6/API/ArtistController.php
Normal file
40
app/Http/Controllers/V6/API/ArtistController.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\ArtistResource;
|
||||
use App\Models\Artist;
|
||||
use App\Models\User;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Services\MediaInformationService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class ArtistController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private ArtistRepository $artistRepository,
|
||||
private MediaInformationService $informationService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$pagination = Artist::withMeta($this->user)
|
||||
->isStandard()
|
||||
->orderBy('artists.name')
|
||||
->simplePaginate(21);
|
||||
|
||||
return ArtistResource::collection($pagination);
|
||||
}
|
||||
|
||||
public function show(Artist $artist)
|
||||
{
|
||||
$artist = $this->artistRepository->getOne($artist->id, $this->user);
|
||||
$artist->information = $this->informationService->getArtistInformation($artist);
|
||||
|
||||
return ArtistResource::make($artist);
|
||||
}
|
||||
}
|
23
app/Http/Controllers/V6/API/ArtistSongController.php
Normal file
23
app/Http/Controllers/V6/API/ArtistSongController.php
Normal file
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\Artist;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class ArtistSongController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(Artist $artist)
|
||||
{
|
||||
return SongResource::collection($this->songRepository->getByArtist($artist, $this->user));
|
||||
}
|
||||
}
|
53
app/Http/Controllers/V6/API/DataController.php
Normal file
53
app/Http/Controllers/V6/API/DataController.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
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;
|
||||
use App\Services\ITunesService;
|
||||
use App\Services\LastfmService;
|
||||
use App\Services\YouTubeService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class DataController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private LastfmService $lastfmService,
|
||||
private YouTubeService $youTubeService,
|
||||
private ITunesService $iTunesService,
|
||||
private SettingRepository $settingRepository,
|
||||
private PlaylistRepository $playlistRepository,
|
||||
private SongRepository $songRepository,
|
||||
private ApplicationInformationService $applicationInformationService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return response()->json([
|
||||
'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [],
|
||||
'playlists' => $this->playlistRepository->getAllByCurrentUser(),
|
||||
'current_user' => UserResource::make($this->user),
|
||||
'use_last_fm' => $this->lastfmService->used(),
|
||||
'use_you_tube' => $this->youTubeService->enabled(),
|
||||
'use_i_tunes' => $this->iTunesService->used(),
|
||||
'allow_download' => config('koel.download.allow'),
|
||||
'supports_transcoding' => config('koel.streaming.ffmpeg_path')
|
||||
&& is_executable(config('koel.streaming.ffmpeg_path')),
|
||||
'cdn_url' => static_url(),
|
||||
'current_version' => koel_version(),
|
||||
'latest_version' => $this->user->is_admin
|
||||
? $this->applicationInformationService->getLatestVersionNumber()
|
||||
: koel_version(),
|
||||
'song_count' => $this->songRepository->count(),
|
||||
'song_length' => $this->songRepository->getTotalLength(),
|
||||
]);
|
||||
}
|
||||
}
|
19
app/Http/Controllers/V6/API/ExcerptSearchController.php
Normal file
19
app/Http/Controllers/V6/API/ExcerptSearchController.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Controllers\V6\Requests\SearchRequest;
|
||||
use App\Http\Resources\ExcerptSearchResource;
|
||||
use App\Models\User;
|
||||
use App\Services\V6\SearchService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class ExcerptSearchController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __invoke(SearchRequest $request, SearchService $searchService, Authenticatable $user)
|
||||
{
|
||||
return ExcerptSearchResource::make($searchService->excerptSearch($request->q, $user));
|
||||
}
|
||||
}
|
22
app/Http/Controllers/V6/API/FavoriteController.php
Normal file
22
app/Http/Controllers/V6/API/FavoriteController.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class FavoriteController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return SongResource::collection($this->songRepository->getFavorites($this->user));
|
||||
}
|
||||
}
|
33
app/Http/Controllers/V6/API/OverviewController.php
Normal file
33
app/Http/Controllers/V6/API/OverviewController.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\AlbumResource;
|
||||
use App\Http\Resources\ArtistResource;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Repositories\SongRepository;
|
||||
|
||||
class OverviewController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private SongRepository $songRepository,
|
||||
private AlbumRepository $albumRepository,
|
||||
private ArtistRepository $artistRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return response()->json([
|
||||
'most_played_songs' => SongResource::collection($this->songRepository->getMostPlayed()),
|
||||
'recently_played_songs' => SongResource::collection($this->songRepository->getRecentlyPlayed()),
|
||||
'recently_added_albums' => AlbumResource::collection($this->albumRepository->getRecentlyAdded()),
|
||||
'recently_added_songs' => SongResource::collection($this->songRepository->getRecentlyAdded()),
|
||||
'most_played_artists' => ArtistResource::collection($this->artistRepository->getMostPlayed()),
|
||||
'most_played_albums' => AlbumResource::collection($this->albumRepository->getMostPlayed()),
|
||||
]);
|
||||
}
|
||||
}
|
19
app/Http/Controllers/V6/API/PlayCountController.php
Normal file
19
app/Http/Controllers/V6/API/PlayCountController.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Events\SongStartedPlaying;
|
||||
use App\Http\Controllers\API\Interaction\Controller;
|
||||
use App\Http\Requests\API\Interaction\StorePlayCountRequest;
|
||||
use App\Http\Resources\InteractionResource;
|
||||
|
||||
class PlayCountController extends Controller
|
||||
{
|
||||
public function store(StorePlayCountRequest $request)
|
||||
{
|
||||
$interaction = $this->interactionService->increasePlayCount($request->song, $this->user);
|
||||
event(new SongStartedPlaying($interaction->song, $interaction->user));
|
||||
|
||||
return InteractionResource::make($interaction);
|
||||
}
|
||||
}
|
33
app/Http/Controllers/V6/API/PlaylistSongController.php
Normal file
33
app/Http/Controllers/V6/API/PlaylistSongController.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Services\SmartPlaylistService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class PlaylistSongController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(
|
||||
private SongRepository $songRepository,
|
||||
private SmartPlaylistService $smartPlaylistService,
|
||||
private ?Authenticatable $user
|
||||
) {
|
||||
}
|
||||
|
||||
public function index(Playlist $playlist)
|
||||
{
|
||||
$this->authorize('owner', $playlist);
|
||||
|
||||
return SongResource::collection(
|
||||
$playlist->is_smart
|
||||
? $this->smartPlaylistService->getSongs($playlist)
|
||||
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
|
||||
);
|
||||
}
|
||||
}
|
34
app/Http/Controllers/V6/API/QueueController.php
Normal file
34
app/Http/Controllers/V6/API/QueueController.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Controllers\V6\Requests\QueueFetchSongRequest;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class QueueController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function fetchSongs(QueueFetchSongRequest $request)
|
||||
{
|
||||
if ($request->order === 'rand') {
|
||||
return SongResource::collection($this->songRepository->getRandom($request->limit, $this->user));
|
||||
} else {
|
||||
return SongResource::collection(
|
||||
$this->songRepository->getForQueue(
|
||||
$request->sort,
|
||||
$request->order,
|
||||
$this->user,
|
||||
$request->limit,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
24
app/Http/Controllers/V6/API/RecentlyPlayedSongController.php
Normal file
24
app/Http/Controllers/V6/API/RecentlyPlayedSongController.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class RecentlyPlayedSongController extends Controller
|
||||
{
|
||||
private const MAX_ITEM_COUNT = 128;
|
||||
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
return SongResource::collection($this->songRepository->getRecentlyPlayed(self::MAX_ITEM_COUNT, $this->user));
|
||||
}
|
||||
}
|
29
app/Http/Controllers/V6/API/SongController.php
Normal file
29
app/Http/Controllers/V6/API/SongController.php
Normal file
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Controllers\V6\Requests\SongListRequest;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Repositories\SongRepository;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class SongController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __construct(private SongRepository $songRepository, private ?Authenticatable $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function index(SongListRequest $request)
|
||||
{
|
||||
return SongResource::collection(
|
||||
$this->songRepository->getForListing(
|
||||
$request->sort ?: 'songs.title',
|
||||
$request->order ?: 'asc',
|
||||
$this->user
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
19
app/Http/Controllers/V6/API/SongSearchController.php
Normal file
19
app/Http/Controllers/V6/API/SongSearchController.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Controllers\V6\Requests\SearchRequest;
|
||||
use App\Http\Resources\SongResource;
|
||||
use App\Models\User;
|
||||
use App\Services\V6\SearchService;
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
|
||||
class SongSearchController extends Controller
|
||||
{
|
||||
/** @param User $user */
|
||||
public function __invoke(SearchRequest $request, SearchService $searchService, Authenticatable $user)
|
||||
{
|
||||
return SongResource::collection($searchService->searchSongs($request->q, $user));
|
||||
}
|
||||
}
|
16
app/Http/Controllers/V6/API/YouTubeSearchController.php
Normal file
16
app/Http/Controllers/V6/API/YouTubeSearchController.php
Normal file
|
@ -0,0 +1,16 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\API;
|
||||
|
||||
use App\Http\Controllers\API\Controller;
|
||||
use App\Http\Controllers\V6\Requests\YouTubeSearchRequest;
|
||||
use App\Models\Song;
|
||||
use App\Services\YouTubeService;
|
||||
|
||||
class YouTubeSearchController extends Controller
|
||||
{
|
||||
public function __invoke(YouTubeSearchRequest $request, Song $song, YouTubeService $youTubeService)
|
||||
{
|
||||
return $youTubeService->searchVideosRelatedToSong($song, (string) $request->pageToken);
|
||||
}
|
||||
}
|
24
app/Http/Controllers/V6/Requests/QueueFetchSongRequest.php
Normal file
24
app/Http/Controllers/V6/Requests/QueueFetchSongRequest.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\Requests;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property-read string|null $sort
|
||||
* @property-read string $order
|
||||
* @property-read int $limit
|
||||
*/
|
||||
class QueueFetchSongRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'order' => ['required', Rule::in('asc', 'desc', 'rand')],
|
||||
'limit' => 'required|integer|min:1',
|
||||
'sort' => 'required_unless:order,rand',
|
||||
];
|
||||
}
|
||||
}
|
17
app/Http/Controllers/V6/Requests/SearchRequest.php
Normal file
17
app/Http/Controllers/V6/Requests/SearchRequest.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\Requests;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
|
||||
/**
|
||||
* @property-read string $q
|
||||
*/
|
||||
class SearchRequest extends Request
|
||||
{
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return ['q' => 'required'];
|
||||
}
|
||||
}
|
13
app/Http/Controllers/V6/Requests/SongListRequest.php
Normal file
13
app/Http/Controllers/V6/Requests/SongListRequest.php
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\Requests;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
|
||||
/**
|
||||
* @property-read string $order
|
||||
* @property-read string $sort
|
||||
*/
|
||||
class SongListRequest extends Request
|
||||
{
|
||||
}
|
12
app/Http/Controllers/V6/Requests/YouTubeSearchRequest.php
Normal file
12
app/Http/Controllers/V6/Requests/YouTubeSearchRequest.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\V6\Requests;
|
||||
|
||||
use App\Http\Requests\API\Request;
|
||||
|
||||
/**
|
||||
* @property-read string|null $pageToken
|
||||
*/
|
||||
class YouTubeSearchRequest extends Request
|
||||
{
|
||||
}
|
|
@ -5,6 +5,7 @@ namespace App\Http;
|
|||
use App\Http\Middleware\Authenticate;
|
||||
use App\Http\Middleware\ForceHttps;
|
||||
use App\Http\Middleware\ObjectStorageAuthenticate;
|
||||
use App\Http\Middleware\ThrottleRequests;
|
||||
use App\Http\Middleware\TrimStrings;
|
||||
use Illuminate\Auth\Middleware\Authorize;
|
||||
use Illuminate\Foundation\Http\Kernel as HttpKernel;
|
||||
|
@ -12,7 +13,6 @@ use Illuminate\Foundation\Http\Middleware\CheckForMaintenanceMode;
|
|||
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
|
||||
class Kernel extends HttpKernel
|
||||
{
|
||||
|
|
19
app/Http/Middleware/ThrottleRequests.php
Normal file
19
app/Http/Middleware/ThrottleRequests.php
Normal file
|
@ -0,0 +1,19 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests as BaseThrottleRequests;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ThrottleRequests extends BaseThrottleRequests
|
||||
{
|
||||
public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = ''): Response
|
||||
{
|
||||
if (app()->environment('production')) {
|
||||
return parent::handle($request, $next, $maxAttempts, $decayMinutes, $prefix);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
|
@ -8,11 +8,6 @@ use Illuminate\Http\UploadedFile;
|
|||
/** @property UploadedFile $file */
|
||||
class UploadRequest extends AbstractRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->is_admin;
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
|
@ -20,7 +15,7 @@ class UploadRequest extends AbstractRequest
|
|||
'file' => [
|
||||
'required',
|
||||
'file',
|
||||
'mimetypes:audio/flac,audio/mpeg,audio/ogg,audio/x-flac,audio/x-aac',
|
||||
'mimes:mp3,mpga,aac,flac,ogg,oga,opus',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
@ -5,18 +5,12 @@ namespace App\Http\Requests\API;
|
|||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* @property string $password
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property bool $is_admin
|
||||
* @property-read string $password
|
||||
* @property-read string $name
|
||||
* @property-read string $email
|
||||
*/
|
||||
class UserStoreRequest extends Request
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->is_admin;
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
|
@ -24,7 +18,7 @@ class UserStoreRequest extends Request
|
|||
'name' => 'required',
|
||||
'email' => 'required|email|unique:users',
|
||||
'password' => ['required', Password::defaults()],
|
||||
'is_admin' => 'required',
|
||||
'is_admin' => 'sometimes',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,18 +6,12 @@ use App\Models\User;
|
|||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* @property string $password
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property bool $is_admin
|
||||
* @property-read string $password
|
||||
* @property-read string $name
|
||||
* @property-read string $email
|
||||
*/
|
||||
class UserUpdateRequest extends Request
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return auth()->user()->is_admin;
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
|
@ -28,6 +22,7 @@ class UserUpdateRequest extends Request
|
|||
'name' => 'required',
|
||||
'email' => 'required|email|unique:users,email,' . $user->id,
|
||||
'password' => ['sometimes', Password::defaults()],
|
||||
'is_admin' => 'sometimes',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
33
app/Http/Resources/AlbumResource.php
Normal file
33
app/Http/Resources/AlbumResource.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Album;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AlbumResource extends JsonResource
|
||||
{
|
||||
public function __construct(private Album $album)
|
||||
{
|
||||
parent::__construct($album);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'albums',
|
||||
'id' => $this->album->id,
|
||||
'name' => $this->album->name,
|
||||
'artist_id' => $this->album->artist_id,
|
||||
'artist_name' => $this->album->artist->name,
|
||||
'cover' => $this->album->cover,
|
||||
'created_at' => $this->album->created_at,
|
||||
'length' => $this->album->length,
|
||||
'play_count' => (int) $this->album->play_count,
|
||||
'song_count' => (int) $this->album->song_count,
|
||||
'info' => Arr::wrap($this->album->information),
|
||||
];
|
||||
}
|
||||
}
|
32
app/Http/Resources/ArtistResource.php
Normal file
32
app/Http/Resources/ArtistResource.php
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Artist;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class ArtistResource extends JsonResource
|
||||
{
|
||||
public function __construct(private Artist $artist)
|
||||
{
|
||||
parent::__construct($artist);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'artists',
|
||||
'id' => $this->artist->id,
|
||||
'name' => $this->artist->name,
|
||||
'image' => $this->artist->image,
|
||||
'length' => $this->artist->length,
|
||||
'play_count' => (int) $this->artist->play_count,
|
||||
'song_count' => (int) $this->artist->song_count,
|
||||
'album_count' => (int) $this->artist->album_count,
|
||||
'created_at' => $this->artist->created_at,
|
||||
'info' => Arr::wrap($this->artist->information),
|
||||
];
|
||||
}
|
||||
}
|
24
app/Http/Resources/ExcerptSearchResource.php
Normal file
24
app/Http/Resources/ExcerptSearchResource.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Values\ExcerptSearchResult;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ExcerptSearchResource extends JsonResource
|
||||
{
|
||||
public function __construct(private ExcerptSearchResult $result)
|
||||
{
|
||||
parent::__construct($result);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'songs' => SongResource::collection($this->result->songs),
|
||||
'artists' => ArtistResource::collection($this->result->artists),
|
||||
'albums' => AlbumResource::collection($this->result->albums),
|
||||
];
|
||||
}
|
||||
}
|
28
app/Http/Resources/InteractionResource.php
Normal file
28
app/Http/Resources/InteractionResource.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Interaction;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class InteractionResource extends JsonResource
|
||||
{
|
||||
public function __construct(private Interaction $interaction)
|
||||
{
|
||||
parent::__construct($interaction);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'interactions',
|
||||
'id' => $this->interaction->id,
|
||||
'songId' => $this->interaction->song_id, // @fixme backwards compatibility
|
||||
'song_id' => $this->interaction->song_id,
|
||||
'liked' => $this->interaction->liked,
|
||||
'playCount' => $this->interaction->play_count, // @fixme backwards compatibility
|
||||
'play_count' => $this->interaction->play_count,
|
||||
];
|
||||
}
|
||||
}
|
38
app/Http/Resources/SongResource.php
Normal file
38
app/Http/Resources/SongResource.php
Normal file
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Song;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SongResource extends JsonResource
|
||||
{
|
||||
public function __construct(private Song $song)
|
||||
{
|
||||
parent::__construct($song);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'songs',
|
||||
'id' => $this->song->id,
|
||||
'title' => $this->song->title,
|
||||
'lyrics' => $this->song->lyrics,
|
||||
'album_id' => $this->song->album->id,
|
||||
'album_name' => $this->song->album->name,
|
||||
'artist_id' => $this->song->artist->id,
|
||||
'artist_name' => $this->song->artist->name,
|
||||
'album_artist_id' => $this->song->album->artist->id,
|
||||
'album_artist_name' => $this->song->album->artist->name,
|
||||
'album_cover' => $this->song->album->cover,
|
||||
'length' => $this->song->length,
|
||||
'liked' => (bool) $this->song->liked,
|
||||
'play_count' => (int) $this->song->play_count,
|
||||
'track' => $this->song->track,
|
||||
'disc' => $this->song->disc,
|
||||
'created_at' => $this->song->created_at,
|
||||
];
|
||||
}
|
||||
}
|
27
app/Http/Resources/UserResource.php
Normal file
27
app/Http/Resources/UserResource.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
public function __construct(private User $user)
|
||||
{
|
||||
parent::__construct($user);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'type' => 'users',
|
||||
'id' => $this->user->id,
|
||||
'name' => $this->user->name,
|
||||
'email' => $this->user->email,
|
||||
'avatar' => $this->user->avatar,
|
||||
'is_admin' => $this->user->is_admin,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,19 +2,16 @@
|
|||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Services\MediaSyncService;
|
||||
use App\Services\LibraryManager;
|
||||
|
||||
class PruneLibrary
|
||||
{
|
||||
private MediaSyncService $mediaSyncService;
|
||||
|
||||
public function __construct(MediaSyncService $mediaSyncService)
|
||||
public function __construct(private LibraryManager $libraryManager)
|
||||
{
|
||||
$this->mediaSyncService = $mediaSyncService;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$this->mediaSyncService->prune();
|
||||
$this->libraryManager->prune();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,12 +2,16 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Database\Query\Builder as BuilderContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
|
@ -25,6 +29,7 @@ use Laravel\Scout\Searchable;
|
|||
* @property string|null $thumbnail_path The full path to the thumbnail.
|
||||
* Notice that this doesn't guarantee the thumbnail exists.
|
||||
* @property string|null $thumbnail The public URL to the album's thumbnail
|
||||
* @property Carbon $created_at
|
||||
*
|
||||
* @method static self firstOrCreate(array $where, array $params = [])
|
||||
* @method static self|null find(int $id)
|
||||
|
@ -32,6 +37,7 @@ use Laravel\Scout\Searchable;
|
|||
* @method static self first()
|
||||
* @method static Builder whereArtistIdAndName(int $id, string $name)
|
||||
* @method static orderBy(...$params)
|
||||
* @method static Builder latest()
|
||||
*/
|
||||
class Album extends Model
|
||||
{
|
||||
|
@ -147,6 +153,29 @@ class Album extends Model
|
|||
return $this->thumbnail_name ? album_cover_url($this->thumbnail_name) : null;
|
||||
}
|
||||
|
||||
public function scopeIsStandard(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNot('albums.id', self::UNKNOWN_ID);
|
||||
}
|
||||
|
||||
public static function withMeta(User $scopedUser): BuilderContract
|
||||
{
|
||||
return static::query()
|
||||
->with('artist')
|
||||
->leftJoin('songs', 'albums.id', '=', 'songs.album_id')
|
||||
->leftJoin('interactions', static function (JoinClause $join) use ($scopedUser): void {
|
||||
$join->on('songs.id', '=', 'interactions.song_id')
|
||||
->where('interactions.user_id', $scopedUser->id);
|
||||
})
|
||||
->groupBy('albums.id')
|
||||
->select(
|
||||
'albums.*',
|
||||
DB::raw('CAST(SUM(interactions.play_count) AS INTEGER) AS play_count')
|
||||
)
|
||||
->withCount('songs AS song_count')
|
||||
->withSum('songs AS length', 'length');
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
|
|
|
@ -3,12 +3,14 @@
|
|||
namespace App\Models;
|
||||
|
||||
use App\Facades\Util;
|
||||
use Illuminate\Contracts\Database\Query\Builder as BuilderContract;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
|
@ -27,6 +29,7 @@ use Laravel\Scout\Searchable;
|
|||
* @method static self first()
|
||||
* @method static Builder whereName(string $name)
|
||||
* @method static Builder orderBy(...$params)
|
||||
* @method static Builder join(...$params)
|
||||
*/
|
||||
class Artist extends Model
|
||||
{
|
||||
|
@ -68,13 +71,9 @@ class Artist extends Model
|
|||
return $this->hasMany(Album::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* An artist can have many songs.
|
||||
* Unless he is Rick Astley.
|
||||
*/
|
||||
public function songs(): HasManyThrough
|
||||
public function songs(): HasMany
|
||||
{
|
||||
return $this->hasManyThrough(Song::class, Album::class);
|
||||
return $this->hasMany(Song::class);
|
||||
}
|
||||
|
||||
public function getIsUnknownAttribute(): bool
|
||||
|
@ -124,6 +123,25 @@ class Artist extends Model
|
|||
return file_exists(artist_image_path($image));
|
||||
}
|
||||
|
||||
public function scopeIsStandard(Builder $query): Builder
|
||||
{
|
||||
return $query->whereNotIn('artists.id', [self::UNKNOWN_ID, self::VARIOUS_ID]);
|
||||
}
|
||||
|
||||
public static function withMeta(User $scopedUser): BuilderContract
|
||||
{
|
||||
return static::query()
|
||||
->leftJoin('songs', 'artists.id', '=', 'songs.artist_id')
|
||||
->leftJoin('interactions', static function (JoinClause $join) use ($scopedUser): void {
|
||||
$join->on('interactions.song_id', '=', 'songs.id')
|
||||
->where('interactions.user_id', $scopedUser->id);
|
||||
})
|
||||
->groupBy('artists.id')
|
||||
->select(['artists.*', DB::raw('CAST(SUM(interactions.play_count) AS INTEGER) AS play_count')])
|
||||
->withCount('albums AS album_count', 'songs AS song_count')
|
||||
->withSum('songs AS length', 'length');
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
|
|
|
@ -13,11 +13,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
* @property Song $song
|
||||
* @property User $user
|
||||
* @property int $id
|
||||
* @property string $song_id
|
||||
*
|
||||
* @method static self firstOrCreate(array $where, array $params = [])
|
||||
* @method static self find(int $id)
|
||||
* @method static Builder whereSongIdAndUserId(string $songId, string $userId)
|
||||
* @method static Builder whereIn(...$params)
|
||||
* @method static Builder where(...$params)
|
||||
*/
|
||||
class Interaction extends Model
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ 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\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
||||
|
@ -35,6 +36,7 @@ use Laravel\Scout\Searchable;
|
|||
* @method static int count()
|
||||
* @method static self|Collection|null find($id)
|
||||
* @method static Builder take(int $count)
|
||||
* @method static float|int sum(string $column)
|
||||
*/
|
||||
class Song extends Model
|
||||
{
|
||||
|
@ -214,15 +216,30 @@ class Song extends Model
|
|||
return $value ?: pathinfo($this->path, PATHINFO_FILENAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the lyrics for displaying.
|
||||
*/
|
||||
public function getLyricsAttribute(string $value): string
|
||||
public static function withMeta(User $scopedUser, ?Builder $query = null): Builder
|
||||
{
|
||||
// We don't use nl2br() here, because the function actually preserves line breaks -
|
||||
// it just _appends_ a "<br />" after each of them. This would cause our client
|
||||
// implementation of br2nl to fail with duplicated line breaks.
|
||||
return str_replace(["\r\n", "\r", "\n"], '<br />', $value);
|
||||
$query ??= static::query();
|
||||
|
||||
return $query
|
||||
->with('artist', 'album', 'album.artist')
|
||||
->leftJoin('interactions', static function (JoinClause $join) use ($scopedUser): void {
|
||||
$join->on('interactions.song_id', '=', 'songs.id')
|
||||
->where('interactions.user_id', $scopedUser->id);
|
||||
})
|
||||
->join('albums', 'songs.album_id', '=', 'albums.id')
|
||||
->join('artists', 'songs.artist_id', '=', 'artists.id')
|
||||
->select(
|
||||
'songs.*',
|
||||
'albums.name',
|
||||
'artists.name',
|
||||
'interactions.liked',
|
||||
'interactions.play_count'
|
||||
);
|
||||
}
|
||||
|
||||
public function scopeWithMeta(Builder $query, User $scopedUser): Builder
|
||||
{
|
||||
return static::withMeta($scopedUser, $query);
|
||||
}
|
||||
|
||||
/** @return array<mixed> */
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace App\Models;
|
|||
|
||||
use App\Casts\UserPreferencesCast;
|
||||
use App\Values\UserPreferences;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
|
@ -19,6 +20,7 @@ use Laravel\Sanctum\HasApiTokens;
|
|||
* @property string $name
|
||||
* @property string $email
|
||||
* @property string $password
|
||||
* @property-read string $avatar
|
||||
*
|
||||
* @method static self create(array $params)
|
||||
* @method static int count()
|
||||
|
@ -32,6 +34,7 @@ class User extends Authenticatable
|
|||
|
||||
protected $guarded = ['id'];
|
||||
protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at'];
|
||||
protected $appends = ['avatar'];
|
||||
|
||||
protected $casts = [
|
||||
'id' => 'int',
|
||||
|
@ -49,6 +52,13 @@ class User extends Authenticatable
|
|||
return $this->hasMany(Interaction::class);
|
||||
}
|
||||
|
||||
protected function avatar(): Attribute
|
||||
{
|
||||
return Attribute::get(
|
||||
fn () => sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($this->email))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the user is connected to Last.fm.
|
||||
*/
|
||||
|
|
|
@ -6,6 +6,11 @@ use App\Models\User;
|
|||
|
||||
class UserPolicy
|
||||
{
|
||||
public function admin(User $currentUser): bool
|
||||
{
|
||||
return $currentUser->is_admin;
|
||||
}
|
||||
|
||||
public function destroy(User $currentUser, User $userToDestroy): bool
|
||||
{
|
||||
return $currentUser->is_admin && $currentUser->id !== $userToDestroy->id;
|
||||
|
|
|
@ -5,9 +5,9 @@ namespace App\Providers;
|
|||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Database\Schema\Builder;
|
||||
use Illuminate\Database\SQLiteConnection;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Factory as Validator;
|
||||
use Laravel\Tinker\TinkerServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
@ -26,6 +26,9 @@ class AppServiceProvider extends ServiceProvider
|
|||
|
||||
// Add some custom validation rules
|
||||
$validator->extend('path.valid', static fn ($attribute, $value): bool => is_dir($value) && is_readable($value));
|
||||
|
||||
// disable wrapping JSON resource in a `data` key
|
||||
JsonResource::withoutWrapping();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -33,8 +36,8 @@ class AppServiceProvider extends ServiceProvider
|
|||
*/
|
||||
public function register(): void
|
||||
{
|
||||
if ($this->app->environment() !== 'production') {
|
||||
$this->app->register(TinkerServiceProvider::class);
|
||||
if ($this->app->environment() !== 'production' && class_exists('Laravel\Tinker\TinkerServiceProvider')) {
|
||||
$this->app->register('Laravel\Tinker\TinkerServiceProvider');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ namespace App\Providers;
|
|||
use App\Events\AlbumInformationFetched;
|
||||
use App\Events\ArtistInformationFetched;
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Events\MediaCacheObsolete;
|
||||
use App\Events\MediaSyncCompleted;
|
||||
use App\Events\SongLikeToggled;
|
||||
use App\Events\SongsBatchLiked;
|
||||
|
@ -50,10 +49,6 @@ class EventServiceProvider extends ServiceProvider
|
|||
ClearMediaCache::class,
|
||||
],
|
||||
|
||||
MediaCacheObsolete::class => [
|
||||
ClearMediaCache::class,
|
||||
],
|
||||
|
||||
AlbumInformationFetched::class => [
|
||||
DownloadAlbumCover::class,
|
||||
],
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace App\Providers;
|
|||
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
@ -11,17 +12,21 @@ class RouteServiceProvider extends ServiceProvider
|
|||
|
||||
public function map(): void
|
||||
{
|
||||
$this->mapApiRoutes();
|
||||
$this->mapWebRoutes();
|
||||
self::loadVersionAwareRoutes('web');
|
||||
self::loadVersionAwareRoutes('api');
|
||||
}
|
||||
|
||||
protected function mapWebRoutes(): void
|
||||
private static function loadVersionAwareRoutes(string $type): void
|
||||
{
|
||||
Route::middleware('web')->group(base_path('routes/web.php'));
|
||||
}
|
||||
Assert::oneOf($type, ['web', 'api']);
|
||||
|
||||
protected function mapApiRoutes(): void
|
||||
{
|
||||
Route::prefix('api')->middleware('api')->group(base_path('routes/api.php'));
|
||||
Route::group([], base_path(sprintf('routes/%s.base.php', $type)));
|
||||
|
||||
$apiVersion = request()->header('X-Api-Version');
|
||||
$routeFile = $apiVersion ? base_path(sprintf('routes/%s.%s.php', $type, $apiVersion)) : null;
|
||||
|
||||
if ($routeFile && file_exists($routeFile)) {
|
||||
Route::group([], $routeFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ abstract class AbstractRepository implements RepositoryInterface
|
|||
// rendering the whole installation failing.
|
||||
try {
|
||||
$this->auth = app(Guard::class);
|
||||
} catch (Throwable $e) {
|
||||
} catch (Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,20 +2,50 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\Album;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Traits\Searchable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class AlbumRepository extends AbstractRepository
|
||||
{
|
||||
use Searchable;
|
||||
|
||||
/** @return array<int> */
|
||||
public function getNonEmptyAlbumIds(): array
|
||||
public function getOne(int $id, ?User $scopedUser = null): Album
|
||||
{
|
||||
return Song::select('album_id')
|
||||
->groupBy('album_id')
|
||||
->get()
|
||||
->pluck('album_id')
|
||||
->toArray();
|
||||
return Album::withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('albums.id', $id)
|
||||
->first();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Album> */
|
||||
public function getRecentlyAdded(int $count = 6, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Album::withMeta($scopedUser ?? $this->auth->user())
|
||||
->isStandard()
|
||||
->latest('albums.created_at')
|
||||
->limit($count)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Album> */
|
||||
public function getMostPlayed(int $count = 6, ?User $scopedUser = null): Collection
|
||||
{
|
||||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Album::withMeta($scopedUser ?? $this->auth->user())
|
||||
->isStandard()
|
||||
->orderByDesc('play_count')
|
||||
->limit($count)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Album> */
|
||||
public function getByIds(array $ids, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Album::withMeta($scopedUser ?? $this->auth->user())
|
||||
->isStandard()
|
||||
->whereIn('albums.id', $ids)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,20 +2,38 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Song;
|
||||
use App\Models\Artist;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Traits\Searchable;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ArtistRepository extends AbstractRepository
|
||||
{
|
||||
use Searchable;
|
||||
|
||||
/** @return array<int> */
|
||||
public function getNonEmptyArtistIds(): array
|
||||
/** @return Collection|array<array-key, Artist> */
|
||||
public function getMostPlayed(int $count = 6, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::select('artist_id')
|
||||
->groupBy('artist_id')
|
||||
->get()
|
||||
->pluck('artist_id')
|
||||
->toArray();
|
||||
return Artist::withMeta($scopedUser ?? $this->auth->user())
|
||||
->isStandard()
|
||||
->orderByDesc('play_count')
|
||||
->limit($count)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getOne(int $id, ?User $scopedUser = null): Artist
|
||||
{
|
||||
return Artist::withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('artists.id', $id)
|
||||
->first();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Artist> */
|
||||
public function getByIds(array $ids, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Artist::withMeta($scopedUser ?? $this->auth->user())
|
||||
->isStandard()
|
||||
->whereIn('artists.id', $ids)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,22 +2,37 @@
|
|||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Repositories\Traits\Searchable;
|
||||
use App\Services\Helper;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
use Illuminate\Contracts\Pagination\Paginator;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
class SongRepository extends AbstractRepository
|
||||
{
|
||||
use Searchable;
|
||||
|
||||
private Helper $helper;
|
||||
private const SORT_COLUMNS_NORMALIZE_MAP = [
|
||||
'title' => 'songs.title',
|
||||
'track' => 'songs.track',
|
||||
'length' => 'songs.length',
|
||||
'disc' => 'songs.disc',
|
||||
'artistName' => 'artists.name',
|
||||
'albumName' => 'albums.name',
|
||||
];
|
||||
|
||||
public function __construct(Helper $helper)
|
||||
private const VALID_SORT_COLUMNS = ['songs.title', 'songs.track', 'songs.length', 'artists.name', 'albums.name'];
|
||||
private const DEFAULT_QUEUE_LIMIT = 500;
|
||||
|
||||
public function __construct(private Helper $helper)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->helper = $helper;
|
||||
}
|
||||
|
||||
public function getOneByPath(string $path): ?Song
|
||||
|
@ -30,4 +45,165 @@ class SongRepository extends AbstractRepository
|
|||
{
|
||||
return Song::hostedOnS3()->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getRecentlyAdded(int $count = 10, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())->latest()->limit($count)->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getMostPlayed(int $count = 7, ?User $scopedUser = null): Collection
|
||||
{
|
||||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Song::withMeta($scopedUser)
|
||||
->where('interactions.play_count', '>', 0)
|
||||
->orderByDesc('interactions.play_count')
|
||||
->limit($count)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getRecentlyPlayed(int $count = 7, ?User $scopedUser = null): Collection
|
||||
{
|
||||
$scopedUser ??= $this->auth->user();
|
||||
|
||||
return Song::withMeta($scopedUser)
|
||||
->where('interactions.play_count', '>', 0)
|
||||
->orderByDesc('interactions.updated_at')
|
||||
->limit($count)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getForListing(
|
||||
string $sortColumn,
|
||||
string $sortDirection,
|
||||
?User $scopedUser = null,
|
||||
int $perPage = 50
|
||||
): Paginator {
|
||||
return self::applySort(
|
||||
Song::withMeta($scopedUser ?? $this->auth->user()),
|
||||
$sortColumn,
|
||||
$sortDirection
|
||||
)
|
||||
->simplePaginate($perPage);
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getForQueue(
|
||||
string $sortColumn,
|
||||
string $sortDirection,
|
||||
?User $scopedUser = null,
|
||||
int $limit = self::DEFAULT_QUEUE_LIMIT
|
||||
): Collection {
|
||||
return self::applySort(
|
||||
Song::withMeta($scopedUser ?? $this->auth->user()),
|
||||
$sortColumn,
|
||||
$sortDirection
|
||||
)
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getFavorites(?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())->where('interactions.liked', true)->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getByAlbum(Album $album, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('album_id', $album->id)
|
||||
->orderBy('songs.track')
|
||||
->orderBy('songs.disc')
|
||||
->orderBy('songs.title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getByArtist(Artist $artist, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())
|
||||
->where('songs.artist_id', $artist->id)
|
||||
->orderBy('albums.name')
|
||||
->orderBy('songs.track')
|
||||
->orderBy('songs.disc')
|
||||
->orderBy('songs.title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getByStandardPlaylist(Playlist $playlist, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())
|
||||
->leftJoin('playlist_song', 'songs.id', '=', 'playlist_song.song_id')
|
||||
->leftJoin('playlists', 'playlists.id', '=', 'playlist_song.playlist_id')
|
||||
->where('playlists.id', $playlist->id)
|
||||
->orderBy('songs.title')
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getRandom(int $limit, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())->inRandomOrder()->limit($limit)->get();
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function getByIds(array $ids, ?User $scopedUser = null): Collection
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())->whereIn('songs.id', $ids)->get();
|
||||
}
|
||||
|
||||
public function getOne($id, ?User $scopedUser = null): Song
|
||||
{
|
||||
return Song::withMeta($scopedUser ?? $this->auth->user())->findOrFail($id);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
{
|
||||
return Song::count();
|
||||
}
|
||||
|
||||
public function getTotalLength(): float
|
||||
{
|
||||
return Song::sum('length');
|
||||
}
|
||||
|
||||
private static function normalizeSortColumn(string $column): string
|
||||
{
|
||||
return key_exists($column, self::SORT_COLUMNS_NORMALIZE_MAP)
|
||||
? self::SORT_COLUMNS_NORMALIZE_MAP[$column]
|
||||
: $column;
|
||||
}
|
||||
|
||||
private static function applySort(Builder $query, string $column, string $direction): Builder
|
||||
{
|
||||
$column = self::normalizeSortColumn($column);
|
||||
|
||||
Assert::oneOf($column, self::VALID_SORT_COLUMNS);
|
||||
Assert::oneOf(strtolower($direction), ['asc', 'desc']);
|
||||
|
||||
$query->orderBy($column, $direction);
|
||||
|
||||
if ($column === 'artists.name') {
|
||||
$query->orderBy('albums.name')
|
||||
->orderBy('songs.track')
|
||||
->orderBy('songs.disc')
|
||||
->orderBy('songs.title');
|
||||
} elseif ($column === 'albums.name') {
|
||||
$query->orderBy('artists.name')
|
||||
->orderBy('songs.track')
|
||||
->orderBy('songs.disc')
|
||||
->orderBy('songs.title');
|
||||
} elseif ($column === 'track') {
|
||||
$query->orderBy('songs.track')
|
||||
->orderBy('song.disc');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
|
47
app/Services/LibraryManager.php
Normal file
47
app/Services/LibraryManager.php
Normal file
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LibraryManager
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* albums: Collection<array-key, Album>,
|
||||
* artists: Collection<array-key, Artist>,
|
||||
* }
|
||||
*/
|
||||
public function prune(bool $dryRun = false): array
|
||||
{
|
||||
return DB::transaction(static function () use ($dryRun): array {
|
||||
/** @var Builder $albumQuery */
|
||||
$albumQuery = Album::leftJoin('songs', 'songs.album_id', '=', 'albums.id')
|
||||
->whereNull('songs.album_id')
|
||||
->whereNotIn('albums.id', [Album::UNKNOWN_ID]);
|
||||
|
||||
/** @var Builder $artistQuery */
|
||||
$artistQuery = Artist::leftJoin('songs', 'songs.artist_id', '=', 'artists.id')
|
||||
->leftJoin('albums', 'albums.artist_id', '=', 'artists.id')
|
||||
->whereNull('songs.artist_id')
|
||||
->whereNull('albums.artist_id')
|
||||
->whereNotIn('artists.id', [Artist::UNKNOWN_ID, Artist::VARIOUS_ID]);
|
||||
|
||||
$results = [
|
||||
'albums' => $albumQuery->get('albums.*'),
|
||||
'artists' => $artistQuery->get('artists.*'),
|
||||
];
|
||||
|
||||
if (!$dryRun) {
|
||||
$albumQuery->delete();
|
||||
$artistQuery->delete();
|
||||
}
|
||||
|
||||
return $results;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -6,14 +6,16 @@ use App\Events\AlbumInformationFetched;
|
|||
use App\Events\ArtistInformationFetched;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
|
||||
class MediaInformationService
|
||||
{
|
||||
private LastfmService $lastfmService;
|
||||
|
||||
public function __construct(LastfmService $lastfmService)
|
||||
{
|
||||
$this->lastfmService = $lastfmService;
|
||||
public function __construct(
|
||||
private LastfmService $lastfmService,
|
||||
private AlbumRepository $albumRepository,
|
||||
private ArtistRepository $artistRepository
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -32,9 +34,8 @@ class MediaInformationService
|
|||
if ($info) {
|
||||
event(new AlbumInformationFetched($album, $info));
|
||||
|
||||
// The album may have been updated.
|
||||
$album->refresh();
|
||||
$info['cover'] = $album->cover;
|
||||
// The album cover may have been updated.
|
||||
$info['cover'] = $this->albumRepository->getOneById($album->id)->cover;
|
||||
}
|
||||
|
||||
return $info;
|
||||
|
@ -56,9 +57,8 @@ class MediaInformationService
|
|||
if ($info) {
|
||||
event(new ArtistInformationFetched($artist, $info));
|
||||
|
||||
// The artist may have been updated.
|
||||
$artist->refresh();
|
||||
$info['image'] = $artist->image;
|
||||
// The artist image may have been updated.
|
||||
$info['image'] = $this->artistRepository->getOneById($artist->id)->image;
|
||||
}
|
||||
|
||||
return $info;
|
||||
|
|
|
@ -6,12 +6,8 @@ use App\Console\Commands\SyncCommand;
|
|||
use App\Events\LibraryChanged;
|
||||
use App\Events\MediaSyncCompleted;
|
||||
use App\Libraries\WatchRecord\WatchRecordInterface;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Song;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Values\SyncResult;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
@ -37,27 +33,12 @@ class MediaSyncService
|
|||
'compilation',
|
||||
];
|
||||
|
||||
private SongRepository $songRepository;
|
||||
private FileSynchronizer $fileSynchronizer;
|
||||
private Finder $finder;
|
||||
private ArtistRepository $artistRepository;
|
||||
private AlbumRepository $albumRepository;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
SongRepository $songRepository,
|
||||
ArtistRepository $artistRepository,
|
||||
AlbumRepository $albumRepository,
|
||||
FileSynchronizer $fileSynchronizer,
|
||||
Finder $finder,
|
||||
LoggerInterface $logger
|
||||
private SongRepository $songRepository,
|
||||
private FileSynchronizer $fileSynchronizer,
|
||||
private Finder $finder,
|
||||
private LoggerInterface $logger
|
||||
) {
|
||||
$this->songRepository = $songRepository;
|
||||
$this->fileSynchronizer = $fileSynchronizer;
|
||||
$this->finder = $finder;
|
||||
$this->artistRepository = $artistRepository;
|
||||
$this->albumRepository = $albumRepository;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -187,18 +168,6 @@ class MediaSyncService
|
|||
}
|
||||
}
|
||||
|
||||
public function prune(): void
|
||||
{
|
||||
$inUseAlbums = $this->albumRepository->getNonEmptyAlbumIds();
|
||||
$inUseAlbums[] = Album::UNKNOWN_ID;
|
||||
Album::deleteWhereIDsNotIn($inUseAlbums);
|
||||
|
||||
$inUseArtists = $this->artistRepository->getNonEmptyArtistIds();
|
||||
$inUseArtists[] = Artist::UNKNOWN_ID;
|
||||
$inUseArtists[] = Artist::VARIOUS_ID;
|
||||
Artist::deleteWhereIDsNotIn(array_filter($inUseArtists));
|
||||
}
|
||||
|
||||
private function setSystemRequirements(): void
|
||||
{
|
||||
if (!app()->runningInConsole()) {
|
||||
|
|
|
@ -30,12 +30,14 @@ class SmartPlaylistService
|
|||
|
||||
$ruleGroups = $this->addRequiresUserRules($playlist->rule_groups, $playlist->user);
|
||||
|
||||
return $this->buildQueryFromRules($ruleGroups)->get();
|
||||
return $this->buildQueryFromRules($ruleGroups, $playlist->user)
|
||||
->orderBy('songs.title')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function buildQueryFromRules(Collection $ruleGroups): Builder
|
||||
public function buildQueryFromRules(Collection $ruleGroups, User $user): Builder
|
||||
{
|
||||
$query = Song::query();
|
||||
$query = Song::withMeta($user);
|
||||
|
||||
$ruleGroups->each(function (SmartPlaylistRuleGroup $group) use ($query): void {
|
||||
$query->orWhere(function (Builder $subQuery) use ($group): void {
|
||||
|
|
60
app/Services/SongService.php
Normal file
60
app/Services/SongService.php
Normal file
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Song;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Values\SongUpdateData;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SongService
|
||||
{
|
||||
public function __construct(private SongRepository $songRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function updateSongs(array $songIds, SongUpdateData $data): Collection
|
||||
{
|
||||
$updatedSongs = collect();
|
||||
|
||||
DB::transaction(function () use ($songIds, $data, $updatedSongs): void {
|
||||
foreach ($songIds as $id) {
|
||||
/** @var Song|null $song */
|
||||
$song = Song::with('album', 'album.artist', 'artist')->find($id);
|
||||
|
||||
if (!$song) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$updatedSongs->push($this->updateSong($song, $data));
|
||||
}
|
||||
});
|
||||
|
||||
return $updatedSongs;
|
||||
}
|
||||
|
||||
private function updateSong(Song $song, SongUpdateData $data): Song
|
||||
{
|
||||
if ($data->artistName && $song->artist->name !== $data->artistName) {
|
||||
$song->artist_id = Artist::getOrCreate($data->artistName)->id;
|
||||
}
|
||||
|
||||
if ($data->albumName || $data->albumArtistName) {
|
||||
$albumArtist = $data->albumArtistName ? Artist::getOrCreate($data->albumArtistName) : $song->album->artist;
|
||||
$song->album_id = Album::getOrCreate($albumArtist, $data->albumName)->id;
|
||||
}
|
||||
|
||||
$song->title = $data->title ?: $song->title;
|
||||
$song->lyrics = $data->lyrics ?: $song->lyrics;
|
||||
$song->track = $data->track ?: $song->track;
|
||||
$song->disc = $data->disc ?: $song->disc;
|
||||
|
||||
$song->save();
|
||||
|
||||
return $this->songRepository->getOne($song->id);
|
||||
}
|
||||
}
|
|
@ -14,11 +14,8 @@ class UploadService
|
|||
{
|
||||
private const UPLOAD_DIRECTORY = '__KOEL_UPLOADS__';
|
||||
|
||||
private FileSynchronizer $fileSynchronizer;
|
||||
|
||||
public function __construct(FileSynchronizer $fileSynchronizer)
|
||||
public function __construct(private FileSynchronizer $fileSynchronizer)
|
||||
{
|
||||
$this->fileSynchronizer = $fileSynchronizer;
|
||||
}
|
||||
|
||||
public function handleUploadedFile(UploadedFile $file): Song
|
||||
|
|
40
app/Services/UserService.php
Normal file
40
app/Services/UserService.php
Normal file
|
@ -0,0 +1,40 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Hashing\Hasher;
|
||||
|
||||
class UserService
|
||||
{
|
||||
public function __construct(private Hasher $hash)
|
||||
{
|
||||
}
|
||||
|
||||
public function createUser(string $name, string $email, string $plainTextPassword, bool $isAdmin): User
|
||||
{
|
||||
return User::create([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $this->hash->make($plainTextPassword),
|
||||
'is_admin' => $isAdmin,
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateUser(User $user, string $name, string $email, string|null $password, bool $isAdmin): User
|
||||
{
|
||||
$user->update([
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'password' => $password ? $this->hash->make($password) : $user->password,
|
||||
'is_admin' => $isAdmin,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function deleteUser(User $user): void
|
||||
{
|
||||
$user->delete();
|
||||
}
|
||||
}
|
63
app/Services/V6/SearchService.php
Normal file
63
app/Services/V6/SearchService.php
Normal file
|
@ -0,0 +1,63 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\V6;
|
||||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Song;
|
||||
use App\Models\User;
|
||||
use App\Repositories\AlbumRepository;
|
||||
use App\Repositories\ArtistRepository;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Values\ExcerptSearchResult;
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class SearchService
|
||||
{
|
||||
public const DEFAULT_EXCERPT_RESULT_COUNT = 6;
|
||||
public const DEFAULT_MAX_SONG_RESULT_COUNT = 500;
|
||||
|
||||
public function __construct(
|
||||
private SongRepository $songRepository,
|
||||
private AlbumRepository $albumRepository,
|
||||
private ArtistRepository $artistRepository
|
||||
) {
|
||||
}
|
||||
|
||||
public function excerptSearch(
|
||||
string $keywords,
|
||||
?User $scopedUser = null,
|
||||
int $count = self::DEFAULT_EXCERPT_RESULT_COUNT
|
||||
): ExcerptSearchResult {
|
||||
$scopedUser ??= auth()->user();
|
||||
|
||||
return ExcerptSearchResult::make(
|
||||
$this->songRepository->getByIds(
|
||||
Song::search($keywords)->take($count)->get()->pluck('id')->all(),
|
||||
$scopedUser
|
||||
),
|
||||
$this->artistRepository->getByIds(
|
||||
Artist::search($keywords)->take($count)->get()->pluck('id')->all(),
|
||||
$scopedUser
|
||||
),
|
||||
$this->albumRepository->getByIds(
|
||||
Album::search($keywords)->take($count)->get()->pluck('id')->all(),
|
||||
$scopedUser
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return Collection|array<array-key, Song> */
|
||||
public function searchSongs(
|
||||
string $keywords,
|
||||
?User $scopedUser = null,
|
||||
int $limit = self::DEFAULT_MAX_SONG_RESULT_COUNT
|
||||
): Collection {
|
||||
return Song::search($keywords)
|
||||
->query(static function (Builder $builder) use ($scopedUser, $limit): void {
|
||||
$builder->withMeta($scopedUser ?? auth()->user())->limit($limit);
|
||||
})
|
||||
->get();
|
||||
}
|
||||
}
|
17
app/Values/ExcerptSearchResult.php
Normal file
17
app/Values/ExcerptSearchResult.php
Normal file
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class ExcerptSearchResult
|
||||
{
|
||||
private function __construct(public Collection $songs, public Collection $artists, public Collection $albums)
|
||||
{
|
||||
}
|
||||
|
||||
public static function make(Collection $songs, Collection $artists, Collection $albums): self
|
||||
{
|
||||
return new self($songs, $artists, $albums);
|
||||
}
|
||||
}
|
68
app/Values/SongUpdateData.php
Normal file
68
app/Values/SongUpdateData.php
Normal file
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Values;
|
||||
|
||||
use App\Http\Requests\API\SongUpdateRequest;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
final class SongUpdateData implements Arrayable
|
||||
{
|
||||
private function __construct(
|
||||
public ?string $title,
|
||||
public ?string $artistName,
|
||||
public ?string $albumName,
|
||||
public ?string $albumArtistName,
|
||||
public ?int $track,
|
||||
public ?int $disc,
|
||||
public ?string $lyrics,
|
||||
) {
|
||||
$this->albumArtistName = $this->albumArtistName ?: $this->artistName;
|
||||
}
|
||||
|
||||
public static function fromRequest(SongUpdateRequest $request): self
|
||||
{
|
||||
return new self(
|
||||
title: $request->input('data.title'),
|
||||
artistName: $request->input('data.artist_name'),
|
||||
albumName: $request->input('data.album_name'),
|
||||
albumArtistName: $request->input('data.album_artist_name'),
|
||||
track: $request->input('data.track'),
|
||||
disc: $request->input('data.disc'),
|
||||
lyrics: $request->input('data.lyrics'),
|
||||
);
|
||||
}
|
||||
|
||||
public static function make(
|
||||
?string $title,
|
||||
?string $artistName,
|
||||
?string $albumName,
|
||||
?string $albumArtistName,
|
||||
?int $track,
|
||||
?int $disc,
|
||||
?string $lyrics
|
||||
): self {
|
||||
return new self(
|
||||
$title,
|
||||
$artistName,
|
||||
$albumName,
|
||||
$albumArtistName,
|
||||
$track,
|
||||
$disc,
|
||||
$lyrics,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->title,
|
||||
'artist' => $this->artistName,
|
||||
'album' => $this->albumName,
|
||||
'album_artist' => $this->albumArtistName,
|
||||
'track' => $this->track,
|
||||
'disc' => $this->disc,
|
||||
'lyrics' => $this->lyrics,
|
||||
];
|
||||
}
|
||||
}
|
204
composer.json
204
composer.json
|
@ -1,106 +1,106 @@
|
|||
{
|
||||
"name": "phanan/koel",
|
||||
"description": "Personal audio streaming service that works.",
|
||||
"keywords": [
|
||||
"audio",
|
||||
"stream",
|
||||
"mp3"
|
||||
"name": "phanan/koel",
|
||||
"description": "Personal audio streaming service that works.",
|
||||
"keywords": [
|
||||
"audio",
|
||||
"stream",
|
||||
"mp3"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=8.0",
|
||||
"laravel/framework": "^9.0",
|
||||
"james-heinrich/getid3": "^1.9",
|
||||
"guzzlehttp/guzzle": "^7.0.1",
|
||||
"aws/aws-sdk-php-laravel": "^3.1",
|
||||
"pusher/pusher-php-server": "^4.0",
|
||||
"predis/predis": "~1.0",
|
||||
"jackiedo/dotenv-editor": "^1.0",
|
||||
"ext-exif": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-json": "*",
|
||||
"ext-SimpleXML": "*",
|
||||
"daverandom/resume": "^0.0.3",
|
||||
"laravel/helpers": "^1.0",
|
||||
"intervention/image": "^2.5",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"lstrojny/functional-php": "^1.14",
|
||||
"teamtnt/laravel-scout-tntsearch-driver": "^11.1",
|
||||
"algolia/algoliasearch-client-php": "^2.7",
|
||||
"laravel/ui": "^3.2",
|
||||
"webmozart/assert": "^1.10",
|
||||
"laravel/sanctum": "^2.15",
|
||||
"laravel/scout": "^9.4",
|
||||
"nunomaduro/collision": "^6.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "~1.0",
|
||||
"phpunit/phpunit": "^9.0",
|
||||
"php-mock/php-mock-mockery": "^1.3",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.1",
|
||||
"fakerphp/faker": "^1.13",
|
||||
"slevomat/coding-standard": "^7.0",
|
||||
"nunomaduro/larastan": "^2.1",
|
||||
"laravel/tinker": "^2.7"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-zip": "Allow downloading multiple songs as Zip archives"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"database"
|
||||
],
|
||||
"license": "MIT",
|
||||
"type": "project",
|
||||
"require": {
|
||||
"php": ">=7.4",
|
||||
"laravel/framework": "^8.42",
|
||||
"james-heinrich/getid3": "^1.9",
|
||||
"guzzlehttp/guzzle": "^7.0.1",
|
||||
"aws/aws-sdk-php-laravel": "^3.1",
|
||||
"pusher/pusher-php-server": "^4.0",
|
||||
"predis/predis": "~1.0",
|
||||
"jackiedo/dotenv-editor": "^1.0",
|
||||
"ext-exif": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-json": "*",
|
||||
"ext-SimpleXML": "*",
|
||||
"daverandom/resume": "^0.0.3",
|
||||
"laravel/helpers": "^1.0",
|
||||
"intervention/image": "^2.5",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"lstrojny/functional-php": "^1.14",
|
||||
"teamtnt/laravel-scout-tntsearch-driver": "^11.1",
|
||||
"algolia/algoliasearch-client-php": "^2.7",
|
||||
"laravel/ui": "^3.2",
|
||||
"webmozart/assert": "^1.10",
|
||||
"laravel/sanctum": "^2.11"
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Tests\\": "tests/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"require-dev": {
|
||||
"facade/ignition": "^2.5",
|
||||
"mockery/mockery": "~1.0",
|
||||
"phpunit/phpunit": "^9.0",
|
||||
"laravel/tinker": "^2.0",
|
||||
"php-mock/php-mock-mockery": "^1.3",
|
||||
"dms/phpunit-arraysubset-asserts": "^0.2.1",
|
||||
"fakerphp/faker": "^1.13",
|
||||
"slevomat/coding-standard": "^7.0",
|
||||
"nunomaduro/larastan": "^0.6.11",
|
||||
"nunomaduro/collision": "^5.3"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-zip": "Allow downloading multiple songs as Zip archives"
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"database"
|
||||
],
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Tests\\": "tests/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
},
|
||||
"files": [
|
||||
"app/Helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"classmap": [
|
||||
"tests/TestCase.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"@php artisan clear-compiled",
|
||||
"@php artisan cache:clear",
|
||||
"@php -r \"if (!file_exists('.env')) copy('.env.example', '.env');\""
|
||||
],
|
||||
"pre-update-cmd": [
|
||||
"@php artisan clear-compiled"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan cache:clear"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate"
|
||||
],
|
||||
"test": "@php artisan test",
|
||||
"coverage": "@php artisan test --coverage-clover=coverage.xml",
|
||||
"cs": "phpcs --standard=ruleset.xml",
|
||||
"cs:fix": "phpcbf --standard=ruleset.xml",
|
||||
"analyze": "phpstan analyse --memory-limit 1G --configuration phpstan.neon.dist --ansi"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": "dist",
|
||||
"optimize-autoloader": true,
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": false
|
||||
"files": [
|
||||
"app/Helpers.php"
|
||||
]
|
||||
},
|
||||
"autoload-dev": {
|
||||
"classmap": [
|
||||
"tests/TestCase.php"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover"
|
||||
],
|
||||
"post-install-cmd": [
|
||||
"@php artisan clear-compiled",
|
||||
"@php artisan cache:clear",
|
||||
"@php -r \"if (!file_exists('.env')) copy('.env.example', '.env');\""
|
||||
],
|
||||
"pre-update-cmd": [
|
||||
"@php artisan clear-compiled"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan cache:clear"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate"
|
||||
],
|
||||
"test": "@php artisan test",
|
||||
"coverage": "@php artisan test --coverage-clover=coverage.xml",
|
||||
"cs": "phpcs --standard=ruleset.xml",
|
||||
"cs:fix": "phpcbf --standard=ruleset.xml",
|
||||
"analyze": "phpstan analyse --memory-limit 1G --configuration phpstan.neon.dist --ansi"
|
||||
},
|
||||
"config": {
|
||||
"preferred-install": "dist",
|
||||
"optimize-autoloader": true,
|
||||
"allow-plugins": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": false
|
||||
}
|
||||
|
|
3667
composer.lock
generated
3667
composer.lock
generated
File diff suppressed because it is too large
Load diff
20
database/migrations/2022_06_11_091750_add_indexes.php
Normal file
20
database/migrations/2022_06_11_091750_add_indexes.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?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::table('songs', static function (Blueprint $table): void {
|
||||
$table->index('title');
|
||||
$table->index(['track', 'disc']);
|
||||
});
|
||||
|
||||
Schema::table('albums', static function (Blueprint $table): void {
|
||||
$table->index('name');
|
||||
});
|
||||
}
|
||||
};
|
|
@ -69,7 +69,7 @@
|
|||
"jest-serializer-vue": "^2.0.2",
|
||||
"jsdom": "^19.0.0",
|
||||
"kill-port": "^1.6.1",
|
||||
"laravel-mix": "^6.0.43",
|
||||
"laravel-vite-plugin": "^0.2.4",
|
||||
"lint-staged": "^10.3.0",
|
||||
"postcss": "^8.4.12",
|
||||
"resolve-url-loader": "^3.1.1",
|
||||
|
@ -78,7 +78,7 @@
|
|||
"start-server-and-test": "^1.14.0",
|
||||
"ts-loader": "^9.3.0",
|
||||
"typescript": "^4.6.3",
|
||||
"vite": "^2.9.6",
|
||||
"vite": "^2.9.13",
|
||||
"vitest": "^0.10.0",
|
||||
"vue-loader": "^16.2.0",
|
||||
"webpack": "^5.72.0",
|
||||
|
@ -89,13 +89,13 @@
|
|||
"test:unit": "vitest",
|
||||
"test:e2e": "kill-port 8080 && start-test dev http-get://localhost:8080/api/ping 'cypress open'",
|
||||
"test:e2e:ci": "kill-port 8080 && start-test 'php artisan serve --port=8080 --quiet' http-get://localhost:8080/api/ping 'cypress run --browser chromium'",
|
||||
"build": "yarn prod",
|
||||
"build": "vite build",
|
||||
"build-demo": "cross-env KOEL_ENV=demo mix --production",
|
||||
"dev": "kill-port 8000 && start-test 'php artisan serve --port=8000 --quiet' http-get://localhost:8000/api/ping hot",
|
||||
"development": "mix",
|
||||
"watch": "mix watch",
|
||||
"watch-poll": "mix watch -- --watch-options-poll=1000",
|
||||
"hot": "mix watch --hot",
|
||||
"hot": "vite",
|
||||
"prod": "npm run production",
|
||||
"production": "mix --production"
|
||||
},
|
||||
|
|
1
public/.gitignore
vendored
1
public/.gitignore
vendored
|
@ -8,3 +8,4 @@ manifest-remote.json
|
|||
hot
|
||||
mix-manifest.json
|
||||
.user.ini
|
||||
build
|
||||
|
|
|
@ -99,8 +99,6 @@ const init = async () => {
|
|||
showOverlay()
|
||||
await socketService.init()
|
||||
|
||||
// Make the most important HTTP request to get all necessary data from the server.
|
||||
// Afterwards, init all mandatory stores and services.
|
||||
try {
|
||||
await commonStore.init()
|
||||
|
||||
|
|
|
@ -18,8 +18,8 @@ export default abstract class UnitTestCase {
|
|||
|
||||
protected beforeEach (cb?: Closure) {
|
||||
beforeEach(() => {
|
||||
commonStore.state.allowDownload = true
|
||||
commonStore.state.useiTunes = true
|
||||
commonStore.state.allow_download = true
|
||||
commonStore.state.use_i_tunes = true
|
||||
cb && cb()
|
||||
})
|
||||
}
|
||||
|
|
|
@ -14,7 +14,6 @@ const bobDylan = factory<Artist>('artist', { id: 4, name: 'Bob Dylan' })
|
|||
const jamesBlunt = factory<Artist>('artist', { id: 5, name: 'James Blunt' })
|
||||
|
||||
const all4OneAlbum = factory<Album>('album', {
|
||||
artist: all4One,
|
||||
id: 1193,
|
||||
artist_id: 3,
|
||||
name: 'All-4-One',
|
||||
|
@ -22,7 +21,6 @@ const all4OneAlbum = factory<Album>('album', {
|
|||
})
|
||||
|
||||
const musicSpeaks = factory<Album>('album', {
|
||||
artist: all4One,
|
||||
id: 1194,
|
||||
artist_id: 3,
|
||||
name: 'And The Music Speaks',
|
||||
|
@ -30,7 +28,6 @@ const musicSpeaks = factory<Album>('album', {
|
|||
})
|
||||
|
||||
const spaceJam = factory<Album>('album', {
|
||||
artist: all4One,
|
||||
id: 1195,
|
||||
artist_id: 3,
|
||||
name: 'Space Jam',
|
||||
|
@ -38,7 +35,6 @@ const spaceJam = factory<Album>('album', {
|
|||
})
|
||||
|
||||
const highway = factory<Album>('album', {
|
||||
artist: bobDylan,
|
||||
id: 1217,
|
||||
artist_id: 4,
|
||||
name: 'Highway 61 Revisited',
|
||||
|
@ -46,7 +42,6 @@ const highway = factory<Album>('album', {
|
|||
})
|
||||
|
||||
const patGarrett = factory<Album>('album', {
|
||||
artist: bobDylan,
|
||||
id: 1218,
|
||||
artist_id: 4,
|
||||
name: 'Pat Garrett & Billy the Kid',
|
||||
|
@ -54,15 +49,13 @@ const patGarrett = factory<Album>('album', {
|
|||
})
|
||||
|
||||
const theTimes = factory<Album>('album', {
|
||||
artist: bobDylan,
|
||||
id: 1219,
|
||||
artist_id: 4,
|
||||
name: "The Times They Are A-Changin",
|
||||
name: 'The Times They Are A-Changin',
|
||||
cover: '/img/covers/unknown-album.png'
|
||||
})
|
||||
|
||||
const backToBedlam = factory<Album>('album', {
|
||||
artist: jamesBlunt,
|
||||
id: 1268,
|
||||
artist_id: 5,
|
||||
name: 'Back To Bedlam',
|
||||
|
@ -83,28 +76,22 @@ export default {
|
|||
|
||||
songs: [
|
||||
factory<Song>('song', {
|
||||
artist: all4One,
|
||||
album: all4OneAlbum,
|
||||
id: '39189f4545f9d5671fb3dc964f0080a0',
|
||||
album_id: all4OneAlbum.id,
|
||||
artist_id: all4One.id,
|
||||
title: 'I Swear',
|
||||
length: 259.92,
|
||||
playCount: 4
|
||||
play_count: 4
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: all4One,
|
||||
album: musicSpeaks,
|
||||
id: 'a6a550f7d950d2a2520f9bf1a60f025a',
|
||||
album_id: musicSpeaks.id,
|
||||
artist_id: all4One.id,
|
||||
title: 'I can love you like that',
|
||||
length: 262.61,
|
||||
playCount: 2
|
||||
play_count: 2
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: all4One,
|
||||
album: spaceJam,
|
||||
id: 'd86c30fd34f13c1aff8db59b7fc9c610',
|
||||
album_id: spaceJam.id,
|
||||
artist_id: all4One.id,
|
||||
|
@ -112,8 +99,6 @@ export default {
|
|||
length: 293.04
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: bobDylan,
|
||||
album: highway,
|
||||
id: 'e6d3977f3ffa147801ca5d1fdf6fa55e',
|
||||
album_id: highway.id,
|
||||
artist_id: bobDylan.id,
|
||||
|
@ -121,26 +106,20 @@ export default {
|
|||
length: 373.63
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: bobDylan,
|
||||
album: patGarrett,
|
||||
id: 'aa16bbef6a9710eb9a0f41ecc534fad5',
|
||||
album_id: patGarrett.id,
|
||||
artist_id: bobDylan.id,
|
||||
title: "Knockin' on heaven's door",
|
||||
title: 'Knockin\' on heaven\'s door',
|
||||
length: 151.9
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: bobDylan,
|
||||
album: theTimes,
|
||||
id: 'cb7edeac1f097143e65b1b2cde102482',
|
||||
album_id: theTimes.id,
|
||||
artist_id: bobDylan.id,
|
||||
title: "The times they are a-changin'",
|
||||
title: 'The times they are a-changin\'',
|
||||
length: 196
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '0ba9fb128427b32683b9eb9140912a70',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -148,8 +127,6 @@ export default {
|
|||
length: 243.12
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '123fd1ad32240ecab28a4e86ed5173',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -157,8 +134,6 @@ export default {
|
|||
length: 265.04
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '6a54c674d8b16732f26df73f59c63e21',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -166,8 +141,6 @@ export default {
|
|||
length: 223.14
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '6df7d82a9a8701e40d1c291cf14a16bc',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -175,8 +148,6 @@ export default {
|
|||
length: 258.61
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '74a2000d343e4587273d3ad14e2fd741',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -184,17 +155,13 @@ export default {
|
|||
length: 245.86
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '7900ab518f51775fe6cf06092c074ee5',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
title: "You're beautiful",
|
||||
title: 'You\'re beautiful',
|
||||
length: 213.29
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: '803910a51f9893347e087af851e38777',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -202,8 +169,6 @@ export default {
|
|||
length: 246.91
|
||||
}),
|
||||
factory<Song>('song', {
|
||||
artist: jamesBlunt,
|
||||
album: backToBedlam,
|
||||
id: 'd82b0d4d4803ebbcb61000a5b6a868f5',
|
||||
album_id: backToBedlam.id,
|
||||
artist_id: jamesBlunt.id,
|
||||
|
@ -242,7 +207,7 @@ export default {
|
|||
liked: false,
|
||||
play_count: 4
|
||||
}
|
||||
],
|
||||
] as Interaction[],
|
||||
currentUser,
|
||||
users: [
|
||||
currentUser,
|
||||
|
|
|
@ -5,9 +5,11 @@ export default (faker: Faker): Album => {
|
|||
const artist = factory<Artist>('artist')
|
||||
|
||||
return {
|
||||
artist,
|
||||
id: faker.datatype.number(),
|
||||
type: 'albums',
|
||||
artist_id: artist.id,
|
||||
artist_name: artist.name,
|
||||
song_count: 0,
|
||||
id: faker.datatype.number(),
|
||||
name: faker.lorem.sentence(),
|
||||
cover: faker.image.imageUrl(),
|
||||
info: {
|
||||
|
@ -20,20 +22,18 @@ export default (faker: Faker): Album => {
|
|||
{
|
||||
title: faker.lorem.sentence(),
|
||||
length: 222,
|
||||
fmtLength: '3:42'
|
||||
fmt_length: '3:42'
|
||||
},
|
||||
{
|
||||
title: faker.lorem.sentence(),
|
||||
length: 157,
|
||||
fmtLength: '2:37'
|
||||
fmt_length: '2:37'
|
||||
}
|
||||
],
|
||||
url: faker.internet.url()
|
||||
},
|
||||
songs: [],
|
||||
is_compilation: false,
|
||||
playCount: 0,
|
||||
play_count: 0,
|
||||
length: 0,
|
||||
fmtLength: '00:00:00'
|
||||
fmt_length: '00:00:00'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Faker } from '@faker-js/faker'
|
||||
|
||||
export default (faker: Faker): Artist => ({
|
||||
type: 'artists',
|
||||
id: faker.datatype.number(),
|
||||
name: faker.name.findName(),
|
||||
info: {
|
||||
|
@ -12,9 +13,9 @@ export default (faker: Faker): Artist => ({
|
|||
url: faker.internet.url()
|
||||
},
|
||||
image: 'foo.jpg',
|
||||
albums: [],
|
||||
songs: [],
|
||||
playCount: 0,
|
||||
play_count: 0,
|
||||
album_count: 0,
|
||||
song_count: 0,
|
||||
length: 0,
|
||||
fmtLength: '00:00:00'
|
||||
fmt_length: '00:00:00'
|
||||
})
|
||||
|
|
|
@ -5,22 +5,25 @@ import { Faker } from '@faker-js/faker'
|
|||
export default (faker: Faker): Song => {
|
||||
const artist = factory<Artist>('artist')
|
||||
const album = factory<Album>('album', {
|
||||
artist,
|
||||
artist_id: artist.id
|
||||
})
|
||||
|
||||
return {
|
||||
artist,
|
||||
album,
|
||||
type: 'songs',
|
||||
artist_id: artist.id,
|
||||
album_id: album.id,
|
||||
artist_name: artist.name,
|
||||
album_name: album.name,
|
||||
album_artist_id: album.artist_id,
|
||||
album_artist_name: album.artist_name,
|
||||
album_cover: album.cover,
|
||||
id: crypto(32),
|
||||
title: faker.lorem.sentence(),
|
||||
length: faker.datatype.number(),
|
||||
track: faker.datatype.number(),
|
||||
disc: faker.datatype.number({ min: 1, max: 2 }),
|
||||
lyrics: faker.lorem.paragraph(),
|
||||
playCount: 0,
|
||||
play_count: 0,
|
||||
liked: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,6 @@ export default (faker: Faker): User => ({
|
|||
|
||||
export const states = {
|
||||
admin: {
|
||||
is_admin: true
|
||||
isAdmin: true
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { fireEvent } from '@testing-library/vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { downloadService } from '@/services'
|
||||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import AlbumCard from './AlbumCard.vue'
|
||||
|
@ -11,8 +11,7 @@ new class extends UnitTestCase {
|
|||
protected beforeEach () {
|
||||
super.beforeEach(() => {
|
||||
album = factory<Album>('album', {
|
||||
name: 'IV',
|
||||
songs: factory<Song>('song', 10)
|
||||
name: 'IV'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -45,16 +44,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('shuffles', async () => {
|
||||
const mock = this.mock(playbackService, 'playAllInAlbum')
|
||||
|
||||
const { getByTestId } = this.render(AlbumCard, {
|
||||
props: {
|
||||
album
|
||||
}
|
||||
})
|
||||
|
||||
await fireEvent.click(getByTestId('shuffle-album'))
|
||||
expect(mock).toHaveBeenCalled()
|
||||
throw 'Unimplemented'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<article
|
||||
v-if="album.songs.length"
|
||||
v-if="album.song_count"
|
||||
:class="layout"
|
||||
:title="`${album.name} by ${album.artist.name}`"
|
||||
:title="`${album.name} by ${album.artist_name}`"
|
||||
class="item"
|
||||
data-testid="album-card"
|
||||
draggable="true"
|
||||
|
@ -19,16 +19,16 @@
|
|||
<div class="info">
|
||||
<a :href="`#!/album/${album.id}`" class="name" data-testid="name">{{ album.name }}</a>
|
||||
<span class="sep text-secondary"> by </span>
|
||||
<a v-if="isNormalArtist" :href="`#!/artist/${album.artist.id}`" class="artist">{{ album.artist.name }}</a>
|
||||
<span v-else class="artist nope">{{ album.artist.name }}</span>
|
||||
<a v-if="isNormalArtist" :href="`#!/artist/${album.artist_id}`" class="artist">{{ album.artist_name }}</a>
|
||||
<span v-else class="artist nope">{{ album.artist_name }}</span>
|
||||
</div>
|
||||
<p class="meta">
|
||||
<span class="left">
|
||||
{{ pluralize(album.songs.length, 'song') }}
|
||||
{{ pluralize(album.song_count, 'song') }}
|
||||
•
|
||||
{{ duration }}
|
||||
•
|
||||
{{ pluralize(album.playCount, 'play') }}
|
||||
{{ pluralize(album.play_count, 'play') }}
|
||||
</span>
|
||||
<span class="right">
|
||||
<a
|
||||
|
@ -60,7 +60,7 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, toRef, toRefs } from 'vue'
|
||||
import { eventBus, pluralize, startDragging } from '@/utils'
|
||||
import { eventBus, pluralize, secondsToHis, startDragging } from '@/utils'
|
||||
import { artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
|
||||
|
@ -69,15 +69,18 @@ const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/AlbumA
|
|||
const props = withDefaults(defineProps<{ album: Album, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { album, layout } = toRefs(props)
|
||||
|
||||
const allowDownload = toRef(commonStore.state, 'allowDownload')
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
||||
const duration = computed(() => songStore.getFormattedLength(album.value.songs))
|
||||
const duration = computed(() => secondsToHis(album.value.length))
|
||||
|
||||
const isNormalArtist = computed(() => {
|
||||
return !artistStore.isVariousArtists(album.value.artist) && !artistStore.isUnknownArtist(album.value.artist)
|
||||
return !artistStore.isVarious(album.value.artist_id) && !artistStore.isUnknown(album.value.artist_id)
|
||||
})
|
||||
|
||||
const shuffle = () => playbackService.playAllInAlbum(album.value, true /* shuffled */)
|
||||
const shuffle = async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true /* shuffled */)
|
||||
}
|
||||
|
||||
const download = () => downloadService.fromAlbum(album.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, album.value, 'Album')
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ALBUM_CONTEXT_MENU_REQUESTED', event, album.value)
|
||||
|
|
|
@ -16,39 +16,31 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, Ref, toRef } from 'vue'
|
||||
import { albumStore, artistStore, commonStore } from '@/stores'
|
||||
import { albumStore, artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { useContextMenu } from '@/composables'
|
||||
import router from '@/router'
|
||||
|
||||
const { context, base, ContextMenuBase, open, close } = useContextMenu()
|
||||
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
|
||||
|
||||
const album = toRef(context, 'album') as Ref<Album>
|
||||
const allowDownload = toRef(commonStore.state, 'allowDownload')
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
||||
const isStandardAlbum = computed(() => !albumStore.isUnknownAlbum(album.value))
|
||||
const isStandardAlbum = computed(() => !albumStore.isUnknown(album.value))
|
||||
|
||||
const isStandardArtist = computed(() => {
|
||||
return !artistStore.isUnknownArtist(album.value.artist) && !artistStore.isVariousArtists(album.value.artist)
|
||||
return !artistStore.isUnknown(album.value.artist_id) && !artistStore.isVarious(album.value.artist_id)
|
||||
})
|
||||
|
||||
const play = () => playbackService.playAllInAlbum(album.value)
|
||||
const shuffle = () => playbackService.playAllInAlbum(album.value, true /* shuffled */)
|
||||
const play = () => trigger(async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value)))
|
||||
|
||||
const viewAlbumDetails = () => {
|
||||
router.go(`album/${album.value.id}`)
|
||||
close()
|
||||
const shuffle = () => {
|
||||
trigger(async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value), true))
|
||||
}
|
||||
|
||||
const viewArtistDetails = () => {
|
||||
router.go(`artist/${album.value.artist.id}`)
|
||||
close()
|
||||
}
|
||||
const viewAlbumDetails = () => trigger(() => router.go(`album/${album.value.id}`))
|
||||
const viewArtistDetails = () => trigger(() => router.go(`artist/${album.value.artist_id}`))
|
||||
const download = () => trigger(() => downloadService.fromAlbum(album.value))
|
||||
|
||||
const download = () => {
|
||||
downloadService.fromAlbum(album.value)
|
||||
close()
|
||||
}
|
||||
|
||||
defineExpose({ open, close })
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<article :class="mode" class="album-info" data-testid="album-info">
|
||||
<h1 class="name">
|
||||
<span>{{ album.name }}</span>
|
||||
<button :title="`Shuffle all songs in ${album.name}`" class="shuffle control" @click.prevent="shuffleAll">
|
||||
<i class="fa fa-random"/>
|
||||
<button :title="`Play all songs in ${album.name}`" class="control play" @click.prevent="play">
|
||||
<i class="fa fa-play"/>
|
||||
</button>
|
||||
</h1>
|
||||
|
||||
|
@ -11,26 +11,31 @@
|
|||
<AlbumThumbnail :entity="album"/>
|
||||
|
||||
<template v-if="album.info">
|
||||
<div v-if="album.info?.wiki?.summary" class="wiki">
|
||||
<div v-if="showSummary" class="summary" v-html="album.info?.wiki?.summary"/>
|
||||
<div v-if="showFull" class="full" v-html="album.info?.wiki?.full"/>
|
||||
<div v-if="album.info.wiki?.summary" class="wiki">
|
||||
<div v-if="showSummary" class="summary" v-html="album.info.wiki.summary"/>
|
||||
<div v-if="showFull" class="full" v-html="album.info.wiki.full"/>
|
||||
|
||||
<button v-if="showSummary" class="more" data-testid="more-btn" @click.prevent="showingFullWiki = true">
|
||||
Full Wiki
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TrackList v-if="album.info?.tracks?.length" :album="album" data-testid="album-info-tracks"/>
|
||||
<TrackList v-if="album.info.tracks?.length" :album="album" data-testid="album-info-tracks"/>
|
||||
|
||||
<footer>Data © <a :href="album.info?.url" rel="noopener" target="_blank">Last.fm</a></footer>
|
||||
<footer v-if="useLastfm">
|
||||
Data ©
|
||||
<a :href="album.info.url" rel="noopener" target="_blank">Last.fm</a>
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { playbackService } from '@/services'
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
|
||||
const TrackList = defineAsyncComponent(() => import('./AlbumTrackList.vue'))
|
||||
const AlbumThumbnail = defineAsyncComponent(() => import('@/components/ui/AlbumArtistThumbnail.vue'))
|
||||
|
@ -42,6 +47,8 @@ const { album, mode } = toRefs(props)
|
|||
|
||||
const showingFullWiki = ref(false)
|
||||
|
||||
const { useLastfm } = useThirdPartyServices()
|
||||
|
||||
/**
|
||||
* Whenever a new album is loaded into this component, we reset the "full wiki" state.
|
||||
*/
|
||||
|
@ -50,7 +57,7 @@ watch(album, () => (showingFullWiki.value = false))
|
|||
const showSummary = computed(() => mode.value !== 'full' && !showingFullWiki.value)
|
||||
const showFull = computed(() => !showSummary.value)
|
||||
|
||||
const shuffleAll = () => playbackService.playAllInAlbum(album.value)
|
||||
const play = async () => playbackService.queueAndPlay(await songStore.fetchForAlbum(album.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -16,12 +16,20 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, toRefs } from 'vue'
|
||||
import { defineAsyncComponent, onMounted, provide, ref, toRefs } from 'vue'
|
||||
import { songStore } from '@/stores'
|
||||
import { SongsKey } from '@/symbols'
|
||||
|
||||
const TrackListItem = defineAsyncComponent(() => import('./AlbumTrackListItem.vue'))
|
||||
|
||||
const props = defineProps<{ album: Album }>()
|
||||
const { album } = toRefs(props)
|
||||
|
||||
const songs = ref<Song[]>([])
|
||||
|
||||
provide(SongsKey, songs)
|
||||
|
||||
onMounted(async () => songs.value = await songStore.fetchForAlbum(album.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -33,7 +33,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('plays', async () => {
|
||||
const guessMock = this.mock(songStore, 'guess', song)
|
||||
const guessMock = this.mock(songStore, 'match', song)
|
||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
||||
const playMock = this.mock(playbackService, 'play')
|
||||
|
||||
|
|
|
@ -1,35 +1,47 @@
|
|||
<template>
|
||||
<li :class="{ available: song }" :title="tooltip" tabindex="0" @click="play">
|
||||
<li
|
||||
:class="{ active, available: matchedSong }"
|
||||
:title="tooltip"
|
||||
tabindex="0"
|
||||
@click="play"
|
||||
>
|
||||
<span class="title">{{ track.title }}</span>
|
||||
<AppleMusicButton v-if="useAppleMusic && !song" :url="iTunesUrl"/>
|
||||
<span class="length">{{ track.fmtLength }}</span>
|
||||
<AppleMusicButton v-if="useAppleMusic && !matchedSong" :url="iTunesUrl"/>
|
||||
<span class="length">{{ fmtLength }}</span>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, toRefs } from 'vue'
|
||||
import { computed, defineAsyncComponent, inject, ref, toRefs } from 'vue'
|
||||
import { queueStore, songStore } from '@/stores'
|
||||
import { authService, playbackService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { secondsToHis } from '@/utils'
|
||||
import { SongsKey } from '@/symbols'
|
||||
|
||||
const AppleMusicButton = defineAsyncComponent(() => import('@/components/ui/AppleMusicButton.vue'))
|
||||
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack }>()
|
||||
const { album, track } = toRefs(props)
|
||||
const props = defineProps<{ album: Album, track: AlbumTrack, songs: Song[] }>()
|
||||
const { album, track, songs } = toRefs(props)
|
||||
|
||||
const { useAppleMusic } = useThirdPartyServices()
|
||||
|
||||
const song = computed(() => songStore.guess(track.value.title, album.value))
|
||||
const tooltip = computed(() => song.value ? 'Click to play' : '')
|
||||
const songsToMatchAgainst = inject(SongsKey, ref([]))
|
||||
|
||||
const matchedSong = computed(() => songStore.match(track.value.title, songsToMatchAgainst.value))
|
||||
const tooltip = computed(() => matchedSong.value ? 'Click to play' : '')
|
||||
const fmtLength = computed(() => secondsToHis(track.value.length))
|
||||
|
||||
const active = computed(() => matchedSong.value && matchedSong.value.playback_state !== 'Stopped')
|
||||
|
||||
const iTunesUrl = computed(() => {
|
||||
return `${window.BASE_URL}itunes/song/${album.value.id}?q=${encodeURIComponent(track.value.title)}&api_token=${authService.getToken()}`
|
||||
})
|
||||
|
||||
const play = () => {
|
||||
if (song.value) {
|
||||
queueStore.queueIfNotQueued(song.value)
|
||||
playbackService.play(song.value)
|
||||
if (matchedSong.value) {
|
||||
queueStore.queueIfNotQueued(matchedSong.value)
|
||||
playbackService.play(matchedSong.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -40,7 +52,7 @@ li {
|
|||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus, &.active {
|
||||
span.title {
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { fireEvent } from '@testing-library/vue'
|
||||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { downloadService } from '@/services'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import ArtistCard from './ArtistCard.vue'
|
||||
|
||||
|
@ -12,9 +12,7 @@ new class extends UnitTestCase {
|
|||
super.beforeEach(() => {
|
||||
artist = factory<Artist>('artist', {
|
||||
id: 3, // make sure it's not "Various Artists"
|
||||
name: 'Led Zeppelin',
|
||||
albums: factory<Album>('album', 4),
|
||||
songs: factory<Song>('song', 16)
|
||||
name: 'Led Zeppelin'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -47,16 +45,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('shuffles', async () => {
|
||||
const mock = this.mock(playbackService, 'playAllByArtist')
|
||||
|
||||
const { getByTestId } = this.render(ArtistCard, {
|
||||
props: {
|
||||
artist
|
||||
}
|
||||
})
|
||||
|
||||
await fireEvent.click(getByTestId('shuffle-artist'))
|
||||
expect(mock).toHaveBeenCalled()
|
||||
throw 'Unimplemented'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
@contextmenu.prevent="requestContextMenu"
|
||||
>
|
||||
<span class="thumbnail-wrapper">
|
||||
<ArtistThumbnail :entity="artist" />
|
||||
<ArtistThumbnail :entity="artist"/>
|
||||
</span>
|
||||
|
||||
<footer>
|
||||
|
@ -21,13 +21,11 @@
|
|||
</div>
|
||||
<p class="meta">
|
||||
<span class="left">
|
||||
{{ pluralize(artist.albums.length, 'album') }}
|
||||
{{ pluralize(artist.album_count, 'album') }}
|
||||
•
|
||||
{{ pluralize(artist.songs.length, 'song') }}
|
||||
{{ pluralize(artist.song_count, 'song') }}
|
||||
•
|
||||
{{ duration }}
|
||||
•
|
||||
{{ pluralize(artist.playCount, 'play') }}
|
||||
{{ pluralize(artist.play_count, 'play') }}
|
||||
</span>
|
||||
<span class="right">
|
||||
<a
|
||||
|
@ -38,7 +36,7 @@
|
|||
data-testid="shuffle-artist"
|
||||
@click.prevent="shuffle"
|
||||
>
|
||||
<i class="fa fa-random" />
|
||||
<i class="fa fa-random"/>
|
||||
</a>
|
||||
<a
|
||||
v-if="allowDownload"
|
||||
|
@ -49,7 +47,7 @@
|
|||
data-testid="download-artist"
|
||||
@click.prevent="download"
|
||||
>
|
||||
<i class="fa fa-download" />
|
||||
<i class="fa fa-download"/>
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
|
@ -68,12 +66,14 @@ const ArtistThumbnail = defineAsyncComponent(() => import('@/components/ui/Album
|
|||
const props = withDefaults(defineProps<{ artist: Artist, layout?: ArtistAlbumCardLayout }>(), { layout: 'full' })
|
||||
const { artist, layout } = toRefs(props)
|
||||
|
||||
const allowDownload = toRef(commonStore.state, 'allowDownload')
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
||||
const duration = computed(() => songStore.getFormattedLength(artist.value.songs))
|
||||
const showing = computed(() => artist.value.songs.length && !artistStore.isVariousArtists(artist.value))
|
||||
const showing = computed(() => artist.value.song_count && !artistStore.isVarious(artist.value))
|
||||
|
||||
const shuffle = async () => {
|
||||
await playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true /* shuffled */)
|
||||
}
|
||||
|
||||
const shuffle = () => playbackService.playAllByArtist(artist.value, true /* shuffled */)
|
||||
const download = () => downloadService.fromArtist(artist.value)
|
||||
const dragStart = (event: DragEvent) => startDragging(event, artist.value, 'Artist')
|
||||
const requestContextMenu = (event: MouseEvent) => eventBus.emit('ARTIST_CONTEXT_MENU_REQUESTED', event, artist.value)
|
||||
|
|
|
@ -17,33 +17,29 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import { computed, Ref, toRef } from 'vue'
|
||||
import { artistStore, commonStore } from '@/stores'
|
||||
import { artistStore, commonStore, songStore } from '@/stores'
|
||||
import { downloadService, playbackService } from '@/services'
|
||||
import { useContextMenu } from '@/composables'
|
||||
import router from '@/router'
|
||||
|
||||
const { context, base, ContextMenuBase, open, close } = useContextMenu()
|
||||
const { context, base, ContextMenuBase, open, trigger } = useContextMenu()
|
||||
|
||||
const artist = toRef(context, 'artist') as Ref<Artist>
|
||||
const allowDownload = toRef(commonStore.state, 'allowDownload')
|
||||
const allowDownload = toRef(commonStore.state, 'allow_download')
|
||||
|
||||
const isStandardArtist = computed(() =>
|
||||
!artistStore.isUnknownArtist(artist.value)
|
||||
&& !artistStore.isVariousArtists(artist.value)
|
||||
!artistStore.isUnknown(artist.value)
|
||||
&& !artistStore.isVarious(artist.value)
|
||||
)
|
||||
|
||||
const play = () => playbackService.playAllByArtist(artist.value)
|
||||
const shuffle = () => playbackService.playAllByArtist(artist.value, true /* shuffled */)
|
||||
const play = () => trigger(async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value)))
|
||||
|
||||
const viewArtistDetails = () => {
|
||||
router.go(`artist/${artist.value.id}`)
|
||||
close()
|
||||
const shuffle = () => {
|
||||
trigger(async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value), true))
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
downloadService.fromArtist(artist.value)
|
||||
close()
|
||||
}
|
||||
const viewArtistDetails = () => trigger(() => router.go(`artist/${artist.value.id}`))
|
||||
const download = () => trigger(() => downloadService.fromArtist(artist.value))
|
||||
|
||||
defineExpose({ open, close })
|
||||
defineExpose({ open })
|
||||
</script>
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<article :class="mode" class="artist-info" data-testid="artist-info">
|
||||
<h1 class="name">
|
||||
<span>{{ artist.name }}</span>
|
||||
<button :title="`Shuffle all songs by ${artist.name}`" class="shuffle control" @click.prevent="shuffleAll">
|
||||
<i class="fa fa-random"/>
|
||||
<button :title="`Play all songs by ${artist.name}`" class="play control" @click.prevent="play">
|
||||
<i class="fa fa-play"/>
|
||||
</button>
|
||||
</h1>
|
||||
|
||||
|
@ -11,19 +11,19 @@
|
|||
<ArtistThumbnail :entity="artist"/>
|
||||
|
||||
<template v-if="artist.info">
|
||||
<div v-if="artist.info?.bio?.summary" class="bio">
|
||||
<div v-if="showSummary" class="summary" v-html="artist.info?.bio?.summary"/>
|
||||
<div v-if="showFull" class="full" v-html="artist.info?.bio?.full"/>
|
||||
<div v-if="artist.info.bio?.summary" class="bio">
|
||||
<div v-if="showSummary" class="summary" v-html="artist.info.bio.summary"/>
|
||||
<div v-if="showFull" class="full" v-html="artist.info.bio.full"/>
|
||||
|
||||
<button v-show="showSummary" class="more" data-testid="more-btn" @click.prevent="showingFullBio = true">
|
||||
Full Bio
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-secondary none">
|
||||
This artist has no Last.fm biography – yet.
|
||||
</p>
|
||||
|
||||
<footer>Data © <a :href="artist.info?.url" rel="openener" target="_blank">Last.fm</a></footer>
|
||||
<footer v-if="useLastfm">
|
||||
Data ©
|
||||
<a :href="artist.info.url" rel="openener" target="_blank">Last.fm</a>
|
||||
</footer>
|
||||
</template>
|
||||
</main>
|
||||
</article>
|
||||
|
@ -32,6 +32,8 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, defineAsyncComponent, ref, toRefs, watch } from 'vue'
|
||||
import { playbackService } from '@/services'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
|
||||
type DisplayMode = 'aside' | 'full'
|
||||
|
||||
|
@ -42,12 +44,14 @@ const { artist, mode } = toRefs(props)
|
|||
|
||||
const showingFullBio = ref(false)
|
||||
|
||||
const { useLastfm } = useThirdPartyServices()
|
||||
|
||||
watch(artist, () => (showingFullBio.value = false))
|
||||
|
||||
const showSummary = computed(() => mode.value !== 'full' && !showingFullBio.value)
|
||||
const showFull = computed(() => !showSummary.value)
|
||||
|
||||
const shuffleAll = () => playbackService.playAllByArtist(artist.value, false)
|
||||
const play = async () => playbackService.queueAndPlay(await songStore.fetchForArtist(artist.value))
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -28,12 +28,12 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { useNewVersionNotification } from '@/composables'
|
||||
|
||||
const SearchForm = defineAsyncComponent(() => import('@/components/ui/SearchForm.vue'))
|
||||
const UserBadge = defineAsyncComponent(() => import('@/components/user/UserBadge.vue'))
|
||||
import SearchForm from '@/components/ui/SearchForm.vue'
|
||||
import UserBadge from '@/components/user/UserBadge.vue'
|
||||
|
||||
const showSearchForm = ref(!isMobile.any)
|
||||
const { shouldNotifyNewVersion, latestVersion } = useNewVersionNotification()
|
||||
|
|
|
@ -17,12 +17,9 @@ new class extends UnitTestCase {
|
|||
expect(this.render(FooterExtraControls, {
|
||||
props: {
|
||||
song: factory<Song>('song', {
|
||||
playbackState: 'Playing',
|
||||
playback_state: 'Playing',
|
||||
// Set these values for Like button's rendered HTML to be consistent
|
||||
title: 'Fahrstuhl to Heaven',
|
||||
artist: factory<Artist>('artist', {
|
||||
name: 'Led Zeppelin'
|
||||
})
|
||||
title: 'Fahrstuhl to Heaven'
|
||||
})
|
||||
},
|
||||
global: {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<Equalizer v-if="useEqualizer" v-show="showEqualizer"/>
|
||||
|
||||
<button
|
||||
v-if="song?.playbackState === 'Playing'"
|
||||
v-if="song?.playback_state === 'Playing'"
|
||||
data-testid="toggle-visualizer-btn"
|
||||
title="Click for a marvelous visualizer!"
|
||||
type="button"
|
||||
|
@ -49,16 +49,16 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { defineAsyncComponent, ref, toRef, toRefs } from 'vue'
|
||||
import { ref, toRef, toRefs } from 'vue'
|
||||
import isMobile from 'ismobilejs'
|
||||
import { eventBus, isAudioContextSupported as useEqualizer } from '@/utils'
|
||||
import { preferenceStore } from '@/stores'
|
||||
|
||||
const Equalizer = defineAsyncComponent(() => import('@/components/ui/Equalizer.vue'))
|
||||
const SoundBar = defineAsyncComponent(() => import('@/components/ui/SoundBar.vue'))
|
||||
const Volume = defineAsyncComponent(() => import('@/components/ui/Volume.vue'))
|
||||
const LikeButton = defineAsyncComponent(() => import('@/components/song/SongLikeButton.vue'))
|
||||
const RepeatModeSwitch = defineAsyncComponent(() => import('@/components/ui/RepeatModeSwitch.vue'))
|
||||
import Equalizer from '@/components/ui/Equalizer.vue'
|
||||
import SoundBar from '@/components/ui/SoundBar.vue'
|
||||
import Volume from '@/components/ui/Volume.vue'
|
||||
import LikeButton from '@/components/song/SongLikeButton.vue'
|
||||
import RepeatModeSwitch from '@/components/ui/RepeatModeSwitch.vue'
|
||||
|
||||
const props = defineProps<{ song: Song }>()
|
||||
const { song } = toRefs(props)
|
||||
|
|
|
@ -10,19 +10,8 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('renders with a song', () => {
|
||||
const album = factory<Album>('album', {
|
||||
id: 42,
|
||||
name: 'IV',
|
||||
artist: factory<Artist>('artist', {
|
||||
id: 104,
|
||||
name: 'Led Zeppelin'
|
||||
})
|
||||
})
|
||||
|
||||
const song = factory<Song>('song', {
|
||||
album,
|
||||
title: 'Fahrstuhl to Heaven',
|
||||
artist: album.artist
|
||||
title: 'Fahrstuhl to Heaven'
|
||||
})
|
||||
|
||||
expect(this.render(FooterMiddlePane, {
|
||||
|
|
|
@ -2,10 +2,10 @@
|
|||
<div class="middle-pane" data-testid="footer-middle-pane">
|
||||
<div id="progressPane" class="progress">
|
||||
<template v-if="song">
|
||||
<h3 class="title">{{ song?.title }}</h3>
|
||||
<h3 class="title">{{ song.title }}</h3>
|
||||
<p class="meta">
|
||||
<a :href="`/#!/artist/${song?.artist.id}`" class="artist">{{ song?.artist.name }}</a> –
|
||||
<a :href="`/#!/album/${song?.album.id}`" class="album">{{ song?.album.name }}</a>
|
||||
<a :href="`/#!/artist/${song.artist_id}`" class="artist">{{ song.artist_name }}</a> –
|
||||
<a :href="`/#!/album/${song.album_id}`" class="album">{{ song.album_name }}</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ new class extends UnitTestCase {
|
|||
const { getByTitle } = this.render(FooterPlayerControls, {
|
||||
props: {
|
||||
song: factory<Song>('song', {
|
||||
playbackState: 'Playing'
|
||||
playback_state: 'Playing'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
|
@ -54,8 +54,8 @@ import { defaultCover } from '@/utils'
|
|||
const props = defineProps<{ song: Song | null }>()
|
||||
const { song } = toRefs(props)
|
||||
|
||||
const cover = computed(() => song.value?.album.cover ? song.value.album.cover : defaultCover)
|
||||
const shouldShowPlayButton = computed(() => !song || song.value?.playbackState !== 'Playing')
|
||||
const cover = computed(() => song.value?.album_cover ? song.value?.album_cover : defaultCover)
|
||||
const shouldShowPlayButton = computed(() => !song || song.value?.playback_state !== 'Playing')
|
||||
|
||||
const playPrev = async () => await playbackService.playPrev()
|
||||
const playNext = async () => await playbackService.playNext()
|
||||
|
|
|
@ -2,8 +2,6 @@ import { expect, it } from 'vitest'
|
|||
import { fireEvent } from '@testing-library/vue'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { commonStore } from '@/stores'
|
||||
import { songInfoService } from '@/services'
|
||||
import { eventBus } from '@/utils'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import ExtraPanel from './ExtraPanel.vue'
|
||||
|
||||
|
@ -26,14 +24,14 @@ new class extends UnitTestCase {
|
|||
|
||||
protected test () {
|
||||
it('has a YouTube tab if using YouTube ', () => {
|
||||
commonStore.state.useYouTube = true
|
||||
commonStore.state.use_you_tube = true
|
||||
const { getByTestId } = this.renderComponent()
|
||||
|
||||
getByTestId('extra-tab-youtube')
|
||||
})
|
||||
|
||||
it('does not have a YouTube tab if not using YouTube', async () => {
|
||||
commonStore.state.useYouTube = false
|
||||
commonStore.state.use_you_tube = false
|
||||
const { queryByTestId } = this.renderComponent()
|
||||
|
||||
expect(await queryByTestId('extra-tab-youtube')).toBeNull()
|
||||
|
@ -46,15 +44,5 @@ new class extends UnitTestCase {
|
|||
|
||||
expect(container.querySelector('[aria-selected=true]')).toBe(getByTestId(id))
|
||||
})
|
||||
|
||||
it('fetches song info when a new song is played', () => {
|
||||
this.renderComponent()
|
||||
const song = factory<Song>('song')
|
||||
const mock = this.mock(songInfoService, 'fetch', song)
|
||||
|
||||
eventBus.emit('SONG_STARTED', song)
|
||||
|
||||
expect(mock).toHaveBeenCalledWith(song)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -93,10 +93,9 @@
|
|||
|
||||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'
|
||||
import { defineAsyncComponent, ref, toRef, watch } from 'vue'
|
||||
import { $, eventBus } from '@/utils'
|
||||
import { preferenceStore as preferences } from '@/stores'
|
||||
import { songInfoService } from '@/services'
|
||||
import { albumStore, artistStore, preferenceStore as preferences } from '@/stores'
|
||||
import { useThirdPartyServices } from '@/composables'
|
||||
|
||||
type Tab = 'Lyrics' | 'Artist' | 'Album' | 'YouTube'
|
||||
|
@ -113,8 +112,8 @@ const currentTab = ref<Tab>(defaultTab)
|
|||
|
||||
const { useYouTube } = useThirdPartyServices()
|
||||
|
||||
const artist = computed(() => song.value?.artist)
|
||||
const album = computed(() => song.value?.album)
|
||||
const artist = ref<Artist>()
|
||||
const album = ref<Album>()
|
||||
|
||||
watch(showing, (showingExtraPanel) => {
|
||||
if (showingExtraPanel && !isMobile.any) {
|
||||
|
@ -126,9 +125,10 @@ watch(showing, (showingExtraPanel) => {
|
|||
|
||||
const fetchSongInfo = async (_song: Song) => {
|
||||
try {
|
||||
song.value = await songInfoService.fetch(_song)
|
||||
} catch (err) {
|
||||
song.value = _song
|
||||
artist.value = await artistStore.resolve(_song.artist_id)
|
||||
album.value = await albumStore.resolve(_song.album_id)
|
||||
} catch (err) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ import Visualizer from '@/components/ui/Visualizer.vue'
|
|||
new class extends UnitTestCase {
|
||||
protected test () {
|
||||
it('has a translucent overlay per album', async () => {
|
||||
this.mock(albumStore, 'getThumbnail', 'https://foo/bar.jpg')
|
||||
this.mock(albumStore, 'fetchThumbnail', 'https://foo/bar.jpg')
|
||||
|
||||
const { getByTestId } = this.render(MainContent, {
|
||||
global: {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
For those that don't need to maintain their own UI state, we use v-if and enjoy some code-splitting juice.
|
||||
-->
|
||||
<Visualizer v-if="showingVisualizer"/>
|
||||
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album"/>
|
||||
<AlbumArtOverlay v-if="showAlbumArtOverlay && currentSong" :album="currentSong?.album_id"/>
|
||||
|
||||
<HomeScreen v-show="view === 'Home'"/>
|
||||
<QueueScreen v-show="view === 'Queue'"/>
|
||||
|
|
|
@ -50,8 +50,8 @@
|
|||
<script lang="ts" setup>
|
||||
import isMobile from 'ismobilejs'
|
||||
import { defineAsyncComponent, ref } from 'vue'
|
||||
import { eventBus } from '@/utils'
|
||||
import { queueStore, songStore } from '@/stores'
|
||||
import { eventBus, resolveSongsFromDragEvent } from '@/utils'
|
||||
import { queueStore } from '@/stores'
|
||||
import { useAuthorization, useThirdPartyServices } from '@/composables'
|
||||
|
||||
const PlaylistList = defineAsyncComponent(() => import('@/components/playlist/PlaylistSidebarList.vue'))
|
||||
|
@ -61,12 +61,8 @@ const currentView = ref<MainViewName>('Home')
|
|||
const { useYouTube } = useThirdPartyServices()
|
||||
const { isAdmin } = useAuthorization()
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (!event.dataTransfer?.getData('application/x-koel.text+plain')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const songs = songStore.byIds(event.dataTransfer.getData('application/x-koel.text+plain').split(','))
|
||||
const handleDrop = async (event: DragEvent) => {
|
||||
const songs = await resolveSongsFromDragEvent(event)
|
||||
songs.length && queueStore.queue(songs)
|
||||
|
||||
return false
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue