feat!: make app progressive

This commit is contained in:
Phan An 2022-06-10 12:47:46 +02:00
parent a89595289a
commit fbbe434204
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
208 changed files with 4977 additions and 7768 deletions

View file

@ -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;
}
}

View file

@ -1,7 +0,0 @@
<?php
namespace App\Events;
class MediaCacheObsolete extends Event
{
}

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -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));
}
}

View file

@ -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);

View file

@ -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));
}
}

View file

@ -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;
}
}

View file

@ -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(),
]);
}
}

View file

@ -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'));
}
}

View file

@ -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();
}

View 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);
}
}

View 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));
}
}

View 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);
}
}

View 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));
}
}

View 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(),
]);
}
}

View 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));
}
}

View 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));
}
}

View 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()),
]);
}
}

View 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);
}
}

View 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)
);
}
}

View 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,
)
);
}
}
}

View 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));
}
}

View 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
)
);
}
}

View 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));
}
}

View 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);
}
}

View 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',
];
}
}

View 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'];
}
}

View 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
{
}

View 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
{
}

View file

@ -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
{

View 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);
}
}

View file

@ -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',
],
];
}

View file

@ -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',
];
}
}

View file

@ -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',
];
}
}

View 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),
];
}
}

View 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),
];
}
}

View 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),
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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,
];
}
}

View file

@ -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();
}
}

View file

@ -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
{

View file

@ -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
{

View file

@ -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
{

View file

@ -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> */

View file

@ -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.
*/

View file

@ -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;

View file

@ -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');
}
}
}

View file

@ -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,
],

View file

@ -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);
}
}
}

View file

@ -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) {
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View 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;
});
}
}

View file

@ -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;

View file

@ -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()) {

View file

@ -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 {

View 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);
}
}

View file

@ -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

View 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();
}
}

View 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();
}
}

View 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);
}
}

View 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,
];
}
}

View file

@ -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

File diff suppressed because it is too large Load diff

View 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');
});
}
};

View file

@ -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
View file

@ -8,3 +8,4 @@ manifest-remote.json
hot
mix-manifest.json
.user.ini
build

View file

@ -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()

View file

@ -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()
})
}

View file

@ -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,

View file

@ -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'
}
}

View file

@ -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'
})

View file

@ -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
}
}

View file

@ -12,6 +12,6 @@ export default (faker: Faker): User => ({
export const states = {
admin: {
is_admin: true
isAdmin: true
}
}

View file

@ -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'
})
}
}

View file

@ -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)

View file

@ -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>

View file

@ -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 &copy; <a :href="album.info?.url" rel="noopener" target="_blank">Last.fm</a></footer>
<footer v-if="useLastfm">
Data &copy;
<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">

View file

@ -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>

View file

@ -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')

View file

@ -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);
}

View file

@ -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'
})
}
}

View file

@ -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)

View file

@ -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>

View file

@ -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 &copy; <a :href="artist.info?.url" rel="openener" target="_blank">Last.fm</a></footer>
<footer v-if="useLastfm">
Data &copy;
<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">

View file

@ -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()

View file

@ -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: {

View file

@ -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)

View file

@ -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, {

View file

@ -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>

View file

@ -30,7 +30,7 @@ new class extends UnitTestCase {
const { getByTitle } = this.render(FooterPlayerControls, {
props: {
song: factory<Song>('song', {
playbackState: 'Playing'
playback_state: 'Playing'
})
}
})

View file

@ -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()

View file

@ -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)
})
}
}

View file

@ -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
}
}

View file

@ -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: {

View file

@ -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'"/>

View file

@ -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