feat: rework playlist cover upload/remove (#1806)

This commit is contained in:
Phan An 2024-07-26 16:33:14 +02:00 committed by GitHub
parent 0729f7d60e
commit 16a20dd554
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 144 additions and 104 deletions

View file

@ -30,7 +30,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
node-version: [ 18, 20, 21, 22 ] node-version: [ 18 ]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- uses: actions/setup-node@v2 - uses: actions/setup-node@v2

View file

@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\PlaylistCoverUpdateRequest;
use App\Models\Playlist;
use App\Services\MediaMetadataService;
class PlaylistCoverController extends Controller
{
public function __construct(private readonly MediaMetadataService $mediaMetadataService)
{
}
public function update(PlaylistCoverUpdateRequest $request, Playlist $playlist)
{
$this->authorize('own', $playlist);
$this->mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent());
return response()->json(['cover_url' => $playlist->cover]);
}
public function destroy(Playlist $playlist)
{
$this->authorize('own', $playlist);
$this->mediaMetadataService->deletePlaylistCover($playlist);
return response()->noContent();
}
}

View file

@ -1,22 +0,0 @@
<?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('own', $playlist);
$mediaMetadataService->writePlaylistCover($playlist, $request->getFileContent());
return response()->json(['cover_url' => $playlist->cover]);
}
}

View file

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

View file

@ -135,4 +135,12 @@ class MediaMetadataService
File::delete($album->cover_path); File::delete($album->cover_path);
File::delete($album->thumbnail_path); File::delete($album->thumbnail_path);
} }
public function deletePlaylistCover(Playlist $playlist): void
{
if ($playlist->cover_path) {
File::delete($playlist->cover_path);
$playlist->update(['cover' => null]);
}
}
} }

View file

@ -6,10 +6,12 @@
<template v-if="canShowCollaboration"> <template v-if="canShowCollaboration">
<li class="separator" /> <li class="separator" />
<li @click="showCollaborationModal">Collaborate</li> <li @click="showCollaborationModal">Collaborate</li>
<li class="separator" />
</template> </template>
<li v-if="canEditPlaylist" @click="edit">Edit</li> <template v-if="canEditPlaylist">
<li v-if="canEditPlaylist" @click="destroy">Delete</li> <li class="separator" />
<li @click="edit">Edit</li>
<li @click="destroy">Delete</li>
</template>
</ContextMenu> </ContextMenu>
</template> </template>

View file

