feat: add upload feature

This commit is contained in:
Phan An 2020-06-07 22:43:04 +02:00
parent 16f802301f
commit e6eb28ba2d
15 changed files with 424 additions and 6 deletions

View file

@ -0,0 +1,28 @@
<?php
namespace App\Console\Commands;
use App\Events\LibraryChanged;
use App\Services\MediaSyncService;
use Illuminate\Console\Command;
class TidyLibraryCommand extends Command
{
protected $signature = 'koel:tidy';
protected $description = 'Tidy up the library by deleting empty artists and albums';
private $mediaSyncService;
public function __construct(MediaSyncService $mediaSyncService)
{
parent::__construct();
$this->mediaSyncService = $mediaSyncService;
}
public function handle(): void
{
$this->mediaSyncService->tidy();
event(new LibraryChanged());
$this->info('Empty artists and albums removed.');
}
}

View file

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

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class MediaPathNotSetException extends Exception
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class SongUploadFailedException extends Exception
{
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\API;
use App\Events\MediaCacheObsolete;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Http\Requests\API\UploadRequest;
use App\Services\UploadService;
use Illuminate\Http\JsonResponse;
class UploadController extends Controller
{
private $uploadService;
public function __construct(UploadService $uploadService)
{
$this->uploadService = $uploadService;
}
public function store(UploadRequest $request): JsonResponse
{
try {
$song = $this->uploadService->handleUploadedFile($request->file);
} catch (MediaPathNotSetException $e) {
abort(403, $e->getMessage());
} catch (SongUploadFailedException $e) {
abort(400, $e->getMessage());
}
event(new MediaCacheObsolete());
return response()->json([
'song' => $song->load('album', 'artist'),
]);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\API;
use App\Http\Requests\AbstractRequest;
use Illuminate\Http\UploadedFile;
/** @property-read UploadedFile $file */
class UploadRequest extends AbstractRequest
{
public function authorize(): bool
{
return auth()->user()->is_admin;
}
public function rules(): array
{
return [
'file' => [
'required',
'file',
'mimetypes:audio/mpeg,audio/ogg,audio/x-flac,audio/x-aac'
],
];
}
}

View file

@ -32,7 +32,7 @@ class Setting extends Model
return $record->value;
}
return '';
return null;
}
/**

View file

@ -5,6 +5,7 @@ namespace App\Providers;
use App\Events\AlbumInformationFetched;
use App\Events\ArtistInformationFetched;
use App\Events\LibraryChanged;
use App\Events\MediaCacheObsolete;
use App\Events\SongLikeToggled;
use App\Events\SongStartedPlaying;
use App\Listeners\ClearMediaCache;
@ -40,6 +41,10 @@ class EventServiceProvider extends ServiceProvider
ClearMediaCache::class,
],
MediaCacheObsolete::class => [
ClearMediaCache::class,
],
AlbumInformationFetched::class => [
DownloadAlbumCover::class,
],

View file

@ -16,9 +16,9 @@ use Symfony\Component\Finder\Finder;
class FileSynchronizer
{
const SYNC_RESULT_SUCCESS = 1;
const SYNC_RESULT_BAD_FILE = 2;
const SYNC_RESULT_UNMODIFIED = 3;
public const SYNC_RESULT_SUCCESS = 1;
public const SYNC_RESULT_BAD_FILE = 2;
public const SYNC_RESULT_UNMODIFIED = 3;
private $getID3;
private $mediaMetadataService;
@ -356,4 +356,9 @@ class FileSynchronizer
// Also, the latter is only valid if the value is NOT the same as "artist".
return $props['albumartist'] && $props['artist'] !== $props['albumartist'];
}
public function getSong(): ?Song
{
return $this->song;
}
}

View file

@ -26,7 +26,7 @@ class MediaSyncService
*
* @var array
*/
private const APPLICABLE_TAGS = [
public const APPLICABLE_TAGS = [
'artist',
'album',
'title',

View file

@ -0,0 +1,82 @@
<?php
namespace App\Services;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use Illuminate\Http\UploadedFile;
class UploadService
{
private const UPLOAD_DIRECTORY = '__KOEL_UPLOADS__';
private $fileSynchronizer;
public function __construct(FileSynchronizer $fileSynchronizer)
{
$this->fileSynchronizer = $fileSynchronizer;
}
/**
* @throws MediaPathNotSetException
* @throws SongUploadFailedException
*/
public function handleUploadedFile(UploadedFile $file): Song
{
$targetFileName = $this->getTargetFileName($file);
$file->move($this->getUploadDirectory(), $targetFileName);
$targetPathName = $this->getUploadDirectory() . $targetFileName;
$this->fileSynchronizer->setFile($targetPathName);
$result = $this->fileSynchronizer->sync(MediaSyncService::APPLICABLE_TAGS);
if ($result !== FileSynchronizer::SYNC_RESULT_SUCCESS) {
@unlink($targetPathName);
throw new SongUploadFailedException($this->fileSynchronizer->getSyncError());
}
return $this->fileSynchronizer->getSong();
}
/**
* @throws MediaPathNotSetException
*/
private function getUploadDirectory(): string
{
static $uploadDirectory;
if (!$uploadDirectory) {
$mediaPath = Setting::get('media_path');
if (!$mediaPath) {
throw new MediaPathNotSetException();
}
$uploadDirectory = $mediaPath . DIRECTORY_SEPARATOR . self::UPLOAD_DIRECTORY . DIRECTORY_SEPARATOR;
}
return $uploadDirectory;
}
/**
* @throws MediaPathNotSetException
*/
private function getTargetFileName(UploadedFile $file): string
{
// If there's no existing file with the same name in the upload directory, use the original name.
// Otherwise, prefix the original name with a hash.
// The whole point is to keep a readable file name when we can.
if (!file_exists($this->getUploadDirectory() . $file->getClientOriginalName())) {
return $file->getClientOriginalName();
}
return $this->getUniqueHash() . '_' . $file->getClientOriginalName();
}
private function getUniqueHash(): string
{
return substr(sha1(uniqid()), 0, 6);
}
}

@ -1 +1 @@
Subproject commit 73bd6bb652579f33b9e8c350e7cc55081d5037fc
Subproject commit 5e5a6e5578bf3e56b91b570003463d3cb219e770

View file

@ -35,6 +35,8 @@ Route::group(['namespace' => 'API'], function () {
]);
Route::put('songs', 'SongController@update');
Route::resource('upload', 'UploadController');
// Interaction routes
Route::post('interaction/play', 'Interaction\PlayCountController@store');
Route::post('interaction/like', 'Interaction\LikeController@store');

View file

@ -0,0 +1,96 @@
<?php
namespace Tests\Feature;
use App\Events\MediaCacheObsolete;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use App\Models\User;
use App\Services\UploadService;
use Illuminate\Http\UploadedFile;
use Mockery\MockInterface;
class UploadTest extends TestCase
{
/**
* @var MockInterface
*/
private $uploadService;
public function setUp(): void
{
parent::setUp();
$this->uploadService = $this->mockIocDependency(UploadService::class);
}
public function testUnauthorizedPost(): void
{
Setting::set('media_path', '/media/koel');
$this->doesntExpectEvents(MediaCacheObsolete::class);
$file = UploadedFile::fake()->create('foo.mp3', 2048);
$this->uploadService
->shouldReceive('handleUploadedFile')
->never();
$this->postAsUser(
'/api/upload',
['file' => $file],
factory(User::class)->create()
)->seeStatusCode(403);
}
public function provideUploadExceptions(): array
{
return [
[MediaPathNotSetException::class, 403],
[SongUploadFailedException::class, 400],
];
}
/**
* @dataProvider provideUploadExceptions
*/
public function testPostShouldFail(string $exceptionClass, int $statusCode): void
{
$this->doesntExpectEvents(MediaCacheObsolete::class);
$file = UploadedFile::fake()->create('foo.mp3', 2048);
$this->uploadService
->shouldReceive('handleUploadedFile')
->once()
->with($file)
->andThrow($exceptionClass);
$this->postAsUser(
'/api/upload',
['file' => $file],
factory(User::class, 'admin')->create()
)->seeStatusCode($statusCode);
}
public function testPost(): void
{
Setting::set('media_path', '/media/koel');
$this->expectsEvents(MediaCacheObsolete::class);
$file = UploadedFile::fake()->create('foo.mp3', 2048);
/** @var Song $song */
$song = factory(Song::class)->create();
$this->uploadService
->shouldReceive('handleUploadedFile')
->once()
->with($file)
->andReturn($song);
$this->postAsUser(
'/api/upload',
['file' => $file],
factory(User::class, 'admin')->create()
)->seeJsonStructure(['song' => [
'album',
'artist',
]]);
}
}

View file

@ -0,0 +1,112 @@
<?php
namespace Tests\Unit\Services;
use App\Exceptions\MediaPathNotSetException;
use App\Exceptions\SongUploadFailedException;
use App\Models\Setting;
use App\Models\Song;
use App\Services\FileSynchronizer;
use App\Services\MediaSyncService;
use App\Services\UploadService;
use Illuminate\Http\UploadedFile;
use Mockery;
use Mockery\MockInterface;
use Tests\TestCase;
class UploadServiceTest extends TestCase
{
/**
* @var FileSynchronizer|MockInterface
*/
private $fileSynchronizer;
/**
* @var UploadService
*/
private $uploadService;
public function setUp(): void
{
parent::setUp();
$this->fileSynchronizer = Mockery::mock(FileSynchronizer::class);
$this->uploadService = new UploadService($this->fileSynchronizer);
}
public function testHandleUploadedFileWithMediaPathNotSet(): void
{
Setting::set('media_path', null);
self::expectException(MediaPathNotSetException::class);
$this->uploadService->handleUploadedFile(Mockery::mock(UploadedFile::class));
}
public function testHandleUploadedFileFails(): void
{
Setting::set('media_path', '/media/koel');
/** @var UploadedFile|MockInterface $file */
$file = Mockery::mock(UploadedFile::class);
$file->shouldReceive('getClientOriginalName')
->andReturn('foo.mp3');
$file->shouldReceive('move')
->once()
->with('/media/koel/__KOEL_UPLOADS__/', 'foo.mp3');
$this->fileSynchronizer
->shouldReceive('setFile')
->once()
->with('/media/koel/__KOEL_UPLOADS__/foo.mp3');
$this->fileSynchronizer
->shouldReceive('sync')
->once()
->with(MediaSyncService::APPLICABLE_TAGS)
->andReturn(FileSynchronizer::SYNC_RESULT_BAD_FILE);
$this->fileSynchronizer
->shouldReceive('getSyncError')
->once()
->andReturn('A monkey ate your file oh no');
self::expectException(SongUploadFailedException::class);
self::expectExceptionMessage('A monkey ate your file oh no');
$this->uploadService->handleUploadedFile($file);
}
public function testHandleUploadedFile(): void
{
Setting::set('media_path', '/media/koel');
/** @var UploadedFile|MockInterface $file */
$file = Mockery::mock(UploadedFile::class);
$file->shouldReceive('getClientOriginalName')
->andReturn('foo.mp3');
$file->shouldReceive('move')
->once()
->with('/media/koel/__KOEL_UPLOADS__/', 'foo.mp3');
$this->fileSynchronizer
->shouldReceive('setFile')
->once()
->with('/media/koel/__KOEL_UPLOADS__/foo.mp3');
$this->fileSynchronizer
->shouldReceive('sync')
->once()
->with(MediaSyncService::APPLICABLE_TAGS)
->andReturn(FileSynchronizer::SYNC_RESULT_SUCCESS);
$song = new Song();
$this->fileSynchronizer
->shouldReceive('getSong')
->once()
->andReturn($song);
self::assertSame($song, $this->uploadService->handleUploadedFile($file));
}
}