mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: add upload feature
This commit is contained in:
parent
16f802301f
commit
e6eb28ba2d
15 changed files with 424 additions and 6 deletions
28
app/Console/Commands/TidyLibraryCommand.php
Normal file
28
app/Console/Commands/TidyLibraryCommand.php
Normal 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.');
|
||||
}
|
||||
}
|
7
app/Events/MediaCacheObsolete.php
Normal file
7
app/Events/MediaCacheObsolete.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
class MediaCacheObsolete extends Event
|
||||
{
|
||||
}
|
9
app/Exceptions/MediaPathNotSetException.php
Normal file
9
app/Exceptions/MediaPathNotSetException.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class MediaPathNotSetException extends Exception
|
||||
{
|
||||
}
|
9
app/Exceptions/SongUploadFailedException.php
Normal file
9
app/Exceptions/SongUploadFailedException.php
Normal file
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class SongUploadFailedException extends Exception
|
||||
{
|
||||
}
|
37
app/Http/Controllers/API/UploadController.php
Normal file
37
app/Http/Controllers/API/UploadController.php
Normal 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'),
|
||||
]);
|
||||
}
|
||||
}
|
26
app/Http/Requests/API/UploadRequest.php
Normal file
26
app/Http/Requests/API/UploadRequest.php
Normal 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'
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
|
@ -32,7 +32,7 @@ class Setting extends Model
|
|||
return $record->value;
|
||||
}
|
||||
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,7 +26,7 @@ class MediaSyncService
|
|||
*
|
||||
* @var array
|
||||
*/
|
||||
private const APPLICABLE_TAGS = [
|
||||
public const APPLICABLE_TAGS = [
|
||||
'artist',
|
||||
'album',
|
||||
'title',
|
||||
|
|
82
app/Services/UploadService.php
Normal file
82
app/Services/UploadService.php
Normal 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
|
|
@ -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');
|
||||
|
|
96
tests/Feature/UploadTest.php
Normal file
96
tests/Feature/UploadTest.php
Normal 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',
|
||||
]]);
|
||||
}
|
||||
}
|
112
tests/Unit/Services/UploadServiceTest.php
Normal file
112
tests/Unit/Services/UploadServiceTest.php
Normal 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));
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue