chore: merge v6 into base API

This commit is contained in:
Phan An 2023-06-04 23:51:53 +02:00
parent 050c992cf1
commit 48f6bcc105
No known key found for this signature in database
GPG key ID: A81E4477F0BB6FDC
83 changed files with 512 additions and 1207 deletions

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource; use App\Http\Resources\AlbumResource;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource; use App\Http\Resources\AlbumResource;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\ArtistResource; use App\Http\Resources\ArtistResource;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;

View file

@ -3,57 +3,50 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistFolderResource;
use App\Http\Resources\PlaylistResource;
use App\Http\Resources\UserResource;
use App\Models\User; use App\Models\User;
use App\Repositories\InteractionRepository;
use App\Repositories\PlaylistRepository;
use App\Repositories\SettingRepository; use App\Repositories\SettingRepository;
use App\Repositories\UserRepository; use App\Repositories\SongRepository;
use App\Services\ApplicationInformationService; use App\Services\ApplicationInformationService;
use App\Services\ITunesService; use App\Services\ITunesService;
use App\Services\LastfmService; use App\Services\LastfmService;
use App\Services\MediaCacheService;
use App\Services\YouTubeService; use App\Services\YouTubeService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
class DataController extends Controller class DataController extends Controller
{ {
private const RECENTLY_PLAYED_EXCERPT_COUNT = 7; /** @param User $user */
/** @param User $currentUser */
public function __construct( public function __construct(
private MediaCacheService $mediaCacheService, private ITunesService $iTunesService,
private SettingRepository $settingRepository, private SettingRepository $settingRepository,
private PlaylistRepository $playlistRepository, private SongRepository $songRepository,
private InteractionRepository $interactionRepository,
private UserRepository $userRepository,
private ApplicationInformationService $applicationInformationService, private ApplicationInformationService $applicationInformationService,
private ?Authenticatable $currentUser private ?Authenticatable $user
) { ) {
} }
public function index() public function index()
{ {
return response()->json($this->mediaCacheService->get() + [ return response()->json([
'settings' => $this->currentUser->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [], 'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [],
'playlists' => $this->playlistRepository->getAllByCurrentUser(), 'playlists' => PlaylistResource::collection($this->user->playlists),
'interactions' => $this->interactionRepository->getAllByCurrentUser(), 'playlist_folders' => PlaylistFolderResource::collection($this->user->playlist_folders),
'recentlyPlayed' => $this->interactionRepository->getRecentlyPlayed( 'current_user' => UserResource::make($this->user, true),
$this->currentUser, 'use_last_fm' => LastfmService::used(),
self::RECENTLY_PLAYED_EXCERPT_COUNT 'use_you_tube' => YouTubeService::enabled(),
), 'use_i_tunes' => $this->iTunesService->used(),
'users' => $this->currentUser->is_admin ? $this->userRepository->getAll() : [], 'allow_download' => config('koel.download.allow'),
'currentUser' => $this->currentUser, 'supports_transcoding' => config('koel.streaming.ffmpeg_path')
'useLastfm' => LastfmService::used(),
'useYouTube' => YouTubeService::enabled(),
'useiTunes' => ITunesService::used(),
'allowDownload' => config('koel.download.allow'),
'supportsTranscoding' => config('koel.streaming.ffmpeg_path')
&& is_executable(config('koel.streaming.ffmpeg_path')), && is_executable(config('koel.streaming.ffmpeg_path')),
'cdnUrl' => static_url(), 'cdn_url' => static_url(),
'currentVersion' => koel_version(), 'current_version' => koel_version(),
'latestVersion' => $this->currentUser->is_admin 'latest_version' => $this->user->is_admin
? $this->applicationInformationService->getLatestVersionNumber() ? $this->applicationInformationService->getLatestVersionNumber()
: koel_version(), : koel_version(),
'song_count' => $this->songRepository->count(),
'song_length' => $this->songRepository->getTotalLength(),
]); ]);
} }
} }

View file

@ -1,12 +1,12 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\SearchRequest; use App\Http\Requests\API\SearchRequest;
use App\Http\Resources\ExcerptSearchResource; use App\Http\Resources\ExcerptSearchResource;
use App\Models\User; use App\Models\User;
use App\Services\V6\SearchService; use App\Services\SearchService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
class ExcerptSearchController extends Controller class ExcerptSearchController extends Controller

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Album; use App\Models\Album;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Artist; use App\Models\Artist;

View file

@ -1,9 +1,9 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\FetchRandomSongsInGenreRequest; use App\Http\Requests\API\FetchRandomSongsInGenreRequest;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;
use App\Models\User; use App\Models\User;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\GenreResource; use App\Http\Resources\GenreResource;

View file

@ -1,9 +1,9 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\GenreFetchSongRequest; use App\Http\Requests\API\GenreFetchSongRequest;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;
use App\Models\User; use App\Models\User;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers\API\Interaction;
use App\Events\SongStartedPlaying; use App\Events\SongStartedPlaying;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\API\Interaction\StorePlayCountRequest; use App\Http\Requests\API\Interaction\StorePlayCountRequest;
use App\Http\Resources\InteractionResource;
use App\Models\User; use App\Models\User;
use App\Services\InteractionService; use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
@ -21,6 +22,6 @@ class PlayCountController extends Controller
$interaction = $this->interactionService->increasePlayCount($request->song, $this->user); $interaction = $this->interactionService->increasePlayCount($request->song, $this->user);
event(new SongStartedPlaying($interaction->song, $interaction->user)); event(new SongStartedPlaying($interaction->song, $interaction->user));
return response()->json($interaction); return InteractionResource::make($interaction);
} }
} }

View file

@ -1,19 +0,0 @@
<?php
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Album;
use App\Services\MediaInformationService;
class AlbumController extends Controller
{
public function __construct(private MediaInformationService $mediaInformationService)
{
}
public function show(Album $album)
{
return response()->json($this->mediaInformationService->getAlbumInformation($album)?->toArray() ?: []);
}
}

View file

