diff --git a/app/Commands/CreateShowSongCommand.php b/app/Commands/CreateShowSongCommand.php new file mode 100644 index 00000000..0e4bcb17 --- /dev/null +++ b/app/Commands/CreateShowSongCommand.php @@ -0,0 +1,76 @@ +. + */ + +namespace Poniverse\Ponyfm\Commands; + +use Gate; +use Illuminate\Support\Str; +use Poniverse\Ponyfm\Models\ShowSong; +use Validator; + +class CreateShowSongCommand extends CommandBase +{ + /** @var ShowSong */ + private $_songName; + + public function __construct($songName) + { + $this->_songName = $songName; + } + + /** + * @return bool + */ + public function authorize() + { + return Gate::allows('create-show-song'); + } + + /** + * @throws \Exception + * @return CommandResponse + */ + public function execute() + { + $slug = Str::slug($this->_songName); + + $rules = [ + 'title' => 'required|unique:show_songs,title,NULL,id,deleted_at,NULL|max:250', + 'slug' => 'required|unique:show_songs,slug,NULL,id,deleted_at,NULL' + ]; + + $validator = Validator::make([ + 'title' => $this->_songName, + 'slug' => $slug + ], $rules); + + if ($validator->fails()) { + return CommandResponse::fail($validator); + } + + ShowSong::create([ + 'title' => $this->_songName, + 'slug' => $slug, + 'lyrics' => '' + ]); + + return CommandResponse::succeed(['message' => 'Song created!']); + } +} diff --git a/app/Commands/DeleteShowSongCommand.php b/app/Commands/DeleteShowSongCommand.php new file mode 100644 index 00000000..7cab2ef5 --- /dev/null +++ b/app/Commands/DeleteShowSongCommand.php @@ -0,0 +1,76 @@ +. + */ + +namespace Poniverse\Ponyfm\Commands; + +use Gate; +use Illuminate\Foundation\Bus\DispatchesJobs; +use Poniverse\Ponyfm\Models\ShowSong; +use Poniverse\Ponyfm\Jobs\DeleteShowSong; +use Validator; + +class DeleteShowSongCommand extends CommandBase +{ + use DispatchesJobs; + + + /** @var ShowSong */ + private $_songToDelete; + private $_destinationSong; + + public function __construct($songId, $destinationSongId) { + $this->_songToDelete = ShowSong::find($songId); + $this->_destinationSong = ShowSong::find($destinationSongId); + } + + /** + * @return bool + */ + public function authorize() { + return Gate::allows('delete', $this->_destinationSong); + } + + /** + * @throws \Exception + * @return CommandResponse + */ + public function execute() { + $rules = [ + 'song_to_delete' => 'required', + 'destination_song' => 'required', + ]; + + // The validation will fail if the genres don't exist + // because they'll be null. + $validator = Validator::make([ + 'song_to_delete' => $this->_songToDelete, + 'destination_song' => $this->_destinationSong, + ], $rules); + + + if ($validator->fails()) { + return CommandResponse::fail($validator); + } + + $this->dispatch(new DeleteShowSong($this->_songToDelete, $this->_destinationSong)); + + return CommandResponse::succeed(['message' => 'Song deleted!']); + } +} diff --git a/app/Commands/RenameShowSongCommand.php b/app/Commands/RenameShowSongCommand.php new file mode 100644 index 00000000..987b5329 --- /dev/null +++ b/app/Commands/RenameShowSongCommand.php @@ -0,0 +1,83 @@ +. + */ + +namespace Poniverse\Ponyfm\Commands; + +use Gate; +use Illuminate\Foundation\Bus\DispatchesJobs; +use Illuminate\Support\Str; +use Poniverse\Ponyfm\Jobs\UpdateTagsForRenamedShowSong; +use Poniverse\Ponyfm\Models\ShowSong; +use Validator; + +class RenameShowSongCommand extends CommandBase +{ + use DispatchesJobs; + + /** @var Song */ + private $_song; + private $_newName; + + public function __construct($genreId, $newName) + { + $this->_song = ShowSong::find($genreId); + $this->_newName = $newName; + } + + /** + * @return bool + */ + public function authorize() + { + return Gate::allows('rename', $this->_song); + } + + /** + * @throws \Exception + * @return CommandResponse + */ + public function execute() + { + $slug = Str::slug($this->_newName); + + $rules = [ + 'title' => 'required|unique:show_songs,title,'.$this->_song->id.',id|max:250', + 'slug' => 'required|unique:show_songs,slug,'.$this->_song->id.',id' + ]; + + $validator = Validator::make([ + 'title' => $this->_newName, + 'slug' => $slug + ], $rules); + + + if ($validator->fails()) { + return CommandResponse::fail($validator); + } + + $this->_song->title = $this->_newName; + $this->_song->slug = $slug; + $this->_song->save(); + + $this->dispatch(new UpdateTagsForRenamedShowSong($this->_song)); + + return CommandResponse::succeed(['message' => 'Show song renamed!']); + } +} diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 6e12222d..ba061245 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -27,7 +27,7 @@ class AdminController extends Controller { public function getIndex() { - return Redirect::to('AdminController@getGenres'); + return View::make('shared.null'); } public function getGenres() @@ -39,4 +39,9 @@ class AdminController extends Controller { return View::make('shared.null'); } + + public function getShowSongs() + { + return View::make('shared.null'); + } } diff --git a/app/Http/Controllers/Api/Web/ShowSongsController.php b/app/Http/Controllers/Api/Web/ShowSongsController.php new file mode 100644 index 00000000..258b97fd --- /dev/null +++ b/app/Http/Controllers/Api/Web/ShowSongsController.php @@ -0,0 +1,68 @@ +. + */ + +namespace Poniverse\Ponyfm\Http\Controllers\Api\Web; + +use Input; +use Poniverse\Ponyfm\Commands\CreateShowSongCommand; +use Poniverse\Ponyfm\Commands\DeleteShowSongCommand; +use Poniverse\Ponyfm\Commands\RenameShowSongCommand; +use Poniverse\Ponyfm\Models\ShowSong; +use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase; +use Response; + + +class ShowSongsController extends ApiControllerBase +{ + public function getIndex() + { + $this->authorize('access-admin-area'); + + $songs = ShowSong::with(['trackCountRelation' => function($query) { + $query->withTrashed(); + }]) + ->orderBy('title', 'asc') + ->select('id', 'title', 'slug') + ->get(); + + return Response::json([ + 'showsongs' => $songs->toArray() + ], 200); + } + + public function postCreate() + { + $command = new CreateShowSongCommand(Input::get('title')); + return $this->execute($command); + } + + public function putRename($songId) + { + $command = new RenameShowSongCommand($songId, Input::get('title')); + return $this->execute($command); + } + + + public function deleteSong($songId) + { + $command = new DeleteShowSongCommand($songId, Input::get('destination_song_id')); + return $this->execute($command); + } +} diff --git a/app/Http/Controllers/Api/Web/TaxonomiesController.php b/app/Http/Controllers/Api/Web/TaxonomiesController.php index 7c6de020..4c09ad02 100644 --- a/app/Http/Controllers/Api/Web/TaxonomiesController.php +++ b/app/Http/Controllers/Api/Web/TaxonomiesController.php @@ -42,7 +42,7 @@ class TaxonomiesController extends ApiControllerBase ->where('id', '!=', TrackType::UNCLASSIFIED_TRACK) ->get()->toArray(), 'show_songs' => ShowSong::select('title', 'id', 'slug', - DB::raw('(SELECT COUNT(tracks.id) FROM show_song_track INNER JOIN tracks ON tracks.id = show_song_track.track_id WHERE show_song_track.show_song_id = show_songs.id AND tracks.published_at IS NOT NULL) AS track_count'))->get()->toArray() + DB::raw('(SELECT COUNT(tracks.id) FROM show_song_track INNER JOIN tracks ON tracks.id = show_song_track.track_id WHERE show_song_track.show_song_id = show_songs.id AND tracks.published_at IS NOT NULL) AS track_count'))->orderBy('title')->get()->toArray() ], 200); } } diff --git a/app/Http/routes.php b/app/Http/routes.php index 3463c36c..560647f0 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -161,6 +161,11 @@ Route::group(['prefix' => 'api/web'], function() { Route::put('/genres/{id}', 'Api\Web\GenresController@putRename')->where('id', '\d+'); Route::delete('/genres/{id}', 'Api\Web\GenresController@deleteGenre')->where('id', '\d+'); + Route::get('/showsongs', 'Api\Web\ShowSongsController@getIndex'); + Route::post('/showsongs', 'Api\Web\ShowSongsController@postCreate'); + Route::put('/showsongs/{id}', 'Api\Web\ShowSongsController@putRename')->where('id', '\d+'); + Route::delete('/showsongs/{id}', 'Api\Web\ShowSongsController@deleteSong')->where('id', '\d+'); + Route::get('/tracks', 'Api\Web\TracksController@getAllTracks'); }); @@ -171,6 +176,7 @@ Route::group(['prefix' => 'api/web'], function() { Route::group(['prefix' => 'admin', 'middleware' => ['auth', 'can:access-admin-area']], function() { Route::get('/genres', 'AdminController@getGenres'); Route::get('/tracks', 'AdminController@getTracks'); + Route::get('/show-songs', 'AdminController@getShowSongs'); Route::get('/', 'AdminController@getIndex'); }); diff --git a/app/Jobs/DeleteShowSong.php b/app/Jobs/DeleteShowSong.php new file mode 100644 index 00000000..64635c65 --- /dev/null +++ b/app/Jobs/DeleteShowSong.php @@ -0,0 +1,89 @@ +. + */ + +namespace Poniverse\Ponyfm\Jobs; + +use Auth; +use DB; +use Poniverse\Ponyfm\Models\ShowSong; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Contracts\Bus\SelfHandling; +use Illuminate\Contracts\Queue\ShouldQueue; +use Poniverse\Ponyfm\Models\Track; +use SerializesModels; + +class DeleteShowSong extends Job implements SelfHandling, ShouldQueue +{ + use InteractsWithQueue, SerializesModels; + + protected $executingUser; + protected $songToDelete; + protected $destinationSong; + + /** + * Create a new job instance. + * + * @param ShowSong $songToDelete + * @param ShowSong $destinationSong + */ + public function __construct(ShowSong $songToDelete, ShowSong $destinationSong) + { + $this->executingUser = Auth::user(); + $this->songToDelete = $songToDelete; + $this->destinationSong = $destinationSong; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $this->beforeHandle(); + + // The user who kicked off this job is used when generating revision log entries. + Auth::login($this->executingUser); + + // This is done instead of a single UPDATE query in order to + // generate revision logs for the change. + $this->songToDelete->tracks()->chunk(200, function ($tracks) { + foreach ($tracks as $track) { + /** @var Track $track */ + $oldSongs = $track->showSongs; + $newSongs = []; + + foreach ($oldSongs as $key => $showSong) { + if ($showSong->id == $this->songToDelete->id) { + $newSongs[$key] = $this->destinationSong->id; + } else { + $newSongs[$key] = $showSong->id; + } + } + + $track->showSongs()->sync($newSongs); + $track->save(); + $track->updateTags(); + } + }); + + $this->songToDelete->delete(); + } +} diff --git a/app/Jobs/UpdateTagsForRenamedShowSong.php b/app/Jobs/UpdateTagsForRenamedShowSong.php new file mode 100644 index 00000000..a39a09da --- /dev/null +++ b/app/Jobs/UpdateTagsForRenamedShowSong.php @@ -0,0 +1,99 @@ +. + */ + +namespace Poniverse\Ponyfm\Jobs; + +use Auth; +use Cache; +use Log; +use Poniverse\Ponyfm\Models\ShowSong; +use Illuminate\Queue\InteractsWithQueue; +use Illuminate\Contracts\Bus\SelfHandling; +use Illuminate\Contracts\Queue\ShouldQueue; +use Poniverse\Ponyfm\Models\Track; +use SerializesModels; + +/** + * Class RenameGenre + * + * NOTE: It is assumed that the genre passed into this job has already been renamed! + * All this job does is update the tags in that genre's tracks. + * + * @package Poniverse\Ponyfm\Jobs + */ +class UpdateTagsForRenamedShowSong extends Job implements SelfHandling, ShouldQueue +{ + use InteractsWithQueue, SerializesModels; + + protected $executingUser; + protected $songThatWasRenamed; + protected $lockKey; + + /** + * Create a new job instance. + * + * @param ShowSong $songThatWasRenamed + */ + public function __construct(ShowSong $songThatWasRenamed) + { + $this->executingUser = Auth::user(); + $this->songThatWasRenamed = $songThatWasRenamed; + + $this->lockKey = "show-song-{$this->songThatWasRenamed->id}-tag-update-lock"; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle() + { + $this->beforeHandle(); + + // The user who kicked off this job is used when generating revision log entries. + Auth::login($this->executingUser); + + // "Lock" this genre to prevent race conditions + if (Cache::has($this->lockKey)) { + Log::info("Tag updates for the \"{$this->songThatWasRenamed->title}\" song are currently in progress! Will try again in 30 seconds."); + $this->release(30); + return; + + } else { + Cache::forever($this->lockKey, true); + } + + + $this->songThatWasRenamed->tracks()->chunk(200, function ($tracks) { + foreach ($tracks as $track) { + /** @var Track $track */ + $track->updateTags(); + } + }); + + Cache::forget($this->lockKey); + } + + public function failed() + { + Cache::forget($this->lockKey); + } +} diff --git a/app/Models/ShowSong.php b/app/Models/ShowSong.php index 8b14f466..34667ea1 100644 --- a/app/Models/ShowSong.php +++ b/app/Models/ShowSong.php @@ -20,6 +20,7 @@ namespace Poniverse\Ponyfm\Models; +use DB; use Illuminate\Database\Eloquent\Model; /** @@ -33,4 +34,15 @@ use Illuminate\Database\Eloquent\Model; class ShowSong extends Model { protected $table = 'show_songs'; + protected $fillable = ['title', 'slug', 'lyrics']; + + public function trackCountRelation() { + return $this->belongsToMany(Track::class) + ->select(['show_song_id', DB::raw('count(*) as track_count')]) + ->groupBy('show_song_id'); + } + + public function tracks(){ + return $this->belongsToMany(Track::class); + } } diff --git a/app/Policies/ShowSongPolicy.php b/app/Policies/ShowSongPolicy.php new file mode 100644 index 00000000..0e865e38 --- /dev/null +++ b/app/Policies/ShowSongPolicy.php @@ -0,0 +1,17 @@ +hasRole('admin'); + } + + public function delete(User $user, ShowSong $song) { + return $user->hasRole('admin'); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 7656ba92..9a6abbe2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -26,9 +26,11 @@ use Poniverse\Ponyfm\Models\Album; use Poniverse\Ponyfm\Models\Genre; use Poniverse\Ponyfm\Policies\AlbumPolicy; use Poniverse\Ponyfm\Policies\GenrePolicy; +use Poniverse\Ponyfm\Policies\ShowSongPolicy; use Poniverse\Ponyfm\Policies\TrackPolicy; use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\User; +use Poniverse\Ponyfm\Models\ShowSong; use Poniverse\Ponyfm\Policies\UserPolicy; class AuthServiceProvider extends ServiceProvider @@ -43,6 +45,7 @@ class AuthServiceProvider extends ServiceProvider Track::class => TrackPolicy::class, Album::class => AlbumPolicy::class, User::class => UserPolicy::class, + ShowSong::class => ShowSongPolicy::class ]; /** @@ -61,6 +64,10 @@ class AuthServiceProvider extends ServiceProvider return $user->hasRole('admin'); }); + $gate->define('create-show-song', function(User $user) { + return $user->hasRole('admin'); + }); + $this->registerPolicies($gate); } } diff --git a/database/migrations/2016_06_05_221208_add_timestamps_to_show_songs.php b/database/migrations/2016_06_05_221208_add_timestamps_to_show_songs.php new file mode 100644 index 00000000..17a58bba --- /dev/null +++ b/database/migrations/2016_06_05_221208_add_timestamps_to_show_songs.php @@ -0,0 +1,33 @@ +timestamp('created_at')->default(DB::raw('CURRENT_TIMESTAMP')); + $table->timestamp('updated_at')->default(DB::raw('CURRENT_TIMESTAMP')); + $table->timestamp('deleted_at')->default(DB::raw('CURRENT_TIMESTAMP')); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('show_songs', function (Blueprint $table) { + $table->dropColumn(['created_at', 'updated_at', 'deleted_at']); + }); + } +} diff --git a/public/templates/admin/_layout.html b/public/templates/admin/_layout.html index bf04aabd..6e485deb 100644 --- a/public/templates/admin/_layout.html +++ b/public/templates/admin/_layout.html @@ -1,6 +1,7 @@ diff --git a/public/templates/admin/show-songs.html b/public/templates/admin/show-songs.html new file mode 100644 index 00000000..234c65da --- /dev/null +++ b/public/templates/admin/show-songs.html @@ -0,0 +1,55 @@ +

Show Song Editor

+ +
+ +
+

Add show song

+ +

Enter a show song name and press enter to create it!

+ + +
+ {{ createSongError }} +
+
+ +
+

Rename & delete songs

+ + + + + + + + + + + + + + +
Song# of tracks (including deleted)Actions
+ +
+ {{ song.errorMessage }} +
+
{{ song.track_count_relation[0].track_count }} + + + +
+
+
diff --git a/public/templates/partials/credits-dialog.html b/public/templates/partials/credits-dialog.html index 5a09ddfd..2e5f3a00 100644 --- a/public/templates/partials/credits-dialog.html +++ b/public/templates/partials/credits-dialog.html @@ -30,6 +30,8 @@
  • FFmpegPHP - for providing a sweet PHP interface to ffmpeg
  • Laravel IDE Helper Generator - for making our IDE useful
  • Laravel NewRelic Service Provider - for making it easy to monitor Pony.fm''s performance
  • +
  • Whoops - for
  • +
  • Whoops - for
  • Vagrant - for making cross-platform dev environments possible
  • AtomicParsley - for making it easy to work with tags in M4A files
  • FLAC - FLAC is best audio codec /)
  • diff --git a/resources/assets/scripts/app/app.coffee b/resources/assets/scripts/app/app.coffee index 646883cb..4828465a 100644 --- a/resources/assets/scripts/app/app.coffee +++ b/resources/assets/scripts/app/app.coffee @@ -273,6 +273,11 @@ ponyfm.config [ controller: 'admin-genres' templateUrl: '/templates/admin/genres.html' + state.state 'admin.showsongs', + url: '/show-songs' + controller: 'admin-show-songs' + templateUrl: '/templates/admin/show-songs.html' + state.state 'admin.tracks', url: '/tracks' controller: 'admin-tracks' diff --git a/resources/assets/scripts/app/controllers/admin-show-songs.coffee b/resources/assets/scripts/app/controllers/admin-show-songs.coffee new file mode 100644 index 00000000..aa515039 --- /dev/null +++ b/resources/assets/scripts/app/controllers/admin-show-songs.coffee @@ -0,0 +1,85 @@ +# Pony.fm - A community for pony fan music. +# Copyright (C) 2015 Peter Deltchev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +module.exports = angular.module('ponyfm').controller 'admin-show-songs', [ + '$scope', '$state', 'admin-show-songs' + ($scope, $state, showsongs) -> + + $scope.showsongs = [] + + $scope.isCreating = false + $scope.songToCreate = '' + $scope.hasCreationError = false + $scope.createSongError = '' + + # Used for merging/deleting show songs + $scope.mergeInProgress = false + $scope.songToDelete = null + + setSongs = (showsongs) -> + $scope.showsongs = [] + for song in showsongs + song.isSaving = false + song.isError = false + $scope.showsongs.push(song) + + loadSongs = () -> + showsongs.fetch().done setSongs + + loadSongs() + + + $scope.createSong = (songName) -> + $scope.isCreating = true + showsongs.create(songName) + .done (response) -> + $scope.hasCreationError = false + $scope.songToCreate = '' + loadSongs() + .fail (response) -> + $scope.hasCreationError = true + $scope.createSongError = response + console.log(response) + .always (response) -> + $scope.isCreating = false + + + # Renames the given song + $scope.renameSong = (song) -> + song.isSaving = true + showsongs.rename(song.id, song.title) + .done (response)-> + song.isError = false + .fail (response)-> + song.errorMessage = response + song.isError = true + .always (response)-> + song.isSaving = false + + + $scope.startMerge = (destinationSong) -> + $scope.destinationSong = destinationSong + $scope.mergeInProgress = true + + $scope.cancelMerge = () -> + $scope.destinationSong = null + $scope.mergeInProgress = false + + $scope.finishMerge = (songToDelete) -> + showsongs.merge(songToDelete.id, $scope.destinationSong.id) + .done (response) -> + loadSongs() +] diff --git a/resources/assets/scripts/app/services/admin-show-songs.coffee b/resources/assets/scripts/app/services/admin-show-songs.coffee new file mode 100644 index 00000000..79fda9c9 --- /dev/null +++ b/resources/assets/scripts/app/services/admin-show-songs.coffee @@ -0,0 +1,68 @@ +# Pony.fm - A community for pony fan music. +# Copyright (C) 2015 Peter Deltchev +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +module.exports = angular.module('ponyfm').factory('admin-show-songs', [ + '$rootScope', '$http' + ($rootScope, $http) -> + def = null + showsongs = [] + + self = + fetch: () -> + url = '/api/web/admin/showsongs' + def = new $.Deferred() + $http.get(url).success (showsongs) -> + def.resolve(showsongs['showsongs']) + def.promise() + + create: (name) -> + url = '/api/web/admin/showsongs' + def = new $.Deferred() + $http.post(url, {title: name}) + .success (response) -> + def.resolve(response) + .error (response) -> + def.reject(response) + + def.promise() + + rename: (song_id, new_name) -> + url = "/api/web/admin/showsongs/#{song_id}" + def = new $.Deferred() + + $http.put(url, {title: new_name}) + .success (response)-> + def.resolve(response) + + .error (response)-> + def.reject(response) + + def.promise() + + merge: (song_id_to_delete, destination_song_id) -> + url = "/api/web/admin/showsongs/#{song_id_to_delete}" + def = new $.Deferred() + + $http.delete(url, {params: {destination_song_id: destination_song_id}}) + .success (response)-> + def.resolve(response) + + .error (response)-> + def.reject(response) + + def.promise() + self +])