feat: custom cover for playlists

This commit is contained in:
Phan An 2024-02-24 22:37:01 +07:00
parent 52285a1c48
commit 302f2a84d0
69 changed files with 477 additions and 144 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

@ -2,7 +2,7 @@
namespace App\Http\Requests\API;
/** @property string $cover */
/** @property-read string $cover */
class UploadAlbumCoverRequest extends MediaImageUpdateRequest
{
protected function getImageFieldName(): string

View file

@ -2,7 +2,7 @@
namespace App\Http\Requests\API;
/** @property string $image */
/** @property-read string $image */
class UploadArtistImageRequest extends MediaImageUpdateRequest
{
protected function getImageFieldName(): string

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

@ -11,6 +11,8 @@ img/covers/*
!img/artists
img/artists/*
!img/artists/.gitkeep
img/playlists/*
!img/playlists/.gitkeep
images
js

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 its still part of a collaborative playlist.
</p>

View file

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

View file

@ -90,5 +90,11 @@ li {
flex: 0 0 72px;
text-align: right;
}
&:only-child {
.actions:not(:has(button)) {
display: none;
}
}
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1 @@
export const acceptedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -237,6 +237,7 @@ interface Playlist {
is_collaborative: boolean
rules: SmartPlaylistRuleGroup[]
own_songs_only: boolean
cover: string | null
songs?: Song[]
}

View file

@ -164,6 +164,10 @@ label {
}
}
.pointer-events-none {
pointer-events: none;
}
.tabs {
display: flex;
flex-direction: column;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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