diff --git a/app/commands/MigrateOldData.php b/app/commands/MigrateOldData.php index 1ebde208..278b20d6 100644 --- a/app/commands/MigrateOldData.php +++ b/app/commands/MigrateOldData.php @@ -14,11 +14,11 @@ } public function fire() { + $oldDb = DB::connection('old'); + $this->call('migrate:refresh'); - $oldDb = DB::connection('old'); $oldUsers = $oldDb->table('users')->get(); - $this->info('Syncing Users'); foreach ($oldUsers as $user) { $displayName = $user->display_name; @@ -51,9 +51,14 @@ $coverId = null; if (!$user->uses_gravatar) { - $coverFile = $this->getIdDirectory('users', $user->id) . '/' . $user->id . '_.png'; - $coverId = \Entities\Image::upload(new Symfony\Component\HttpFoundation\File\UploadedFile($coverFile, $user->id . '_.png'), $user->id)->id; - DB::table('users')->where('id', $user->id)->update(['avatar_id' => $coverId]); + try { + $coverFile = $this->getIdDirectory('users', $user->id) . '/' . $user->id . '_.png'; + $coverId = \Entities\Image::upload(new Symfony\Component\HttpFoundation\File\UploadedFile($coverFile, $user->id . '_.png'), $user->id)->id; + DB::table('users')->where('id', $user->id)->update(['avatar_id' => $coverId]); + } catch (\Exception $e) { + $this->error('Could copy user avatar ' . $user->id . ' because ' . $e->getMessage()); + DB::table('users')->where('id', $user->id)->update(['uses_gravatar' => true]); + } } } @@ -70,6 +75,9 @@ $this->info('Syncing Albums'); $oldAlbums = $oldDb->table('albums')->get(); foreach ($oldAlbums as $playlist) { + $logViews = $oldDb->table('album_log_views')->whereAlbumId($playlist->id)->get(); + $logDownload = $oldDb->table('album_log_downloads')->whereAlbumId($playlist->id)->get(); + DB::table('albums')->insert([ 'title' => $playlist->title, 'description' => $playlist->description, @@ -78,8 +86,39 @@ 'deleted_at' => $playlist->deleted_at, 'slug' => $playlist->slug, 'id' => $playlist->id, - 'user_id' => $playlist->user_id + 'user_id' => $playlist->user_id, + 'view_count' => 0, + 'download_count' => 0 ]); + + foreach ($logViews as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::VIEW, + 'album_id' => $logItem->album_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address, + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for album ' . $playlist->id . ' because ' . $e->getMessage()); + } + } + + foreach ($logDownload as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::DOWNLOAD, + 'album_id' => $logItem->album_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address, + 'track_format_id' => $logItem->track_file_format_id - 1 + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for album ' . $playlist->id . ' because ' . $e->getMessage()); + } + } } $this->info('Syncing Tracks'); @@ -87,10 +126,18 @@ foreach ($oldTracks as $track) { $coverId = null; if ($track->cover) { - $coverFile = $this->getIdDirectory('tracks', $track->id) . '/' . $track->id . '_' . $track->cover . '.png'; - $coverId = \Entities\Image::upload(new Symfony\Component\HttpFoundation\File\UploadedFile($coverFile, $track->id . '_' . $track->cover . '.png'), $track->user_id)->id; + try { + $coverFile = $this->getIdDirectory('tracks', $track->id) . '/' . $track->id . '_' . $track->cover . '.png'; + $coverId = \Entities\Image::upload(new Symfony\Component\HttpFoundation\File\UploadedFile($coverFile, $track->id . '_' . $track->cover . '.png'), $track->user_id)->id; + } catch (\Exception $e) { + $this->error('Could copy track cover ' . $track->id . ' because ' . $e->getMessage()); + } } + $trackLogViews = $oldDb->table('track_log_views')->whereTrackId($track->id)->get(); + $trackLogPlays = $oldDb->table('track_log_plays')->whereTrackId($track->id)->get(); + $trackLogDownload = $oldDb->table('track_log_downloads')->whereTrackId($track->id)->get(); + DB::table('tracks')->insert([ 'id' => $track->id, 'title' => $track->title, @@ -112,22 +159,75 @@ 'album_id' => $track->album_id, 'cover_id' => $coverId, 'license_id' => $track->license_id, - 'duration' => $track->duration + 'duration' => $track->duration, + 'view_count' => 0, + 'play_count' => 0, + 'download_count' => 0 ]); + + foreach ($trackLogViews as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::VIEW, + 'track_id' => $logItem->track_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for track ' . $track->id . ' because ' . $e->getMessage()); + } + } + + foreach ($trackLogPlays as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::PLAY, + 'track_id' => $logItem->track_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for track ' . $track->id . ' because ' . $e->getMessage()); + } + } + + foreach ($trackLogDownload as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::DOWNLOAD, + 'track_id' => $logItem->track_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address, + 'track_format_id' => $logItem->track_file_format_id - 1 + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for track ' . $track->id . ' because ' . $e->getMessage()); + } + } } $oldShowSongs = $oldDb->table('song_track')->get(); foreach ($oldShowSongs as $song) { - DB::table('show_song_track')->insert([ - 'id' => $song->id, - 'show_song_id' => $song->song_id, - 'track_id' => $song->track_id - ]); + try { + DB::table('show_song_track')->insert([ + 'id' => $song->id, + 'show_song_id' => $song->song_id, + 'track_id' => $song->track_id + ]); + } catch (\Exception $e) { + $this->error('Could insert show track item for ' . $song->track_id . ' because ' . $e->getMessage()); + } } $this->info('Syncing Playlists'); $oldPlaylists = $oldDb->table('playlists')->get(); foreach ($oldPlaylists as $playlist) { + $logViews = $oldDb->table('playlist_log_views')->wherePlaylistId($playlist->id)->get(); + $logDownload = $oldDb->table('playlist_log_downloads')->wherePlaylistId($playlist->id)->get(); + DB::table('playlists')->insert([ 'title' => $playlist->title, 'description' => $playlist->description, @@ -137,8 +237,39 @@ 'slug' => $playlist->slug, 'id' => $playlist->id, 'user_id' => $playlist->user_id, - 'is_public' => true + 'is_public' => true, + 'view_count' => 0, + 'download_count' => 0, ]); + + foreach ($logViews as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::VIEW, + 'playlist_id' => $logItem->playlist_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address, + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for playlist ' . $playlist->id . ' because ' . $e->getMessage()); + } + } + + foreach ($logDownload as $logItem) { + try { + DB::table('resource_log_items')->insert([ + 'user_id' => $logItem->user_id, + 'log_type' => \Entities\ResourceLogItem::DOWNLOAD, + 'playlist_id' => $logItem->playlist_id, + 'created_at' => $logItem->created_at, + 'ip_address' => $logItem->ip_address, + 'track_format_id' => $logItem->track_file_format_id - 1 + ]); + } catch (\Exception $e) { + $this->error('Could insert log item for playlist ' . $playlist->id . ' because ' . $e->getMessage()); + } + } } $this->info('Syncing Playlist Tracks'); @@ -183,7 +314,6 @@ 'id' => $fav->id, 'user_id' => $fav->user_id, 'created_at' => $fav->created_at, - 'updated_at' => $fav->updated_at, 'track_id' => $fav->track_id, 'album_id' => $fav->album_id, 'playlist_id' => $fav->playlist_id, @@ -199,8 +329,7 @@ return \Config::get('app.files_directory') . '/' . $type . '/' . $dir; } - protected function getArguments() - { + protected function getArguments() { return []; } diff --git a/app/commands/RefreshCache.php b/app/commands/RefreshCache.php new file mode 100644 index 00000000..ca36b05e --- /dev/null +++ b/app/commands/RefreshCache.php @@ -0,0 +1,182 @@ +update(['comment_count' => DB::raw('(SELECT COUNT(id) FROM comments WHERE comments.track_id = tracks.id AND deleted_at IS NULL)')]); + DB::table('albums')->update(['comment_count' => DB::raw('(SELECT COUNT(id) FROM comments WHERE comments.album_id = albums.id AND deleted_at IS NULL)')]); + DB::table('playlists')->update(['comment_count' => DB::raw('(SELECT COUNT(id) FROM comments WHERE comments.playlist_id = playlists.id AND deleted_at IS NULL)')]); + DB::table('users')->update(['comment_count' => DB::raw('(SELECT COUNT(id) FROM comments WHERE comments.profile_id = users.id AND deleted_at IS NULL)')]); + + $users = DB::table('users')->get(); + $cacheItems = []; + $resources = [ + 'album' => [], + 'playlist' => [], + 'track' => [] + ]; + + foreach ($users as $user) { + $cacheItems[$user->id] = [ + 'album' => [], + 'playlist' => [], + 'track' => [], + ]; + } + + $logItems = DB::table('resource_log_items')->get(); + foreach ($logItems as $item) { + $type = ''; + $id = 0; + + if ($item->album_id) { + $type = 'album'; + $id = $item->album_id; + } + else if ($item->playlist_id) { + $type = 'playlist'; + $id = $item->playlist_id; + } + else if ($item->track_id) { + $type = 'track'; + $id = $item->track_id; + } + + $resource = $this->getCacheItem($resources, $type, $id); + + if ($item->user_id != null) { + $userResource = $this->getUserCacheItem($cacheItems, $item->user_id, $type, $id); + + if ($item->log_type == \Entities\ResourceLogItem::DOWNLOAD) { + $userResource['download_count']++; + } + else if ($item->log_type == \Entities\ResourceLogItem::VIEW) { + $userResource['view_count']++; + } + else if ($item->log_type == \Entities\ResourceLogItem::PLAY) { + $userResource['play_count']++; + } + + $cacheItems[$item->user_id][$type][$id] = $userResource; + } + + if ($item->log_type == \Entities\ResourceLogItem::DOWNLOAD) { + $resource['download_count']++; + } + else if ($item->log_type == \Entities\ResourceLogItem::VIEW) { + $resource['view_count']++; + } + else if ($item->log_type == \Entities\ResourceLogItem::PLAY) { + $resource['play_count']++; + } + + $resources[$type][$id] = $resource; + } + + $pins = DB::table('pinned_playlists')->get(); + foreach ($pins as $pin) { + $userResource = $this->getUserCacheItem($cacheItems, $pin->user_id, 'playlist', $pin->playlist_id); + $userResource['is_pinned'] = true; + $cacheItems[$pin->user_id]['playlist'][$pin->playlist_id] = $userResource; + } + + $favs = DB::table('favourites')->get(); + foreach ($favs as $fav) { + $type = ''; + $id = 0; + + if ($fav->album_id) { + $type = 'album'; + $id = $fav->album_id; + } + else if ($fav->playlist_id) { + $type = 'playlist'; + $id = $fav->playlist_id; + } + else if ($fav->track_id) { + $type = 'track'; + $id = $fav->track_id; + } + + $userResource = $this->getUserCacheItem($cacheItems, $fav->user_id, $type, $id); + $userResource['is_favourited'] = true; + $cacheItems[$fav->user_id][$type][$id] = $userResource; + + $resource = $this->getCacheItem($resources, $type, $id); + $resource['favourite_count']++; + $resources[$type][$id] = $resource; + } + + foreach ($resources as $name => $resourceArray) { + foreach ($resourceArray as $id => $resource) { + DB::table($name . 's')->whereId($id)->update($resource); + } + } + + DB::table('resource_users')->delete(); + foreach ($cacheItems as $cacheItem) { + foreach ($cacheItem as $resources) { + foreach ($resources as $resource) { + DB::table('resource_users')->insert($resource); + } + } + } + } + + private function getCacheItem(&$resources, $type, $id) { + if (!isset($resources[$type][$id])) { + $item = [ + 'view_count' => 0, + 'download_count' => 0, + 'favourite_count' => 0, + ]; + + if ($type == 'track') + $item['play_count'] = 0; + + $resources[$type][$id] = $item; + return $item; + } + + return $resources[$type][$id]; + } + + private function getUserCacheItem(&$items, $userId, $type, $id) { + if (!isset($items[$userId][$type][$id])) { + $item = [ + 'is_followed' => false, + 'is_favourited' => false, + 'is_pinned' => false, + 'view_count' => 0, + 'play_count' => 0, + 'download_count' => 0, + 'user_id' => $userId + ]; + + $item[$type . '_id'] = $id; + + $items[$userId][$type][$id] = $item; + return $item; + } + + return $items[$userId][$type][$id]; + } + + protected function getArguments() { + return []; + } + + protected function getOptions() { + return []; + } +} \ No newline at end of file diff --git a/app/controllers/AlbumsController.php b/app/controllers/AlbumsController.php index 7538288c..d8286cf6 100644 --- a/app/controllers/AlbumsController.php +++ b/app/controllers/AlbumsController.php @@ -1,6 +1,8 @@ slug]); } + + public function getDownload($id, $extension) { + $album = Album::with('tracks', 'user')->find($id); + if (!$album) + App::abort(404); + + $format = null; + $formatName = null; + + foreach (Track::$Formats as $name => $item) { + if ($item['extension'] == $extension) { + $format = $item; + $formatName = $name; + break; + } + } + + if ($format == null) + App::abort(404); + + ResourceLogItem::logItem('album', $id, ResourceLogItem::DOWNLOAD, $format['index']); + $downloader = new AlbumDownloader($album, $formatName); + $downloader->download(); + } } \ No newline at end of file diff --git a/app/controllers/Api/Web/AlbumsController.php b/app/controllers/Api/Web/AlbumsController.php index 3672d41c..302ef1c3 100644 --- a/app/controllers/Api/Web/AlbumsController.php +++ b/app/controllers/Api/Web/AlbumsController.php @@ -8,6 +8,7 @@ use Entities\Album; use Entities\Comment; use Entities\Image; + use Entities\ResourceLogItem; use Entities\Track; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Input; @@ -27,10 +28,15 @@ } public function getShow($id) { - $album = Album::with(['tracks', 'user', 'comments' => function($query) { $query->with('user'); }])->details()->find($id); + $album = Album::with(['tracks' => function($query) { $query->details(); }, 'user', 'comments' => function($query) { $query->with('user'); }])->details()->find($id); if (!$album) App::abort(404); + if (Input::get('log')) { + ResourceLogItem::logItem('album', $id, ResourceLogItem::VIEW); + $album->view_count++; + } + return Response::json([ 'album' => Album::mapPublicAlbumShow($album) ], 200); diff --git a/app/controllers/Api/Web/TracksController.php b/app/controllers/Api/Web/TracksController.php index d69386fb..2d9090ef 100644 --- a/app/controllers/Api/Web/TracksController.php +++ b/app/controllers/Api/Web/TracksController.php @@ -8,6 +8,8 @@ use Cover; use Entities\Favourite; use Entities\Image; + use Entities\ResourceLogItem; + use Entities\ResourceUser; use Entities\Track; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Input; @@ -32,6 +34,11 @@ if (!$track || !$track->canView(Auth::user())) return $this->notFound('Track not found!'); + if (Input::get('log')) { + ResourceLogItem::logItem('track', $id, ResourceLogItem::VIEW); + $track->view_count++; + } + return Response::json(['track' => Track::mapPublicTrackShow($track)], 200); } @@ -51,7 +58,7 @@ public function getIndex() { $page = 1; - $perPage = 60; + $perPage = 45; if (Input::has('page')) $page = Input::get('page'); @@ -64,7 +71,7 @@ $this->applyFilters($query); $totalCount = $query->count(); - $query->take($perPage)->skip(30 * ($page - 1)); + $query->take($perPage)->skip($perPage * ($page - 1)); $tracks = []; $ids = []; diff --git a/app/controllers/TracksController.php b/app/controllers/TracksController.php index fc18f02e..cff770ae 100644 --- a/app/controllers/TracksController.php +++ b/app/controllers/TracksController.php @@ -1,5 +1,6 @@ header('X-Sendfile', $track->getFileFor('MP3')); - $response->header('Content-Disposition', 'filename=' . $track->getFilenameFor('MP3')); + $response->header('Content-Disposition', 'filename="' . $track->getFilenameFor('MP3') . '"'); + $response->header('Content-Type', $format['mime_type']); + + return $response; + } + + public function getDownload($id, $extension) { + $track = Track::find($id); + if (!$track || !$track->canView(Auth::user())) + App::abort(404); + + $format = null; + $formatName = null; + + foreach (Track::$Formats as $name => $item) { + if ($item['extension'] == $extension) { + $format = $item; + $formatName = $name; + break; + } + } + + if ($format == null) + App::abort(404); + + ResourceLogItem::logItem('track', $id, ResourceLogItem::DOWNLOAD, $format['index']); + + $response = Response::make('', 200); + $response->header('X-Sendfile', $track->getFileFor($formatName)); + $response->header('Content-Disposition', 'attachment; filename="' . $track->getDownloadFilenameFor($formatName) . '"'); $response->header('Content-Type', $format['mime_type']); return $response; diff --git a/app/database/migrations/2013_06_07_003952_create_users_table.php b/app/database/migrations/2013_06_07_003952_create_users_table.php index a9b97a88..6f4c91a8 100644 --- a/app/database/migrations/2013_06_07_003952_create_users_table.php +++ b/app/database/migrations/2013_06_07_003952_create_users_table.php @@ -18,6 +18,7 @@ class CreateUsersTable extends Migration { $table->boolean('uses_gravatar')->default(true); $table->boolean('can_see_explicit_content'); $table->text('bio'); + $table->integer('comment_count')->unsigned(); $table->timestamps(); }); diff --git a/app/database/migrations/2013_06_27_015259_create_tracks_table.php b/app/database/migrations/2013_06_27_015259_create_tracks_table.php index 506e2b35..ad97ac73 100644 --- a/app/database/migrations/2013_06_27_015259_create_tracks_table.php +++ b/app/database/migrations/2013_06_27_015259_create_tracks_table.php @@ -43,6 +43,12 @@ class CreateTracksTable extends Migration { $table->boolean('is_downloadable'); $table->float('duration')->unsigned(); + $table->integer('play_count')->unsigned(); + $table->integer('view_count')->unsigned(); + $table->integer('download_count')->unsigned(); + $table->integer('favourite_count')->unsigned(); + $table->integer('comment_count')->unsigned(); + $table->timestamps(); $table->timestamp('deleted_at')->nullable()->index(); $table->timestamp('published_at')->nullable()->index(); diff --git a/app/database/migrations/2013_07_28_060804_create_albums.php b/app/database/migrations/2013_07_28_060804_create_albums.php index 5d52df51..dca1d0dc 100644 --- a/app/database/migrations/2013_07_28_060804_create_albums.php +++ b/app/database/migrations/2013_07_28_060804_create_albums.php @@ -11,6 +11,12 @@ class CreateAlbums extends Migration { $table->string('slug')->index(); $table->text('description'); $table->integer('cover_id')->unsigned()->nullable(); + + $table->integer('view_count')->unsigned(); + $table->integer('download_count')->unsigned(); + $table->integer('favourite_count')->unsigned(); + $table->integer('comment_count')->unsigned(); + $table->timestamps(); $table->timestamp('deleted_at')->nullable()->index(); diff --git a/app/database/migrations/2013_07_28_135136_create_playlists.php b/app/database/migrations/2013_07_28_135136_create_playlists.php index 35f3139b..6fa43eb1 100644 --- a/app/database/migrations/2013_07_28_135136_create_playlists.php +++ b/app/database/migrations/2013_07_28_135136_create_playlists.php @@ -11,22 +11,19 @@ class CreatePlaylists extends Migration { $table->string('slug'); $table->text('description'); $table->boolean('is_public'); + + $table->integer('view_count')->unsigned(); + $table->integer('download_count')->unsigned(); + $table->integer('favourite_count')->unsigned(); + $table->integer('follow_count')->unsigned(); + $table->integer('comment_count')->unsigned(); + $table->timestamps(); $table->date('deleted_at')->nullable()->index(); $table->foreign('user_id')->references('id')->on('users')->on_update('cascade'); }); - Schema::create('pinned_playlists', function($table) { - $table->increments('id'); - $table->integer('user_id')->unsigned()->index(); - $table->integer('playlist_id')->unsigned()->index(); - $table->timestamps(); - - $table->foreign('user_id')->references('id')->on('users')->on_update('cascade'); - $table->foreign('playlist_id')->references('id')->on('playlists')->on_update('cascade'); - }); - Schema::create('playlist_track', function($table){ $table->increments('id'); $table->timestamps(); @@ -37,6 +34,16 @@ class CreatePlaylists extends Migration { $table->foreign('playlist_id')->references('id')->on('playlists')->on_update('cascade')->on_delete('cascade'); $table->foreign('track_id')->references('id')->on('tracks')->on_update('cascade'); }); + + Schema::create('pinned_playlists', function($table) { + $table->increments('id'); + $table->integer('user_id')->unsigned()->index(); + $table->integer('playlist_id')->unsigned()->index(); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users')->on_update('cascade'); + $table->foreign('playlist_id')->references('id')->on('playlists')->on_update('cascade'); + }); } public function down() { @@ -47,11 +54,6 @@ class CreatePlaylists extends Migration { Schema::drop('playlist_track'); - Schema::table('pinned_playlists', function($table){ - $table->dropForeign('pinned_playlists_user_id_foreign'); - $table->dropForeign('pinned_playlists_playlist_id_foreign'); - }); - Schema::drop('pinned_playlists'); Schema::table('playlists', function($table){ diff --git a/app/database/migrations/2013_08_01_024827_create_favourites.php b/app/database/migrations/2013_08_01_024827_create_favourites.php deleted file mode 100644 index e426cb8c..00000000 --- a/app/database/migrations/2013_08_01_024827_create_favourites.php +++ /dev/null @@ -1,34 +0,0 @@ -increments('id'); - $table->integer('user_id')->unsigned()->index(); - - $table->integer('track_id')->unsigned()->nullable()->index(); - $table->integer('album_id')->unsigned()->nullable()->index(); - $table->integer('playlist_id')->unsigned()->nullable()->index(); - - $table->timestamps(); - - $table->foreign('user_id')->references('id')->on('users')->on_delete('cascade'); - $table->foreign('track_id')->references('id')->on('tracks'); - $table->foreign('album_id')->references('id')->on('albums'); - $table->foreign('playlist_id')->references('id')->on('playlists'); - }); - } - - public function down() { - Schema::table('favourites', function($table){ - $table->dropForeign('favourites_user_id_foreign'); - $table->dropForeign('favourites_track_id_foreign'); - $table->dropForeign('favourites_album_id_foreign'); - $table->dropForeign('favourites_playlist_id_foreign'); - }); - - Schema::drop('favourites'); - } -} \ No newline at end of file diff --git a/app/database/migrations/2013_08_01_051337_create_comments.php b/app/database/migrations/2013_08_01_051337_create_comments.php index 0840c1ce..d70491cb 100644 --- a/app/database/migrations/2013_08_01_051337_create_comments.php +++ b/app/database/migrations/2013_08_01_051337_create_comments.php @@ -7,9 +7,10 @@ class CreateComments extends Migration { Schema::create('comments', function($table){ $table->increments('id'); $table->integer('user_id')->unsigned(); - $table->timestamps(); $table->string('ip_address', 46); $table->text('content'); + + $table->timestamps(); $table->timestamp('deleted_at')->nullable()->index(); $table->integer('profile_id')->unsigned()->nullable()->index(); diff --git a/app/database/migrations/2013_08_18_041928_create_user_tables.php b/app/database/migrations/2013_08_18_041928_create_user_tables.php new file mode 100644 index 00000000..e258497e --- /dev/null +++ b/app/database/migrations/2013_08_18_041928_create_user_tables.php @@ -0,0 +1,55 @@ +increments('id'); + $table->integer('user_id')->unsigned()->index(); + + $table->integer('track_id')->unsigned()->nullable()->index(); + $table->integer('album_id')->unsigned()->nullable()->index(); + $table->integer('playlist_id')->unsigned()->nullable()->index(); + + $table->boolean('is_followed'); + $table->boolean('is_favourited'); + $table->boolean('is_pinned'); + + $table->integer('view_count'); + $table->integer('play_count'); + $table->integer('download_count'); + + $table->foreign('user_id')->references('id')->on('users')->on_delete('cascade'); + $table->foreign('track_id')->references('id')->on('tracks'); + $table->foreign('album_id')->references('id')->on('albums'); + $table->foreign('playlist_id')->references('id')->on('playlists'); + + $table->unique(['user_id', 'track_id', 'album_id', 'playlist_id']); + }); + + Schema::create('resource_log_items', function($table){ + $table->increments('id'); + $table->integer('user_id')->unsigned()->nullable()->index(); + $table->integer('log_type')->unsigned(); + $table->string('ip_address', 46)->index(); + $table->integer('track_format_id')->unsigned()->nullable(); + + $table->integer('track_id')->unsigned()->nullable()->index(); + $table->integer('album_id')->unsigned()->nullable()->index(); + $table->integer('playlist_id')->unsigned()->nullable()->index(); + + $table->timestamp('created_at'); + + $table->foreign('user_id')->references('id')->on('users')->on_delete('cascade'); + $table->foreign('track_id')->references('id')->on('tracks'); + $table->foreign('album_id')->references('id')->on('albums'); + $table->foreign('playlist_id')->references('id')->on('playlists'); + }); + } + + public function down() { + Schema::drop('resource_users'); + Schema::drop('resource_log_items'); + } +} \ No newline at end of file diff --git a/app/database/migrations/2013_08_18_045248_create_favourites.php b/app/database/migrations/2013_08_18_045248_create_favourites.php new file mode 100644 index 00000000..8901d451 --- /dev/null +++ b/app/database/migrations/2013_08_18_045248_create_favourites.php @@ -0,0 +1,34 @@ +increments('id'); + $table->integer('user_id')->unsigned()->index(); + + $table->integer('track_id')->unsigned()->nullable()->index(); + $table->integer('album_id')->unsigned()->nullable()->index(); + $table->integer('playlist_id')->unsigned()->nullable()->index(); + + $table->timestamp('created_at'); + + $table->foreign('user_id')->references('id')->on('users')->on_delete('cascade'); + $table->foreign('track_id')->references('id')->on('tracks'); + $table->foreign('album_id')->references('id')->on('albums'); + $table->foreign('playlist_id')->references('id')->on('playlists'); + }); + } + + public function down() { + Schema::table('favourites', function($table){ + $table->dropForeign('favourites_user_id_foreign'); + $table->dropForeign('favourites_track_id_foreign'); + $table->dropForeign('favourites_album_id_foreign'); + $table->dropForeign('favourites_playlist_id_foreign'); + }); + + Schema::drop('favourites'); + } + } \ No newline at end of file diff --git a/app/library/ZipStream.php b/app/library/ZipStream.php new file mode 100644 index 00000000..e627efb8 --- /dev/null +++ b/app/library/ZipStream.php @@ -0,0 +1,573 @@ + + * @copyright 2009-2013 A. Grandt + * @license GNU LGPL, Attribution required for commercial implementations, requested for everything else. + * @link http://www.phpclasses.org/package/6116 + * @link https://github.com/Grandt/PHPZip + * @version 1.37 + */ +class ZipStream { + const VERSION = 1.37; + + const ZIP_LOCAL_FILE_HEADER = "\x50\x4b\x03\x04"; // Local file header signature + const ZIP_CENTRAL_FILE_HEADER = "\x50\x4b\x01\x02"; // Central file header signature + const ZIP_END_OF_CENTRAL_DIRECTORY = "\x50\x4b\x05\x06\x00\x00\x00\x00"; //end of Central directory record + + const EXT_FILE_ATTR_DIR = "\x10\x00\xFF\x41"; + const EXT_FILE_ATTR_FILE = "\x00\x00\xFF\x81"; + + const ATTR_VERSION_TO_EXTRACT = "\x14\x00"; // Version needed to extract + const ATTR_MADE_BY_VERSION = "\x1E\x03"; // Made By Version + + private $zipMemoryThreshold = 1048576; // Autocreate tempfile if the zip data exceeds 1048576 bytes (1 MB) + + private $zipComment = null; + private $cdRec = array(); // central directory + private $offset = 0; + private $isFinalized = FALSE; + private $addExtraField = TRUE; + + private $streamChunkSize = 16384; // 65536; + private $streamFilePath = null; + private $streamTimeStamp = null; + private $streamComment = null; + private $streamFile = null; + private $streamData = null; + private $streamFileLength = 0; + + /** + * Constructor. + * + * @param String $archiveName Name to send to the HTTP client. + * @param String $contentType Content mime type. Optional, defaults to "application/zip". + */ + function __construct($archiveName = "", $contentType = "application/zip") { + if (!function_exists('sys_get_temp_dir')) { + die ("ERROR: ZipStream " . self::VERSION . " requires PHP version 5.2.1 or above."); + } + + $headerFile = null; + $headerLine = null; + if (!headers_sent($headerFile, $headerLine) or die("

Error: Unable to send file $archiveName. HTML Headers have already been sent from $headerFile in line $headerLine

")) { + if ((ob_get_contents() === FALSE || ob_get_contents() == '') or die("\n

Error: Unable to send file $archiveName.epub. Output buffer contains the following text (typically warnings or errors):
" . ob_get_contents() . "

")) { + if (ini_get('zlib.output_compression')) { + ini_set('zlib.output_compression', 'Off'); + } + + header('Pragma: public'); + header("Last-Modified: " . gmdate("D, d M Y H:i:s T")); + header("Expires: 0"); + header("Accept-Ranges: bytes"); + //header("Connection: Keep-Alive"); + header("Content-Type: " . $contentType); + header('Content-Disposition: attachment; filename="' . $archiveName . '";'); + header("Content-Transfer-Encoding: binary"); + flush(); + } + } + } + + function __destruct() { + $this->isFinalized = TRUE; + $this->cdRec = null; + exit; + } + + /** + * Extra fields on the Zip directory records are Unix time codes needed for compatibility on the default Mac zip archive tool. + * These are enabled as default, as they do no harm elsewhere and only add 26 bytes per file added. + * + * @param bool $setExtraField TRUE (default) will enable adding of extra fields, anything else will disable it. + */ + function setExtraField($setExtraField = TRUE) { + $this->addExtraField = ($setExtraField === TRUE); + } + + /** + * Set Zip archive comment. + * + * @param string $newComment New comment. null to clear. + * @return bool $success + */ + public function setComment($newComment = null) { + if ($this->isFinalized) { + return FALSE; + } + $this->zipComment = $newComment; + + return TRUE; + } + + /** + * Add an empty directory entry to the zip archive. + * Basically this is only used if an empty directory is added. + * + * @param string $directoryPath Directory Path and name to be added to the archive. + * @param int $timestamp (Optional) Timestamp for the added directory, if omitted or set to 0, the current time will be used. + * @param string $fileComment (Optional) Comment to be added to the archive for this directory. To use fileComment, timestamp must be given. + * @return bool $success + */ + public function addDirectory($directoryPath, $timestamp = 0, $fileComment = null) { + if ($this->isFinalized) { + return FALSE; + } + + $directoryPath = str_replace("\\", "/", $directoryPath); + $directoryPath = rtrim($directoryPath, "/"); + + if (strlen($directoryPath) > 0) { + $this->buildZipEntry($directoryPath.'/', $fileComment, "\x00\x00", "\x00\x00", $timestamp, "\x00\x00\x00\x00", 0, 0, self::EXT_FILE_ATTR_DIR); + return TRUE; + } + return FALSE; + } + + /** + * Add a file to the archive at the specified location and file name. + * + * @param string $data File data. + * @param string $filePath Filepath and name to be used in the archive. + * @param int $timestamp (Optional) Timestamp for the added file, if omitted or set to 0, the current time will be used. + * @param string $fileComment (Optional) Comment to be added to the archive for this file. To use fileComment, timestamp must be given. + * @return bool $success + */ + public function addFile($data, $filePath, $timestamp = 0, $fileComment = null) { + if ($this->isFinalized) { + return FALSE; + } + + if (is_resource($data) && get_resource_type($data) == "stream") { + $this->addLargeFile($data, $filePath, $timestamp, $fileComment); + return FALSE; + } + + $gzType = "\x08\x00"; // Compression type 8 = deflate + $gpFlags = "\x00\x00"; // General Purpose bit flags for compression type 8 it is: 0=Normal, 1=Maximum, 2=Fast, 3=super fast compression. + $dataLength = strlen($data); + $fileCRC32 = pack("V", crc32($data)); + + $gzData = gzcompress($data); + $gzData = substr(substr($gzData, 0, strlen($gzData) - 4), 2); // gzcompress adds a 2 byte header and 4 byte CRC we can't use. + // The 2 byte header does contain useful data, though in this case the 2 parameters we'd be interrested in will always be 8 for compression type, and 2 for General purpose flag. + $gzLength = strlen($gzData); + + if ($gzLength >= $dataLength) { + $gzLength = $dataLength; + $gzData = $data; + $gzType = "\x00\x00"; // Compression type 0 = stored + $gpFlags = "\x00\x00"; // Compression type 0 = stored + } + + $this->buildZipEntry($filePath, $fileComment, $gpFlags, $gzType, $timestamp, $fileCRC32, $gzLength, $dataLength, self::EXT_FILE_ATTR_FILE); + + print ($gzData); + + return TRUE; + } + + /** + * Add the content to a directory. + * + * @author Adam Schmalhofer + * @author A. Grandt + * + * @param String $realPath Path on the file system. + * @param String $zipPath Filepath and name to be used in the archive. + * @param bool $recursive Add content recursively, default is TRUE. + * @param bool $followSymlinks Follow and add symbolic links, if they are accessible, default is TRUE. + * @param array &$addedFiles Reference to the added files, this is used to prevent duplicates, efault is an empty array. + * If you start the function by parsing an array, the array will be populated with the realPath + * and zipPath kay/value pairs added to the archive by the function. + */ + public function addDirectoryContent($realPath, $zipPath, $recursive = TRUE, $followSymlinks = TRUE, &$addedFiles = array()) { + if (file_exists($realPath) && !isset($addedFiles[realpath($realPath)])) { + if (is_dir($realPath)) { + $this->addDirectory($zipPath); + } + + $addedFiles[realpath($realPath)] = $zipPath; + + $iter = new DirectoryIterator($realPath); + foreach ($iter as $file) { + if ($file->isDot()) { + continue; + } + $newRealPath = $file->getPathname(); + $newZipPath = self::pathJoin($zipPath, $file->getFilename()); + + if (file_exists($newRealPath) && ($followSymlinks === TRUE || !is_link($newRealPath))) { + if ($file->isFile()) { + $addedFiles[realpath($newRealPath)] = $newZipPath; + $this->addLargeFile($newRealPath, $newZipPath); + } else if ($recursive === TRUE) { + $this->addDirectoryContent($newRealPath, $newZipPath, $recursive); + } else { + $this->addDirectory($zipPath); + } + } + } + } + } + + /** + * Add a file to the archive at the specified location and file name. + * + * @param string $dataFile File name/path. + * @param string $filePath Filepath and name to be used in the archive. + * @param int $timestamp (Optional) Timestamp for the added file, if omitted or set to 0, the current time will be used. + * @param string $fileComment (Optional) Comment to be added to the archive for this file. To use fileComment, timestamp must be given. + * @return bool $success + */ + public function addLargeFile($dataFile, $filePath, $timestamp = 0, $fileComment = null) { + if ($this->isFinalized) { + return FALSE; + } + + if (is_string($dataFile) && is_file($dataFile)) { + $this->processFile($dataFile, $filePath, $timestamp, $fileComment); + } else if (is_resource($dataFile) && get_resource_type($dataFile) == "stream") { + $fh = $dataFile; + $this->openStream($filePath, $timestamp, $fileComment); + + while (!feof($fh)) { + $this->addStreamData(fread($fh, $this->streamChunkSize)); + } + $this->closeStream($this->addExtraField); + } + return TRUE; + } + + /** + * Create a stream to be used for large entries. + * + * @param string $filePath Filepath and name to be used in the archive. + * @param int $timestamp (Optional) Timestamp for the added file, if omitted or set to 0, the current time will be used. + * @param string $fileComment (Optional) Comment to be added to the archive for this file. To use fileComment, timestamp must be given. + * @return bool $success + */ + public function openStream($filePath, $timestamp = 0, $fileComment = null) { + if (!function_exists('sys_get_temp_dir')) { + die ("ERROR: Zip " . self::VERSION . " requires PHP version 5.2.1 or above if large files are used."); + } + + if ($this->isFinalized) { + return FALSE; + } + + if (strlen($this->streamFilePath) > 0) { + closeStream(); + } + + $this->streamFile = tempnam(sys_get_temp_dir(), 'ZipStream'); + $this->streamData = fopen($this->streamFile, "wb"); + $this->streamFilePath = $filePath; + $this->streamTimestamp = $timestamp; + $this->streamFileComment = $fileComment; + $this->streamFileLength = 0; + + return TRUE; + } + + /** + * Add data to the open stream. + * + * @param String $data + * @return $length bytes added or FALSE if the archive is finalized or there are no open stream. + */ + public function addStreamData($data) { + if ($this->isFinalized || strlen($this->streamFilePath) == 0) { + return FALSE; + } + + $length = fwrite($this->streamData, $data, strlen($data)); + if ($length != strlen($data)) { + die ("

Length mismatch

\n"); + } + $this->streamFileLength += $length; + + return $length; + } + + /** + * Close the current stream. + * + * @return bool $success + */ + public function closeStream() { + if ($this->isFinalized || strlen($this->streamFilePath) == 0) { + return FALSE; + } + + fflush($this->streamData); + fclose($this->streamData); + + $this->processFile($this->streamFile, $this->streamFilePath, $this->streamTimestamp, $this->streamFileComment); + + $this->streamData = null; + $this->streamFilePath = null; + $this->streamTimestamp = null; + $this->streamFileComment = null; + $this->streamFileLength = 0; + + // Windows is a little slow at times, so a millisecond later, we can unlink this. + unlink($this->streamFile); + + $this->streamFile = null; + + return TRUE; + } + + private function processFile($dataFile, $filePath, $timestamp = 0, $fileComment = null) { + if ($this->isFinalized) { + return FALSE; + } + + $tempzip = tempnam(sys_get_temp_dir(), 'ZipStream'); + + $zip = new ZipArchive; + if ($zip->open($tempzip) === TRUE) { + $zip->addFile($dataFile, 'file'); + $zip->close(); + } + + $file_handle = fopen($tempzip, "rb"); + $stats = fstat($file_handle); + $eof = $stats['size']-72; + + fseek($file_handle, 6); + + $gpFlags = fread($file_handle, 2); + $gzType = fread($file_handle, 2); + fread($file_handle, 4); + $fileCRC32 = fread($file_handle, 4); + $v = unpack("Vval", fread($file_handle, 4)); + $gzLength = $v['val']; + $v = unpack("Vval", fread($file_handle, 4)); + $dataLength = $v['val']; + + $this->buildZipEntry($filePath, $fileComment, $gpFlags, $gzType, $timestamp, $fileCRC32, $gzLength, $dataLength, self::EXT_FILE_ATTR_FILE); + + fseek($file_handle, 34); + $pos = 34; + + while (!feof($file_handle) && $pos < $eof) { + $datalen = $this->streamChunkSize; + if ($pos + $this->streamChunkSize > $eof) { + $datalen = $eof-$pos; + } + echo fread($file_handle, $datalen); + $pos += $datalen; + flush(); + } + + fclose($file_handle); + unlink($tempzip); + } + + /** + * Close the archive. + * A closed archive can no longer have new files added to it. + * @return bool $success + */ + public function finalize() { + if (!$this->isFinalized) { + if (strlen($this->streamFilePath) > 0) { + $this->closeStream(); + } + + $cdRecSize = pack("v", sizeof($this->cdRec)); + + $cd = implode("", $this->cdRec); + print($cd); + print(self::ZIP_END_OF_CENTRAL_DIRECTORY); + print($cdRecSize.$cdRecSize); + print(pack("VV", strlen($cd), $this->offset)); + if (!empty($this->zipComment)) { + print(pack("v", strlen($this->zipComment))); + print($this->zipComment); + } else { + print("\x00\x00"); + } + + flush(); + + $this->isFinalized = TRUE; + $cd = null; + $this->cdRec = null; + + return TRUE; + } + return FALSE; + } + + /** + * Calculate the 2 byte dostime used in the zip entries. + * + * @param int $timestamp + * @return 2-byte encoded DOS Date + */ + private function getDosTime($timestamp = 0) { + $timestamp = (int)$timestamp; + $oldTZ = @date_default_timezone_get(); + date_default_timezone_set('UTC'); + $date = ($timestamp == 0 ? getdate() : getdate($timestamp)); + date_default_timezone_set($oldTZ); + if ($date["year"] >= 1980) { + return pack("V", (($date["mday"] + ($date["mon"] << 5) + (($date["year"]-1980) << 9)) << 16) | + (($date["seconds"] >> 1) + ($date["minutes"] << 5) + ($date["hours"] << 11))); + } + return "\x00\x00\x00\x00"; + } + + /** + * Build the Zip file structures + * + * @param String $filePath + * @param String $fileComment + * @param String $gpFlags + * @param String $gzType + * @param int $timestamp + * @param string $fileCRC32 + * @param int $gzLength + * @param int $dataLength + * @param integer $extFileAttr Use self::EXT_FILE_ATTR_FILE for files, self::EXT_FILE_ATTR_DIR for Directories. + */ + private function buildZipEntry($filePath, $fileComment, $gpFlags, $gzType, $timestamp, $fileCRC32, $gzLength, $dataLength, $extFileAttr) { + $filePath = str_replace("\\", "/", $filePath); + $fileCommentLength = (empty($fileComment) ? 0 : strlen($fileComment)); + $timestamp = (int)$timestamp; + $timestamp = ($timestamp == 0 ? time() : $timestamp); + + $dosTime = $this->getDosTime($timestamp); + $tsPack = pack("V", $timestamp); + + $ux = "\x75\x78\x0B\x00\x01\x04\xE8\x03\x00\x00\x04\x00\x00\x00\x00"; + + if (!isset($gpFlags) || strlen($gpFlags) != 2) { + $gpFlags = "\x00\x00"; + } + + $isFileUTF8 = mb_check_encoding($filePath, "UTF-8") && !mb_check_encoding($filePath, "ASCII"); + $isCommentUTF8 = !empty($fileComment) && mb_check_encoding($fileComment, "UTF-8") && !mb_check_encoding($fileComment, "ASCII"); + if ($isFileUTF8 || $isCommentUTF8) { + $flag = 0; + $gpFlagsV = unpack("vflags", $gpFlags); + if (isset($gpFlagsV['flags'])) { + $flag = $gpFlagsV['flags']; + } + $gpFlags = pack("v", $flag | (1 << 11)); + } + + $header = $gpFlags . $gzType . $dosTime. $fileCRC32 + . pack("VVv", $gzLength, $dataLength, strlen($filePath)); // File name length + + $zipEntry = self::ZIP_LOCAL_FILE_HEADER; + $zipEntry .= self::ATTR_VERSION_TO_EXTRACT; + $zipEntry .= $header; + $zipEntry .= $this->addExtraField ? "\x1C\x00" : "\x00\x00"; // Extra field length + $zipEntry .= $filePath; // FileName + // Extra fields + if ($this->addExtraField) { + $zipEntry .= "\x55\x54\x09\x00\x03" . $tsPack . $tsPack . $ux; + } + + print($zipEntry); + + $cdEntry = self::ZIP_CENTRAL_FILE_HEADER; + $cdEntry .= self::ATTR_MADE_BY_VERSION; + $cdEntry .= ($dataLength === 0 ? "\x0A\x00" : self::ATTR_VERSION_TO_EXTRACT); + $cdEntry .= $header; + $cdEntry .= $this->addExtraField ? "\x18\x00" : "\x00\x00"; // Extra field length + $cdEntry .= pack("v", $fileCommentLength); // File comment length + $cdEntry .= "\x00\x00"; // Disk number start + $cdEntry .= "\x00\x00"; // internal file attributes + $cdEntry .= $extFileAttr; // External file attributes + $cdEntry .= pack("V", $this->offset); // Relative offset of local header + $cdEntry .= $filePath; // FileName + // Extra fields + if ($this->addExtraField) { + $cdEntry .= "\x55\x54\x05\x00\x03" . $tsPack . $ux; + } + + if (!empty($fileComment)) { + $cdEntry .= $fileComment; // Comment + } + + $this->cdRec[] = $cdEntry; + $this->offset += strlen($zipEntry) + $gzLength; + } + + /** + * Join $file to $dir path, and clean up any excess slashes. + * + * @param String $dir + * @param String $file + */ + public static function pathJoin($dir, $file) { + if (empty($dir) || empty($file)) { + return self::getRelativePath($dir . $file); + } + return self::getRelativePath($dir . '/' . $file); + } + + /** + * Clean up a path, removing any unnecessary elements such as /./, // or redundant ../ segments. + * If the path starts with a "/", it is deemed an absolute path and any /../ in the beginning is stripped off. + * The returned path will not end in a "/". + * + * @param String $path The path to clean up + * @return String the clean path + */ + public static function getRelativePath($path) { + $path = preg_replace("#/+\.?/+#", "/", str_replace("\\", "/", $path)); + $dirs = explode("/", rtrim(preg_replace('#^(?:\./)+#', '', $path), '/')); + + $offset = 0; + $sub = 0; + $subOffset = 0; + $root = ""; + + if (empty($dirs[0])) { + $root = "/"; + $dirs = array_splice($dirs, 1); + } else if (preg_match("#[A-Za-z]:#", $dirs[0])) { + $root = strtoupper($dirs[0]) . "/"; + $dirs = array_splice($dirs, 1); + } + + $newDirs = array(); + foreach ($dirs as $dir) { + if ($dir !== "..") { + $subOffset--; + $newDirs[++$offset] = $dir; + } else { + $subOffset++; + if (--$offset < 0) { + $offset = 0; + if ($subOffset > $sub) { + $sub++; + } + } + } + } + + if (empty($root)) { + $root = str_repeat("../", $sub); + } + return $root . implode("/", array_slice($newDirs, 0, $offset)); + } +} +?> \ No newline at end of file diff --git a/app/models/AlbumDownloader.php b/app/models/AlbumDownloader.php new file mode 100644 index 00000000..4d2913a0 --- /dev/null +++ b/app/models/AlbumDownloader.php @@ -0,0 +1,48 @@ +_album = $album; + $this->_format = $format; + } + + function download() { + $zip = new ZipStream($this->_album->user->display_name . ' - ' . $this->_album->title . '.zip'); + $zip->setComment( + 'Album: ' . $this->_album->title ."\r\n". + 'Artist: ' . $this->_album->user->display_name ."\r\n". + 'URL: ' . $this->_album->url ."\r\n"."\r\n". + 'Downloaded on '. date('l, F jS, Y, \a\t h:i:s A') . '.' + ); + + $directory = $this->_album->user->display_name . '/' . $this->_album->title . '/'; + + $notes = + 'Album: ' . $this->_album->title ."\r\n". + 'Artist: ' . $this->_album->user->display_name ."\r\n". + 'URL: ' . $this->_album->url ."\r\n". + "\r\n". + $this->_album->description ."\r\n". + "\r\n". + "\r\n". + 'Tracks' ."\r\n". + "\r\n"; + + foreach ($this->_album->tracks as $track) { + if (!$track->is_downloadable) + continue; + + $zip->addLargeFile($track->getFileFor($this->_format), $directory . $track->getDownloadFilenameFor($this->_format)); + $notes .= + $track->track_number . '. ' . $track->title ."\r\n". + $track->description ."\r\n". + "\r\n"; + } + + $zip->addFile($notes, $directory . 'Album Notes.txt'); + $zip->finalize(); + } + } \ No newline at end of file diff --git a/app/models/Commands/ToggleFavouriteCommand.php b/app/models/Commands/ToggleFavouriteCommand.php index 427d3285..106dd7d3 100644 --- a/app/models/Commands/ToggleFavouriteCommand.php +++ b/app/models/Commands/ToggleFavouriteCommand.php @@ -5,8 +5,10 @@ use Entities\Album; use Entities\Favourite; use Entities\Playlist; + use Entities\ResourceUser; use Entities\Track; use Illuminate\Support\Facades\Auth; + use Illuminate\Support\Facades\DB; class ToggleFavouriteCommand extends CommandBase { private $_resourceType; @@ -31,7 +33,7 @@ */ public function execute() { $typeId = $this->_resourceType . '_id'; - $existing = Favourite::where($typeId, '=', $this->_resourceId)->first(); + $existing = Favourite::where($typeId, '=', $this->_resourceId)->whereUserId(Auth::user()->id)->first(); $isFavourited = false; if ($existing) { @@ -40,10 +42,30 @@ $fav = new Favourite(); $fav->$typeId = $this->_resourceId; $fav->user_id = Auth::user()->id; + $fav->created_at = time(); $fav->save(); $isFavourited = true; } + $resourceUser = ResourceUser::get(Auth::user()->id, $this->_resourceType, $this->_resourceId); + $resourceUser->is_favourited = $isFavourited; + $resourceUser->save(); + + $resourceTable = $this->_resourceType . 's'; + + // We do this to prevent a race condition. Sure I could simply increment the count columns and re-save back to the db + // but that would require an additional SELECT and the operation would be non-atomic. If two log items are created + // for the same resource at the same time, the cached values will still be correct with this method. + + DB::table($resourceTable)->whereId($this->_resourceId)->update(['favourite_count' => + DB::raw('( + SELECT + COUNT(id) + FROM + favourites + WHERE ' . + $typeId . ' = ' . $this->_resourceId . ')')]); + return CommandResponse::succeed(['is_favourited' => $isFavourited]); } } \ No newline at end of file diff --git a/app/models/Entities/Album.php b/app/models/Entities/Album.php index fc9072f7..bfb34d15 100644 --- a/app/models/Entities/Album.php +++ b/app/models/Entities/Album.php @@ -16,12 +16,12 @@ use SlugTrait; public static function summary() { - return self::select('id', 'title', 'user_id', 'slug', 'created_at', 'cover_id'); + return self::select('id', 'title', 'user_id', 'slug', 'created_at', 'cover_id', 'comment_count', 'download_count', 'view_count', 'favourite_count'); } public function scopeDetails($query) { if (Auth::check()) { - $query->with(['favourites' => function($query) { + $query->with(['users' => function($query) { $query->whereUserId(Auth::user()->id); }]); } @@ -35,6 +35,10 @@ return $this->belongsTo('Entities\User'); } + public function users() { + return $this->hasMany('Entities\ResourceUser'); + } + public function favourites() { return $this->hasMany('Entities\Favourite'); } @@ -76,21 +80,42 @@ $data['tracks'] = $tracks; $data['comments'] = ['count' => count($comments), 'list' => $comments]; $data['formats'] = $formats; - $data['stats'] = [ - 'views' => 0, - 'downloads' => 0 - ]; return $data; } public static function mapPublicAlbumSummary($album) { + $userData = [ + 'stats' => [ + 'views' => 0, + 'downloads' => 0 + ], + 'is_favourited' => false + ]; + + if ($album->users->count()) { + $userRow = $album->users[0]; + $userData = [ + 'stats' => [ + 'views' => $userRow->view_count, + 'downloads' => $userRow->download_count, + ], + 'is_favourited' => $userRow->is_favourited + ]; + } + return [ 'id' => $album->id, 'track_count' => $album->tracks->count(), 'title' => $album->title, 'slug' => $album->slug, 'created_at' => $album->created_at, + 'stats' => [ + 'views' => $album->view_count, + 'downloads' => $album->download_count, + 'comments' => $album->comment_count, + 'favourites' => $album->favourite_count + ], 'covers' => [ 'small' => $album->getCoverUrl(Image::SMALL), 'normal' => $album->getCoverUrl(Image::NORMAL) @@ -101,7 +126,7 @@ 'name' => $album->user->display_name, 'url' => $album->user->url, ], - 'is_favourited' => $album->favourites->count() > 0 + 'user_data' => $userData ]; } diff --git a/app/models/Entities/Favourite.php b/app/models/Entities/Favourite.php index 8d85cb50..8afdf064 100644 --- a/app/models/Entities/Favourite.php +++ b/app/models/Entities/Favourite.php @@ -4,6 +4,7 @@ class Favourite extends \Eloquent { protected $table = 'favourites'; + public $timestamps = false; /* |-------------------------------------------------------------------------- @@ -44,7 +45,7 @@ // no resource - this should never happen under real circumstances else - return NULL; + return null; } public function getTypeAttribute(){ diff --git a/app/models/Entities/ResourceLogItem.php b/app/models/Entities/ResourceLogItem.php new file mode 100644 index 00000000..9d111a07 --- /dev/null +++ b/app/models/Entities/ResourceLogItem.php @@ -0,0 +1,69 @@ +{$resourceIdColumn} = $resourceId; + $logItem->created_at = time(); + $logItem->log_type = $logType; + $logItem->track_format_id = $formatId; + $logItem->ip_address = Request::getClientIp(); + + if (Auth::check()) { + $logItem->user_id = Auth::user()->id; + } + + $logItem->save(); + + $resourceTable = $resourceType . 's'; + $countColumn = ''; + + if ($logType == self::VIEW) $countColumn = 'view_count'; + else if ($logType == self::DOWNLOAD) $countColumn = 'download_count'; + else if ($logType == self::PLAY) $countColumn = 'play_count'; + + // We do this to prevent a race condition. Sure I could simply increment the count columns and re-save back to the db + // but that would require an additional SELECT and the operation would be non-atomic. If two log items are created + // for the same resource at the same time, the cached values will still be correct with this method. + + DB::table($resourceTable)->whereId($resourceId)->update([$countColumn => + DB::raw('(SELECT + COUNT(id) + FROM + resource_log_items + WHERE ' . + $resourceIdColumn . ' = ' . $resourceId . ' + AND + log_type = ' . $logType . ')')]); + + if (Auth::check()) { + $resourceUserId = ResourceUser::getId(Auth::user()->id, $resourceType, $resourceId); + DB::table('resource_users')->whereId($resourceUserId)->update([$countColumn => + DB::raw('(SELECT + COUNT(id) + FROM + resource_log_items + WHERE + user_id = ' . Auth::user()->id . ' + AND ' . + $resourceIdColumn . ' = ' . $resourceId . ' + AND + log_type = ' . $logType . ')')]); + } + } + } \ No newline at end of file diff --git a/app/models/Entities/ResourceUser.php b/app/models/Entities/ResourceUser.php new file mode 100644 index 00000000..5cb09d59 --- /dev/null +++ b/app/models/Entities/ResourceUser.php @@ -0,0 +1,30 @@ +where('user_id', '=', $userId)->first(); + if ($existing) + return $existing; + + $item = new ResourceUser(); + $item->{$resourceIdColumn} = $resourceId; + $item->user_id = $userId; + return $item; + } + + public static function getId($userId, $resourceType, $resourceId) { + $item = self::get($userId, $resourceType, $resourceId); + if ($item->exists) + return $item->id; + + $item->save(); + return $item->id; + } + } \ No newline at end of file diff --git a/app/models/Entities/Track.php b/app/models/Entities/Track.php index 8b2c5e3e..21cad80c 100644 --- a/app/models/Entities/Track.php +++ b/app/models/Entities/Track.php @@ -20,20 +20,20 @@ use SlugTrait; public static $Formats = [ - 'FLAC' => ['extension' => 'flac', 'tag_format' => 'metaflac', 'tag_method' => 'updateTagsWithGetId3', 'mime_type' => 'audio/flac', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec flac -aq 8 -f flac {$target}'], - 'MP3' => ['extension' => 'mp3', 'tag_format' => 'id3v2.3', 'tag_method' => 'updateTagsWithGetId3', 'mime_type' => 'audio/mpeg', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec libmp3lame -ab 320k -f mp3 {$target}'], - 'OGG Vorbis' => ['extension' => 'ogg', 'tag_format' => 'vorbiscomment', 'tag_method' => 'updateTagsWithGetId3', 'mime_type' => 'audio/ogg', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec libvorbis -aq 7 -f ogg {$target}'], - 'AAC' => ['extension' => 'm4a', 'tag_format' => 'AtomicParsley', 'tag_method' => 'updateTagsWithAtomicParsley', 'mime_type' => 'audio/mp4', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec libfaac -ab 256k -f mp4 {$target}'], - 'ALAC' => ['extension' => 'alac.m4a', 'tag_format' => 'AtomicParsley', 'tag_method' => 'updateTagsWithAtomicParsley', 'mime_type' => 'audio/mp4', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec alac {$target}'], + 'FLAC' => ['index' => 0, 'extension' => 'flac', 'tag_format' => 'metaflac', 'tag_method' => 'updateTagsWithGetId3', 'mime_type' => 'audio/flac', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec flac -aq 8 -f flac {$target}'], + 'MP3' => ['index' => 1, 'extension' => 'mp3', 'tag_format' => 'id3v2.3', 'tag_method' => 'updateTagsWithGetId3', 'mime_type' => 'audio/mpeg', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec libmp3lame -ab 320k -f mp3 {$target}'], + 'OGG Vorbis' => ['index' => 2, 'extension' => 'ogg', 'tag_format' => 'vorbiscomment', 'tag_method' => 'updateTagsWithGetId3', 'mime_type' => 'audio/ogg', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec libvorbis -aq 7 -f ogg {$target}'], + 'AAC' => ['index' => 3, 'extension' => 'm4a', 'tag_format' => 'AtomicParsley', 'tag_method' => 'updateTagsWithAtomicParsley', 'mime_type' => 'audio/mp4', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec libfaac -ab 256k -f mp4 {$target}'], + 'ALAC' => ['index' => 4, 'extension' => 'alac.m4a', 'tag_format' => 'AtomicParsley', 'tag_method' => 'updateTagsWithAtomicParsley', 'mime_type' => 'audio/mp4', 'command' => 'ffmpeg 2>&1 -y -i {$source} -acodec alac {$target}'], ]; public static function summary() { - return self::select('id', 'title', 'user_id', 'slug', 'is_vocal', 'is_explicit', 'created_at', 'published_at', 'duration', 'is_downloadable', 'genre_id', 'track_type_id', 'cover_id', 'album_id'); + return self::select('id', 'title', 'user_id', 'slug', 'is_vocal', 'is_explicit', 'created_at', 'published_at', 'duration', 'is_downloadable', 'genre_id', 'track_type_id', 'cover_id', 'album_id', 'comment_count', 'download_count', 'view_count', 'play_count', 'favourite_count'); } public function scopeDetails($query) { if (Auth::check()) { - $query->with(['favourites' => function($query) { + $query->with(['users' => function($query) { $query->whereUserId(Auth::user()->id); }]); } @@ -49,11 +49,6 @@ $returnValue = self::mapPublicTrackSummary($track); $returnValue['description'] = $track->description; $returnValue['lyrics'] = $track->lyrics; - $returnValue['stats'] = [ - 'views' => 0, - 'plays' => 0, - 'downloads' => 0 - ]; $comments = []; @@ -61,7 +56,7 @@ $comments[] = Comment::mapPublic($comment); } - $returnValue['comments'] = ['count' => count($comments), 'list' => $comments]; + $returnValue['comments'] = $comments; if ($track->album_id != null) { $returnValue['album'] = [ @@ -87,6 +82,27 @@ } public static function mapPublicTrackSummary($track) { + $userData = [ + 'stats' => [ + 'views' => 0, + 'plays' => 0, + 'downloads' => 0 + ], + 'is_favourited' => false + ]; + + if ($track->users->count()) { + $userRow = $track->users[0]; + $userData = [ + 'stats' => [ + 'views' => $userRow->view_count, + 'plays' => $userRow->play_count, + 'downloads' => $userRow->download_count, + ], + 'is_favourited' => $userRow->is_favourited + ]; + } + return [ 'id' => $track->id, 'title' => $track->title, @@ -95,6 +111,13 @@ 'name' => $track->user->display_name, 'url' => $track->user->url ], + 'stats' => [ + 'views' => $track->view_count, + 'plays' => $track->play_count, + 'downloads' => $track->download_count, + 'comments' => $track->comment_count, + 'favourites' => $track->favourite_count + ], 'url' => $track->url, 'slug' => $track->slug, 'is_vocal' => $track->is_vocal, @@ -116,11 +139,10 @@ 'small' => $track->getCoverUrl(Image::SMALL), 'normal' => $track->getCoverUrl(Image::NORMAL) ], - 'is_favourited' => $track->favourites->count() > 0, - 'duration' => $track->duration, 'streams' => [ 'mp3' => $track->getStreamUrl('MP3') - ] + ], + 'user_data' => $userData ]; } @@ -184,6 +206,10 @@ return $this->belongsToMany('Entities\ShowSong'); } + public function users() { + return $this->hasMany('Entities\ResourceUser'); + } + public function user() { return $this->belongsTo('Entities\User'); } @@ -276,6 +302,14 @@ return "{$this->id}.{$format['extension']}"; } + public function getDownloadFilenameFor($format) { + if (!isset(self::$Formats[$format])) + throw new Exception("$format is not a valid format!"); + + $format = self::$Formats[$format]; + return "{$this->title}.{$format['extension']}"; + } + public function getFileFor($format) { if (!isset(self::$Formats[$format])) throw new Exception("$format is not a valid format!"); diff --git a/app/models/Entities/User.php b/app/models/Entities/User.php index ddf09440..47570850 100644 --- a/app/models/Entities/User.php +++ b/app/models/Entities/User.php @@ -45,7 +45,7 @@ public function getAvatarUrl($type = Image::NORMAL) { if (!$this->uses_gravatar) - return $this->avatar->getUrl(); + return $this->avatar->getUrl($type); $email = $this->gravatar; if (!strlen($email)) diff --git a/app/models/PlaylistDownloader.php b/app/models/PlaylistDownloader.php new file mode 100644 index 00000000..6ea860f9 --- /dev/null +++ b/app/models/PlaylistDownloader.php @@ -0,0 +1,48 @@ +_playlist = $playlist; + $this->_format = $format; + } + + function download() { + $zip = new ZipStream($this->_playlist->user->display_name . ' - ' . $this->_playlist->title . '.zip'); + $zip->setComment( + 'Album: ' . $this->_playlist->title ."\r\n". + 'Artist: ' . $this->_playlist->user->display_name ."\r\n". + 'URL: ' . $this->_playlist->url ."\r\n"."\r\n". + 'Downloaded on '. date('l, F jS, Y, \a\t h:i:s A') . '.' + ); + + $directory = $this->_playlist->user->display_name . '/' . $this->_playlist->title . '/'; + + $notes = + 'Album: ' . $this->_playlist->title ."\r\n". + 'Artist: ' . $this->_playlist->user->display_name ."\r\n". + 'URL: ' . $this->_playlist->url ."\r\n". + "\r\n". + $this->_playlist->description ."\r\n". + "\r\n". + "\r\n". + 'Tracks' ."\r\n". + "\r\n"; + + foreach ($this->_playlist->tracks as $track) { + if (!$track->is_downloadable) + continue; + + $zip->addLargeFile($track->getFileFor($this->_format), $directory . $track->getDownloadFilenameFor($this->_format)); + $notes .= + $track->track_number . '. ' . $track->title ."\r\n". + $track->description ."\r\n". + "\r\n"; + } + + $zip->addFile($notes, $directory . 'Album Notes.txt'); + $zip->finalize(); + } + } \ No newline at end of file diff --git a/app/routes.php b/app/routes.php index 69c9e525..774a46cf 100644 --- a/app/routes.php +++ b/app/routes.php @@ -24,6 +24,7 @@ Route::get('albums', 'AlbumsController@getIndex'); Route::get('albums/{id}-{slug}', 'AlbumsController@getShow'); Route::get('a{id}', 'AlbumsController@getShortlink')->where('id', '\d+'); + Route::get('a{id}/dl.{extension}', 'AlbumsController@getDownload' ); Route::get('artists', 'ArtistsController@getIndex'); Route::get('playlists', 'PlaylistsController@getIndex'); diff --git a/app/start/artisan.php b/app/start/artisan.php index 58867910..e475bac7 100644 --- a/app/start/artisan.php +++ b/app/start/artisan.php @@ -11,4 +11,5 @@ | */ - Artisan::add(new MigrateOldData); \ No newline at end of file + Artisan::add(new MigrateOldData); + Artisan::add(new RefreshCache); \ No newline at end of file diff --git a/public/scripts/app/directives/comments.coffee b/public/scripts/app/directives/comments.coffee index 691b9013..01934b4e 100644 --- a/public/scripts/app/directives/comments.coffee +++ b/public/scripts/app/directives/comments.coffee @@ -15,9 +15,8 @@ angular.module('ponyfm').directive 'pfmComments', () -> refresh = () -> comments.fetchList($scope.type, $scope.resource.id, true).done (comments) -> - $scope.resource.comments.count = comments.count - $scope.resource.comments.list.length = 0 - $scope.resource.comments.list.push comment for comment in comments.list + $scope.resource.comments.length = 0 + $scope.resource.comments.push comment for comment in comments.list $scope.isWorking = false $scope.addComment = () -> diff --git a/public/scripts/app/directives/favouriteButton.coffee b/public/scripts/app/directives/favouriteButton.coffee index 2956ceea..889b8f76 100644 --- a/public/scripts/app/directives/favouriteButton.coffee +++ b/public/scripts/app/directives/favouriteButton.coffee @@ -16,5 +16,5 @@ angular.module('ponyfm').directive 'pfmFavouriteButton', () -> $scope.isWorking = true favourites.toggle($scope.type, $scope.resource.id).done (res) -> $scope.isWorking = false - $scope.resource.is_favourited = res.is_favourited + $scope.resource.user_data.is_favourited = res.is_favourited ] \ No newline at end of file diff --git a/public/scripts/app/directives/tracks-list.coffee b/public/scripts/app/directives/tracks-list.coffee index f132708d..cc1f2a65 100644 --- a/public/scripts/app/directives/tracks-list.coffee +++ b/public/scripts/app/directives/tracks-list.coffee @@ -12,7 +12,7 @@ angular.module('ponyfm').directive 'pfmTracksList', () -> $scope.toggleFavourite = (track) -> favourites.toggle('track', track.id).done (res) -> - track.is_favourited = res.is_favourited + track.user_data.is_favourited = res.is_favourited $scope.play = (track) -> index = _.indexOf $scope.tracks, (t) -> t.id == track.id diff --git a/public/scripts/app/filters/trust.coffee b/public/scripts/app/filters/trust.coffee new file mode 100644 index 00000000..76342839 --- /dev/null +++ b/public/scripts/app/filters/trust.coffee @@ -0,0 +1,6 @@ +angular.module('ponyfm').filter 'trust', [ + '$sce' + ($sce) -> + (input) -> + $sce.trustAsHtml input +] \ No newline at end of file diff --git a/public/scripts/app/services/albums.coffee b/public/scripts/app/services/albums.coffee index 49b4d26a..33ed63ec 100644 --- a/public/scripts/app/services/albums.coffee +++ b/public/scripts/app/services/albums.coffee @@ -23,7 +23,7 @@ angular.module('ponyfm').factory('albums', [ id = 1 if !id return albums[id] if !force && albums[id] albumsDef = new $.Deferred() - $http.get('/api/web/albums/' + id).success (albums) -> + $http.get('/api/web/albums/' + id + '?log=true').success (albums) -> albumsDef.resolve albums albums[id] = albumsDef.promise() diff --git a/public/scripts/app/services/tracks.coffee b/public/scripts/app/services/tracks.coffee index b6493c7f..3c94cbd5 100644 --- a/public/scripts/app/services/tracks.coffee +++ b/public/scripts/app/services/tracks.coffee @@ -146,7 +146,7 @@ angular.module('ponyfm').factory('tracks', [ force = force || false return trackCache[id] if !force && trackCache[id] trackDef = new $.Deferred() - $http.get('/api/web/tracks/' + id).success (track) -> + $http.get('/api/web/tracks/' + id + '?log=true').success (track) -> trackDef.resolve track trackCache[id] = trackDef.promise() @@ -168,8 +168,10 @@ angular.module('ponyfm').factory('tracks', [ self.filters.sort = type: 'single' values: [ - {title: 'Newest to Oldest', query: '', isDefault: true, filter: 'order=created_at,desc'}, - {title: 'Oldest to Newest', query: 'created_at,asc', isDefault: false, filter: 'order=created_at,asc'} + {title: 'Latest', query: '', isDefault: true, filter: 'order=created_at,desc'}, + {title: 'Most Played', query: 'play_count', isDefault: false, filter: 'order=play_count,desc'}, + {title: 'Most Downloaded', query: 'download_count', isDefault: false, filter: 'order=download_count,desc'}, + {title: 'Most Favourited', query: 'favourite_count', isDefault: false, filter: 'order=favourite_count,desc'} ] self.filters.genres = diff --git a/public/styles/components.less b/public/styles/components.less index 50d0f8f8..f1378786 100644 --- a/public/styles/components.less +++ b/public/styles/components.less @@ -184,6 +184,10 @@ html body { .border-radius(0px); } + .btn.btn-primary { + background: @pfm-purple; + } + .ui-datepicker { .border-radius(0px); @@ -309,6 +313,16 @@ html .dropdown-menu { } html { + .modal-backdrop { + background: #fff; + } + + .modal { + .border-radius(0px); + + border: 2px solid @pfm-purple; + } + .breadcrumb { .border-radius(0px); background: #eee; diff --git a/public/styles/tracks.less b/public/styles/tracks.less index d01714d2..08e2006e 100644 --- a/public/styles/tracks.less +++ b/public/styles/tracks.less @@ -85,8 +85,8 @@ } line-height: normal; - padding: 5px 0px; - margin: 0px; + padding: 0px; + margin: 5px 0px; padding-right: 10px; position: relative; @@ -160,6 +160,10 @@ background: #ddd; } + &.has-played { + background: red; + } + &.is-favourited { .icons { a.icon-favourite { diff --git a/public/templates/albums/show.html b/public/templates/albums/show.html index 258c1f7f..a5886254 100644 --- a/public/templates/albums/show.html +++ b/public/templates/albums/show.html @@ -11,7 +11,7 @@ Downloads @@ -49,6 +49,7 @@ diff --git a/public/templates/directives/comments.html b/public/templates/directives/comments.html index 05a2f102..f6b6dd28 100644 --- a/public/templates/directives/comments.html +++ b/public/templates/directives/comments.html @@ -2,8 +2,8 @@
There are no comments yet!
-