mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: custom cover for playlists
This commit is contained in:
parent
52285a1c48
commit
302f2a84d0
69 changed files with 477 additions and 144 deletions
|
@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Log;
|
|||
* If this installation of Koel has a CDN_URL configured, use it as the base.
|
||||
* Otherwise, just use a full URL to the asset.
|
||||
*
|
||||
* @param string $name The optional resource name/path
|
||||
* @param string|null $name The optional resource name/path
|
||||
*/
|
||||
function static_url(?string $name = null): string
|
||||
{
|
||||
|
@ -37,6 +37,16 @@ function artist_image_url(?string $fileName): ?string
|
|||
return $fileName ? static_url(config('koel.artist_image_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function playlist_cover_path(?string $fileName): ?string
|
||||
{
|
||||
return $fileName ? public_path(config('koel.playlist_cover_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function playlist_cover_url(?string $fileName): ?string
|
||||
{
|
||||
return $fileName ? static_url(config('koel.playlist_cover_dir') . $fileName) : null;
|
||||
}
|
||||
|
||||
function koel_version(): string
|
||||
{
|
||||
return trim(FileFacade::get(base_path('.version')));
|
||||
|
@ -74,5 +84,5 @@ function attempt_unless($condition, callable $callback, bool $log = true): mixed
|
|||
|
||||
function gravatar(string $email, int $size = 192): string
|
||||
{
|
||||
return sprintf('https://www.gravatar.com/avatar/%s?s=192&d=robohash', md5($email));
|
||||
return sprintf("https://www.gravatar.com/avatar/%s?s=$size&d=robohash", md5($email));
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\UploadAlbumCoverRequest;
|
||||
use App\Models\Album;
|
||||
|
@ -20,8 +19,6 @@ class UploadAlbumCoverController extends Controller
|
|||
$request->getFileExtension()
|
||||
);
|
||||
|
||||
event(new LibraryChanged());
|
||||
|
||||
return response()->json(['cover_url' => $album->cover]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\UploadArtistImageRequest;
|
||||
use App\Models\Artist;
|
||||
|
@ -23,8 +22,6 @@ class UploadArtistImageController extends Controller
|
|||
$request->getFileExtension()
|
||||
);
|
||||
|
||||
event(new LibraryChanged());
|
||||
|
||||
return response()->json(['image_url' => $artist->image]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Exceptions\MediaPathNotSetException;
|
||||
use App\Exceptions\SongUploadFailedException;
|
||||
use App\Http\Controllers\Controller;
|
||||
|
@ -32,8 +31,6 @@ class UploadController extends Controller
|
|||
// @todo decouple Song from storage, as storages should not be responsible for creating a song.
|
||||
$song = $songRepository->getOne($storage->storeUploadedFile($request->file, $user)->id, $user);
|
||||
|
||||
event(new LibraryChanged());
|
||||
|
||||
return response()->json([
|
||||
'song' => SongResource::make($song),
|
||||
'album' => AlbumResource::make($albumRepository->getOne($song->album_id)),
|
||||
|
|
27
app/Http/Controllers/API/UploadPlaylistCoverController.php
Normal file
27
app/Http/Controllers/API/UploadPlaylistCoverController.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\API;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\API\UploadPlaylistCoverRequest;
|
||||
use App\Models\Playlist;
|
||||
use App\Services\MediaMetadataService;
|
||||
|
||||
class UploadPlaylistCoverController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
UploadPlaylistCoverRequest $request,
|
||||
Playlist $playlist,
|
||||
MediaMetadataService $mediaMetadataService
|
||||
) {
|
||||
$this->authorize('collaborate', $playlist);
|
||||
|
||||
$mediaMetadataService->writePlaylistCover(
|
||||
$playlist,
|
||||
$request->getFileContentAsBinaryString(),
|
||||
$request->getFileExtension()
|
||||
);
|
||||
|
||||
return response()->json(['cover_url' => $playlist->cover]);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
/** @property string $cover */
|
||||
/** @property-read string $cover */
|
||||
class UploadAlbumCoverRequest extends MediaImageUpdateRequest
|
||||
{
|
||||
protected function getImageFieldName(): string
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
/** @property string $image */
|
||||
/** @property-read string $image */
|
||||
class UploadArtistImageRequest extends MediaImageUpdateRequest
|
||||
{
|
||||
protected function getImageFieldName(): string
|
||||
|
|
12
app/Http/Requests/API/UploadPlaylistCoverRequest.php
Normal file
12
app/Http/Requests/API/UploadPlaylistCoverRequest.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\API;
|
||||
|
||||
/** @property-read string $cover */
|
||||
class UploadPlaylistCoverRequest extends MediaImageUpdateRequest
|
||||
{
|
||||
protected function getImageFieldName(): string
|
||||
{
|
||||
return 'cover';
|
||||
}
|
||||
}
|
|
@ -36,6 +36,7 @@ class PlaylistResource extends JsonResource
|
|||
'is_smart' => $this->playlist->is_smart,
|
||||
'is_collaborative' => $this->playlist->is_collaborative,
|
||||
'rules' => $this->playlist->rules,
|
||||
'cover' => $this->playlist->cover,
|
||||
'own_songs_only' => $this->playlist->own_songs_only,
|
||||
'created_at' => $this->playlist->created_at,
|
||||
];
|
||||
|
|
|
@ -16,7 +16,7 @@ use Illuminate\Support\Facades\File;
|
|||
use Laravel\Scout\Searchable;
|
||||
|
||||
/**
|
||||
* @property string $cover The album cover's file name
|
||||
* @property string $cover The album cover's URL
|
||||
* @property string|null $cover_path The absolute path to the cover file
|
||||
* @property bool $has_cover If the album has a non-default cover image
|
||||
* @property int $id
|
||||
|
|
|
@ -12,6 +12,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\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Scout\Searchable;
|
||||
|
@ -33,6 +34,8 @@ use LogicException;
|
|||
* @property bool $own_songs_only
|
||||
* @property Collection|array<array-key, User> $collaborators
|
||||
* @property-read bool $is_collaborative
|
||||
* @property-read ?string $cover The playlist cover's URL
|
||||
* @property-read ?string $cover_path
|
||||
*/
|
||||
class Playlist extends Model
|
||||
{
|
||||
|
@ -104,6 +107,20 @@ class Playlist extends Model
|
|||
return Attribute::get(fn () => $this->songs->pluck('id')->all());
|
||||
}
|
||||
|
||||
protected function cover(): Attribute
|
||||
{
|
||||
return Attribute::get(static fn (?string $value): ?string => playlist_cover_url($value));
|
||||
}
|
||||
|
||||
protected function coverPath(): Attribute
|
||||
{
|
||||
return Attribute::get(function () {
|
||||
$cover = Arr::get($this->attributes, 'cover');
|
||||
|
||||
return $cover ? playlist_cover_path($cover) : null;
|
||||
});
|
||||
}
|
||||
|
||||
public function ownedBy(User $user): bool
|
||||
{
|
||||
return $this->user_id === $user->id;
|
||||
|
|
|
@ -10,6 +10,8 @@ use Illuminate\Support\Facades\DB;
|
|||
class LibraryManager
|
||||
{
|
||||
/**
|
||||
* Delete albums and artists that have no songs.
|
||||
*
|
||||
* @return array{
|
||||
* albums: Collection<array-key, Album>,
|
||||
* artists: Collection<array-key, Artist>,
|
||||
|
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
|||
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Playlist;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
|
@ -80,6 +81,21 @@ class MediaMetadataService
|
|||
});
|
||||
}
|
||||
|
||||
public function writePlaylistCover(Playlist $playlist, string $source, string $extension = 'png'): void
|
||||
{
|
||||
attempt(function () use ($playlist, $source, $extension): void {
|
||||
$extension = trim(strtolower($extension), '. ');
|
||||
$destination = $this->generatePlaylistCoverPath($extension);
|
||||
$this->imageWriter->write($destination, $source);
|
||||
|
||||
if ($playlist->cover_path) {
|
||||
File::delete($playlist->cover_path);
|
||||
}
|
||||
|
||||
$playlist->update(['cover' => basename($destination)]);
|
||||
});
|
||||
}
|
||||
|
||||
private function generateAlbumCoverPath(string $extension): string
|
||||
{
|
||||
return album_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.')));
|
||||
|
@ -90,6 +106,11 @@ class MediaMetadataService
|
|||
return artist_image_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.')));
|
||||
}
|
||||
|
||||
private function generatePlaylistCoverPath(string $extension): string
|
||||
{
|
||||
return playlist_cover_path(sprintf('%s.%s', sha1(Str::uuid()), trim($extension, '.')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL of an album's thumbnail.
|
||||
* Auto-generate the thumbnail when possible, if one doesn't exist yet.
|
||||
|
|
|
@ -139,7 +139,6 @@ class MediaScanner
|
|||
if ($song) {
|
||||
$song->delete();
|
||||
$this->logger->info("$path deleted.");
|
||||
event(new LibraryChanged());
|
||||
} else {
|
||||
$this->logger->info("$path doesn't exist in our database--skipping.");
|
||||
}
|
||||
|
@ -154,8 +153,6 @@ class MediaScanner
|
|||
} else {
|
||||
$this->logger->info("Failed to scan $path. Maybe an invalid file?");
|
||||
}
|
||||
|
||||
event(new LibraryChanged());
|
||||
}
|
||||
|
||||
private function handleDeletedDirectoryRecord(string $path): void
|
||||
|
@ -164,8 +161,6 @@ class MediaScanner
|
|||
|
||||
if ($count) {
|
||||
$this->logger->info("Deleted $count song(s) under $path");
|
||||
|
||||
event(new LibraryChanged());
|
||||
} else {
|
||||
$this->logger->info("$path is empty--no action needed.");
|
||||
}
|
||||
|
@ -186,7 +181,6 @@ class MediaScanner
|
|||
$this->logger->info("Scanned all song(s) under $path");
|
||||
|
||||
event(new MediaScanCompleted($scanResults));
|
||||
event(new LibraryChanged());
|
||||
}
|
||||
|
||||
public function on(string $event, callable $callback): void
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Facades\License;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
|
@ -144,8 +143,6 @@ class SongService
|
|||
]);
|
||||
}
|
||||
});
|
||||
|
||||
event(new LibraryChanged());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Services\SongStorages;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Exceptions\MethodNotImplementedException;
|
||||
use App\Exceptions\SongPathNotFoundException;
|
||||
use App\Models\Album;
|
||||
|
@ -77,8 +76,6 @@ final class S3LambdaStorage extends S3CompatibleStorage
|
|||
'storage' => SongStorageTypes::S3_LAMBDA,
|
||||
]);
|
||||
|
||||
event(new LibraryChanged());
|
||||
|
||||
return $song;
|
||||
}
|
||||
|
||||
|
@ -90,7 +87,6 @@ final class S3LambdaStorage extends S3CompatibleStorage
|
|||
throw_unless((bool) $song, SongPathNotFoundException::create($path));
|
||||
|
||||
$song->delete();
|
||||
event(new LibraryChanged());
|
||||
}
|
||||
|
||||
public function supported(): bool
|
||||
|
|
|
@ -11,6 +11,9 @@ return [
|
|||
// The *relative* path to the directory to store artist images, *with* a trailing slash.
|
||||
'artist_image_dir' => 'img/artists/',
|
||||
|
||||
// The *relative* path to the directory to store playlist covers, *with* a trailing slash.
|
||||
'playlist_cover_dir' => 'img/playlists/',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Sync Options
|
||||
|
|
15
database/migrations/2024_02_24_085736_add_playlist_cover.php
Normal file
15
database/migrations/2024_02_24_085736_add_playlist_cover.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?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('playlists', static function (Blueprint $table): void {
|
||||
$table->string('cover')->nullable();
|
||||
});
|
||||
}
|
||||
};
|
2
public/.gitignore
vendored
2
public/.gitignore
vendored
|
@ -11,6 +11,8 @@ img/covers/*
|
|||
!img/artists
|
||||
img/artists/*
|
||||
!img/artists/.gitkeep
|
||||
img/playlists/*
|
||||
!img/playlists/.gitkeep
|
||||
|
||||
images
|
||||
js
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
<PlaylistContextMenu />
|
||||
<PlaylistFolderContextMenu />
|
||||
<CreateNewPlaylistContextMenu />
|
||||
<DropZone v-show="showDropZone" />
|
||||
<DropZone v-show="showDropZone" @close="showDropZone = false" />
|
||||
</div>
|
||||
|
||||
<LoginForm v-if="layout === 'auth'" @loggedin="onUserLoggedIn" />
|
||||
|
|
|
@ -11,6 +11,7 @@ export default (faker: Faker): Playlist => ({
|
|||
rules: [],
|
||||
own_songs_only: false,
|
||||
is_collaborative: false,
|
||||
cover: faker.image.imageUrl(),
|
||||
})
|
||||
|
||||
export const states: Record<string, (faker: Faker) => Omit<Partial<Playlist>, 'type'>> = {
|
||||
|
|
|
@ -38,7 +38,7 @@ import { songStore } from '@/stores'
|
|||
import { mediaInfoService, playbackService } from '@/services'
|
||||
import { useRouter, useThirdPartyServices } from '@/composables'
|
||||
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import AlbumThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
|
||||
|
||||
const TrackList = defineAsyncComponent(() => import('@/components/album/AlbumTrackList.vue'))
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ import { mediaInfoService, playbackService } from '@/services'
|
|||
import { useRouter, useThirdPartyServices } from '@/composables'
|
||||
import { songStore } from '@/stores'
|
||||
|
||||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import ArtistThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{ artist: Artist, mode?: MediaInfoDisplayMode }>(), { mode: 'aside' })
|
||||
const { artist, mode } = toRefs(props)
|
||||
|
|
|
@ -9,6 +9,7 @@ new class extends UnitTestCase {
|
|||
protected test () {
|
||||
it('works', async () => {
|
||||
this.mock(playlistCollaborationService, 'createInviteLink').mockResolvedValue('http://localhost:3000/invite/1234')
|
||||
const writeTextMock = this.mock(navigator.clipboard, 'writeText')
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
|
||||
this.render(Component, {
|
||||
|
@ -17,11 +18,10 @@ new class extends UnitTestCase {
|
|||
}
|
||||
})
|
||||
|
||||
this.render(Component)
|
||||
await this.user.click(screen.getByText('Invite'))
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(navigator.clipboard.readText()).equal('http://localhost:3000/invite/1234')
|
||||
expect(writeTextMock).toHaveBeenCalledWith('http://localhost:3000/invite/1234')
|
||||
screen.getByText('Link copied to clipboard!')
|
||||
})
|
||||
})
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<main>
|
||||
<p class="intro text-secondary">
|
||||
Collaborative playlists allow multiple users to contribute. <br>
|
||||
Please note that songs added to a collaborative playlist are made accessible to all users,
|
||||
Note: Songs added to a collaborative playlist are made accessible to all users,
|
||||
and you cannot mark a song as private if it’s still part of a collaborative playlist.
|
||||
</p>
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<ListItem
|
||||
is="li"
|
||||
v-for="collaborator in collaborators"
|
||||
:role="currentUserIsOwner ? 'owner' : 'contributor'"
|
||||
:role="collaborator.id === playlist.user_id ? 'owner' : 'contributor'"
|
||||
:manageable="currentUserIsOwner"
|
||||
:removable="currentUserIsOwner && collaborator.id !== playlist.user_id"
|
||||
:collaborator="collaborator"
|
||||
|
|
|
@ -90,5 +90,11 @@ li {
|
|||
flex: 0 0 72px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
.actions:not(:has(button)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -6,7 +6,7 @@ exports[`renders the modal 1`] = `
|
|||
<h1 data-v-886145d2="">Playlist Collaboration</h1>
|
||||
</header>
|
||||
<main data-v-886145d2="">
|
||||
<p data-v-886145d2="" class="intro text-secondary"> Collaborative playlists allow multiple users to contribute. <br data-v-886145d2=""> Please note that songs added to a collaborative playlist are made accessible to all users, and you cannot mark a song as private if it’s still part of a collaborative playlist. </p>
|
||||
<p data-v-886145d2="" class="intro text-secondary"> Collaborative playlists allow multiple users to contribute. <br data-v-886145d2=""> Note: Songs added to a collaborative playlist are made accessible to all users, and you cannot mark a song as private if it’s still part of a collaborative playlist. </p>
|
||||
<section data-v-886145d2="" class="collaborators">
|
||||
<h2 data-v-886145d2=""><span data-v-886145d2="">Current Collaborators</span>
|
||||
<!--v-if-->
|
||||
|
|
|
@ -36,8 +36,7 @@
|
|||
|
||||
<div class="form-row" v-if="isPlus">
|
||||
<label class="own-songs-only text-secondary small">
|
||||
<CheckBox v-model="ownSongsOnly" />
|
||||
Only show songs from my own library
|
||||
<CheckBox v-model="ownSongsOnly" /> Only include songs from my own library
|
||||
</label>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
@ -41,7 +41,7 @@
|
|||
|
||||
<div class="form-row" v-if="isPlus">
|
||||
<label class="own-songs-only text-secondary small">
|
||||
<CheckBox v-model="mutablePlaylist.own_songs_only" /> Only show songs from my own library
|
||||
<CheckBox v-model="mutablePlaylist.own_songs_only" /> Only include songs from my own library
|
||||
</label>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
@ -96,7 +96,7 @@ import { downloadService } from '@/services'
|
|||
import { useDialogBox, useRouter, useSongList, useSongListControls } from '@/composables'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import AlbumThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import AlbumThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import ScreenTabs from '@/components/ui/ArtistAlbumScreenTabs.vue'
|
||||
|
|
|
@ -92,7 +92,7 @@ import { downloadService } from '@/services'
|
|||
import { useDialogBox, useRouter, useSongList, useSongListControls, useThirdPartyServices } from '@/composables'
|
||||
|
||||
import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
||||
import ArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import ArtistThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
|
||||
import ScreenHeaderSkeleton from '@/components/ui/skeletons/ScreenHeaderSkeleton.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import ScreenTabs from '@/components/ui/ArtistAlbumScreenTabs.vue'
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
<ControlsToggle v-if="songs.length" v-model="showingControls" />
|
||||
|
||||
<template #thumbnail>
|
||||
<ThumbnailStack :thumbnails="thumbnails" />
|
||||
<PlaylistThumbnail :playlist="playlist">
|
||||
<ThumbnailStack :thumbnails="thumbnails" v-if="!playlist.cover" />
|
||||
</PlaylistThumbnail>
|
||||
</template>
|
||||
|
||||
<template v-if="songs.length || playlist.is_collaborative" #meta>
|
||||
|
@ -78,6 +80,7 @@ import ScreenHeader from '@/components/ui/ScreenHeader.vue'
|
|||
import ScreenEmptyState from '@/components/ui/ScreenEmptyState.vue'
|
||||
import SongListSkeleton from '@/components/ui/skeletons/SongListSkeleton.vue'
|
||||
import CollaboratorsBadge from '@/components/playlist/PlaylistCollaboratorsBadge.vue'
|
||||
import PlaylistThumbnail from '@/components/ui/PlaylistThumbnail.vue'
|
||||
|
||||
const { currentUser } = useAuthorization()
|
||||
const { triggerNotFound, getRouteParam, onScreenActivated } = useRouter()
|
||||
|
|
|
@ -121,15 +121,6 @@ article {
|
|||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.play-count {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.by {
|
||||
font-size: .9rem;
|
||||
opacity: .8;
|
||||
|
|
|
@ -325,7 +325,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
await this.be(user).renderComponent(songs)
|
||||
const privatizeMock = this.mock(songStore, 'privatize')
|
||||
const privatizeMock = this.mock(songStore, 'privatize').mockResolvedValue(songs.map(song => song.id))
|
||||
|
||||
await this.user.click(screen.getByText('Mark as Private'))
|
||||
|
||||
|
@ -342,7 +342,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
await this.be(user).renderComponent(songs)
|
||||
const publicizeMock = this.mock(songStore, 'publicize')
|
||||
const publicizeMock = this.mock(songStore, 'publicize').mockResolvedValue(songs.map(song => song.id))
|
||||
|
||||
await this.user.click(screen.getByText('Unmark as Private'))
|
||||
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
<template>
|
||||
<div :style="{ backgroundImage: `url(${defaultCover})` }" class="cover">
|
||||
<img v-koel-hide-broken-icon :alt="song.album_name" :src="song.album_cover" loading="lazy">
|
||||
<img
|
||||
v-koel-hide-broken-icon
|
||||
:alt="song.album_name"
|
||||
:src="song.album_cover"
|
||||
class="pointer-events-none"
|
||||
loading="lazy"
|
||||
>
|
||||
<a :title="title" class="control" role="button" @click.prevent="changeSongState">
|
||||
<Icon :icon="song.playback_state === 'Playing' ? faPause : faPlay" class="text-highlight" />
|
||||
</a>
|
||||
|
@ -65,7 +71,6 @@ const changeSongState = () => {
|
|||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `
|
||||
<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span>
|
||||
<div class="playing song-item" data-testid="song-item" tabindex="0"><span class="track-number"><i data-v-47e95701=""><span data-v-47e95701=""></span><span data-v-47e95701=""></span><span data-v-47e95701=""></span></i></span><span class="thumbnail"><div data-v-a2b2e00f="" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" class="cover"><img data-v-a2b2e00f="" alt="Test Album" src="https://example.com/cover.jpg" class="pointer-events-none" loading="lazy"><a data-v-a2b2e00f="" title="Pause" class="control" role="button"><br data-v-a2b2e00f="" data-testid="Icon" icon="[object Object]" class="text-highlight"></a></div></span><span class="title-artist"><span class="title text-primary"><!--v-if--> Test Song</span><span class="artist">Test Artist</span></span><span class="album">Test Album</span>
|
||||
<!--v-if--><span class="time">16:40</span><span class="extra"><button title="Unlike Test Song by Test Artist" type="button"><br data-testid="Icon" icon="[object Object]"></button></span>
|
||||
</div>
|
||||
`;
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<template>
|
||||
<div :style="{ backgroundImage: thumbnailUrl ? `url(${thumbnailUrl})` : 'none' }" data-testid="album-art-overlay" />
|
||||
<div
|
||||
:style="{ backgroundImage: thumbnailUrl ? `url(${thumbnailUrl})` : 'none' }"
|
||||
class="pointer-events-none"
|
||||
data-testid="album-art-overlay"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
@ -28,7 +32,6 @@ div {
|
|||
overflow: hidden;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import AlbumArtistThumbnail from '@/components/ui/AlbumArtistThumbnail.vue'
|
||||
import AlbumArtistThumbnail from '@/components/ui/ArtistAlbumThumbnail.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ layout?: ArtistAlbumCardLayout, entity: Artist | Album }>(),
|
||||
|
|
|
@ -5,7 +5,7 @@ import factory from '@/__tests__/factory'
|
|||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { queueStore, songStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import Thumbnail from './AlbumArtistThumbnail.vue'
|
||||
import Thumbnail from './ArtistAlbumThumbnail.vue'
|
||||
|
||||
let album: Album
|
||||
let artist: Artist
|
|
@ -5,7 +5,7 @@
|
|||
class="cover"
|
||||
data-testid="album-artist-thumbnail"
|
||||
>
|
||||
<img v-koel-hide-broken-icon :alt="entity.name" :src="image" loading="lazy">
|
||||
<img v-koel-hide-broken-icon :alt="entity.name" :src="image" class="pointer-events-none" loading="lazy">
|
||||
<a
|
||||
class="control control-play"
|
||||
role="button"
|
||||
|
@ -28,8 +28,7 @@ import { albumStore, artistStore, queueStore, songStore, userStore } from '@/sto
|
|||
import { playbackService } from '@/services'
|
||||
import { defaultCover, fileReader, logger } from '@/utils'
|
||||
import { useAuthorization, useMessageToaster, useRouter, useKoelPlus } from '@/composables'
|
||||
|
||||
const VALID_IMAGE_TYPES = ['image/jpeg', 'image/gif', 'image/png', 'image/webp']
|
||||
import { acceptedImageTypes } from '@/config'
|
||||
|
||||
const { toastSuccess } = useMessageToaster()
|
||||
const { go } = useRouter()
|
||||
|
@ -91,7 +90,7 @@ const validImageDropEvent = (event: DragEvent) => {
|
|||
return false
|
||||
}
|
||||
|
||||
return VALID_IMAGE_TYPES.includes(event.dataTransfer.items[0].getAsFile()!.type)
|
||||
return acceptedImageTypes.includes(event.dataTransfer.items[0].getAsFile()!.type)
|
||||
}
|
||||
|
||||
const onDrop = async (event: DragEvent) => {
|
||||
|
@ -153,7 +152,6 @@ const onDrop = async (event: DragEvent) => {
|
|||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
|
@ -233,17 +231,6 @@ const onDrop = async (event: DragEvent) => {
|
|||
}
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
font-size: 4rem;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
background: rgba(0, 0, 0, .7);
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.droppable {
|
||||
border: 2px dotted rgba(255, 255, 255, 1);
|
||||
filter: brightness(0.4);
|
107
resources/assets/js/components/ui/PlaylistThumbnail.vue
Normal file
107
resources/assets/js/components/ui/PlaylistThumbnail.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div
|
||||
:class="{ droppable }"
|
||||
:style="{ backgroundImage: `url(${playlist.cover || defaultCover})` }"
|
||||
class="cover"
|
||||
data-testid="playlist-thumbnail"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
@dragover.prevent
|
||||
>
|
||||
<div class="pointer-events-none">
|
||||
<slot/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, toRef, toRefs } from 'vue'
|
||||
import { defaultCover, fileReader, logger } from '@/utils'
|
||||
import { playlistStore, userStore } from '@/stores'
|
||||
import { useAuthorization, useKoelPlus, useMessageToaster } from '@/composables'
|
||||
import { acceptedImageTypes } from '@/config'
|
||||
|
||||
const props = defineProps<{ playlist: Playlist }>()
|
||||
const { playlist } = toRefs(props)
|
||||
|
||||
const droppable = ref(false)
|
||||
const user = toRef(userStore.state, 'current')
|
||||
|
||||
const { isAdmin } = useAuthorization()
|
||||
const { isPlus } = useKoelPlus()
|
||||
const { toastError } = useMessageToaster()
|
||||
|
||||
const allowsUpload = computed(() => isAdmin.value || isPlus.value)
|
||||
const onDragEnter = () => (droppable.value = allowsUpload.value)
|
||||
const onDragLeave = () => (droppable.value = false)
|
||||
|
||||
const validImageDropEvent = (event: DragEvent) => {
|
||||
if (!event.dataTransfer || !event.dataTransfer.items) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.dataTransfer.items.length !== 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (event.dataTransfer.items[0].kind !== 'file') {
|
||||
return false
|
||||
}
|
||||
|
||||
return acceptedImageTypes.includes(event.dataTransfer.items[0].getAsFile()!.type)
|
||||
}
|
||||
|
||||
const onDrop = async (event: DragEvent) => {
|
||||
droppable.value = false
|
||||
|
||||
if (!allowsUpload.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!validImageDropEvent(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
const backupImage = playlist.value.cover
|
||||
|
||||
try {
|
||||
const fileData = await fileReader.readAsDataUrl(event.dataTransfer!.files[0])
|
||||
|
||||
// Replace the image right away to create an "instant" effect
|
||||
playlist.value.cover = fileData
|
||||
await playlistStore.uploadCover(playlist.value, fileData)
|
||||
} catch (e) {
|
||||
const message = e?.response?.data?.message ?? 'Unknown error.'
|
||||
toastError(`Failed to upload: ${message}`)
|
||||
|
||||
// restore the backup image
|
||||
playlist.value.cover = backupImage
|
||||
|
||||
logger.error(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
display: block;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
|
||||
&.droppable {
|
||||
border: 2px dotted rgba(255, 255, 255, 1);
|
||||
filter: brightness(0.4);
|
||||
}
|
||||
|
||||
.thumbnail-stack {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -4,6 +4,10 @@ exports[`displays nothing if fetching fails 1`] = `<div style="background-image:
|
|||
|
||||
exports[`displays nothing if fetching fails 2`] = `<div data-v-e7775cad="" style="background-image: none;" data-testid="album-art-overlay"></div>`;
|
||||
|
||||
exports[`displays nothing if fetching fails 3`] = `<div data-v-e7775cad="" style="background-image: none;" class="pointer-events-none" data-testid="album-art-overlay"></div>`;
|
||||
|
||||
exports[`fetches and displays the album thumbnail 1`] = `<div style="background-image: url(http://test/thumb.jpg);" data-testid="album-art-overlay" data-v-e7775cad=""></div>`;
|
||||
|
||||
exports[`fetches and displays the album thumbnail 2`] = `<div data-v-e7775cad="" style="background-image: url(http://test/thumb.jpg);" data-testid="album-art-overlay"></div>`;
|
||||
|
||||
exports[`fetches and displays the album thumbnail 3`] = `<div data-v-e7775cad="" style="background-image: url(http://test/thumb.jpg);" class="pointer-events-none" data-testid="album-art-overlay"></div>`;
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders for album 1`] = `<span data-v-a14c1d10="" class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-a14c1d10="" alt="IV" src="https://test/album.jpg" class="pointer-events-none" loading="lazy"><a data-v-a14c1d10="" class="control control-play" role="button"><span data-v-a14c1d10="" class="hidden">Play all songs in the album IV</span><span data-v-a14c1d10="" class="icon"></span></a></span>`;
|
||||
|
||||
exports[`renders for artist 1`] = `<span data-v-a14c1d10="" class="cover" style="background-image: url(undefined/resources/assets/img/covers/default.svg);" data-testid="album-artist-thumbnail"><img data-v-a14c1d10="" alt="Led Zeppelin" src="https://test/blimp.jpg" class="pointer-events-none" loading="lazy"><a data-v-a14c1d10="" class="control control-play" role="button"><span data-v-a14c1d10="" class="hidden">Play all songs by Led Zeppelin</span><span data-v-a14c1d10="" class="icon"></span></a></span>`;
|
|
@ -1,6 +1,7 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="allowsUpload && mediaPathSetUp"
|
||||
v-koel-clickaway="close"
|
||||
:class="{ droppable }"
|
||||
class="drop-zone"
|
||||
@dragleave="onDropLeave"
|
||||
|
@ -19,6 +20,7 @@ import { useUpload } from '@/composables'
|
|||
|
||||
const { allowsUpload, mediaPathSetUp, handleDropEvent } = useUpload()
|
||||
|
||||
const emit = defineEmits<{(e: 'close'): void}>()
|
||||
const droppable = ref(false)
|
||||
|
||||
const onDropLeave = () => (droppable.value = false)
|
||||
|
@ -34,6 +36,8 @@ const onDrop = async (event: DragEvent) => {
|
|||
droppable.value = false
|
||||
await handleDropEvent(event)
|
||||
}
|
||||
|
||||
const close = () => emit('close')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
// Vitest Snapshot v1
|
||||
|
||||
exports[`renders 1`] = `<div data-v-c7c7e2e2="" class="canceled upload-item" title=""><span data-v-c7c7e2e2="" style="width: 42%;" class="progress"></span><span data-v-c7c7e2e2="" class="details"><span data-v-c7c7e2e2="" class="name">Sample Track</span><span data-v-c7c7e2e2="" class="controls"><button data-v-e368fe26="" data-v-c7c7e2e2="" icon-only="" title="Retry" transparent="" unrounded=""><br data-v-c7c7e2e2="" data-testid="Icon" icon="[object Object]"></button><button data-v-e368fe26="" data-v-c7c7e2e2="" icon-only="" title="Remove" transparent="" unrounded=""><br data-v-c7c7e2e2="" data-testid="Icon" icon="[object Object]"></button></span></span></div>`;
|
||||
exports[`renders 1`] = `<div data-v-c7c7e2e2="" class="canceled upload-item" title=""><span data-v-c7c7e2e2="" style="width: 42%;" class="progress"></span><span data-v-c7c7e2e2="" class="details"><span data-v-c7c7e2e2="" class="name">Sample Track</span><span data-v-c7c7e2e2="" class="controls"><!--v-if--><button data-v-e368fe26="" data-v-c7c7e2e2="" icon-only="" title="Retry" transparent="" unrounded=""><br data-v-c7c7e2e2="" data-testid="Icon" icon="[object Object]"></button><button data-v-e368fe26="" data-v-c7c7e2e2="" icon-only="" title="Remove" transparent="" unrounded=""><br data-v-c7c7e2e2="" data-testid="Icon" icon="[object Object]"></button></span></span></div>`;
|
||||
|
|
1
resources/assets/js/config/acceptedImageTypes.ts
Normal file
1
resources/assets/js/config/acceptedImageTypes.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const acceptedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
|
@ -1,6 +1,7 @@
|
|||
export * from './events'
|
||||
export * from './acceptedImageTypes'
|
||||
export * from './acceptedMediaTypes'
|
||||
export * from './audio'
|
||||
export * from './events'
|
||||
export * from './genres'
|
||||
export * from './routes'
|
||||
export * from './audio'
|
||||
export * from './visualizers'
|
||||
|
|
|
@ -63,7 +63,7 @@ new class extends UnitTestCase {
|
|||
await albumStore.uploadCover(album, 'data://cover')
|
||||
|
||||
expect(album.cover).toBe('http://test/cover.jpg')
|
||||
expect(putMock).toHaveBeenCalledWith(`album/${album.id}/cover`, { cover: 'data://cover' })
|
||||
expect(putMock).toHaveBeenCalledWith(`albums/${album.id}/cover`, { cover: 'data://cover' })
|
||||
expect(albumStore.byId(album.id)?.cover).toBe('http://test/cover.jpg')
|
||||
songsInAlbum.forEach(song => expect(song.album_cover).toBe('http://test/cover.jpg'))
|
||||
})
|
||||
|
@ -74,7 +74,7 @@ new class extends UnitTestCase {
|
|||
|
||||
const url = await albumStore.fetchThumbnail(album.id)
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith(`album/${album.id}/thumbnail`)
|
||||
expect(getMock).toHaveBeenCalledWith(`albums/${album.id}/thumbnail`)
|
||||
expect(url).toBe('http://test/thumbnail.jpg')
|
||||
})
|
||||
|
||||
|
|
|
@ -47,7 +47,7 @@ export const albumStore = {
|
|||
* @param {string} cover The content data string of the cover
|
||||
*/
|
||||
async uploadCover (album: Album, cover: string) {
|
||||
album.cover = (await http.put<{ cover_url: string }>(`album/${album.id}/cover`, { cover })).cover_url
|
||||
album.cover = (await http.put<{ cover_url: string }>(`albums/${album.id}/cover`, { cover })).cover_url
|
||||
songStore.byAlbum(album).forEach(song => song.album_cover = album.cover)
|
||||
|
||||
// sync to vault
|
||||
|
@ -60,7 +60,7 @@ export const albumStore = {
|
|||
* Fetch the (blurry) thumbnail-sized version of an album's cover.
|
||||
*/
|
||||
fetchThumbnail: async (id: number) => {
|
||||
return (await http.get<{ thumbnailUrl: string }>(`album/${id}/thumbnail`)).thumbnailUrl
|
||||
return (await http.get<{ thumbnailUrl: string }>(`albums/${id}/thumbnail`)).thumbnailUrl
|
||||
},
|
||||
|
||||
async resolve (id: number) {
|
||||
|
|
|
@ -75,7 +75,7 @@ new class extends UnitTestCase {
|
|||
await artistStore.uploadImage(artist, 'data://image')
|
||||
|
||||
expect(artist.image).toBe('http://test/img.jpg')
|
||||
expect(putMock).toHaveBeenCalledWith(`artist/${artist.id}/image`, { image: 'data://image' })
|
||||
expect(putMock).toHaveBeenCalledWith(`artists/${artist.id}/image`, { image: 'data://image' })
|
||||
expect(artistStore.byId(artist.id)?.image).toBe('http://test/img.jpg')
|
||||
})
|
||||
|
||||
|
|
|
@ -35,7 +35,7 @@ export const artistStore = {
|
|||
},
|
||||
|
||||
async uploadImage (artist: Artist, image: string) {
|
||||
artist.image = (await http.put<{ image_url: string }>(`artist/${artist.id}/image`, { image })).image_url
|
||||
artist.image = (await http.put<{ image_url: string }>(`artists/${artist.id}/image`, { image })).image_url
|
||||
|
||||
// sync to vault
|
||||
this.byId(artist.id)!.image = artist.image
|
||||
|
|
|
@ -84,7 +84,7 @@ new class extends UnitTestCase {
|
|||
const playlist = factory<Playlist>('playlist')
|
||||
const folder = factory<PlaylistFolder>('playlist-folder')
|
||||
const postMock = this.mock(http, 'post').mockResolvedValue(playlist)
|
||||
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', null)
|
||||
this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', null)
|
||||
|
||||
await playlistStore.store('New Playlist', { folder_id: folder.id }, songs)
|
||||
|
||||
|
@ -99,39 +99,45 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('deletes a playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const deleteMock = this.mock(http, 'delete')
|
||||
playlistStore.state.playlists = [factory<Playlist>('playlist'), playlist]
|
||||
|
||||
await playlistStore.delete(playlist)
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledWith('playlists/12')
|
||||
expect(deleteMock).toHaveBeenCalledWith(`playlists/${playlist.id}`)
|
||||
expect(playlistStore.state.playlists).toHaveLength(1)
|
||||
expect(playlistStore.byId(playlist.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds songs to a playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const songs = factory<Song>('song', 3)
|
||||
const postMock = this.mock(http, 'post').mockResolvedValue(playlist)
|
||||
const removeMock = this.mock(cache, 'remove')
|
||||
|
||||
await playlistStore.addSongs(playlist, songs)
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('playlists/12/songs', { songs: songs.map(song => song.id) })
|
||||
expect(removeMock).toHaveBeenCalledWith(['playlist.songs', 12])
|
||||
expect(postMock).toHaveBeenCalledWith(`playlists/${playlist.id}/songs`, {
|
||||
songs: songs.map(song => song.id)
|
||||
})
|
||||
|
||||
expect(removeMock).toHaveBeenCalledWith(['playlist.songs', playlist.id])
|
||||
})
|
||||
|
||||
it('removes songs from a playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
const songs = factory<Song>('song', 3)
|
||||
const deleteMock = this.mock(http, 'delete').mockResolvedValue(playlist)
|
||||
const removeMock = this.mock(cache, 'remove')
|
||||
|
||||
await playlistStore.removeSongs(playlist, songs)
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledWith('playlists/12/songs', { songs: songs.map(song => song.id) })
|
||||
expect(removeMock).toHaveBeenCalledWith(['playlist.songs', 12])
|
||||
expect(deleteMock).toHaveBeenCalledWith(`playlists/${playlist.id}/songs`, {
|
||||
songs: songs.map(song => song.id)
|
||||
})
|
||||
|
||||
expect(removeMock).toHaveBeenCalledWith(['playlist.songs', playlist.id])
|
||||
})
|
||||
|
||||
it('does not modify a smart playlist content', async () => {
|
||||
|
@ -146,7 +152,7 @@ new class extends UnitTestCase {
|
|||
})
|
||||
|
||||
it('updates a standard playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist', { id: 12 })
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
playlistStore.state.playlists = [playlist]
|
||||
const folder = factory<PlaylistFolder>('playlist-folder')
|
||||
|
||||
|
@ -154,12 +160,18 @@ new class extends UnitTestCase {
|
|||
|
||||
await playlistStore.update(playlist, { name: 'Foo', folder_id: folder.id })
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith('playlists/12', { name: 'Foo', rules: null, folder_id: folder.id })
|
||||
expect(putMock).toHaveBeenCalledWith(`playlists/${playlist.id}`, {
|
||||
name: 'Foo',
|
||||
rules: null,
|
||||
folder_id: folder.id
|
||||
})
|
||||
|
||||
expect(playlist.name).toBe('Foo')
|
||||
})
|
||||
|
||||
it('updates a smart playlist', async () => {
|
||||
const playlist = factory.states('smart')<Playlist>('playlist', { id: 12 })
|
||||
const playlist = factory.states('smart')<Playlist>('playlist')
|
||||
playlistStore.state.playlists = [playlist]
|
||||
const rules = factory<SmartPlaylistRuleGroup>('smart-playlist-rule-group', 2)
|
||||
const serializeMock = this.mock(playlistStore, 'serializeSmartPlaylistRulesForStorage', ['Whatever'])
|
||||
const putMock = this.mock(http, 'put').mockResolvedValue(playlist)
|
||||
|
@ -168,8 +180,26 @@ new class extends UnitTestCase {
|
|||
await playlistStore.update(playlist, { name: 'Foo', rules })
|
||||
|
||||
expect(serializeMock).toHaveBeenCalledWith(rules)
|
||||
expect(putMock).toHaveBeenCalledWith('playlists/12', { name: 'Foo', rules: ['Whatever'], folder_id: undefined })
|
||||
expect(removeMock).toHaveBeenCalledWith(['playlist.songs', 12])
|
||||
|
||||
expect(putMock).toHaveBeenCalledWith(`playlists/${playlist.id}`, {
|
||||
name: 'Foo',
|
||||
rules: ['Whatever'],
|
||||
folder_id: undefined
|
||||
})
|
||||
|
||||
expect(removeMock).toHaveBeenCalledWith(['playlist.songs', playlist.id])
|
||||
})
|
||||
|
||||
it('uploads a cover for a playlist', async () => {
|
||||
const playlist = factory<Playlist>('playlist')
|
||||
playlistStore.state.playlists = [playlist]
|
||||
const putMock = this.mock(http, 'put').mockResolvedValue({ cover_url: 'http://test/cover.jpg' })
|
||||
|
||||
await playlistStore.uploadCover(playlist, 'data://cover')
|
||||
|
||||
expect(playlist.cover).toBe('http://test/cover.jpg')
|
||||
expect(putMock).toHaveBeenCalledWith(`playlists/${playlist.id}/cover`, { cover: 'data://cover' })
|
||||
expect(playlistStore.byId(playlist.id)?.cover).toBe('http://test/cover.jpg')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -176,5 +176,20 @@ export const playlistStore = {
|
|||
type
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload a cover for a playlist.
|
||||
*
|
||||
* @param {Playlist} playlist The playlist object
|
||||
* @param {string} cover The content data string of the cover
|
||||
*/
|
||||
async uploadCover (playlist: Playlist, cover: string) {
|
||||
playlist.cover = (await http.put<{ cover_url: string }>(`playlists/${playlist.id}/cover`, { cover })).cover_url
|
||||
|
||||
// sync to vault
|
||||
this.byId(playlist.id)!.cover = playlist.cover
|
||||
|
||||
return playlist.cover
|
||||
}
|
||||
}
|
||||
|
|
1
resources/assets/js/types.d.ts
vendored
1
resources/assets/js/types.d.ts
vendored
|
@ -237,6 +237,7 @@ interface Playlist {
|
|||
is_collaborative: boolean
|
||||
rules: SmartPlaylistRuleGroup[]
|
||||
own_songs_only: boolean
|
||||
cover: string | null
|
||||
songs?: Song[]
|
||||
}
|
||||
|
||||
|
|
|
@ -164,6 +164,10 @@ label {
|
|||
}
|
||||
}
|
||||
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -50,6 +50,7 @@ use App\Http\Controllers\API\UpdateUserPreferenceController;
|
|||
use App\Http\Controllers\API\UploadAlbumCoverController;
|
||||
use App\Http\Controllers\API\UploadArtistImageController;
|
||||
use App\Http\Controllers\API\UploadController;
|
||||
use App\Http\Controllers\API\UploadPlaylistCoverController;
|
||||
use App\Http\Controllers\API\UserController;
|
||||
use App\Http\Controllers\API\UserInvitationController;
|
||||
use App\Models\Song;
|
||||
|
@ -157,9 +158,14 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
|
|||
Route::get('artists/{artist}/information', FetchArtistInformationController::class);
|
||||
|
||||
// Cover/image upload routes
|
||||
Route::put('albums/{album}/cover', UploadAlbumCoverController::class);
|
||||
Route::get('albums/{album}/thumbnail', FetchAlbumThumbnailController::class);
|
||||
Route::put('artists/{artist}/image', UploadArtistImageController::class);
|
||||
Route::put('playlists/{playlist}/cover', UploadPlaylistCoverController::class);
|
||||
// deprecated routes
|
||||
Route::put('album/{album}/cover', UploadAlbumCoverController::class);
|
||||
Route::put('artist/{artist}/image', UploadArtistImageController::class);
|
||||
Route::get('album/{album}/thumbnail', FetchAlbumThumbnailController::class);
|
||||
Route::put('artist/{artist}/image', UploadArtistImageController::class);
|
||||
|
||||
Route::get('search', ExcerptSearchController::class);
|
||||
Route::get('search/songs', SongSearchController::class);
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Models\Album;
|
||||
use App\Services\MediaMetadataService;
|
||||
use Mockery;
|
||||
|
@ -25,8 +24,6 @@ class AlbumCoverTest extends TestCase
|
|||
|
||||
public function testUpdate(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
/** @var Album $album */
|
||||
$album = Album::factory()->create();
|
||||
|
||||
|
|
|
@ -39,7 +39,7 @@ class AlbumThumbnailTest extends TestCase
|
|||
}))
|
||||
->andReturn($thumbnailUrl);
|
||||
|
||||
$response = $this->getAs("api/album/{$createdAlbum->id}/thumbnail");
|
||||
$response = $this->getAs("api/albums/{$createdAlbum->id}/thumbnail");
|
||||
$response->assertJson(['thumbnailUrl' => $thumbnailUrl]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Models\Artist;
|
||||
use App\Services\MediaMetadataService;
|
||||
use Mockery;
|
||||
|
@ -24,8 +23,6 @@ class ArtistImageTest extends TestCase
|
|||
|
||||
public function testUpdate(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
Artist::factory()->create(['id' => 9999]);
|
||||
|
||||
$this->mediaMetadataService
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Feature\KoelPlus;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Models\Album;
|
||||
use App\Models\Song;
|
||||
use App\Services\MediaMetadataService;
|
||||
|
@ -23,8 +22,6 @@ class AlbumCoverTest extends PlusTestCase
|
|||
|
||||
public function testNormalUserCanUploadCoverIfOwningAllSongsInAlbum(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$user = create_user();
|
||||
|
||||
/** @var Album $album */
|
||||
|
@ -36,7 +33,7 @@ class AlbumCoverTest extends PlusTestCase
|
|||
->once()
|
||||
->with(Mockery::on(static fn (Album $target) => $target->is($album)), 'Foo', 'jpeg');
|
||||
|
||||
$this->putAs("api/album/$album->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
$this->putAs("api/albums/$album->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
|
@ -53,14 +50,12 @@ class AlbumCoverTest extends PlusTestCase
|
|||
->shouldReceive('writeAlbumCover')
|
||||
->never();
|
||||
|
||||
$this->putAs("api/album/$album->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
$this->putAs("api/albums/$album->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function testAdminCanUploadCoverEvenIfNotOwningAllSongsInAlbum(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$user = create_user();
|
||||
|
||||
/** @var Album $album */
|
||||
|
@ -72,7 +67,7 @@ class AlbumCoverTest extends PlusTestCase
|
|||
->once()
|
||||
->with(Mockery::on(static fn (Album $target) => $target->is($album)), 'Foo', 'jpeg');
|
||||
|
||||
$this->putAs("api/album/$album->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], create_admin())
|
||||
$this->putAs("api/albums/$album->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], create_admin())
|
||||
->assertOk();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Feature\KoelPlus;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Models\Artist;
|
||||
use App\Models\Song;
|
||||
use App\Services\MediaMetadataService;
|
||||
|
@ -23,8 +22,6 @@ class ArtistImageTest extends PlusTestCase
|
|||
|
||||
public function testNormalUserCanUploadImageIfOwningAllSongsInArtist(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$user = create_user();
|
||||
|
||||
/** @var Artist $artist */
|
||||
|
@ -36,7 +33,7 @@ class ArtistImageTest extends PlusTestCase
|
|||
->once()
|
||||
->with(Mockery::on(static fn (Artist $target) => $target->is($artist)), 'Foo', 'jpeg');
|
||||
|
||||
$this->putAs("api/artist/$artist->id/image", ['image' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
$this->putAs("api/artists/$artist->id/image", ['image' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
|
@ -53,14 +50,12 @@ class ArtistImageTest extends PlusTestCase
|
|||
->shouldReceive('writeArtistImage')
|
||||
->never();
|
||||
|
||||
$this->putAs("api/artist/$artist->id/image", ['image' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
$this->putAs("api/artists/$artist->id/image", ['image' => 'data:image/jpeg;base64,Rm9v'], $user)
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function testAdminCanUploadImageEvenIfNotOwningAllSongsInArtist(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$user = create_user();
|
||||
|
||||
/** @var Artist $artist */
|
||||
|
@ -72,7 +67,7 @@ class ArtistImageTest extends PlusTestCase
|
|||
->once()
|
||||
->with(Mockery::on(static fn (Artist $target) => $target->is($artist)), 'Foo', 'jpeg');
|
||||
|
||||
$this->putAs("api/artist/$artist->id/image", ['image' => 'data:image/jpeg;base64,Rm9v'], create_admin())
|
||||
$this->putAs("api/artists/$artist->id/image", ['image' => 'data:image/jpeg;base64,Rm9v'], create_admin())
|
||||
->assertOk();
|
||||
}
|
||||
}
|
||||
|
|
43
tests/Feature/KoelPlus/PlaylistCoverTest.php
Normal file
43
tests/Feature/KoelPlus/PlaylistCoverTest.php
Normal file
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\KoelPlus;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Services\MediaMetadataService;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\PlusTestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
|
||||
class PlaylistCoverTest extends PlusTestCase
|
||||
{
|
||||
private MockInterface|MediaMetadataService $mediaMetadataService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->mediaMetadataService = self::mock(MediaMetadataService::class);
|
||||
}
|
||||
|
||||
public function testCollaboratorCanUploadCover(): void
|
||||
{
|
||||
/** @var Playlist $playlist */
|
||||
$playlist = Playlist::factory()->create();
|
||||
$collaborator = create_user();
|
||||
$playlist->addCollaborator($collaborator);
|
||||
|
||||
$this->mediaMetadataService
|
||||
->shouldReceive('writePlaylistCover')
|
||||
->once()
|
||||
->with(Mockery::on(static fn (Playlist $target) => $target->is($playlist)), 'Foo', 'jpeg');
|
||||
|
||||
$this->putAs(
|
||||
"api/playlists/$playlist->id/cover",
|
||||
['cover' => 'data:image/jpeg;base64,Rm9v'],
|
||||
$collaborator
|
||||
)
|
||||
->assertOk();
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Feature\ObjectStorage;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Models\Song;
|
||||
use Illuminate\Foundation\Testing\WithoutMiddleware;
|
||||
use Tests\TestCase;
|
||||
|
@ -51,8 +50,6 @@ class S3Test extends TestCase
|
|||
|
||||
public function testRemovingASong(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
Song::factory()->create([
|
||||
'path' => 's3://koel/sample.mp3',
|
||||
]);
|
||||
|
|
57
tests/Feature/PlaylistCoverTest.php
Normal file
57
tests/Feature/PlaylistCoverTest.php
Normal file
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Playlist;
|
||||
use App\Services\MediaMetadataService;
|
||||
use Mockery;
|
||||
use Mockery\MockInterface;
|
||||
use Tests\TestCase;
|
||||
|
||||
use function Tests\create_user;
|
||||
|
||||
class PlaylistCoverTest extends TestCase
|
||||
{
|
||||
private MockInterface|MediaMetadataService $mediaMetadataService;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->mediaMetadataService = self::mock(MediaMetadataService::class);
|
||||
}
|
||||
|
||||
public function testUploadCover(): void
|
||||
{
|
||||
/** @var Playlist $playlist */
|
||||
$playlist = Playlist::factory()->create();
|
||||
self::assertNull($playlist->cover);
|
||||
|
||||
$this->mediaMetadataService
|
||||
->shouldReceive('writePlaylistCover')
|
||||
->once()
|
||||
->with(Mockery::on(static fn (Playlist $target) => $target->is($playlist)), 'Foo', 'jpeg');
|
||||
|
||||
$this->putAs(
|
||||
"api/playlists/$playlist->id/cover",
|
||||
['cover' => 'data:image/jpeg;base64,Rm9v'],
|
||||
$playlist->user
|
||||
)
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function testUploadCoverNotAllowedForNonOwner(): void
|
||||
{
|
||||
/** @var Playlist $playlist */
|
||||
$playlist = Playlist::factory()->create();
|
||||
|
||||
$this->mediaMetadataService->shouldNotReceive('writePlaylistCover');
|
||||
|
||||
$this->putAs(
|
||||
"api/playlists/$playlist->id/cover",
|
||||
['cover' => 'data:image/jpeg;base64,Rm9v'],
|
||||
create_user()
|
||||
)
|
||||
->assertForbidden();
|
||||
}
|
||||
}
|
|
@ -2,13 +2,11 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Exceptions\MediaPathNotSetException;
|
||||
use App\Exceptions\SongUploadFailedException;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
use function Tests\create_admin;
|
||||
|
@ -50,10 +48,8 @@ class UploadTest extends TestCase
|
|||
|
||||
public function testUploadSuccessful(): void
|
||||
{
|
||||
Event::fake(LibraryChanged::class);
|
||||
Setting::set('media_path', public_path('sandbox/media'));
|
||||
|
||||
$this->postAs('/api/upload', ['file' => $this->file], create_admin())->assertJsonStructure(['song', 'album']);
|
||||
Event::assertDispatched(LibraryChanged::class);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Integration\Services;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Events\MediaScanCompleted;
|
||||
use App\Models\Album;
|
||||
use App\Models\Artist;
|
||||
|
@ -205,8 +204,6 @@ class MediaScannerTest extends TestCase
|
|||
|
||||
public function testScanAddedSongViaWatch(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$path = $this->path('/blank.mp3');
|
||||
|
||||
$this->scanner->scanWatchRecord(
|
||||
|
@ -219,8 +216,6 @@ class MediaScannerTest extends TestCase
|
|||
|
||||
public function testScanDeletedSongViaWatch(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
/** @var Song $song */
|
||||
$song = Song::factory()->create();
|
||||
|
||||
|
@ -234,7 +229,7 @@ class MediaScannerTest extends TestCase
|
|||
|
||||
public function testScanDeletedDirectoryViaWatch(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class, MediaScanCompleted::class);
|
||||
$this->expectsEvents(MediaScanCompleted::class);
|
||||
|
||||
$config = ScanConfiguration::make(owner: create_admin());
|
||||
|
||||
|
|
|
@ -35,11 +35,15 @@ abstract class TestCase extends BaseTestCase
|
|||
|
||||
private static function createSandbox(): void
|
||||
{
|
||||
config(['koel.album_cover_dir' => 'sandbox/img/covers/']);
|
||||
config(['koel.artist_image_dir' => 'sandbox/img/artists/']);
|
||||
config([
|
||||
'koel.album_cover_dir' => 'sandbox/img/covers/',
|
||||
'koel.artist_image_dir' => 'sandbox/img/artists/',
|
||||
'koel.playlist_cover_dir' => 'sandbox/img/playlists/',
|
||||
]);
|
||||
|
||||
File::ensureDirectoryExists(public_path(config('koel.album_cover_dir')));
|
||||
File::ensureDirectoryExists(public_path(config('koel.artist_image_dir')));
|
||||
File::ensureDirectoryExists(public_path(config('koel.playlist_cover_dir')));
|
||||
File::ensureDirectoryExists(public_path('sandbox/media/'));
|
||||
}
|
||||
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace Tests\Unit\Services\SongStorages;
|
||||
|
||||
use App\Events\LibraryChanged;
|
||||
use App\Models\Song;
|
||||
use App\Repositories\SongRepository;
|
||||
use App\Repositories\UserRepository;
|
||||
|
@ -38,8 +37,6 @@ class S3LambdaStorageTest extends TestCase
|
|||
|
||||
public function testCreateSongEntry(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$user = create_admin();
|
||||
$this->userRepository->shouldReceive('getDefaultAdminUser')
|
||||
->once()
|
||||
|
@ -70,9 +67,8 @@ class S3LambdaStorageTest extends TestCase
|
|||
|
||||
public function testUpdateSongEntry(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
$user = create_admin();
|
||||
|
||||
$this->userRepository->shouldReceive('getDefaultAdminUser')
|
||||
->once()
|
||||
->andReturn($user);
|
||||
|
@ -80,6 +76,7 @@ class S3LambdaStorageTest extends TestCase
|
|||
/** @var Song $song */
|
||||
$song = Song::factory()->create([
|
||||
'path' => 's3://foo/bar',
|
||||
'storage' => 's3-lambda',
|
||||
]);
|
||||
|
||||
$this->storage->createSongEntry(
|
||||
|
@ -111,11 +108,10 @@ class S3LambdaStorageTest extends TestCase
|
|||
|
||||
public function testDeleteSong(): void
|
||||
{
|
||||
$this->expectsEvents(LibraryChanged::class);
|
||||
|
||||
/** @var Song $song */
|
||||
$song = Song::factory()->create([
|
||||
'path' => 's3://foo/bar',
|
||||
'storage' => 's3-lambda',
|
||||
]);
|
||||
|
||||
$this->songRepository->shouldReceive('findOneByPath')
|
||||
|
|
Loading…
Reference in a new issue