From 0665286bd5aff12c953048fce2bf0b336cbcb4c6 Mon Sep 17 00:00:00 2001 From: devfake Date: Mon, 23 Dec 2019 15:47:32 +0100 Subject: [PATCH 01/14] create api settings page --- .../Http/Controllers/SettingController.php | 27 ++++++++ backend/app/User.php | 9 +++ ...2019_12_23_122213_add_api_key_to_users.php | 22 ++++++ backend/routes/web.php | 8 ++- .../app/components/Content/Settings/Api.vue | 68 +++++++++++++++++++ .../app/components/Content/Settings/Index.vue | 7 +- 6 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 backend/database/migrations/2019_12_23_122213_add_api_key_to_users.php create mode 100644 client/app/components/Content/Settings/Api.vue diff --git a/backend/app/Http/Controllers/SettingController.php b/backend/app/Http/Controllers/SettingController.php index 761878f..8be7358 100644 --- a/backend/app/Http/Controllers/SettingController.php +++ b/backend/app/Http/Controllers/SettingController.php @@ -10,6 +10,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Request; + use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; class SettingController { @@ -68,6 +69,32 @@ ]; } + /** + * @return string + */ + public function generateApiKey() + { + if (isDemo()) { + return response('Success', Response::HTTP_OK); + } + + $key = Str::random(24); + + Auth::user()->update([ + 'api_key' => $key, + ]); + + return $key; + } + + /** + * @return string + */ + public function getApiKey() + { + return Auth::user()->api_key; + } + /** * @return array */ diff --git a/backend/app/User.php b/backend/app/User.php index 2905b59..88e2a39 100644 --- a/backend/app/User.php +++ b/backend/app/User.php @@ -14,6 +14,7 @@ protected $fillable = [ 'username', 'password', + 'api_key', ]; /** @@ -25,4 +26,12 @@ 'password', 'remember_token', ]; + + /** + * Scope to find a user by an api key. + */ + public function scopeFindByApiKey($query, $key) + { + return $query->where('api_key', $key); + } } diff --git a/backend/database/migrations/2019_12_23_122213_add_api_key_to_users.php b/backend/database/migrations/2019_12_23_122213_add_api_key_to_users.php new file mode 100644 index 0000000..2ae0d2c --- /dev/null +++ b/backend/database/migrations/2019_12_23_122213_add_api_key_to_users.php @@ -0,0 +1,22 @@ +string('api_key')->nullable()->index(); + }); + } + + public function down(){} +} diff --git a/backend/routes/web.php b/backend/routes/web.php index cfd2682..3f13d79 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -7,7 +7,7 @@ Route::get('/episodes/{tmdbId}', 'ItemController@episodes'); Route::get('/items/{type}/{orderBy}/{sortDirection}', 'ItemController@items'); Route::get('/search-items', 'ItemController@search'); - + Route::get('/calendar', 'CalendarController@items'); Route::get('/item/{tmdbId}/{mediaType}', 'SubpageController@item'); @@ -23,10 +23,14 @@ Route::patch('/refresh-all', 'ItemController@refreshAll'); Route::get('/settings', 'SettingController@settings'); +// Route::middleware('api') + Route::middleware('auth')->group(function() { Route::get('/check-update', 'SettingController@checkForUpdate'); Route::get('/version', 'SettingController@getVersion'); + Route::get('/api-key', 'SettingController@getApiKey'); Route::patch('/settings/refresh', 'SettingController@updateRefresh'); + Route::patch('/settings/api-key', 'SettingController@generateApiKey'); Route::patch('/settings/reminders-send-to', 'SettingController@updateRemindersSendTo'); Route::patch('/settings/reminder-options', 'SettingController@updateReminderOptions'); Route::patch('/settings', 'SettingController@updateSettings'); @@ -51,5 +55,5 @@ Route::get('/video/{type}/{id}', 'VideoController@serve'); }); }); - + Route::fallback('HomeController@app'); diff --git a/client/app/components/Content/Settings/Api.vue b/client/app/components/Content/Settings/Api.vue new file mode 100644 index 0000000..b0dacad --- /dev/null +++ b/client/app/components/Content/Settings/Api.vue @@ -0,0 +1,68 @@ + + + diff --git a/client/app/components/Content/Settings/Index.vue b/client/app/components/Content/Settings/Index.vue index f0ac6b0..eafa807 100644 --- a/client/app/components/Content/Settings/Index.vue +++ b/client/app/components/Content/Settings/Index.vue @@ -9,6 +9,7 @@ {{ lang('tab backup') }} {{ lang('refresh') }} {{ lang('reminders') }} + API @@ -19,6 +20,7 @@ + @@ -31,10 +33,11 @@ import Misc from './Misc.vue'; import Refresh from './Refresh.vue'; import Reminders from './Reminders.vue'; + import Api from './Api.vue'; import { mapState, mapActions } from 'vuex'; import MiscHelper from '../../../helpers/misc'; - + export default { mixins: [MiscHelper], @@ -43,7 +46,7 @@ }, components: { - User, Options, Backup, Misc, Refresh, Reminders + User, Options, Backup, Misc, Refresh, Reminders, Api }, data() { From 5b22bb1abe1cfeb67909c2e16766f3aebd20c2e5 Mon Sep 17 00:00:00 2001 From: devfake Date: Mon, 23 Dec 2019 21:46:43 +0100 Subject: [PATCH 02/14] implement plex api --- backend/app/Episode.php | 18 +- .../app/Http/Controllers/ApiController.php | 25 +++ backend/app/Http/Kernel.php | 2 + backend/app/Http/Middleware/VerifyApiKey.php | 41 +++++ backend/app/Item.php | 15 +- backend/app/Services/Api/Api.php | 157 ++++++++++++++++++ backend/app/Services/Api/Plex.php | 106 ++++++++++++ backend/app/Services/TMDB.php | 4 +- backend/routes/web.php | 4 +- 9 files changed, 361 insertions(+), 11 deletions(-) create mode 100644 backend/app/Http/Controllers/ApiController.php create mode 100644 backend/app/Http/Middleware/VerifyApiKey.php create mode 100644 backend/app/Services/Api/Api.php create mode 100644 backend/app/Services/Api/Plex.php diff --git a/backend/app/Episode.php b/backend/app/Episode.php index e47403a..36dd564 100644 --- a/backend/app/Episode.php +++ b/backend/app/Episode.php @@ -6,7 +6,7 @@ use Illuminate\Database\Eloquent\Model; class Episode extends Model { - + /** * The accessors to append to the model's array form. * @@ -73,6 +73,22 @@ return $query->where('tmdb_id', $tmdbId); } + /** + * Scope to find the result via episode_number. + */ + public function scopeFindByEpisodeNumber($query, $number) + { + return $query->where('episode_number', $number); + } + + /** + * Scope to find the result via season_number. + */ + public function scopeFindBySeasonNumber($query, $number) + { + return $query->where('season_number', $number); + } + /** * Scope to find the result via src. */ diff --git a/backend/app/Http/Controllers/ApiController.php b/backend/app/Http/Controllers/ApiController.php new file mode 100644 index 0000000..6b5ff40 --- /dev/null +++ b/backend/app/Http/Controllers/ApiController.php @@ -0,0 +1,25 @@ +plex = $plex; + } + + public function plex() + { + $payload = json_decode(request('payload'), true); + + $this->plex->handle($payload); + } + } diff --git a/backend/app/Http/Kernel.php b/backend/app/Http/Kernel.php index ae28616..9a2877f 100644 --- a/backend/app/Http/Kernel.php +++ b/backend/app/Http/Kernel.php @@ -2,6 +2,7 @@ namespace App\Http; +use App\Http\Middleware\VerifyApiKey; use Illuminate\Foundation\Http\Kernel as HttpKernel; class Kernel extends HttpKernel @@ -53,5 +54,6 @@ class Kernel extends HttpKernel 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'csrf' => \App\Http\Middleware\VerifyCsrfToken::class, + 'api_key' => VerifyApiKey::class, ]; } diff --git a/backend/app/Http/Middleware/VerifyApiKey.php b/backend/app/Http/Middleware/VerifyApiKey.php new file mode 100644 index 0000000..c261e04 --- /dev/null +++ b/backend/app/Http/Middleware/VerifyApiKey.php @@ -0,0 +1,41 @@ +user = $user; + } + + /** + * Handle an incoming request. + * + * @param \Illuminate\Http\Request $request + * @param \Closure $next + * @return mixed + */ + public function handle($request, Closure $next) + { + if (!$request->token) { + return response(['message' => 'No token provided'], Response::HTTP_UNAUTHORIZED); + } + + if (!$this->user->findByApiKey($request->token)->exists()) { + return response(['message' => 'No valid token provided'], Response::HTTP_UNAUTHORIZED); + } + + return $next($request); + } +} diff --git a/backend/app/Item.php b/backend/app/Item.php index eb5fb29..da500f1 100644 --- a/backend/app/Item.php +++ b/backend/app/Item.php @@ -11,14 +11,14 @@ * Fallback date string for a item. */ const FALLBACK_DATE = '1970-12-1'; - + /** * The attributes that should be mutated to dates. * * @var array */ protected $dates = [ - 'last_seen_at', + 'last_seen_at', 'refreshed_at', 'created_at', 'updated_at', @@ -32,7 +32,7 @@ protected $appends = [ 'startDate', ]; - + /** * The relations to eager load on every query. * @@ -55,8 +55,9 @@ */ public function store($data) { - return $this->create([ + return $this->firstOrCreate([ 'tmdb_id' => $data['tmdb_id'], + ], [ 'title' => $data['title'], 'media_type' => $data['media_type'], 'original_title' => $data['original_title'], @@ -77,7 +78,7 @@ /** * Create a new empty movie / tv show (for FP). - * + * * @param $data * @param $mediaType * @return Item @@ -118,7 +119,7 @@ return Carbon::createFromTimestamp($this->released)->format('Y-m-d'); } } - + /** * Belongs to many genres. */ @@ -144,7 +145,7 @@ } /** - * The latest unseen episode. + * The latest unseen episode. */ public function latestEpisode() { diff --git a/backend/app/Services/Api/Api.php b/backend/app/Services/Api/Api.php new file mode 100644 index 0000000..96d8c94 --- /dev/null +++ b/backend/app/Services/Api/Api.php @@ -0,0 +1,157 @@ +item = $item; + $this->tmdb = $tmdb; + $this->itemService = $itemService; + $this->episode = $episode; + } + + public function handle(array $data) + { + logInfo('api data:', $data); + + $this->data = $data; + + if ($this->abortRequest()) { + abort(Response::HTTP_NOT_IMPLEMENTED); + } + + $found = $this->item->findByTitle($this->getTitle(), $this->getType())->first(); + + // Nothing found in our database, so we search in TMDb. + if (!$found) { + $foundFromTmdb = $this->tmdb->search($this->getTitle(), $this->getType()); + + if (!$foundFromTmdb) { + return false; + } + + // The first result is mostly the one we need. + $firstResult = $foundFromTmdb[0]; + + // Search again in our database with the TMDb ID. + $found = $this->item->findByTmdbId($firstResult['tmdb_id'])->first(); + + if (!$found) { + $found = $this->itemService->create($firstResult); + } + } + + if ($this->shouldRateItem()) { + $found->update([ + 'rating' => $this->getRating(), + ]); + } + + if ($this->shouldEpisodeMarkedAsSeen()) { + $episode = $this->episode + ->findByTmdbId($found->tmdb_id) + ->findByEpisodeNumber($this->getEpisodeNumber()) + ->findBySeasonNumber($this->getSeasonNumber()) + ->first(); + + if ($episode) { + $episode->update([ + 'seen' => true, + ]); + } + } + } + + /** + * Abort the complete request if it's not a movie or episode. + * + * @return bool + */ + abstract protected function abortRequest(); + + /** + * Is it a movie or tv show? Should return 'tv' or 'movie'. + * + * @return string + */ + abstract protected function getType(); + + /** + * Title for the item (name of the movie or tv show). + * + * @return string + */ + abstract protected function getTitle(); + + /** + * Rating for flox in a 3-Point system. + * + * 1 = Good. + * 2 = Medium. + * 3 = Bad. + * + * @return int + */ + abstract protected function getRating(); + + /** + * Check if rating is requested. + * + * @return bool + */ + abstract protected function shouldRateItem(); + + /** + * Check if seen episode is requested. + * + * @return bool + */ + abstract protected function shouldEpisodeMarkedAsSeen(); + + /** + * Number of the episode. + * + * @return null|int + */ + abstract protected function getEpisodeNumber(); + + /** + * Number of the season. + * + * @return null|int + */ + abstract protected function getSeasonNumber(); +} diff --git a/backend/app/Services/Api/Plex.php b/backend/app/Services/Api/Plex.php new file mode 100644 index 0000000..bc1c5cc --- /dev/null +++ b/backend/app/Services/Api/Plex.php @@ -0,0 +1,106 @@ +data['Metadata']['type'], ['episode', 'show', 'movie']); + } + + /** + * Is it a movie or tv show? Should return 'tv' or 'movie'. + * + * @return string + */ + protected function getType() + { + $type = $this->data['Metadata']['type']; + + return in_array($type, ['episode', 'show']) ? 'tv' : 'movie'; + } + + /** + * Title for the item (name of the movie or tv show). + * + * @return string + */ + protected function getTitle() + { + return $this->data['Metadata']['grandparentTitle'] ?? $this->data['Metadata']['title']; + } + + /** + * Check if rating is requested. + * + * @return bool + */ + protected function shouldRateItem() + { + $type = $this->data['Metadata']['type']; + + // Flox has no ratings for seasons or episodes. + return in_array($type, ['show', 'movie']) && $this->data['event'] === 'media.rate'; + } + + /** + * Rating for flox in a 3-Point system. + * + * 1 = Good. + * 2 = Medium. + * 3 = Bad. + * + * @return int + */ + protected function getRating() + { + $rating = +$this->data['rating']; + + if ($rating > 7) { + return 1; + } + + if ($rating > 4) { + return 2; + } + + return 3; + } + + /** + * Check if seen episode is requested. + * + * @return bool + */ + protected function shouldEpisodeMarkedAsSeen() + { + return $this->data['event'] === 'media.scrobble' && $this->getType() === 'tv'; + } + + /** + * Number of the episode. + * + * @return null|int + */ + protected function getEpisodeNumber() + { + return $this->data['Metadata']['index'] ?? null; + } + + /** + * Number of the season. + * + * @return null|int + */ + protected function getSeasonNumber() + { + return $this->data['Metadata']['parentIndex'] ?? null; + } +} diff --git a/backend/app/Services/TMDB.php b/backend/app/Services/TMDB.php index f505fac..67c6b12 100644 --- a/backend/app/Services/TMDB.php +++ b/backend/app/Services/TMDB.php @@ -36,7 +36,7 @@ * * @param $title * @param null $mediaType - * @return Collection + * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response|Collection */ public function search($title, $mediaType = null) { @@ -52,7 +52,7 @@ $tv = collect($this->createItems($response, 'tv')); } - if( ! $mediaType || $mediaType == 'movies') { + if( ! $mediaType || $mediaType == 'movies' || $mediaType == 'movie') { $response = $this->fetchSearch($title, 'movie'); $movies = collect($this->createItems($response, 'movie')); } diff --git a/backend/routes/web.php b/backend/routes/web.php index 3f13d79..0191fc6 100644 --- a/backend/routes/web.php +++ b/backend/routes/web.php @@ -23,7 +23,9 @@ Route::patch('/refresh-all', 'ItemController@refreshAll'); Route::get('/settings', 'SettingController@settings'); -// Route::middleware('api') + Route::middleware('api_key')->group(function() { + Route::post('plex', 'ApiController@plex'); + }); Route::middleware('auth')->group(function() { Route::get('/check-update', 'SettingController@checkForUpdate'); From 84212f5444045186ace9f9c18aeb25d05ba862f9 Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 10:55:05 +0100 Subject: [PATCH 03/14] add timestmap for release date --- backend/app/Item.php | 2 ++ backend/app/Services/TMDB.php | 3 +- backend/database/factories/ModelFactory.php | 3 +- ...104600_add_released_timestamp_to_items.php | 30 +++++++++++++++++++ 4 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 backend/database/migrations/2019_12_24_104600_add_released_timestamp_to_items.php diff --git a/backend/app/Item.php b/backend/app/Item.php index da500f1..4fc9465 100644 --- a/backend/app/Item.php +++ b/backend/app/Item.php @@ -64,6 +64,7 @@ 'poster' => $data['poster'] ?? '', 'rating' => 0, 'released' => $data['released'], + 'released_timestamp' => Carbon::parse($data['released']), 'overview' => $data['overview'], 'backdrop' => $data['backdrop'], 'tmdb_rating' => $data['tmdb_rating'], @@ -93,6 +94,7 @@ 'poster' => '', 'rating' => 0, 'released' => time(), + 'released_timestamp' => now(), 'src' => $data['src'], 'subtitles' => $data['subtitles'], 'last_seen_at' => now(), diff --git a/backend/app/Services/TMDB.php b/backend/app/Services/TMDB.php index 67c6b12..8922e76 100644 --- a/backend/app/Services/TMDB.php +++ b/backend/app/Services/TMDB.php @@ -272,7 +272,8 @@ 'original_title' => $data->original_name ?? $data->original_title, 'poster' => $data->poster_path, 'media_type' => $mediaType, - 'released' => $release->getTimestamp(), + 'released' => $release->copy()->getTimestamp(), + 'released_timestamp' => $release, 'genre_ids' => $data->genre_ids, 'genre' => Genre::whereIn('id', $data->genre_ids)->get(), 'episodes' => [], diff --git a/backend/database/factories/ModelFactory.php b/backend/database/factories/ModelFactory.php index 4922a04..1770511 100644 --- a/backend/database/factories/ModelFactory.php +++ b/backend/database/factories/ModelFactory.php @@ -27,7 +27,8 @@ 'rating' => 1, //'genre' => '', 'released' => time(), - 'last_seen_at' => Carbon::now(), + 'released_timestamp' => now(), + 'last_seen_at' => now(), 'src' => null, ]; }); diff --git a/backend/database/migrations/2019_12_24_104600_add_released_timestamp_to_items.php b/backend/database/migrations/2019_12_24_104600_add_released_timestamp_to_items.php new file mode 100644 index 0000000..e2cd483 --- /dev/null +++ b/backend/database/migrations/2019_12_24_104600_add_released_timestamp_to_items.php @@ -0,0 +1,30 @@ +timestamp('released_timestamp')->nullable(); + }); + + Item::query()->each(function (Item $item) { + $item->update([ + 'released_timestamp' => Carbon::parse($item->released), + ]); + }); + } + + public function down(){} +} From 2fa545cb2ba8ee19908ebe3747d875db639c07ab Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 11:25:56 +0100 Subject: [PATCH 04/14] middleware test --- backend/app/Item.php | 8 ++++++ backend/app/Services/Api/Api.php | 5 +++- backend/tests/Services/Api/ApiTest.php | 35 ++++++++++++++++++++++++++ backend/tests/Setting/SettingTest.php | 18 +++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 backend/tests/Services/Api/ApiTest.php diff --git a/backend/app/Item.php b/backend/app/Item.php index 4fc9465..138f480 100644 --- a/backend/app/Item.php +++ b/backend/app/Item.php @@ -184,6 +184,14 @@ return $query->where('tmdb_id', $tmdbId); } + /** + * Scope to find the result by year. + */ + public function scopeFindByYear($query, $year) + { + return $query->whereYear('released_timestamp', $year); + } + /** * Scope to find the result via fp_name. */ diff --git a/backend/app/Services/Api/Api.php b/backend/app/Services/Api/Api.php index 96d8c94..be5fc6a 100644 --- a/backend/app/Services/Api/Api.php +++ b/backend/app/Services/Api/Api.php @@ -10,6 +10,7 @@ use Symfony\Component\HttpFoundation\Response; abstract class Api { + /** * @var array */ @@ -53,7 +54,9 @@ abstract class Api abort(Response::HTTP_NOT_IMPLEMENTED); } - $found = $this->item->findByTitle($this->getTitle(), $this->getType())->first(); + $found = $this->item + ->findByTitle($this->getTitle(), $this->getType()) + ->first(); // Nothing found in our database, so we search in TMDb. if (!$found) { diff --git a/backend/tests/Services/Api/ApiTest.php b/backend/tests/Services/Api/ApiTest.php new file mode 100644 index 0000000..1ccd038 --- /dev/null +++ b/backend/tests/Services/Api/ApiTest.php @@ -0,0 +1,35 @@ +postJson('api/plex'); + + $response->assertJson(['message' => 'No token provided']); + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } + + /** @test */ + public function valid_token_needs_to_be_provided() + { + $response = $this->postJson('api/plex', ['token' => 'not-valid']); + + $response->assertJson(['message' => 'No valid token provided']); + $response->assertStatus(Response::HTTP_UNAUTHORIZED); + } +} diff --git a/backend/tests/Setting/SettingTest.php b/backend/tests/Setting/SettingTest.php index 651b254..0948f57 100644 --- a/backend/tests/Setting/SettingTest.php +++ b/backend/tests/Setting/SettingTest.php @@ -95,4 +95,22 @@ $this->assertEquals(1, $newSettings->daily_reminder); $this->assertEquals(1, $newSettings->weekly_reminder); } + + /** @test */ + public function user_can_generate_a_new_api_key() + { + $apiKeyBefore = $this->user->api_key; + + $this->actingAs($this->user)->patchJson('api/settings/api-key'); + + $apiKeyAfter = $this->user->api_key; + + $this->actingAs($this->user)->patchJson('api/settings/api-key'); + + $apiKeyAfterSecond = $this->user->api_key; + + $this->assertNull($apiKeyBefore); + $this->assertNotNull($apiKeyAfter); + $this->assertNotEquals($apiKeyAfterSecond, $apiKeyAfter); + } } From 778c95e68773c82594bb5fd053d8d0e89aed2964 Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 13:39:43 +0100 Subject: [PATCH 05/14] tweak responsive for items --- client/resources/sass/components/_content.scss | 9 +++++++-- client/resources/sass/components/_lists.scss | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/client/resources/sass/components/_content.scss b/client/resources/sass/components/_content.scss index 4c1f353..fbfd66a 100644 --- a/client/resources/sass/components/_content.scss +++ b/client/resources/sass/components/_content.scss @@ -70,8 +70,13 @@ main { //@include media(3) { lost-column: 1/5; } @include media(3) { lost-column: 1/3; } //@include media(4) { lost-column: 1/4; } - @include media(5) { lost-column: 1/2; } - @include media(6) { lost-column: 1; } + @include media(5) { + lost-column: 1/2; + display: flex; + align-items: center; + flex-direction: column; + } + @include media(6) { } } .show-ratings-never { diff --git a/client/resources/sass/components/_lists.scss b/client/resources/sass/components/_lists.scss index ce7dcc9..c0fb96c 100644 --- a/client/resources/sass/components/_lists.scss +++ b/client/resources/sass/components/_lists.scss @@ -18,7 +18,7 @@ &:hover { //box-shadow: 0 0 2px 2px $main2; } - + &:active { //box-shadow: 0 0 2px 2px $main1; } From f17e7145318945a5f1e9a90174898cfffff9e60b Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 14:41:05 +0100 Subject: [PATCH 06/14] fake api test --- backend/app/Services/Api/Plex.php | 36 +--- backend/database/factories/ModelFactory.php | 1 + backend/tests/Services/Api/ApiTest.php | 176 +++++++++++++++++- backend/tests/Traits/Factories.php | 4 +- backend/tests/Traits/Fixtures.php | 7 +- backend/tests/fixtures/FakeApi.php | 73 ++++++++ backend/tests/fixtures/api/fake/abort.json | 5 + .../tests/fixtures/api/fake/episode_seen.json | 12 ++ backend/tests/fixtures/api/fake/movie.json | 12 ++ .../tests/fixtures/api/fake/movie_rating.json | 12 ++ backend/tests/fixtures/api/fake/tv.json | 12 ++ .../tests/fixtures/api/fake/tv_rating.json | 12 ++ 12 files changed, 328 insertions(+), 34 deletions(-) create mode 100644 backend/tests/fixtures/FakeApi.php create mode 100644 backend/tests/fixtures/api/fake/abort.json create mode 100644 backend/tests/fixtures/api/fake/episode_seen.json create mode 100644 backend/tests/fixtures/api/fake/movie.json create mode 100644 backend/tests/fixtures/api/fake/movie_rating.json create mode 100644 backend/tests/fixtures/api/fake/tv.json create mode 100644 backend/tests/fixtures/api/fake/tv_rating.json diff --git a/backend/app/Services/Api/Plex.php b/backend/app/Services/Api/Plex.php index bc1c5cc..5c6c3cd 100644 --- a/backend/app/Services/Api/Plex.php +++ b/backend/app/Services/Api/Plex.php @@ -6,9 +6,7 @@ class Plex extends Api { /** - * Abort the complete request if it's not a movie or episode. - * - * @return bool + * @inheritDoc */ protected function abortRequest() { @@ -16,9 +14,7 @@ class Plex extends Api } /** - * Is it a movie or tv show? Should return 'tv' or 'movie'. - * - * @return string + * @inheritDoc */ protected function getType() { @@ -28,9 +24,7 @@ class Plex extends Api } /** - * Title for the item (name of the movie or tv show). - * - * @return string + * @inheritDoc */ protected function getTitle() { @@ -38,9 +32,7 @@ class Plex extends Api } /** - * Check if rating is requested. - * - * @return bool + * @inheritDoc */ protected function shouldRateItem() { @@ -51,13 +43,7 @@ class Plex extends Api } /** - * Rating for flox in a 3-Point system. - * - * 1 = Good. - * 2 = Medium. - * 3 = Bad. - * - * @return int + * @inheritDoc */ protected function getRating() { @@ -75,9 +61,7 @@ class Plex extends Api } /** - * Check if seen episode is requested. - * - * @return bool + * @inheritDoc */ protected function shouldEpisodeMarkedAsSeen() { @@ -85,9 +69,7 @@ class Plex extends Api } /** - * Number of the episode. - * - * @return null|int + * @inheritDoc */ protected function getEpisodeNumber() { @@ -95,9 +77,7 @@ class Plex extends Api } /** - * Number of the season. - * - * @return null|int + * @inheritDoc */ protected function getSeasonNumber() { diff --git a/backend/database/factories/ModelFactory.php b/backend/database/factories/ModelFactory.php index 1770511..ce81d04 100644 --- a/backend/database/factories/ModelFactory.php +++ b/backend/database/factories/ModelFactory.php @@ -9,6 +9,7 @@ 'username' => $faker->name, 'password' => $password ?: $password = bcrypt('secret'), 'remember_token' => Illuminate\Support\Str::random(10), + 'api_key' => null, ]; }); diff --git a/backend/tests/Services/Api/ApiTest.php b/backend/tests/Services/Api/ApiTest.php index 1ccd038..ed98ee7 100644 --- a/backend/tests/Services/Api/ApiTest.php +++ b/backend/tests/Services/Api/ApiTest.php @@ -2,17 +2,31 @@ namespace Tests\Services\Api; +use App\Episode; +use App\Item; +use App\Services\Api\Plex; +use Tests\Fixtures\FakeApi; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; use Tests\TestCase; +use Tests\Traits\Factories; +use Tests\Traits\Fixtures; +use Tests\Traits\Mocks; class ApiTest extends TestCase { use RefreshDatabase; + use Mocks; + use Factories; + use Fixtures; public function setUp(): void { parent::setUp(); + + $this->createStorageDownloadsMock(); + $this->createImdbRatingMock(); } /** @test */ @@ -27,9 +41,165 @@ class ApiTest extends TestCase /** @test */ public function valid_token_needs_to_be_provided() { - $response = $this->postJson('api/plex', ['token' => 'not-valid']); + $mock = $this->mock(Plex::class); + $mock->shouldReceive('handle')->once()->andReturn(null); + $user = $this->createUser(['api_key' => Str::random(24)]); - $response->assertJson(['message' => 'No valid token provided']); - $response->assertStatus(Response::HTTP_UNAUTHORIZED); + $responseBefore = $this->postJson('api/plex', ['token' => 'not-valid']); + $responseAfter = $this->postJson('api/plex', ['token' => $user->api_key, 'payload' => '[]']); + + $responseBefore->assertJson(['message' => 'No valid token provided']); + $responseBefore->assertStatus(Response::HTTP_UNAUTHORIZED); + + $responseAfter->assertSuccessful(); + } + + /** @test */ + public function it_should_abort_the_request() + { + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + try { + $fakeApi->handle($this->apiFixtures('fake/abort.json')); + } catch (\Exception $exception) { + $this->assertTrue(true); + } + } + + /** @test */ + public function it_should_create_a_new_movie() + { + $this->createGuzzleMock( + $this->tmdbFixtures('movie/movie'), + $this->tmdbFixtures('movie/details'), + $this->tmdbFixtures('movie/alternative_titles') + ); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $itemsBefore = Item::all(); + + $fakeApi->handle($this->apiFixtures('fake/movie.json')); + + $itemsAfter = Item::all(); + + $this->assertCount(0, $itemsBefore); + $this->assertCount(1, $itemsAfter); + } + + /** @test */ + public function it_should_not_create_a_new_movie_if_it_exists() + { + $this->createMovie(); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $itemsBefore = Item::all(); + + $fakeApi->handle($this->apiFixtures('fake/movie.json')); + + $itemsAfter = Item::all(); + + $this->assertCount(1, $itemsBefore); + $this->assertCount(1, $itemsAfter); + } + + /** @test */ + public function it_should_create_a_new_tv_show() + { + $this->createGuzzleMock( + $this->tmdbFixtures('tv/tv'), + $this->tmdbFixtures('tv/details'), + $this->tmdbFixtures('tv/alternative_titles') + ); + + $this->createTmdbEpisodeMock(); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $itemsBefore = Item::all(); + + $fakeApi->handle($this->apiFixtures('fake/tv.json')); + + $itemsAfter = Item::all(); + + $this->assertCount(0, $itemsBefore); + $this->assertCount(1, $itemsAfter); + } + + /** @test */ + public function it_should_not_create_a_new_tv_show_if_it_exists() + { + $this->createTv(); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $itemsBefore = Item::all(); + + $fakeApi->handle($this->apiFixtures('fake/tv.json')); + + $itemsAfter = Item::all(); + + $this->assertCount(1, $itemsBefore); + $this->assertCount(1, $itemsAfter); + } + + /** @test */ + public function it_should_rate_a_movie() + { + $this->createMovie(); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $movieBefore = Item::first(); + + $fakeApi->handle($this->apiFixtures('fake/movie_rating.json')); + + $movieAfter = Item::first(); + + $this->assertEquals(1, $movieBefore->rating); + $this->assertEquals(2, $movieAfter->rating); + } + + /** @test */ + public function it_should_rate_a_tv_show() + { + $this->createTv(); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $tvBefore = Item::first(); + + $fakeApi->handle($this->apiFixtures('fake/tv_rating.json')); + + $tvAfter = Item::first(); + + $this->assertEquals(1, $tvBefore->rating); + $this->assertEquals(3, $tvAfter->rating); + } + + /** @test */ + public function it_should_mark_an_episode_as_seen() + { + $this->createTv(); + + /** @var FakeApi $fakeApi */ + $fakeApi = app(FakeApi::class); + + $seenEpisodesBefore = Episode::where('seen', true)->get(); + + $fakeApi->handle($this->apiFixtures('fake/episode_seen.json')); + + $seenEpisodesAfter = Episode::where('seen', true)->get(); + + $this->assertCount(0, $seenEpisodesBefore); + $this->assertCount(1, $seenEpisodesAfter); } } diff --git a/backend/tests/Traits/Factories.php b/backend/tests/Traits/Factories.php index 310c5af..e6c309a 100644 --- a/backend/tests/Traits/Factories.php +++ b/backend/tests/Traits/Factories.php @@ -9,9 +9,9 @@ trait Factories { - public function createUser() + public function createUser($custom = []) { - return factory(User::class)->create(); + return factory(User::class)->create($custom); } public function createSetting() diff --git a/backend/tests/Traits/Fixtures.php b/backend/tests/Traits/Fixtures.php index f870c6c..7d8acff 100644 --- a/backend/tests/Traits/Fixtures.php +++ b/backend/tests/Traits/Fixtures.php @@ -1,7 +1,7 @@ toArray(); } + protected function apiFixtures($path) + { + return collect(json_decode(file_get_contents(__DIR__ . '/../fixtures/api/' . $path), true))->toArray(); + } + protected function getMovieSrc() { return '/movies/Warcraft.2016.720p.WEB-DL/Warcraft.2016.720p.WEB-DL.mkv'; diff --git a/backend/tests/fixtures/FakeApi.php b/backend/tests/fixtures/FakeApi.php new file mode 100644 index 0000000..ed0922d --- /dev/null +++ b/backend/tests/fixtures/FakeApi.php @@ -0,0 +1,73 @@ +data['data']['abort']; + } + + /** + * @inheritDoc + */ + protected function getType() + { + return $this->data['data']['type']; + } + + /** + * @inheritDoc + */ + protected function getTitle() + { + return $this->data['data']['title']; + } + + /** + * @inheritDoc + */ + protected function getRating() + { + return $this->data['data']['rating']; + } + + /** + * @inheritDoc + */ + protected function shouldRateItem() + { + return $this->data['data']['rate']; + } + + /** + * @inheritDoc + */ + protected function shouldEpisodeMarkedAsSeen() + { + return $this->data['data']['seen']; + } + + /** + * @inheritDoc + */ + protected function getEpisodeNumber() + { + return $this->data['data']['episode']; + } + + /** + * @inheritDoc + */ + protected function getSeasonNumber() + { + return $this->data['data']['season']; + } +} diff --git a/backend/tests/fixtures/api/fake/abort.json b/backend/tests/fixtures/api/fake/abort.json new file mode 100644 index 0000000..3a57f99 --- /dev/null +++ b/backend/tests/fixtures/api/fake/abort.json @@ -0,0 +1,5 @@ +{ + "data": { + "abort": true + } +} diff --git a/backend/tests/fixtures/api/fake/episode_seen.json b/backend/tests/fixtures/api/fake/episode_seen.json new file mode 100644 index 0000000..563e4c1 --- /dev/null +++ b/backend/tests/fixtures/api/fake/episode_seen.json @@ -0,0 +1,12 @@ +{ + "data": { + "abort": false, + "type": "tv", + "title": "Game of Thrones", + "rate": false, + "seen": true, + "rating": null, + "episode": 2, + "season": 1 + } +} diff --git a/backend/tests/fixtures/api/fake/movie.json b/backend/tests/fixtures/api/fake/movie.json new file mode 100644 index 0000000..ed041c8 --- /dev/null +++ b/backend/tests/fixtures/api/fake/movie.json @@ -0,0 +1,12 @@ +{ + "data": { + "abort": false, + "type": "movie", + "title": "Warcraft", + "rate": false, + "seen": false, + "rating": null, + "episode": null, + "season": null + } +} diff --git a/backend/tests/fixtures/api/fake/movie_rating.json b/backend/tests/fixtures/api/fake/movie_rating.json new file mode 100644 index 0000000..63fd35f --- /dev/null +++ b/backend/tests/fixtures/api/fake/movie_rating.json @@ -0,0 +1,12 @@ +{ + "data": { + "abort": false, + "type": "movie", + "title": "Warcraft", + "rate": true, + "seen": false, + "rating": 2, + "episode": null, + "season": null + } +} diff --git a/backend/tests/fixtures/api/fake/tv.json b/backend/tests/fixtures/api/fake/tv.json new file mode 100644 index 0000000..f0c97f6 --- /dev/null +++ b/backend/tests/fixtures/api/fake/tv.json @@ -0,0 +1,12 @@ +{ + "data": { + "abort": false, + "type": "tv", + "title": "Game of Thrones", + "rate": false, + "seen": false, + "rating": null, + "episode": null, + "season": null + } +} diff --git a/backend/tests/fixtures/api/fake/tv_rating.json b/backend/tests/fixtures/api/fake/tv_rating.json new file mode 100644 index 0000000..2733593 --- /dev/null +++ b/backend/tests/fixtures/api/fake/tv_rating.json @@ -0,0 +1,12 @@ +{ + "data": { + "abort": false, + "type": "tv", + "title": "Game of Thrones", + "rate": true, + "seen": false, + "rating": 3, + "episode": null, + "season": null + } +} From 9538ab407bc861b588781643fa1b3b229e32145b Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 15:23:40 +0100 Subject: [PATCH 07/14] abstract the functionality for the api tests --- backend/tests/Services/Api/ApiTest.php | 72 ++++++++---------- .../tests/Services/Api/ApiTestInterface.php | 15 ++++ backend/tests/Services/Api/FakeApiTest.php | 73 +++++++++++++++++++ 3 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 backend/tests/Services/Api/ApiTestInterface.php create mode 100644 backend/tests/Services/Api/FakeApiTest.php diff --git a/backend/tests/Services/Api/ApiTest.php b/backend/tests/Services/Api/ApiTest.php index ed98ee7..8b7271c 100644 --- a/backend/tests/Services/Api/ApiTest.php +++ b/backend/tests/Services/Api/ApiTest.php @@ -5,7 +5,6 @@ namespace Tests\Services\Api; use App\Episode; use App\Item; use App\Services\Api\Plex; -use Tests\Fixtures\FakeApi; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -21,6 +20,8 @@ class ApiTest extends TestCase use Factories; use Fixtures; + private $apiClass; + public function setUp(): void { parent::setUp(); @@ -29,6 +30,11 @@ class ApiTest extends TestCase $this->createImdbRatingMock(); } + public function setApiClass($api) + { + $this->apiClass = $api; + } + /** @test */ public function token_needs_to_be_provided() { @@ -54,21 +60,18 @@ class ApiTest extends TestCase $responseAfter->assertSuccessful(); } - /** @test */ - public function it_should_abort_the_request() + public function it_should_abort_the_request($fixture) { - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $api = app($this->apiClass); try { - $fakeApi->handle($this->apiFixtures('fake/abort.json')); + $api->handle($this->apiFixtures($fixture)); } catch (\Exception $exception) { $this->assertTrue(true); } } - /** @test */ - public function it_should_create_a_new_movie() + public function it_should_create_a_new_movie($fixture) { $this->createGuzzleMock( $this->tmdbFixtures('movie/movie'), @@ -76,12 +79,11 @@ class ApiTest extends TestCase $this->tmdbFixtures('movie/alternative_titles') ); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures('fake/movie.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -89,17 +91,15 @@ class ApiTest extends TestCase $this->assertCount(1, $itemsAfter); } - /** @test */ - public function it_should_not_create_a_new_movie_if_it_exists() + public function it_should_not_create_a_new_movie_if_it_exists($fixture) { $this->createMovie(); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures('fake/movie.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -107,8 +107,7 @@ class ApiTest extends TestCase $this->assertCount(1, $itemsAfter); } - /** @test */ - public function it_should_create_a_new_tv_show() + public function it_should_create_a_new_tv_show($fixture) { $this->createGuzzleMock( $this->tmdbFixtures('tv/tv'), @@ -118,12 +117,11 @@ class ApiTest extends TestCase $this->createTmdbEpisodeMock(); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures('fake/tv.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -131,17 +129,15 @@ class ApiTest extends TestCase $this->assertCount(1, $itemsAfter); } - /** @test */ - public function it_should_not_create_a_new_tv_show_if_it_exists() + public function it_should_not_create_a_new_tv_show_if_it_exists($fixture) { $this->createTv(); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures('fake/tv.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -149,17 +145,15 @@ class ApiTest extends TestCase $this->assertCount(1, $itemsAfter); } - /** @test */ - public function it_should_rate_a_movie() + public function it_should_rate_a_movie($fixture) { $this->createMovie(); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $movieBefore = Item::first(); - $fakeApi->handle($this->apiFixtures('fake/movie_rating.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $movieAfter = Item::first(); @@ -167,17 +161,15 @@ class ApiTest extends TestCase $this->assertEquals(2, $movieAfter->rating); } - /** @test */ - public function it_should_rate_a_tv_show() + public function it_should_rate_a_tv_show($fixture) { $this->createTv(); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $tvBefore = Item::first(); - $fakeApi->handle($this->apiFixtures('fake/tv_rating.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $tvAfter = Item::first(); @@ -185,17 +177,15 @@ class ApiTest extends TestCase $this->assertEquals(3, $tvAfter->rating); } - /** @test */ - public function it_should_mark_an_episode_as_seen() + public function it_should_mark_an_episode_as_seen($fixture) { $this->createTv(); - /** @var FakeApi $fakeApi */ - $fakeApi = app(FakeApi::class); + $fakeApi = app($this->apiClass); $seenEpisodesBefore = Episode::where('seen', true)->get(); - $fakeApi->handle($this->apiFixtures('fake/episode_seen.json')); + $fakeApi->handle($this->apiFixtures($fixture)); $seenEpisodesAfter = Episode::where('seen', true)->get(); diff --git a/backend/tests/Services/Api/ApiTestInterface.php b/backend/tests/Services/Api/ApiTestInterface.php new file mode 100644 index 0000000..18d85fc --- /dev/null +++ b/backend/tests/Services/Api/ApiTestInterface.php @@ -0,0 +1,15 @@ +apiTest = app(ApiTest::class); + + $this->apiTest->setApiClass(FakeApi::class); + + $this->apiTest->setUp(); + } + + /** @test */ + public function it_should_abort_the_request() + { + $this->apiTest->it_should_abort_the_request('fake/abort.json'); + } + + /** @test */ + public function it_should_create_a_new_movie() + { + $this->apiTest->it_should_create_a_new_movie('fake/movie.json'); + } + + /** @test */ + public function it_should_not_create_a_new_movie_if_it_exists() + { + $this->apiTest->it_should_not_create_a_new_movie_if_it_exists('fake/movie.json'); + } + + /** @test */ + public function it_should_create_a_new_tv_show() + { + $this->apiTest->it_should_create_a_new_tv_show('fake/tv.json'); + } + + /** @test */ + public function it_should_not_create_a_new_tv_show_if_it_exists() + { + $this->apiTest->it_should_not_create_a_new_tv_show_if_it_exists('fake/tv.json'); + } + + /** @test */ + public function it_should_rate_a_movie() + { + $this->apiTest->it_should_rate_a_movie('fake/movie_rating.json'); + } + + /** @test */ + public function it_should_rate_a_tv_show() + { + $this->apiTest->it_should_rate_a_tv_show('fake/tv_rating.json'); + } + + /** @test */ + public function it_should_mark_an_episode_as_seen() + { + $this->apiTest->it_should_mark_an_episode_as_seen('fake/episode_seen.json'); + } +} From 2bb21feaf79492b73731b6e7408b23a0a76d5433 Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 17:27:14 +0100 Subject: [PATCH 08/14] restructure tests and implement tests for plex --- backend/app/Services/Api/Plex.php | 2 +- backend/tests/Services/Api/ApiTest.php | 46 ++++++------ .../tests/Services/Api/ApiTestInterface.php | 1 + backend/tests/Services/Api/FakeApiTest.php | 6 +- backend/tests/Services/Api/PlexApiTest.php | 73 +++++++++++++++++++ backend/tests/fixtures/api/plex/abort.json | 45 ++++++++++++ .../tests/fixtures/api/plex/episode_seen.json | 45 ++++++++++++ backend/tests/fixtures/api/plex/movie.json | 43 +++++++++++ .../tests/fixtures/api/plex/movie_rating.json | 44 +++++++++++ backend/tests/fixtures/api/plex/tv.json | 45 ++++++++++++ .../tests/fixtures/api/plex/tv_rating.json | 42 +++++++++++ 11 files changed, 363 insertions(+), 29 deletions(-) create mode 100644 backend/tests/Services/Api/PlexApiTest.php create mode 100644 backend/tests/fixtures/api/plex/abort.json create mode 100644 backend/tests/fixtures/api/plex/episode_seen.json create mode 100644 backend/tests/fixtures/api/plex/movie.json create mode 100644 backend/tests/fixtures/api/plex/movie_rating.json create mode 100644 backend/tests/fixtures/api/plex/tv.json create mode 100644 backend/tests/fixtures/api/plex/tv_rating.json diff --git a/backend/app/Services/Api/Plex.php b/backend/app/Services/Api/Plex.php index 5c6c3cd..0af82be 100644 --- a/backend/app/Services/Api/Plex.php +++ b/backend/app/Services/Api/Plex.php @@ -47,7 +47,7 @@ class Plex extends Api */ protected function getRating() { - $rating = +$this->data['rating']; + $rating = $this->data['Metadata']['userRating']; if ($rating > 7) { return 1; diff --git a/backend/tests/Services/Api/ApiTest.php b/backend/tests/Services/Api/ApiTest.php index 8b7271c..5daf44e 100644 --- a/backend/tests/Services/Api/ApiTest.php +++ b/backend/tests/Services/Api/ApiTest.php @@ -8,6 +8,7 @@ use App\Services\Api\Plex; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; use Tests\TestCase; use Tests\Traits\Factories; use Tests\Traits\Fixtures; @@ -20,7 +21,7 @@ class ApiTest extends TestCase use Factories; use Fixtures; - private $apiClass; + public $apiClass; public function setUp(): void { @@ -30,11 +31,6 @@ class ApiTest extends TestCase $this->createImdbRatingMock(); } - public function setApiClass($api) - { - $this->apiClass = $api; - } - /** @test */ public function token_needs_to_be_provided() { @@ -66,7 +62,7 @@ class ApiTest extends TestCase try { $api->handle($this->apiFixtures($fixture)); - } catch (\Exception $exception) { + } catch (HttpException $exception) { $this->assertTrue(true); } } @@ -79,11 +75,11 @@ class ApiTest extends TestCase $this->tmdbFixtures('movie/alternative_titles') ); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -95,11 +91,11 @@ class ApiTest extends TestCase { $this->createMovie(); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -117,11 +113,11 @@ class ApiTest extends TestCase $this->createTmdbEpisodeMock(); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -133,11 +129,11 @@ class ApiTest extends TestCase { $this->createTv(); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $itemsBefore = Item::all(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $itemsAfter = Item::all(); @@ -145,47 +141,47 @@ class ApiTest extends TestCase $this->assertCount(1, $itemsAfter); } - public function it_should_rate_a_movie($fixture) + public function it_should_rate_a_movie($fixture, $shouldHaveRating) { $this->createMovie(); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $movieBefore = Item::first(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $movieAfter = Item::first(); $this->assertEquals(1, $movieBefore->rating); - $this->assertEquals(2, $movieAfter->rating); + $this->assertEquals($shouldHaveRating, $movieAfter->rating); } - public function it_should_rate_a_tv_show($fixture) + public function it_should_rate_a_tv_show($fixture, $shouldHaveRating) { $this->createTv(); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $tvBefore = Item::first(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $tvAfter = Item::first(); $this->assertEquals(1, $tvBefore->rating); - $this->assertEquals(3, $tvAfter->rating); + $this->assertEquals($shouldHaveRating, $tvAfter->rating); } public function it_should_mark_an_episode_as_seen($fixture) { $this->createTv(); - $fakeApi = app($this->apiClass); + $api = app($this->apiClass); $seenEpisodesBefore = Episode::where('seen', true)->get(); - $fakeApi->handle($this->apiFixtures($fixture)); + $api->handle($this->apiFixtures($fixture)); $seenEpisodesAfter = Episode::where('seen', true)->get(); diff --git a/backend/tests/Services/Api/ApiTestInterface.php b/backend/tests/Services/Api/ApiTestInterface.php index 18d85fc..849c24c 100644 --- a/backend/tests/Services/Api/ApiTestInterface.php +++ b/backend/tests/Services/Api/ApiTestInterface.php @@ -4,6 +4,7 @@ namespace Tests\Services\Api; interface ApiTestInterface { + public function setUp(): void; public function it_should_abort_the_request(); public function it_should_create_a_new_movie(); public function it_should_not_create_a_new_movie_if_it_exists(); diff --git a/backend/tests/Services/Api/FakeApiTest.php b/backend/tests/Services/Api/FakeApiTest.php index a991905..96e9647 100644 --- a/backend/tests/Services/Api/FakeApiTest.php +++ b/backend/tests/Services/Api/FakeApiTest.php @@ -18,7 +18,7 @@ class FakeApiTest extends TestCase implements ApiTestInterface $this->apiTest = app(ApiTest::class); - $this->apiTest->setApiClass(FakeApi::class); + $this->apiTest->apiClass = FakeApi::class; $this->apiTest->setUp(); } @@ -56,13 +56,13 @@ class FakeApiTest extends TestCase implements ApiTestInterface /** @test */ public function it_should_rate_a_movie() { - $this->apiTest->it_should_rate_a_movie('fake/movie_rating.json'); + $this->apiTest->it_should_rate_a_movie('fake/movie_rating.json', 2); } /** @test */ public function it_should_rate_a_tv_show() { - $this->apiTest->it_should_rate_a_tv_show('fake/tv_rating.json'); + $this->apiTest->it_should_rate_a_tv_show('fake/tv_rating.json', 3); } /** @test */ diff --git a/backend/tests/Services/Api/PlexApiTest.php b/backend/tests/Services/Api/PlexApiTest.php new file mode 100644 index 0000000..0be11d7 --- /dev/null +++ b/backend/tests/Services/Api/PlexApiTest.php @@ -0,0 +1,73 @@ +apiTest = app(ApiTest::class); + + $this->apiTest->apiClass = Plex::class; + + $this->apiTest->setUp(); + } + + /** @test */ + public function it_should_abort_the_request() + { + $this->apiTest->it_should_abort_the_request('plex/abort.json'); + } + + /** @test */ + public function it_should_create_a_new_movie() + { + $this->apiTest->it_should_create_a_new_movie('plex/movie.json'); + } + + /** @test */ + public function it_should_not_create_a_new_movie_if_it_exists() + { + $this->apiTest->it_should_not_create_a_new_movie_if_it_exists('plex/movie.json'); + } + + /** @test */ + public function it_should_create_a_new_tv_show() + { + $this->apiTest->it_should_create_a_new_tv_show('plex/tv.json'); + } + + /** @test */ + public function it_should_not_create_a_new_tv_show_if_it_exists() + { + $this->apiTest->it_should_not_create_a_new_tv_show_if_it_exists('plex/tv.json'); + } + + /** @test */ + public function it_should_rate_a_movie() + { + $this->apiTest->it_should_rate_a_movie('plex/movie_rating.json', 2); + } + + /** @test */ + public function it_should_rate_a_tv_show() + { + $this->apiTest->it_should_rate_a_tv_show('plex/tv_rating.json', 3); + } + + /** @test */ + public function it_should_mark_an_episode_as_seen() + { + $this->apiTest->it_should_mark_an_episode_as_seen('plex/episode_seen.json'); + } +} diff --git a/backend/tests/fixtures/api/plex/abort.json b/backend/tests/fixtures/api/plex/abort.json new file mode 100644 index 0000000..e23eac7 --- /dev/null +++ b/backend/tests/fixtures/api/plex/abort.json @@ -0,0 +1,45 @@ +{ + "event": "media.play", + "user": true, + "owner": true, + "Account": {}, + "Server": {}, + "Player": {}, + "Metadata": { + "librarySectionType": "", + "ratingKey": "", + "key": "", + "parentRatingKey": "", + "grandparentRatingKey": "", + "guid": "", + "parentGuid": "", + "grandparentGuid": "", + "type": "podcast", + "title": "a title here", + "titleSort": "", + "grandparentKey": "", + "parentKey": "", + "grandparentTitle": "Game of Thrones", + "parentTitle": "Season 1", + "contentRating": "", + "summary": "", + "index": 2, + "parentIndex": 1, + "rating": null, + "userRating": null, + "viewCount": null, + "lastViewedAt": null, + "lastRatedAt": null, + "year": null, + "thumb": "", + "art": "", + "parentThumb": "", + "grandparentThumb": "", + "grandparentArt": "", + "grandparentTheme": "", + "originallyAvailableAt": "", + "addedAt": null, + "updatedAt": null, + "Writer": [] + } +} diff --git a/backend/tests/fixtures/api/plex/episode_seen.json b/backend/tests/fixtures/api/plex/episode_seen.json new file mode 100644 index 0000000..11cab39 --- /dev/null +++ b/backend/tests/fixtures/api/plex/episode_seen.json @@ -0,0 +1,45 @@ +{ + "event": "media.scrobble", + "user": true, + "owner": true, + "Account": {}, + "Server": {}, + "Player": {}, + "Metadata": { + "librarySectionType": "", + "ratingKey": "", + "key": "", + "parentRatingKey": "", + "grandparentRatingKey": "", + "guid": "", + "parentGuid": "", + "grandparentGuid": "", + "type": "episode", + "title": "", + "titleSort": "", + "grandparentKey": "", + "parentKey": "", + "grandparentTitle": "Game of Thrones", + "parentTitle": "", + "contentRating": "", + "summary": "", + "index": 2, + "parentIndex": 1, + "rating": null, + "userRating": null, + "viewCount": null, + "lastViewedAt": null, + "lastRatedAt": null, + "year": null, + "thumb": "", + "art": "", + "parentThumb": "", + "grandparentThumb": "", + "grandparentArt": "", + "grandparentTheme": "", + "originallyAvailableAt": "", + "addedAt": null, + "updatedAt": null, + "Writer": [] + } +} diff --git a/backend/tests/fixtures/api/plex/movie.json b/backend/tests/fixtures/api/plex/movie.json new file mode 100644 index 0000000..9cf24fb --- /dev/null +++ b/backend/tests/fixtures/api/plex/movie.json @@ -0,0 +1,43 @@ +{ + "event": "media.play", + "user": true, + "owner": true, + "Account": {}, + "Server": {}, + "Player": {}, + "Metadata": { + "librarySectionType": "", + "ratingKey": "", + "key": "", + "guid": "", + "studio": "", + "type": "movie", + "title": "Warcraft", + "contentRating": "", + "summary": "", + "rating": null, + "audienceRating": null, + "userRating": null, + "viewCount": null, + "lastViewedAt": null, + "lastRatedAt": null, + "year": null, + "tagline": "", + "thumb": "", + "art": "", + "duration": null, + "originallyAvailableAt": "", + "addedAt": null, + "updatedAt": null, + "audienceRatingImage": "", + "primaryExtraKey": "", + "ratingImage": "", + "Genre": [], + "Director": [], + "Writer": [], + "Producer": [], + "Country": [], + "Role": [], + "Similar": [] + } +} diff --git a/backend/tests/fixtures/api/plex/movie_rating.json b/backend/tests/fixtures/api/plex/movie_rating.json new file mode 100644 index 0000000..a6e48ae --- /dev/null +++ b/backend/tests/fixtures/api/plex/movie_rating.json @@ -0,0 +1,44 @@ +{ + "rating": "", + "event": "media.rate", + "user": true, + "owner": true, + "Account": {}, + "Server": {}, + "Player": {}, + "Metadata": { + "librarySectionType": "", + "ratingKey": "", + "key": "", + "guid": "", + "studio": "", + "type": "movie", + "title": "Warcraft", + "contentRating": "", + "summary": "", + "rating": null, + "audienceRating": null, + "userRating": 5, + "viewCount": null, + "lastViewedAt": null, + "lastRatedAt": null, + "year": null, + "tagline": "", + "thumb": "", + "art": "", + "duration": null, + "originallyAvailableAt": "", + "addedAt": null, + "updatedAt": null, + "audienceRatingImage": "", + "primaryExtraKey": "", + "ratingImage": "", + "Genre": [], + "Director": [], + "Writer": [], + "Producer": [], + "Country": [], + "Role": [], + "Similar": [] + } +} diff --git a/backend/tests/fixtures/api/plex/tv.json b/backend/tests/fixtures/api/plex/tv.json new file mode 100644 index 0000000..edf8767 --- /dev/null +++ b/backend/tests/fixtures/api/plex/tv.json @@ -0,0 +1,45 @@ +{ + "event": "media.play", + "user": true, + "owner": true, + "Account": {}, + "Server": {}, + "Player": {}, + "Metadata": { + "librarySectionType": "", + "ratingKey": "", + "key": "", + "parentRatingKey": "", + "grandparentRatingKey": "", + "guid": "", + "parentGuid": "", + "grandparentGuid": "", + "type": "episode", + "title": "", + "titleSort": "", + "grandparentKey": "", + "parentKey": "", + "grandparentTitle": "Game of Thrones", + "parentTitle": "", + "contentRating": "", + "summary": "", + "index": 2, + "parentIndex": 1, + "rating": null, + "userRating": null, + "viewCount": null, + "lastViewedAt": null, + "lastRatedAt": null, + "year": null, + "thumb": "", + "art": "", + "parentThumb": "", + "grandparentThumb": "", + "grandparentArt": "", + "grandparentTheme": "", + "originallyAvailableAt": "", + "addedAt": null, + "updatedAt": null, + "Writer": [] + } +} diff --git a/backend/tests/fixtures/api/plex/tv_rating.json b/backend/tests/fixtures/api/plex/tv_rating.json new file mode 100644 index 0000000..e47c98f --- /dev/null +++ b/backend/tests/fixtures/api/plex/tv_rating.json @@ -0,0 +1,42 @@ +{ + "rating": "", + "event": "media.rate", + "user": true, + "owner": true, + "Account": {}, + "Server": {}, + "Player": {}, + "Metadata": { + "librarySectionType": "", + "ratingKey": "", + "key": "", + "guid": "", + "studio": "", + "type": "show", + "title": "Game of Thrones", + "contentRating": "", + "summary": "", + "index": null, + "rating": null, + "userRating": 1, + "viewCount": null, + "lastViewedAt": null, + "lastRatedAt": null, + "year": null, + "thumb": "", + "art": "", + "banner": "", + "theme": "", + "duration": null, + "originallyAvailableAt": "", + "leafCount": null, + "viewedLeafCount": null, + "childCount": null, + "addedAt": null, + "updatedAt": null, + "Genre": [], + "Role": [], + "Similar": [], + "Location": [] + } +} From deb41f9eb299830dd6a38489b99b655758c7d3cf Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 18:09:57 +0100 Subject: [PATCH 09/14] fix next episode sorting --- backend/app/Services/Models/EpisodeService.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/Services/Models/EpisodeService.php b/backend/app/Services/Models/EpisodeService.php index a90caba..640066d 100644 --- a/backend/app/Services/Models/EpisodeService.php +++ b/backend/app/Services/Models/EpisodeService.php @@ -87,7 +87,7 @@ return [ 'episodes' => $episodes->sortBy('episode_number')->groupBy('season_number'), - 'next_episode' => $episodes->where('seen', 0)->first(), + 'next_episode' => $episodes->where('seen', 0)->sortBy('season_number')->first(), 'spoiler' => Setting::first()->episode_spoiler_protection, ]; } From e028d33b5beb6daa0ba56004d03c20e0d10d2df3 Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 18:25:04 +0100 Subject: [PATCH 10/14] fix sorting for next episode --- backend/app/Services/Models/EpisodeService.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/app/Services/Models/EpisodeService.php b/backend/app/Services/Models/EpisodeService.php index 640066d..1fc256a 100644 --- a/backend/app/Services/Models/EpisodeService.php +++ b/backend/app/Services/Models/EpisodeService.php @@ -83,11 +83,12 @@ { Carbon::setLocale(config('app.TRANSLATION')); - $episodes = $this->model->findByTmdbId($tmdbId)->get(); + $episodes = $this->model->findByTmdbId($tmdbId)->oldest('episode_number')->get()->groupBy('season_number'); + $nextEpisode = $this->model->findByTmdbId($tmdbId)->where('seen', 0)->oldest('season_number')->oldest('episode_number')->first(); return [ - 'episodes' => $episodes->sortBy('episode_number')->groupBy('season_number'), - 'next_episode' => $episodes->where('seen', 0)->sortBy('season_number')->first(), + 'episodes' => $episodes, + 'next_episode' => $nextEpisode, 'spoiler' => Setting::first()->episode_spoiler_protection, ]; } From f0b68bdddb88fc3a7563f6048c1160ff7a6197c5 Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 18:37:09 +0100 Subject: [PATCH 11/14] update readme for plex --- README.md | 19 ++++++++++++++++--- backend/config/app.php | 2 +- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 2c35f3c..6e82aa9 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,19 @@ php artisan flox:db # Running migrations and enter your admin credentials for th * Set your `CLIENT_URI` in `backend/.env`. ```bash # CLIENT_URI=/flox/public -http://localhost:8888/flox/public +https://localhost:8888/flox/public # CLIENT_URI=/subfolder/for/flox/public -http://mydomain.com/subfolder/for/flox/public +https://mydomain.com/subfolder/for/flox/public # CLIENT_URI=/ -http://mydomain.com +https://mydomain.com ``` ### Features +- API for Plex. + - Sync movies, shows and watched episodes from Plex to Flox. - Episode Tracking. - Suggestions. - Watchlist. @@ -52,6 +54,17 @@ http://mydomain.com - Calendar. - A simple calendar for your episodes and movies. - Movies and tv shows have different colors for better differentiation. You can also use the arrow keys to jump months forward or backward. +- Reminders. + +### Plex + +To enable the sync from Plex to Flox, you first need to generate an API-Key in Flox in the settings page. Then enter the Flox API-URL to the webhooks section in Plex. + +``` +https://YOUR-FLOX-URL/api/plex?token=YOUR-TOKEN +``` + +If you start a tv show or movie in Plex, Flox will search the item via the title from TMDb and add them into the Flox database. If you rate a movie or tv show in Plex, Flox will also rate the item. Note that rating for seasons or episodes are not supported in Flox. If you rate an movie or tv show, which is not in the Flox database, Flox will also fetch them from TMDb first. If you complete an episode (passing the 90% mark), Flox will also check this episode as seen. ### Update Process diff --git a/backend/config/app.php b/backend/config/app.php index 58d5f7f..ea81b5e 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -3,7 +3,7 @@ return [ // Current version. Is synced with git releases. - 'version' => '2.0.0', + 'version' => '2.1.0', 'TRANSLATION' => env('TRANSLATION', 'EN'), 'LOADING_ITEMS' => env('LOADING_ITEMS'), From b2d2f3650a642619bca899fa7d9b6db1fbaddd73 Mon Sep 17 00:00:00 2001 From: devfake Date: Tue, 24 Dec 2019 18:38:07 +0100 Subject: [PATCH 12/14] prod build --- public/assets/app.css | 26 +++++++++++--------------- public/assets/app.js | 2 +- public/assets/vendor.js | 8 ++++---- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/public/assets/app.css b/public/assets/app.css index 6456e13..6893541 100644 --- a/public/assets/app.css +++ b/public/assets/app.css @@ -2134,7 +2134,17 @@ main { clear: both; } } @media (max-width: 620px) { .item-wrap { - width: calc(99.9% * 1/2 - (30px - 30px * 1/2)); } + width: calc(99.9% * 1/2 - (30px - 30px * 1/2)); + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; } .item-wrap:nth-child(1n) { float: left; margin-right: 30px; @@ -2146,20 +2156,6 @@ main { float: right; } .item-wrap:nth-child(2n + 1) { clear: both; } } - @media (max-width: 450px) { - .item-wrap { - width: calc(99.9% * 1 - (30px - 30px * 1)); } - .item-wrap:nth-child(1n) { - float: left; - margin-right: 30px; - clear: none; } - .item-wrap:last-child { - margin-right: 0; } - .item-wrap:nth-child(NaNn) { - margin-right: 0; - float: right; } - .item-wrap:nth-child(NaNn + 1) { - clear: both; } } .show-ratings-never .rating-0, .show-ratings-never .rating-1, diff --git a/public/assets/app.js b/public/assets/app.js index df8b9d4..01ee598 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -1 +1 @@ -webpackJsonp([0],[,function(t,e){t.exports=function(t,e,n,r,i,a){var o,s=t=t||{},c=typeof t.default;"object"!==c&&"function"!==c||(o=t,s=t.default);var u="function"==typeof s?s.options:s;e&&(u.render=e.render,u.staticRenderFns=e.staticRenderFns,u._compiled=!0),n&&(u.functional=!0),i&&(u._scopeId=i);var l;if(a?(l=function(t){t=t||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext,t||"undefined"==typeof __VUE_SSR_CONTEXT__||(t=__VUE_SSR_CONTEXT__),r&&r.call(this,t),t&&t._registeredComponents&&t._registeredComponents.add(a)},u._ssrRegister=l):r&&(l=r),l){var f=u.functional,d=f?u.render:u.beforeCreate;f?(u._injectStyles=l,u.render=function(t,e){return l.call(e),d(t,e)}):u.beforeCreate=d?[].concat(d,l):[l]}return{esModule:o,exports:s,options:u}}},function(t,e,n){"use strict";e.__esModule=!0;var r=n(97),i=function(t){return t&&t.__esModule?t:{default:t}}(r);e.default=i.default||function(t){for(var e=1;e=Math.PI&&window.scrollTo(0,0),0!==window.scrollY&&(window.scrollTo(0,Math.round(n+n*Math.cos(r))),i=a,window.requestAnimationFrame(t))}var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:300,n=window.scrollY/2,r=0,i=performance.now();window.requestAnimationFrame(t)},suggestionsUri:function(t){return"/suggestions?for="+t.tmdb_id+"&name="+t.title+"&type="+t.media_type},lang:function(t){return JSON.parse(config.language)[t]||t},formatLocaleDate:function(t){var e=navigator.language||navigator.userLanguage;return t.toLocaleDateString(e,{year:"2-digit",month:"2-digit",day:"2-digit"})},isSubpage:function(){return this.$route.name.includes("subpage")}},computed:{displayHeader:function(){return!this.isSubpage()||this.itemLoadedSubpage}}}},,function(t,e){var n=t.exports="undefined"!=typeof window&&window.Math==Math?window:"undefined"!=typeof self&&self.Math==Math?self:Function("return this")();"number"==typeof __g&&(__g=n)},function(t,e){var n=t.exports={version:"2.5.7"};"number"==typeof __e&&(__e=n)},function(t,e,n){var r=n(37)("wks"),i=n(26),a=n(5).Symbol,o="function"==typeof a;(t.exports=function(t){return r[t]||(r[t]=o&&a[t]||(o?a:i)("Symbol."+t))}).store=r},,function(t,e,n){var r=n(10),i=n(50),a=n(32),o=Object.defineProperty;e.f=n(12)?Object.defineProperty:function(t,e,n){if(r(t),e=a(e,!0),r(n),i)try{return o(t,e,n)}catch(t){}if("get"in n||"set"in n)throw TypeError("Accessors not supported!");return"value"in n&&(t[e]=n.value),t}},function(t,e,n){var r=n(14);t.exports=function(t){if(!r(t))throw TypeError(t+" is not an object!");return t}},function(t,e,n){var r=n(5),i=n(6),a=n(17),o=n(13),s=n(15),c=function(t,e,n){var u,l,f,d=t&c.F,h=t&c.G,p=t&c.S,v=t&c.P,m=t&c.B,g=t&c.W,_=h?i:i[e]||(i[e]={}),y=_.prototype,b=h?r:p?r[e]:(r[e]||{}).prototype;h&&(n=e);for(u in n)(l=!d&&b&&void 0!==b[u])&&s(_,u)||(f=l?b[u]:n[u],_[u]=h&&"function"!=typeof b[u]?n[u]:m&&l?a(f,r):g&&b[u]==f?function(t){var e=function(e,n,r){if(this instanceof t){switch(arguments.length){case 0:return new t;case 1:return new t(e);case 2:return new t(e,n)}return new t(e,n,r)}return t.apply(this,arguments)};return e.prototype=t.prototype,e}(f):v&&"function"==typeof f?a(Function.call,f):f,v&&((_.virtual||(_.virtual={}))[u]=f,t&c.R&&y&&!y[u]&&o(y,u,f)))};c.F=1,c.G=2,c.S=4,c.P=8,c.B=16,c.W=32,c.U=64,c.R=128,t.exports=c},function(t,e,n){t.exports=!n(18)(function(){return 7!=Object.defineProperty({},"a",{get:function(){return 7}}).a})},function(t,e,n){var r=n(9),i=n(19);t.exports=n(12)?function(t,e,n){return r.f(t,e,i(1,n))}:function(t,e,n){return t[e]=n,t}},function(t,e){t.exports=function(t){return"object"==typeof t?null!==t:"function"==typeof t}},function(t,e){var n={}.hasOwnProperty;t.exports=function(t,e){return n.call(t,e)}},function(t,e,n){var r=n(52),i=n(33);t.exports=function(t){return r(i(t))}},function(t,e,n){var r=n(24);t.exports=function(t,e,n){if(r(t),void 0===e)return t;switch(n){case 1:return function(n){return t.call(e,n)};case 2:return function(n,r){return t.call(e,n,r)};case 3:return function(n,r,i){return t.call(e,n,r,i)}}return function(){return t.apply(e,arguments)}}},function(t,e){t.exports=function(t){try{return!!t()}catch(t){return!0}}},function(t,e){t.exports=function(t,e){return{enumerable:!(1&t),configurable:!(2&t),writable:!(4&t),value:e}}},function(t,e){var n={}.toString;t.exports=function(t){return n.call(t).slice(8,-1)}},function(t,e){t.exports=!0},,function(t,e){t.exports={}},function(t,e){t.exports=function(t){if("function"!=typeof t)throw TypeError(t+" is not a function!");return t}},function(t,e,n){var r=n(51),i=n(38);t.exports=Object.keys||function(t){return r(t,i)}},function(t,e){var n=0,r=Math.random();t.exports=function(t){return"Symbol(".concat(void 0===t?"":t,")_",(++n+r).toString(36))}},function(t,e){e.f={}.propertyIsEnumerable},function(t,e,n){var r=n(9).f,i=n(15),a=n(7)("toStringTag");t.exports=function(t,e,n){t&&!i(t=n?t:t.prototype,a)&&r(t,a,{configurable:!0,value:e})}},function(t,e,n){"use strict";var r=n(147)(!0);n(65)(String,"String",function(t){this._t=String(t),this._i=0},function(){var t,e=this._t,n=this._i;return n>=e.length?{value:void 0,done:!0}:(t=r(e,n),this._i+=t.length,{value:t,done:!1})})},,function(t,e,n){var r=n(14),i=n(5).document,a=r(i)&&r(i.createElement);t.exports=function(t){return a?i.createElement(t):{}}},function(t,e,n){var r=n(14);t.exports=function(t,e){if(!r(t))return t;var n,i;if(e&&"function"==typeof(n=t.toString)&&!r(i=n.call(t)))return i;if("function"==typeof(n=t.valueOf)&&!r(i=n.call(t)))return i;if(!e&&"function"==typeof(n=t.toString)&&!r(i=n.call(t)))return i;throw TypeError("Can't convert object to primitive value")}},function(t,e){t.exports=function(t){if(void 0==t)throw TypeError("Can't call method on "+t);return t}},function(t,e,n){var r=n(35),i=Math.min;t.exports=function(t){return t>0?i(r(t),9007199254740991):0}},function(t,e){var n=Math.ceil,r=Math.floor;t.exports=function(t){return isNaN(t=+t)?0:(t>0?r:n)(t)}},function(t,e,n){var r=n(37)("keys"),i=n(26);t.exports=function(t){return r[t]||(r[t]=i(t))}},function(t,e,n){var r=n(6),i=n(5),a=i["__core-js_shared__"]||(i["__core-js_shared__"]={});(t.exports=function(t,e){return a[t]||(a[t]=void 0!==e?e:{})})("versions",[]).push({version:r.version,mode:n(21)?"pure":"global",copyright:"© 2018 Denis Pushkarev (zloirock.ru)"})},function(t,e){t.exports="constructor,hasOwnProperty,isPrototypeOf,propertyIsEnumerable,toLocaleString,toString,valueOf".split(",")},function(t,e){e.f=Object.getOwnPropertySymbols},function(t,e,n){var r=n(33);t.exports=function(t){return Object(r(t))}},,,function(t,e,n){n(141);for(var r=n(5),i=n(13),a=n(23),o=n(7)("toStringTag"),s="CSSRuleList,CSSStyleDeclaration,CSSValueList,ClientRectList,DOMRectList,DOMStringList,DOMTokenList,DataTransferItemList,FileList,HTMLAllCollection,HTMLCollection,HTMLFormElement,HTMLSelectElement,MediaList,MimeTypeArray,NamedNodeMap,NodeList,PaintRequestList,Plugin,PluginArray,SVGLengthList,SVGNumberList,SVGPathSegList,SVGPointList,SVGStringList,SVGTransformList,SourceBufferList,StyleSheetList,TextTrackCueList,TextTrackList,TouchList".split(","),c=0;cc;)r(s,n=e[c++])&&(~a(u,n)||u.push(n));return u}},function(t,e,n){var r=n(20);t.exports=Object("z").propertyIsEnumerable(0)?Object:function(t){return"String"==r(t)?t.split(""):Object(t)}},,function(t,e,n){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var i=n(2),a=r(i),o=n(111),s=r(o),c=n(3),u=r(c),l=n(0);e.default={mixins:[u.default],data:function(){return{sticky:!1,enableStickyOn:100,latestRoute:"",mobileNavigationOpen:!1}},mounted:function(){this.latestRoute=this.$route.name,this.initSticky()},computed:(0,a.default)({},(0,l.mapState)({itemLoadedSubpage:function(t){return t.itemLoadedSubpage}}),{root:function(){return config.uri}}),methods:(0,a.default)({},(0,l.mapActions)(["loadItems"]),{initSticky:function(){var t=this;window.onscroll=function(){t.sticky=document.body.scrollTop+document.documentElement.scrollTop>t.enableStickyOn}},toggleMobileNavigation:function(){this.mobileNavigationOpen=!this.mobileNavigationOpen},refresh:function(t){this.mobileNavigationOpen=!1;var e=this.$route.name;this.latestRoute===t&&this.loadItems({name:e}),this.latestRoute=e}}),components:{Search:s.default}}},function(t,e,n){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var i=n(2),a=r(i),o=n(3),s=r(o),c=n(0);e.default={mixins:[s.default],data:function(){return{hideSearch:!1}},created:function(){this.disableSearch()},watch:{$route:function(){this.disableSearch()}},computed:(0,a.default)({},(0,c.mapState)({itemLoadedSubpage:function(t){return t.itemLoadedSubpage}}),{suggestionsFor:function(){return this.$route.query.name},title:{get:function(){return this.$store.state.searchTitle},set:function(t){this.$store.commit("SET_SEARCH_TITLE",t)}},placeholder:function(){return config.auth?this.lang("search or add"):this.lang("search")}}),methods:{search:function(){""!==this.title&&this.$router.push({path:"/search?q="+this.title})},disableSearch:function(){this.hideSearch="calendar"===this.$route.name}}}},function(t,e,n){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var i=n(2),a=r(i),o=n(0),s=n(3),c=r(s);e.default={mixins:[c.default],data:function(){return{hideFooter:!1,auth:config.auth,logout:config.api+"/logout",login:config.url+"/login",settings:config.url+"/settings"}},computed:(0,a.default)({},(0,o.mapState)({colorScheme:function(t){return t.colorScheme},loading:function(t){return t.loading}})),created:function(){this.disableFooter()},methods:(0,a.default)({},(0,o.mapActions)(["setColorScheme"]),{toggleColorScheme:function(){var t="light"===this.colorScheme?"dark":"light";this.setColorScheme(t)},disableFooter:function(){this.hideFooter="calendar"===this.$route.name}}),watch:{$route:function(){this.disableFooter()}}}},function(t,e,n){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var i=n(4),a=r(i),o=n(3),s=r(o);e.default={mixins:[s.default],created:function(){document.body.classList.add("dark")},data:function(){return{username:"",password:"",error:!1,errorShake:!1}},methods:{login:function(){var t=this;this.error=!1;var e=this.username,n=this.password;a.default.post(config.api+"/login",{username:e,password:n}).then(function(t){window.location.href=config.url},function(e){t.error=!0,t.errorShake=!0,setTimeout(function(){t.errorShake=!1},500)})}}}},,,,,,function(t,e,n){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var i=n(2),a=r(i),o=n(138),s=r(o),c=n(163),u=r(c),l=n(0);e.default={computed:(0,a.default)({},(0,l.mapState)({overlay:function(t){return t.overlay},modalType:function(t){return t.modalType}})),methods:(0,a.default)({},(0,l.mapMutations)(["CLOSE_MODAL"])),components:{Season:s.default,Trailer:u.default}}},function(t,e,n){"use strict";function r(t){return t&&t.__esModule?t:{default:t}}Object.defineProperty(e,"__esModule",{value:!0});var i=n(139),a=r(i),o=n(2),s=r(o),c=n(0),u=n(4),l=r(u),f=n(3),d=r(f),h=n(45),p=r(h);e.default={mixins:[d.default,p.default],data:function(){return{auth:config.auth}},computed:(0,s.default)({},(0,c.mapState)({modalData:function(t){return t.modalData},loadingModalData:function(t){return t.loadingModalData},seasonActiveModal:function(t){return t.seasonActiveModal}}),{episodes:function(){return this.modalData.episodes},spoiler:function(){return this.modalData.spoiler}}),methods:(0,s.default)({},(0,c.mapMutations)(["SET_SEASON_ACTIVE_MODAL","CLOSE_MODAL","SET_LOADING_MODAL_DATA","SET_MODAL_DATA"]),{released:function(t){var e=new Date(1e3*t);return this.formatLocaleDate(e)},toggleAll:function(){var t=this.seasonActiveModal,e=this.modalData.episodes[1][0].tmdb_id,n=this.seasonCompleted(t);this.markAllEpisodes(t,n),l.default.patch(config.api+"/toggle-season",{tmdb_id:e,season:t,seen:!n})},markAllEpisodes:function(t,e){var n=this.episodes[t],r=!0,i=!1,o=void 0;try{for(var s,c=(0,a.default)(n);!(r=(s=c.next()).done);r=!0){s.value.seen=!e}}catch(t){i=!0,o=t}finally{try{!r&&c.return&&c.return()}finally{if(i)throw o}}},toggleEpisode:function(t){this.auth&&(t.seen=!t.seen,l.default.patch(config.api+"/toggle-episode/"+t.id).catch(function(e){t.seen=!t.seen}))},seasonCompleted:function(t){var e=this.episodes[t],n=!0,r=!1,i=void 0;try{for(var o,s=(0,a.default)(e);!(n=(o=s.next()).done);n=!0){if(!o.value.seen)return!1}}catch(t){r=!0,i=t}finally{try{!n&&s.return&&s.return()}finally{if(r)throw i}}return!0}})}},function(t,e,n){"use strict";var r=n(21),i=n(11),a=n(66),o=n(13),s=n(23),c=n(144),u=n(28),l=n(146),f=n(7)("iterator"),d=!([].keys&&"next"in[].keys()),h=function(){return this};t.exports=function(t,e,n,p,v,m,g){c(n,e,p);var _,y,b,S=function(t){if(!d&&t in O)return O[t];switch(t){case"keys":case"values":return function(){return new n(this,t)}}return function(){return new n(this,t)}},w=e+" Iterator",x="values"==v,E=!1,O=t.prototype,M=O[f]||O["@@iterator"]||v&&O[v],T=M||S(v),D=v?x?S("entries"):T:void 0,C="Array"==e?O.entries||M:M;if(C&&(b=l(C.call(new t)))!==Object.prototype&&b.next&&(u(b,w,!0),r||"function"==typeof b[f]||o(b,f,h)),x&&M&&"values"!==M.name&&(E=!0,T=function(){return M.call(this)}),r&&!g||!d&&!E&&O[f]||o(O,f,T),s[e]=T,s[w]=h,v)if(_={values:x?T:S("values"),keys:m?T:S("keys"),entries:D},g)for(y in _)y in O||a(O,y,_[y]);else i(i.P+i.F*(d||E),e,_);return _}},function(t,e,n){t.exports=n(13)},function(t,e,n){var r=n(10),i=n(145),a=n(38),o=n(36)("IE_PROTO"),s=function(){},c=function(){var t,e=n(31)("iframe"),r=a.length;for(e.style.display="none",n(68).appendChild(e),e.src="javascript:",t=e.contentWindow.document,t.open(),t.write("