@ -59,6 +59,8 @@ new class extends UnitTestCase {
private async renderComponent (songs: Playable[]) { private async renderComponent (songs: Playable[]) {
playlist = playlist || factory('playlist') playlist = playlist || factory('playlist')
this.be(factory('user', { id: playlist.user_id }))
playlistStore.init([playlist]) playlistStore.init([playlist])
playlist.playables = songs playlist.playables = songs

View file

@ -1,15 +1,27 @@
<template> <template>
<article <article
:class="{ droppable }"
class="cover relative w-full aspect-square block rounded-md overflow-hidden bg-no-repeat bg-cover bg-center" class="cover relative w-full aspect-square block rounded-md overflow-hidden bg-no-repeat bg-cover bg-center"
data-testid="playlist-thumbnail" data-testid="playlist-thumbnail"
@dragenter.prevent="onDragEnter"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@dragover.prevent
> >
<div class="pointer-events-none">
<slot /> <slot />
<div
v-if="canEditPlaylist"
class="absolute inset-0 w-full h-full bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity duration-200
flex items-center justify-center"
>
<div class="border border-1.5 border-white/20 rounded-md overflow-hidden">
<button class="p-2 hover:bg-black/50" title="Upload cover image" @click.prevent="uploadCover">
<Icon :icon="faUpload" class="text-white text-xl" fixed-width />
</button>
<button
v-if="playlist.cover"
class="p-2 hover:bg-black/30"
title="Remove cover image"
@click.prevent="removeCover"
>
<Icon :icon="faTrash" class="text-white text-xl" fixed-width />
</button>
</div>
</div> </div>
</article> </article>
</template> </template>
@ -18,74 +30,55 @@
import { computed, ref, toRefs } from 'vue' import { computed, ref, toRefs } from 'vue'
import { defaultCover } from '@/utils' import { defaultCover } from '@/utils'
import { playlistStore } from '@/stores' import { playlistStore } from '@/stores'
import { useAuthorization, useErrorHandler, useFileReader, useKoelPlus } from '@/composables' import { useErrorHandler, useFileReader, useKoelPlus, usePolicies } from '@/composables'
import { acceptedImageTypes } from '@/config' import { faTrash, faUpload } from '@fortawesome/free-solid-svg-icons'
const props = defineProps<{ playlist: Playlist }>() const props = defineProps<{ playlist: Playlist }>()
const { playlist } = toRefs(props) const { playlist } = toRefs(props)
const droppable = ref(false) const { currentUserCan } = usePolicies()
const { isAdmin, currentUser } = useAuthorization()
const { isPlus } = useKoelPlus() const { isPlus } = useKoelPlus()
const canEditPlaylist = computed(() => currentUserCan.editPlaylist(playlist.value!))
const backgroundImage = computed(() => `url(${playlist.value.cover || defaultCover})`) const backgroundImage = computed(() => `url(${playlist.value.cover || defaultCover})`)
const allowsUpload = computed(() => (isAdmin.value || isPlus.value) && playlist.value.user_id === currentUser.value.id) const uploadCover = () => {
const onDragEnter = () => (droppable.value = allowsUpload.value) const input = document.createElement('input')
const onDragLeave = () => (droppable.value = false) input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
const validImageDropEvent = (event: DragEvent) => { input.addEventListener('change', async () => {
if (!event.dataTransfer || !event.dataTransfer.items) { const file = input.files?.[0]
return false
}
if (event.dataTransfer.items.length !== 1) { if (!file) {
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 return
} }
const backupImage = playlist.value.cover const backupImage = playlist.value.cover
try { try {
useFileReader().readAsDataUrl(event.dataTransfer!.files[0], async url => { useFileReader().readAsDataUrl(file, async url => {
// Replace the image right away to create an "instant" effect playlist.value!.cover = url
playlist.value.cover = url
await playlistStore.uploadCover(playlist.value, url) await playlistStore.uploadCover(playlist.value, url)
toastSuccess('Playlist cover updated.')
}) })
} catch (error: unknown) { } catch (error: unknown) {
// restore the backup image // restore the backup image
playlist.value.cover = backupImage playlist.value.cover = backupImage
useErrorHandler().handleHttpError(error) useErrorHandler().handleHttpError(error)
} }
})
input.dispatchEvent(new MouseEvent('click'))
} }
const removeCover = async () => await playlistStore.removeCover(playlist.value)
</script> </script>
<style lang="postcss" scoped> <style lang="postcss" scoped>
article { article {
background-image: v-bind(backgroundImage); background-image: v-bind(backgroundImage);
&.droppable {
@apply border-2 border-dotted border-white brightness-50;
}
.thumbnail-stack { .thumbnail-stack {
@apply pointer-events-none; @apply pointer-events-none;
} }

View file

@ -195,5 +195,10 @@ export const playlistStore = {
this.byId(playlist.id)!.cover = playlist.cover this.byId(playlist.id)!.cover = playlist.cover
return playlist.cover return playlist.cover
},
async removeCover (playlist: Playlist) {
playlist.cover = null
await http.delete(`playlists/${playlist.id}/cover`)
} }
} }

View file

@ -31,6 +31,7 @@ use App\Http\Controllers\API\PlaylistCollaboration\AcceptPlaylistCollaborationIn
use App\Http\Controllers\API\PlaylistCollaboration\CreatePlaylistCollaborationTokenController; use App\Http\Controllers\API\PlaylistCollaboration\CreatePlaylistCollaborationTokenController;
use App\Http\Controllers\API\PlaylistCollaboration\PlaylistCollaboratorController; use App\Http\Controllers\API\PlaylistCollaboration\PlaylistCollaboratorController;
use App\Http\Controllers\API\PlaylistController; use App\Http\Controllers\API\PlaylistController;
use App\Http\Controllers\API\PlaylistCoverController;
use App\Http\Controllers\API\PlaylistFolderController; use App\Http\Controllers\API\PlaylistFolderController;
use App\Http\Controllers\API\PlaylistFolderPlaylistController; use App\Http\Controllers\API\PlaylistFolderPlaylistController;
use App\Http\Controllers\API\PlaylistSongController; use App\Http\Controllers\API\PlaylistSongController;
@ -57,7 +58,6 @@ use App\Http\Controllers\API\UpdateUserPreferenceController;
use App\Http\Controllers\API\UploadAlbumCoverController; use App\Http\Controllers\API\UploadAlbumCoverController;
use App\Http\Controllers\API\UploadArtistImageController; use App\Http\Controllers\API\UploadArtistImageController;
use App\Http\Controllers\API\UploadController; use App\Http\Controllers\API\UploadController;
use App\Http\Controllers\API\UploadPlaylistCoverController;
use App\Http\Controllers\API\UserController; use App\Http\Controllers\API\UserController;
use App\Http\Controllers\API\UserInvitationController; use App\Http\Controllers\API\UserInvitationController;
use App\Models\Song; use App\Models\Song;
@ -174,7 +174,8 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::put('albums/{album}/cover', UploadAlbumCoverController::class); Route::put('albums/{album}/cover', UploadAlbumCoverController::class);
Route::get('albums/{album}/thumbnail', FetchAlbumThumbnailController::class); Route::get('albums/{album}/thumbnail', FetchAlbumThumbnailController::class);
Route::put('artists/{artist}/image', UploadArtistImageController::class); Route::put('artists/{artist}/image', UploadArtistImageController::class);
Route::put('playlists/{playlist}/cover', UploadPlaylistCoverController::class); Route::put('playlists/{playlist}/cover', [PlaylistCoverController::class, 'update']);
Route::delete('playlists/{playlist}/cover', [PlaylistCoverController::class, 'destroy']);
// deprecated routes // deprecated routes
Route::put('album/{album}/cover', UploadAlbumCoverController::class); Route::put('album/{album}/cover', UploadAlbumCoverController::class);
Route::get('album/{album}/thumbnail', FetchAlbumThumbnailController::class); Route::get('album/{album}/thumbnail', FetchAlbumThumbnailController::class);

View file

@ -19,4 +19,15 @@ class PlaylistCoverTest extends PlusTestCase
$this->putAs("api/playlists/$playlist->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $collaborator) $this->putAs("api/playlists/$playlist->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $collaborator)
->assertForbidden(); ->assertForbidden();
} }
public function testCollaboratorCannotDeleteCover(): void
{
/** @var Playlist $playlist */
$playlist = Playlist::factory()->create();
$collaborator = create_user();
$playlist->addCollaborator($collaborator);
$this->deleteAs("api/playlists/$playlist->id/cover", [], $collaborator)
->assertForbidden();
}
} }