@ -1,19 +0,0 @@
<?php
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Artist;
use App\Services\MediaInformationService;
class ArtistController extends Controller
{
public function __construct(private MediaInformationService $mediaInformationService)
{
}
public function show(Artist $artist)
{
return response()->json($this->mediaInformationService->getArtistInformation($artist)?->toArray() ?: []);
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers\API\MediaInformation;
use App\Http\Controllers\Controller;
use App\Models\Song;
use App\Services\MediaInformationService;
use App\Services\YouTubeService;
class SongController extends Controller
{
public function __construct(
private MediaInformationService $mediaInformationService,
private YouTubeService $youTubeService
) {
}
public function show(Song $song)
{
return response()->json([
'lyrics' => nl2br($song->lyrics), // backward compat
'album_info' => $this->mediaInformationService->getAlbumInformation($song->album)?->toArray() ?: [],
'artist_info' => $this->mediaInformationService->getArtistInformation($song->artist)?->toArray() ?: [],
'youtube' => $this->youTubeService->searchVideosRelatedToSong($song),
]);
}
}

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\AlbumResource; use App\Http\Resources\AlbumResource;

View file

@ -6,9 +6,11 @@ use App\Exceptions\PlaylistBothSongsAndRulesProvidedException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistStoreRequest; use App\Http\Requests\API\PlaylistStoreRequest;
use App\Http\Requests\API\PlaylistUpdateRequest; use App\Http\Requests\API\PlaylistUpdateRequest;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User; use App\Models\User;
use App\Repositories\PlaylistRepository; use App\Repositories\PlaylistFolderRepository;
use App\Services\PlaylistService; use App\Services\PlaylistService;
use App\Values\SmartPlaylistRuleGroupCollection; use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
@ -19,31 +21,37 @@ class PlaylistController extends Controller
{ {
/** @param User $user */ /** @param User $user */
public function __construct( public function __construct(
private PlaylistRepository $playlistRepository,
private PlaylistService $playlistService, private PlaylistService $playlistService,
private PlaylistFolderRepository $folderRepository,
private ?Authenticatable $user private ?Authenticatable $user
) { ) {
} }
public function index() public function index()
{ {
return response()->json($this->playlistRepository->getAllByCurrentUser()); return PlaylistResource::collection($this->user->playlists);
} }
public function store(PlaylistStoreRequest $request) public function store(PlaylistStoreRequest $request)
{ {
$folder = null;
if ($request->folder_id) {
/** @var PlaylistFolder $folder */
$folder = $this->folderRepository->getOneById($request->folder_id);
$this->authorize('own', $folder);
}
try { try {
$playlist = $this->playlistService->createPlaylist( $playlist = $this->playlistService->createPlaylist(
$request->name, $request->name,
$this->user, $this->user,
null, $folder,
Arr::wrap($request->songs), Arr::wrap($request->songs),
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null $request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null
); );
$playlist->songs = $playlist->songs->pluck('id')->toArray(); return PlaylistResource::make($playlist);
return response()->json($playlist);
} catch (PlaylistBothSongsAndRulesProvidedException $e) { } catch (PlaylistBothSongsAndRulesProvidedException $e) {
throw ValidationException::withMessages(['songs' => [$e->getMessage()]]); throw ValidationException::withMessages(['songs' => [$e->getMessage()]]);
} }
@ -53,9 +61,22 @@ class PlaylistController extends Controller
{ {
$this->authorize('own', $playlist); $this->authorize('own', $playlist);
$playlist->update($request->only('name', 'rules')); $folder = null;
return response()->json($playlist); if ($request->folder_id) {
/** @var PlaylistFolder $folder */
$folder = $this->folderRepository->getOneById($request->folder_id);
$this->authorize('own', $folder);
}
return PlaylistResource::make(
$this->playlistService->updatePlaylist(
$playlist,
$request->name,
$folder,
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null
)
);
} }
public function destroy(Playlist $playlist) public function destroy(Playlist $playlist)

View file

@ -1,10 +1,10 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\PlaylistFolderStoreRequest; use App\Http\Requests\API\PlaylistFolderStoreRequest;
use App\Http\Requests\V6\API\PlaylistFolderUpdateRequest; use App\Http\Requests\API\PlaylistFolderUpdateRequest;
use App\Http\Resources\PlaylistFolderResource; use App\Http\Resources\PlaylistFolderResource;
use App\Models\PlaylistFolder; use App\Models\PlaylistFolder;
use App\Models\User; use App\Models\User;

View file

@ -1,10 +1,10 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\PlaylistFolderPlaylistDestroyRequest; use App\Http\Requests\API\PlaylistFolderPlaylistDestroyRequest;
use App\Http\Requests\V6\API\PlaylistFolderPlaylistStoreRequest; use App\Http\Requests\API\PlaylistFolderPlaylistStoreRequest;
use App\Models\PlaylistFolder; use App\Models\PlaylistFolder;
use App\Services\PlaylistFolderService; use App\Services\PlaylistFolderService;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;

View file

@ -3,21 +3,24 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistSongUpdateRequest; use App\Http\Requests\API\AddSongsToPlaylistRequest;
use App\Http\Requests\API\RemoveSongsFromPlaylistRequest;
use App\Http\Resources\SongResource;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\User; use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\PlaylistService; use App\Services\PlaylistService;
use App\Services\SmartPlaylistService; use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response; use Illuminate\Http\Response;
use Illuminate\Support\Arr;
class PlaylistSongController extends Controller class PlaylistSongController extends Controller
{ {
/** @param User $user */ /** @param User $user */
public function __construct( public function __construct(
private SmartPlaylistService $smartPlaylistService, private SongRepository $songRepository,
private PlaylistService $playlistService, private PlaylistService $playlistService,
private SmartPlaylistService $smartPlaylistService,
private ?Authenticatable $user private ?Authenticatable $user
) { ) {
} }
@ -26,21 +29,31 @@ class PlaylistSongController extends Controller
{ {
$this->authorize('own', $playlist); $this->authorize('own', $playlist);
return response()->json( return SongResource::collection(
$playlist->is_smart $playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist, $this->user)->pluck('id') ? $this->smartPlaylistService->getSongs($playlist, $this->user)
: $playlist->songs->pluck('id') : $this->songRepository->getByStandardPlaylist($playlist, $this->user)
); );
} }
/** @deprecated */ public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
public function update(PlaylistSongUpdateRequest $request, Playlist $playlist)
{ {
$this->authorize('own', $playlist); $this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN, 'A smart playlist cannot be populated manually.'); abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->populatePlaylist($playlist, Arr::wrap($request->songs)); $this->playlistService->addSongsToPlaylist($playlist, $request->songs);
return response()->noContent();
}
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
{
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
return response()->noContent(); return response()->noContent();
} }

View file

@ -1,9 +1,9 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\QueueFetchSongRequest; use App\Http\Requests\API\QueueFetchSongRequest;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;
use App\Models\User; use App\Models\User;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;

View file

@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers\API\Search;
use App\Http\Controllers\Controller;
use App\Services\SearchService;
use Illuminate\Http\Request;
use InvalidArgumentException;
class ExcerptSearchController extends Controller
{
public function __construct(private SearchService $searchService)
{
}
public function index(Request $request)
{
throw_unless((bool) $request->get('q'), new InvalidArgumentException('A search query is required.'));
$count = (int) $request->get('count', SearchService::DEFAULT_EXCERPT_RESULT_COUNT);
throw_if($count < 0, new InvalidArgumentException('Invalid count parameter.'));
return [
'results' => $this->searchService->excerptSearch($request->get('q'), $count),
];
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Http\Controllers\API\Search;
use App\Http\Controllers\Controller;
use App\Services\SearchService;
use Illuminate\Http\Request;
use InvalidArgumentException;
class SongSearchController extends Controller
{
public function __construct(private SearchService $searchService)
{
}
public function index(Request $request)
{
throw_unless((bool) $request->get('q'), new InvalidArgumentException('A search query is required.'));
return [
'songs' => $this->searchService->searchSongs($request->get('q')),
];
}
}

View file

@ -3,26 +3,51 @@
namespace App\Http\Controllers\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\API\DeleteSongsRequest;
use App\Http\Requests\API\SongListRequest;
use App\Http\Requests\API\SongUpdateRequest; use App\Http\Requests\API\SongUpdateRequest;
use App\Http\Resources\AlbumResource; use App\Http\Resources\AlbumResource;
use App\Http\Resources\ArtistResource; use App\Http\Resources\ArtistResource;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;
use App\Models\Song;
use App\Models\User;
use App\Repositories\AlbumRepository; use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository; use App\Repositories\ArtistRepository;
use App\Repositories\SongRepository;
use App\Services\LibraryManager; use App\Services\LibraryManager;
use App\Services\SongService; use App\Services\SongService;
use App\Values\SongUpdateData; use App\Values\SongUpdateData;
use Illuminate\Contracts\Auth\Authenticatable;
class SongController extends Controller class SongController extends Controller
{ {
/** @param User $user */
public function __construct( public function __construct(
private SongService $songService, private SongService $songService,
private SongRepository $songRepository,
private AlbumRepository $albumRepository, private AlbumRepository $albumRepository,
private ArtistRepository $artistRepository, private ArtistRepository $artistRepository,
private LibraryManager $libraryManager private LibraryManager $libraryManager,
private ?Authenticatable $user
) { ) {
} }
public function index(SongListRequest $request)
{
return SongResource::collection(
$this->songRepository->getForListing(
$request->sort ?: 'songs.title',
$request->order ?: 'asc',
$this->user
)
);
}
public function show(Song $song)
{
return SongResource::make($this->songRepository->getOne($song->id));
}
public function update(SongUpdateRequest $request) public function update(SongUpdateRequest $request)
{ {
$updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request)); $updatedSongs = $this->songService->updateSongs($request->songs, SongUpdateData::fromRequest($request));
@ -42,4 +67,13 @@ class SongController extends Controller
'removed' => $this->libraryManager->prune(), 'removed' => $this->libraryManager->prune(),
]); ]);
} }
public function destroy(DeleteSongsRequest $request)
{
$this->authorize('admin', $this->user);
$this->songService->deleteSongs($request->songs);
return response()->noContent();
}
} }

View file

@ -1,12 +1,12 @@
<?php <?php
namespace App\Http\Controllers\V6\API; namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\SearchRequest; use App\Http\Requests\API\SearchRequest;
use App\Http\Resources\SongResource; use App\Http\Resources\SongResource;
use App\Models\User; use App\Models\User;
use App\Services\V6\SearchService; use App\Services\SearchService;
use Illuminate\Contracts\Auth\Authenticatable; use Illuminate\Contracts\Auth\Authenticatable;
class SongSearchController extends Controller class SongSearchController extends Controller

View file

@ -1,52 +0,0 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Resources\PlaylistFolderResource;
use App\Http\Resources\PlaylistResource;
use App\Http\Resources\UserResource;
use App\Models\User;
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 ITunesService $iTunesService,
private SettingRepository $settingRepository,
private SongRepository $songRepository,
private ApplicationInformationService $applicationInformationService,
private ?Authenticatable $user
) {
}
public function index()
{
return response()->json([
'settings' => $this->user->is_admin ? $this->settingRepository->getAllAsKeyValueArray() : [],
'playlists' => PlaylistResource::collection($this->user->playlists),
'playlist_folders' => PlaylistFolderResource::collection($this->user->playlist_folders),
'current_user' => UserResource::make($this->user, true),
'use_last_fm' => LastfmService::used(),
'use_you_tube' => 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

@ -1,20 +0,0 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\DeleteSongsRequest;
use App\Services\SongService;
use Illuminate\Contracts\Auth\Authenticatable;
class DeleteSongsController extends Controller
{
public function __invoke(DeleteSongsRequest $request, SongService $service, Authenticatable $user)
{
$this->authorize('admin', $user);
$service->deleteSongs($request->songs);
return response()->noContent();
}
}

View file

@ -1,27 +0,0 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Events\SongStartedPlaying;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\Interaction\StorePlayCountRequest;
use App\Http\Resources\InteractionResource;
use App\Models\User;
use App\Services\InteractionService;
use Illuminate\Contracts\Auth\Authenticatable;
class PlayCountController extends Controller
{
/** @param User $user */
public function __construct(private InteractionService $interactionService, private ?Authenticatable $user)
{
}
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

@ -1,90 +0,0 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Exceptions\PlaylistBothSongsAndRulesProvidedException;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistStoreRequest;
use App\Http\Requests\API\PlaylistUpdateRequest;
use App\Http\Resources\PlaylistResource;
use App\Models\Playlist;
use App\Models\PlaylistFolder;
use App\Models\User;
use App\Repositories\PlaylistFolderRepository;
use App\Services\PlaylistService;
use App\Values\SmartPlaylistRuleGroupCollection;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
class PlaylistController extends Controller
{
/** @param User $user */
public function __construct(
private PlaylistService $playlistService,
private PlaylistFolderRepository $folderRepository,
private ?Authenticatable $user
) {
}
public function index()
{
return PlaylistResource::collection($this->user->playlists);
}
public function store(PlaylistStoreRequest $request)
{
$folder = null;
if ($request->folder_id) {
/** @var PlaylistFolder $folder */
$folder = $this->folderRepository->getOneById($request->folder_id);
$this->authorize('own', $folder);
}
try {
$playlist = $this->playlistService->createPlaylist(
$request->name,
$this->user,
$folder,
Arr::wrap($request->songs),
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null
);
return PlaylistResource::make($playlist);
} catch (PlaylistBothSongsAndRulesProvidedException $e) {
throw ValidationException::withMessages(['songs' => [$e->getMessage()]]);
}
}
public function update(PlaylistUpdateRequest $request, Playlist $playlist)
{
$this->authorize('own', $playlist);
$folder = null;
if ($request->folder_id) {
/** @var PlaylistFolder $folder */
$folder = $this->folderRepository->getOneById($request->folder_id);
$this->authorize('own', $folder);
}
return PlaylistResource::make(
$this->playlistService->updatePlaylist(
$playlist,
$request->name,
$folder,
$request->rules ? SmartPlaylistRuleGroupCollection::create(Arr::wrap($request->rules)) : null
)
);
}
public function destroy(Playlist $playlist)
{
$this->authorize('own', $playlist);
$playlist->delete();
return response()->noContent();
}
}

View file

@ -1,60 +0,0 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\AddSongsToPlaylistRequest;
use App\Http\Requests\V6\API\RemoveSongsFromPlaylistRequest;
use App\Http\Resources\SongResource;
use App\Models\Playlist;
use App\Models\User;
use App\Repositories\SongRepository;
use App\Services\PlaylistService;
use App\Services\SmartPlaylistService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Http\Response;
class PlaylistSongController extends Controller
{
/** @param User $user */
public function __construct(
private SongRepository $songRepository,
private PlaylistService $playlistService,
private SmartPlaylistService $smartPlaylistService,
private ?Authenticatable $user
) {
}
public function index(Playlist $playlist)
{
$this->authorize('own', $playlist);
return SongResource::collection(
$playlist->is_smart
? $this->smartPlaylistService->getSongs($playlist, $this->user)
: $this->songRepository->getByStandardPlaylist($playlist, $this->user)
);
}
public function store(Playlist $playlist, AddSongsToPlaylistRequest $request)
{
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->addSongsToPlaylist($playlist, $request->songs);
return response()->noContent();
}
public function destroy(Playlist $playlist, RemoveSongsFromPlaylistRequest $request)
{
$this->authorize('own', $playlist);
abort_if($playlist->is_smart, Response::HTTP_FORBIDDEN);
$this->playlistService->removeSongsFromPlaylist($playlist, $request->songs);
return response()->noContent();
}
}

View file

@ -1,35 +0,0 @@
<?php
namespace App\Http\Controllers\V6\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\V6\API\SongListRequest;
use App\Http\Resources\SongResource;
use App\Models\Song;
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 show(Song $song)
{
return SongResource::make($this->songRepository->getOne($song->id));
}
public function index(SongListRequest $request)
{
return SongResource::collection(
$this->songRepository->getForListing(
$request->sort ?: 'songs.title',
$request->order ?: 'asc',
$this->user
)
);
}
}

View file

@ -1,8 +1,7 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
use App\Models\Song; use App\Models\Song;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** @property-read array<string> $songs */ /** @property-read array<string> $songs */
class DeleteSongsRequest extends Request class DeleteSongsRequest extends Request

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read string $genre * @property-read string $genre

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read string $order * @property-read string $order

View file

@ -1,8 +1,7 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
use App\Models\Playlist; use App\Models\Playlist;
use App\Rules\AllPlaylistsBelongToUser; use App\Rules\AllPlaylistsBelongToUser;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;

View file

@ -1,8 +1,7 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
use App\Models\Playlist; use App\Models\Playlist;
use App\Rules\AllPlaylistsBelongToUser; use App\Rules\AllPlaylistsBelongToUser;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read string $name * @property-read string $name

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read string $name * @property-read string $name

View file

@ -1,8 +1,7 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read array<string> $songs * @property-read array<string> $songs

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read string $q * @property-read string $q

View file

@ -1,8 +1,6 @@
<?php <?php
namespace App\Http\Requests\V6\API; namespace App\Http\Requests\API;
use App\Http\Requests\API\Request;
/** /**
* @property-read string $order * @property-read string $order

View file

@ -3,7 +3,7 @@
namespace App\Http\Requests\API; namespace App\Http\Requests\API;
/** /**
* @property string $pageToken * @property-read string|null $pageToken
*/ */
class YouTubeSearchRequest extends Request class YouTubeSearchRequest extends Request
{ {

View file

@ -1,12 +0,0 @@
<?php
namespace App\Http\Requests\V6\API;
use App\Http\Requests\API\Request;
/**
* @property-read string|null $pageToken
*/
class YouTubeSearchRequest extends Request
{
}

View file

@ -2,19 +2,21 @@
namespace App\Services; namespace App\Services;
use App\Builders\SongBuilder;
use App\Models\Album; use App\Models\Album;
use App\Models\Artist; use App\Models\Artist;
use App\Models\Song; use App\Models\Song;
use App\Models\User;
use App\Repositories\AlbumRepository; use App\Repositories\AlbumRepository;
use App\Repositories\ArtistRepository; use App\Repositories\ArtistRepository;
use App\Repositories\SongRepository; use App\Repositories\SongRepository;
use Illuminate\Database\Eloquent\Model; use App\Values\ExcerptSearchResult;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Laravel\Scout\Builder;
class SearchService class SearchService
{ {
public const DEFAULT_EXCERPT_RESULT_COUNT = 6; public const DEFAULT_EXCERPT_RESULT_COUNT = 6;
public const DEFAULT_MAX_SONG_RESULT_COUNT = 500;
public function __construct( public function __construct(
private SongRepository $songRepository, private SongRepository $songRepository,
@ -23,31 +25,33 @@ class SearchService
) { ) {
} }
/** @return array<mixed> */ public function excerptSearch(
public function excerptSearch(string $keywords, int $count): array string $keywords,
{ ?User $scopedUser = null,
return [ int $count = self::DEFAULT_EXCERPT_RESULT_COUNT
'songs' => self::getTopResults($this->songRepository->search($keywords), $count) ): ExcerptSearchResult {
->map(static fn (Song $song): string => $song->id), $scopedUser ??= auth()->user();
'artists' => self::getTopResults($this->artistRepository->search($keywords), $count)
->map(static fn (Artist $artist): int => $artist->id), return ExcerptSearchResult::make(
'albums' => self::getTopResults($this->albumRepository->search($keywords), $count) $this->songRepository->getByIds(
->map(static fn (Album $album): int => $album->id), Song::search($keywords)->get()->take($count)->pluck('id')->all(),
]; $scopedUser
),
$this->artistRepository->getByIds(Artist::search($keywords)->get()->take($count)->pluck('id')->all()),
$this->albumRepository->getByIds(Album::search($keywords)->get()->take($count)->pluck('id')->all()),
);
} }
/** @return Collection|array<Model> */ /** @return Collection|array<array-key, Song> */
private static function getTopResults(Builder $query, int $count): Collection public function searchSongs(
{ string $keywords,
return $query->take($count)->get(); ?User $scopedUser = null,
} int $limit = self::DEFAULT_MAX_SONG_RESULT_COUNT
): Collection {
/** @return Collection|array<string> */ return Song::search($keywords)
public function searchSongs(string $keywords): Collection ->query(static function (SongBuilder $builder) use ($scopedUser, $limit): void {
{ $builder->withMeta($scopedUser ?? auth()->user())->limit($limit);
return $this->songRepository })
->search($keywords) ->get();
->get()
->map(static fn (Song $song): string => $song->id); // @phpstan-ignore-line
} }
} }

View file

@ -1,57 +0,0 @@
<?php
namespace App\Services\V6;
use App\Builders\SongBuilder;
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\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)->get()->take($count)->pluck('id')->all(),
$scopedUser
),
$this->artistRepository->getByIds(Artist::search($keywords)->get()->take($count)->pluck('id')->all()),
$this->albumRepository->getByIds(Album::search($keywords)->get()->take($count)->pluck('id')->all()),
);
}
/** @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 (SongBuilder $builder) use ($scopedUser, $limit): void {
$builder->withMeta($scopedUser ?? auth()->user())->limit($limit);
})
->get();
}
}

View file

@ -1,32 +1,45 @@
<?php <?php
use App\Facades\YouTube; use App\Facades\YouTube;
use App\Http\Controllers\API\AlbumController;
use App\Http\Controllers\API\AlbumCoverController; use App\Http\Controllers\API\AlbumCoverController;
use App\Http\Controllers\API\AlbumSongController;
use App\Http\Controllers\API\AlbumThumbnailController; use App\Http\Controllers\API\AlbumThumbnailController;
use App\Http\Controllers\API\ArtistAlbumController;
use App\Http\Controllers\API\ArtistController;
use App\Http\Controllers\API\ArtistImageController; use App\Http\Controllers\API\ArtistImageController;
use App\Http\Controllers\API\ArtistSongController;
use App\Http\Controllers\API\AuthController; use App\Http\Controllers\API\AuthController;
use App\Http\Controllers\API\DataController; use App\Http\Controllers\API\DataController;
use App\Http\Controllers\API\DemoCreditController; use App\Http\Controllers\API\DemoCreditController;
use App\Http\Controllers\API\ExcerptSearchController;
use App\Http\Controllers\API\FavoriteSongController;
use App\Http\Controllers\API\FetchAlbumInformationController;
use App\Http\Controllers\API\FetchArtistInformationController;
use App\Http\Controllers\API\FetchRandomSongsInGenreController;
use App\Http\Controllers\API\GenreController;
use App\Http\Controllers\API\GenreSongController;
use App\Http\Controllers\API\Interaction\BatchLikeController; use App\Http\Controllers\API\Interaction\BatchLikeController;
use App\Http\Controllers\API\Interaction\LikeController; use App\Http\Controllers\API\Interaction\LikeController;
use App\Http\Controllers\API\Interaction\PlayCountController; use App\Http\Controllers\API\Interaction\PlayCountController;
use App\Http\Controllers\API\Interaction\RecentlyPlayedController;
use App\Http\Controllers\API\LastfmController; use App\Http\Controllers\API\LastfmController;
use App\Http\Controllers\API\MediaInformation\AlbumController as AlbumInformationController;
use App\Http\Controllers\API\MediaInformation\ArtistController as ArtistInformationController;
use App\Http\Controllers\API\MediaInformation\SongController as SongInformationController;
use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController; use App\Http\Controllers\API\ObjectStorage\S3\SongController as S3SongController;
use App\Http\Controllers\API\OverviewController;
use App\Http\Controllers\API\PlaylistController; use App\Http\Controllers\API\PlaylistController;
use App\Http\Controllers\API\PlaylistFolderController;
use App\Http\Controllers\API\PlaylistFolderPlaylistController;
use App\Http\Controllers\API\PlaylistSongController; use App\Http\Controllers\API\PlaylistSongController;
use App\Http\Controllers\API\ProfileController; use App\Http\Controllers\API\ProfileController;
use App\Http\Controllers\API\QueueController;
use App\Http\Controllers\API\RecentlyPlayedSongController;
use App\Http\Controllers\API\ScrobbleController; use App\Http\Controllers\API\ScrobbleController;
use App\Http\Controllers\API\Search\ExcerptSearchController;
use App\Http\Controllers\API\Search\SongSearchController;
use App\Http\Controllers\API\SettingController; use App\Http\Controllers\API\SettingController;
use App\Http\Controllers\API\SongController; use App\Http\Controllers\API\SongController;
use App\Http\Controllers\API\SongSearchController;
use App\Http\Controllers\API\UploadController; use App\Http\Controllers\API\UploadController;
use App\Http\Controllers\API\UserController; use App\Http\Controllers\API\UserController;
use App\Http\Controllers\API\YouTubeController; use App\Http\Controllers\API\YouTubeController;
use App\Models\Song;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Pusher\Pusher; use Pusher\Pusher;
@ -52,16 +65,28 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
return $pusher->socket_auth($request->channel_name, $request->socket_id); return $pusher->socket_auth($request->channel_name, $request->socket_id);
})->name('broadcasting.auth'); })->name('broadcasting.auth');
Route::get('overview', [OverviewController::class, 'index']);
Route::get('data', [DataController::class, 'index']); Route::get('data', [DataController::class, 'index']);
Route::get('queue/fetch', [QueueController::class, 'fetchSongs']);
Route::put('settings', [SettingController::class, 'update']); Route::put('settings', [SettingController::class, 'update']);
/** Route::apiResource('albums', AlbumController::class);
* @deprecated Use songs/{song}/scrobble instead Route::apiResource('albums.songs', AlbumSongController::class);
*/
Route::post('{song}/scrobble', [ScrobbleController::class, 'store']); Route::apiResource('artists', ArtistController::class);
Route::post('songs/{song}/scrobble', [ScrobbleController::class, 'store']); Route::apiResource('artists.albums', ArtistAlbumController::class);
Route::apiResource('artists.songs', ArtistSongController::class);
Route::post('songs/{song}/scrobble', [ScrobbleController::class, 'store'])->where(['song' => Song::ID_REGEX]);
Route::apiResource('songs', SongController::class)
->except('update', 'destroy')
->where(['song' => Song::ID_REGEX]);
Route::put('songs', [SongController::class, 'update']); Route::put('songs', [SongController::class, 'update']);
Route::delete('songs', [SongController::class, 'destroy']);
Route::post('upload', UploadController::class); Route::post('upload', UploadController::class);
@ -70,19 +95,30 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::post('interaction/like', [LikeController::class, 'store']); Route::post('interaction/like', [LikeController::class, 'store']);
Route::post('interaction/batch/like', [BatchLikeController::class, 'store']); Route::post('interaction/batch/like', [BatchLikeController::class, 'store']);
Route::post('interaction/batch/unlike', [BatchLikeController::class, 'destroy']); Route::post('interaction/batch/unlike', [BatchLikeController::class, 'destroy']);
Route::get('interaction/recently-played/{count?}', [RecentlyPlayedController::class, 'index'])->where([
'count' => '\d+', Route::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
]); Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
Route::apiResource('playlist-folders', PlaylistFolderController::class);
Route::apiResource('playlist-folders.playlists', PlaylistFolderPlaylistController::class)->except('destroy');
Route::delete(
'playlist-folders/{playlistFolder}/playlists',
[PlaylistFolderPlaylistController::class, 'destroy']
);
// Playlist routes // Playlist routes
Route::apiResource('playlist', PlaylistController::class); Route::apiResource('playlists', PlaylistController::class);
Route::apiResource('playlists.songs', PlaylistSongController::class)->except('destroy');
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'destroy']);
Route::put('playlist/{playlist}/sync', [PlaylistSongController::class, 'update']); // @deprecated Route::get('genres/{genre}/songs', GenreSongController::class)->where('genre', '.*');
Route::put('playlist/{playlist}/songs', [PlaylistSongController::class, 'update']); Route::get('genres/{genre}/songs/random', FetchRandomSongsInGenreController::class)->where('genre', '.*');
Route::get('playlist/{playlist}/songs', [PlaylistSongController::class, 'index']); Route::apiResource('genres', GenreController::class)->where(['genre' => '.*']);
Route::apiResource('users', UserController::class);
// User and user profile routes // User and user profile routes
Route::apiResource('user', UserController::class)->only('store', 'update', 'destroy'); Route::apiResource('user', UserController::class);
Route::get('me', [ProfileController::class, 'show']); Route::get('me', [ProfileController::class, 'show']);
Route::put('me', [ProfileController::class, 'update']); Route::put('me', [ProfileController::class, 'update']);
@ -96,20 +132,16 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
} }
// Media information routes // Media information routes
Route::get('album/{album}/info', [AlbumInformationController::class, 'show']); Route::get('albums/{album}/information', FetchAlbumInformationController::class);
Route::get('artist/{artist}/info', [ArtistInformationController::class, 'show']); Route::get('artists/{artist}/information', FetchArtistInformationController::class);
Route::get('song/{song}/info', [SongInformationController::class, 'show']);
// Cover/image upload routes // Cover/image upload routes
Route::put('album/{album}/cover', [AlbumCoverController::class, 'update']); Route::put('album/{album}/cover', [AlbumCoverController::class, 'update']);
Route::put('artist/{artist}/image', [ArtistImageController::class, 'update']); Route::put('artist/{artist}/image', [ArtistImageController::class, 'update']);
Route::get('album/{album}/thumbnail', [AlbumThumbnailController::class, 'show']); Route::get('album/{album}/thumbnail', [AlbumThumbnailController::class, 'show']);
// Search routes Route::get('search', ExcerptSearchController::class);
Route::prefix('search')->group(static function (): void { Route::get('search/songs', SongSearchController::class);
Route::get('/', [ExcerptSearchController::class, 'index']);
Route::get('songs', [SongSearchController::class, 'index']);
});
}); });
// Object-storage (S3) routes // Object-storage (S3) routes

