diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..c13c5f62 --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015"] +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5839a676 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,3 @@ +[*.{js,sass,scss,json,coffee,vue}] +indent_style = space +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..c19a31ad --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +APP_ENV=local +APP_DEBUG=true +APP_KEY=SomeRandomString + +# Username and passsword for the initial admin account +# This info will be populated into the database during `php artisan db:seed` +# After that, it can (and should) be removed from this .env file +ADMIN_EMAIL= +ADMIN_NAME= +ADMIN_PASSWORD= + +# The maximum scan time, in seconds. Increase this if you have a huge library. +APP_MAX_SCAN_TIME=600 + +DB_HOST=localhost +DB_DATABASE=homestead +DB_USERNAME=homestead +DB_PASSWORD=secret + +CACHE_DRIVER=file +SESSION_DRIVER=file +QUEUE_DRIVER=sync + +MAIL_DRIVER=smtp +MAIL_HOST=mailtrap.io +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_ENCRYPTION=null diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..95883dea --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto +*.css linguist-vendored +*.less linguist-vendored diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..99551eac --- /dev/null +++ b/.gitignore @@ -0,0 +1,75 @@ +/vendor +/node_modules +Homestead.yaml +Homestead.json +.env +/public +/.idea +/_ide_helper.php +/config.testing +/config.local +db.mwb.bak +bower_components + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directory +# https://docs.npmjs.com/misc/faq#should-i-check-my-node-modules-folder-into-git +node_modules + + +### OSX ### +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### Sass ### +.sass-cache/ +*.css.map diff --git a/.htaccess b/.htaccess new file mode 100644 index 00000000..e1a3feb6 --- /dev/null +++ b/.htaccess @@ -0,0 +1,41 @@ + + + Header set Access-Control-Allow-Origin "*" + + + + + + Options -MultiViews + + + RewriteEngine On + RewriteBase / + + # Deny access to framework directories + RewriteRule ^(app/|bootstrap/|config/|database/|resources/|storage/tests|vendor/) - [R=404,L,NC] + + # And dot files/folders (for example .env) + RedirectMatch 404 /\..*$ + + # Redirect Trailing Slashes... + RewriteRule ^(.*)/$ /$1 [L,R=301] + + # Handle Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + + + + # Disable deflation for media files. + SetEnvIfNoCase Request_URI "^/api/play/" no-gzip dont-vary + + + + # Set a MOD_X_SENDFILE_ENABLED env variable for PHP to use later. + + XSendFile On + SetEnv MOD_X_SENDFILE_ENABLED 1 + + diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 00000000..97f496b1 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,6 @@ +{ + "globals": { "$": false }, + "globalstrict": false, + "devel": true, + "esnext": true +} diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..64a4351b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,15 @@ +language: php + +php: + - 5.6 + - 7.0 + +branches: + only: + - master + +before_script: + - curl -s http://getcomposer.org/installer | php + - php composer.phar install + +script: phpunit diff --git a/app/Application.php b/app/Application.php new file mode 100644 index 00000000..1ee5a4b0 --- /dev/null +++ b/app/Application.php @@ -0,0 +1,35 @@ +publicPath().'/build/rev-manifest.json'), true); + } + + if (isset($manifest[$file])) { + return "/public/build/{$manifest[$file]}"; + } + + throw new InvalidArgumentException("File {$file} not defined in asset manifest."); + } +} diff --git a/app/Console/Commands/SyncMedia.php b/app/Console/Commands/SyncMedia.php new file mode 100644 index 00000000..37754663 --- /dev/null +++ b/app/Console/Commands/SyncMedia.php @@ -0,0 +1,86 @@ +error("Media path hasn't been configured. Exiting."); + + return; + } + + $this->info('Koel syncing started. All we need now is just a little patience…'); + + Media::sync(null, $this); + + $this->output->writeln("Completed! {$this->synced} new or updated songs(s), " + ."{$this->ignored} unchanged song(s), " + ."and {$this->invalid} invalid file(s)."); + } + + /** + * Log a song's sync status to console. + */ + public function logToConsole($path, $result) + { + $name = basename($path); + + if ($result === true) { + if ($this->option('verbose')) { + $this->line("$name has no changes – ignoring"); + } + + ++$this->ignored; + } elseif ($result === false) { + if ($this->option('verbose')) { + $this->error("$name is not a valid media file"); + } + + ++$this->invalid; + } else { + if ($this->option('verbose')) { + $this->info("$name synced"); + } + + ++$this->synced; + } + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php new file mode 100644 index 00000000..e385eb44 --- /dev/null +++ b/app/Console/Kernel.php @@ -0,0 +1,28 @@ +getMessage(), $e); + } + + return parent::render($request, $e); + } +} diff --git a/app/Facades/Media.php b/app/Facades/Media.php new file mode 100644 index 00000000..33643428 --- /dev/null +++ b/app/Facades/Media.php @@ -0,0 +1,13 @@ +orderBy('name')->with('songs')->get()->toArray(); + + // We don't need full song data, just ID's + foreach ($playlists as &$playlist) { + $playlist['songs'] = array_pluck($playlist['songs'], 'id'); + } + + return response()->json([ + 'artists' => Artist::orderBy('name')->with('albums', with('albums.songs'))->get(), + 'settings' => Setting::all()->lists('value', 'key'), + 'playlists' => $playlists, + 'interactions' => Interaction::byCurrentUser()->get(), + 'users' => auth()->user()->is_admin ? User::all() : [], + 'user' => auth()->user(), + ]); + } +} diff --git a/app/Http/Controllers/API/InteractionController.php b/app/Http/Controllers/API/InteractionController.php new file mode 100644 index 00000000..a1dcf921 --- /dev/null +++ b/app/Http/Controllers/API/InteractionController.php @@ -0,0 +1,58 @@ +json(Interaction::increasePlayCount($request->input('id'))); + } + + /** + * Like or unlike a song as the currently authenticated user. + * + * @param Request $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function like(Request $request) + { + return response()->json(Interaction::toggleLike($request->input('id'))); + } + + /** + * Like several songs at once as the currently authenticated user. + * + * @param BatchInteractionRequest $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function batchLike(BatchInteractionRequest $request) + { + return response()->json(Interaction::batchLike((array) $request->input('ids'))); + } + + /** + * Unlike several songs at once as the currently authenticated user. + * + * @param BatchInteractionRequest $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function batchUnlike(BatchInteractionRequest $request) + { + return response()->json(Interaction::batchUnlike((array) $request->input('ids'))); + } +} diff --git a/app/Http/Controllers/API/PlaylistController.php b/app/Http/Controllers/API/PlaylistController.php new file mode 100644 index 00000000..1235eb33 --- /dev/null +++ b/app/Http/Controllers/API/PlaylistController.php @@ -0,0 +1,90 @@ +user()->playlists()->create($request->only('name')); + $playlist->songs()->sync($request->input('songs')); + + $playlist->songs = $playlist->songs->fetch('id'); + + return response()->json($playlist); + } + + /** + * Rename a playlist. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function update(Request $request, $id) + { + $playlist = Playlist::findOrFail($id); + + if ($playlist->user_id !== auth()->user()->id) { + abort(403); + } + + $playlist->name = $request->input('name'); + $playlist->save(); + + return response()->json($playlist); + } + + /** + * Sync a playlist with songs. + * Any songs that are not populated here will be removed from the playlist. + * + * @param \Illuminate\Http\Request $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function sync(Request $request, $id) + { + $playlist = Playlist::findOrFail($id); + + if ($playlist->user_id !== auth()->user()->id) { + abort(403); + } + + $playlist->songs()->sync($request->input('songs')); + + return response()->json(); + } + + /** + * Delete a playlist. + * + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + // This can't be put into a Request authorize(), due to Laravel(?)'s limitation. + if (Playlist::findOrFail($id)->user_id !== auth()->user()->id) { + abort(403); + } + + Playlist::destroy($id); + + return response()->json(); + } +} diff --git a/app/Http/Controllers/API/SettingController.php b/app/Http/Controllers/API/SettingController.php new file mode 100644 index 00000000..1a9f98de --- /dev/null +++ b/app/Http/Controllers/API/SettingController.php @@ -0,0 +1,29 @@ +input('media_path')), '/')); + + // In a next version we should opt for a "MediaPathChanged" event, + // but let's just do this async now. + Media::sync(); + + return response()->json(); + } +} diff --git a/app/Http/Controllers/API/SongController.php b/app/Http/Controllers/API/SongController.php new file mode 100644 index 00000000..72ea1ecc --- /dev/null +++ b/app/Http/Controllers/API/SongController.php @@ -0,0 +1,43 @@ +stream(); + + return; + } + + (new PHPStreamer($id))->stream(); + + // Exit here to avoid accidentally sending extra content at the end of the file. + exit; + } + + /** + * Get the lyrics of a song. + * + * @param $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function getLyrics($id) + { + return response()->json(Song::findOrFail($id)->lyrics); + } +} diff --git a/app/Http/Controllers/API/UserController.php b/app/Http/Controllers/API/UserController.php new file mode 100644 index 00000000..f8c91656 --- /dev/null +++ b/app/Http/Controllers/API/UserController.php @@ -0,0 +1,81 @@ +json(User::create([ + 'name' => $request->input('name'), + 'email' => $request->input('email'), + 'password' => Hash::make($request->input('password')), + ])); + } + + /** + * Update a user. + * + * @param UserUpdateRequest $request + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function update(UserUpdateRequest $request, $id) + { + $data = $request->only('name', 'email'); + + if ($password = $request->input('password')) { + $data['password'] = Hash::make($password); + } + + return response()->json(User::findOrFail($id)->update($data)); + } + + /** + * Delete a user. + * + * @param int $id + * + * @return \Illuminate\Http\JsonResponse + */ + public function destroy($id) + { + if (!auth()->user()->is_admin || auth()->user()->id === $id) { + abort(403); + } + + return response()->json(User::destroy($id)); + } + + /** + * Update the current user's profile. + * + * @param ProfileUpdateRequest $request + * + * @return \Illuminate\Http\JsonResponse + */ + public function updateProfile(ProfileUpdateRequest $request) + { + $data = $request->only('name', 'email'); + + if ($password = $request->input('password')) { + $data['password'] = Hash::make($password); + } + + return response()->json(auth()->user()->update($data)); + } +} diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 00000000..cc22dac3 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,68 @@ +middleware('guest', ['except' => 'getLogout']); + } + + /** + * Get a validator for an incoming registration request. + * + * @param array $data + * + * @return \Illuminate\Contracts\Validation\Validator + */ + protected function validator(array $data) + { + return Validator::make($data, [ + 'name' => 'required|max:255', + 'email' => 'required|email|max:255|unique:users', + 'password' => 'required|confirmed', + ]); + } + + /** + * Create a new user instance after a valid registration. + * + * @param array $data + * + * @return User + */ + protected function create(array $data) + { + return User::create([ + 'name' => $data['name'], + 'email' => $data['email'], + 'password' => bcrypt($data['password']), + ]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordController.php b/app/Http/Controllers/Auth/PasswordController.php new file mode 100644 index 00000000..9c97bbaa --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordController.php @@ -0,0 +1,30 @@ +middleware('guest'); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 00000000..4eb37d58 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,13 @@ + Authenticate::class, + 'auth.basic' => AuthenticateWithBasicAuth::class, + 'guest' => RedirectIfAuthenticated::class, + ]; +} diff --git a/app/Http/Middleware/Authenticate.php b/app/Http/Middleware/Authenticate.php new file mode 100644 index 00000000..b424aa9a --- /dev/null +++ b/app/Http/Middleware/Authenticate.php @@ -0,0 +1,47 @@ +auth = $auth; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * + * @return mixed + */ + public function handle($request, Closure $next) + { + if ($this->auth->guest()) { + if ($request->ajax() || $request->route()->getName() == 'play') { + return response('Unauthorized.', 401); + } else { + return redirect()->guest('login'); + } + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php new file mode 100644 index 00000000..3aa15f8d --- /dev/null +++ b/app/Http/Middleware/EncryptCookies.php @@ -0,0 +1,17 @@ +auth = $auth; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * + * @return mixed + */ + public function handle($request, Closure $next) + { + if ($this->auth->check()) { + return redirect('/♫'); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 00000000..a2c35414 --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,17 @@ + 'required|array', + ]; + } +} diff --git a/app/Http/Requests/API/PlaylistStoreRequest.php b/app/Http/Requests/API/PlaylistStoreRequest.php new file mode 100644 index 00000000..72a2beb0 --- /dev/null +++ b/app/Http/Requests/API/PlaylistStoreRequest.php @@ -0,0 +1,29 @@ + 'required', + 'songs' => 'array', + ]; + } +} diff --git a/app/Http/Requests/API/ProfileUpdateRequest.php b/app/Http/Requests/API/ProfileUpdateRequest.php new file mode 100644 index 00000000..2eda07fa --- /dev/null +++ b/app/Http/Requests/API/ProfileUpdateRequest.php @@ -0,0 +1,29 @@ + 'required', + 'email' => 'required|email|unique:users,email,'.auth()->user()->id, + ]; + } +} diff --git a/app/Http/Requests/API/Request.php b/app/Http/Requests/API/Request.php new file mode 100644 index 00000000..66a0b992 --- /dev/null +++ b/app/Http/Requests/API/Request.php @@ -0,0 +1,10 @@ +user()->is_admin; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'media_path' => 'string|required|valid_path', + ]; + } +} diff --git a/app/Http/Requests/API/UserStoreRequest.php b/app/Http/Requests/API/UserStoreRequest.php new file mode 100644 index 00000000..e35fcb95 --- /dev/null +++ b/app/Http/Requests/API/UserStoreRequest.php @@ -0,0 +1,30 @@ +user()->is_admin; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'name' => 'required', + 'email' => 'required|email|unique:users', + 'password' => 'required' + ]; + } +} diff --git a/app/Http/Requests/API/UserUpdateRequest.php b/app/Http/Requests/API/UserUpdateRequest.php new file mode 100644 index 00000000..12460d76 --- /dev/null +++ b/app/Http/Requests/API/UserUpdateRequest.php @@ -0,0 +1,29 @@ +user()->is_admin; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array + */ + public function rules() + { + return [ + 'name' => 'required', + 'email' => 'required|email|unique:users,email,'.$this->route('user'), + ]; + } +} diff --git a/app/Http/Requests/Request.php b/app/Http/Requests/Request.php new file mode 100644 index 00000000..76b2ffd4 --- /dev/null +++ b/app/Http/Requests/Request.php @@ -0,0 +1,10 @@ +song = $song instanceof Song ? $song : Song::findOrFail($song); + + if (!file_exists($this->song->path)) { + abort(404); + } + + // Hard code the content type instead of relying on PHP's fileinfo() + // or even Symfony's MIMETypeGuesser, since they appear to be wrong sometimes. + $this->contentType = 'audio/'.pathinfo($this->song->path, PATHINFO_EXTENSION); + + // Turn off error reporting to make sure our stream isn't interfered. + @error_reporting(0); + } +} diff --git a/app/Http/Streamers/PHPStreamer.php b/app/Http/Streamers/PHPStreamer.php new file mode 100644 index 00000000..f907d1c7 --- /dev/null +++ b/app/Http/Streamers/PHPStreamer.php @@ -0,0 +1,116 @@ + $val) { + $headers[strtolower($header)] = $val; + } + + $range = isset($headers['range']) ? $headers['range'] : false; + } else { + // We can't get the header/there isn't one set + $range = false; + } + + // Get the data range requested (if any) + $fileSize = filesize($this->song->path); + + if ($range) { + $partial = true; + list($param, $range) = explode('=', $range); + + if (strtolower(trim($param)) != 'bytes') { + // Bad request - range unit is not 'bytes' + abort(400); + } + + $range = explode(',', $range); + $range = explode('-', $range[0]); // We only deal with the first requested range + + if (count($range) != 2) { + // Bad request - 'bytes' parameter is not valid + abort(400); + } + + if ($range[0] === '') { + // First number missing, return last $range[1] bytes + $end = $fileSize - 1; + $start = $end - intval($range[0]); + } elseif ($range[1] === '') { + // Second number missing, return from byte $range[0] to end + $start = intval($range[0]); + $end = $fileSize - 1; + } else { + // Both numbers present, return specific range + $start = intval($range[0]); + $end = intval($range[1]); + + if ($end >= $fileSize || (!$start && (!$end || $end == ($fileSize - 1)))) { + // Invalid range/whole file specified, return whole file + $partial = false; + } + } + + $length = $end - $start + 1; + } else { + // No range requested + $partial = false; + } + + // Send standard headers + header("Content-Type: {$this->contentType}"); + header("Content-Length: $fileSize"); + header('Content-Disposition: attachment; filename="'.basename($this->song->path).'"'); + header('Accept-Ranges: bytes'); + + // if requested, send extra headers and part of file... + if ($partial) { + header('HTTP/1.1 206 Partial Content'); + header("Content-Range: bytes $start-$end/$fileSize"); + + if (!$fp = fopen($this->song->path, 'r')) { + // Error out if we can't read the file + abort(500); + } + + if ($start) { + fseek($fp, $start); + } + + while ($length) { + // Read in blocks of 8KB so we don't chew up memory on the server + $read = ($length > 8192) ? 8192 : $length; + $length -= $read; + print(fread($fp, $read)); + } + + fclose($fp); + } else { + // ...otherwise just send the whole file + readfile($this->song->path); + } + + exit; + } +} diff --git a/app/Http/Streamers/StreamerInterface.php b/app/Http/Streamers/StreamerInterface.php new file mode 100644 index 00000000..114ee9be --- /dev/null +++ b/app/Http/Streamers/StreamerInterface.php @@ -0,0 +1,11 @@ +song->path}"); + header("Content-Type: {$this->contentType}"); + header('Content-Disposition: inline; filename="'.basename($this->song->path).'"'); + + exit; + } +} diff --git a/app/Http/routes.php b/app/Http/routes.php new file mode 100644 index 00000000..e407c04d --- /dev/null +++ b/app/Http/routes.php @@ -0,0 +1,38 @@ + 'auth', function () { + return view('index'); +}]); + +Route::group(['prefix' => 'api', 'middleware' => 'auth', 'namespace' => 'API'], function () { + get('/', function () { + // Just acting as a ping service. + }); + + get('data', 'DataController@index'); + + post('settings', 'SettingController@save'); + + get('{id}/play', 'SongController@play')->where('id', '[a-f0-9]{32}'); + + get('{id}/lyrics', 'SongController@getLyrics')->where('id', '[a-f0-9]{32}'); + + post('interaction/play', 'InteractionController@play'); + post('interaction/like', 'InteractionController@like'); + post('interaction/batch/like', 'InteractionController@batchLike'); + post('interaction/batch/unlike', 'InteractionController@batchUnlike'); + + resource('playlist', 'PlaylistController'); + put('playlist/{id}/sync', 'PlaylistController@sync')->where(['id' => '\d+']); + + resource('user', 'UserController'); + put('me', 'UserController@updateProfile'); +}); diff --git a/app/Jobs/Job.php b/app/Jobs/Job.php new file mode 100644 index 00000000..55ece29a --- /dev/null +++ b/app/Jobs/Job.php @@ -0,0 +1,21 @@ +belongsTo(Artist::class); + } + + public function songs() + { + return $this->hasMany(Song::class); + } + + /** + * Get an album using some provided information. + * + * @param Artist $artist + * @param $name + * + * @return self + */ + public static function get(Artist $artist, $name) + { + // If an empty name is provided, turn it into our "Unknown Album" + $name = $name ?: self::UNKNOWN_NAME; + + $album = self::firstOrCreate([ + 'artist_id' => $artist->id, + 'name' => $name, + ]); + + return $album; + } + + /** + * Generate a cover from provided data. + * + * @param array $cover The cover data in array format, extracted by getID3. + * For example: + * [ + * 'data' => '', + * 'image_mime' => 'image/png', + * 'image_width' => 512, + * 'image_height' => 512, + * 'imagetype' => 'PNG', // not always present + * 'picturetype' => 'Other', + * 'description' => '', + * 'datalength' => 7627, + * ] + */ + public function generateCover(array $cover) + { + $extension = explode('/', $cover['image_mime']); + $fileName = uniqid().'.'.strtolower($extension[1]); + $coverPath = app()->publicPath().'/img/covers/'.$fileName; + + file_put_contents($coverPath, $cover['data']); + + $this->update(['cover' => $fileName]); + } + + public function setCoverAttribute($value) + { + $this->attributes['cover'] = $value ?: self::UNKNOWN_COVER; + } + + public function getCoverAttribute($value) + { + return '/public/img/covers/'.($value ?: self::UNKNOWN_COVER); + } + + /** + * Determine if the current album has a cover. + * + * @return bool + */ + public function getHasCoverAttribute() + { + return $this->cover !== $this->getCoverAttribute(null); + } + + /** + * Sometimes the tags extracted from getID3 are HTML entity encoded. + * This makes sure they are always sane. + * + * @param $value + */ + public function setNameAttribute($value) + { + $this->attributes['name'] = html_entity_decode($value); + } +} diff --git a/app/Models/Artist.php b/app/Models/Artist.php new file mode 100644 index 00000000..6387d05f --- /dev/null +++ b/app/Models/Artist.php @@ -0,0 +1,54 @@ +hasMany(Album::class); + } + + public function getNameAttribute($value) + { + return $value ?: self::UNKNOWN_NAME; + } + + /** + * Get an Artist object from their name. + * If such is not found, a new artist will be created. + * + * @param string $name + * + * @return Artist + */ + public static function get($name) + { + $name = trim($name) ?: self::UNKNOWN_NAME; + + return self::firstOrCreate(compact('name'), compact('name')); + } + + /** + * Sometimes the tags extracted from getID3 are HTML entity encoded. + * This makes sure they are always sane. + * + * @param $value + */ + public function setNameAttribute($value) + { + $this->attributes['name'] = html_entity_decode($value); + } +} diff --git a/app/Models/Interaction.php b/app/Models/Interaction.php new file mode 100644 index 00000000..adb80ab8 --- /dev/null +++ b/app/Models/Interaction.php @@ -0,0 +1,132 @@ + 'boolean', + 'play_count' => 'integer', + ]; + + protected $guarded = ['id']; + + protected $hidden = ['user_id', 'created_at', 'updated_at']; + + public function user() + { + return $this->belongsTo(User::class); + } + + public function song() + { + return $this->belongsTo(Song::class); + } + + /** + * Increase the number of times a song is played by a user. + * + * @param string $songId + * @param int|null $userId + * + * @return Interaction + */ + public static function increasePlayCount($songId, $userId = null) + { + $interaction = self::firstOrCreate([ + 'song_id' => $songId, + 'user_id' => $userId ?: auth()->user()->id, + ]); + + if (!$interaction->exists) { + $interaction->liked = false; + } + + ++$interaction->play_count; + $interaction->save(); + + return $interaction; + } + + /** + * Like or unlike a song on behalf of a user. + * + * @param string $songId + * @param int|null $userId + * + * @return Interaction + */ + public static function toggleLike($songId, $userId = null) + { + $interaction = self::firstOrCreate([ + 'song_id' => $songId, + 'user_id' => $userId ?: auth()->user()->id, + ]); + + if (!$interaction->exists) { + $interaction->play_count = 0; + } + + $interaction->liked = !$interaction->liked; + $interaction->save(); + + return $interaction; + } + + /** + * Like several songs at once. + * + * @param array $songIds + * @param int|null $userId + * + * @return array + */ + public static function batchLike(array $songIds, $userId = null) + { + $result = []; + + foreach ($songIds as $songId) { + $interaction = self::firstOrCreate([ + 'song_id' => $songId, + 'user_id' => $userId ?: auth()->user()->id, + ]); + + if (!$interaction->exists) { + $interaction->play_count = 0; + } + + $interaction->liked = true; + $interaction->save(); + + $result[] = $interaction; + } + + return $result; + } + + /** + * Unlike several songs at once. + * + * @param array $songIds + * @param int|null $userId + * + * @return int + */ + public static function batchUnlike(array $songIds, $userId = null) + { + return DB::table('interactions') + ->whereIn('song_id', $songIds) + ->where('user_id', $userId ?: auth()->user()->id) + ->update(['liked' => false]); + } +} diff --git a/app/Models/Playlist.php b/app/Models/Playlist.php new file mode 100644 index 00000000..a7f499d8 --- /dev/null +++ b/app/Models/Playlist.php @@ -0,0 +1,29 @@ + 'int', + ]; + + public function songs() + { + return $this->belongsToMany(Song::class); + } + + public function user() + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Setting.php b/app/Models/Setting.php new file mode 100644 index 00000000..84917e7f --- /dev/null +++ b/app/Models/Setting.php @@ -0,0 +1,69 @@ +value; + } + + return ''; + } + + /** + * Set a setting (no pun) value. + * + * @param string|array $key The key of the setting, or an associative array of settings, + * in which case $value will be discarded. + * @param mixed $value + * + */ + public static function set($key, $value = null) + { + if (is_array($key)) { + foreach ($key as $k => $v) { + self::set($k, $v); + } + + return; + } + + self::updateOrCreate(compact('key'), compact('value')); + } + + /** + * Serialize the setting value before saving into the database. + * This makes settings more flexible. + * + * @param mixed $value + */ + public function setValueAttribute($value) + { + $this->attributes['value'] = serialize($value); + } + + public function getValueAttribute($value) + { + return unserialize($value); + } +} diff --git a/app/Models/Song.php b/app/Models/Song.php new file mode 100644 index 00000000..39f22c43 --- /dev/null +++ b/app/Models/Song.php @@ -0,0 +1,68 @@ +belongsTo(Album::class); + } + + public function playlists() + { + return $this->belongsToMany(Playlist::class); + } + + /** + * Sometimes the tags extracted from getID3 are HTML entity encoded. + * This makes sure they are always sane. + * + * @param $value + */ + public function setTitleAttribute($value) + { + $this->attributes['title'] = html_entity_decode($value); + } + + /** + * Some songs don't have a title. + * Fall back to the file name (without extension) for such. + * + * @param $value + * + * @return string + */ + public function getTitleAttribute($value) + { + return $value ?: pathinfo($this->path, PATHINFO_FILENAME); + } + + /** + * Prepare the lyrics for displaying. + * + * @param $value + * + * @return string + */ + public function getLyricsAttribute($value) + { + return nl2br($value); + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 00000000..91849459 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,53 @@ + 'bool', + ]; + + /** + * The attributes excluded from the model's JSON form. + * + * @var array + */ + protected $hidden = ['password', 'remember_token', 'created_at', 'updated_at' ]; + + public function playlists() + { + return $this->hasMany(Playlist::class); + } + + public function interactions() + { + return $this->hasMany(Interaction::class); + } +} diff --git a/app/Policies/.gitkeep b/app/Policies/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 00000000..a3c006a5 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,32 @@ + 'App\Policies\ModelPolicy', + ]; + + /** + * Register any application authentication / authorization services. + * + * @param \Illuminate\Contracts\Auth\Access\Gate $gate + * @return void + */ + public function boot(GateContract $gate) + { + $this->registerPolicies($gate); + + // + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 00000000..a94bd89b --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,45 @@ + [ + 'App\Listeners\EventListener', + ], + ]; + + /** + * Register any other events for your application. + * + * @param \Illuminate\Contracts\Events\Dispatcher $events + */ + public function boot(DispatcherContract $events) + { + parent::boot($events); + + // Generate a unique hash for a song from its path to be the ID + Song::creating(function ($song) { + $song->id = Media::getHash($song->path); + }); + + // Remove the cover file if the album is deleted + Album::deleted(function ($album) { + if ($album->hasCover) { + @unlink(app()->publicPath().'/img/covers/'.$album->cover); + } + }); + } +} diff --git a/app/Providers/MediaServiceProvider.php b/app/Providers/MediaServiceProvider.php new file mode 100644 index 00000000..12e699a8 --- /dev/null +++ b/app/Providers/MediaServiceProvider.php @@ -0,0 +1,31 @@ +singleton('Media', function() { + return new Media(); + }); + } +} diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php new file mode 100644 index 00000000..d50b1c0f --- /dev/null +++ b/app/Providers/RouteServiceProvider.php @@ -0,0 +1,44 @@ +group(['namespace' => $this->namespace], function ($router) { + require app_path('Http/routes.php'); + }); + } +} diff --git a/app/Services/Media.php b/app/Services/Media.php new file mode 100644 index 00000000..12a02cec --- /dev/null +++ b/app/Services/Media.php @@ -0,0 +1,231 @@ +setGetID3(); + } + + /** + * Sync the media. Oh sync the media. + * + * @param string|null $path + * @param SyncMedia $syncCommand The SyncMedia command object, to log to console if executed by artisan. + */ + public function sync($path = null, SyncMedia $syncCommand = null) + { + set_time_limit(env('APP_MAX_SCAN_TIME', 600)); + + $path = $path ?: Setting::get('media_path'); + + $results = [ + 'good' => [], // Updated or added files + 'bad' => [], // Bad files + 'ugly' => [], // Unmodified files + ]; + + // For now we only care about mp3 and ogg files. + // Support for other formats (AAC?) may be added in the future. + $files = Finder::create()->files()->name('/\.(mp3|ogg)$/')->in($path); + + foreach ($files as $file) { + $song = $this->syncFile($file); + + if ($song === true) { + $results['ugly'][] = $file; + } elseif ($song === false) { + $results['bad'][] = $file; + } else { + $results['good'][] = $file; + } + + if ($syncCommand) { + $syncCommand->logToConsole($file->getPathname(), $song); + } + } + + // Delete non-existing songs. + $hashes = array_map(function ($f) { + return $this->getHash($f->getPathname()); + }, array_merge($results['ugly'], $results['good'])); + + Song::whereNotIn('id', $hashes)->delete(); + + // Empty albums and artists should be gone as well. + $inUseAlbums = Song::select('album_id')->groupBy('album_id')->get()->lists('album_id'); + $inUseAlbums[] = Album::UNKNOWN_ID; + Album::whereNotIn('id', $inUseAlbums)->delete(); + + $inUseArtists = Album::select('artist_id')->groupBy('artist_id')->get()->lists('artist_id'); + $inUseArtists[] = Artist::UNKNOWN_ID; + Artist::whereNotIn('id', $inUseArtists)->delete(); + } + + /** + * Sync a song with all available media info against the database. + * + * @param SplFileInfo $file The SplFileInfo instance of the file. + * + * @return bool|Song A Song object on success, + * true if file existing but unmodified, + * or false on error. + */ + public function syncFile(SplFileInfo $file) + { + if (!$info = $this->getInfo($file)) { + return false; + } + + if (!$this->isNewOrChanged($file)) { + return true; + } + + $artist = Artist::get($info['artist']); + $album = Album::get($artist, $info['album']); + + if ($info['cover'] && !$album->has_cover) { + try { + $album->generateCover($info['cover']); + } catch (Exception $e) { + Log::error($e); + } + } + + $info['album_id'] = $album->id; + + unset($info['artist']); + unset($info['album']); + unset($info['cover']); + + $song = Song::updateOrCreate(['id' => $this->getHash($file->getPathname())], $info); + $song->save(); + + return $song; + } + + /** + * Check if a media file is new or changed. + * A file is considered existing and unchanged only when: + * - its hash (ID) can be found in the database, and + * - its last modified time is the same with that of the comparing file. + * + * @param SplFileInfo $file + * + * @return bool + */ + protected function isNewOrChanged(SplFileInfo $file) + { + return !Song::whereIdAndMtime($this->getHash($file->getPathname()), $file->getMTime())->count(); + } + + /** + * Get ID3 info from a file. + * + * @param SplFileInfo $file + * + * @return array|null + */ + public function getInfo(SplFileInfo $file) + { + $info = $this->getID3->analyze($file->getPathname()); + + if (isset($info['error'])) { + return; + } + + // Copy the available tags over to comment. + // This is a helper from getID3, though it doesn't really work well. + // We'll still prefer getting ID3v2 tags directly later. + // Read on. + getid3_lib::CopyTagsToComments($info); + + $props = [ + 'artist' => '', + 'album' => '', + 'title' => '', + 'length' => $info['playtime_seconds'], + 'lyrics' => '', + 'cover' => array_get($info, 'comments.picture', [null])[0], + 'path' => $file->getPathname(), + 'mtime' => $file->getMTime(), + ]; + + if (!$comments = array_get($info, 'comments_html')) { + return $props; + } + + // We prefer id3v2 tags over others. + if (!$artist = array_get($info, 'tags.id3v2.artist', [null])[0]) { + $artist = array_get($comments, 'artist', [''])[0]; + } + + if (!$album = array_get($info, 'tags.id3v2.album', [null])[0]) { + $album = array_get($comments, 'album', [''])[0]; + } + + if (!$title = array_get($info, 'tags.id3v2.title', [null])[0]) { + $title = array_get($comments, 'title', [''])[0]; + } + + if (!$lyrics = array_get($info, 'tags.id3v2.unsynchronised_lyric', [null])[0]) { + $lyrics = array_get($comments, 'unsynchronised_lyric', [''])[0]; + } + + $props['artist'] = trim($artist); + $props['album'] = trim($album); + $props['title'] = trim($title); + $props['lyrics'] = trim($lyrics); + + return $props; + } + + /** + * Generate a unique hash for a file path. + * + * @param $path + * + * @return string + */ + public function getHash($path) + { + return md5(config('app.key').$path); + } + + /** + * @return getID3 + */ + public function getGetID3() + { + return $this->getID3; + } + + /** + * @param getID3 $getID3 + */ + public function setGetID3($getID3 = null) + { + $this->getID3 = $getID3 ?: new getID3(); + } +} diff --git a/app/Traits/CanFilterByUser.php b/app/Traits/CanFilterByUser.php new file mode 100644 index 00000000..2bbba271 --- /dev/null +++ b/app/Traits/CanFilterByUser.php @@ -0,0 +1,16 @@ +whereUserId(auth()->user()->id); + } +} diff --git a/apple-touch-icon-precomposed.png b/apple-touch-icon-precomposed.png new file mode 100644 index 00000000..815d35ec Binary files /dev/null and b/apple-touch-icon-precomposed.png differ diff --git a/artisan b/artisan new file mode 100755 index 00000000..df630d0d --- /dev/null +++ b/artisan @@ -0,0 +1,51 @@ +#!/usr/bin/env php +make(Illuminate\Contracts\Console\Kernel::class); + +$status = $kernel->handle( + $input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +/* +|-------------------------------------------------------------------------- +| Shutdown The Application +|-------------------------------------------------------------------------- +| +| Once Artisan has finished running. We will fire off the shutdown events +| so that any final work may be done by the application before we shut +| down the process. This is the last thing to happen to the request. +| +*/ + +$kernel->terminate($input, $status); + +exit($status); diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 00000000..e973c456 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,55 @@ +singleton( + Illuminate\Contracts\Http\Kernel::class, + App\Http\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Console\Kernel::class, + App\Console\Kernel::class +); + +$app->singleton( + Illuminate\Contracts\Debug\ExceptionHandler::class, + App\Exceptions\Handler::class +); + +/* +|-------------------------------------------------------------------------- +| Return The Application +|-------------------------------------------------------------------------- +| +| This script returns the application instance. The instance is given to +| the calling script so we can separate the building of the instances +| from the actual running of the application and sending responses. +| +*/ + +return $app; diff --git a/bootstrap/autoload.php b/bootstrap/autoload.php new file mode 100644 index 00000000..38301379 --- /dev/null +++ b/bootstrap/autoload.php @@ -0,0 +1,34 @@ +" + ], + "moduleType": [ + "es6" + ], + "keywords": [ + "koel", + "audio", + "streaming", + "mp3" + ], + "license": "MIT", + "homepage": "http://koel.phanan.net", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ], + "devDependencies": { + "plyr": "~1.3.6", + "fontawesome": "~4.5.0" + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..fa84a16f --- /dev/null +++ b/composer.json @@ -0,0 +1,54 @@ +{ + "name": "phanan/koel", + "description": "Personal audio streaming service that works.", + "keywords": ["audio", "stream", "mp3"], + "license": "MIT", + "type": "project", + "require": { + "php": ">=5.5.9", + "laravel/framework": "5.1.*", + "james-heinrich/getid3": "^1.9" + }, + "require-dev": { + "fzaninotto/faker": "~1.4", + "mockery/mockery": "0.9.*", + "phpunit/phpunit": "~5.0", + "phpspec/phpspec": "~2.1", + "barryvdh/laravel-ide-helper": "^2.1", + "phanan/cascading-config": "~2.0" + }, + "autoload": { + "classmap": [ + "database" + ], + "psr-4": { + "App\\": "app/" + } + }, + "autoload-dev": { + "classmap": [ + "tests/TestCase.php" + ] + }, + "scripts": { + "post-install-cmd": [ + "php artisan clear-compiled", + "php artisan optimize" + ], + "pre-update-cmd": [ + "php artisan clear-compiled" + ], + "post-update-cmd": [ + "php artisan optimize" + ], + "post-root-package-install": [ + "php -r \"copy('.env.example', '.env');\"" + ], + "post-create-project-cmd": [ + "php artisan key:generate" + ] + }, + "config": { + "preferred-install": "dist" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..cf3df89f --- /dev/null +++ b/composer.lock @@ -0,0 +1,3241 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", + "This file is @generated automatically" + ], + "hash": "860169bf0630a0bdc331d0aa917c2054", + "content-hash": "440407e5bd2fab148c80ef9147a42b88", + "packages": [ + { + "name": "classpreloader/classpreloader", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/ClassPreloader/ClassPreloader.git", + "reference": "9b10b913c2bdf90c3d2e0d726b454fb7f77c552a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ClassPreloader/ClassPreloader/zipball/9b10b913c2bdf90c3d2e0d726b454fb7f77c552a", + "reference": "9b10b913c2bdf90c3d2e0d726b454fb7f77c552a", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^1.0|^2.0", + "php": ">=5.5.9" + }, + "require-dev": { + "phpunit/phpunit": "^4.8|^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "ClassPreloader\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com" + }, + { + "name": "Graham Campbell", + "email": "graham@alt-three.com" + } + ], + "description": "Helps class loading performance by generating a single PHP file containing all of the autoloaded files for a specific use case", + "keywords": [ + "autoload", + "class", + "preload" + ], + "time": "2015-11-09 22:51:51" + }, + { + "name": "danielstjules/stringy", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/danielstjules/Stringy.git", + "reference": "4749c205db47ee5b32e8d1adf6d9aff8db6caf3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/danielstjules/Stringy/zipball/4749c205db47ee5b32e8d1adf6d9aff8db6caf3b", + "reference": "4749c205db47ee5b32e8d1adf6d9aff8db6caf3b", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Stringy\\": "src/" + }, + "files": [ + "src/Create.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Daniel St. Jules", + "email": "danielst.jules@gmail.com", + "homepage": "http://www.danielstjules.com" + } + ], + "description": "A string manipulation library with multibyte support", + "homepage": "https://github.com/danielstjules/Stringy", + "keywords": [ + "UTF", + "helpers", + "manipulation", + "methods", + "multibyte", + "string", + "utf-8", + "utility", + "utils" + ], + "time": "2015-07-23 00:54:12" + }, + { + "name": "dnoegel/php-xdg-base-dir", + "version": "0.1", + "source": { + "type": "git", + "url": "https://github.com/dnoegel/php-xdg-base-dir.git", + "reference": "265b8593498b997dc2d31e75b89f053b5cc9621a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dnoegel/php-xdg-base-dir/zipball/265b8593498b997dc2d31e75b89f053b5cc9621a", + "reference": "265b8593498b997dc2d31e75b89f053b5cc9621a", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "@stable" + }, + "type": "project", + "autoload": { + "psr-4": { + "XdgBaseDir\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "implementation of xdg base directory specification for php", + "time": "2014-10-24 07:27:01" + }, + { + "name": "doctrine/inflector", + "version": "v1.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "90b2128806bfde671b6952ab8bea493942c1fdae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/90b2128806bfde671b6952ab8bea493942c1fdae", + "reference": "90b2128806bfde671b6952ab8bea493942c1fdae", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "4.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-0": { + "Doctrine\\Common\\Inflector\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "Common String Manipulations with regard to casing and singular/plural rules.", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "inflection", + "pluralize", + "singularize", + "string" + ], + "time": "2015-11-06 14:35:42" + }, + { + "name": "jakub-onderka/php-console-color", + "version": "0.1", + "source": { + "type": "git", + "url": "https://github.com/JakubOnderka/PHP-Console-Color.git", + "reference": "e0b393dacf7703fc36a4efc3df1435485197e6c1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JakubOnderka/PHP-Console-Color/zipball/e0b393dacf7703fc36a4efc3df1435485197e6c1", + "reference": "e0b393dacf7703fc36a4efc3df1435485197e6c1", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "jakub-onderka/php-code-style": "1.0", + "jakub-onderka/php-parallel-lint": "0.*", + "jakub-onderka/php-var-dump-check": "0.*", + "phpunit/phpunit": "3.7.*", + "squizlabs/php_codesniffer": "1.*" + }, + "type": "library", + "autoload": { + "psr-0": { + "JakubOnderka\\PhpConsoleColor": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "jakub.onderka@gmail.com", + "homepage": "http://www.acci.cz" + } + ], + "time": "2014-04-08 15:00:19" + }, + { + "name": "jakub-onderka/php-console-highlighter", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/JakubOnderka/PHP-Console-Highlighter.git", + "reference": "7daa75df45242c8d5b75a22c00a201e7954e4fb5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JakubOnderka/PHP-Console-Highlighter/zipball/7daa75df45242c8d5b75a22c00a201e7954e4fb5", + "reference": "7daa75df45242c8d5b75a22c00a201e7954e4fb5", + "shasum": "" + }, + "require": { + "jakub-onderka/php-console-color": "~0.1", + "php": ">=5.3.0" + }, + "require-dev": { + "jakub-onderka/php-code-style": "~1.0", + "jakub-onderka/php-parallel-lint": "~0.5", + "jakub-onderka/php-var-dump-check": "~0.1", + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~1.5" + }, + "type": "library", + "autoload": { + "psr-0": { + "JakubOnderka\\PhpConsoleHighlighter": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jakub Onderka", + "email": "acci@acci.cz", + "homepage": "http://www.acci.cz/" + } + ], + "time": "2015-04-20 18:58:01" + }, + { + "name": "james-heinrich/getid3", + "version": "v1.9.10", + "source": { + "type": "git", + "url": "https://github.com/JamesHeinrich/getID3.git", + "reference": "835a0db62993cb1d85925f9a8aefe13e4ebe2b67" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/JamesHeinrich/getID3/zipball/835a0db62993cb1d85925f9a8aefe13e4ebe2b67", + "reference": "835a0db62993cb1d85925f9a8aefe13e4ebe2b67", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "getid3/getid3.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL" + ], + "description": "PHP script that extracts useful information from popular multimedia file formats", + "homepage": "http://www.getid3.org/", + "keywords": [ + "codecs", + "php", + "tags" + ], + "time": "2015-09-14 05:42:21" + }, + { + "name": "jeremeamia/SuperClosure", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/jeremeamia/super_closure.git", + "reference": "b712f39c671e5ead60c7ebfe662545456aade833" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jeremeamia/super_closure/zipball/b712f39c671e5ead60c7ebfe662545456aade833", + "reference": "b712f39c671e5ead60c7ebfe662545456aade833", + "shasum": "" + }, + "require": { + "nikic/php-parser": "~1.0", + "php": ">=5.4" + }, + "require-dev": { + "codeclimate/php-test-reporter": "~0.1.2", + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "SuperClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia", + "role": "Developer" + } + ], + "description": "Serialize Closure objects, including their context and binding", + "homepage": "https://github.com/jeremeamia/super_closure", + "keywords": [ + "closure", + "function", + "lambda", + "parser", + "serializable", + "serialize", + "tokenizer" + ], + "time": "2015-03-11 20:06:43" + }, + { + "name": "laravel/framework", + "version": "v5.1.24", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "875baf2d1645ce23e2ec0bf94fa7bb3e7fbfd6ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/875baf2d1645ce23e2ec0bf94fa7bb3e7fbfd6ed", + "reference": "875baf2d1645ce23e2ec0bf94fa7bb3e7fbfd6ed", + "shasum": "" + }, + "require": { + "classpreloader/classpreloader": "~2.0|~3.0", + "danielstjules/stringy": "~1.8", + "doctrine/inflector": "~1.0", + "ext-mbstring": "*", + "ext-openssl": "*", + "jeremeamia/superclosure": "~2.0", + "league/flysystem": "~1.0", + "monolog/monolog": "~1.11", + "mtdowling/cron-expression": "~1.0", + "nesbot/carbon": "~1.19", + "paragonie/random_compat": "~1.1", + "php": ">=5.5.9", + "psy/psysh": "0.6.*", + "swiftmailer/swiftmailer": "~5.1", + "symfony/console": "2.7.*", + "symfony/css-selector": "2.7.*", + "symfony/debug": "2.7.*", + "symfony/dom-crawler": "2.7.*", + "symfony/finder": "2.7.*", + "symfony/http-foundation": "2.7.*", + "symfony/http-kernel": "2.7.*", + "symfony/process": "2.7.*", + "symfony/routing": "2.7.*", + "symfony/translation": "2.7.*", + "symfony/var-dumper": "2.7.*", + "vlucas/phpdotenv": "~1.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/exception": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/foundation": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/log": "self.version", + "illuminate/mail": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version" + }, + "require-dev": { + "aws/aws-sdk-php": "~3.0", + "iron-io/iron_mq": "~2.0", + "mockery/mockery": "~0.9.1", + "pda/pheanstalk": "~3.0", + "phpunit/phpunit": "~4.0", + "predis/predis": "~1.0" + }, + "suggest": { + "aws/aws-sdk-php": "Required to use the SQS queue driver and SES mail driver (~3.0).", + "doctrine/dbal": "Required to rename columns and drop SQLite columns (~2.4).", + "fzaninotto/faker": "Required to use the eloquent factory builder (~1.4).", + "guzzlehttp/guzzle": "Required to use the Mailgun and Mandrill mail drivers (~5.3|~6.0).", + "iron-io/iron_mq": "Required to use the iron queue driver (~2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (~1.0).", + "league/flysystem-rackspace": "Required to use the Flysystem Rackspace driver (~1.0).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (~3.0).", + "predis/predis": "Required to use the redis cache and queue drivers (~1.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (~2.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/Illuminate/Queue/IlluminateQueueClosure.php" + ], + "files": [ + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylorotwell@gmail.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "http://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "time": "2015-11-11 22:45:42" + }, + { + "name": "league/flysystem", + "version": "1.0.15", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "31525caf9e8772683672fefd8a1ca0c0736020f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/31525caf9e8772683672fefd8a1ca0c0736020f4", + "reference": "31525caf9e8772683672fefd8a1ca0c0736020f4", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "conflict": { + "league/flysystem-sftp": "<1.0.6" + }, + "require-dev": { + "ext-fileinfo": "*", + "mockery/mockery": "~0.9", + "phpspec/phpspec": "^2.2", + "phpspec/prophecy-phpunit": "~1.0", + "phpunit/phpunit": "~4.1" + }, + "suggest": { + "ext-fileinfo": "Required for MimeType", + "league/flysystem-aws-s3-v2": "Allows you to use S3 storage with AWS SDK v2", + "league/flysystem-aws-s3-v3": "Allows you to use S3 storage with AWS SDK v3", + "league/flysystem-azure": "Allows you to use Windows Azure Blob storage", + "league/flysystem-cached-adapter": "Flysystem adapter decorator for metadata caching", + "league/flysystem-copy": "Allows you to use Copy.com storage", + "league/flysystem-dropbox": "Allows you to use Dropbox storage", + "league/flysystem-eventable-filesystem": "Allows you to use EventableFilesystem", + "league/flysystem-rackspace": "Allows you to use Rackspace Cloud Files", + "league/flysystem-sftp": "Allows you to use SFTP server storage via phpseclib", + "league/flysystem-webdav": "Allows you to use WebDAV storage", + "league/flysystem-ziparchive": "Allows you to use ZipArchive adapter" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frenky.net" + } + ], + "description": "Filesystem abstraction: Many filesystems, one API.", + "keywords": [ + "Cloud Files", + "WebDAV", + "abstraction", + "aws", + "cloud", + "copy.com", + "dropbox", + "file systems", + "files", + "filesystem", + "filesystems", + "ftp", + "rackspace", + "remote", + "s3", + "sftp", + "storage" + ], + "time": "2015-09-30 22:26:59" + }, + { + "name": "monolog/monolog", + "version": "1.17.2", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "bee7f0dc9c3e0b69a6039697533dca1e845c8c24" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/bee7f0dc9c3e0b69a6039697533dca1e845c8c24", + "reference": "bee7f0dc9c3e0b69a6039697533dca1e845c8c24", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "psr/log": "~1.0" + }, + "provide": { + "psr/log-implementation": "1.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9", + "doctrine/couchdb": "~1.0@dev", + "graylog2/gelf-php": "~1.0", + "jakub-onderka/php-parallel-lint": "0.9", + "php-console/php-console": "^3.1.3", + "phpunit/phpunit": "~4.5", + "phpunit/phpunit-mock-objects": "2.3.0", + "raven/raven": "^0.13", + "ruflin/elastica": ">=0.90 <3.0", + "swiftmailer/swiftmailer": "~5.3", + "videlalvaro/php-amqplib": "~2.4" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-mongo": "Allow sending log messages to a MongoDB server", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "php-console/php-console": "Allow sending log messages to Google Chrome", + "raven/raven": "Allow sending log messages to a Sentry server", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server", + "videlalvaro/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.16.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "http://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "time": "2015-10-14 12:51:02" + }, + { + "name": "mtdowling/cron-expression", + "version": "v1.0.4", + "source": { + "type": "git", + "url": "https://github.com/mtdowling/cron-expression.git", + "reference": "fd92e883195e5dfa77720b1868cf084b08be4412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mtdowling/cron-expression/zipball/fd92e883195e5dfa77720b1868cf084b08be4412", + "reference": "fd92e883195e5dfa77720b1868cf084b08be4412", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "4.*" + }, + "type": "library", + "autoload": { + "psr-0": { + "Cron": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "time": "2015-01-11 23:07:46" + }, + { + "name": "nesbot/carbon", + "version": "1.21.0", + "source": { + "type": "git", + "url": "https://github.com/briannesbitt/Carbon.git", + "reference": "7b08ec6f75791e130012f206e3f7b0e76e18e3d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/7b08ec6f75791e130012f206e3f7b0e76e18e3d7", + "reference": "7b08ec6f75791e130012f206e3f7b0e76e18e3d7", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "symfony/translation": "~2.6|~3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.0|~5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "http://nesbot.com" + } + ], + "description": "A simple API extension for DateTime.", + "homepage": "http://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "time": "2015-11-04 20:07:17" + }, + { + "name": "nikic/php-parser", + "version": "v1.4.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "f78af2c9c86107aa1a34cd1dbb5bbe9eeb0d9f51" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/f78af2c9c86107aa1a34cd1dbb5bbe9eeb0d9f51", + "reference": "f78af2c9c86107aa1a34cd1dbb5bbe9eeb0d9f51", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "files": [ + "lib/bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "time": "2015-09-19 14:15:08" + }, + { + "name": "paragonie/random_compat", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/paragonie/random_compat.git", + "reference": "19f765b66c6fbb56ee3b11bc16d52e38eebdc295" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/paragonie/random_compat/zipball/19f765b66c6fbb56ee3b11bc16d52e38eebdc295", + "reference": "19f765b66c6fbb56ee3b11bc16d52e38eebdc295", + "shasum": "" + }, + "require": { + "php": ">=5.2.0" + }, + "require-dev": { + "phpunit/phpunit": "4.*|5.*" + }, + "suggest": { + "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + }, + "type": "library", + "autoload": { + "files": [ + "lib/random.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paragon Initiative Enterprises", + "email": "security@paragonie.com", + "homepage": "https://paragonie.com" + } + ], + "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", + "keywords": [ + "csprng", + "pseudorandom", + "random" + ], + "time": "2015-11-10 00:45:41" + }, + { + "name": "psr/log", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/fe0936ee26643249e916849d48e3a51d5f5e278b", + "reference": "fe0936ee26643249e916849d48e3a51d5f5e278b", + "shasum": "" + }, + "type": "library", + "autoload": { + "psr-0": { + "Psr\\Log\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "time": "2012-12-21 11:40:51" + }, + { + "name": "psy/psysh", + "version": "v0.6.1", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "0f04df0b23663799a8941fae13cd8e6299bde3ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/0f04df0b23663799a8941fae13cd8e6299bde3ed", + "reference": "0f04df0b23663799a8941fae13cd8e6299bde3ed", + "shasum": "" + }, + "require": { + "dnoegel/php-xdg-base-dir": "0.1", + "jakub-onderka/php-console-highlighter": "0.3.*", + "nikic/php-parser": "^1.2.1|~2.0", + "php": ">=5.3.9", + "symfony/console": "~2.3.10|^2.4.2|~3.0", + "symfony/var-dumper": "~2.7|~3.0" + }, + "require-dev": { + "fabpot/php-cs-fixer": "~1.5", + "phpunit/phpunit": "~3.7|~4.0|~5.0", + "squizlabs/php_codesniffer": "~2.0", + "symfony/finder": "~2.1|~3.0" + }, + "suggest": { + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-pdo-sqlite": "The doc command requires SQLite to work.", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well.", + "ext-readline": "Enables support for arrow-key history navigation, and showing and manipulating command history." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "0.7.x-dev" + } + }, + "autoload": { + "files": [ + "src/Psy/functions.php" + ], + "psr-4": { + "Psy\\": "src/Psy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info", + "homepage": "http://justinhileman.com" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "http://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "time": "2015-11-12 16:18:56" + }, + { + "name": "swiftmailer/swiftmailer", + "version": "v5.4.1", + "source": { + "type": "git", + "url": "https://github.com/swiftmailer/swiftmailer.git", + "reference": "0697e6aa65c83edf97bb0f23d8763f94e3f11421" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/swiftmailer/swiftmailer/zipball/0697e6aa65c83edf97bb0f23d8763f94e3f11421", + "reference": "0697e6aa65c83edf97bb0f23d8763f94e3f11421", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "mockery/mockery": "~0.9.1,<0.9.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.4-dev" + } + }, + "autoload": { + "files": [ + "lib/swift_required.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Corbyn" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "Swiftmailer, free feature-rich PHP mailer", + "homepage": "http://swiftmailer.org", + "keywords": [ + "email", + "mail", + "mailer" + ], + "time": "2015-06-06 14:19:39" + }, + { + "name": "symfony/console", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "5efd632294c8320ea52492db22292ff853a43766" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/5efd632294c8320ea52492db22292ff853a43766", + "reference": "5efd632294c8320ea52492db22292ff853a43766", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/event-dispatcher": "~2.1", + "symfony/process": "~2.1" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/process": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "time": "2015-10-20 14:38:46" + }, + { + "name": "symfony/css-selector", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "e1b865b26be4a56d22a8dee398375044a80c865b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/e1b865b26be4a56d22a8dee398375044a80c865b", + "reference": "e1b865b26be4a56d22a8dee398375044a80c865b", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony CssSelector Component", + "homepage": "https://symfony.com", + "time": "2015-10-11 09:39:48" + }, + { + "name": "symfony/debug", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug.git", + "reference": "fb9e6887db716939f41af0ba8ef38a1582eb501b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug/zipball/fb9e6887db716939f41af0ba8ef38a1582eb501b", + "reference": "fb9e6887db716939f41af0ba8ef38a1582eb501b", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "psr/log": "~1.0" + }, + "conflict": { + "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2" + }, + "require-dev": { + "symfony/class-loader": "~2.2", + "symfony/http-kernel": "~2.3.24|~2.5.9|~2.6,>=2.6.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Debug\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Debug Component", + "homepage": "https://symfony.com", + "time": "2015-10-11 09:39:48" + }, + { + "name": "symfony/dom-crawler", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "5fef7d8b80d8f9992df99d8ee283f420484c9612" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/5fef7d8b80d8f9992df99d8ee283f420484c9612", + "reference": "5fef7d8b80d8f9992df99d8ee283f420484c9612", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "require-dev": { + "symfony/css-selector": "~2.3" + }, + "suggest": { + "symfony/css-selector": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DomCrawler Component", + "homepage": "https://symfony.com", + "time": "2015-10-11 09:39:48" + }, + { + "name": "symfony/event-dispatcher", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "87a5db5ea887763fa3a31a5471b512ff1596d9b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/87a5db5ea887763fa3a31a5471b512ff1596d9b8", + "reference": "87a5db5ea887763fa3a31a5471b512ff1596d9b8", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~2.0,>=2.0.5", + "symfony/dependency-injection": "~2.6", + "symfony/expression-language": "~2.6", + "symfony/stopwatch": "~2.3" + }, + "suggest": { + "symfony/dependency-injection": "", + "symfony/http-kernel": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony EventDispatcher Component", + "homepage": "https://symfony.com", + "time": "2015-10-11 09:39:48" + }, + { + "name": "symfony/finder", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "2ffb4e9598db3c48eb6d0ae73b04bbf09280c59d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/2ffb4e9598db3c48eb6d0ae73b04bbf09280c59d", + "reference": "2ffb4e9598db3c48eb6d0ae73b04bbf09280c59d", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Finder Component", + "homepage": "https://symfony.com", + "time": "2015-10-11 09:39:48" + }, + { + "name": "symfony/http-foundation", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "7598eea151ae3d4134df1f9957364b17809eea75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/7598eea151ae3d4134df1f9957364b17809eea75", + "reference": "7598eea151ae3d4134df1f9957364b17809eea75", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "require-dev": { + "symfony/expression-language": "~2.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony HttpFoundation Component", + "homepage": "https://symfony.com", + "time": "2015-10-23 14:47:27" + }, + { + "name": "symfony/http-kernel", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "4260f2273a446a6715063dc9ca89fd0c475c2f77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/4260f2273a446a6715063dc9ca89fd0c475c2f77", + "reference": "4260f2273a446a6715063dc9ca89fd0c475c2f77", + "shasum": "" + }, + "require": { + "php": ">=5.3.9", + "psr/log": "~1.0", + "symfony/debug": "~2.6,>=2.6.2", + "symfony/event-dispatcher": "~2.6,>=2.6.7", + "symfony/http-foundation": "~2.5,>=2.5.4" + }, + "conflict": { + "symfony/config": "<2.7" + }, + "require-dev": { + "symfony/browser-kit": "~2.3", + "symfony/class-loader": "~2.1", + "symfony/config": "~2.7", + "symfony/console": "~2.3", + "symfony/css-selector": "~2.0,>=2.0.5", + "symfony/dependency-injection": "~2.2", + "symfony/dom-crawler": "~2.0,>=2.0.5", + "symfony/expression-language": "~2.4", + "symfony/finder": "~2.0,>=2.0.5", + "symfony/process": "~2.0,>=2.0.5", + "symfony/routing": "~2.2", + "symfony/stopwatch": "~2.3", + "symfony/templating": "~2.2", + "symfony/translation": "~2.0,>=2.0.5", + "symfony/var-dumper": "~2.6" + }, + "suggest": { + "symfony/browser-kit": "", + "symfony/class-loader": "", + "symfony/config": "", + "symfony/console": "", + "symfony/dependency-injection": "", + "symfony/finder": "", + "symfony/var-dumper": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony HttpKernel Component", + "homepage": "https://symfony.com", + "time": "2015-10-27 19:07:21" + }, + { + "name": "symfony/process", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "4a959dd4e19c2c5d7512689413921e0a74386ec7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/4a959dd4e19c2c5d7512689413921e0a74386ec7", + "reference": "4a959dd4e19c2c5d7512689413921e0a74386ec7", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "time": "2015-10-23 14:47:27" + }, + { + "name": "symfony/routing", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "f353e1f588679c3ec987624e6c617646bd01ba38" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/f353e1f588679c3ec987624e6c617646bd01ba38", + "reference": "f353e1f588679c3ec987624e6c617646bd01ba38", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "conflict": { + "symfony/config": "<2.7" + }, + "require-dev": { + "doctrine/annotations": "~1.0", + "doctrine/common": "~2.2", + "psr/log": "~1.0", + "symfony/config": "~2.7", + "symfony/expression-language": "~2.4", + "symfony/http-foundation": "~2.3", + "symfony/yaml": "~2.0,>=2.0.5" + }, + "suggest": { + "doctrine/annotations": "For using the annotation loader", + "symfony/config": "For using the all-in-one router or any loader", + "symfony/expression-language": "For using expression matching", + "symfony/yaml": "For using the YAML loader" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Routing Component", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "time": "2015-10-27 15:38:06" + }, + { + "name": "symfony/translation", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "6ccd9289ec1c71d01a49d83480de3b5293ce30c8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/6ccd9289ec1c71d01a49d83480de3b5293ce30c8", + "reference": "6ccd9289ec1c71d01a49d83480de3b5293ce30c8", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "conflict": { + "symfony/config": "<2.7" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~2.7", + "symfony/intl": "~2.4", + "symfony/yaml": "~2.2" + }, + "suggest": { + "psr/log": "To use logging capability in translator", + "symfony/config": "", + "symfony/yaml": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Translation\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Translation Component", + "homepage": "https://symfony.com", + "time": "2015-10-27 15:38:06" + }, + { + "name": "symfony/var-dumper", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "eb033050050916b6bfa51be71009ef67b16046c9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/eb033050050916b6bfa51be71009ef67b16046c9", + "reference": "eb033050050916b6bfa51be71009ef67b16046c9", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "suggest": { + "ext-symfony_debug": "" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony mechanism for exploring and dumping PHP variables", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "time": "2015-10-25 17:17:38" + }, + { + "name": "vlucas/phpdotenv", + "version": "v1.1.1", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "0cac554ce06277e33ddf9f0b7ade4b8bbf2af3fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/0cac554ce06277e33ddf9f0b7ade4b8bbf2af3fa", + "reference": "0cac554ce06277e33ddf9f0b7ade4b8bbf2af3fa", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "Dotenv": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD" + ], + "authors": [ + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "http://www.vancelucas.com" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "homepage": "http://github.com/vlucas/phpdotenv", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "time": "2015-05-30 15:59:26" + } + ], + "packages-dev": [ + { + "name": "barryvdh/laravel-ide-helper", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/barryvdh/laravel-ide-helper.git", + "reference": "83999f8467374adcb8893f566c9171c9d9691f50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/barryvdh/laravel-ide-helper/zipball/83999f8467374adcb8893f566c9171c9d9691f50", + "reference": "83999f8467374adcb8893f566c9171c9d9691f50", + "shasum": "" + }, + "require": { + "illuminate/console": "5.0.x|5.1.x", + "illuminate/filesystem": "5.0.x|5.1.x", + "illuminate/support": "5.0.x|5.1.x", + "php": ">=5.4.0", + "phpdocumentor/reflection-docblock": "2.0.4", + "symfony/class-loader": "~2.3" + }, + "require-dev": { + "doctrine/dbal": "~2.3" + }, + "suggest": { + "doctrine/dbal": "Load information from the database about models for phpdocs (~2.3)" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1-dev" + } + }, + "autoload": { + "psr-4": { + "Barryvdh\\LaravelIdeHelper\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Barry vd. Heuvel", + "email": "barryvdh@gmail.com" + } + ], + "description": "Laravel IDE Helper, generates correct PHPDocs for all Facade classes, to improve auto-completion.", + "keywords": [ + "autocomplete", + "codeintel", + "helper", + "ide", + "laravel", + "netbeans", + "phpdoc", + "phpstorm", + "sublime" + ], + "time": "2015-08-13 11:40:00" + }, + { + "name": "doctrine/instantiator", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d", + "reference": "8e884e78f9f0eb1329e445619e04456e64d8051d", + "shasum": "" + }, + "require": { + "php": ">=5.3,<8.0-DEV" + }, + "require-dev": { + "athletic/athletic": "~0.1.8", + "ext-pdo": "*", + "ext-phar": "*", + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "http://ocramius.github.com/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://github.com/doctrine/instantiator", + "keywords": [ + "constructor", + "instantiate" + ], + "time": "2015-06-14 21:17:01" + }, + { + "name": "fzaninotto/faker", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/fzaninotto/Faker.git", + "reference": "d0190b156bcca848d401fb80f31f504f37141c8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fzaninotto/Faker/zipball/d0190b156bcca848d401fb80f31f504f37141c8d", + "reference": "d0190b156bcca848d401fb80f31f504f37141c8d", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.0", + "squizlabs/php_codesniffer": "~1.5" + }, + "suggest": { + "ext-intl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "time": "2015-05-29 06:29:14" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v1.2.2", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b37020aa976fa52d3de9aa904aa2522dc518f79c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b37020aa976fa52d3de9aa904aa2522dc518f79c", + "reference": "b37020aa976fa52d3de9aa904aa2522dc518f79c", + "shasum": "" + }, + "require": { + "php": ">=5.3.2" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpunit/php-file-iterator": "1.3.3", + "satooshi/php-coveralls": "dev-master" + }, + "type": "library", + "autoload": { + "classmap": [ + "hamcrest" + ], + "files": [ + "hamcrest/Hamcrest.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "time": "2015-05-11 14:41:42" + }, + { + "name": "mockery/mockery", + "version": "0.9.4", + "source": { + "type": "git", + "url": "https://github.com/padraic/mockery.git", + "reference": "70bba85e4aabc9449626651f48b9018ede04f86b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/padraic/mockery/zipball/70bba85e4aabc9449626651f48b9018ede04f86b", + "reference": "70bba85e4aabc9449626651f48b9018ede04f86b", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "~1.1", + "lib-pcre": ">=7.0", + "php": ">=5.3.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "0.9.x-dev" + } + }, + "autoload": { + "psr-0": { + "Mockery": "library/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "http://blog.astrumfutura.com" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "http://davedevelopment.co.uk" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework for use in unit testing with PHPUnit, PHPSpec or any other testing framework. Its core goal is to offer a test double framework with a succinct API capable of clearly defining all possible object operations and interactions using a human readable Domain Specific Language (DSL). Designed as a drop in alternative to PHPUnit's phpunit-mock-objects library, Mockery is easy to integrate with PHPUnit and can operate alongside phpunit-mock-objects without the World ending.", + "homepage": "http://github.com/padraic/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "time": "2015-04-02 19:54:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "e3abefcd7f106677fd352cd7c187d6c969aa9ddc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/e3abefcd7f106677fd352cd7c187d6c969aa9ddc", + "reference": "e3abefcd7f106677fd352cd7c187d6c969aa9ddc", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "doctrine/collections": "1.*", + "phpunit/phpunit": "~4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "homepage": "https://github.com/myclabs/DeepCopy", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "time": "2015-11-07 22:20:37" + }, + { + "name": "phanan/cascading-config", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phanan/cascading-config.git", + "reference": "02efc75ae964f63f0c2a40a22654111fecea895c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phanan/cascading-config/zipball/02efc75ae964f63f0c2a40a22654111fecea895c", + "reference": "02efc75ae964f63f0c2a40a22654111fecea895c", + "shasum": "" + }, + "require-dev": { + "laravel/framework": "~5.1", + "laravel/lumen-framework": "~5.1.6", + "phpunit/phpunit": "~5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhanAn\\CascadingConfig\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Phan An", + "email": "me@phanan.net", + "homepage": "http://phanan.net" + } + ], + "description": "Bringing the cascading configuration system back to Laravel 5.", + "homepage": "https://github.com/phanan/cascading-config", + "keywords": [ + "cascade", + "cascading", + "config", + "configuration", + "laravel", + "laravel 5" + ], + "time": "2015-11-16 17:01:33" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d68dbdc53dc358a816f00b300704702b2eaff7b8", + "reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "suggest": { + "dflydev/markdown": "~1.0", + "erusev/parsedown": "~1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "phpDocumentor": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "time": "2015-02-03 12:10:50" + }, + { + "name": "phpspec/php-diff", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/phpspec/php-diff.git", + "reference": "30e103d19519fe678ae64a60d77884ef3d71b28a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/php-diff/zipball/30e103d19519fe678ae64a60d77884ef3d71b28a", + "reference": "30e103d19519fe678ae64a60d77884ef3d71b28a", + "shasum": "" + }, + "type": "library", + "autoload": { + "psr-0": { + "Diff": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Chris Boulton", + "homepage": "http://github.com/chrisboulton", + "role": "Original developer" + } + ], + "description": "A comprehensive library for generating differences between two hashable objects (strings or arrays).", + "time": "2013-11-01 13:02:21" + }, + { + "name": "phpspec/phpspec", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/phpspec.git", + "reference": "36635a903bdeb54899d7407bc95610501fd98559" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/phpspec/zipball/36635a903bdeb54899d7407bc95610501fd98559", + "reference": "36635a903bdeb54899d7407bc95610501fd98559", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.1", + "php": ">=5.3.3", + "phpspec/php-diff": "~1.0.0", + "phpspec/prophecy": "~1.4", + "sebastian/exporter": "~1.0", + "symfony/console": "~2.3", + "symfony/event-dispatcher": "~2.1", + "symfony/finder": "~2.1", + "symfony/process": "^2.6", + "symfony/yaml": "~2.1" + }, + "require-dev": { + "behat/behat": "^3.0.11", + "bossa/phpspec2-expect": "~1.0", + "phpunit/phpunit": "~4.4", + "symfony/filesystem": "~2.1" + }, + "suggest": { + "phpspec/nyan-formatters": "~1.0 – Adds Nyan formatters" + }, + "bin": [ + "bin/phpspec" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2.x-dev" + } + }, + "autoload": { + "psr-0": { + "PhpSpec": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "homepage": "http://marcelloduarte.net/" + } + ], + "description": "Specification-oriented BDD framework for PHP 5.3+", + "homepage": "http://phpspec.net/", + "keywords": [ + "BDD", + "SpecBDD", + "TDD", + "spec", + "specification", + "testing", + "tests" + ], + "time": "2015-09-07 07:07:37" + }, + { + "name": "phpspec/prophecy", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/phpspec/prophecy.git", + "reference": "4745ded9307786b730d7a60df5cb5a6c43cf95f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpspec/prophecy/zipball/4745ded9307786b730d7a60df5cb5a6c43cf95f7", + "reference": "4745ded9307786b730d7a60df5cb5a6c43cf95f7", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "phpdocumentor/reflection-docblock": "~2.0", + "sebastian/comparator": "~1.1" + }, + "require-dev": { + "phpspec/phpspec": "~2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "psr-0": { + "Prophecy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Konstantin Kudryashov", + "email": "ever.zet@gmail.com", + "homepage": "http://everzet.com" + }, + { + "name": "Marcello Duarte", + "email": "marcello.duarte@gmail.com" + } + ], + "description": "Highly opinionated mocking framework for PHP 5.3+", + "homepage": "https://github.com/phpspec/prophecy", + "keywords": [ + "Double", + "Dummy", + "fake", + "mock", + "spy", + "stub" + ], + "time": "2015-08-13 10:07:40" + }, + { + "name": "phpunit/php-code-coverage", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "f7bb5cddf4ffe113eeb737b05241adb947b43f9d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f7bb5cddf4ffe113eeb737b05241adb947b43f9d", + "reference": "f7bb5cddf4ffe113eeb737b05241adb947b43f9d", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "phpunit/php-file-iterator": "~1.3", + "phpunit/php-text-template": "~1.2", + "phpunit/php-token-stream": "~1.3", + "sebastian/environment": "^1.3.2", + "sebastian/version": "~1.0" + }, + "require-dev": { + "ext-xdebug": ">=2.1.4", + "phpunit/phpunit": "~5" + }, + "suggest": { + "ext-dom": "*", + "ext-xdebug": ">=2.2.1", + "ext-xmlwriter": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "time": "2015-11-12 21:08:20" + }, + { + "name": "phpunit/php-file-iterator", + "version": "1.4.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6150bf2c35d3fc379e50c7602b75caceaa39dbf0", + "reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "time": "2015-06-21 13:08:43" + }, + { + "name": "phpunit/php-text-template", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "time": "2015-06-21 13:50:34" + }, + { + "name": "phpunit/php-timer", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3e82f4e9fc92665fafd9157568e4dcb01d014e5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3e82f4e9fc92665fafd9157568e4dcb01d014e5b", + "reference": "3e82f4e9fc92665fafd9157568e4dcb01d014e5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "time": "2015-06-21 08:01:12" + }, + { + "name": "phpunit/php-token-stream", + "version": "1.4.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-token-stream.git", + "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", + "reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Wrapper around PHP's tokenizer extension.", + "homepage": "https://github.com/sebastianbergmann/php-token-stream/", + "keywords": [ + "tokenizer" + ], + "time": "2015-09-15 10:49:45" + }, + { + "name": "phpunit/phpunit", + "version": "5.0.9", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "ed084be6b5b912f11c3559e17110f8d8a1e3a8a1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ed084be6b5b912f11c3559e17110f8d8a1e3a8a1", + "reference": "ed084be6b5b912f11c3559e17110f8d8a1e3a8a1", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-pcre": "*", + "ext-reflection": "*", + "ext-spl": "*", + "myclabs/deep-copy": "~1.3", + "php": ">=5.6", + "phpspec/prophecy": "^1.3.1", + "phpunit/php-code-coverage": "~3.0", + "phpunit/php-file-iterator": "~1.4", + "phpunit/php-text-template": "~1.2", + "phpunit/php-timer": ">=1.0.6", + "phpunit/phpunit-mock-objects": ">=3.0", + "sebastian/comparator": "~1.1", + "sebastian/diff": "~1.2", + "sebastian/environment": "~1.3", + "sebastian/exporter": "~1.2", + "sebastian/global-state": "~1.0", + "sebastian/resource-operations": "~1.0", + "sebastian/version": "~1.0", + "symfony/yaml": "~2.1|~3.0" + }, + "suggest": { + "phpunit/php-invoker": "~1.1" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "time": "2015-11-10 21:47:43" + }, + { + "name": "phpunit/phpunit-mock-objects", + "version": "3.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git", + "reference": "b28b029356e65091dfbf8c2bc4ef106ffece509e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/b28b029356e65091dfbf8c2bc4ef106ffece509e", + "reference": "b28b029356e65091dfbf8c2bc4ef106ffece509e", + "shasum": "" + }, + "require": { + "doctrine/instantiator": "^1.0.2", + "php": ">=5.6", + "phpunit/php-text-template": "~1.2", + "sebastian/exporter": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~5" + }, + "suggest": { + "ext-soap": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sb@sebastian-bergmann.de", + "role": "lead" + } + ], + "description": "Mock Object library for PHPUnit", + "homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/", + "keywords": [ + "mock", + "xunit" + ], + "time": "2015-11-09 15:37:17" + }, + { + "name": "sebastian/comparator", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "937efb279bd37a375bcadf584dec0726f84dbf22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/937efb279bd37a375bcadf584dec0726f84dbf22", + "reference": "937efb279bd37a375bcadf584dec0726f84dbf22", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/diff": "~1.2", + "sebastian/exporter": "~1.2" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "http://www.github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "time": "2015-07-26 15:48:44" + }, + { + "name": "sebastian/diff", + "version": "1.3.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "863df9687835c62aa423a22412d26fa2ebde3fd3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/863df9687835c62aa423a22412d26fa2ebde3fd3", + "reference": "863df9687835c62aa423a22412d26fa2ebde3fd3", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Diff implementation", + "homepage": "http://www.github.com/sebastianbergmann/diff", + "keywords": [ + "diff" + ], + "time": "2015-02-22 15:13:53" + }, + { + "name": "sebastian/environment", + "version": "1.3.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "6324c907ce7a52478eeeaede764f48733ef5ae44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/6324c907ce7a52478eeeaede764f48733ef5ae44", + "reference": "6324c907ce7a52478eeeaede764f48733ef5ae44", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "http://www.github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "time": "2015-08-03 06:14:51" + }, + { + "name": "sebastian/exporter", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "7ae5513327cb536431847bcc0c10edba2701064e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/7ae5513327cb536431847bcc0c10edba2701064e", + "reference": "7ae5513327cb536431847bcc0c10edba2701064e", + "shasum": "" + }, + "require": { + "php": ">=5.3.3", + "sebastian/recursion-context": "~1.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.2.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "http://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "time": "2015-06-21 07:55:53" + }, + { + "name": "sebastian/global-state", + "version": "1.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4", + "reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.2" + }, + "suggest": { + "ext-uopz": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "http://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "time": "2015-10-12 03:26:01" + }, + { + "name": "sebastian/recursion-context", + "version": "1.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "994d4a811bafe801fb06dccbee797863ba2792ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/994d4a811bafe801fb06dccbee797863ba2792ba", + "reference": "994d4a811bafe801fb06dccbee797863ba2792ba", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "http://www.github.com/sebastianbergmann/recursion-context", + "time": "2015-06-21 08:04:50" + }, + { + "name": "sebastian/resource-operations", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/resource-operations.git", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "reference": "ce990bb21759f94aeafd30209e8cfcdfa8bc3f52", + "shasum": "" + }, + "require": { + "php": ">=5.6.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides a list of PHP built-in functions that operate on resources", + "homepage": "https://www.github.com/sebastianbergmann/resource-operations", + "time": "2015-07-28 20:34:47" + }, + { + "name": "sebastian/version", + "version": "1.0.6", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6", + "shasum": "" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "time": "2015-06-21 13:59:46" + }, + { + "name": "symfony/class-loader", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/class-loader.git", + "reference": "320f8d2a9cdbcbeb24be602c124aae9d998474a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/class-loader/zipball/320f8d2a9cdbcbeb24be602c124aae9d998474a4", + "reference": "320f8d2a9cdbcbeb24be602c124aae9d998474a4", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "require-dev": { + "symfony/finder": "~2.0,>=2.0.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\ClassLoader\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony ClassLoader Component", + "homepage": "https://symfony.com", + "time": "2015-10-23 14:47:27" + }, + { + "name": "symfony/yaml", + "version": "v2.7.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "eca9019c88fbe250164affd107bc8057771f3f4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/eca9019c88fbe250164affd107bc8057771f3f4d", + "reference": "eca9019c88fbe250164affd107bc8057771f3f4d", + "shasum": "" + }, + "require": { + "php": ">=5.3.9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Yaml Component", + "homepage": "https://symfony.com", + "time": "2015-10-11 09:39:48" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=5.5.9" + }, + "platform-dev": [] +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 00000000..77740211 --- /dev/null +++ b/config/app.php @@ -0,0 +1,206 @@ + 'Personal audio streaming service that works.', + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | your application so that it is used when running Artisan tasks. + | + */ + + 'url' => 'http://localhost', + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. We have gone + | ahead and set this to a sensible default for you out of the box. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by the translation service provider. You are free to set this value + | to any of the locales which will be supported by the application. + | + */ + + 'locale' => 'en', + + /* + |-------------------------------------------------------------------------- + | Application Fallback Locale + |-------------------------------------------------------------------------- + | + | The fallback locale determines the locale to use when the current one + | is not available. You may change the value to correspond to any of + | the language folders that are provided through your application. + | + */ + + 'fallback_locale' => 'en', + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is used by the Illuminate encrypter service and should be set + | to a random, 32 character string, otherwise these encrypted strings + | will not be safe. Please do this before deploying an application! + | + */ + + 'key' => env('APP_KEY', 'SomeRandomString'), + + 'cipher' => 'AES-256-CBC', + + /* + |-------------------------------------------------------------------------- + | Logging Configuration + |-------------------------------------------------------------------------- + | + | Here you may configure the log settings for your application. Out of + | the box, Laravel uses the Monolog PHP logging library. This gives + | you a variety of powerful log handlers / formatters to utilize. + | + | Available Settings: "single", "daily", "syslog", "errorlog" + | + */ + + 'log' => env('APP_LOG', 'single'), + + /* + |-------------------------------------------------------------------------- + | Autoloaded Service Providers + |-------------------------------------------------------------------------- + | + | The service providers listed here will be automatically loaded on the + | request to your application. Feel free to add your own services to + | this array to grant expanded functionality to your applications. + | + */ + + 'providers' => [ + + /* + * Laravel Framework Service Providers... + */ + Illuminate\Foundation\Providers\ArtisanServiceProvider::class, + Illuminate\Auth\AuthServiceProvider::class, + Illuminate\Broadcasting\BroadcastServiceProvider::class, + Illuminate\Bus\BusServiceProvider::class, + Illuminate\Cache\CacheServiceProvider::class, + Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class, + Illuminate\Routing\ControllerServiceProvider::class, + Illuminate\Cookie\CookieServiceProvider::class, + Illuminate\Database\DatabaseServiceProvider::class, + Illuminate\Encryption\EncryptionServiceProvider::class, + Illuminate\Filesystem\FilesystemServiceProvider::class, + Illuminate\Foundation\Providers\FoundationServiceProvider::class, + Illuminate\Hashing\HashServiceProvider::class, + Illuminate\Mail\MailServiceProvider::class, + Illuminate\Pagination\PaginationServiceProvider::class, + Illuminate\Pipeline\PipelineServiceProvider::class, + Illuminate\Queue\QueueServiceProvider::class, + Illuminate\Redis\RedisServiceProvider::class, + Illuminate\Auth\Passwords\PasswordResetServiceProvider::class, + Illuminate\Session\SessionServiceProvider::class, + Illuminate\Translation\TranslationServiceProvider::class, + Illuminate\Validation\ValidationServiceProvider::class, + Illuminate\View\ViewServiceProvider::class, + + PhanAn\CascadingConfig\CascadingConfigServiceProvider::class, + Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider::class, + + /* + * Application Service Providers... + */ + App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, + App\Providers\EventServiceProvider::class, + App\Providers\RouteServiceProvider::class, + App\Providers\MediaServiceProvider::class, + + ], + + /* + |-------------------------------------------------------------------------- + | Class Aliases + |-------------------------------------------------------------------------- + | + | This array of class aliases will be registered when this application + | is started. However, feel free to register as many as you wish as + | the aliases are "lazy" loaded so they don't hinder performance. + | + */ + + 'aliases' => [ + + 'App' => Illuminate\Support\Facades\App::class, + 'Artisan' => Illuminate\Support\Facades\Artisan::class, + 'Auth' => Illuminate\Support\Facades\Auth::class, + 'Blade' => Illuminate\Support\Facades\Blade::class, + 'Bus' => Illuminate\Support\Facades\Bus::class, + 'Cache' => Illuminate\Support\Facades\Cache::class, + 'Config' => Illuminate\Support\Facades\Config::class, + 'Cookie' => Illuminate\Support\Facades\Cookie::class, + 'Crypt' => Illuminate\Support\Facades\Crypt::class, + 'DB' => Illuminate\Support\Facades\DB::class, + 'Eloquent' => Illuminate\Database\Eloquent\Model::class, + 'Event' => Illuminate\Support\Facades\Event::class, + 'File' => Illuminate\Support\Facades\File::class, + 'Gate' => Illuminate\Support\Facades\Gate::class, + 'Hash' => Illuminate\Support\Facades\Hash::class, + 'Input' => Illuminate\Support\Facades\Input::class, + 'Inspiring' => Illuminate\Foundation\Inspiring::class, + 'Lang' => Illuminate\Support\Facades\Lang::class, + 'Log' => Illuminate\Support\Facades\Log::class, + 'Mail' => Illuminate\Support\Facades\Mail::class, + 'Password' => Illuminate\Support\Facades\Password::class, + 'Queue' => Illuminate\Support\Facades\Queue::class, + 'Redirect' => Illuminate\Support\Facades\Redirect::class, + 'Redis' => Illuminate\Support\Facades\Redis::class, + 'Request' => Illuminate\Support\Facades\Request::class, + 'Response' => Illuminate\Support\Facades\Response::class, + 'Route' => Illuminate\Support\Facades\Route::class, + 'Schema' => Illuminate\Support\Facades\Schema::class, + 'Session' => Illuminate\Support\Facades\Session::class, + 'Storage' => Illuminate\Support\Facades\Storage::class, + 'URL' => Illuminate\Support\Facades\URL::class, + 'Validator' => Illuminate\Support\Facades\Validator::class, + 'View' => Illuminate\Support\Facades\View::class, + + 'Media' => App\Facades\Media::class, + + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 00000000..e8952c85 --- /dev/null +++ b/config/auth.php @@ -0,0 +1,67 @@ + 'eloquent', + + /* + |-------------------------------------------------------------------------- + | Authentication Model + |-------------------------------------------------------------------------- + | + | When using the "Eloquent" authentication driver, we need to know which + | Eloquent model should be used to retrieve your users. Of course, it + | is often just the "User" model but you may use whatever you like. + | + */ + + 'model' => App\Models\User::class, + + /* + |-------------------------------------------------------------------------- + | Authentication Table + |-------------------------------------------------------------------------- + | + | When using the "Database" authentication driver, we need to know which + | table should be used to retrieve your users. We have chosen a basic + | default value but you may easily change it to any table you like. + | + */ + + 'table' => 'users', + + /* + |-------------------------------------------------------------------------- + | Password Reset Settings + |-------------------------------------------------------------------------- + | + | Here you may set the options for resetting passwords including the view + | that is your password reset e-mail. You can also set the name of the + | table that maintains all of the reset tokens for your application. + | + | The expire time is the number of minutes that the reset token should be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + */ + + 'password' => [ + 'email' => 'emails.password', + 'table' => 'password_resets', + 'expire' => 60, + ], + +]; diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 00000000..abaaac32 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,52 @@ + env('BROADCAST_DRIVER', 'pusher'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over websockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_KEY'), + 'secret' => env('PUSHER_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + // + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + + 'log' => [ + 'driver' => 'log', + ], + + ], + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 00000000..379135b0 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,79 @@ + env('CACHE_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + */ + + 'stores' => [ + + 'apc' => [ + 'driver' => 'apc', + ], + + 'array' => [ + 'driver' => 'array', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'cache', + 'connection' => null, + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'servers' => [ + [ + 'host' => '127.0.0.1', 'port' => 11211, 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing a RAM based store such as APC or Memcached, there might + | be other applications utilizing the same cache. So, we'll specify a + | value to get prefixed to all our keys so we can avoid collisions. + | + */ + + 'prefix' => 'laravel', + +]; diff --git a/config/compile.php b/config/compile.php new file mode 100644 index 00000000..04807eac --- /dev/null +++ b/config/compile.php @@ -0,0 +1,35 @@ + [ + // + ], + + /* + |-------------------------------------------------------------------------- + | Compiled File Providers + |-------------------------------------------------------------------------- + | + | Here you may list service providers which define a "compiles" function + | that returns additional files that should be compiled, providing an + | easy way to get common files from any packages you are utilizing. + | + */ + + 'providers' => [ + // + ], + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 00000000..5987be69 --- /dev/null +++ b/config/database.php @@ -0,0 +1,126 @@ + PDO::FETCH_CLASS, + + /* + |-------------------------------------------------------------------------- + | Default Database Connection Name + |-------------------------------------------------------------------------- + | + | Here you may specify which of the database connections below you wish + | to use as your default connection for all database work. Of course + | you may use many connections at once using the Database library. + | + */ + + 'default' => env('DB_CONNECTION', 'mysql'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Here are each of the database connections setup for your application. + | Of course, examples of configuring each database platform that is + | supported by Laravel is shown below to make development simple. + | + | + | All database work in Laravel is done through the PHP PDO facilities + | so make sure you have the driver for your particular database of + | choice installed on your machine before you begin development. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'database' => database_path('database.sqlite'), + 'prefix' => '', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'host' => env('DB_HOST', 'localhost'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'collation' => 'utf8_unicode_ci', + 'prefix' => '', + 'strict' => false, + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'host' => env('DB_HOST', 'localhost'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + 'schema' => 'public', + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'host' => env('DB_HOST', 'localhost'), + 'database' => env('DB_DATABASE', 'forge'), + 'username' => env('DB_USERNAME', 'forge'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => 'utf8', + 'prefix' => '', + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run in the database. + | + */ + + 'migrations' => 'migrations', + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer set of commands than a typical key-value systems + | such as APC or Memcached. Laravel makes it easy to dig right in. + | + */ + + 'redis' => [ + + 'cluster' => false, + + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'database' => 0, + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 00000000..3fffcf0a --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,85 @@ + 'local', + + /* + |-------------------------------------------------------------------------- + | Default Cloud Filesystem Disk + |-------------------------------------------------------------------------- + | + | Many applications store files both locally and in the cloud. For this + | reason, you may specify a default "cloud" driver here. This driver + | will be bound as the Cloud disk implementation in the container. + | + */ + + 'cloud' => 's3', + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Here you may configure as many filesystem "disks" as you wish, and you + | may even configure multiple disks of the same driver. Defaults have + | been setup for each driver as an example of the required options. + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app'), + ], + + 'ftp' => [ + 'driver' => 'ftp', + 'host' => 'ftp.example.com', + 'username' => 'your-username', + 'password' => 'your-password', + + // Optional FTP Settings... + // 'port' => 21, + // 'root' => '', + // 'passive' => true, + // 'ssl' => true, + // 'timeout' => 30, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => 'your-key', + 'secret' => 'your-secret', + 'region' => 'your-region', + 'bucket' => 'your-bucket', + ], + + 'rackspace' => [ + 'driver' => 'rackspace', + 'username' => 'your-username', + 'key' => 'your-key', + 'container' => 'your-container', + 'endpoint' => 'https://identity.api.rackspacecloud.com/v2.0/', + 'region' => 'IAD', + 'url_type' => 'publicURL', + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 00000000..fd5123c0 --- /dev/null +++ b/config/mail.php @@ -0,0 +1,124 @@ + env('MAIL_DRIVER', 'smtp'), + + /* + |-------------------------------------------------------------------------- + | SMTP Host Address + |-------------------------------------------------------------------------- + | + | Here you may provide the host address of the SMTP server used by your + | applications. A default option is provided that is compatible with + | the Mailgun mail service which will provide reliable deliveries. + | + */ + + 'host' => env('MAIL_HOST', 'smtp.mailgun.org'), + + /* + |-------------------------------------------------------------------------- + | SMTP Host Port + |-------------------------------------------------------------------------- + | + | This is the SMTP port used by your application to deliver e-mails to + | users of the application. Like the host we have set this value to + | stay compatible with the Mailgun e-mail application by default. + | + */ + + 'port' => env('MAIL_PORT', 587), + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all e-mails sent by your application to be sent from + | the same address. Here, you may specify a name and address that is + | used globally for all e-mails that are sent by your application. + | + */ + + 'from' => ['address' => null, 'name' => null], + + /* + |-------------------------------------------------------------------------- + | E-Mail Encryption Protocol + |-------------------------------------------------------------------------- + | + | Here you may specify the encryption protocol that should be used when + | the application send e-mail messages. A sensible default using the + | transport layer security protocol should provide great security. + | + */ + + 'encryption' => env('MAIL_ENCRYPTION', 'tls'), + + /* + |-------------------------------------------------------------------------- + | SMTP Server Username + |-------------------------------------------------------------------------- + | + | If your SMTP server requires a username for authentication, you should + | set it here. This will get used to authenticate with your server on + | connection. You may also set the "password" value below this one. + | + */ + + 'username' => env('MAIL_USERNAME'), + + /* + |-------------------------------------------------------------------------- + | SMTP Server Password + |-------------------------------------------------------------------------- + | + | Here you may set the password required by your SMTP server to send out + | messages from your application. This will be given to the server on + | connection so that the application will be able to send messages. + | + */ + + 'password' => env('MAIL_PASSWORD'), + + /* + |-------------------------------------------------------------------------- + | Sendmail System Path + |-------------------------------------------------------------------------- + | + | When using the "sendmail" driver to send e-mails, we will need to know + | the path to where Sendmail lives on this server. A default path has + | been provided here, which will work well on most of your systems. + | + */ + + 'sendmail' => '/usr/sbin/sendmail -bs', + + /* + |-------------------------------------------------------------------------- + | Mail "Pretend" + |-------------------------------------------------------------------------- + | + | When this option is enabled, e-mail will not actually be sent over the + | web and will instead be written to your application's logs files so + | you may inspect the message. This is great for local development. + | + */ + + 'pretend' => env('MAIL_PRETEND', false), + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 00000000..9d30238e --- /dev/null +++ b/config/queue.php @@ -0,0 +1,94 @@ + env('QUEUE_DRIVER', 'sync'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection information for each server that + | is used by your application. A default configuration has been added + | for each back-end shipped with Laravel. You are free to add more. + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'table' => 'jobs', + 'queue' => 'default', + 'expire' => 60, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => 'localhost', + 'queue' => 'default', + 'ttr' => 60, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => 'your-public-key', + 'secret' => 'your-secret-key', + 'queue' => 'your-queue-url', + 'region' => 'us-east-1', + ], + + 'iron' => [ + 'driver' => 'iron', + 'host' => 'mq-aws-us-east-1.iron.io', + 'token' => 'your-token', + 'project' => 'your-project-id', + 'queue' => 'your-queue-name', + 'encrypt' => true, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => 'default', + 'expire' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control which database and table are used to store the jobs that + | have failed. You may change them to any database / table you wish. + | + */ + + 'failed' => [ + 'database' => env('DB_CONNECTION', 'mysql'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 00000000..93eec863 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'domain' => env('MAILGUN_DOMAIN'), + 'secret' => env('MAILGUN_SECRET'), + ], + + 'mandrill' => [ + 'secret' => env('MANDRILL_SECRET'), + ], + + 'ses' => [ + 'key' => env('SES_KEY'), + 'secret' => env('SES_SECRET'), + 'region' => 'us-east-1', + ], + + 'stripe' => [ + 'model' => App\User::class, + 'key' => env('STRIPE_KEY'), + 'secret' => env('STRIPE_SECRET'), + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 00000000..96a2fe02 --- /dev/null +++ b/config/session.php @@ -0,0 +1,153 @@ + env('SESSION_DRIVER', 'file'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to immediately expire on the browser closing, set that option. + | + */ + + 'lifetime' => 30*24*60, + + 'expire_on_close' => false, + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it is stored. All encryption will be run + | automatically by Laravel and you can use the Session like normal. + | + */ + + 'encrypt' => true, + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When using the native session driver, we need a location where session + | files may be stored. A default has been set for you but a different + | location may be specified. This is only needed for file sessions. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => null, + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table we + | should use to manage the sessions. Of course, a sensible default is + | provided for you; however, you are free to change this as needed. + | + */ + + 'table' => 'sessions', + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the cookie used to identify a session + | instance by ID. The name specified here will get used every time a + | new session cookie is created by the framework for every driver. + | + */ + + 'cookie' => 'remember_me_before_the_war', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application but you are free to change this when necessary. + | + */ + + 'path' => '/', + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | Here you may change the domain of the cookie used to identify a session + | in your application. This will determine which domains the cookie is + | available to in your application. A sensible default has been set. + | + */ + + 'domain' => null, + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you if it can not be done securely. + | + */ + + 'secure' => false, + +]; diff --git a/config/view.php b/config/view.php new file mode 100644 index 00000000..e193ab61 --- /dev/null +++ b/config/view.php @@ -0,0 +1,33 @@ + [ + realpath(base_path('resources/views')), + ], + + /* + |-------------------------------------------------------------------------- + | Compiled View Path + |-------------------------------------------------------------------------- + | + | This option determines where all the compiled Blade templates will be + | stored for your application. Typically, this is within the storage + | directory. However, as usual, you are free to change this value. + | + */ + + 'compiled' => realpath(storage_path('framework/views')), + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 00000000..9b1dffd9 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php new file mode 100644 index 00000000..8e4b9a21 --- /dev/null +++ b/database/factories/ModelFactory.php @@ -0,0 +1,46 @@ +define(App\Models\User::class, function ($faker) { + return [ + 'name' => $faker->name, + 'email' => $faker->email, + 'password' => bcrypt(str_random(10)), + 'is_admin' => false, + 'remember_token' => str_random(10), + ]; +}); + +$factory->defineAs(App\Models\User::class, 'admin', function ($faker) use ($factory) { + $user = $factory->raw(App\Models\User::class); + + return array_merge($user, ['is_admin' => true]); +}); + +$factory->define(App\Models\Artist::class, function ($faker) { + return [ + 'name' => $faker->name, + ]; +}); + +$factory->define(App\Models\Album::class, function ($faker) { + return [ + 'name' => $faker->sentence, + 'cover' => md5(uniqid()).'.jpg', + ]; +}); + +$factory->define(App\Models\Song::class, function ($faker) { + return [ + 'title' => $faker->sentence, + 'length' => $faker->randomFloat(2, 10, 500), + 'lyrics' => $faker->paragraph(), + 'path' => '/tmp/'.uniqid().'.mp3', + 'mtime' => time(), + ]; +}); + +$factory->define(App\Models\Playlist::class, function ($faker) { + return [ + 'name' => $faker->name, + ]; +}); diff --git a/database/migrations/.gitkeep b/database/migrations/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php new file mode 100644 index 00000000..f8fb02b4 --- /dev/null +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->string('name'); + $table->string('email')->unique(); + $table->string('password', 60); + $table->boolean('is_admin')->default(false); + $table->rememberToken(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('users'); + } +} diff --git a/database/migrations/2014_10_12_100000_create_password_resets_table.php b/database/migrations/2014_10_12_100000_create_password_resets_table.php new file mode 100644 index 00000000..00057f9c --- /dev/null +++ b/database/migrations/2014_10_12_100000_create_password_resets_table.php @@ -0,0 +1,31 @@ +string('email')->index(); + $table->string('token')->index(); + $table->timestamp('created_at'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('password_resets'); + } +} diff --git a/database/migrations/2015_11_23_074600_create_artists_table.php b/database/migrations/2015_11_23_074600_create_artists_table.php new file mode 100644 index 00000000..3f5f523f --- /dev/null +++ b/database/migrations/2015_11_23_074600_create_artists_table.php @@ -0,0 +1,31 @@ +increments('id'); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('artists'); + } +} diff --git a/database/migrations/2015_11_23_074709_create_albums_table.php b/database/migrations/2015_11_23_074709_create_albums_table.php new file mode 100644 index 00000000..1cf31331 --- /dev/null +++ b/database/migrations/2015_11_23_074709_create_albums_table.php @@ -0,0 +1,35 @@ +increments('id'); + $table->integer('artist_id')->unsigned(); + $table->string('name'); + $table->string('cover')->default(''); + $table->timestamps(); + + $table->foreign('artist_id')->references('id')->on('artists')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('albums'); + } +} diff --git a/database/migrations/2015_11_23_074713_create_songs_table.php b/database/migrations/2015_11_23_074713_create_songs_table.php new file mode 100644 index 00000000..bae3863b --- /dev/null +++ b/database/migrations/2015_11_23_074713_create_songs_table.php @@ -0,0 +1,39 @@ +string('id', 32); + $table->integer('album_id')->unsigned(); + $table->string('title'); + $table->float('length'); + $table->text('lyrics'); + $table->text('path'); + $table->integer('mtime'); + $table->timestamps(); + + $table->primary('id'); + $table->foreign('album_id')->references('id')->on('albums'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('songs'); + } +} diff --git a/database/migrations/2015_11_23_074723_create_playlists_table.php b/database/migrations/2015_11_23_074723_create_playlists_table.php new file mode 100644 index 00000000..2274a134 --- /dev/null +++ b/database/migrations/2015_11_23_074723_create_playlists_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->integer('user_id')->unsigned(); + $table->string('name'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('playlists'); + } +} diff --git a/database/migrations/2015_11_23_074733_create_interactions_table.php b/database/migrations/2015_11_23_074733_create_interactions_table.php new file mode 100644 index 00000000..5dd6b525 --- /dev/null +++ b/database/migrations/2015_11_23_074733_create_interactions_table.php @@ -0,0 +1,37 @@ +bigIncrements('id'); + $table->integer('user_id')->unsigned(); + $table->string('song_id', 32); + $table->boolean('liked')->default(false); + $table->integer('play_count')->default(0); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); + $table->foreign('song_id')->references('id')->on('songs')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('interactions'); + } +} diff --git a/database/migrations/2015_11_23_082854_create_playlist_song_table.php b/database/migrations/2015_11_23_082854_create_playlist_song_table.php new file mode 100644 index 00000000..530b0bbb --- /dev/null +++ b/database/migrations/2015_11_23_082854_create_playlist_song_table.php @@ -0,0 +1,34 @@ +increments('id'); + $table->integer('playlist_id')->unsigned(); + $table->string('song_id', 32); + + $table->foreign('playlist_id')->references('id')->on('playlists')->onDelete('cascade'); + $table->foreign('song_id')->references('id')->on('songs')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('playlist_song'); + } +} diff --git a/database/migrations/2015_11_25_033351_create_settings_table.php b/database/migrations/2015_11_25_033351_create_settings_table.php new file mode 100644 index 00000000..137bc3d0 --- /dev/null +++ b/database/migrations/2015_11_25_033351_create_settings_table.php @@ -0,0 +1,32 @@ +string('key'); + $table->text('value'); + + $table->primary('key'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('settings'); + } +} diff --git a/database/seeds/.gitkeep b/database/seeds/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/database/seeds/AlbumTableSeeder.php b/database/seeds/AlbumTableSeeder.php new file mode 100644 index 00000000..10c745c1 --- /dev/null +++ b/database/seeds/AlbumTableSeeder.php @@ -0,0 +1,21 @@ + Album::UNKNOWN_ID, + 'artist_id' => Artist::UNKNOWN_ID, + 'name' => Album::UNKNOWN_NAME, + 'cover' => Album::UNKNOWN_COVER, + ]); + } +} diff --git a/database/seeds/ArtistTableSeeder.php b/database/seeds/ArtistTableSeeder.php new file mode 100644 index 00000000..735d6855 --- /dev/null +++ b/database/seeds/ArtistTableSeeder.php @@ -0,0 +1,18 @@ + Artist::UNKNOWN_ID, + 'name' => Artist::UNKNOWN_NAME, + ]); + } +} diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php new file mode 100644 index 00000000..03c1e005 --- /dev/null +++ b/database/seeds/DatabaseSeeder.php @@ -0,0 +1,24 @@ +call(UserTableSeeder::class); + + $this->call(ArtistTableSeeder::class); + $this->call(AlbumTableSeeder::class); + $this->call(SettingTableSeeder::class); + + Model::reguard(); + } +} diff --git a/database/seeds/SettingTableSeeder.php b/database/seeds/SettingTableSeeder.php new file mode 100644 index 00000000..6a1afbc3 --- /dev/null +++ b/database/seeds/SettingTableSeeder.php @@ -0,0 +1,17 @@ +command->error('Please fill in initial admin details in .env file first.'); + abort(422); + } + + User::create([ + 'name' => env('ADMIN_NAME'), + 'email' => env('ADMIN_EMAIL'), + 'password' => Hash::make(env('ADMIN_PASSWORD')), + 'is_admin' => true, + ]); + + if (app()->environment() !== 'testing') { + $this->command->info('Admin user created. You can (and should) remove the auth details from .env now.'); + } + } +} diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 00000000..b06c15cc Binary files /dev/null and b/favicon.ico differ diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..98a2a9f6 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,23 @@ +require('events').EventEmitter.defaultMaxListeners = 30; + +var elixir = require('laravel-elixir'); +require('laravel-elixir-vueify'); + +elixir(function (mix) { + mix.browserify('main.js'); + mix.sass('app.scss'); + + mix.copy('resources/assets/img', 'public/img') + .copy('bower_components/fontawesome/fonts', 'public/build/fonts'); + + mix.scripts([ + 'bower_components/plyr/dist/plyr.js' + ], 'public/js/vendors.js', './') + .styles([ + 'resources/assets/css/**/*.css', + 'bower_components/fontawesome/css/font-awesome.min.css', + 'bower_components/plyr/dist/plyr.css' + ], 'public/css/vendors.css', './'); + + mix.version(['css/vendors.css', 'css/app.css', 'js/vendors.js', 'js/main.js']); +}); diff --git a/index.php b/index.php new file mode 100644 index 00000000..339842fd --- /dev/null +++ b/index.php @@ -0,0 +1,58 @@ + + */ + +/* +|-------------------------------------------------------------------------- +| Register The Auto Loader +|-------------------------------------------------------------------------- +| +| Composer provides a convenient, automatically generated class loader for +| our application. We just need to utilize it! We'll simply require it +| into the script here so that we don't have to worry about manual +| loading any of our classes later on. It feels nice to relax. +| +*/ + +require __DIR__.'/bootstrap/autoload.php'; + +/* +|-------------------------------------------------------------------------- +| Turn On The Lights +|-------------------------------------------------------------------------- +| +| We need to illuminate PHP development, so let us turn on the lights. +| This bootstraps the framework and gets it ready for use, then it +| will load up this application so that we can run it and send +| the responses back to the browser and delight our users. +| +*/ + +$app = require_once __DIR__.'/bootstrap/app.php'; + +/* +|-------------------------------------------------------------------------- +| Run The Application +|-------------------------------------------------------------------------- +| +| Once we have the application, we can handle the incoming request +| through the kernel, and send the associated response back to +| the client's browser allowing them to enjoy the creative +| and wonderful application we have prepared for them. +| +*/ + +$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); + +$response = $kernel->handle( + $request = Illuminate\Http\Request::capture() +); + +$response->send(); + +$kernel->terminate($request, $response); diff --git a/package.json b/package.json new file mode 100644 index 00000000..1ec79495 --- /dev/null +++ b/package.json @@ -0,0 +1,46 @@ +{ + "name": "koel", + "version": "0.1.0", + "author": "Phan An ", + "homepage": "http://koel.phanan.net", + "license": "MIT", + "description": "A personal music streaming server that works", + "keywords": [ + "music", + "audio", + "stream" + ], + "repository": { + "type": "git", + "url": "https://github.com/phanan/koel" + }, + "devDependencies": { + "babel-plugin-transform-runtime": "^6.3.13", + "babel-preset-es2015": "^6.3.13", + "babel-preset-react": "^6.3.13", + "babel-register": "^6.3.13", + "babel-runtime": "^5.8.34", + "blueimp-md5": "^1.1.1", + "bower": "^1.7.0", + "chai": "^3.4.1", + "gulp": "^3.9.0", + "ismobilejs": "^0.3.9", + "jquery": "^2.1.4", + "laravel-elixir": "^4.1.0", + "laravel-elixir-vueify": "^1.0.1", + "local-storage": "^1.4.2", + "lodash": "^3.10.1", + "mocha": "^2.3.4", + "node-sass": "^3.4.2", + "sinon": "^1.17.2", + "vue": "^1.0.11", + "vue-hot-reload-api": "^1.2.2", + "vue-resource": "^0.1.17", + "vueify": "^7.0.2", + "vueify-insert-css": "^1.0.0" + }, + "scripts": { + "postinstall": "node node_modules/bower/bin/bower install && gulp --production", + "test": "mocha --compilers js:babel-register resources/assets/js/tests/**/*Test.js" + } +} diff --git a/phpspec.yml b/phpspec.yml new file mode 100644 index 00000000..eb57939e --- /dev/null +++ b/phpspec.yml @@ -0,0 +1,5 @@ +suites: + main: + namespace: App + psr4_prefix: App + src_path: app \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..cc0841c1 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,27 @@ + + + + + ./tests/ + + + + + app/ + + + + + + + + + diff --git a/resources/assets/css/meyer-reset.min.css b/resources/assets/css/meyer-reset.min.css new file mode 100644 index 00000000..3b2627d6 --- /dev/null +++ b/resources/assets/css/meyer-reset.min.css @@ -0,0 +1 @@ +html,body,div,span,applet,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,a,abbr,acronym,address,big,cite,code,del,dfn,em,img,ins,kbd,q,s,samp,small,strike,strong,sub,sup,tt,var,b,u,i,center,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,table,caption,tbody,tfoot,thead,tr,th,td,article,aside,canvas,details,embed,figure,figcaption,footer,header,hgroup,menu,nav,output,ruby,section,summary,time,mark,audio,video{margin:0;padding:0;border:0;font-size:100%;font:inherit;vertical-align:baseline}article,aside,details,figcaption,figure,footer,header,hgroup,menu,nav,section{display:block}body{line-height:1}ol,ul{list-style:none}blockquote,q{quotes:none}blockquote:before,blockquote:after,q:before,q:after{content:'';content:none}table{border-collapse:collapse;border-spacing:0} diff --git a/resources/assets/db/db.mwb b/resources/assets/db/db.mwb new file mode 100644 index 00000000..9445d016 Binary files /dev/null and b/resources/assets/db/db.mwb differ diff --git a/resources/assets/img/covers/unknown-album.png b/resources/assets/img/covers/unknown-album.png new file mode 100644 index 00000000..6187bc92 Binary files /dev/null and b/resources/assets/img/covers/unknown-album.png differ diff --git a/resources/assets/img/drag-icon.png b/resources/assets/img/drag-icon.png new file mode 100644 index 00000000..2ff35785 Binary files /dev/null and b/resources/assets/img/drag-icon.png differ diff --git a/resources/assets/img/logo.png b/resources/assets/img/logo.png new file mode 100644 index 00000000..97e60537 Binary files /dev/null and b/resources/assets/img/logo.png differ diff --git a/resources/assets/img/logo.svg b/resources/assets/img/logo.svg new file mode 100644 index 00000000..025237de --- /dev/null +++ b/resources/assets/img/logo.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + diff --git a/resources/assets/js/app.vue b/resources/assets/js/app.vue new file mode 100644 index 00000000..b2e31cca --- /dev/null +++ b/resources/assets/js/app.vue @@ -0,0 +1,208 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/extra/index.vue b/resources/assets/js/components/main-wrapper/extra/index.vue new file mode 100644 index 00000000..9f67f473 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/extra/index.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/extra/lyrics.vue b/resources/assets/js/components/main-wrapper/extra/lyrics.vue new file mode 100644 index 00000000..4c7e148f --- /dev/null +++ b/resources/assets/js/components/main-wrapper/extra/lyrics.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/index.vue b/resources/assets/js/components/main-wrapper/index.vue new file mode 100644 index 00000000..f8d3aba7 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/index.vue @@ -0,0 +1,27 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/albums.vue b/resources/assets/js/components/main-wrapper/main-content/albums.vue new file mode 100644 index 00000000..329f5972 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/albums.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/artists.vue b/resources/assets/js/components/main-wrapper/main-content/artists.vue new file mode 100644 index 00000000..515a010f --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/artists.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/favorites.vue b/resources/assets/js/components/main-wrapper/main-content/favorites.vue new file mode 100644 index 00000000..e4b44bfb --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/favorites.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/index.vue b/resources/assets/js/components/main-wrapper/main-content/index.vue new file mode 100644 index 00000000..7a148944 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/index.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/playlist.vue b/resources/assets/js/components/main-wrapper/main-content/playlist.vue new file mode 100644 index 00000000..af1b465d --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/playlist.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/profile.vue b/resources/assets/js/components/main-wrapper/main-content/profile.vue new file mode 100644 index 00000000..01226f99 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/profile.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/queue.vue b/resources/assets/js/components/main-wrapper/main-content/queue.vue new file mode 100644 index 00000000..0e28b25e --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/queue.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/settings.vue b/resources/assets/js/components/main-wrapper/main-content/settings.vue new file mode 100644 index 00000000..f326f4df --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/settings.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/songs.vue b/resources/assets/js/components/main-wrapper/main-content/songs.vue new file mode 100644 index 00000000..131309a8 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/songs.vue @@ -0,0 +1,65 @@ + + + + + + diff --git a/resources/assets/js/components/main-wrapper/main-content/users.vue b/resources/assets/js/components/main-wrapper/main-content/users.vue new file mode 100644 index 00000000..2ed5d267 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/main-content/users.vue @@ -0,0 +1,378 @@ + + + + + diff --git a/resources/assets/js/components/main-wrapper/sidebar/index.vue b/resources/assets/js/components/main-wrapper/sidebar/index.vue new file mode 100644 index 00000000..8c162e68 --- /dev/null +++ b/resources/assets/js/components/main-wrapper/sidebar/index.vue @@ -0,0 +1,177 @@ + + + + + + diff --git a/resources/assets/js/components/main-wrapper/sidebar/playlists.vue b/resources/assets/js/components/main-wrapper/sidebar/playlists.vue new file mode 100644 index 00000000..0381d81b --- /dev/null +++ b/resources/assets/js/components/main-wrapper/sidebar/playlists.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/resources/assets/js/components/shared/album-item.vue b/resources/assets/js/components/shared/album-item.vue new file mode 100644 index 00000000..8d990e5b --- /dev/null +++ b/resources/assets/js/components/shared/album-item.vue @@ -0,0 +1,40 @@ + + + + + diff --git a/resources/assets/js/components/shared/artist-item.vue b/resources/assets/js/components/shared/artist-item.vue new file mode 100644 index 00000000..115b3e46 --- /dev/null +++ b/resources/assets/js/components/shared/artist-item.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/resources/assets/js/components/shared/overlay.vue b/resources/assets/js/components/shared/overlay.vue new file mode 100644 index 00000000..c0040d8f --- /dev/null +++ b/resources/assets/js/components/shared/overlay.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/resources/assets/js/components/shared/song-item.vue b/resources/assets/js/components/shared/song-item.vue new file mode 100644 index 00000000..79c37675 --- /dev/null +++ b/resources/assets/js/components/shared/song-item.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/resources/assets/js/components/shared/song-list.vue b/resources/assets/js/components/shared/song-list.vue new file mode 100644 index 00000000..1cfeba28 --- /dev/null +++ b/resources/assets/js/components/shared/song-list.vue @@ -0,0 +1,471 @@ + + + + + diff --git a/resources/assets/js/components/shared/sound-bar.vue b/resources/assets/js/components/shared/sound-bar.vue new file mode 100644 index 00000000..663f29dd --- /dev/null +++ b/resources/assets/js/components/shared/sound-bar.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/resources/assets/js/components/site-footer/index.vue b/resources/assets/js/components/site-footer/index.vue new file mode 100644 index 00000000..cc36023f --- /dev/null +++ b/resources/assets/js/components/site-footer/index.vue @@ -0,0 +1,568 @@ + + + + + diff --git a/resources/assets/js/components/site-header/index.vue b/resources/assets/js/components/site-header/index.vue new file mode 100644 index 00000000..52380dcf --- /dev/null +++ b/resources/assets/js/components/site-header/index.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/resources/assets/js/components/site-header/search-form.vue b/resources/assets/js/components/site-header/search-form.vue new file mode 100644 index 00000000..dbd95d5a --- /dev/null +++ b/resources/assets/js/components/site-header/search-form.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/resources/assets/js/components/site-header/user-badge.vue b/resources/assets/js/components/site-header/user-badge.vue new file mode 100644 index 00000000..b3a2f171 --- /dev/null +++ b/resources/assets/js/components/site-header/user-badge.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/resources/assets/js/config/index.js b/resources/assets/js/config/index.js new file mode 100644 index 00000000..cfb5f3e2 --- /dev/null +++ b/resources/assets/js/config/index.js @@ -0,0 +1,4 @@ +export default { + unknownCover: '/public/img/covers/unknown-album.png', + appTitle: 'koel', +}; diff --git a/resources/assets/js/directives/focus.js b/resources/assets/js/directives/focus.js new file mode 100644 index 00000000..c8060e5c --- /dev/null +++ b/resources/assets/js/directives/focus.js @@ -0,0 +1,14 @@ +/** + * A simple directive to set focus into an input field when it's shown. + */ +export default function (value) { + if (!value) { + return; + } + + var el = this.el; + + Vue.nextTick(() => { + el.focus(); + }); +} diff --git a/resources/assets/js/main.js b/resources/assets/js/main.js new file mode 100644 index 00000000..be818f80 --- /dev/null +++ b/resources/assets/js/main.js @@ -0,0 +1,12 @@ +import $ from 'jquery'; +window.Vue = require('vue'); +Vue.use(require('vue-resource')); +Vue.http.options.root = '/api'; +Vue.http.headers.common['X-CSRF-TOKEN'] = $('meta[name="csrf-token"]').attr('content'); +Vue.config.debug = false; + +// Exit light, +// Enter night, +// Take my hand, +// We're off to never never land. +new Vue(require('./app.vue')).$mount('body'); diff --git a/resources/assets/js/mixins/infinite-scroll.js b/resources/assets/js/mixins/infinite-scroll.js new file mode 100644 index 00000000..44aa996d --- /dev/null +++ b/resources/assets/js/mixins/infinite-scroll.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; + +/** + * Add a "infinite scroll" functionality to any component using this plugin. + * Such component should: + * - have the parent DOM element defined as "wrapper": v-el:wrapper + * - have a `scrolling` method bound to `scroll` event on such element: @scroll="scrolling" + * - have the array of all objects named as `items`, either as data, computed, or a prop + */ +export default { + data() { + return { + numOfItems: 30, // Number of currently loaded and displayed items + perPage: 30, // Number of items to be loaded per "page" + }; + }, + + methods: { + scrolling(e) { + var $wrapper = $(this.$els.wrapper); + + // Here we checks if the user has scrolled to the end of the wrapper (or 32px to the end). + // If that's true, load more items. + if ($wrapper.scrollTop() + $wrapper.innerHeight() >= $wrapper[0].scrollHeight - 32) { + this.displayMore(); + } + }, + + displayMore() { + this.numOfItems += this.perPage; + + if (this.numOfItems > this.items.length) { + this.numOfItems = this.items.length; + } + }, + }, +}; diff --git a/resources/assets/js/services/http.js b/resources/assets/js/services/http.js new file mode 100644 index 00000000..d8d0fd53 --- /dev/null +++ b/resources/assets/js/services/http.js @@ -0,0 +1,57 @@ +import { extend } from 'lodash'; + +/** + * Responsible for all HTTP requests. + * + * IMPORTANT: + * If the user has a good enough connection to stream music, he or she shouldn't + * encounter any HTTP errors. That's why Koel doesn't handle HTTP errors. + * After all, even if there were errors, how bad can it be? + */ +export default { + request(method, url, data, cb = null, options = {}) { + options = extend(options, { + error: (data, status, request) => { + if (status === 401) { + document.location.href = "/login"; + } + }, + }); + + switch (method) { + case 'get': + return Vue.http.get(url, data, cb, options); + case 'post': + return Vue.http.post(url, data, cb, options); + case 'put': + return Vue.http.put(url, data, cb, options); + case 'delete': + return Vue.http.delete(url, data, cb, options); + default: + break; + } + }, + + get(url, data = {}, cb = null, options = {}) { + return this.request('get', url, data, cb, options); + }, + + post(url, data, cb = null, options = {}) { + return this.request('post', url, data, cb, options); + }, + + put(url, data, cb = null, options = {}) { + return this.request('put', url, data, cb, options); + }, + + delete(url, data = {}, cb = null, options = {}) { + return this.request('delete', url, data, cb, options); + }, + + /** + * A shortcut method to ping and check if the user session is still valid. + */ + ping() { + return this.get('/'); + }, +}; diff --git a/resources/assets/js/services/ls.js b/resources/assets/js/services/ls.js new file mode 100644 index 00000000..800c82c2 --- /dev/null +++ b/resources/assets/js/services/ls.js @@ -0,0 +1,17 @@ +import ls from 'local-storage'; + +export default { + get(key, defaultVal = null) { + var val = ls(key); + + return val ? val : defaultVal; + }, + + set(key, val) { + return ls(key, val); + }, + + remove(key) { + return ls.remove(key); + }, +}; diff --git a/resources/assets/js/services/playback.js b/resources/assets/js/services/playback.js new file mode 100644 index 00000000..6a10a5d4 --- /dev/null +++ b/resources/assets/js/services/playback.js @@ -0,0 +1,291 @@ +import _ from 'lodash'; +import $ from 'jquery'; + +import queueStore from '../stores/queue'; +import songStore from '../stores/song'; +import preferenceStore from '../stores/preference'; +import config from '../config'; + +export default { + app: null, + player: null, + $volumeInput: null, + repeatModes: ['NO_REPEAT', 'REPEAT_ALL', 'REPEAT_ONE'], + + /** + * Initialize the playback service for this whole Koel app. + * + * @param object app The root Vue component. + */ + init(app) { + this.app = app; + + plyr.setup({ + controls: [], + }); + + this.player = $('.player')[0].plyr; + this.$volumeInput = $('#volumeRange'); + + /** + * Listen to 'error' event on the audio player and play the next song if any. + * We don't care about network error capturing here, since every play() call + * comes with a POST to api/interaction/play. If the user is not logged in anymore, + * this call will result in a 401, following by a redirection to our login page. + */ + this.player.media.addEventListener('error', e => { + this.playNext(); + }); + + /** + * Listen to 'input' event on the volume range control. + * When user drags the volume control, this event will be triggered, and we + * update the volume on the plyr object. + */ + this.$volumeInput.on('input', e => { + this.setVolume($(e.target).val()); + }); + + // Listen to 'ended' event on the audio player and play the next song in the queue. + this.player.media.addEventListener('ended', e => { + if (preferenceStore.get('repeatMode') === 'REPEAT_ONE') { + this.player.restart(); + this.player.play(); + + return; + } + + this.playNext(); + }); + + // On init, set the volume to the value found in the local storage. + this.setVolume(preferenceStore.get('volume')); + }, + + /** + * Play a song. Because + * + * So many adventures couldn't happen today, + * So many songs we forgot to play + * So many dreams swinging out of the blue + * We'll let them come true + * + * @param object song The song to play + */ + play(song) { + if (!song) { + return; + } + + // Set the song as the current song + queueStore.current(song); + + this.app.$broadcast('song:play', song); + + $('title').text(`${song.title} ♫ Koel`); + this.player.source(`/api/${song.id}/play`); + this.player.play(); + + // Register the play count to the server + songStore.registerPlay(song); + + // Show the notification if we're allowed to + if (!window.Notification || !preferenceStore.get('notify')) { + return; + } + + var notification = new Notification(`♫ ${song.title}`, { + icon: song.album.cover, + body: `${song.album.name} – ${song.album.artist.name}` + }); + + window.setTimeout(() => { + notification.close(); + }, 5000); + }, + + /** + * Get the next song in the queue. + * If we're in REPEAT_ALL mode and there's no next song, just get the first song. + */ + nextSong() { + var next = queueStore.getNextSong(); + + if (next) { + return next; + } + + if (preferenceStore.get('repeatMode') === 'REPEAT_ALL') { + return queueStore.first(); + } + }, + + /** + * Get the prev song in the queue. + * If we're in REPEAT_ALL mode and there's no prev song, get the last song. + */ + prevSong() { + var prev = queueStore.getPrevSong(); + + if (prev) { + return prev; + } + + if (preferenceStore.get('repeatMode') === 'REPEAT_ALL') { + return queueStore.last(); + } + }, + + /** + * Circle through the repeat mode. + * The selected mode will be stored into local storage as well. + */ + changeRepeatMode() { + var i = this.repeatModes.indexOf(preferenceStore.get('repeatMode')) + 1; + + if (i >= this.repeatModes.length) { + i = 0; + } + + preferenceStore.set('repeatMode', this.repeatModes[i]); + }, + + /** + * Play the prev song in the queue, if one is found. + * If the prev song is not found and the current mode is NO_REPEAT, we stop completely. + */ + playPrev() { + var prev = this.prevSong(); + + if (!prev && preferenceStore.get('repeatMode') === 'NO_REPEAT') { + this.stop(); + + return; + } + + this.play(prev); + }, + + /** + * Play the next song in the queue, if one is found. + * If the next song is not found and the current mode is NO_REPEAT, we stop completely. + */ + playNext() { + var next = this.nextSong(); + + if (!next && preferenceStore.get('repeatMode') === 'NO_REPEAT') { + // Nothing lasts forever, even cold November rain. + this.stop(); + + return; + } + + this.play(next); + }, + + /** + * Set the volume level. + * + * @param integer volume 0-10 + * @param boolean persist Whether the volume should be saved into local storage + */ + setVolume(volume, persist = true) { + this.player.setVolume(volume); + + if (persist) { + preferenceStore.set('volume', volume); + } + + this.$volumeInput.val(volume); + }, + + /** + * Mute playback. + */ + mute() { + this.setVolume(0, false); + }, + + /** + * Unmute playback. + */ + unmute() { + // If the saved volume is 0, we unmute to the default level (7). + if (preferenceStore.get('volume') === '0' || preferenceStore.get('volume') === 0) { + preferenceStore.set('volume', 7); + } + + this.setVolume(preferenceStore.get('volume')); + }, + + /** + * Completely stop playback. + */ + stop() { + $('title').text(config.appTitle); + this.player.pause(); + this.player.seek(0); + + this.app.$broadcast('song:stop'); + }, + + /** + * Pause playback. + */ + pause() { + this.player.pause(); + }, + + /** + * Resume playback. + */ + resume() { + this.player.play(); + }, + + /** + * Queue up songs (replace them into the queue) and start playing right away. + * + * @param array|null songs An array of song objects. Defaults to all songs if null. + * @param bool shuffle Whether to shuffle the songs before playing. + */ + queueAndPlay(songs = null, shuffle = false) { + if (!songs) { + songs = songStore.all(); + } + + if (!songs.length) { + return; + } + + if (shuffle) { + songs = _.shuffle(songs); + } + + queueStore.clear(); + + queueStore.queue(songs, true); + + this.app.loadMainView('queue'); + + // Wrap this inside a nextTick() to wait for the DOM to complete updating + // and then play the first song in the queue. + Vue.nextTick(() => { + this.play(queueStore.first()); + }); + }, + + /** + * Play the first song in the queue. + * If the current queue is empty, try creating it by shuffling all songs. + */ + playFirstInQueue() { + if (!queueStore.all().length) { + this.queueAndPlay(); + + return; + } + + this.play(queueStore.first()); + }, +}; diff --git a/resources/assets/js/services/utils.js b/resources/assets/js/services/utils.js new file mode 100644 index 00000000..98302d27 --- /dev/null +++ b/resources/assets/js/services/utils.js @@ -0,0 +1,29 @@ +export default { + /** + * Convert a duration in seconds into H:i:s format. + * If H is 0, it will be ommited. + */ + secondsToHis(d) { + d = parseInt(d); + + var s = d%60; + + if (s < 10) { + s = '0' + s; + } + + var i = Math.floor((d/60)%60); + + if (i < 10) { + i = '0' + i; + } + + var h = Math.floor(d/3600); + + if (h < 10) { + h = '0' + h; + } + + return (h === '00' ? '' : h + ':') + i + ':' + s; + }, +}; diff --git a/resources/assets/js/stores/album.js b/resources/assets/js/stores/album.js new file mode 100644 index 00000000..16c1fac3 --- /dev/null +++ b/resources/assets/js/stores/album.js @@ -0,0 +1,58 @@ +import _ from 'lodash'; + +import utils from '../services/utils'; +import stub from '../stubs/album'; +import songStore from './song'; + +export default { + stub, + artists: [], + + state: { + albums: [stub], + }, + + /** + * Init the store. + * + * @param array artists The array of artists to extract album data from. + */ + init(artists) { + this.artists = artists; + + // Traverse through the artists array and add their albums into our master album list. + this.state.albums = _.reduce(artists, (albums, artist) => { + // While we're doing so, for each album, we get its length + // and keep a back reference to the artist too. + _.each(artist.albums, album => { + album.artist = artist; + this.getLength(album); + }); + + return albums.concat(artist.albums); + }, []); + + // Then we init the song store. + songStore.init(this.state.albums); + }, + + all() { + return this.state.albums; + }, + + /** + * Get the total length of an album by summing up its songs' duration. + * The length will also be converted into a H:i:s format and stored as fmtLength. + * + * @param object album + * + * @return string The H:i:s format of the album. + */ + getLength(album) { + album.length = _.reduce(album.songs, (length, song) => { + return length + song.length; + }, 0); + + return (album.fmtLength = utils.secondsToHis(album.length)); + }, +}; diff --git a/resources/assets/js/stores/artist.js b/resources/assets/js/stores/artist.js new file mode 100644 index 00000000..0c7637e1 --- /dev/null +++ b/resources/assets/js/stores/artist.js @@ -0,0 +1,81 @@ +import _ from 'lodash'; + +import config from '../config'; +import albumStore from './album'; +import sharedStore from './shared'; + +export default { + state: { + artists: [], + }, + + /** + * Init the store. + * + * @param array artists The array of artists we got from the server. + */ + init(artists = null) { + if (artists) { + this.state.artists = artists; + } else { + this.state.artists = sharedStore.state.artists; + } + + // Init the album store. This must be called prior to the next logic, + // because we're using some data from the album store later. + albumStore.init(this.state.artists); + + // Traverse through artists array to get the cover and number of songs for each. + _.each(this.state.artists, artist => { + this.getCover(artist); + + artist.songCount = _.reduce(artist.albums, (count, album) => { + return count + album.songs.length; + }, 0); + }); + }, + + all() { + return this.state.artists; + }, + + /** + * Get all songs performed by an artist. + * + * @param object artist + * + * @return array + */ + getSongsByArtist(artist) { + if (!artist.songs) { + artist.songs = _.reduce(artist.albums, (songs, album) => { + return songs.concat(album.songs); + }, []); + } + + return artist.songs; + }, + + /** + * Get the artist's cover + * + * @param object artist + * + * @return string + */ + getCover(artist) { + artist.cover = config.unknownCover; + + artist.albums.every(album => { + // If there's a "real" cover, use it. + if (album.cover != config.unknownCover) { + artist.cover = album.cover; + + // I want to break free. + return false; + } + }); + + return artist.cover; + }, +}; diff --git a/resources/assets/js/stores/favorite.js b/resources/assets/js/stores/favorite.js new file mode 100644 index 00000000..0cd4a43a --- /dev/null +++ b/resources/assets/js/stores/favorite.js @@ -0,0 +1,93 @@ +import _ from 'lodash'; + +import http from '../services/http'; +import utils from '../services/utils'; + +export default { + state: { + songs: [], + }, + + all() { + return this.state.songs; + }, + + /** + * Toggle like/unlike a song. + * A request to the server will be made. + * + * @param object The song object + * @param closure|null The function to execute afterwards + */ + toggleOne(song, cb = null) { + http.post('interaction/like', { id: song.id }, data => { + song.liked = data.liked; + + if (data.liked) { + this.add(song); + } else { + this.remove(song); + } + + if (cb) { + cb(); + } + }); + }, + + /** + * Add a song into the store. + * + * @param object The song object + */ + add(song) { + this.state.songs.push(song); + }, + + /** + * Remove a song from the store. + * + * @param object The song object + */ + remove(song) { + this.state.songs = _.difference(this.state.songs, [song]); + }, + + /** + * Like a bunch of songs. + * + * @param array An array of songs to like + */ + like(songs, cb = null) { + this.state.songs = _.union(this.state.songs, songs); + + http.post('interaction/batch/like', { ids: _.pluck(songs, 'id') }, data => { + _.each(songs, song => { + song.liked = true; + }); + + if (cb) { + cb(); + } + }); + }, + + /** + * Unlike a bunch of songs. + * + * @param array An array of songs to unlike + */ + unlike(songs, cb = null) { + this.state.songs = _.difference(this.state.songs, songs); + + http.post('interaction/batch/unlike', { ids: _.pluck(songs, 'id') }, data => { + _.each(songs, song => { + song.liked = false; + }); + + if (cb) { + cb(); + } + }); + }, +}; diff --git a/resources/assets/js/stores/playlist.js b/resources/assets/js/stores/playlist.js new file mode 100644 index 00000000..beaf10a0 --- /dev/null +++ b/resources/assets/js/stores/playlist.js @@ -0,0 +1,84 @@ +import _ from 'lodash'; + +import http from '../services/http'; +import stub from '../stubs/playlist'; +import sharedStore from './shared'; +import songStore from './song'; + +export default { + stub, + + state: { + playlists: [], + }, + + init() { + this.state.playlists = sharedStore.state.playlists; + + _.each(this.state.playlists, this.getSongs); + }, + + all() { + return this.state.playlists; + }, + + /** + * Get all songs in a playlist. + * + * return array + */ + getSongs(playlist) { + playlist.songs = songStore.byIds(playlist.songs); + + return playlist.songs; + }, + + store(name, songs, cb = null) { + http.post('playlist', { name, songs }, playlist => { + this.getSongs(playlist); + this.state.playlists.push(playlist); + + if (cb) { + cb(); + } + }); + }, + + delete(playlist, cb = null) { + http.delete(`playlist/${playlist.id}`, {}, () => { + this.state.playlists = _.without(this.state.playlists, playlist); + + if (cb) { + cb(); + } + }); + }, + + addSongs(playlist, songs, cb = null) { + playlist.songs = _.union(playlist.songs, songs); + + http.put(`playlist/${playlist.id}/sync`, { songs: _.pluck(playlist.songs, 'id') }, () => { + if (cb) { + cb(); + } + }); + }, + + removeSongs(playlist, songs, cb = null) { + playlist.songs = _.difference(playlist.songs, songs); + + http.put(`playlist/${playlist.id}/sync`, { songs: _.pluck(playlist.songs, 'id') }, () => { + if (cb) { + cb(); + } + }); + }, + + update(playlist, cb = null) { + http.put(`playlist/${playlist.id}`, { name: playlist.name }, () => { + if (cb) { + cb(); + } + }); + }, +}; diff --git a/resources/assets/js/stores/preference.js b/resources/assets/js/stores/preference.js new file mode 100644 index 00000000..16d8d0fc --- /dev/null +++ b/resources/assets/js/stores/preference.js @@ -0,0 +1,43 @@ +import _ from 'lodash'; + +import userStore from './user'; +import ls from '../services/ls'; + +export default { + storeKey: '', + + state: { + volume: 7, + notify: true, + repeatMode: 'NO_REPEAT', + showExtraPanel: true, + }, + + /** + * Init the store + * @param object user The user whose preferences we are managing. + */ + init(user = null) { + if (!user) { + user = userStore.current(); + } + + this.storeKey = `preferences_${user.id}`; + _.extend(this.state, ls.get(this.storeKey, this.state)); + }, + + set(key, val) { + this.state[key] = val; + this.save(); + }, + + get(key) { + var val = _.has(this.state, key) ? this.state[key] : null; + + return val; + }, + + save() { + ls.set(this.storeKey, this.state); + }, +}; diff --git a/resources/assets/js/stores/queue.js b/resources/assets/js/stores/queue.js new file mode 100644 index 00000000..5ea7d1d0 --- /dev/null +++ b/resources/assets/js/stores/queue.js @@ -0,0 +1,137 @@ +import _ from 'lodash'; + +import songStub from '../stubs/song'; + +export default { + state: { + songs: [], + current: songStub, + }, + + init() { + // We don't have anything to do here yet. + // How about another song then? + // + // LITTLE WING + // -- by Jimmy Fucking Hendrick + // + // Well she's walking + // Trough the clouds + // With a circus mind + // That's running wild + // Butterflies and zebras and moonbeams and fairytales + // That's all she ever thinks about + // Riding with the wind + // + // When i'm sad + // She comes to me + // With a thousand smiles + // She gives to me free + // It's alright she said + // It's alright + // Take anything you want from me + // Anything... + // + // [CRAZY SOLO BITCH!] + }, + + all() { + return this.state.songs; + }, + + first() { + return _.first(this.state.songs); + }, + + last() { + return _.last(this.state.songs); + }, + + /** + * Add a list of songs to the end of the current queue, + * or replace the current queue as a whole if `replace` is true. + * + * @param object|array songs The song, or an array of songs + * @param bool replace Whether to replace the current queue + * @param bool toTop Whether to prepend of append to the queue + */ + queue(songs, replace = false, toTop = false) { + if (!Array.isArray(songs)) { + songs = [songs]; + } + + if (replace) { + this.state.songs = songs; + } else { + if (toTop) { + this.state.songs = _.union(songs, this.state.songs); + } else { + this.state.songs = _.union(this.state.songs, songs); + } + } + }, + + /** + * Unqueue a song, or several songs at once. + * + * @param object|string|array songs The song(s) to unqueue. + */ + unqueue(songs) { + if (!Array.isArray(songs)) { + songs = [songs]; + } + + this.state.songs = _.difference(this.state.songs, songs); + }, + + /** + * Clear the current queue. + */ + clear(cb = null) { + this.state.songs = []; + + if (cb) { + cb(); + } + }, + + /** + * Get the next song in queue. + * + * @return object|null + */ + getNextSong() { + var i = _.pluck(this.state.songs, 'id').indexOf(this.current().id) + 1; + + return i >= this.state.songs.length ? null : this.state.songs[i]; + }, + + /** + * Get the previous song in queue. + * + * @return object|null + */ + getPrevSong() { + var i = _.pluck(this.state.songs, 'id').indexOf(this.current().id) - 1; + + return i < 0 ? null : this.state.songs[i]; + }, + + /** + * Get or set the current song. + */ + current(song = null) { + if (song) { + this.state.current = song; + } + + return this.state.current; + }, + + /** + * Shuffle the queue. + */ + shuffle() { + return (this.state.songs = _.shuffle(this.state.songs)); + }, +}; diff --git a/resources/assets/js/stores/setting.js b/resources/assets/js/stores/setting.js new file mode 100644 index 00000000..bccc943a --- /dev/null +++ b/resources/assets/js/stores/setting.js @@ -0,0 +1,27 @@ +import http from '../services/http'; +import stub from '../stubs/settings'; +import sharedStore from './shared'; + +export default { + stub, + + state: { + settings: [], + }, + + init() { + this.state.settings = sharedStore.state.settings; + }, + + all() { + return this.state.settings; + }, + + update(cb = null) { + http.post('settings', this.all(), msg => { + if (cb) { + cb(); + } + }); + }, +}; diff --git a/resources/assets/js/stores/shared.js b/resources/assets/js/stores/shared.js new file mode 100644 index 00000000..79e28e69 --- /dev/null +++ b/resources/assets/js/stores/shared.js @@ -0,0 +1,34 @@ +import http from '../services/http'; + +export default { + state: { + songs: [], + albums: [], + artists: [], + favorites: [], + queued: [], + interactions: [], + users: [], + settings: [], + currentUser: null, + playlists: [], + }, + + init(cb = null) { + http.get('data', {}, data => { + this.state.songs = data.songs; + this.state.artists = data.artists; + this.state.albums = data.albums; + this.state.settings = data.settings; + this.state.playlists = data.playlists; + this.state.interactions = data.interactions; + this.state.users = data.users; + this.state.currentUser = data.user; + this.state.settings = data.settings; + + if (cb) { + cb(); + } + }); + }, +}; diff --git a/resources/assets/js/stores/song.js b/resources/assets/js/stores/song.js new file mode 100644 index 00000000..05e59b46 --- /dev/null +++ b/resources/assets/js/stores/song.js @@ -0,0 +1,141 @@ +import _ from 'lodash'; + +import http from '../services/http'; +import utils from '../services/utils'; +import stub from '../stubs/song'; +import albumStore from './album'; +import favoriteStore from './favorite'; +import sharedStore from './shared'; + +export default { + stub, + sharedStore: null, + albums: [], + + state: { + songs: [stub], + interactions: [], + }, + + /** + * Init the store. + * + * @param array albums The array of albums to extract our songs from + * @param array interactions The array of interactions (like/play count) of the current user + */ + init(albums, interactions = null) { + this.albums = albums; + + if (interactions) { + this.state.interactions = interactions; + } else { + this.state.interactions = sharedStore.state.interactions; + } + + // Iterate through the albums. With each, add its songs into our master song list. + this.state.songs = _.reduce(albums, (songs, album) => { + // While doing so, we populate some other information into the songs as well. + _.each(album.songs, song => { + song.fmtLength = utils.secondsToHis(song.length); + + // Keep a back reference to the album + song.album = album; + + this.setInteractionStats(song); + + if (song.liked) { + favoriteStore.add(song); + } + }); + + return songs.concat(album.songs); + }, []); + }, + + /** + * Get all songs. + */ + all() { + return this.state.songs; + }, + + /** + * Get a song by its ID + * + * @param string id + * + * @return object + */ + byId(id) { + return _.find(this.state.songs, {id}); + }, + + /** + * Get songs by their ID's + * + * @param array ids + * + * @return array + */ + byIds(ids) { + return _.filter(this.state.songs, song => { + return _.contains(ids, song.id); + }); + }, + + /** + * Set the interaction stats (like status and playcount) for a song. + * + * @param object song + */ + setInteractionStats(song) { + var interaction = _.find(this.state.interactions, { song_id: song.id }); + + if (!interaction) { + song.liked = false; + song.playCount = 0; + + return; + } + + song.liked = interaction.liked; + song.playCount = interaction.play_count; + }, + + /** + * Increase a play count for a song. + * + * @param object song + */ + registerPlay(song) { + // Increase playcount + http.post('interaction/play', { id: song.id }, data => { + song.playCount = data.play_count; + }); + }, + + /** + * Get a song's lyrics. + * A HTTP request will be made if the song has no lyrics attribute yet. + * + * @param object song + * @param function cb + */ + getLyrics(song, cb = null) { + if (!_.isUndefined(song.lyrics)) { + if (cb) { + cb(); + } + + return; + } + + http.get(`${song.id}/lyrics`, lyrics => { + song.lyrics = lyrics; + + if (cb) { + cb(); + } + }); + } +}; diff --git a/resources/assets/js/stores/user.js b/resources/assets/js/stores/user.js new file mode 100644 index 00000000..779560f2 --- /dev/null +++ b/resources/assets/js/stores/user.js @@ -0,0 +1,159 @@ +import _ from 'lodash'; +import { md5 } from 'blueimp-md5'; + +import http from '../services/http'; +import stub from '../stubs/user'; +import sharedStore from './shared'; + +export default { + stub, + + state: { + users: [], + current: stub, + }, + + /** + * Init the store. + * + * @param object data The data object that contain the users array. + * Mostly for DI and testing purpose. + * For production, this data is retrieved from the shared store. + * + */ + init(data = null) { + if (!data) { + data = sharedStore.state; + } + + this.state.users = data.users; + this.state.current = data.currentUser; + + // Set the avatar for each of the users… + _.each(this.state.users, this.setAvatar); + + // …and the current user as well. + this.setAvatar(); + }, + + all() { + return this.state.users; + }, + + /** + * Get a user by his ID + * + * @param integer id + * + * @return object + */ + byId(id) { + return _.find(this.state.users, {id}); + }, + + /** + * Get or set the current user. + */ + current(user = null) { + if (user) { + this.state.current = user; + } + + return this.state.current; + }, + + /** + * Set a user's avatar using Gravatar's service. + * + * @param object user The user. If null, the current user. + */ + setAvatar(user = null) { + if (!user) { + user = this.current(); + } + + user.avatar = `https://www.gravatar.com/avatar/${md5(user.email)}?s=256`; + }, + + /** + * Update the current user's profile. + * + * @param string password Can be an empty string if the user is not changing his password. + */ + updateProfile(password = null, cb = null) { + http.put('me', { + password, + name: this.current().name, + email: this.current().email + }, data => { + this.setAvatar(); + + if (cb) { + cb(); + } + } + ); + }, + + /** + * Stores a new user into the database. + * + * @param string name + * @param string email + * @param string password + * @param function cb + */ + store(name, email, password, cb = null) { + http.post('user', { name, email, password }, user => { + this.setAvatar(user); + this.state.users.push(user); + + if (cb) { + cb(); + } + }); + }, + + update(user, name, email, password, cb = null) { + http.put(`user/${user.id}`, { name, email, password }, () => { + this.setAvatar(user); + user.password = ''; + + if (cb) { + cb(); + } + }); + }, + + destroy(user, cb = null) { + http.delete(`user/${user.id}`, {}, () => { + this.state.users = _.without(this.state.users, user); + + // Mama, just killed a man + // Put a gun against his head + // Pulled my trigger, now he's dead + // Mama, life had just begun + // But now I've gone and thrown it all away + // Mama, oooh + // Didn't mean to make you cry + // If I'm not back again this time tomorrow + // Carry on, carry on, as if nothing really matters + // + // Too late, my time has come + // Sends shivers down my spine + // Body's aching all the time + // Goodbye everybody - I've got to go + // Gotta leave you all behind and face the truth + // Mama, oooh + // I don't want to die + // I sometimes wish I'd never been born at all + + /** + * Brian May enters the stage. + */ + if (cb) { + cb(); + } + }); + }, +}; diff --git a/resources/assets/js/stubs/album.js b/resources/assets/js/stubs/album.js new file mode 100644 index 00000000..dfb0bd02 --- /dev/null +++ b/resources/assets/js/stubs/album.js @@ -0,0 +1,11 @@ +import config from '../config'; +import artist from './artist'; + +export default { + artist, + id: 0, + artist_id: 0, + name: '', + cover: config.unknownCover, + songs: [] +}; diff --git a/resources/assets/js/stubs/artist.js b/resources/assets/js/stubs/artist.js new file mode 100644 index 00000000..2d57e74f --- /dev/null +++ b/resources/assets/js/stubs/artist.js @@ -0,0 +1,5 @@ +export default { + id: 0, + name: '', + albums: [], +}; diff --git a/resources/assets/js/stubs/playlist.js b/resources/assets/js/stubs/playlist.js new file mode 100644 index 00000000..36387972 --- /dev/null +++ b/resources/assets/js/stubs/playlist.js @@ -0,0 +1,4 @@ +export default { + name: '', + songs: [], +}; diff --git a/resources/assets/js/stubs/settings.js b/resources/assets/js/stubs/settings.js new file mode 100644 index 00000000..735bc9ce --- /dev/null +++ b/resources/assets/js/stubs/settings.js @@ -0,0 +1,3 @@ +export default { + media_path: '', +}; diff --git a/resources/assets/js/stubs/song.js b/resources/assets/js/stubs/song.js new file mode 100644 index 00000000..0c873de4 --- /dev/null +++ b/resources/assets/js/stubs/song.js @@ -0,0 +1,13 @@ +import album from './album'; + +export default { + album, + id: null, + album_id: 0, + title: '', + length: 0, + fmtLength: '00:00', + lyrics: '', + playing: false, + liked: false, +}; diff --git a/resources/assets/js/stubs/user.js b/resources/assets/js/stubs/user.js new file mode 100644 index 00000000..8b170022 --- /dev/null +++ b/resources/assets/js/stubs/user.js @@ -0,0 +1,7 @@ +export default { + id: 0, + name: '', + email: '', + avatar: '', + is_admin: false, +}; diff --git a/resources/assets/js/tests/blobs/interactions.js b/resources/assets/js/tests/blobs/interactions.js new file mode 100644 index 00000000..56a31ccd --- /dev/null +++ b/resources/assets/js/tests/blobs/interactions.js @@ -0,0 +1,32 @@ +export default [ + { + id: 1, + song_id: "7900ab518f51775fe6cf06092c074ee5", + liked: false, + play_count: 1 + }, + { + id: 2, + song_id: "95c0ffc33c08c8c14ea5de0a44d5df3c", + liked: false, + play_count: 2 + }, + { + id: 3, + song_id: "c83b201502eb36f1084f207761fa195c", + liked: false, + play_count: 1 + }, + { + id: 4, + song_id: "cb7edeac1f097143e65b1b2cde102482", + liked: true, + play_count: 3 + }, + { + id: 5, + song_id: "ccc38cc14bb95aefdf6da4b34adcf548", + liked: false, + play_count: 4 + } +]; diff --git a/resources/assets/js/tests/blobs/media.js b/resources/assets/js/tests/blobs/media.js new file mode 100644 index 00000000..09990102 --- /dev/null +++ b/resources/assets/js/tests/blobs/media.js @@ -0,0 +1,160 @@ +export default [ + { + id: 1, + name: "All-4-One", + albums: [ + { + id: 1193, + artist_id: 1, + name: "All-4-One", + cover: "/public/img/covers/565c0f7067425.jpeg", + songs: [ + { + id: "39189f4545f9d5671fb3dc964f0080a0", + album_id: 1193, + title: "I Swear", + length: 259.92 + } + ] + }, + { + id: 1194, + artist_id: 1, + name: "And The Music Speaks", + cover: "/public/img/covers/unknown-album.png", + songs: [ + { + id: "a6a550f7d950d2a2520f9bf1a60f025a", + album_id: 1194, + title: "I can love you like that", + length: 262.61 + } + ] + }, + { + id: 1195, + artist_id: 1, + name: "Space Jam", + cover: "/public/img/covers/565c0f7115e0f.png", + songs: [ + { + id: "d86c30fd34f13c1aff8db59b7fc9c610", + album_id: 1195, + title: "I turn to you", + length: 293.04 + } + ] + } + ] + }, + { + id: 2, + name: "Bob Dylan", + albums: [ + { + id: 1217, + artist_id: 2, + name: "Highway 61 Revisited", + cover: "/public/img/covers/565c0f76dc6e8.jpeg", + songs: [ + { + id: "e6d3977f3ffa147801ca5d1fdf6fa55e", + album_id: 1217, + title: "Like a rolling stone", + length: 373.63 + } + ] + }, + { + id: 1218, + artist_id: 2, + name: "Pat Garrett & Billy the Kid", + cover: "/public/img/covers/unknown-album.png", + songs: [ + { + id: "aa16bbef6a9710eb9a0f41ecc534fad5", + album_id: 1218, + title: "Knockin' on heaven's door", + length: 151.9 + } + ] + }, + { + id: 1219, + artist_id: 2, + name: "The Times They Are A-Changin'", + cover: "/public/img/covers/unknown-album.png", + songs: [ + { + id: "cb7edeac1f097143e65b1b2cde102482", + album_id: 1219, + title: "The times they are a-changin'", + length: 196 + } + ] + } + ] + }, + { + id: 3, + name: "James Blunt", + albums: [ + { + id: 1268, + artist_id: 3, + name: "Back To Bedlam", + cover: "/public/img/covers/unknown-album.png", + songs: [ + { + id: "0ba9fb128427b32683b9eb9140912a70", + album_id: 1268, + title: "No bravery", + length: 243.12 + }, + { + id: "123fd1ad32240ecab28a4e86ed5173", + album_id: 1268, + title: "So long, Jimmy", + length: 265.04 + }, + { + id: "6a54c674d8b16732f26df73f59c63e21", + album_id: 1268, + title: "Wisemen", + length: 223.14 + }, + { + id: "6df7d82a9a8701e40d1c291cf14a16bc", + album_id: 1268, + title: "Goodbye my lover", + length: 258.61 + }, + { + id: "74a2000d343e4587273d3ad14e2fd741", + album_id: 1268, + title: "High", + length: 245.86 + }, + { + id: "7900ab518f51775fe6cf06092c074ee5", + album_id: 1268, + title: "You're beautiful", + length: 213.29 + }, + { + id: "803910a51f9893347e087af851e38777", + album_id: 1268, + title: "Cry", + length: 246.91 + }, + { + id: "d82b0d4d4803ebbcb61000a5b6a868f5", + album_id: 1268, + title: "Tears and rain", + length: 244.45 + } + ] + } + ] + } +]; diff --git a/resources/assets/js/tests/blobs/users.js b/resources/assets/js/tests/blobs/users.js new file mode 100644 index 00000000..2fbc83bc --- /dev/null +++ b/resources/assets/js/tests/blobs/users.js @@ -0,0 +1,23 @@ +export default { + currentUser: { + id: 1, + name: 'Phan An', + email: 'me@phanan.net', + is_admin: true, + }, + + users: [ + { + id: 1, + name: 'Phan An', + email: 'me@phanan.net', + is_admin: true, + }, + { + id: 2, + name: 'John Doe', + email: 'john@doe.tld', + is_admin: false, + }, + ] +}; diff --git a/resources/assets/js/tests/services/lsTest.js b/resources/assets/js/tests/services/lsTest.js new file mode 100644 index 00000000..ea8ac26e --- /dev/null +++ b/resources/assets/js/tests/services/lsTest.js @@ -0,0 +1,36 @@ +require('chai').should(); + +import localStorage from 'local-storage'; +import ls from '../../services/ls'; + +describe('services/ls', () => { + beforeEach(() => { + localStorage.remove('foo'); + }); + + describe('#get', () => { + it('correctly gets an existing item from local storage', () => { + localStorage('foo', 'bar'); + ls.get('foo').should.equal('bar'); + }); + + it('correctly returns the default value for a non exising item', () => { + ls.get('baz', 'qux').should.equal('qux'); + }); + }); + + describe('#set', () => { + it('correctly sets an item into local storage', () => { + ls.set('foo', 'bar'); + localStorage('foo').should.equal('bar'); + }); + }); + + describe('#remove', () => { + it('correctly removes an item from local storage', () => { + localStorage('foo', 'bar'); + ls.remove('foo'); + (localStorage('foo') === null).should.be.true; + }); + }); +}); diff --git a/resources/assets/js/tests/services/utilsTest.js b/resources/assets/js/tests/services/utilsTest.js new file mode 100644 index 00000000..6842ac72 --- /dev/null +++ b/resources/assets/js/tests/services/utilsTest.js @@ -0,0 +1,15 @@ +require('chai').should(); + +import utils from '../../services/utils'; + +describe('services/utils', () => { + describe('#secondsToHis', () => { + it('correctly formats a duration to H:i:s', () => { + utils.secondsToHis(7547).should.equal('02:05:47'); + }); + + it('ommits hours from short duration when formats to H:i:s', () => { + utils.secondsToHis(314).should.equal('05:14'); + }); + }); +}); diff --git a/resources/assets/js/tests/stores/albumTest.js b/resources/assets/js/tests/stores/albumTest.js new file mode 100644 index 00000000..b104a398 --- /dev/null +++ b/resources/assets/js/tests/stores/albumTest.js @@ -0,0 +1,37 @@ +require('chai').should(); + +import albumStore from '../../stores/album'; +import artists from '../blobs/media'; + +describe('stores/album', () => { + beforeEach(() => { + albumStore.init(artists); + }); + + describe('#init', () => { + it('correctly gathers albums', () => { + albumStore.state.albums.length.should.equal(7); + }); + + it('correctly sets albums length', () => { + albumStore.state.albums[0].length.should.equal(259.92); + }); + + it('correctly sets album artists', () => { + albumStore.state.albums[0].artist.id.should.equal(1); + }); + }); + + describe('#all', () => { + it('correctly returns all songs', () => { + albumStore.all().length.should.equal(7); + }); + }); + + describe('#getLength', () => { + it('correctly calculates an album’s length', () => { + albumStore.getLength(albumStore.state.albums[6]); + albumStore.state.albums[6].length.should.equal(1940.42); // I'm sorry… + }); + }); +}); diff --git a/resources/assets/js/tests/stores/artistTest.js b/resources/assets/js/tests/stores/artistTest.js new file mode 100644 index 00000000..c5b64890 --- /dev/null +++ b/resources/assets/js/tests/stores/artistTest.js @@ -0,0 +1,36 @@ +require('chai').should(); + +import artistStore from '../../stores/artist'; +import artists from '../blobs/media'; + +describe('stores/artist', () => { + beforeEach(() => { + artistStore.init(artists); + }); + + describe('#init', () => { + it('correctly gathers artists', () => { + artistStore.state.artists.length.should.equal(3); + }); + + it('correctly gets artists’ covers', () => { + artistStore.state.artists[0].cover.should.equal('/public/img/covers/565c0f7067425.jpeg'); + }); + + it('correctly counts songs by artists', () => { + artistStore.state.artists[0].songCount = 3; + }); + }); + + describe('#getSongsByArtist', () => { + it('correctly gathers all songs by artist', () => { + artistStore.getSongsByArtist(artistStore.state.artists[0]).length.should.equal(3); + }); + }); + + describe('#getCover', () => { + it('correctly gets an artist’s cover', () => { + artistStore.getCover(artistStore.state.artists[0]).should.equal('/public/img/covers/565c0f7067425.jpeg'); + }); + }); +}); diff --git a/resources/assets/js/tests/stores/preferenceTest.js b/resources/assets/js/tests/stores/preferenceTest.js new file mode 100644 index 00000000..fd340837 --- /dev/null +++ b/resources/assets/js/tests/stores/preferenceTest.js @@ -0,0 +1,30 @@ +require('chai').should(); + +import localStorage from 'local-storage'; +import preferenceStore from '../../stores/preference'; + +let user = { id: 0 }; +let preferences = { + volume: 8, + notify: false, +}; + +describe('stores/preference', () => { + beforeEach(() => { + localStorage.set(`preferences_${user.id}`, preferences); + preferenceStore.init(user); + }); + + describe("#set", () => { + it('correctly sets preferences', () => { + preferenceStore.set('volume', 5); + localStorage.get(`preferences_${user.id}`).volume.should.equal(5); + }); + }); + + describe("#get", () => { + it('returns correct preference values', () => { + preferenceStore.get('volume').should.equal(8); + }); + }); +}); diff --git a/resources/assets/js/tests/stores/queueTest.js b/resources/assets/js/tests/stores/queueTest.js new file mode 100644 index 00000000..9d516847 --- /dev/null +++ b/resources/assets/js/tests/stores/queueTest.js @@ -0,0 +1,110 @@ +require('chai').should(); + +import queueStore from '../../stores/queue'; +import artists from '../blobs/media'; + +let songs = artists[2].albums[0].songs; + +describe('stores/queue', () => { + beforeEach(() => { + queueStore.state.songs = songs; + queueStore.state.current = songs[1]; + }); + + describe('#all', () => { + it('correctly returns all queued songs', () => { + queueStore.all().should.equal(songs); + }); + }); + + describe('#first', () => { + it('correctly returns the first queued song', () => { + queueStore.first().title.should.equal('No bravery'); + }); + }); + + describe('#last', () => { + it('correctly returns the last queued song', () => { + queueStore.last().title.should.equal('Tears and rain'); + }); + }); + + describe('#queue', () => { + beforeEach(() => { + queueStore.state.songs = songs; + }); + + let song = artists[0].albums[0].songs[0]; + + it('correctly appends a song to end of the queue', () => { + queueStore.queue(song); + queueStore.last().title.should.equal('I Swear'); + }); + + it('correctly prepends a song to top of the queue', () => { + queueStore.queue(song, false, true); + queueStore.first().title.should.equal('I Swear'); + }); + + it('correctly replaces the whole queue', () => { + queueStore.queue(song, true); + queueStore.all().length.should.equal(1); + queueStore.first().title.should.equal('I Swear'); + }); + }); + + describe('#unqueue', () => { + beforeEach(() => { + queueStore.state.songs = songs; + }); + + it('correctly removes a song from queue', () => { + queueStore.unqueue(queueStore.state.songs[0]); + queueStore.first().title.should.equal('So long, Jimmy'); // Oh the irony. + }); + + it('correctly removes mutiple songs from queue', () => { + queueStore.unqueue([queueStore.state.songs[0], queueStore.state.songs[1]]); + queueStore.first().title.should.equal('Wisemen'); + }); + }); + + describe('#clear', () => { + it('correctly clears all songs from queue', () => { + queueStore.clear(); + queueStore.state.songs.length.should.equal(0); + }); + }); + + describe('#current', () => { + it('returns the correct current song', () => { + queueStore.current().title.should.equal('So long, Jimmy'); + }); + + it('successfully sets the current song', () => { + queueStore.current(queueStore.state.songs[0]).title.should.equal('No bravery'); + }); + }); + + describe('#getNextSong', () => { + it('correctly gets the next song in queue', () => { + queueStore.getNextSong().title.should.equal('Wisemen'); + }); + + it('correctly returns null if at end of queue', () => { + queueStore.current(queueStore.state.songs[queueStore.state.songs.length - 1]); + (queueStore.getNextSong() === null).should.be.true; + }); + }); + + describe('#getPrevSong', () => { + it('correctly gets the previous song in queue', () => { + queueStore.getPrevSong().title.should.equal('No bravery'); + }); + + it('correctly returns null if at end of queue', () => { + queueStore.current(queueStore.state.songs[0]); + (queueStore.getPrevSong() === null).should.be.true; + }); + }); +}); diff --git a/resources/assets/js/tests/stores/songTest.js b/resources/assets/js/tests/stores/songTest.js new file mode 100644 index 00000000..1b243589 --- /dev/null +++ b/resources/assets/js/tests/stores/songTest.js @@ -0,0 +1,56 @@ +require('chai').should(); + +import songStore from '../../stores/song'; +import albumStore from '../../stores/album'; +import artists from '../blobs/media'; +import interactions from '../blobs/interactions'; + +describe('stores/song', () => { + beforeEach(() => { + // This is ugly and not very "unit," but anyway. + albumStore.init(artists); + songStore.init(albumStore.all(), interactions); + }); + + describe('#init', () => { + it('correctly gathers all songs', () => { + songStore.state.songs.length.should.equal(14); + }); + + it ('coverts lengths to formatted lengths', () => { + songStore.state.songs[0].fmtLength.should.be.a.string; + }); + + it('correctly sets albums', () => { + songStore.state.songs[0].album.id.should.equal(1193); + }); + }); + + describe('#all', () => { + it('correctly returns all songs', () => { + songStore.all().length.should.equal(14); + }); + }); + + describe('#byId', () => { + it('correctly gets a song by ID', () => { + songStore.byId('e6d3977f3ffa147801ca5d1fdf6fa55e').title.should.equal('Like a rolling stone'); + }); + }); + + describe('#byIds', () => { + it('correctly gets multiple songs by IDs', () => { + let songs = songStore.byIds(['e6d3977f3ffa147801ca5d1fdf6fa55e', 'aa16bbef6a9710eb9a0f41ecc534fad5']); + songs[0].title.should.equal('Like a rolling stone'); + songs[1].title.should.equal("Knockin' on heaven's door"); + }); + }); + + describe('#setInteractionStats', () => { + it('correctly sets interaction status', () => { + let song = songStore.byId('cb7edeac1f097143e65b1b2cde102482'); + song.liked.should.be.true; + song.playCount.should.equal(3); + }); + }); +}); diff --git a/resources/assets/js/tests/stores/userTest.js b/resources/assets/js/tests/stores/userTest.js new file mode 100644 index 00000000..0518c03f --- /dev/null +++ b/resources/assets/js/tests/stores/userTest.js @@ -0,0 +1,68 @@ +require('chai').should(); + +import userStore from '../../stores/user'; +import data from '../blobs/users'; + +describe('stores/user', () => { + beforeEach(() => { + userStore.init(data); + }); + + describe('#init', () => { + it('correctly sets data state', () => { + userStore.state.users.should.equal(data.users); + userStore.state.current.should.equal(data.currentUser); + }); + }); + + describe('#all', () => { + it('correctly returns all users', () => { + userStore.all().should.equal(data.users); + }); + }); + + describe('#byId', () => { + it('correctly gets a user by ID', () => { + userStore.byId(1).should.equal(data.users[0]); + }); + }); + + describe('#current', () => { + it('correctly gets the current user', () => { + userStore.current().id.should.equal(1); + }); + + it('correctly sets the current user', () => { + userStore.current(data.users[1]); + userStore.state.current.id.should.equal(2); + }); + }); + + describe('#setAvatar', () => { + it('correctly sets the current user’s avatar', () => { + userStore.setAvatar(); + userStore.current().avatar.should.equal('https://www.gravatar.com/avatar/b9611f1bba1aacbe6f5de5856695a202?s=256'); + }); + + it('correctly sets a user’s avatar', () => { + userStore.setAvatar(data.users[1]); + data.users[1].avatar.should.equal('https://www.gravatar.com/avatar/5024672cfe53f113b746e1923e373058?s=256'); + }); + }); + + describe('#updateProfile', () => { + + }); + + describe('#store', () => { + + }); + + describe('#update', () => { + + }); + + describe('#destroy', () => { + + }); +}); diff --git a/resources/assets/psd/logo.psd b/resources/assets/psd/logo.psd new file mode 100644 index 00000000..906b1b55 Binary files /dev/null and b/resources/assets/psd/logo.psd differ diff --git a/resources/assets/sass/app.scss b/resources/assets/sass/app.scss new file mode 100644 index 00000000..a14053ba --- /dev/null +++ b/resources/assets/sass/app.scss @@ -0,0 +1,6 @@ +@charset "urf-8"; + +@import "partials/_vars.scss"; +@import "partials/_mixins.scss"; + +@import "partials/_shared.scss"; diff --git a/resources/assets/sass/partials/_mixins.scss b/resources/assets/sass/partials/_mixins.scss new file mode 100644 index 00000000..07bff77d --- /dev/null +++ b/resources/assets/sass/partials/_mixins.scss @@ -0,0 +1,106 @@ +@mixin vertical-center() { + display: flex; + align-items: center; + justify-content: center; +} + +@mixin artist-album-card() { + justify-content: space-between; + flex-wrap: wrap; + display: flex; + + .item { + display: flex; + flex: 0 0 256px; + flex-direction: column; + margin-bottom: 16px; + + .cover { + @include vertical_center(); + + display: flex; + flex: 0 0 256px; + background-repeat: no-repeat; + background-size: cover; + background-position: center center; + position: relative; + + .control { + display: none; + width: 96px; + height: 96px; + text-align: center; + line-height: 96px; + font-size: 54px; + background: #111; + border-radius: 50%; + text-indent: 5px; + opacity: .7; + border: 1px solid transparent; + + transition: .3s; + + &:hover { + opacity: 1; + border-color: #fff; + transform: scale(1.1); + } + } + + &:before { + content: " "; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; + transition: .3s; + } + + &:hover { + &:before { + background-color: rgba(0, 0, 0, .4); + } + + .control { + display: block; + } + } + } + + footer { + padding: 16px; + background: #333; + flex: 1; + } + + .name { + font-weight: 500; + } + + .meta { + color: $color2ndText; + margin-top: 4px; + font-size: 12px; + } + } + + @media only screen + and (max-device-width : 768px) + and (orientation : portrait) { + justify-content: center; + } + + @media only screen + and (min-device-width: 769px) + and (max-device-width : 1024px) { + justify-content: space-around; + } +} + +@mixin inset-when-pressed() { + &:active { + box-shadow: inset 0px 10px 10px -10px rgba(0,0,0,1); + } +} diff --git a/resources/assets/sass/partials/_shared.scss b/resources/assets/sass/partials/_shared.scss new file mode 100644 index 00000000..47e884c1 --- /dev/null +++ b/resources/assets/sass/partials/_shared.scss @@ -0,0 +1,109 @@ +*, *:before, *:after { + box-sizing: border-box; + outline: none; +} + +input, select, button { + -webkit-appearance: none; + border: 0; + outline: 0; + font-family: $fontFamily; + font-size: $fontSize; + font-weight: $fontWeight_Thin; + padding: 4px 8px; + border-radius: 3px; + + &:required, &:invalid { + box-shadow: none; + } + + &.dirty { + background: $colorDirtyInputBgr; + } + + &[type="search"] { + border-radius: 12px; + height: 24px; + padding: 0 6px; + } + + &[type="text"] { + display: block; + } +} + +input[type="checkbox"] { + -webkit-appearance: checkbox; +} + +a, a:link, a:visited { + color: $colorLink; + text-decoration: none; + cursor: pointer; +} + +.clear, .clearfix { + &:after { + content: " "; + clear: both; + } +} + +.side { + width: 256px; +} + +.ir { + color: transparent; + font: 0/0 serif; +} + +.control { + cursor: pointer; + color: $colorLink; + + &:hover { + color: $colorLinkHovered; + } +} + +p { + line-height: 20px; +} + +.help { + opacity: .7; + font-size: 90%; + line-height: 16px; +} + +label { + font-size: 110%; + margin-bottom: 8px; + display: block; +} + +button, input[type="submit"], input[type="reset"], input[type="button"] { + background: $colorBtnBgr; + color: $colorBtnText; + font-size: 14px; + padding: 8px 12px; + cursor: pointer; + + @include inset-when-pressed(); +} + +body, html { + overflow: hidden; +} + +[draggable] { + user-select: none; + /* Required to make elements draggable in old WebKit */ + -khtml-user-drag: element; + -webkit-user-drag: element; +} + +.form-row { + margin-top: 16px; +} diff --git a/resources/assets/sass/partials/_vars.scss b/resources/assets/sass/partials/_vars.scss new file mode 100644 index 00000000..afc1dd9b --- /dev/null +++ b/resources/assets/sass/partials/_vars.scss @@ -0,0 +1,33 @@ +$colorMainBgr: #181818; +$color2ndBgr: #282828; +$colorMainText: #fff; +$color2ndText: #a0a0a0; +$colorBtnBgr: #0191f7; +$colorBtnText: #fff; +$colorDirtyInputBgr: #fff5b0; + +$colorHeart: #bf2043; +$colorGreen: #56a052; +$colorBlue: #4c769a; +$colorRed: #c34848; + +$colorSidebarBgr: #212121; +$colorExtraBgr: #212121; +$colorSearchFormBgr: transparent; +$colorPlayerControlsBgr: transparent; + +$colorLink: #aaa; +$colorLinkHovered: #fff; + +$colorHighlight: #ff7d2e; + +$fontFamily: 'Roboto', sans-serif; +$fontSize: 13px; + +$fontWeight_UltraThin: 100; +$fontWeight_Thin: 300; +$fontWeight_Normal: 500; + +$headerHeight: 48px; +$footerHeight: 64px; +$mainHeadingHeight: 64px; diff --git a/resources/assets/sass/vendors/_plyr.scss b/resources/assets/sass/vendors/_plyr.scss new file mode 100644 index 00000000..129a5a47 --- /dev/null +++ b/resources/assets/sass/vendors/_plyr.scss @@ -0,0 +1,651 @@ +// ========================================================================== +// Plyr styles +// https://github.com/selz/plyr +// ========================================================================== + +// Variables +// ------------------------------- + +// Colors +$blue: #3498DB !default; +$gray-dark: #343F4A !default; +$gray: #565D64 !default; +$gray-light: #6B7D86 !default; +$gray-lighter: #CBD0D3 !default; +$off-white: #313131 !default; + +// Font sizes +$font-size-small: 14px !default; +$font-size-base: 16px !default; + +// Captions +$font-size-captions-base: ceil($font-size-base * 1.25) !default; +$font-size-captions-medium: ceil($font-size-base * 1.5) !default; +$font-size-captions-large: ($font-size-base * 2) !default; + +// Controls +$control-spacing: 0 !default; +$controls-bg: #fff !default; +$control-bg-hover: $blue !default; +$control-color: null !default; +$control-color-hover: null !default; + +// Contrast +@if lightness($controls-bg) >= 65% { + $control-color: $gray-light; +} @else { + $control-color: $gray-lighter; +} +@if lightness($control-bg-hover) >= 65% { + $control-color-hover: $gray; +} @else { + $control-color-hover: #fff; +} + +// Tooltips +$tooltip-bg: $controls-bg !default; +$tooltip-color: $control-color !default; +$tooltip-padding: $control-spacing !default; +$tooltip-arrow-size: 5px !default; +$tooltip-radius: 3px !default; + +// Progress +$progress-bg: rgba(red($gray), green($gray), blue($gray), .2) !default; +$progress-playing-bg: $blue !default; +$progress-buffered-bg: rgba(red($gray), green($gray), blue($gray), .25) !default; +$progress-loading-size: 40px !default; +$progress-loading-bg: rgba(0,0,0, .15) !default; + +// Volume +$volume-track-height: 6px !default; +$volume-track-bg: darken($controls-bg, 10%) !default; +$volume-thumb-height: ($volume-track-height * 2) !default; +$volume-thumb-width: ($volume-track-height * 2) !default; +$volume-thumb-bg: $control-color !default; +$volume-thumb-bg-focus: $control-bg-hover !default; + +// Breakpoints +$bp-control-split: 560px !default; // When controls split into left/right +$bp-captions-large: 768px !default; // When captions jump to the larger font size + +// Animation +// --------------------------------------- + +@keyframes progress { + to { background-position: $progress-loading-size 0; } +} + +// Font smoothing +@mixin font-smoothing($mode: on) +{ + @if ($mode == 'on') { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; + } + @else if ($mode == 'off') { + -moz-osx-font-smoothing: auto; + -webkit-font-smoothing: subpixel-antialiased; + } +} + +// Contain floats: nicolasgallagher.com/micro-clearfix-hack/ +@mixin clearfix() +{ + zoom: 1; + &:before, + &:after { content: ''; display: table; } + &:after { clear: both; } +} +// Tab focus styles +@mixin tab-focus() +{ + outline: thin dotted #000; + outline-offset: 0; +} + +// styling +@mixin volume-thumb() +{ + height: $volume-thumb-height; + width: $volume-thumb-width; + background: $volume-thumb-bg; + border: 0; + border-radius: ($volume-thumb-height / 2); + transition: background .3s ease; + cursor: ew-resize; +} +@mixin volume-track() +{ + height: $volume-track-height; + background: $volume-track-bg; + border: 0; + border-radius: ($volume-track-height / 2); +} +@mixin seek-thumb() +{ + background: transparent; + border: 0; + width: ($control-spacing * 2); + height: $control-spacing; +} +@mixin seek-track() +{ + background: none; + border: 0; +} + +// Screen reader only +// ------------------------------- +.sr-only { + position: absolute !important; + clip: rect(1px, 1px, 1px, 1px); + padding: 0 !important; + border: 0 !important; + height: 1px !important; + width: 1px !important; + overflow: hidden; +} + +// Styles +// ------------------------------- +// Base +.player { + position: relative; + max-width: 100%; + min-width: 290px; + + // border-box everything + // http://paulirish.com/2012/box-sizing-border-box-ftw/ + &, + *, + *::after, + *::before { + box-sizing: border-box; + } + + // For video + &-video-wrapper { + position: relative; + } + video, + audio { + width: 100%; + height: auto; + vertical-align: middle; + } + + // For embeds + &-video-embed { + padding-bottom: 56.25%; /* 16:9 */ + height: 0; + + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; + } + } + + // Captions + &-captions { + display: none; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: ($control-spacing * 2) ($control-spacing * 2) ($control-spacing * 3); + color: #fff; + font-size: $font-size-captions-base; + text-align: center; + @include font-smoothing(); + + span { + border-radius: 2px; + padding: 3px 10px; + background: rgba(0,0,0, .9); + } + span:empty { + display: none; + } + + @media (min-width: $bp-captions-large) { + font-size: $font-size-captions-medium; + } + } + &.captions-active &-captions { + display: block; + } + &.fullscreen-active &-captions { + font-size: $font-size-captions-large; + } + + // Player controls + &-controls { + @include clearfix(); + @include font-smoothing(); + position: relative; + padding: $control-spacing; + background: $controls-bg; + line-height: 1; + text-align: center; + box-shadow: 0 1px 1px rgba(red($gray-dark), green($gray-dark), blue($gray-dark), .2); + + // Layout + &-right { + display: block; + margin: $control-spacing auto 0; + } + @media (min-width: $bp-control-split) { + &-left { + float: left; + } + &-right { + float: right; + margin-top: 0; + } + } + + // Buttons + button { + display: inline-block; + vertical-align: middle; + margin: 0 2px; + padding: ($control-spacing / 2) $control-spacing; + overflow: hidden; + border: 0; + background: transparent; + border-radius: 3px; + cursor: pointer; + color: $control-color; + transition: background .3s ease, color .3s ease, opacity .3s ease; + + svg { + width: 18px; + height: 18px; + display: block; + fill: currentColor; + transition: fill .3s ease; + } + + // Hover and tab focus + &.tab-focus, + &:hover { + background: $control-bg-hover; + color: $control-color-hover; + } + // Default focus + &:focus { + outline: 0; + } + } + + // Hide toggle icons by default + .icon-exit-fullscreen, + .icon-muted, + .icon-captions-on { + display: none; + } + + // Time display + .player-time { + display: inline-block; + vertical-align: middle; + margin-left: $control-spacing; + color: $control-color; + font-weight: 600; + font-size: $font-size-small; + @include font-smoothing(); + } + + // Media duration hidden on small screens + .player-time + .player-time { + display: none; + + @media (min-width: $bp-control-split) { + display: inline-block; + } + + // Add a slash in before + &::before { + content: '\2044'; + margin-right: $control-spacing; + } + } + } + + // Tooltips + &-tooltip { + position: absolute; + z-index: 2; + bottom: 100%; + margin-bottom: $tooltip-padding; + padding: $tooltip-padding ($tooltip-padding * 1.5); + + opacity: 0; + background: $tooltip-bg; + border-radius: $tooltip-radius; + color: $tooltip-color; + font-size: $font-size-small; + line-height: 1.5; + font-weight: 600; + + transform: translate(-50%, ($tooltip-padding * 3)) scale(0); + transform-origin: 50% 100%; + transition: transform .2s .1s ease, opacity .2s .1s ease; + + &::after { + content: ''; + display: block; + position: absolute; + left: 50%; + bottom: -$tooltip-arrow-size; + margin-left: -$tooltip-arrow-size; + width: 0; + height: 0; + transition: inherit; + border-style: solid; + border-width: $tooltip-arrow-size $tooltip-arrow-size 0 $tooltip-arrow-size; + border-color: $controls-bg transparent transparent; + } + } + button:hover .player-tooltip, + button:focus .player-tooltip { + opacity: 1; + transform: translate(-50%, 0) scale(1); + } + button:hover .player-tooltip { + z-index: 3; + } + + // Player progress + // element + &-progress { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + width: 100%; + height: 5px; + background: $progress-bg; + + &-buffer[value], + &-played[value], + &-seek[type='range'] { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 5px; + margin: 0; + padding: 0; + vertical-align: top; + + -webkit-appearance: none; + -moz-appearance: none; + border: none; + background: transparent; + } + &-buffer[value], + &-played[value] { + &::-webkit-progress-bar { + background: transparent; + } + + // Inherit from currentColor; + &::-webkit-progress-value { + background: currentColor; + } + &::-moz-progress-bar { + background: currentColor; + } + } + &-played[value] { + z-index: 2; + color: $progress-playing-bg; + } + &-buffer[value] { + color: $progress-buffered-bg; + } + + // Seek control + // element + // Specificity is for bootstrap compatibility + &-seek[type='range'] { + z-index: 4; + cursor: pointer; + outline: 0; + + // Webkit + &::-webkit-slider-runnable-track { + @include seek-track(); + } + &::-webkit-slider-thumb { + -webkit-appearance: none; + @include seek-thumb(); + } + + // Mozilla + &::-moz-range-track { + @include seek-track(); + } + &::-moz-range-thumb { + -moz-appearance: none; + @include seek-thumb(); + } + + // Microsoft + &::-ms-track { + color: transparent; + @include seek-track(); + } + &::-ms-fill-lower, + &::-ms-fill-upper { + @include seek-track(); + } + &::-ms-thumb { + @include seek-thumb(); + } + + &:focus { + outline: 0; + } + &::-moz-focus-outer { + border: 0; + } + } + } + + // Loading state + &.loading .player-progress-buffer { + animation: progress 1s linear infinite; + background-size: $progress-loading-size $progress-loading-size; + background-repeat: repeat-x; + background-color: $progress-buffered-bg; + background-image: linear-gradient( + -45deg, + $progress-loading-bg 25%, + transparent 25%, + transparent 50%, + $progress-loading-bg 50%, + $progress-loading-bg 75%, + transparent 75%, + transparent); + color: transparent; + } + + // States + &-controls [data-player='pause'], + &.playing .player-controls [data-player='play'] { + display: none; + } + &.playing .player-controls [data-player='pause'] { + display: inline-block; + } + + // Volume control + // element + // Specificity is for bootstrap compatibility + &-volume[type='range'] { + display: inline-block; + vertical-align: middle; + -webkit-appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + width: 100px; + padding: 0; + cursor: pointer; + background: transparent; + border: none; + outline: 0 !important; + + // Webkit + &::-webkit-slider-runnable-track { + @include volume-track(); + } + &::-webkit-slider-thumb { + -webkit-appearance: none; + margin-top: -(($volume-thumb-height - $volume-track-height) / 2); + @include volume-thumb(); + } + + // Mozilla + &::-moz-range-track { + @include volume-track(); + } + &::-moz-range-thumb { + @include volume-thumb(); + } + + // Microsoft + &::-ms-track { + height: $volume-track-height; + background: transparent; + border-color: transparent; + border-width: (($volume-thumb-height - $volume-track-height) / 2) 0; + color: transparent; + } + &::-ms-fill-lower, + &::-ms-fill-upper { + @include volume-track(); + } + &::-ms-thumb { + @include volume-thumb(); + } + + &:focus { + outline: 0; + + &::-webkit-slider-thumb { + background: $volume-thumb-bg-focus; + } + &::-moz-range-thumb { + background: $volume-thumb-bg-focus; + } + &::-ms-thumb { + background: $volume-thumb-bg-focus; + } + } + } + + // Hide sound controls on iOS + // It's not supported to change volume using JavaScript: + // https://developer.apple.com/library/safari/documentation/AudioVideo/Conceptual/Using_HTML5_Audio_Video/Device-SpecificConsiderations/Device-SpecificConsiderations.html + &.ios &-volume, + &.ios [data-player='mute'], + &-audio.ios &-controls-right { + display: none; + } + // Center buttons so it looks less odd + &-audio.ios &-controls-left { + float: none; + } + + // Audio specific styles + // Position the progress within the container + &-audio .player-controls { + padding-top: ($control-spacing * 2); + } + &-audio .player-progress { + bottom: auto; + top: 0; + background: $off-white; + } + + // Full screen mode + &-fullscreen, + &.fullscreen-active { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + height: 100%; + width: 100%; + z-index: 10000000; + background: #000; + + video { + height: 100%; + } + .player-video-wrapper { + height: 100%; + width: 100%; + } + .player-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + } + + // Hide controls when playing in full screen + &.fullscreen-hide-controls.playing { + .player-controls { + transform: translateY(100%) translateY($control-spacing / 2); + transition: transform .3s .2s ease; + } + &.player-hover .player-controls { + transform: translateY(0); + } + .player-captions { + bottom: ($control-spacing / 2); + transition: bottom .3s .2s ease; + } + } + + // Captions + .player-captions, + &.fullscreen-hide-controls.playing.player-hover .player-captions { + top: auto; + bottom: 90px; + + @media (min-width: $bp-control-split) { + bottom: 60px; + } + } + } + + // Change icons on state change + &.fullscreen-active .icon-exit-fullscreen, + &.muted .player-controls .icon-muted, + &.captions-active .player-controls .icon-captions-on { + display: block; + + & + svg { + display: none; + } + } + + // Some options are hidden by default + [data-player='captions'], + [data-player='fullscreen'] { + display: none; + } + &.captions-enabled [data-player='captions'], + &.fullscreen-enabled [data-player='fullscreen'] { + display: inline-block; + } +} diff --git a/resources/lang/en/auth.php b/resources/lang/en/auth.php new file mode 100644 index 00000000..e5506df2 --- /dev/null +++ b/resources/lang/en/auth.php @@ -0,0 +1,19 @@ + 'These credentials do not match our records.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/resources/lang/en/pagination.php b/resources/lang/en/pagination.php new file mode 100644 index 00000000..fcab34b2 --- /dev/null +++ b/resources/lang/en/pagination.php @@ -0,0 +1,19 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/resources/lang/en/passwords.php b/resources/lang/en/passwords.php new file mode 100644 index 00000000..e6f3a671 --- /dev/null +++ b/resources/lang/en/passwords.php @@ -0,0 +1,22 @@ + 'Passwords must be at least six characters and match the confirmation.', + 'reset' => 'Your password has been reset!', + 'sent' => 'We have e-mailed your password reset link!', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that e-mail address.", + +]; diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php new file mode 100644 index 00000000..b0a1f143 --- /dev/null +++ b/resources/lang/en/validation.php @@ -0,0 +1,110 @@ + 'The :attribute must be accepted.', + 'active_url' => 'The :attribute is not a valid URL.', + 'after' => 'The :attribute must be a date after :date.', + 'alpha' => 'The :attribute may only contain letters.', + 'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.', + 'alpha_num' => 'The :attribute may only contain letters and numbers.', + 'array' => 'The :attribute must be an array.', + 'before' => 'The :attribute must be a date before :date.', + 'between' => [ + 'numeric' => 'The :attribute must be between :min and :max.', + 'file' => 'The :attribute must be between :min and :max kilobytes.', + 'string' => 'The :attribute must be between :min and :max characters.', + 'array' => 'The :attribute must have between :min and :max items.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'confirmed' => 'The :attribute confirmation does not match.', + 'date' => 'The :attribute is not a valid date.', + 'date_format' => 'The :attribute does not match the format :format.', + 'different' => 'The :attribute and :other must be different.', + 'digits' => 'The :attribute must be :digits digits.', + 'digits_between' => 'The :attribute must be between :min and :max digits.', + 'email' => 'The :attribute must be a valid email address.', + 'exists' => 'The selected :attribute is invalid.', + 'filled' => 'The :attribute field is required.', + 'image' => 'The :attribute must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'integer' => 'The :attribute must be an integer.', + 'ip' => 'The :attribute must be a valid IP address.', + 'json' => 'The :attribute must be a valid JSON string.', + 'max' => [ + 'numeric' => 'The :attribute may not be greater than :max.', + 'file' => 'The :attribute may not be greater than :max kilobytes.', + 'string' => 'The :attribute may not be greater than :max characters.', + 'array' => 'The :attribute may not have more than :max items.', + ], + 'mimes' => 'The :attribute must be a file of type: :values.', + 'min' => [ + 'numeric' => 'The :attribute must be at least :min.', + 'file' => 'The :attribute must be at least :min kilobytes.', + 'string' => 'The :attribute must be at least :min characters.', + 'array' => 'The :attribute must have at least :min items.', + ], + 'not_in' => 'The selected :attribute is invalid.', + 'numeric' => 'The :attribute must be a number.', + 'regex' => 'The :attribute format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values is present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute and :other must match.', + 'size' => [ + 'numeric' => 'The :attribute must be :size.', + 'file' => 'The :attribute must be :size kilobytes.', + 'string' => 'The :attribute must be :size characters.', + 'array' => 'The :attribute must contain :size items.', + ], + 'string' => 'The :attribute must be a string.', + 'timezone' => 'The :attribute must be a valid zone.', + 'unique' => 'The :attribute has already been taken.', + 'url' => 'The :attribute format is invalid.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap attribute place-holders + | with something more reader friendly such as E-Mail Address instead + | of "email". This simply helps us make messages a little cleaner. + | + */ + + 'attributes' => [], + +]; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php new file mode 100644 index 00000000..4dc68b4b --- /dev/null +++ b/resources/views/auth/login.blade.php @@ -0,0 +1,97 @@ + + + + + + Koel + + + + + + + + + + + +
+ + + + {!! csrf_field() !!} +
+ + diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..4a415059 --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,47 @@ + + + + Be right back. + + + + + + +
+
+
Be right back.
+
+
+ + diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php new file mode 100644 index 00000000..4c50ef4d --- /dev/null +++ b/resources/views/index.blade.php @@ -0,0 +1,24 @@ + + + + Koel + + + + + + + + + + + + + + + + + + + + diff --git a/resources/views/vendor/.gitkeep b/resources/views/vendor/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/robots.txt b/robots.txt new file mode 100644 index 00000000..eb053628 --- /dev/null +++ b/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/server.php b/server.php new file mode 100644 index 00000000..900d472e --- /dev/null +++ b/server.php @@ -0,0 +1,21 @@ + + */ + +$uri = urldecode( + parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH) +); + +// This file allows us to emulate Apache's "mod_rewrite" functionality from the +// built-in PHP web server. This provides a convenient way to test a Laravel +// application without having installed a "real" web server software here. +if ($uri !== '/' && file_exists(__DIR__.$uri)) { + return false; +} + +require_once __DIR__.'/index.php'; diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/storage/framework/.gitignore b/storage/framework/.gitignore new file mode 100644 index 00000000..953edb7a --- /dev/null +++ b/storage/framework/.gitignore @@ -0,0 +1,7 @@ +config.php +routes.php +compiled.php +services.json +events.scanned.php +routes.scanned.php +down diff --git a/storage/framework/cache/.gitignore b/storage/framework/cache/.gitignore new file mode 100644 index 00000000..c96a04f0 --- /dev/null +++ b/storage/framework/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/storage/framework/sessions/.gitignore b/storage/framework/sessions/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/framework/sessions/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/storage/framework/views/.gitignore b/storage/framework/views/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/storage/framework/views/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/InteractionTest.php b/tests/InteractionTest.php new file mode 100644 index 00000000..e7cc2f26 --- /dev/null +++ b/tests/InteractionTest.php @@ -0,0 +1,99 @@ +createSampleMediaSet(); + } + + public function testPlayCountRegister() + { + $user = factory(User::class)->create(); + + $song = Song::orderBy('id')->first(); + $this->actingAs($user) + ->post('api/interaction/play', ['id' => $song->id]); + + $this->seeInDatabase('interactions', [ + 'user_id' => $user->id, + 'song_id' => $song->id, + 'play_count' => 1, + ]); + + // Try again + $this->actingAs($user) + ->post('api/interaction/play', ['id' => $song->id]); + + $this->seeInDatabase('interactions', [ + 'user_id' => $user->id, + 'song_id' => $song->id, + 'play_count' => 2, + ]); + } + + public function testLikeRegister() + { + $user = factory(User::class)->create(); + + $song = Song::orderBy('id')->first(); + $this->actingAs($user) + ->post('api/interaction/like', ['id' => $song->id]); + + $this->seeInDatabase('interactions', [ + 'user_id' => $user->id, + 'song_id' => $song->id, + 'liked' => 1, + ]); + + // Try again + $this->actingAs($user) + ->post('api/interaction/like', ['id' => $song->id]); + + $this->seeInDatabase('interactions', [ + 'user_id' => $user->id, + 'song_id' => $song->id, + 'liked' => 0, + ]); + } + + public function testBatchLikeAndUnlike() + { + $user = factory(User::class)->create(); + + $songs = Song::orderBy('id')->take(2)->get(); + $songIds = array_pluck($songs->toArray(), 'id'); + + $this->actingAs($user) + ->post('api/interaction/batch/like', ['ids' => $songIds]); + + foreach ($songs as $song) { + $this->seeInDatabase('interactions', [ + 'user_id' => $user->id, + 'song_id' => $song->id, + 'liked' => 1, + ]); + } + + $this->actingAs($user) + ->post('api/interaction/batch/unlike', ['ids' => $songIds]); + + foreach ($songs as $song) { + $this->seeInDatabase('interactions', [ + 'user_id' => $user->id, + 'song_id' => $song->id, + 'liked' => 0, + ]); + } + } +} diff --git a/tests/MediaTest.php b/tests/MediaTest.php new file mode 100644 index 00000000..bcc7d0b9 --- /dev/null +++ b/tests/MediaTest.php @@ -0,0 +1,54 @@ +sync($this->mediaPath); + + // Standard mp3 files under root path should be recognized + $this->seeInDatabase('songs', ['path' => $this->mediaPath.'/full.mp3']); + + // Ogg files and audio files in subdirectories should be recognized + $this->seeInDatabase('songs', ['path' => $this->mediaPath.'/subdir/back-in-black.ogg']); + + // Non-audio files shouldn't be recognized + $this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/rubbish.log']); + + // Broken/corrupted audio files shouldn't be recognized + $this->notSeeInDatabase('songs', ['path' => $this->mediaPath.'/fake.mp3']); + + // Artists should be created + $this->seeInDatabase('artists', ['name' => 'Cuckoo']); + $this->seeInDatabase('artists', ['name' => 'Koel']); + + // Albums should be created + $this->seeInDatabase('albums', ['name' => 'Koel Testing Vol. 1']); + + // Albums and artists should be correctly linked + $album = Album::whereName('Koel Testing Vol. 1')->first(); + $this->assertEquals('Koel', $album->artist->name); + + $currentCover = $album->cover; + + $song = Song::orderBy('id', 'desc')->first(); + + // Modified file should be recognized + touch($song->path, $time = time()); + $media->sync($this->mediaPath); + $song = Song::find($song->id); + $this->assertEquals($time, $song->mtime); + + // Albums with a non-default cover should have their covers overwritten + $this->assertEquals($currentCover, Album::find($album->id)->cover); + } +} diff --git a/tests/PlaylistTest.php b/tests/PlaylistTest.php new file mode 100644 index 00000000..7730d8af --- /dev/null +++ b/tests/PlaylistTest.php @@ -0,0 +1,117 @@ +createSampleMediaSet(); + } + + public function testCreatePlaylist() + { + $user = factory(User::class)->create(); + + // Let's create a playlist with 3 songs + $songs = Song::orderBy('id')->take(3)->get(); + $songIds = array_pluck($songs->toArray(), 'id'); + + $this->actingAs($user) + ->post('api/playlist', [ + 'name' => 'Foo Bar', + 'songs' => $songIds, + ]); + + $this->seeInDatabase('playlists', [ + 'user_id' => $user->id, + 'name' => 'Foo Bar', + ]); + + $playlist = Playlist::orderBy('id', 'desc')->first(); + + foreach ($songs as $song) { + $this->seeInDatabase('playlist_song', [ + 'playlist_id' => $playlist->id, + 'song_id' => $song->id, + ]); + } + } + + public function testUpdatePlaylistName() + { + $user = factory(User::class)->create(); + + $playlist = factory(Playlist::class)->create([ + 'user_id' => $user->id, + ]); + + $this->actingAs($user) + ->put("api/playlist/{$playlist->id}", ['name' => 'Foo Bar']); + + $this->seeInDatabase('playlists', [ + 'user_id' => $user->id, + 'name' => 'Foo Bar', + ]); + + // Other users can't modify it + $response = $this->actingAs(factory(User::class)->create()) + ->call('put', "api/playlist/{$playlist->id}", ['name' => 'Foo Bar']); + + $this->assertEquals(403, $response->status()); + } + + public function testSyncPlaylist() + { + $user = factory(User::class)->create(); + + $playlist = factory(Playlist::class)->create([ + 'user_id' => $user->id, + ]); + + $songs = Song::orderBy('id')->take(4)->get(); + $playlist->songs()->attach(array_pluck($songs->toArray(), 'id')); + + $removedSong = $songs->pop(); + + $this->actingAs($user) + ->put("api/playlist/{$playlist->id}/sync", [ + 'songs' => array_pluck($songs->toArray(), 'id'), + ]); + + // We should still see the first 3 songs, but not the removed one + foreach ($songs as $song) { + $this->seeInDatabase('playlist_song', [ + 'playlist_id' => $playlist->id, + 'song_id' => $song->id, + ]); + } + + $this->notSeeInDatabase('playlist_song', [ + 'playlist_id' => $playlist->id, + 'song_id' => $removedSong->id, + ]); + } + + public function testDeletePlaylist() + { + $user = factory(User::class)->create(); + + $playlist = factory(Playlist::class)->create([ + 'user_id' => $user->id, + ]); + + $this->actingAs($user) + ->delete("api/playlist/{$playlist->id}"); + + $this->notSeeInDatabase('playlists', ['id' => $playlist->id]); + } +} diff --git a/tests/SettingTest.php b/tests/SettingTest.php new file mode 100644 index 00000000..9ee4a438 --- /dev/null +++ b/tests/SettingTest.php @@ -0,0 +1,44 @@ +seeInDatabase('settings', ['key' => 'foo', 'value' => 's:3:"bar";']); + } + + public function testSetMultipleKeyValue() + { + Setting::set([ + 'foo' => 'bar', + 'baz' => 'qux', + ]); + + $this->seeInDatabase('settings', ['key' => 'foo', 'value' => 's:3:"bar";']); + $this->seeInDatabase('settings', ['key' => 'baz', 'value' => 's:3:"qux";']); + } + + public function testExistingShouldBeUpdated() + { + Setting::set('foo', 'bar'); + Setting::set('foo', 'baz'); + + $this->assertEquals('baz', Setting::get('foo')); + } + + public function testGet() + { + Setting::set('foo', 'bar'); + Setting::set('bar', ['baz' => 'qux']); + + $this->assertEquals('bar', Setting::get('foo')); + $this->assertEquals(['baz' => 'qux'], Setting::get('bar')); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..5add13ca --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,83 @@ +mediaPath = dirname(__FILE__).'/songs'; + } + + public function setUp() + { + parent::setUp(); + $this->prepareForTests(); + } + + /** + * The base URL to use while testing the application. + * + * @var string + */ + protected $baseUrl = 'http://localhost'; + + /** + * Creates the application. + * + * @return \Illuminate\Foundation\Application + */ + public function createApplication() + { + $app = require __DIR__.'/../bootstrap/app.php'; + + $app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + + $this->coverPath = $app->publicPath().'/img/covers'; + + return $app; + } + + private function prepareForTests() + { + Artisan::call('migrate'); + + if (!User::all()->count()) { + Artisan::call('db:seed'); + } + + if (!file_exists($this->coverPath)) { + mkdir($this->coverPath, 0777, true); + } + } + + /** + * Create a sample media set, with a complete artist+album+song trio. + */ + protected function createSampleMediaSet() + { + $artist = factory(Artist::class)->create(); + + // Sample 3 albums + $albums = factory(Album::class, 3)->create([ + 'artist_id' => $artist->id, + ]); + + // 7-15 songs per albums + foreach ($albums as $album) { + factory(Song::class, rand(7, 15))->create([ + 'album_id' => $album->id, + ]); + }; + } +} diff --git a/tests/UserTest.php b/tests/UserTest.php new file mode 100644 index 00000000..1786a88b --- /dev/null +++ b/tests/UserTest.php @@ -0,0 +1,66 @@ +actingAs(factory(User::class)->create()) + ->call('post', 'api/user', [ + 'name' => 'Foo', + 'email' => 'bar@baz.com', + 'password' => 'qux', + ]); + + $this->assertEquals(403, $response->status()); + + // But admins can + $this->actingAs(factory(User::class, 'admin')->create()) + ->post('api/user', [ + 'name' => 'Foo', + 'email' => 'bar@baz.com', + 'password' => 'qux', + ]); + + $this->seeInDatabase('users', ['name' => 'Foo']); + } + + public function testUpdateProfile() + { + $user = factory(User::class)->create(); + + $this->actingAs($user) + ->put('api/me', ['name' => 'Foo', 'email' => 'bar@baz.com']); + + $this->seeInDatabase('users', ['name' => 'Foo', 'email' => 'bar@baz.com']); + } + + public function testUpdateUser() + { + $user = factory(User::class)->create(); + + $this->actingAs(factory(User::class, 'admin')->create()) + ->put("api/user/{$user->id}", [ + 'name' => 'Foo', + 'email' => 'bar@baz.com', + 'password' => 'qux', + ]); + + $this->seeInDatabase('users', ['name' => 'Foo', 'email' => 'bar@baz.com']); + } + + public function testDeleteUser() + { + $user = factory(User::class)->create(); + $this->actingAs(factory(User::class, 'admin')->create()) + ->delete("api/user/{$user->id}"); + + $this->notSeeInDatabase('users', ['id' => $user->id]); + } +} diff --git a/tests/songs/blank.mp3 b/tests/songs/blank.mp3 new file mode 100644 index 00000000..7d455487 Binary files /dev/null and b/tests/songs/blank.mp3 differ diff --git a/tests/songs/fake.mp3 b/tests/songs/fake.mp3 new file mode 100644 index 00000000..d089b6f3 --- /dev/null +++ b/tests/songs/fake.mp3 @@ -0,0 +1,2 @@ +This is not a mp3 file. +For testing purpose only. diff --git a/tests/songs/full.mp3 b/tests/songs/full.mp3 new file mode 100644 index 00000000..be83559b Binary files /dev/null and b/tests/songs/full.mp3 differ diff --git a/tests/songs/lorem.mp3 b/tests/songs/lorem.mp3 new file mode 100644 index 00000000..d56fc681 Binary files /dev/null and b/tests/songs/lorem.mp3 differ diff --git a/tests/songs/subdir/back-in-black.ogg b/tests/songs/subdir/back-in-black.ogg new file mode 100644 index 00000000..34096df2 Binary files /dev/null and b/tests/songs/subdir/back-in-black.ogg differ diff --git a/tests/songs/subdir/no-name.mp3 b/tests/songs/subdir/no-name.mp3 new file mode 100644 index 00000000..216a07a1 Binary files /dev/null and b/tests/songs/subdir/no-name.mp3 differ diff --git a/tests/songs/subdir/sic.mp3 b/tests/songs/subdir/sic.mp3 new file mode 100644 index 00000000..790b2be1 Binary files /dev/null and b/tests/songs/subdir/sic.mp3 differ diff --git a/tile-wide.png b/tile-wide.png new file mode 100755 index 00000000..533d38b3 Binary files /dev/null and b/tile-wide.png differ diff --git a/tile.png b/tile.png new file mode 100755 index 00000000..dbcbb6af Binary files /dev/null and b/tile.png differ