diff --git a/app/Album.php b/app/Album.php index 7576efb4..e48d88ad 100644 --- a/app/Album.php +++ b/app/Album.php @@ -37,6 +37,7 @@ class Album extends Model use SoftDeletes, SlugTrait, DispatchesJobs, TrackCollection, RevisionableTrait; protected $dates = ['deleted_at']; + protected $fillable = ['user_id', 'title', 'slug']; public static function summary() { diff --git a/app/Commands/UploadTrackCommand.php b/app/Commands/UploadTrackCommand.php index da2a090e..be33fb3d 100644 --- a/app/Commands/UploadTrackCommand.php +++ b/app/Commands/UploadTrackCommand.php @@ -20,16 +20,20 @@ namespace Poniverse\Ponyfm\Commands; +use Carbon\Carbon; use Config; use Illuminate\Foundation\Bus\DispatchesJobs; +use Input; +use Poniverse\Ponyfm\Album; use Poniverse\Ponyfm\Exceptions\InvalidEncodeOptionsException; +use Poniverse\Ponyfm\Genre; use Poniverse\Ponyfm\Jobs\EncodeTrackFile; use Poniverse\Ponyfm\Track; use Poniverse\Ponyfm\TrackFile; use AudioCache; use File; use Illuminate\Support\Str; -use Storage; +use Poniverse\Ponyfm\TrackType; class UploadTrackCommand extends CommandBase { @@ -38,6 +42,9 @@ class UploadTrackCommand extends CommandBase private $_allowLossy; private $_allowShortTrack; + private $_customTrackSource; + private $_autoPublishByDefault; + private $_losslessFormats = [ 'flac', 'pcm_s16le ([1][0][0][0] / 0x0001)', @@ -49,10 +56,12 @@ class UploadTrackCommand extends CommandBase 'pcm_f32be (fl32 / 0x32336C66)' ]; - public function __construct($allowLossy = false, $allowShortTrack = false) + public function __construct($allowLossy = false, $allowShortTrack = false, $customTrackSource = null, $autoPublishByDefault = false) { $this->_allowLossy = $allowLossy; $this->_allowShortTrack = $allowShortTrack; + $this->_customTrackSource = $customTrackSource; + $this->_autoPublishByDefault = $autoPublishByDefault; } /** @@ -63,6 +72,30 @@ class UploadTrackCommand extends CommandBase return \Auth::user() != null; } + protected function getGenreId(string $genreName) { + return Genre::firstOrCreate(['name' => $genreName, 'slug' => Str::slug($genreName)])->id; + } + + protected function getAlbumId(int $artistId, $albumName) { + if (null !== $albumName) { + $album = Album::firstOrNew([ + 'user_id' => $artistId, + 'title' => $albumName + ]); + + if (null === $album->id) { + $album->description = ''; + $album->track_count = 0; + $album->save(); + return $album->id; + } else { + return $album->id; + } + } else { + return null; + } + } + /** * @throws \Exception * @return CommandResponse @@ -81,9 +114,9 @@ class UploadTrackCommand extends CommandBase $track = new Track(); $track->user_id = $user->id; - $track->title = pathinfo($trackFile->getClientOriginalName(), PATHINFO_FILENAME); + $track->title = Input::get('title', pathinfo($trackFile->getClientOriginalName(), PATHINFO_FILENAME)); $track->duration = $audio->getDuration(); - $track->is_listed = true; + $track->save(); $track->ensureDirectoryExists(); @@ -93,13 +126,30 @@ class UploadTrackCommand extends CommandBase } $trackFile = $trackFile->move(Config::get('ponyfm.files_directory').'/queued-tracks', $track->id); + $input = Input::all(); + $input['track'] = $trackFile; - $validator = \Validator::make(['track' => $trackFile], [ + $validator = \Validator::make($input, [ 'track' => 'required|' . ($this->_allowLossy ? '' : 'audio_format:'. implode(',', $this->_losslessFormats).'|') . ($this->_allowShortTrack ? '' : 'min_duration:30|') - . 'audio_channels:1,2' + . 'audio_channels:1,2', + + 'auto_publish' => 'boolean', + 'title' => 'string', + 'track_type_id' => 'exists:track_types,id', + 'genre' => 'string', + 'album' => 'string', + 'track_number' => 'integer', + 'released_at' => 'date_format:'.Carbon::ISO8601, + 'description' => 'string', + 'lyrics' => 'string', + 'is_vocal' => 'boolean', + 'is_explicit' => 'boolean', + 'is_downloadable' => 'boolean', + 'is_listed' => 'boolean', + 'metadata' => 'json', ]); if ($validator->fails()) { @@ -108,6 +158,32 @@ class UploadTrackCommand extends CommandBase } + // Process optional track fields + $autoPublish = (bool) Input::get('auto_publish', $this->_autoPublishByDefault); + + $track->title = Input::get('title', $track->title); + $track->track_type_id = Input::get('track_type_id', TrackType::UNCLASSIFIED_TRACK); + $track->genre_id = $this->getGenreId(Input::get('genre', 'Unknown')); + $track->album_id = $this->getAlbumId($user->id, Input::get('album', null)); + $track->track_number = $track->album_id !== null ? (int) Input::get('track_number', null) : null; + $track->released_at = Input::has('released_at') ? Carbon::createFromFormat(Carbon::ISO8601, Input::get('released_at')) : null; + $track->description = Input::get('description', ''); + $track->lyrics = Input::get('lyrics', ''); + $track->is_vocal = (bool) Input::get('is_vocal'); + $track->is_explicit = (bool) Input::get('is_explicit'); + $track->is_downloadable = (bool) Input::get('is_downloadable'); + $track->is_listed = (bool) Input::get('is_listed', true); + + $track->source = $this->_customTrackSource ?? 'direct_upload'; + + // If json_decode() isn't called here, Laravel will surround the JSON + // string with quotes when storing it in the database, which breaks things. + $track->metadata = json_decode(Input::get('metadata', null)); + + $track->save(); + + + try { $source = $trackFile->getPathname(); @@ -172,7 +248,7 @@ class UploadTrackCommand extends CommandBase try { foreach($trackFiles as $trackFile) { - $this->dispatch(new EncodeTrackFile($trackFile, false, true)); + $this->dispatch(new EncodeTrackFile($trackFile, false, true, $autoPublish)); } } catch (InvalidEncodeOptionsException $e) { @@ -187,7 +263,10 @@ class UploadTrackCommand extends CommandBase return CommandResponse::succeed([ 'id' => $track->id, - 'name' => $track->name + 'name' => $track->name, + 'title' => $track->title, + 'slug' => $track->slug, + 'autoPublish' => $autoPublish, ]); } } diff --git a/app/Http/Controllers/Api/V1/TracksController.php b/app/Http/Controllers/Api/V1/TracksController.php index c53df319..9c1bd4cb 100644 --- a/app/Http/Controllers/Api/V1/TracksController.php +++ b/app/Http/Controllers/Api/V1/TracksController.php @@ -31,7 +31,7 @@ class TracksController extends ApiControllerBase public function postUploadTrack() { session_write_close(); - $response = $this->execute(new UploadTrackCommand()); + $response = $this->execute(new UploadTrackCommand(true, true, session('api_client_id'), true)); $commandData = $response->getData(true); if (200 !== $response->getStatusCode()) { @@ -39,9 +39,12 @@ class TracksController extends ApiControllerBase } $data = [ - 'id' => $commandData['id'], + 'id' => (string) $commandData['id'], 'status_url' => action('Api\V1\TracksController@getUploadStatus', ['id' => $commandData['id']]), - 'message' => "This track has been accepted for processing! Poll the status_url to know when it's ready to publish.", + 'track_url' => action('TracksController@getTrack', ['id' => $commandData['id'], 'slug' => $commandData['slug']]), + 'message' => $commandData['autoPublish'] + ? "This track has been accepted for processing! Poll the status_url to know when it has been published. It will be published at the track_url." + : "This track has been accepted for processing! Poll the status_url to know when it's ready to publish. It will be published at the track_url.", ]; $response->setData($data); @@ -59,13 +62,16 @@ class TracksController extends ApiControllerBase } elseif ($track->status === Track::STATUS_COMPLETE) { return Response::json([ - 'message' => 'Processing complete! The artist must publish the track by visiting its edit_url.', - 'edit_url' => action('ContentController@getTracks', ['id' => $trackId]) + 'message' => $track->published_at + ? 'Processing complete! The track is live at the track_url. The artist can edit the track by visiting its edit_url.' + : 'Processing complete! The artist must publish the track by visiting its edit_url.', + 'edit_url' => action('ContentController@getTracks', ['id' => $trackId]), + 'track_url' => $track->url ], 201); } else { // something went wrong - return Response::json(['error' => 'Processing failed!'], 500); + return Response::json(['error' => 'Processing failed! Please contact feld0@poniverse.net to figure out what went wrong.'], 500); } } diff --git a/app/Http/Controllers/Api/Web/TracksController.php b/app/Http/Controllers/Api/Web/TracksController.php index 9db79679..78e27950 100644 --- a/app/Http/Controllers/Api/Web/TracksController.php +++ b/app/Http/Controllers/Api/Web/TracksController.php @@ -41,12 +41,7 @@ class TracksController extends ApiControllerBase { session_write_close(); - try { - return $this->execute(new UploadTrackCommand()); - - } catch (InvalidEncodeOptionsException $e) { - - } + return $this->execute(new UploadTrackCommand()); } public function getUploadStatus($trackId) diff --git a/app/Http/Middleware/AuthenticateOAuth.php b/app/Http/Middleware/AuthenticateOAuth.php index b7506cc7..bd3c75a4 100644 --- a/app/Http/Middleware/AuthenticateOAuth.php +++ b/app/Http/Middleware/AuthenticateOAuth.php @@ -65,6 +65,8 @@ class AuthenticateOAuth // Log in as the given user, creating the account if necessary. $this->poniverse->setAccessToken($accessToken); + session()->put('api_client_id', $accessTokenInfo->getClientId()); + $poniverseUser = $this->poniverse->getUser(); $user = User::findOrCreate($poniverseUser['username'], $poniverseUser['display_name'], $poniverseUser['email']); diff --git a/app/Http/routes.php b/app/Http/routes.php index 838c6c7b..165ec426 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -39,7 +39,7 @@ Route::get('/tracks/popular', 'TracksController@getIndex'); Route::get('/tracks/random', 'TracksController@getIndex'); Route::get('tracks/{id}-{slug}', 'TracksController@getTrack'); -Route::get('t{id}', 'TracksController@getShortlink' ); +Route::get('t{id}', 'TracksController@getShortlink' )->where('id', '\d+'); Route::get('t{id}/embed', 'TracksController@getEmbed' ); Route::get('t{id}/stream.{extension}', 'TracksController@getStream' ); Route::get('t{id}/dl.{extension}', 'TracksController@getDownload' ); diff --git a/app/Jobs/EncodeTrackFile.php b/app/Jobs/EncodeTrackFile.php index fc53c34a..a54b68e7 100644 --- a/app/Jobs/EncodeTrackFile.php +++ b/app/Jobs/EncodeTrackFile.php @@ -22,6 +22,7 @@ namespace Poniverse\Ponyfm\Jobs; use Carbon\Carbon; +use DB; use File; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; @@ -52,14 +53,19 @@ class EncodeTrackFile extends Job implements SelfHandling, ShouldQueue * @var bool */ private $isForUpload; + /** + * @var bool + */ + private $autoPublishWhenComplete; /** * Create a new job instance. * @param TrackFile $trackFile * @param bool $isExpirable * @param bool $isForUpload indicates whether this encode job is for an upload + * @param bool $autoPublish */ - public function __construct(TrackFile $trackFile, $isExpirable, $isForUpload = false) + public function __construct(TrackFile $trackFile, $isExpirable, $isForUpload = false, $autoPublish = false) { if( (!$isForUpload && $trackFile->is_master) || @@ -71,6 +77,7 @@ class EncodeTrackFile extends Job implements SelfHandling, ShouldQueue $this->trackFile = $trackFile; $this->isExpirable = $isExpirable; $this->isForUpload = $isForUpload; + $this->autoPublishWhenComplete = $autoPublish; } /** @@ -140,7 +147,16 @@ class EncodeTrackFile extends Job implements SelfHandling, ShouldQueue File::delete($this->trackFile->getFile()); } + // This was the final TrackFile for this track! if ($this->trackFile->track->status === Track::STATUS_COMPLETE) { + if ($this->autoPublishWhenComplete) { + $this->trackFile->track->published_at = Carbon::now(); + DB::table('tracks')->whereUserId($this->trackFile->track->user_id)->update(['is_latest' => false]); + + $this->trackFile->track->is_latest = true; + $this->trackFile->track->save(); + } + File::delete($this->trackFile->track->getTemporarySourceFile()); } } diff --git a/app/Library/Poniverse/Poniverse.php b/app/Library/Poniverse/Poniverse.php index d0ec4250..b6a5ef90 100644 --- a/app/Library/Poniverse/Poniverse.php +++ b/app/Library/Poniverse/Poniverse.php @@ -122,7 +122,7 @@ class Poniverse { * * @param $accessTokenToIntrospect * @return \Poniverse\AccessTokenInfo - * @throws \Poniverse\Ponyfm\InvalidAccessTokenException + * @throws InvalidAccessTokenException * @throws \Symfony\Component\HttpKernel\Exception\HttpException */ public function getAccessTokenInfo($accessTokenToIntrospect) @@ -154,7 +154,8 @@ class Poniverse { $tokenInfo = new \Poniverse\AccessTokenInfo($accessTokenToIntrospect); $tokenInfo ->setIsActive($data['active']) - ->setScopes($data['scope']); + ->setScopes($data['scope']) + ->setClientId($data['client_id']); return $tokenInfo; } diff --git a/app/Track.php b/app/Track.php index 2c25ed11..ef6b37d2 100644 --- a/app/Track.php +++ b/app/Track.php @@ -41,6 +41,7 @@ class Track extends Model use SoftDeletes; protected $dates = ['deleted_at', 'published_at', 'released_at']; + protected $hidden = ['original_tags', 'metadata']; protected $casts = [ 'id' => 'integer', 'user_id' => 'integer', @@ -53,6 +54,8 @@ class Track extends Model 'is_downloadable' => 'boolean', 'is_latest' => 'boolean', 'is_listed' => 'boolean', + 'original_tags' => 'array', + 'metadata' => 'array', ]; use SlugTrait { diff --git a/composer.json b/composer.json index f91a28e5..e1aa7a46 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "license": "AGPL", "type": "project", "require": { - "php": ">=5.5.9", + "php": ">=7.0.1", "laravel/framework": "5.1.*", "codescale/ffmpeg-php": "2.7.0", "kriswallsmith/assetic": "1.2.*@dev", diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php index 1c8381ae..b49765a0 100644 --- a/database/factories/ModelFactory.php +++ b/database/factories/ModelFactory.php @@ -29,15 +29,39 @@ | */ -$factory->define(Poniverse\Ponyfm\User::class, function ($faker) { +use Poniverse\Ponyfm\User; + +$factory->define(Poniverse\Ponyfm\User::class, function (\Faker\Generator $faker) { return [ 'username' => $faker->userName, 'display_name' => $faker->userName, 'slug' => $faker->slug, 'email' => $faker->email, 'can_see_explicit_content' => true, + 'uses_gravatar' => true, 'bio' => $faker->paragraph, 'track_count' => 0, 'comment_count' => 0, ]; }); + +$factory->define(\Poniverse\Ponyfm\Track::class, function(\Faker\Generator $faker) { + $user = factory(User::class)->create(); + + return [ + 'user_id' => $user->id, + 'hash' => $faker->md5, + 'title' => $faker->sentence(5), + 'track_type_id' => \Poniverse\Ponyfm\TrackType::UNCLASSIFIED_TRACK, + 'genre' => $faker->word, + 'album' => $faker->sentence(5), + 'track_number' => null, + 'description' => $faker->paragraph(5), + 'lyrics' => $faker->paragraph(5), + 'is_vocal' => true, + 'is_explicit' => false, + 'is_downloadable' => true, + 'is_listed' => true, + 'metadata' => '{"this":{"is":["very","random","metadata"]}}' + ]; +}); diff --git a/database/migrations/2015_12_25_154727_add_more_defaults.php b/database/migrations/2015_12_25_154727_add_more_defaults.php new file mode 100644 index 00000000..cb82f3f0 --- /dev/null +++ b/database/migrations/2015_12_25_154727_add_more_defaults.php @@ -0,0 +1,52 @@ +. + */ + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +class AddMoreDefaults extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('albums', function (Blueprint $table) { + + $table->text('description')->default('')->change(); + $table->unsignedInteger('view_count')->default(0)->change(); + $table->unsignedInteger('download_count')->default(0)->change(); + $table->unsignedInteger('favourite_count')->default(0)->change(); + $table->unsignedInteger('comment_count')->default(0)->change(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + // This migration is not reversible. + } +} diff --git a/database/migrations/2015_12_25_155223_add_metadata_columns.php b/database/migrations/2015_12_25_155223_add_metadata_columns.php new file mode 100644 index 00000000..620c87ad --- /dev/null +++ b/database/migrations/2015_12_25_155223_add_metadata_columns.php @@ -0,0 +1,50 @@ +. + */ + +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Database\Migrations\Migration; + +class AddMetadataColumns extends Migration +{ + /** + * Run the migrations. + * + * @return void + */ + public function up() + { + Schema::table('tracks', function(Blueprint $table) { + $table->longText('metadata')->nullable(); + $table->longText('original_tags')->nullable(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('tracks', function(Blueprint $table) { + $table->dropColumn(['original_tags', 'metadata']); + }); + } +} diff --git a/tests/ApiAuthTest.php b/tests/ApiAuthTest.php index c0b7bb5e..44c9cddd 100644 --- a/tests/ApiAuthTest.php +++ b/tests/ApiAuthTest.php @@ -37,7 +37,6 @@ class ApiAuthTest extends TestCase { $accessTokenInfo->setIsActive(true); $accessTokenInfo->setScopes(['basic', 'ponyfm:tracks:upload']); - $poniverse = Mockery::mock('overload:Poniverse'); $poniverse->shouldReceive('getUser') ->andReturn([ @@ -56,4 +55,30 @@ class ApiAuthTest extends TestCase { $this->post('/api/v1/tracks', ['access_token' => 'nonsense-token']); $this->seeInDatabase('users', ['username' => $user->username]); } + + public function testApiClientIdIsRecordedWhenUploadingTrack() { + $user = factory(User::class)->make(); + $accessTokenInfo = new \Poniverse\AccessTokenInfo('nonsense-token'); + $accessTokenInfo->setIsActive(true); + $accessTokenInfo->setClientId('Unicorns and rainbows'); + $accessTokenInfo->setScopes(['basic', 'ponyfm:tracks:upload']); + + $poniverse = Mockery::mock('overload:Poniverse'); + $poniverse->shouldReceive('getUser') + ->andReturn([ + 'username' => $user->username, + 'display_name' => $user->display_name, + 'email' => $user->email, + ]); + + $poniverse->shouldReceive('setAccessToken'); + + $poniverse + ->shouldReceive('getAccessTokenInfo') + ->andReturn($accessTokenInfo); + + $this->callUploadWithParameters(['access_token' => $accessTokenInfo->getToken()]); + $this->assertSessionHas('api_client_id', $accessTokenInfo->getClientId()); + $this->seeInDatabase('tracks', ['source' => $accessTokenInfo->getClientId()]); + } } diff --git a/tests/ApiTest.php b/tests/ApiTest.php index e375695c..55cf38f0 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -20,6 +20,7 @@ use Illuminate\Foundation\Testing\WithoutMiddleware; use Illuminate\Foundation\Testing\DatabaseMigrations; +use Poniverse\Ponyfm\Track; use Poniverse\Ponyfm\User; class ApiTest extends TestCase { @@ -40,20 +41,70 @@ class ApiTest extends TestCase { $this->assertResponseStatus(400); } - public function testUploadWithFile() { - $this->expectsJobs(Poniverse\Ponyfm\Jobs\EncodeTrackFile::class); + public function testUploadWithFileWithoutAutoPublish() { + $this->callUploadWithParameters([ + 'auto_publish' => false + ]); - $user = factory(User::class)->create(); - - $file = $this->getTestFileForUpload('ponyfm-test.flac'); - $this->actingAs($user) - ->call('POST', '/api/v1/tracks', [], [], ['track' => $file]); - - $this->assertResponseStatus(202); $this->seeJsonEquals([ - 'message' => "This track has been accepted for processing! Poll the status_url to know when it's ready to publish.", - 'id' => 1, - 'status_url' => "http://ponyfm-testing.poni/api/v1/tracks/1/upload-status" + 'message' => "This track has been accepted for processing! Poll the status_url to know when it's ready to publish. It will be published at the track_url.", + 'id' => "1", + 'status_url' => "http://ponyfm-testing.poni/api/v1/tracks/1/upload-status", + 'track_url' => "http://ponyfm-testing.poni/tracks/1-ponyfm-test", ]); } + + public function testUploadWithFileWithAutoPublish() { + $this->callUploadWithParameters([]); + + $this->seeJsonEquals([ + 'message' => "This track has been accepted for processing! Poll the status_url to know when it has been published. It will be published at the track_url.", + 'id' => "1", + 'status_url' => "http://ponyfm-testing.poni/api/v1/tracks/1/upload-status", + 'track_url' => "http://ponyfm-testing.poni/tracks/1-ponyfm-test", + ]); + + $this->visit('/tracks/1-ponyfm-test'); + $this->assertResponseStatus(200); + } + + public function testUploadWithOptionalData() { + $track = factory(Track::class)->make(); + + $this->callUploadWithParameters([ + 'title' => $track->title, + 'track_type_id' => $track->track_type_id, + 'genre' => $track->genre, + 'album' => $track->album, + 'released_at' => \Carbon\Carbon::create(2015, 1, 1, 1, 1, 1)->toIso8601String(), + 'description' => $track->description, + 'lyrics' => $track->lyrics, + 'is_vocal' => true, + 'is_explicit' => true, + 'is_downloadable' => false, + 'is_listed' => false, + 'metadata' => $track->metadata + ]); + + $this->seeInDatabase('genres', [ + 'name' => $track->genre + ]); + + $this->seeInDatabase('albums', [ + 'title' => $track->album + ]); + + $this->seeInDatabase('tracks', [ + 'title' => $track->title, + 'track_type_id' => $track->track_type_id, + 'released_at' => "2015-01-01 01:01:01", + 'description' => $track->description, + 'lyrics' => $track->lyrics, + 'is_vocal' => true, + 'is_explicit' => true, + 'is_downloadable' => false, + 'is_listed' => false, + 'metadata' => $track->metadata + ]); + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 7d33ab5c..8bbca4c4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -1,4 +1,5 @@ expectsJobs(Poniverse\Ponyfm\Jobs\EncodeTrackFile::class); + $user = factory(User::class)->create(); + + $file = $this->getTestFileForUpload('ponyfm-test.flac'); + + $this->actingAs($user) + ->call('POST', '/api/v1/tracks', $parameters, [], ['track' => $file]); + + $this->assertResponseStatus(202); + } }