View file

@ -1,74 +0,0 @@
<?php
use App\Http\Controllers\API\UserController;
use App\Http\Controllers\V6\API\AlbumController;
use App\Http\Controllers\V6\API\AlbumSongController;
use App\Http\Controllers\V6\API\ArtistAlbumController;
use App\Http\Controllers\V6\API\ArtistController;
use App\Http\Controllers\V6\API\ArtistSongController;
use App\Http\Controllers\V6\API\DataController;
use App\Http\Controllers\V6\API\DeleteSongsController;
use App\Http\Controllers\V6\API\ExcerptSearchController;
use App\Http\Controllers\V6\API\FavoriteSongController;
use App\Http\Controllers\V6\API\FetchAlbumInformationController;
use App\Http\Controllers\V6\API\FetchArtistInformationController;
use App\Http\Controllers\V6\API\FetchRandomSongsInGenreController;
use App\Http\Controllers\V6\API\GenreController;
use App\Http\Controllers\V6\API\GenreSongController;
use App\Http\Controllers\V6\API\OverviewController;
use App\Http\Controllers\V6\API\PlayCountController;
use App\Http\Controllers\V6\API\PlaylistController;
use App\Http\Controllers\V6\API\PlaylistFolderController;
use App\Http\Controllers\V6\API\PlaylistFolderPlaylistController;
use App\Http\Controllers\V6\API\PlaylistSongController;
use App\Http\Controllers\V6\API\QueueController;
use App\Http\Controllers\V6\API\RecentlyPlayedSongController;
use App\Http\Controllers\V6\API\SongController;
use App\Http\Controllers\V6\API\SongSearchController;
use App\Models\Song;
use Illuminate\Support\Facades\Route;
Route::prefix('api')->middleware('api')->group(static function (): void {
Route::middleware('auth')->group(static function (): void {
Route::get('overview', [OverviewController::class, 'index']);
Route::get('data', [DataController::class, 'index']);
Route::apiResource('albums', AlbumController::class);
Route::apiResource('albums.songs', AlbumSongController::class);
Route::apiResource('artists', ArtistController::class);
Route::apiResource('artists.songs', ArtistSongController::class);
Route::apiResource('artists.albums', ArtistAlbumController::class);
Route::get('albums/{album}/information', FetchAlbumInformationController::class);
Route::get('artists/{artist}/information', FetchArtistInformationController::class);
Route::apiResource('playlist-folders', PlaylistFolderController::class);
Route::apiResource('playlist-folders.playlists', PlaylistFolderPlaylistController::class)->except('destroy');
Route::delete(
'playlist-folders/{playlistFolder}/playlists',
[PlaylistFolderPlaylistController::class, 'destroy']
);
Route::apiResource('playlists', PlaylistController::class);
Route::apiResource('playlists.songs', PlaylistSongController::class)->except('destroy');
Route::delete('playlists/{playlist}/songs', [PlaylistSongController::class, 'destroy']);
Route::apiResource('songs', SongController::class)->where(['song' => Song::ID_REGEX]);
Route::get('songs/recently-played', [RecentlyPlayedSongController::class, 'index']);
Route::get('songs/favorite', [FavoriteSongController::class, 'index']);
Route::delete('songs', DeleteSongsController::class);
Route::get('genres/{genre}/songs', GenreSongController::class)->where('genre', '.*');
Route::get('genres/{genre}/songs/random', FetchRandomSongsInGenreController::class)->where('genre', '.*');
Route::apiResource('genres', GenreController::class)->where(['genre' => '.*']);
Route::apiResource('users', UserController::class);
Route::get('search', ExcerptSearchController::class);
Route::get('search/songs', SongSearchController::class);
Route::get('queue/fetch', [QueueController::class, 'fetchSongs']);
Route::post('interaction/play', [PlayCountController::class, 'store']);
});
});

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Album; use App\Models\Album;
use App\Services\MediaInformationService; use App\Services\MediaInformationService;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Album; use App\Models\Album;
use App\Models\Song; use App\Models\Song;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Album; use App\Models\Album;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Album; use App\Models\Album;
use App\Models\Artist; use App\Models\Artist;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Artist; use App\Models\Artist;
use App\Services\MediaInformationService; use App\Services\MediaInformationService;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Artist; use App\Models\Artist;
use App\Models\Song; use App\Models\Song;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Artist; use App\Models\Artist;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
class DataTest extends TestCase class DataTest extends TestCase
{ {

View file

@ -70,8 +70,8 @@ class DownloadTest extends TestCase
->shouldReceive('from') ->shouldReceive('from')
->once() ->once()
->with(Mockery::on(static function (Collection $retrievedSongs) use ($songs): bool { ->with(Mockery::on(static function (Collection $retrievedSongs) use ($songs): bool {
$retrievedIds = $retrievedSongs->pluck('id')->toArray(); $retrievedIds = $retrievedSongs->pluck('id')->all();
$requestedIds = $songs->pluck('id')->toArray(); $requestedIds = $songs->pluck('id')->all();
self::assertEqualsCanonicalizing($requestedIds, $retrievedIds); self::assertEqualsCanonicalizing($requestedIds, $retrievedIds);
return true; return true;
@ -131,9 +131,7 @@ class DownloadTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->create([ $playlist = Playlist::factory()->for($user)->create();
'user_id' => $user->id,
]);
$this->downloadService $this->downloadService
->shouldReceive('from') ->shouldReceive('from')

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Album; use App\Models\Album;
use App\Models\Artist; use App\Models\Artist;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Interaction; use App\Models\Interaction;
use App\Models\User; use App\Models\User;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Song; use App\Models\Song;
use App\Values\Genre; use App\Values\Genre;
@ -35,7 +35,7 @@ class GenreTest extends TestCase
->assertJsonStructure(self::JSON_STRUCTURE) ->assertJsonStructure(self::JSON_STRUCTURE)
->assertJsonFragment(['name' => 'Rock', 'song_count' => 5]); ->assertJsonFragment(['name' => 'Rock', 'song_count' => 5]);
} }
public function testGetNonExistingGenreThrowsNotFound(): void public function testGetNonExistingGenreThrowsNotFound(): void
{ {
$this->getAs('api/genres/NonExistingGenre')->assertNotFound(); $this->getAs('api/genres/NonExistingGenre')->assertNotFound();

View file

@ -80,7 +80,7 @@ class InteractionTest extends TestCase
/** @var Collection|array<Song> $songs */ /** @var Collection|array<Song> $songs */
$songs = Song::query()->orderBy('id')->take(2)->get(); $songs = Song::query()->orderBy('id')->take(2)->get();
$songIds = array_pluck($songs->toArray(), 'id'); $songIds = $songs->pluck('id')->all();
$this->postAs('api/interaction/batch/like', ['songs' => $songIds], $user); $this->postAs('api/interaction/batch/like', ['songs' => $songIds], $user);

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Interaction; use App\Models\Interaction;
use App\Models\User; use App\Models\User;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Events\SongStartedPlaying; use App\Events\SongStartedPlaying;
use App\Models\Interaction; use App\Models\Interaction;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\PlaylistFolder; use App\Models\PlaylistFolder;
use App\Models\User; use App\Models\User;

View file

@ -5,53 +5,141 @@ namespace Tests\Feature;
use App\Models\Playlist; use App\Models\Playlist;
use App\Models\Song; use App\Models\Song;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Collection;
class PlaylistSongTest extends TestCase class PlaylistSongTest extends TestCase
{ {
public function testUpdatePlaylistSongs(): void public function testGetNormalPlaylist(): void
{ {
$this->doTestUpdatePlaylistSongs(); /** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$playlist->songs()->attach(Song::factory(5)->create());
$this->getAs('api/playlists/' . $playlist->id . '/songs', $playlist->user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
} }
/** @deprecated */ public function testGetSmartPlaylist(): void
public function testSyncPlaylist(): void
{ {
$this->doTestUpdatePlaylistSongs(true); Song::factory()->create(['title' => 'A foo song']);
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create([
'rules' => [
[
'id' => '45368b8f-fec8-4b72-b826-6b295af0da65',
'rules' => [
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'model' => 'title',
'operator' => 'contains',
'value' => ['foo'],
],
],
],
],
]);
$this->getAs("api/playlists/$playlist->id/songs", $playlist->user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
} }
private function doTestUpdatePlaylistSongs(bool $useDeprecatedRoute = false): void public function testNonOwnerCannotAccessPlaylist(): void
{
$user = User::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($user)->create();
$playlist->songs()->attach(Song::factory(5)->create());
$this->getAs('api/playlists/' . $playlist->id . '/songs')
->assertForbidden();
}
public function testAddSongsToPlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(2)->create();
$this->postAs('api/playlists/' . $playlist->id . '/songs', [
'songs' => $songs->map(static fn (Song $song) => $song->id)->all(),
], $playlist->user)
->assertNoContent();
self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
}
public function testRemoveSongsFromPlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$toRemainSongs = Song::factory(5)->create();
/** @var Collection|array<array-key, Song> $toBeRemovedSongs */
$toBeRemovedSongs = Song::factory(2)->create();
$playlist->songs()->attach($toRemainSongs->merge($toBeRemovedSongs));
self::assertCount(7, $playlist->songs);
$this->deleteAs('api/playlists/' . $playlist->id . '/songs', [
'songs' => $toBeRemovedSongs->map(static fn (Song $song) => $song->id)->all(),
], $playlist->user)
->assertNoContent();
$playlist->refresh();
self::assertEqualsCanonicalizing($toRemainSongs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
}
public function testNonOwnerCannotModifyPlaylist(): void
{ {
/** @var User $user */
$user = User::factory()->create(); $user = User::factory()->create();
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->for($user)->create(); $playlist = Playlist::factory()->for($user)->create();
$toRemainSongs = Song::factory(3)->create(); /** @var Song $song */
$toBeRemovedSongs = Song::factory(2)->create(); $song = Song::factory()->create();
$playlist->songs()->attach($toRemainSongs->merge($toBeRemovedSongs));
$path = $useDeprecatedRoute ? "api/playlist/$playlist->id/sync" : "api/playlist/$playlist->id/songs"; $this->postAs('api/playlists/' . $playlist->id . '/songs', ['songs' => [$song->id]])
->assertForbidden();
$this->putAs($path, ['songs' => $toRemainSongs->pluck('id')->all()], $user)->assertNoContent(); $this->deleteAs('api/playlists/' . $playlist->id . '/songs', ['songs' => [$song->id]])
->assertForbidden();
self::assertEqualsCanonicalizing(
$toRemainSongs->pluck('id')->all(),
$playlist->refresh()->songs->pluck('id')->all()
);
} }
public function testGetPlaylistSongs(): void public function testSmartPlaylistContentCannotBeModified(): void
{ {
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->create(); $playlist = Playlist::factory()->create([
'rules' => [
[
'id' => '45368b8f-fec8-4b72-b826-6b295af0da65',
'rules' => [
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'model' => 'title',
'operator' => 'contains',
'value' => ['foo'],
],
],
],
],
]);
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(2)->create(); $songs = Song::factory(2)->create();
$playlist->songs()->saveMany($songs); $songIds = $songs->map(static fn (Song $song) => $song->id)->all();
$responseIds = $this->getAs("api/playlist/$playlist->id/songs", $playlist->user) $this->postAs('api/playlists/' . $playlist->id . '/songs', ['songs' => $songIds], $playlist->user)
->json(); ->assertForbidden();
self::assertEqualsCanonicalizing($responseIds, $songs->pluck('id')->all()); $this->deleteAs('api/playlists/' . $playlist->id . '/songs', ['songs' => $songIds], $playlist->user)
->assertForbidden();
} }
} }

View file

@ -10,12 +10,16 @@ use Illuminate\Support\Collection;
class PlaylistTest extends TestCase class PlaylistTest extends TestCase
{ {
public function setUp(): void private const JSON_STRUCTURE = [
{ 'type',
parent::setUp(); 'id',
'name',
static::createSampleMediaSet(); 'folder_id',
} 'user_id',
'is_smart',
'rules',
'created_at',
];
public function testCreatingPlaylist(): void public function testCreatingPlaylist(): void
{ {
@ -23,21 +27,21 @@ class PlaylistTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
/** @var array<Song>|Collection $songs */ /** @var array<Song>|Collection $songs */
$songs = Song::query()->orderBy('id')->take(3)->get(); $songs = Song::factory(4)->create();
$response = $this->postAs('api/playlist', [ $this->postAs('api/playlists', [
'name' => 'Foo Bar', 'name' => 'Foo Bar',
'songs' => $songs->pluck('id')->toArray(), 'songs' => $songs->pluck('id')->all(),
'rules' => [], 'rules' => [],
], $user); ], $user)
->assertJsonStructure(self::JSON_STRUCTURE);
$response->assertOk();
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::query()->orderByDesc('id')->first(); $playlist = Playlist::query()->orderByDesc('id')->first();
self::assertSame('Foo Bar', $playlist->name); self::assertSame('Foo Bar', $playlist->name);
self::assertTrue($playlist->user->is($user)); self::assertTrue($playlist->user->is($user));
self::assertNull($playlist->folder_id);
self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all()); self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
} }
@ -52,15 +56,15 @@ class PlaylistTest extends TestCase
'value' => ['Bob Dylan'], 'value' => ['Bob Dylan'],
]); ]);
$this->postAs('api/playlist', [ $this->postAs('api/playlists', [
'name' => 'Smart Foo Bar', 'name' => 'Smart Foo Bar',
'rules' => [ 'rules' => [
[ [
'id' => '45368b8f-fec8-4b72-b826-6b295af0da65', 'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'rules' => [$rule->toArray()], 'rules' => [$rule->toArray()],
], ],
], ],
], $user); ], $user)->assertJsonStructure(self::JSON_STRUCTURE);
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::query()->orderByDesc('id')->first(); $playlist = Playlist::query()->orderByDesc('id')->first();
@ -69,16 +73,17 @@ class PlaylistTest extends TestCase
self::assertTrue($playlist->user->is($user)); self::assertTrue($playlist->user->is($user));
self::assertTrue($playlist->is_smart); self::assertTrue($playlist->is_smart);
self::assertCount(1, $playlist->rule_groups); self::assertCount(1, $playlist->rule_groups);
self::assertNull($playlist->folder_id);
self::assertTrue($rule->equals($playlist->rule_groups[0]->rules[0])); self::assertTrue($rule->equals($playlist->rule_groups[0]->rules[0]));
} }
public function testCreatingPlaylistCannotHaveBothSongsAndRules(): void public function testCreatingSmartPlaylistFailsIfSongsProvided(): void
{ {
$this->postAs('api/playlist', [ $this->postAs('api/playlists', [
'name' => 'Smart Foo Bar', 'name' => 'Smart Foo Bar',
'rules' => [ 'rules' => [
[ [
'id' => '45368b8f-fec8-4b72-b826-6b295af0da65', 'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'rules' => [ 'rules' => [
SmartPlaylistRule::create([ SmartPlaylistRule::create([
'model' => 'artist.name', 'model' => 'artist.name',
@ -88,13 +93,13 @@ class PlaylistTest extends TestCase
], ],
], ],
], ],
'songs' => Song::query()->orderBy('id')->take(3)->get()->pluck('id')->all(), 'songs' => Song::factory(3)->create()->pluck('id')->all(),
])->assertUnprocessable(); ])->assertUnprocessable();
} }
public function testCreatingPlaylistWithNonExistentSongsFails(): void public function testCreatingPlaylistWithNonExistentSongsFails(): void
{ {
$this->postAs('api/playlist', [ $this->postAs('api/playlists', [
'name' => 'Foo Bar', 'name' => 'Foo Bar',
'rules' => [], 'rules' => [],
'songs' => ['foo'], 'songs' => ['foo'],
@ -107,7 +112,8 @@ class PlaylistTest extends TestCase
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->create(['name' => 'Foo']); $playlist = Playlist::factory()->create(['name' => 'Foo']);
$this->putAs("api/playlist/$playlist->id", ['name' => 'Bar'], $playlist->user); $this->putAs("api/playlists/$playlist->id", ['name' => 'Bar'], $playlist->user)
->assertJsonStructure(self::JSON_STRUCTURE);
self::assertSame('Bar', $playlist->refresh()->name); self::assertSame('Bar', $playlist->refresh()->name);
} }
@ -117,7 +123,8 @@ class PlaylistTest extends TestCase
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->create(['name' => 'Foo']); $playlist = Playlist::factory()->create(['name' => 'Foo']);
$this->putAs("api/playlist/$playlist->id", ['name' => 'Qux'])->assertForbidden(); $this->putAs("api/playlists/$playlist->id", ['name' => 'Qux'])->assertForbidden();
self::assertSame('Foo', $playlist->refresh()->name);
} }
public function testDeletePlaylist(): void public function testDeletePlaylist(): void
@ -125,7 +132,7 @@ class PlaylistTest extends TestCase
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->create(); $playlist = Playlist::factory()->create();
$this->deleteAs("api/playlist/$playlist->id", [], $playlist->user); $this->deleteAs("api/playlists/$playlist->id", [], $playlist->user);
self::assertModelMissing($playlist); self::assertModelMissing($playlist);
} }
@ -135,7 +142,7 @@ class PlaylistTest extends TestCase
/** @var Playlist $playlist */ /** @var Playlist $playlist */
$playlist = Playlist::factory()->create(); $playlist = Playlist::factory()->create();
$this->deleteAs("api/playlist/$playlist->id")->assertForbidden(); $this->deleteAs("api/playlists/$playlist->id")->assertForbidden();
self::assertModelExists($playlist); self::assertModelExists($playlist);
} }

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Song; use App\Models\Song;

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Interaction; use App\Models\Interaction;
use App\Models\User; use App\Models\User;

View file

@ -28,7 +28,7 @@ class ScrobbleTest extends TestCase
) )
->once(); ->once();
$this->postAs("/api/$song->id/scrobble", ['timestamp' => 100], $user) $this->postAs("/api/songs/$song->id/scrobble", ['timestamp' => 100], $user)
->assertNoContent(); ->assertNoContent();
} }
} }