View file

@ -3,45 +3,54 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\Playlist; use App\Models\Playlist;
use App\Services\MediaMetadataService;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase; use Tests\TestCase;
use function Tests\create_user; use function Tests\create_user;
use function Tests\read_as_data_url;
use function Tests\test_path;
class PlaylistCoverTest extends TestCase class PlaylistCoverTest extends TestCase
{ {
private MockInterface|MediaMetadataService $mediaMetadataService;
public function setUp(): void
{
parent::setUp();
$this->mediaMetadataService = self::mock(MediaMetadataService::class);
}
public function testUploadCover(): void public function testUploadCover(): void
{ {
$playlist = Playlist::factory()->create(); $playlist = Playlist::factory()->create();
self::assertNull($playlist->cover); self::assertNull($playlist->cover);
$this->mediaMetadataService $this->putAs(
->shouldReceive('writePlaylistCover') "api/playlists/$playlist->id/cover",
->once() ['cover' => read_as_data_url(test_path('blobs/cover.png'))],
->with(Mockery::on(static fn (Playlist $target) => $target->is($playlist)), 'data:image/jpeg;base64,Rm9v'); $playlist->user
)
$this->putAs("api/playlists/$playlist->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], $playlist->user)
->assertOk(); ->assertOk();
self::assertNotNull($playlist->refresh()->cover);
} }
public function testUploadCoverNotAllowedForNonOwner(): void public function testUploadCoverNotAllowedForNonOwner(): void
{ {
$playlist = Playlist::factory()->create(); $playlist = Playlist::factory()->create();
$this->mediaMetadataService->shouldNotReceive('writePlaylistCover');
$this->putAs("api/playlists/$playlist->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], create_user()) $this->putAs("api/playlists/$playlist->id/cover", ['cover' => 'data:image/jpeg;base64,Rm9v'], create_user())
->assertForbidden(); ->assertForbidden();
} }
public function testDeleteCover(): void
{
$playlist = Playlist::factory()->create(['cover' => 'cover.jpg']);
$this->deleteAs("api/playlists/$playlist->id/cover", [], $playlist->user)
->assertNoContent();
self::assertNull($playlist->refresh()->cover);
}
public function testNonOwnerCannotDeleteCover(): void
{
$playlist = Playlist::factory()->create(['cover' => 'cover.jpg']);
$this->deleteAs("api/playlists/$playlist->id/cover", [], create_user())
->assertForbidden();
self::assertSame('cover.jpg', $playlist->refresh()->getRawOriginal('cover'));
}
} }