View file

@ -1,6 +1,6 @@
<?php <?php
namespace Tests\Feature\V6; namespace Tests\Feature;
use App\Models\Song; use App\Models\Song;

View file

@ -10,15 +10,92 @@ use Illuminate\Support\Collection;
class SongTest extends TestCase class SongTest extends TestCase
{ {
public function setUp(): void public const JSON_STRUCTURE = [
{ 'type',
parent::setUp(); 'id',
'title',
'lyrics',
'album_id',
'album_name',
'artist_id',
'artist_name',
'album_artist_id',
'album_artist_name',
'album_cover',
'length',
'liked',
'play_count',
'track',
'genre',
'year',
'disc',
'created_at',
];
static::createSampleMediaSet(); public const JSON_COLLECTION_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function testIndex(): void
{
Song::factory(10)->create();
$this->getAs('api/songs')->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
$this->getAs('api/songs?sort=title&order=desc')->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
}
public function testShow(): void
{
/** @var Song $song */
$song = Song::factory()->create();
$this->getAs('api/songs/' . $song->id)->assertJsonStructure(self::JSON_STRUCTURE);
}
public function testDelete(): void
{
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(3)->create();
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->deleteAs('api/songs', ['songs' => $songs->pluck('id')->all()], $admin)
->assertNoContent();
$songs->each(fn (Song $song) => $this->assertModelMissing($song));
}
public function testUnauthorizedDelete(): void
{
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(3)->create();
$this->deleteAs('api/songs', ['songs' => $songs->pluck('id')->all()])
->assertForbidden();
$songs->each(fn (Song $song) => $this->assertModelExists($song));
} }
public function testSingleUpdateAllInfoNoCompilation(): void public function testSingleUpdateAllInfoNoCompilation(): void
{ {
static::createSampleMediaSet();
/** @var User $user */ /** @var User $user */
$user = User::factory()->admin()->create(); $user = User::factory()->admin()->create();
@ -57,6 +134,8 @@ class SongTest extends TestCase
public function testSingleUpdateSomeInfoNoCompilation(): void public function testSingleUpdateSomeInfoNoCompilation(): void
{ {
static::createSampleMediaSet();
/** @var User $user */ /** @var User $user */
$user = User::factory()->admin()->create(); $user = User::factory()->admin()->create();
@ -86,9 +165,11 @@ class SongTest extends TestCase
public function testMultipleUpdateNoCompilation(): void public function testMultipleUpdateNoCompilation(): void
{ {
static::createSampleMediaSet();
/** @var User $user */ /** @var User $user */
$user = User::factory()->admin()->create(); $user = User::factory()->admin()->create();
$songIds = Song::query()->latest()->take(3)->pluck('id')->toArray(); $songIds = Song::query()->latest()->take(3)->pluck('id')->all();
$this->putAs('/api/songs', [ $this->putAs('/api/songs', [
'songs' => $songIds, 'songs' => $songIds,
@ -124,6 +205,8 @@ class SongTest extends TestCase
public function testMultipleUpdateCreatingNewAlbumsAndArtists(): void public function testMultipleUpdateCreatingNewAlbumsAndArtists(): void
{ {
static::createSampleMediaSet();
/** @var User $user */ /** @var User $user */
$user = User::factory()->admin()->create(); $user = User::factory()->admin()->create();
@ -131,7 +214,7 @@ class SongTest extends TestCase
$originalSongs = Song::query()->latest()->take(3)->get(); $originalSongs = Song::query()->latest()->take(3)->get();
$this->putAs('/api/songs', [ $this->putAs('/api/songs', [
'songs' => $originalSongs->pluck('id')->toArray(), 'songs' => $originalSongs->pluck('id')->all(),
'data' => [ 'data' => [
'title' => 'Foo Bar', 'title' => 'Foo Bar',
'artist_name' => 'John Cena', 'artist_name' => 'John Cena',
@ -162,6 +245,8 @@ class SongTest extends TestCase
public function testSingleUpdateAllInfoWithCompilation(): void public function testSingleUpdateAllInfoWithCompilation(): void
{ {
static::createSampleMediaSet();
/** @var User $user */ /** @var User $user */
$user = User::factory()->admin()->create(); $user = User::factory()->admin()->create();
@ -205,6 +290,8 @@ class SongTest extends TestCase
public function testUpdateSingleSongWithEmptyTrackAndDisc(): void public function testUpdateSingleSongWithEmptyTrackAndDisc(): void
{ {
static::createSampleMediaSet();
/** @var User $user */ /** @var User $user */
$user = User::factory()->admin()->create(); $user = User::factory()->admin()->create();
@ -231,6 +318,8 @@ class SongTest extends TestCase
public function testDeletingByChunk(): void public function testDeletingByChunk(): void
{ {
Song::factory(5)->create();
self::assertNotSame(0, Song::query()->count()); self::assertNotSame(0, Song::query()->count());
$ids = Song::query()->select('id')->get()->pluck('id')->all(); $ids = Song::query()->select('id')->get()->pluck('id')->all();

View file

@ -1,145 +0,0 @@
<?php
namespace Tests\Feature\V6;
use App\Models\Playlist;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
class PlaylistSongTest extends TestCase
{
public function testGetNormalPlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$playlist->songs()->attach(Song::factory(5)->create());
$this->getAs('api/playlists/' . $playlist->id . '/songs', $playlist->user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
}
public function testGetSmartPlaylist(): void
{
Song::factory()->create(['title' => 'A foo song']);
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create([
'rules' => [
[
'id' => '45368b8f-fec8-4b72-b826-6b295af0da65',
'rules' => [
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'model' => 'title',
'operator' => 'contains',
'value' => ['foo'],
],
],
],
],
]);
$this->getAs('api/playlists/' . $playlist->id . '/songs', $playlist->user)
->assertJsonStructure(['*' => SongTest::JSON_STRUCTURE]);
}
public function testNonOwnerCannotAccessPlaylist(): void
{
$user = User::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($user)->create();
$playlist->songs()->attach(Song::factory(5)->create());
$this->getAs('api/playlists/' . $playlist->id . '/songs')
->assertForbidden();
}
public function testAddSongsToPlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(2)->create();
$this->postAs('api/playlists/' . $playlist->id . '/songs', [
'songs' => $songs->map(static fn (Song $song) => $song->id)->all(),
], $playlist->user)
->assertNoContent();
self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
}
public function testRemoveSongsFromPlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$toRemainSongs = Song::factory(5)->create();
/** @var Collection|array<array-key, Song> $toBeRemovedSongs */
$toBeRemovedSongs = Song::factory(2)->create();
$playlist->songs()->attach($toRemainSongs->merge($toBeRemovedSongs));
self::assertCount(7, $playlist->songs);
$this->deleteAs('api/playlists/' . $playlist->id . '/songs', [
'songs' => $toBeRemovedSongs->map(static fn (Song $song) => $song->id)->all(),
], $playlist->user)
->assertNoContent();
$playlist->refresh();
self::assertEqualsCanonicalizing($toRemainSongs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
}
public function testNonOwnerCannotModifyPlaylist(): void
{
$user = User::factory()->create();
/** @var Playlist $playlist */
$playlist = Playlist::factory()->for($user)->create();
/** @var Song $song */
$song = Song::factory()->create();
$this->postAs('api/playlists/' . $playlist->id . '/songs', ['songs' => [$song->id]])
->assertForbidden();
$this->deleteAs('api/playlists/' . $playlist->id . '/songs', ['songs' => [$song->id]])
->assertForbidden();
}
public function testSmartPlaylistContentCannotBeModified(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create([
'rules' => [
[
'id' => '45368b8f-fec8-4b72-b826-6b295af0da65',
'rules' => [
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'model' => 'title',
'operator' => 'contains',
'value' => ['foo'],
],
],
],
],
]);
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(2)->create();
$songIds = $songs->map(static fn (Song $song) => $song->id)->all();
$this->postAs('api/playlists/' . $playlist->id . '/songs', ['songs' => $songIds], $playlist->user)
->assertForbidden();
$this->deleteAs('api/playlists/' . $playlist->id . '/songs', ['songs' => $songIds], $playlist->user)
->assertForbidden();
}
}

View file

@ -1,149 +0,0 @@
<?php
namespace Tests\Feature\V6;
use App\Models\Playlist;
use App\Models\Song;
use App\Models\User;
use App\Values\SmartPlaylistRule;
use Illuminate\Support\Collection;
class PlaylistTest extends TestCase
{
private const JSON_STRUCTURE = [
'type',
'id',
'name',
'folder_id',
'user_id',
'is_smart',
'rules',
'created_at',
];
public function testCreatingPlaylist(): void
{
/** @var User $user */
$user = User::factory()->create();
/** @var array<Song>|Collection $songs */
$songs = Song::factory(4)->create();
$this->postAs('api/playlists', [
'name' => 'Foo Bar',
'songs' => $songs->pluck('id')->all(),
'rules' => [],
], $user)
->assertJsonStructure(self::JSON_STRUCTURE);
/** @var Playlist $playlist */
$playlist = Playlist::query()->orderByDesc('id')->first();
self::assertSame('Foo Bar', $playlist->name);
self::assertTrue($playlist->user->is($user));
self::assertNull($playlist->folder_id);
self::assertEqualsCanonicalizing($songs->pluck('id')->all(), $playlist->songs->pluck('id')->all());
}
public function testCreatingSmartPlaylist(): void
{
/** @var User $user */
$user = User::factory()->create();
$rule = SmartPlaylistRule::create([
'model' => 'artist.name',
'operator' => SmartPlaylistRule::OPERATOR_IS,
'value' => ['Bob Dylan'],
]);
$this->postAs('api/playlists', [
'name' => 'Smart Foo Bar',
'rules' => [
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'rules' => [$rule->toArray()],
],
],
], $user)->assertJsonStructure(self::JSON_STRUCTURE);
/** @var Playlist $playlist */
$playlist = Playlist::query()->orderByDesc('id')->first();
self::assertSame('Smart Foo Bar', $playlist->name);
self::assertTrue($playlist->user->is($user));
self::assertTrue($playlist->is_smart);
self::assertCount(1, $playlist->rule_groups);
self::assertNull($playlist->folder_id);
self::assertTrue($rule->equals($playlist->rule_groups[0]->rules[0]));
}
public function testCreatingSmartPlaylistFailsIfSongsProvided(): void
{
$this->postAs('api/playlists', [
'name' => 'Smart Foo Bar',
'rules' => [
[
'id' => '2a4548cd-c67f-44d4-8fec-34ff75c8a026',
'rules' => [
SmartPlaylistRule::create([
'model' => 'artist.name',
'operator' => SmartPlaylistRule::OPERATOR_IS,
'value' => ['Bob Dylan'],
])->toArray(),
],
],
],
'songs' => Song::factory(3)->create()->pluck('id')->all(),
])->assertUnprocessable();
}
public function testCreatingPlaylistWithNonExistentSongsFails(): void
{
$this->postAs('api/playlists', [
'name' => 'Foo Bar',
'rules' => [],
'songs' => ['foo'],
])
->assertUnprocessable();
}
public function testUpdatePlaylistName(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create(['name' => 'Foo']);
$this->putAs("api/playlists/$playlist->id", ['name' => 'Bar'], $playlist->user)
->assertJsonStructure(self::JSON_STRUCTURE);
self::assertSame('Bar', $playlist->refresh()->name);
}
public function testNonOwnerCannotUpdatePlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create(['name' => 'Foo']);
$this->putAs("api/playlists/$playlist->id", ['name' => 'Qux'])->assertForbidden();
self::assertSame('Foo', $playlist->refresh()->name);
}
public function testDeletePlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$this->deleteAs("api/playlists/$playlist->id", [], $playlist->user);
self::assertModelMissing($playlist);
}
public function testNonOwnerCannotDeletePlaylist(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$this->deleteAs("api/playlists/$playlist->id")->assertForbidden();
self::assertModelExists($playlist);
}
}

View file

@ -1,92 +0,0 @@
<?php
namespace Tests\Feature\V6;
use App\Models\Song;
use App\Models\User;
use Illuminate\Support\Collection;
class SongTest extends TestCase
{
public const JSON_STRUCTURE = [
'type',
'id',
'title',
'lyrics',
'album_id',
'album_name',
'artist_id',
'artist_name',
'album_artist_id',
'album_artist_name',
'album_cover',
'length',
'liked',
'play_count',
'track',
'genre',
'year',
'disc',
'created_at',
];
public const JSON_COLLECTION_STRUCTURE = [
'data' => [
'*' => self::JSON_STRUCTURE,
],
'links' => [
'first',
'last',
'prev',
'next',
],
'meta' => [
'current_page',
'from',
'path',
'per_page',
'to',
],
];
public function testIndex(): void
{
Song::factory(10)->create();
$this->getAs('api/songs')->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
$this->getAs('api/songs?sort=title&order=desc')->assertJsonStructure(self::JSON_COLLECTION_STRUCTURE);
}
public function testShow(): void
{
/** @var Song $song */
$song = Song::factory()->create();
$this->getAs('api/songs/' . $song->id)->assertJsonStructure(self::JSON_STRUCTURE);
}
public function testDelete(): void
{
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(3)->create();
/** @var User $admin */
$admin = User::factory()->admin()->create();
$this->deleteAs('api/songs', ['songs' => $songs->pluck('id')->toArray()], $admin)
->assertNoContent();
$songs->each(fn (Song $song) => $this->assertModelMissing($song));
}
public function testUnauthorizedDelete(): void
{
/** @var Collection|array<array-key, Song> $songs */
$songs = Song::factory(3)->create();
$this->deleteAs('api/songs', ['songs' => $songs->pluck('id')->toArray()])
->assertForbidden();
$songs->each(fn (Song $song) => $this->assertModelExists($song));
}
}

View file

@ -1,15 +0,0 @@
<?php
namespace Tests\Feature\V6;
use Tests\Feature\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
public function setUp(): void
{
putenv('X_API_VERSION=v6');
parent::setUp();
}
}

View file

@ -77,10 +77,7 @@ class InteractionServiceTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
/** @var Collection $interactions */ /** @var Collection $interactions */
$interactions = Interaction::factory(3)->create([ $interactions = Interaction::factory(3)->for($user)->create(['liked' => true]);
'user_id' => $user->id,
'liked' => true,
]);
$this->interactionService->batchUnlike($interactions->pluck('song.id')->all(), $user); $this->interactionService->batchUnlike($interactions->pluck('song.id')->all(), $user);

View file

@ -39,20 +39,15 @@ abstract class TestCase extends BaseTestCase
$artist = Artist::factory()->create(); $artist = Artist::factory()->create();
/** @var array<Album> $albums */ /** @var array<Album> $albums */
$albums = Album::factory(3)->create([ $albums = Album::factory(3)->for($artist)->create();
'artist_id' => $artist->id,
]);
// 7-15 songs per albums // 7-15 songs per albums
foreach ($albums as $album) { foreach ($albums as $album) {
Song::factory(random_int(7, 15))->create([ Song::factory(random_int(7, 15))->for($artist)->for($album)->create();
'album_id' => $album->id,
'artist_id' => $artist->id,
]);
} }
} }
protected static function getNonPublicProperty($object, string $property) // @phpcs:ignore protected static function getNonPublicProperty($object, string $property): mixed
{ {
$reflection = new ReflectionClass($object); $reflection = new ReflectionClass($object);
$property = $reflection->getProperty($property); $property = $reflection->getProperty($property);

View file

@ -51,7 +51,7 @@ class PlaylistFolderServiceTest extends TestCase
/** @var PlaylistFolder $folder */ /** @var PlaylistFolder $folder */
$folder = PlaylistFolder::factory()->create(); $folder = PlaylistFolder::factory()->create();
$this->service->addPlaylistsToFolder($folder, $playlists->pluck('id')->toArray()); $this->service->addPlaylistsToFolder($folder, $playlists->pluck('id')->all());
self::assertCount(3, $folder->playlists); self::assertCount(3, $folder->playlists);
} }
@ -62,9 +62,9 @@ class PlaylistFolderServiceTest extends TestCase
$folder = PlaylistFolder::factory()->create(); $folder = PlaylistFolder::factory()->create();
/** @var Collection|array<array-key, Playlist> $playlists */ /** @var Collection|array<array-key, Playlist> $playlists */
$playlists = Playlist::factory()->count(3)->create(['folder_id' => $folder->id]); $playlists = Playlist::factory()->count(3)->for($folder, 'folder')->create();
$this->service->movePlaylistsToRootLevel($playlists->pluck('id')->toArray()); $this->service->movePlaylistsToRootLevel($playlists->pluck('id')->all());
self::assertCount(0, $folder->playlists); self::assertCount(0, $folder->playlists);

View file

@ -53,11 +53,8 @@ class SpotifyServiceTest extends TestCase
public function testTryGetAlbumImage(): void public function testTryGetAlbumImage(): void
{ {
/** @var Artist $artist */
$artist = Artist::factory(['name' => 'Foo'])->create();
/** @var Album $album */ /** @var Album $album */
$album = Album::factory(['name' => 'Bar', 'artist_id' => $artist->id])->create(); $album = Album::factory(['name' => 'Bar'])->for(Artist::factory(['name' => 'Foo']))->create();
$this->client $this->client
->shouldReceive('search') ->shouldReceive('search')