1
0
mirror of https://github.com/devfake/flox.git synced 2024-11-23 18:42:30 +01:00

subpage design (#64)

* start with subpage design

* transitions

* transitions

* transitions, stuff

* modal for trailer

* english fallback for trailers

* slugs in url

* bit refactor

* tests and fixtures

* travis

* update production files

* move time limit to config and display error message to user

* fix import

* fix review
This commit is contained in:
Viktor Geringer 2017-04-11 08:45:08 +02:00 committed by GitHub
parent fd2ed4676b
commit 619e407bcd
79 changed files with 1942 additions and 515 deletions

View File

@ -4,6 +4,9 @@ TRANSLATION=
CLIENT_URI=/
LOADING_ITEMS=30
# Default 10 minutes (600 seconds)
PHP_TIME_LIMIT=600
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306

View File

@ -25,34 +25,6 @@
'release_season',
];
/**
* Save all episodes of each season.
*
* @param $seasons
* @param $tmdbId
*/
public function store($seasons, $tmdbId)
{
foreach($seasons as $season) {
$releaseSeason = Carbon::createFromFormat('Y-m-d', $season->air_date ?? '1970-12-1');
foreach($season->episodes as $episode) {
$releaseEpisode = Carbon::createFromFormat('Y-m-d', $episode->air_date ?? '1970-12-1');
$this->create([
'season_tmdb_id' => $season->id,
'episode_tmdb_id' => $episode->id,
'season_number' => $episode->season_number,
'episode_number' => $episode->episode_number,
'release_episode' => $releaseEpisode->getTimestamp(),
'release_season' => $releaseSeason->getTimestamp(),
'name' => $episode->name,
'tmdb_id' => $tmdbId,
]);
}
}
}
/**
* Accessors
*/

View File

@ -5,6 +5,7 @@ namespace App\Exceptions;
use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Symfony\Component\Debug\Exception\FatalErrorException;
class Handler extends ExceptionHandler
{
@ -40,10 +41,17 @@ class Handler extends ExceptionHandler
*
* @param \Illuminate\Http\Request $request
* @param \Exception $exception
* @return \Illuminate\Http\Response
*
* @return \Illuminate\Http\Response|string
*/
public function render($request, Exception $exception)
{
if($exception instanceof FatalErrorException) {
echo $exception->getMessage();
return false;
}
return parent::render($request, $exception);
}

View File

@ -5,6 +5,7 @@
use App\AlternativeTitle;
use App\Episode;
use App\Item;
use App\Services\Models\ItemService;
use App\Services\Storage;
use Carbon\Carbon;
use Illuminate\Support\Facades\Input;
@ -49,9 +50,10 @@
* Reset item table and restore backup.
* Downloads every poster image new.
*
* @param ItemService $itemService
* @return Response
*/
public function import()
public function import(ItemService $itemService)
{
increaseTimeLimit();
@ -65,6 +67,13 @@
$data = json_decode(file_get_contents($file));
$this->importItems($data, $itemService);
$this->importEpisodes($data);
$this->importAlternativeTitles($data);
}
private function importItems($data, ItemService $itemService)
{
if(isset($data->items)) {
$this->item->truncate();
foreach($data->items as $item) {
@ -73,18 +82,31 @@
$item->last_seen_at = Carbon::createFromTimestamp($item->created_at);
}
// For empty items (from file-parser) we don't need access to details.
if($item->tmdb_id) {
$item = $itemService->makeDataComplete((array) $item);
$this->storage->downloadPoster($item['poster']);
$this->storage->downloadBackdrop($item['backdrop']);
}
$this->item->create((array) $item);
$this->storage->downloadPoster($item->poster);
}
}
}
private function importEpisodes($data)
{
if(isset($data->episodes)) {
$this->episodes->truncate();
foreach($data->episodes as $episode) {
$this->episodes->create((array) $episode);
}
}
}
private function importAlternativeTitles($data)
{
if(isset($data->alternative_titles)) {
$this->alternativeTitles->truncate();
foreach($data->alternative_titles as $title) {

View File

@ -3,7 +3,6 @@
namespace App\Http\Controllers;
use App\Services\FileParser;
use App\Setting;
use GuzzleHttp\Exception\ConnectException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@ -27,12 +26,12 @@
public function call()
{
try {
$files = $this->parser->fetch();
$this->parser->fetch();
} catch(ConnectException $e) {
return response("Can't connect to file-parser. Make sure the server is running.", Response::HTTP_NOT_FOUND);
} catch(\Exception $e) {
return response("Error in file-parser:" . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
}
return $this->updateDatabase($files);
}
/**

View File

@ -0,0 +1,19 @@
<?php
namespace App\Http\Controllers;
use App\Services\IMDB;
use App\Services\Subpage;
class SubpageController {
public function item($tmdbId, $mediaType, Subpage $subpage)
{
return $subpage->item($tmdbId, $mediaType);
}
public function imdbRating($id, IMDB $imdb)
{
return $imdb->parseRating($id);
}
}

View File

@ -24,6 +24,13 @@
'last_seen_at',
'created_at',
'updated_at',
'overview',
'tmdb_rating',
'imdb_id',
'imdb_rating',
'backdrop',
'youtube_key',
'slug',
];
/**
@ -43,7 +50,14 @@
'rating' => 0,
'released' => $data['released'],
'genre' => $data['genre'],
'overview' => $data['overview'],
'backdrop' => $data['backdrop'],
'tmdb_rating' => $data['tmdb_rating'],
'imdb_id' => $data['imdb_id'],
'imdb_rating' => $data['imdb_rating'],
'youtube_key' => $data['youtube_key'],
'last_seen_at' => Carbon::now(),
'slug' => $data['slug'],
]);
}

View File

@ -10,7 +10,6 @@
use Carbon\Carbon;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class FileParser {
@ -167,7 +166,7 @@
{
$found = $this->searchTmdb($file);
// Remove the empty item, because we create a new empty or from TMDb.
// We will create a new empty or a real item from TMDb.
$this->itemService->remove($emptyItem->id);
if( ! $found) {

View File

@ -0,0 +1,27 @@
<?php
namespace App\Services;
use DiDom\Document;
class IMDB {
private $document;
public function __construct(Document $document)
{
$this->document = $document;
}
public function parseRating($id = null)
{
$document = $this->document->loadHtmlFile(config('services.imdb.url') . $id);
// We don't need to check if we found a result if we loop over them.
foreach($document->find('.ratingValue strong span') as $rating) {
return $rating->text();
}
return null;
}
}

View File

@ -33,10 +33,28 @@
*/
public function create($item)
{
// todo: rewrite this and make this more generic. we need to use this in refresh too, if a episode is not in database.
if($item->media_type == 'tv') {
$seasons = $this->tmdb->tvEpisodes($item->tmdb_id);
$this->model->store($seasons, $item->tmdb_id);
foreach($seasons as $season) {
$releaseSeason = Carbon::createFromFormat('Y-m-d', $season->air_date ?? '1970-12-1');
foreach($season->episodes as $episode) {
$releaseEpisode = Carbon::createFromFormat('Y-m-d', $episode->air_date ?? '1970-12-1');
$this->model->create([
'season_tmdb_id' => $season->id,
'episode_tmdb_id' => $episode->id,
'season_number' => $episode->season_number,
'episode_number' => $episode->episode_number,
'release_episode' => $releaseEpisode->getTimestamp(),
'release_season' => $releaseSeason->getTimestamp(),
'name' => $episode->name,
'tmdb_id' => $item->tmdb_id,
]);
}
}
}
}

View File

@ -4,6 +4,7 @@
use App\Item as Model;
use App\Item;
use App\Services\IMDB;
use App\Services\Storage;
use App\Services\TMDB;
@ -14,6 +15,7 @@
private $storage;
private $alternativeTitleService;
private $episodeService;
private $imdb;
/**
* @param Model $model
@ -21,19 +23,22 @@
* @param Storage $storage
* @param AlternativeTitleService $alternativeTitleService
* @param EpisodeService $episodeService
* @param IMDB $imdb
*/
public function __construct(
Model $model,
TMDB $tmdb,
Storage $storage,
AlternativeTitleService $alternativeTitleService,
EpisodeService $episodeService
EpisodeService $episodeService,
IMDB $imdb
){
$this->model = $model;
$this->tmdb = $tmdb;
$this->storage = $storage;
$this->alternativeTitleService = $alternativeTitleService;
$this->episodeService = $episodeService;
$this->imdb = $imdb;
}
/**
@ -42,17 +47,99 @@
*/
public function create($data)
{
$data = $this->makeDataComplete($data);
$item = $this->model->store($data);
$this->storage->downloadPoster($item->poster);
$this->episodeService->create($item);
$this->alternativeTitleService->create($item);
$this->storage->downloadPoster($item->poster);
$this->storage->downloadBackdrop($item->backdrop);
return $item;
}
/**
* Search against TMDb and IMDb for more informations.
* We don't need to get more informations if we add the item from the subpage.
*
* @param $data
* @return array
*/
public function makeDataComplete($data)
{
if( ! isset($data['imdb_id'])) {
$details = $this->tmdb->details($data['tmdb_id'], $data['media_type']);
$title = $details->name ?? $details->title;
$data['imdb_id'] = $data['imdb_id'] ?? $this->parseImdbId($details);
$data['youtube_key'] = $data['youtube_key'] ?? $this->parseYoutubeKey($details, $data['media_type']);
$data['overview'] = $data['overview'] ?? $details->overview;
$data['tmdb_rating'] = $data['tmdb_rating'] ?? $details->vote_average;
$data['backdrop'] = $data['backdrop'] ?? $details->backdrop_path;
$data['slug'] = $data['slug'] ?? (str_slug($title) != '' ? str_slug($title) : 'no-slug-available');
}
$data['imdb_rating'] = $this->parseImdbRating($data);
return $data;
}
/**
* If the user clicks to fast on adding item,
* we need to re-fetch the rating from IMDb.
*
* @param $data
*
* @return float|null
*/
private function parseImdbRating($data)
{
if( ! isset($data['imdb_rating'])) {
$imdbId = $data['imdb_id'];
if($imdbId) {
return $this->imdb->parseRating($imdbId);
}
return null;
}
// Otherwise we already have the rating saved.
return $data['imdb_rating'];
}
/**
* TV shows needs an extra append for external ids.
*
* @param $details
* @return mixed
*/
public function parseImdbId($details)
{
return $details->external_ids->imdb_id ?? ($details->imdb_id ?? null);
}
/**
* Get the key for the youtube trailer video. Fallback with english trailer.
*
* @param $details
* @param $mediaType
* @return string|null
*/
public function parseYoutubeKey($details, $mediaType)
{
if(isset($details->videos->results[0])) {
return $details->videos->results[0]->key;
}
// Try to fetch details again with english language as fallback.
$videos = $this->tmdb->videos($details->id, $mediaType, 'en');
return $videos->results[0]->key ?? null;
}
/**
* @param $data
* @param $mediaType
@ -60,7 +147,7 @@
*/
public function createEmpty($data, $mediaType)
{
$mediaType = $mediaType == 'movies' ? 'movie' : 'tv';
$mediaType = mediaType($mediaType);
$data = [
'name' => getFileName($data),
@ -90,10 +177,11 @@
$item->delete();
// Delete all related episodes, alternative titles and poster image.
// Delete all related episodes, alternative titles and images.
$this->episodeService->remove($tmdbId);
$this->alternativeTitleService->remove($tmdbId);
$this->storage->removePosterFile($item->poster);
$this->storage->removePoster($item->poster);
$this->storage->removeBackdrop($item->backdrop);
}
/**
@ -180,7 +268,7 @@
public function findBy($type, $value, $mediaType = null)
{
if($mediaType) {
$mediaType = $mediaType == 'movies' ? 'movie' : 'tv';
$mediaType = mediaType($mediaType);
}
switch($type) {

View File

@ -28,25 +28,49 @@
}
/**
* Download the poster image file.
* Download the poster image files.
*
* @param $poster
*/
public function downloadPoster($poster)
{
if($poster) {
LaravelStorage::put($poster, file_get_contents('http://image.tmdb.org/t/p/w185' . $poster));
LaravelStorage::put($poster, file_get_contents(config('services.tmdb.poster') . $poster));
LaravelStorage::disk('subpage')->put($poster, file_get_contents(config('services.tmdb.poster_subpage') . $poster));
}
}
/**
* Delete the poster image file.
* Download the backdrop image file.
*
* @param $backdrop
*/
public function downloadBackdrop($backdrop)
{
if($backdrop) {
LaravelStorage::disk('backdrop')->put($backdrop, file_get_contents(config('services.tmdb.backdrop') . $backdrop));
}
}
/**
* Delete the poster image files.
*
* @param $poster
*/
public function removePosterFile($poster)
public function removePoster($poster)
{
LaravelStorage::delete($poster);
LaravelStorage::disk('subpage')->delete($poster);
}
/**
* Delete the backdrop image.
*
* @param $backdrop
*/
public function removeBackdrop($backdrop)
{
LaravelStorage::disk('backdrop')->delete($backdrop);
}
/**

View File

@ -0,0 +1,33 @@
<?php
namespace App\Services;
use App\Services\Models\ItemService;
class Subpage {
private $itemService;
private $tmdb;
public function __construct(ItemService $itemService, TMDB $tmdb)
{
$this->itemService = $itemService;
$this->tmdb = $tmdb;
}
public function item($tmdbId, $mediaType)
{
if($found = $this->itemService->findBy('tmdb_id', $tmdbId)) {
return $found;
}
$found = $this->tmdb->details($tmdbId, $mediaType);
$found->genre_ids = collect($found->genres)->pluck('id')->all();
$item = $this->tmdb->createItem($found, $mediaType);
$item['youtube_key'] = $this->itemService->parseYoutubeKey($found, $mediaType);
$item['imdb_id'] = $this->itemService->parseImdbId($found);
return $item;
}
}

View File

@ -51,7 +51,7 @@
$movies = collect($this->createItems($response, 'movie'));
}
return $tv->merge($movies)->toArray();
return $movies->merge($tv)->sortByDesc('popularity')->values()->all();
}
private function fetchSearch($title, $mediaType) {
@ -172,30 +172,43 @@
$response = json_decode($response->getBody());
foreach($response->results as $result) {
$release = Carbon::createFromFormat('Y-m-d', isset($result->release_date) ? ($result->release_date ?: '1970-12-1') : ($result->first_air_date ?: '1970-12-1'));
// 'name' is from tv, 'title' from movies
$items[] = [
'tmdb_id' => $result->id,
'title' => $result->name ?? $result->title,
'original_title' => $result->original_name ?? $result->original_title,
'poster' => $result->poster_path,
'media_type' => $mediaType,
'released' => $release->getTimestamp(),
'genre' => $this->parseGenre($result->genre_ids),
'episodes' => [],
];
$items[] = $this->createItem($result, $mediaType);
}
return $items;
}
public function createItem($data, $mediaType)
{
$release = Carbon::createFromFormat('Y-m-d',
isset($data->release_date) ? ($data->release_date ?: '1970-12-1') : ($data->first_air_date ?: '1970-12-1')
);
$title = $data->name ?? $data->title;
return [
'tmdb_id' => $data->id,
'title' => $title,
'slug' => str_slug($title) != '' ? str_slug($title) : 'no-slug-available',
'original_title' => $data->original_name ?? $data->original_title,
'poster' => $data->poster_path,
'media_type' => $mediaType,
'released' => $release->getTimestamp(),
'genre' => $this->parseGenre($data->genre_ids),
'episodes' => [],
'overview' => $data->overview,
'backdrop' => $data->backdrop_path,
'tmdb_rating' => $data->vote_average,
'popularity' => $data->popularity,
];
}
private function requestTmdb($url, $query = [])
{
$query = array_merge($query, [
$query = array_merge([
'api_key' => $this->apiKey,
'language' => strtolower($this->translation)
]);
], $query);
try {
$response = $this->client->get($url, [
@ -219,7 +232,7 @@
}
/**
* Get full movie or tv details.
* Get full movie or tv details with trailers.
*
* @param $tmdbId
* @param $mediaType
@ -227,7 +240,18 @@
*/
public function details($tmdbId, $mediaType)
{
$response = $this->requestTmdb($this->base . '/3/' . $mediaType . '/' . $tmdbId);
$response = $this->requestTmdb($this->base . '/3/' . $mediaType . '/' . $tmdbId, [
'append_to_response' => 'videos,external_ids',
]);
return json_decode($response->getBody());
}
public function videos($tmdbId, $mediaType, $translation = null)
{
$response = $this->requestTmdb($this->base . '/3/' . $mediaType . '/' . $tmdbId . '/videos', [
'language' => $translation ?? $this->translation,
]);
return json_decode($response->getBody());
}

View File

@ -1,13 +1,16 @@
<?php
const FIVE_MINUTES = 300;
function increaseTimeLimit()
{
set_time_limit(FIVE_MINUTES);
set_time_limit(config('app.PHP_TIME_LIMIT'));
}
function getFileName($file)
{
return $file->changed->name ?? $file->name;
}
function mediaType($mediaType)
{
return $mediaType == 'movies' ? 'movie' : 'tv';
}

View File

@ -10,7 +10,8 @@
"laravel/scout": "^1.1",
"guzzlehttp/guzzle": "^6.2",
"algolia/algoliasearch-client-php": "^1.10",
"doctrine/dbal": "^2.5"
"doctrine/dbal": "^2.5",
"imangazaliev/didom": "^1.9"
},
"require-dev": {
"mockery/mockery": "^0.9.7",
@ -32,7 +33,8 @@
},
"autoload-dev": {
"classmap": [
"tests/TestCase.php"
"tests/TestCase.php",
"tests/Traits"
]
},
"scripts": {

50
backend/composer.lock generated
View File

@ -4,8 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically"
],
"hash": "2f6f2e24c6494cfce80b24e0388f96a0",
"content-hash": "4bb9c55b817be4946797aade8d2df60b",
"hash": "294c93796fb9df3f91863c5c977db9af",
"content-hash": "b6ea193eec158865d05abcdd90db9510",
"packages": [
{
"name": "algolia/algoliasearch-client-php",
@ -786,6 +786,52 @@
],
"time": "2016-06-24 23:00:38"
},
{
"name": "imangazaliev/didom",
"version": "1.9.1",
"source": {
"type": "git",
"url": "https://github.com/Imangazaliev/DiDOM.git",
"reference": "0116a51428b6c34bcdd5d32b8674576154701196"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Imangazaliev/DiDOM/zipball/0116a51428b6c34bcdd5d32b8674576154701196",
"reference": "0116a51428b6c34bcdd5d32b8674576154701196",
"shasum": ""
},
"require": {
"php": ">=5.4"
},
"require-dev": {
"phpunit/phpunit": "^4.8"
},
"type": "library",
"autoload": {
"psr-4": {
"DiDom\\": "src/DiDom/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Imangazaliev Muhammad",
"email": "imangazalievm@gmail.com"
}
],
"description": "Simple and fast HTML parser",
"homepage": "https://github.com/Imangazaliev/DiDOM",
"keywords": [
"didom",
"html",
"parser",
"xml"
],
"time": "2017-02-02 05:35:44"
},
{
"name": "jakub-onderka/php-console-color",
"version": "0.1",

View File

@ -8,6 +8,7 @@
'TRANSLATION' => env('TRANSLATION', 'EN'),
'LOADING_ITEMS' => env('LOADING_ITEMS'),
'CLIENT_URI' => env('CLIENT_URI'),
'PHP_TIME_LIMIT' => env('PHP_TIME_LIMIT'),
/*
|--------------------------------------------------------------------------

View File

@ -48,6 +48,16 @@
'root' => base_path('../public/assets/poster'),
],
'subpage' => [
'driver' => 'local',
'root' => base_path('../public/assets/poster/subpage'),
],
'backdrop' => [
'driver' => 'local',
'root' => base_path('../public/assets/backdrop'),
],
'export' => [
'driver' => 'local',
'root' => base_path('../public/exports'),

View File

@ -37,6 +37,13 @@ return [
'tmdb' => [
'key' => env('TMDB_API_KEY'),
'poster' => 'http://image.tmdb.org/t/p/w185',
'poster_subpage' => 'http://image.tmdb.org/t/p/w342',
'backdrop' => 'http://image.tmdb.org/t/p/w1280',
],
'imdb' => [
'url' => 'http://www.imdb.com/title/',
],
'fp' => [

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class AddSubpageFieldsToItemsTable extends Migration
{
public function up()
{
Schema::table('items', function (Blueprint $table) {
$table->string('backdrop')->nullable();
$table->string('slug')->nullable();
$table->string('youtube_key')->nullable();
$table->string('imdb_id')->nullable();
$table->text('overview')->nullable();
$table->string('tmdb_rating')->nullable();
$table->string('imdb_rating')->nullable();
});
}
public function down()
{
Schema::table('items', function (Blueprint $table) {
//
});
}
}

View File

@ -8,6 +8,9 @@
Route::get('/items/{type}/{orderBy}', 'ItemController@items');
Route::get('/search-items', 'ItemController@search');
Route::get('/item/{tmdbId}/{mediaType}', 'SubpageController@item');
Route::get('/imdb-rating/{imdbId}', 'SubpageController@imdbRating');
Route::get('/suggestions/{tmdbID}/{mediaType}', 'TMDBController@suggestions');
Route::get('/trending', 'TMDBController@trending');
Route::get('/upcoming', 'TMDBController@upcoming');

View File

@ -2,15 +2,14 @@
use App\AlternativeTitle;
use App\Services\Models\AlternativeTitleService;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class AlternativeTitleServiceTest extends TestCase {
use DatabaseMigrations;
use Factories;
use Fixtures;
use Mocks;
private $alternativeTitles;
@ -127,14 +126,4 @@
$this->assertNotNull($titles1);
$this->assertCount(0, $titles2);
}
private function createGuzzleMock($fixture)
{
$mock = new MockHandler([
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture),
]);
$handler = HandlerStack::create($mock);
$this->app->instance(Client::class, new Client(['handler' => $handler]));
}
}

View File

@ -3,13 +3,14 @@
use App\Episode;
use App\Item;
use App\Services\Models\EpisodeService;
use App\Services\Models\ItemService;
use App\Services\TMDB;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class EpisodeServiceTest extends TestCase {
use DatabaseMigrations;
use Factories;
use Fixtures;
use Mocks;
private $episode;
private $episodeService;
@ -29,7 +30,7 @@
{
$tv = $this->getTv();
$this->createTmdbMock();
$this->createTmdbEpisodeMock();
$episodeService = app(EpisodeService::class);
$episodes1 = $this->episode->all();
@ -146,13 +147,4 @@
$this->assertEquals($itemUpdated->last_seen_at, $item->last_seen_at);
}
private function createTmdbMock()
{
// Mock this to avoid unknown requests to TMDb (get seasons and then get episodes for each season)
$mock = Mockery::mock(app(TMDB::class))->makePartial();
$mock->shouldReceive('tvEpisodes')->andReturn(json_decode($this->tmdbFixtures('tv/episodes')));
$this->app->instance(TMDB::class, $mock);
}
}

View File

@ -2,18 +2,16 @@
use App\Episode;
use App\Item;
use App\Services\TMDB;
use App\Setting;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use App\Services\FileParser;
class FileParserTest extends TestCase {
use DatabaseMigrations;
use Factories;
use Fixtures;
use Mocks;
private $item;
private $parser;
@ -26,6 +24,9 @@
$this->item = app(Item::class);
$this->episode = app(Episode::class);
$this->parser = app(FileParser::class);
$this->createStorageDownloadsMock();
$this->createImdbRatingMock();
}
/** @test */
@ -34,7 +35,7 @@
$items = $this->item->get();
$setting = Setting::first()->last_fetch_to_file_parser;
$this->createTmdbMock($this->tmdbFixtures('movie/movie'), $this->tmdbFixtures('movie/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('movie/movie'), $this->tmdbFixtures('movie/alternative_titles'));
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('movie/unknown'));
@ -53,7 +54,7 @@
$episodes = $this->episode->get();
$setting = Setting::first()->last_fetch_to_file_parser;
$this->createTmdbMock($this->tmdbFixtures('tv/tv'), $this->tmdbFixtures('tv/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('tv/tv'), $this->tmdbFixtures('tv/alternative_titles'));
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('tv/unknown'));
@ -106,7 +107,12 @@
{
$items = $this->item->get();
$this->createTmdbMock($this->tmdbFixtures('movie/movie'), $this->tmdbFixtures('movie/alternative_titles'));
$this->createGuzzleMock(
$this->tmdbFixtures('movie/movie'),
$this->tmdbFixtures('movie/details'),
$this->tmdbFixtures('movie/alternative_titles')
);
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('movie/added'));
@ -126,7 +132,13 @@
$items = $this->item->get();
$episodes1 = $this->episode->get();
$this->createTmdbMock($this->tmdbFixtures('tv/tv'), $this->tmdbFixtures('tv/alternative_titles'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/tv'),
$this->tmdbFixtures('tv/details'),
$this->tmdbFixtures('tv/alternative_titles')
);
$this->createTmdbEpisodeMock();
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('tv/added'));
@ -150,7 +162,7 @@
{
$this->createMovie();
$this->createTmdbMock($this->tmdbFixtures('movie/movie'), $this->tmdbFixtures('movie/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('movie/movie'), $this->tmdbFixtures('movie/alternative_titles'));
$parser = app(FileParser::class);
$setting1 = Setting::first();
@ -316,7 +328,12 @@
$empty = $this->item->first();
$this->createTmdbMock($this->tmdbFixtures('movie/movie'), $this->tmdbFixtures('movie/alternative_titles'));
$this->createGuzzleMock(
$this->tmdbFixtures('movie/movie'),
$this->tmdbFixtures('movie/details'),
$this->tmdbFixtures('movie/alternative_titles')
);
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('movie/updated_found'));
@ -337,7 +354,13 @@
$empty = $this->item->first();
$this->createTmdbMock($this->tmdbFixtures('tv/tv'), $this->tmdbFixtures('tv/alternative_titles'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/tv'),
$this->tmdbFixtures('tv/details'),
$this->tmdbFixtures('tv/alternative_titles')
);
$this->createTmdbEpisodeMock();
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('tv/updated_found'));
@ -359,7 +382,7 @@
/** @test */
public function it_should_create_empty_movie_from_updated_if_not_found_in_tmdb()
{
$this->createTmdbMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('movie/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('movie/alternative_titles'));
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('movie/updated_not_found'));
@ -373,7 +396,7 @@
/** @test */
public function it_should_create_empty_tv_without_episodes_from_updated_if_not_found_in_tmdb()
{
$this->createTmdbMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('tv/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('tv/alternative_titles'));
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('tv/updated_not_found'));
@ -388,7 +411,7 @@
/** @test */
public function it_should_create_empty_movie_from_added_if_not_found_in_tmdb()
{
$this->createTmdbMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('movie/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('movie/alternative_titles'));
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('movie/added_not_found'));
@ -401,7 +424,7 @@
/** @test */
public function it_should_create_empty_tv_without_episodes_from_added_if_not_found_in_tmdb()
{
$this->createTmdbMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('tv/alternative_titles'));
$this->createGuzzleMock($this->tmdbFixtures('empty'), $this->tmdbFixtures('tv/alternative_titles'));
$parser = app(FileParser::class);
$parser->updateDatabase($this->fpFixtures('tv/added_not_found'));
@ -433,7 +456,13 @@
$settings->save();
$this->assertEquals($timestamp, Setting::first()->last_fetch_to_file_parser->timestamp);
$this->createTmdbMock($this->tmdbFixtures('tv/tv'), $this->tmdbFixtures('tv/alternative_titles'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/tv'),
$this->tmdbFixtures('tv/details'),
$this->tmdbFixtures('tv/alternative_titles')
);
$this->createTmdbEpisodeMock();
$fixture = json_encode($this->fpFixtures("tv/added"));
$this->call('PATCH', '/api/update-files', [], [], [], $this->http_login(), $fixture);
@ -482,21 +511,4 @@
'PHP_AUTH_PW' => 'snow',
];
}
private function createTmdbMock($fixture, $fixture2)
{
$mock = new MockHandler([
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture),
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture2),
]);
$handler = HandlerStack::create($mock);
$this->app->instance(Client::class, new Client(['handler' => $handler]));
// Mock this to avoid unknown requests to TMDb (get seasons and then get episodes for each season)
$mock = Mockery::mock(app(TMDB::class))->makePartial();
$mock->shouldReceive('tvEpisodes')->andReturn(json_decode($this->tmdbFixtures('tv/episodes')));
$this->app->instance(TMDB::class, $mock);
}
}

View File

@ -0,0 +1,33 @@
<?php
use App\Services\IMDB;
use Illuminate\Foundation\Testing\DatabaseMigrations;
class IMDBTest extends TestCase {
use DatabaseMigrations;
/** @test */
public function it_should_parse_imdb_rating()
{
config(['services.imdb.url' => __DIR__ . '/../fixtures/imdb/with-rating.html']);
$imdbService = app(IMDB::class);
$rating = $imdbService->parseRating();
$this->assertEquals('7,0', $rating);
}
/** @test */
public function it_should_return_null_if_no_rating_was_found()
{
config(['services.imdb.url' => __DIR__ . '/../fixtures/imdb/without-rating.html']);
$imdbService = app(IMDB::class);
$rating = $imdbService->parseRating();
$this->assertNull($rating);
}
}

View File

@ -1,19 +1,15 @@
<?php
use App\Item;
use App\Services\Models\AlternativeTitleService;
use App\Services\Models\EpisodeService;
use App\Services\Models\ItemService;
use App\Services\Storage;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
class ItemServiceTest extends TestCase {
use DatabaseMigrations;
use Factories;
use Fixtures;
use Mocks;
private $item;
private $itemService;
@ -24,14 +20,17 @@
$this->item = app(Item::class);
$this->itemService = app(ItemService::class);
$this->createStorageDownloadsMock();
}
/** @test */
public function it_should_create_a_new_movie()
{
$this->createEpisodeMock();
$this->createStorageMock();
$this->createAlternativeTitleMock();
$this->createGuzzleMock(
$this->tmdbFixtures('movie/details'),
$this->tmdbFixtures('movie/alternative_titles')
);
$itemService = app(ItemService::class);
@ -49,9 +48,12 @@
/** @test */
public function it_should_create_a_new_tv_show()
{
$this->createEpisodeMock();
$this->createStorageMock();
$this->createAlternativeTitleMock();
$this->createGuzzleMock(
$this->tmdbFixtures('tv/details'),
$this->tmdbFixtures('tv/alternative_titles')
);
$this->createTmdbEpisodeMock();
$itemService = app(ItemService::class);
@ -119,7 +121,7 @@
/** @test */
public function it_should_update_genre_for_a_movie()
{
$user = factory(App\User::class)->create();
$user = $this->createUser();
$this->createMovie();
$this->createGuzzleMock($this->tmdbFixtures('movie/details'));
@ -135,7 +137,7 @@
/** @test */
public function it_should_update_genre_for_a_tv_show()
{
$user = factory(App\User::class)->create();
$user = $this->createUser();
$this->createTv();
$this->createGuzzleMock($this->tmdbFixtures('tv/details'));
@ -147,41 +149,42 @@
$this->assertEmpty($withoutGenre->genre);
$this->assertNotEmpty($withGenre->genre);
}
private function createGuzzleMock($fixture)
/** @test */
public function it_should_parse_correct_imdb_id()
{
$mock = new MockHandler([
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture),
]);
$idMovie = $this->itemService->parseImdbId(json_decode($this->tmdbFixtures('movie/details')));
$idTv = $this->itemService->parseImdbId(json_decode($this->tmdbFixtures('tv/details')));
$handler = HandlerStack::create($mock);
$this->app->instance(Client::class, new Client(['handler' => $handler]));
$this->assertEquals('tt0803096', $idMovie);
$this->assertEquals('tt0944947', $idTv);
}
private function mock($class)
/** @test */
public function it_should_parse_correct_youtube_key()
{
$mock = Mockery::mock($class)->makePartial();
$this->createGuzzleMock(
$this->tmdbFixtures('videos'),
$this->tmdbFixtures('videos')
);
$this->app->instance($class, $mock);
$itemService = app(ItemService::class);
return $mock;
}
$fixtureMovie = json_decode($this->tmdbFixtures('movie/details'));
$fixtureTv = json_decode($this->tmdbFixtures('tv/details'));
private function createStorageMock()
{
$storageMock = $this->mock(Storage::class);
$storageMock->shouldReceive('downloadPoster')->once()->andReturn(true);
}
$foundInDetailsMovie = $itemService->parseYoutubeKey($fixtureMovie, 'movie');
$foundInDetailsTv = $itemService->parseYoutubeKey($fixtureTv, 'tv');
private function createEpisodeMock()
{
$episodeMock = $this->mock(EpisodeService::class);
$episodeMock->shouldReceive('create')->once()->andReturn(true);
}
$fixtureMovie->videos->results = null;
$fixtureTv->videos->results = null;
private function createAlternativeTitleMock()
{
$alternativeTitleMock = $this->mock(AlternativeTitleService::class);
$alternativeTitleMock->shouldReceive('create')->once()->andReturn(true);
$fallBackMovie = $itemService->parseYoutubeKey($fixtureMovie, 'movie');
$fallBackTv = $itemService->parseYoutubeKey($fixtureMovie, 'tv');
$this->assertEquals('2Rxoz13Bthc', $foundInDetailsMovie);
$this->assertEquals('BpJYNVhGf1s', $foundInDetailsTv);
$this->assertEquals('qnIhJwhBeqY', $fallBackMovie);
$this->assertEquals('qnIhJwhBeqY', $fallBackTv);
}
}

View File

@ -10,11 +10,17 @@
class TMDBTest extends TestCase {
use DatabaseMigrations;
use Factories;
use Fixtures;
use Mocks;
/** @test */
public function it_should_search_and_merge_movies_and_tv()
{
$this->createGuzzleMock($this->tmdbFixtures('tv/search'), $this->tmdbFixtures('movie/search'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/search'),
$this->tmdbFixtures('movie/search')
);
$result = $this->callSearch();
@ -28,7 +34,10 @@
/** @test */
public function it_should_only_search_for_tv()
{
$this->createGuzzleMock($this->tmdbFixtures('tv/search'), $this->tmdbFixtures('movie/search'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/search'),
$this->tmdbFixtures('movie/search')
);
$result = $this->callSearch('tv');
@ -42,7 +51,10 @@
/** @test */
public function it_should_only_search_for_movies()
{
$this->createGuzzleMock($this->tmdbFixtures('movie/search'), $this->tmdbFixtures('tv/search'));
$this->createGuzzleMock(
$this->tmdbFixtures('movie/search'),
$this->tmdbFixtures('tv/search')
);
$result = $this->callSearch('movies');
@ -56,7 +68,10 @@
/** @test */
public function it_should_fetch_and_merge_movies_and_tv_in_trending()
{
$this->createGuzzleMock($this->tmdbFixtures('tv/trending'), $this->tmdbFixtures('movie/trending'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/trending'),
$this->tmdbFixtures('movie/trending')
);
$tmdb = app(TMDB::class);
$trending = $tmdb->trending();
@ -71,7 +86,10 @@
/** @test */
public function it_should_merge_database_items_in_trending()
{
$this->createGuzzleMock($this->tmdbFixtures('tv/trending'), $this->tmdbFixtures('movie/trending'));
$this->createGuzzleMock(
$this->tmdbFixtures('tv/trending'),
$this->tmdbFixtures('movie/trending')
);
$this->createMovie();
$this->createTv();
@ -87,7 +105,10 @@
/** @test */
public function it_should_merge_database_movie_in_upcoming()
{
$this->createGuzzleMock($this->tmdbFixtures('movie/upcoming'), $this->tmdbFixtures('movie/upcoming'));
$this->createGuzzleMock(
$this->tmdbFixtures('movie/upcoming'),
$this->tmdbFixtures('movie/upcoming')
);
$this->createMovie();
@ -101,11 +122,9 @@
/** @test */
public function it_should_respect_request_limit()
{
$fixture = $this->tmdbFixtures('multi');
$mock = new MockHandler([
new Response(429, []),
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture),
new Response(200, ['X-RateLimit-Remaining' => [40]], $this->tmdbFixtures('multi')),
]);
$handler = HandlerStack::create($mock);
@ -128,15 +147,4 @@
private function in_array_r($item , $array){
return (bool) preg_match('/"' . $item . '"/i' , json_encode($array));
}
private function createGuzzleMock($fixture, $fixture2)
{
$mock = new MockHandler([
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture),
new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture2),
]);
$handler = HandlerStack::create($mock);
$this->app->instance(Client::class, new Client(['handler' => $handler]));
}
}

View File

@ -10,6 +10,7 @@
class ExportImportTest extends TestCase {
use DatabaseMigrations;
use Factories;
protected $user;
@ -17,7 +18,7 @@
{
parent::setUp();
$this->user = factory(App\User::class)->create();
$this->user = $this->createUser();
}
/** @test */
@ -54,9 +55,9 @@
{
$this->callImport('export.json');
$this->assertCount(5, Item::all());
$this->assertCount(143, Episode::all());
$this->assertCount(25, AlternativeTitle::all());
$this->assertCount(4, Item::all());
$this->assertCount(10, Episode::all());
$this->assertCount(38, AlternativeTitle::all());
}
/** @test */

View File

@ -6,6 +6,7 @@
class SettingTest extends TestCase {
use DatabaseMigrations;
use Factories;
protected $user;
@ -13,7 +14,7 @@
{
parent::setUp();
$this->user = factory(App\User::class)->create();
$this->user = $this->createUser();
}
/** @test */

View File

@ -12,90 +12,4 @@
return $app;
}
protected function fpFixtures($type)
{
return json_decode(file_get_contents(__DIR__ . '/fixtures/fp/' . $type . '.json'));
}
protected function tmdbFixtures($type)
{
return file_get_contents(__DIR__ . '/fixtures/tmdb/' . $type . '.json');
}
protected function floxFixtures($type)
{
return collect(json_decode(file_get_contents(__DIR__ . '/fixtures/flox/' . $type . '.json')))->toArray();
}
protected function createSetting()
{
factory(App\Setting::class)->create();
}
protected function createMovie($custom = [])
{
$data = [
'title' => 'Warcraft: The Beginning',
'original_title' => 'Warcraft',
'tmdb_id' => 68735,
'media_type' => 'movie',
];
factory(App\Item::class)->create(array_merge($data, $custom));
}
protected function createTv($custom = [], $withEpisodes = true)
{
$data = [
'title' => 'Game of Thrones',
'original_title' => 'Game of Thrones',
'tmdb_id' => 1399,
'media_type' => 'tv',
];
factory(App\Item::class)->create(array_merge($data, $custom));
if($withEpisodes) {
foreach([1, 2] as $season) {
foreach([1, 2] as $episode) {
factory(App\Episode::class)->create([
'tmdb_id' => 1399,
'season_number' => $season,
'episode_number' => $episode,
]);
}
}
}
}
protected function getMovie($custom = [])
{
$data = [
'title' => 'Warcraft',
'tmdb_id' => 68735,
];
return factory(App\Item::class)->states('movie')->make(array_merge($data, $custom));
}
protected function getTv($custom = [])
{
$data = [
'title' => 'Game of Thrones',
'tmdb_id' => 1399,
];
return factory(App\Item::class)->states('tv')->make(array_merge($data, $custom));
}
protected function getMovieSrc()
{
return '/movies/Warcraft.2016.720p.WEB-DL/Warcraft.2016.720p.WEB-DL.mkv';
}
protected function getTvSrc()
{
return '/tv/Game of Thrones/S1/1.mkv';
}
}

View File

@ -0,0 +1,70 @@
<?php
trait Factories {
public function createUser()
{
return factory(App\User::class)->create();
}
public function createSetting()
{
return factory(App\Setting::class)->create();
}
public function createMovie($custom = [])
{
$data = [
'title' => 'Warcraft: The Beginning',
'original_title' => 'Warcraft',
'tmdb_id' => 68735,
'media_type' => 'movie',
];
return factory(App\Item::class)->create(array_merge($data, $custom));
}
public function createTv($custom = [], $withEpisodes = true)
{
$data = [
'title' => 'Game of Thrones',
'original_title' => 'Game of Thrones',
'tmdb_id' => 1399,
'media_type' => 'tv',
];
factory(App\Item::class)->create(array_merge($data, $custom));
if($withEpisodes) {
foreach([1, 2] as $season) {
foreach([1, 2] as $episode) {
factory(App\Episode::class)->create([
'tmdb_id' => 1399,
'season_number' => $season,
'episode_number' => $episode,
]);
}
}
}
}
public function getMovie($custom = [])
{
$data = [
'title' => 'Warcraft',
'tmdb_id' => 68735,
];
return factory(App\Item::class)->states('movie')->make(array_merge($data, $custom));
}
public function getTv($custom = [])
{
$data = [
'title' => 'Game of Thrones',
'tmdb_id' => 1399,
];
return factory(App\Item::class)->states('tv')->make(array_merge($data, $custom));
}
}

View File

@ -0,0 +1,34 @@
<?php
trait Fixtures {
protected function fpFixtures($type)
{
return json_decode(file_get_contents(__DIR__ . '/../fixtures/fp/' . $type . '.json'));
}
protected function tmdbFixtures($type)
{
return file_get_contents(__DIR__ . '/../fixtures/tmdb/' . $type . '.json');
}
protected function imdbFixtures($type)
{
return file_get_contents(__DIR__ . '/../fixtures/imdb/' . $type);
}
protected function floxFixtures($type)
{
return collect(json_decode(file_get_contents(__DIR__ . '/../fixtures/flox/' . $type . '.json')))->toArray();
}
protected function getMovieSrc()
{
return '/movies/Warcraft.2016.720p.WEB-DL/Warcraft.2016.720p.WEB-DL.mkv';
}
protected function getTvSrc()
{
return '/tv/Game of Thrones/S1/1.mkv';
}
}

View File

@ -0,0 +1,55 @@
<?php
use App\Services\IMDB;
use App\Services\Storage;
use App\Services\TMDB;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
trait Mocks {
public function createGuzzleMock()
{
$fixtures = func_get_args();
$responses = [];
foreach($fixtures as $fixture) {
$responses[] = new Response(200, ['X-RateLimit-Remaining' => [40]], $fixture);
}
$mock = new MockHandler($responses);
$handler = HandlerStack::create($mock);
$this->app->instance(Client::class, new Client(['handler' => $handler]));
}
public function createStorageDownloadsMock()
{
$mock = $this->mock(Storage::class);
$mock->shouldReceive('downloadPoster', 'downloadBackdrop')->andReturn(null, null);
}
public function createTmdbEpisodeMock()
{
// Mock this to avoid unknown requests to TMDb (get seasons and then get episodes for each season)
$mock = $this->mock(TMDB::class);
$mock->shouldReceive('tvEpisodes')->andReturn(json_decode($this->tmdbFixtures('tv/episodes')));
}
private function createImdbRatingMock()
{
$mock = $this->mock(IMDB::class);
$mock->shouldReceive('parseRating')->andReturn(json_decode($this->imdbFixtures('rating.txt')));
}
public function mock($class)
{
$mock = Mockery::mock(app($class))->makePartial();
$this->app->instance($class, $mock);
return $mock;
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,41 +1,5 @@
{
"tv": [
{
"name": "Game of Thrones",
"season_number": 2,
"episode_number": 1,
"status": "added",
"extension": "mkv",
"tags": [],
"year": null,
"filename": "1",
"subtitles": "src",
"src": "/tv/Game of Thrones/S1/1.mkv"
},
{
"name": "Game of Thrones",
"season_number": 2,
"episode_number": 2,
"tags": [],
"status": "added",
"extension": "mkv",
"year": null,
"filename": "2",
"subtitles": "src",
"src": "/tv/Game of Thrones/S1/1.mkv"
},
{
"name": "Game of Thrones",
"season_number": 1,
"episode_number": 1,
"extension": "mkv",
"status": "added",
"filename": "1",
"tags": [],
"subtitles": "src",
"year": null,
"src": "/tv/Game of Thrones/s1/1.mkv"
},
{
"name": "Game of Thrones",
"season_number": 1,

View File

@ -0,0 +1 @@
5.1

View File

@ -0,0 +1,17 @@
<div class="title_block">
<div class="title_bar_wrapper">
<div class="ratings_wrapper">
<div class="imdbRating">
<div class="ratingValue">
<strong title="7,9 based on 466.506 user ratings">
<span itemprop="ratingValue">7,0</span>
</strong>
<span class="grey">/</span>
<span class="grey" itemprop="bestRating">10</span>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,5 @@
<div class="title_block">
<div class="title_bar_wrapper">
</div>
</div>

View File

@ -4,6 +4,10 @@
"belongs_to_collection": null,
"budget": 160000000,
"genres": [
{
"id": 28,
"name": "Action"
},
{
"id": 12,
"name": "Adventure"
@ -11,10 +15,6 @@
{
"id": 14,
"name": "Fantasy"
},
{
"id": 28,
"name": "Action"
}
],
"homepage": "http://www.warcraft-themovie.com/",
@ -23,7 +23,7 @@
"original_language": "en",
"original_title": "Warcraft",
"overview": "The peaceful realm of Azeroth stands on the brink of war as its civilization faces a fearsome race of invaders: orc warriors fleeing their dying home to colonize another. As a portal opens to connect the two worlds, one army faces destruction and the other faces extinction. From opposing sides, two heroes are set on a collision course that will decide the fate of their family, their people, and their home.",
"popularity": 4.823419,
"popularity": 4.131139,
"poster_path": "/ckrTPz6FZ35L5ybjqvkLWzzSLO7.jpg",
"production_companies": [
{
@ -62,7 +62,7 @@
}
],
"release_date": "2016-05-25",
"revenue": 433537548,
"revenue": 433677183,
"runtime": 123,
"spoken_languages": [
{
@ -75,5 +75,19 @@
"title": "Warcraft",
"video": false,
"vote_average": 6.2,
"vote_count": 1265
"vote_count": 1627,
"videos": {
"results": [
{
"id": "571cf024c3a3684e98001b34",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "2Rxoz13Bthc",
"name": "Official Trailer",
"site": "YouTube",
"size": 1080,
"type": "Trailer"
}
]
}
}

View File

@ -11,7 +11,11 @@
"id": 68735,
"media_type": "movie",
"original_language": "en",
"title": "Warcraft: The Beginning"
"title": "Warcraft: The Beginning",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
},
{
"poster_path": "/1rxgOwMNwpVP0Hj8R0zhW1CJLfh.jpg",
@ -23,7 +27,11 @@
"id": 391584,
"media_type": "movie",
"original_language": "de",
"title": "World of Warcraft - Geschichte eines Kult-Spiels"
"title": "World of Warcraft - Geschichte eines Kult-Spiels",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
},
{
"poster_path": "/Y6M5JsoYPrtbTnOY1bCOx3IuDi.jpg",
@ -35,7 +43,11 @@
"id": 301865,
"media_type": "movie",
"original_language": "en",
"title": "World of Warcraft: Looking For Group"
"title": "World of Warcraft: Looking For Group",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
},
{
"poster_path": "/fivN0U4HXUMXKtyYfi5S8zdhHTg.jpg",
@ -45,7 +57,11 @@
"id": 205729,
"media_type": "movie",
"original_language": "en",
"title": "World of Warcraft - Cataclysm - Behind the Scenes"
"title": "World of Warcraft - Cataclysm - Behind the Scenes",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
}
]
}

View File

@ -1,5 +1,5 @@
{
"backdrop_path": "/aKz3lXU71wqdslC1IYRC3yHD6yw.jpg",
"backdrop_path": "/mUkuc2wyV9dHLG0D0Loaw5pO2s8.jpg",
"created_by": [
{
"id": 9813,
@ -38,12 +38,16 @@
"en",
"de"
],
"last_air_date": "2017-06-25",
"last_air_date": "2017-07-16",
"name": "Game of Thrones",
"networks": [
{
"id": 49,
"name": "HBO"
},
{
"id": 1063,
"name": "Sky Atlantic"
}
],
"number_of_episodes": 61,
@ -54,7 +58,7 @@
"original_language": "en",
"original_name": "Game of Thrones",
"overview": "Seven noble families fight for control of the mythical land of Westeros. Friction between the houses leads to full-scale war. All while a very ancient evil awakens in the farthest north. Amidst the war, a neglected military order of misfits, the Night's Watch, is all that stands between the realms of men and icy horrors beyond.",
"popularity": 35.712295,
"popularity": 43.231961,
"poster_path": "/jIhL6mlT7AblhbHJgEoiBIOUVl1.jpg",
"production_companies": [
{
@ -97,14 +101,14 @@
"air_date": "2012-04-01",
"episode_count": 10,
"id": 3625,
"poster_path": "/3U8IVLqitMHMuEAgkuz8qReguHd.jpg",
"poster_path": "/5tuhCkqPOT20XPwwi9NhFnC1g9R.jpg",
"season_number": 2
},
{
"air_date": "2013-03-31",
"episode_count": 10,
"id": 3626,
"poster_path": "/eVWAat0GqF6s5LLThrI7ClpKr96.jpg",
"poster_path": "/7d3vRgbmnrRQ39Qmzd66bQyY7Is.jpg",
"season_number": 3
},
{
@ -129,7 +133,7 @@
"season_number": 6
},
{
"air_date": "2017-06-25",
"air_date": "2017-07-16",
"episode_count": 1,
"id": 81266,
"poster_path": null,
@ -138,6 +142,27 @@
],
"status": "Returning Series",
"type": "Scripted",
"vote_average": 7.9,
"vote_count": 1528
"vote_average": 8,
"vote_count": 2074,
"videos": {
"results": [
{
"id": "572003f992514121f30003c6",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "BpJYNVhGf1s",
"name": "\"The Game Begins\"",
"site": "YouTube",
"size": 720,
"type": "Trailer"
}
]
},
"external_ids": {
"imdb_id": "tt0944947",
"freebase_mid": "/m/0524b41",
"freebase_id": "/en/game_of_thrones",
"tvdb_id": 121361,
"tvrage_id": 24493
}
}

View File

@ -12,7 +12,11 @@
18
],
"name": "Game of Thrones",
"original_name": "Game of Thrones"
"original_name": "Game of Thrones",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
},
{
"poster_path": "/3iFm6Kz7iYoFaEcj4fLyZHAmTQA.jpg",

View File

@ -12,7 +12,11 @@
18
],
"name": "Game of Thrones",
"original_name": "Game of Thrones"
"original_name": "Game of Thrones",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
},
{
"poster_path": "/8y1LSfnb8Dfd3XPIrxlpvZ4e8d.jpg",
@ -27,7 +31,11 @@
"id": 269623,
"media_type": "movie",
"original_language": "en",
"title": "Game of Thrones: Complete History and Lore"
"title": "Game of Thrones: Complete History and Lore",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
},
{
"poster_path": null,
@ -39,7 +47,11 @@
"id": 340200,
"media_type": "movie",
"original_language": "en",
"title": "Game of Thrones: A Day in the Life"
"title": "Game of Thrones: A Day in the Life",
"vote_average": 1,
"popularity": 1,
"backdrop_path": "xxx",
"overview": "xxx"
}
]
}

25
backend/tests/fixtures/tmdb/videos.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"id": 123,
"results": [
{
"id": "57b1f65492514147e0002da5",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "qnIhJwhBeqY",
"name": "Trailer",
"site": "YouTube",
"size": 480,
"type": "Trailer"
},
{
"id": "533ec652c3a3685448000101",
"iso_639_1": "en",
"iso_3166_1": "US",
"key": "6WcJbPlAknw",
"name": "The Lord Of The Rings (Extract) 1978",
"site": "YouTube",
"size": 360,
"type": "Clip"
}
]
}

View File

@ -1,7 +1,7 @@
require('../resources/sass/app.scss');
import Vue from 'vue';
import { mapActions, mapState } from 'vuex'
import { mapState, mapActions } from 'vuex'
import SiteHeader from './components/Header.vue';
import Search from './components/Search.vue';

View File

@ -1,23 +1,17 @@
<template>
<transition mode="out-in" name="fade">
<div class="item-wrap">
<div class="item-image-wrap">
<span v-if="localItem.rating != null" :class="'item-rating rating-' + localItem.rating" @click="changeRating()">
<i class="icon-rating"></i>
</span>
<span v-if="localItem.rating == null && localItem.tmdb_id && ! rated" class="item-rating item-new" @click="addNewItem()">
<i class="icon-add"></i>
</span>
<span v-if="localItem.rating == null && localItem.tmdb_id && rated" class="item-rating item-new">
<span class="loader smallsize-loader"><i></i></span>
</span>
<div class="item-image-wrap no-select">
<rating :item="localItem" :set-item="setItem"></rating>
<router-link v-if="localItem.tmdb_id" :to="suggestions" class="recommend-item">{{ lang('suggestions') }}</router-link>
<span v-if="auth && localItem.rating == null" class="add-to-watchlist" @click="addToWatchlist()">Add to watchlist</span>
<span v-if="auth && ! localItem.tmdb_id" class="edit-item" @click="editItem()">Edit</span>
<span class="remove-item" v-if="localItem.rating != null && auth" @click="removeItem()">{{ lang('delete movie') }}</span>
<img v-if="localItem.poster" :src="poster" class="item-image" width="185" height="278">
<img v-if=" ! localItem.poster" :src="noImage" class="item-image" width="185" height="278">
<router-link :to="{ name: `subpage-${localItem.media_type}`, params: { tmdbId: localItem.tmdb_id, slug: localItem.slug }}">
<img v-if="localItem.poster" :src="poster" class="item-image" width="185" height="278">
<img v-if=" ! localItem.poster" :src="noImage" class="item-image" width="185" height="278">
</router-link>
<span class="show-episode" @click="openSeasonModal()" v-if="displaySeason">
<span class="season-item"><i>S</i>{{ season }}</span>
@ -28,7 +22,7 @@
<div class="item-content">
<span v-if="date == 1" class="item-year">{{ released }}</span>
<i class="item-has-src" v-if="hasSrc"></i>
<a :href="youtube" target="_blank" :title="localItem.title" class="item-title">{{ localItem.title }}</a>
<router-link :to="{ name: `subpage-${localItem.media_type}`, params: { tmdbId: localItem.tmdb_id }}" class="item-title" :title="localItem.title">{{ localItem.title }}</router-link>
<span v-if="genre == 1" class="item-genre">{{ localItem.genre }}</span>
</div>
</div>
@ -36,32 +30,24 @@
</template>
<script>
import Rating from '../Rating.vue';
import http from 'axios';
import debounce from 'debounce';
import Helper from '../../helper';
import { mapMutations, mapActions } from 'vuex';
const ratingMilliseconds = 700;
const newItemMilliseconds = 200;
export default {
mixins: [Helper],
props: ['item', 'genre', 'date'],
created() {
this.saveNewRating = debounce(this.saveNewRating, ratingMilliseconds);
this.addNewItem = debounce(this.addNewItem, newItemMilliseconds, true);
},
data() {
return {
localItem: this.item,
latestEpisode: this.item.latest_episode,
auth: config.auth,
prevRating: null,
rated: false
auth: config.auth
}
},
@ -79,7 +65,9 @@
},
suggestions() {
return `/suggestions?for=${this.localItem.tmdb_id}&name=${this.localItem.title}&type=${this.localItem.media_type}`;
const item = this.localItem;
return `/suggestions?for=${item.tmdb_id}&name=${item.title}&type=${item.media_type}`;
},
noImage() {
@ -97,10 +85,6 @@
return released.getFullYear();
},
youtube() {
return `https://www.youtube.com/results?search_query=${this.localItem.title} ${this.released} Trailer`;
},
displaySeason() {
return this.localItem.media_type == 'tv' && this.localItem.rating != null && this.localItem.tmdb_id;
},
@ -140,50 +124,21 @@
});
},
changeRating() {
if(this.auth) {
this.prevRating = this.localItem.rating;
this.localItem.rating = this.prevRating == 3 ? 1 : +this.prevRating + 1;
this.saveNewRating();
}
setItem(item) {
this.localItem = item;
},
saveNewRating() {
http.patch(`${config.api}/change-rating/${this.localItem.id}`, {rating: this.localItem.rating}).catch(error => {
this.localItem.rating = this.prevRating;
alert('Error in saveNewRating()');
});
addToWatchlist() {
},
addNewItem() {
if(this.auth) {
this.rated = true;
editItem() {
http.post(`${config.api}/add`, {item: this.localItem}).then(value => {
this.localItem = value.data;
this.rated = false;
}, error => {
if(error.status == 409) {
alert(this.localItem.title + ' already exists!');
}
});
}
},
removeItem() {
if(this.auth) {
const confirm = window.confirm(this.lang('confirm delete'));
if(confirm) {
http.delete(`${config.api}/remove/${this.localItem.id}`).then(value => {
this.localItem.rating = null;
}, error => {
alert('Error in removeItem()');
});
}
}
}
},
components: {
Rating
}
}
</script>

View File

@ -0,0 +1,221 @@
<template>
<main>
<!-- todo: make header position absolute, and float teaser and content correct -->
<section class="big-teaser-wrap" :class="{active: itemLoadedSubpage}" v-show=" ! loading">
<div class="big-teaser-image" :style="backdropImage"></div>
<div class="wrap">
<div class="big-teaser-content">
<div class="big-teaser-data-wrap">
<div class="subpage-poster-wrap-mobile">
<rating :item="item" :set-item="setItem"></rating>
<img class="base" :src="noImage" width="120" height="180">
<img class="real" :src="posterImage" width="120" height="180">
</div>
<!-- todo: move to own component -->
<div class="big-teaser-item-data">
<span class="item-year">{{ released }}</span>
<span class="item-title">{{ item.title }}</span>
<span class="item-genre">{{ item.genre }}</span>
</div>
<div class="big-teaser-buttons no-select" :class="{'without-watchlist': item.rating != null || ! auth}">
<span @click="openTrailer()" v-if="item.youtube_key" class="button-trailer"><i class="icon-trailer"></i> Watch Trailer</span>
<span v-if="item.rating == null && auth" class="button-watchlist"><i class="icon-watchlist"></i> Add to Watchlist</span>
<a :href="`https://www.themoviedb.org/${item.media_type}/${item.tmdb_id}`" target="_blank" class="button-tmdb-rating">
<i v-if="item.tmdb_rating && item.tmdb_rating != 0"><b>{{ item.tmdb_rating }}</b> TMDb</i>
<i v-else>No TMDb Rating</i>
</a>
<a v-if="item.imdb_id" :href="`http://www.imdb.com/title/${item.imdb_id}`" target="_blank" class="button-imdb-rating">
<i v-if="loadingImdb">Loading IMDb Rating...</i>
<i v-if="item.imdb_rating && ! loadingImdb"><b>{{ item.imdb_rating }}</b> IMDb</i>
<i v-if=" ! item.imdb_rating && ! loadingImdb">No IMDb Rating</i>
</a>
</div>
</div>
</div>
</div>
</section>
<div class="subpage-content" :class="{active: itemLoadedSubpage}" v-show=" ! loading">
<div class="wrap">
<div class="subpage-overview">
<h2>Overview</h2>
<p>{{ overview }}</p>
</div>
<div class="subpage-sidebar">
<div class="subpage-poster-wrap">
<rating :item="item" :set-item="setItem"></rating>
<img class="base" :src="noImage" width="272" height="408">
<img class="real" :src="posterImage" width="272" height="408">
</div>
<!-- todo: move to own component -->
<div class="subpage-sidebar-buttons no-select" v-if="item.rating != null && auth">
<span class="edit-data">Edit data</span>
<span class="remove-item" @click="removeItem()">{{ lang('delete movie') }}</span>
</div>
</div>
</div>
</div>
<span class="loader fullsize-loader" v-show="loading"><i></i></span>
</main>
</template>
<script>
import Rating from '../Rating.vue';
import { mapMutations, mapState } from 'vuex'
import Helper from '../../helper';
import http from 'axios';
export default {
mixins: [Helper],
props: ['mediaType'],
created() {
document.body.classList.add('subpage-open');
window.scrollTo(0, 0);
this.fetchData();
},
destroyed() {
document.body.classList.remove('subpage-open');
this.SET_ITEM_LOADED_SUBPAGE(false);
this.CLOSE_MODAL();
},
data() {
return {
item: {},
loadingImdb: false,
auth: config.auth
}
},
computed: {
...mapState({
loading: state => state.loading,
itemLoadedSubpage: state => state.itemLoadedSubpage
}),
overview() {
return this.item.overview ? this.item.overview : '-';
},
backdropImage() {
let backdropUrl = config.backdropTMDB;
if(this.item.rating != null) {
backdropUrl = config.backdrop;
}
return {
backgroundImage: `url(${backdropUrl}${this.item.backdrop})`
}
},
posterImage() {
if(this.item.rating != null) {
return config.posterSubpage + this.item.poster;
}
return config.posterSubpageTMDB + this.item.poster;
},
released() {
const released = new Date(this.item.released * 1000);
return released.getFullYear();
},
noImage() {
return config.url + '/assets/img/no-image-subpage.png';
}
},
methods: {
...mapMutations([ 'SET_LOADING', 'SET_ITEM_LOADED_SUBPAGE', 'OPEN_MODAL', 'CLOSE_MODAL' ]),
openTrailer() {
this.OPEN_MODAL({
type: 'trailer',
data: {
youtubeKey: this.item.youtube_key,
title: this.item.title
}
});
},
fetchImdbRating() {
if(this.item.imdb_id && this.item.rating == null) {
this.loadingImdb = true;
http(`${config.api}/imdb-rating/${this.item.imdb_id}`).then(response => {
const rating = this.intToFloat(response.data);
this.$set(this.item, 'imdb_rating', rating);
this.loadingImdb = false;
}, error => {
alert(error);
this.loadingImdb = false;
});
}
},
fetchData() {
const tmdbId = this.$route.params.tmdbId;
this.SET_LOADING(true);
http(`${config.api}/item/${tmdbId}/${this.mediaType}`).then(response => {
this.item = response.data;
this.item.tmdb_rating = this.intToFloat(response.data.tmdb_rating);
this.disableLoading();
this.fetchImdbRating();
}, error => {
alert(error);
this.SET_LOADING(false);
});
},
disableLoading() {
setTimeout(() => {
this.SET_LOADING(false);
this.displayItem();
}, 100);
},
displayItem() {
setTimeout(() => {
this.SET_ITEM_LOADED_SUBPAGE(true);
}, 50);
},
setItem(item) {
this.item = item;
},
removeItem() {
const confirm = window.confirm(this.lang('confirm delete'));
if(confirm) {
http.delete(`${config.api}/remove/${this.item.id}`).then(response => {
this.item.rating = null;
}, error => {
alert(error);
});
}
}
},
components: {
Rating
}
}
</script>

View File

@ -1,13 +1,14 @@
<template>
<header>
<header :class="{active: displayHeader}">
<div class="wrap">
<router-link to="/" class="logo" >
<img src="../../../public/assets/img/logo.png" alt="Flox" width="108" height="32">
</router-link>
<span class="sort-wrap">
<span class="sort-wrap" v-if=" ! isSubpage()">
<i :title="lang('last seen')" class="icon-sort-time" :class="{active: userFilter == 'last_seen_at'}" @click="setUserFilter('last_seen_at')"></i>
<i :title="lang('best rated')" class="icon-sort-star" :class="{active: userFilter == 'rating'}" @click="setUserFilter('rating')"></i>
<!-- will be moved into footer -->
<span :title="lang('change color')" class="icon-constrast" @click="toggleColorScheme()"><i></i></span>
</span>
@ -17,8 +18,8 @@
</ul>
<ul class="site-nav-second">
<li><router-link to="/tv">{{ lang('tv') }}</router-link></li>
<li><router-link to="/movies">{{ lang('movies') }}</router-link></li>
<li><router-link to="/tv" exact>{{ lang('tv') }}</router-link></li>
<li><router-link to="/movies" exact>{{ lang('movies') }}</router-link></li>
</ul>
</div>
@ -40,8 +41,10 @@
computed: {
...mapState({
userFilter: state => state.userFilter,
colorScheme: state => state.colorScheme
colorScheme: state => state.colorScheme,
itemLoadedSubpage: state => state.itemLoadedSubpage
}),
root() {
return config.uri;
}

View File

@ -2,6 +2,7 @@
<div class="all-modals">
<transition mode="out-in" name="fade">
<season v-if="modalType == 'season'"></season>
<trailer v-if="modalType == 'trailer'"></trailer>
</transition>
<span class="overlay" v-if="overlay" @click="CLOSE_MODAL()"></span>
</div>
@ -9,6 +10,7 @@
<script>
import Season from './Season.vue';
import Trailer from './Trailer.vue';
import { mapState, mapMutations } from 'vuex';
@ -25,7 +27,7 @@
},
components: {
Season
Season, Trailer
}
}
</script>

View File

@ -0,0 +1,40 @@
<template>
<div class="modal-wrap modal-wrap-big">
<div class="modal-header">
<span>Trailer for {{ modalData.title }}</span>
<span class="close-modal" @click="CLOSE_MODAL()">
<i class="icon-close"></i>
</span>
</div>
<div class="modal-content">
<iframe width="100%" height="100%" :src="trailerSrc" frameborder="0" allowfullscreen></iframe>
</div>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
import Helper from '../../helper';
export default {
mixins: [Helper],
computed: {
...mapState({
modalData: state => state.modalData
}),
trailerSrc() {
return `https://www.youtube.com/embed/${this.modalData.youtubeKey}`;
}
},
methods: {
...mapMutations([ 'CLOSE_MODAL' ])
}
}
</script>

View File

@ -0,0 +1,70 @@
<template>
<div>
<span v-if="item.rating != null" :class="'item-rating rating-' + item.rating" @click="changeRating()">
<i class="icon-rating"></i>
</span>
<span v-if="item.rating == null && item.tmdb_id && ! rated && auth" class="item-rating item-new" @click="addNewItem()">
<i class="icon-add"></i>
</span>
<span v-if="item.rating == null && item.tmdb_id && rated" class="item-rating item-new">
<span class="loader smallsize-loader"><i></i></span>
</span>
</div>
</template>
<script>
import debounce from 'debounce';
import http from 'axios';
const ratingMilliseconds = 700;
const newItemMilliseconds = 200;
export default {
props: ['item', 'set-item'],
data() {
return {
rated: false,
auth: config.auth
}
},
created() {
this.saveNewRating = debounce(this.saveNewRating, ratingMilliseconds);
this.addNewItem = debounce(this.addNewItem, newItemMilliseconds, true);
},
methods: {
changeRating() {
if(this.auth) {
this.prevRating = this.item.rating;
this.item.rating = this.prevRating == 3 ? 1 : +this.prevRating + 1;
this.saveNewRating();
}
},
saveNewRating() {
http.patch(`${config.api}/change-rating/${this.item.id}`, {rating: this.item.rating}).catch(error => {
this.item.rating = this.prevRating;
alert('Error in saveNewRating()');
});
},
addNewItem() {
if(this.auth) {
this.rated = true;
http.post(`${config.api}/add`, {item: this.item}).then(value => {
this.setItem(value.data);
this.rated = false;
}, error => {
if(error.status == 409) {
alert(this.item.title + ' already exists!');
}
});
}
}
}
}
</script>

View File

@ -1,5 +1,5 @@
<template>
<section class="search-wrap" :class="{sticky: sticky}">
<section class="search-wrap" :class="{sticky: sticky, active: displayHeader}">
<div class="wrap">
<form class="search-form" @submit.prevent="search()">
@ -11,7 +11,7 @@
<div class="suggestions-for" v-if="suggestionsFor">
<div class="wrap">
{{ lang('suggestions for') }} <span>{{ suggestionsFor }}</span>
{{ lang('suggestions for') }} <router-link :to="{ name: `subpage-${$route.query.type}`, params: { tmdbId: $route.query.for }}">{{ suggestionsFor }}</router-link>
</div>
</div>
</section>
@ -19,6 +19,7 @@
<script>
import Helper from '../helper';
import { mapState } from 'vuex'
export default {
mixins: [Helper],
@ -34,6 +35,10 @@
},
computed: {
...mapState({
itemLoadedSubpage: state => state.itemLoadedSubpage
}),
suggestionsFor() {
return this.$route.query.name;
},

View File

@ -1,7 +1,7 @@
import http from 'axios';
http.defaults.headers.common['X-CSRF-TOKEN'] = document.querySelector('#token').getAttribute('content');
const {url, uri, auth, language} = document.body.dataset;
const {url, uri, auth, language, posterTmdb, posterSubpageTmdb, backdropTmdb} = document.body.dataset;
const config = {
uri,
@ -9,7 +9,11 @@ const config = {
auth,
language,
poster: url + '/assets/poster',
posterTMDB: 'http://image.tmdb.org/t/p/w185',
backdrop: url + '/assets/backdrop',
posterSubpage: url + '/assets/poster/subpage',
posterTMDB: posterTmdb,
posterSubpageTMDB: posterSubpageTmdb,
backdropTMDB: backdropTmdb,
api: url + '/api'
};

View File

@ -28,6 +28,14 @@ export default {
return item;
},
intToFloat(int) {
if(int) {
return parseFloat(int).toFixed(1);
}
return null;
},
// Language helper
lang(text) {
const language = JSON.parse(config.language);
@ -43,6 +51,20 @@ export default {
month: '2-digit',
day: '2-digit'
});
},
isSubpage() {
return this.$route.name.includes('subpage');
}
},
computed: {
displayHeader() {
if(this.isSubpage()) {
return this.itemLoadedSubpage;
}
return true;
}
}
}

View File

@ -7,6 +7,7 @@ import Content from './components/Content/Content.vue';
import SearchContent from './components/Content/SearchContent.vue';
import Settings from './components/Content/Settings/Index.vue';
import TMDBContent from './components/Content/TMDBContent.vue';
import Subpage from './components/Content/Subpage.vue';
Vue.use(Router);
@ -15,13 +16,20 @@ export default new Router({
base: config.uri,
routes: [
{ path: '/', component: Content, name: 'home' },
// todo: use props for media type
{ path: '/movies', component: Content, name: 'movie' },
{ path: '/tv', component: Content, name: 'tv' },
{ path: '/movies/:tmdbId/:slug?', component: Subpage, name: 'subpage-movie', props: {mediaType: 'movie'} },
{ path: '/tv/:tmdbId/:slug?', component: Subpage, name: 'subpage-tv', props: {mediaType: 'tv'} },
{ path: '/search', component: SearchContent, name: 'search' },
{ path: '/settings', component: Settings, name: 'settings' },
{ path: '/suggestions', component: TMDBContent, name: 'suggestions' },
{ path: '/trending', component: TMDBContent, name: 'trending' },
{ path: '/upcoming', component: TMDBContent, name: 'upcoming' },
{ path: '*', component: Content }
]
});

View File

@ -37,7 +37,11 @@ export function setSearchTitle({commit}, title) {
}
export function setColorScheme({commit}, color) {
document.body.classList.remove('dark', 'light');
localStorage.setItem('color', color);
document.body.classList.add(color);
commit('SET_COLOR_SCHEME', color);
}

View File

@ -19,7 +19,8 @@ export default new Vuex.Store({
modalData: {},
loadingModalData: true,
seasonActiveModal: 1,
modalType: ''
modalType: '',
itemLoadedSubpage: false
},
mutations,
actions

View File

@ -57,5 +57,9 @@ export default {
[type.SET_MODAL_DATA](state, data) {
state.modalData = data;
},
[type.SET_ITEM_LOADED_SUBPAGE](state, bool) {
state.itemLoadedSubpage = bool;
}
}

View File

@ -11,3 +11,4 @@ export const OPEN_MODAL = 'OPEN_MODAL';
export const SET_SEASON_ACTIVE_MODAL = 'SET_SEASON_ACTIVE_MODAL';
export const SET_LOADING_MODAL_DATA = 'SET_LOADING_MODAL_DATA';
export const SET_MODAL_DATA = 'SET_MODAL_DATA';
export const SET_ITEM_LOADED_SUBPAGE = 'SET_ITEM_LOADED_SUBPAGE';

View File

@ -13,7 +13,7 @@
"vuex": "^2.0.0"
},
"devDependencies": {
"autoprefixer": "^6.5.0",
"autoprefixer": "^6.7.6",
"babel-core": "^6.17.0",
"babel-loader": "^6.2.5",
"babel-plugin-transform-runtime": "^6.15.0",

View File

@ -14,12 +14,15 @@
<body
data-url="{{ url('/') }}"
data-uri="{{ config('app.CLIENT_URI') }}"
data-poster-tmdb="{{ config('services.tmdb.poster') }}"
data-poster-subpage-tmdb="{{ config('services.tmdb.poster_subpage') }}"
data-backdrop-tmdb="{{ config('services.tmdb.backdrop') }}"
data-auth="{{ Auth::check() }}"
data-language="{{ $lang }}"
class="{{ Auth::check() ? 'logged' : 'guest' }}"
>
<div id="app" :class="colorScheme">
<div id="app">
@if(Request::is('login'))
<login></login>
@else

View File

@ -1,6 +1,8 @@
* {
box-sizing: border-box;
font-family: 'Open Sans', sans-serif;
margin: 0;
padding: 0;
}
body {
@ -27,6 +29,7 @@ input {
.wrap,
.wrap-content {
lost-center: 1300px 20px;
width: 100%;
}
.wrap-content {

View File

@ -9,6 +9,7 @@
'components/header',
'components/search',
'components/content',
'components/subpage',
'components/login',
'components/footer',
'components/modal';

View File

@ -19,6 +19,14 @@ main {
@include media(5) {
padding: 80px 0 0 0;
}
.subpage-open & {
padding: 0;
//padding: 65vh 0 0 0;
//position: absolute;
//top: 0;
//left: 0;
}
}
.suggestions-for {
@ -38,8 +46,13 @@ main {
border-top: 1px solid #474747;
}
span {
a {
color: $main2;
text-decoration: none;
&:active {
color: $main1;
}
}
}
@ -75,10 +88,6 @@ main {
.item-new {
display: block;
}
.show-episode {
bottom: 24px;
}
}
.show-episode {
@ -86,25 +95,21 @@ main {
}
.recommend-item,
.remove-item {
.add-to-watchlist {
opacity: .9;
}
.item-rating {
.logged & {
transform: translate(-50%, -50%) scale(1.2);
@include media(3) {
transform: translate(-50%, -50%) scale(1);
}
}
}
}
}
.item-image {
box-shadow: 0 12px 15px 0 rgba(0, 0, 0, .5);
//@include transition(box-shadow);
&:hover {
//box-shadow: 0 15px 20px 0 rgba(0, 0, 0, .5);
}
@include media(3) {
width: 100%;
height: auto;
@ -127,59 +132,59 @@ main {
@include media(5) {
margin: 10px 0 0 0;
}
}
.item-year,
.item-genre {
float: left;
color: #888;
font-size: 14px;
clear: both;
margin: 0 5px 0 0;
.item-year,
.item-genre {
float: left;
color: #888;
font-size: 14px;
clear: both;
margin: 0 5px 0 0;
.dark & {
color: #626262;
.dark & {
color: #626262;
}
@include media(3) {
font-size: 13px;
}
}
@include media(3) {
font-size: 13px;
}
}
.item-has-src {
float: left;
margin: 5px 0 0 0;
font-style: normal;
width: 12px;
height: 9px;
background: url(../../../public/assets/img/has-src.png);
}
.item-title {
color: $dark;
clear: both;
font-size: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
text-decoration: none;
float: left;
.dark & {
color: #717171;
.item-has-src {
float: left;
margin: 5px 0 0 0;
font-style: normal;
width: 12px;
height: 9px;
background: url(../../../public/assets/img/has-src.png);
}
&:hover {
color: $main2;
}
.item-title {
color: $dark;
clear: both;
font-size: 17px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
text-decoration: none;
float: left;
&:active {
color: $main1;
}
.dark & {
color: #717171;
}
@include media(3) {
font-size: 15px;
&:hover {
color: $main2;
}
&:active {
color: $main1;
}
@include media(3) {
font-size: 15px;
}
}
}
@ -192,10 +197,19 @@ main {
transform: translate(-50%, -50%);
box-shadow: 0 0 15px 0 rgba(0, 0, 0, .7);
border-radius: 25px;
z-index: 120;
.logged & {
cursor: pointer;
&:hover {
transform: translate(-50%, -50%) scale(1.2);
@include media(3) {
transform: translate(-50%, -50%) scale(1);
}
}
&:active {
transform: translate(-50%, -50%) scale(1.1) !important;
}
@ -261,6 +275,10 @@ main {
display: block;
}
}
.subpage-open & {
display: block;
}
}
.icon-add {
@ -295,41 +313,57 @@ main {
}
@include media(3) {
font-size: 12px;
display: none;
/*font-size: 12px;
padding: 8px 1px;
position: static;
float: left;
opacity: 1 !important;
opacity: 1 !important;*/
}
}
.remove-item {
.add-to-watchlist,
.edit-item {
position: absolute;
bottom: 0;
right: 0;
opacity: 0;
background: $rating3;
padding: 3px 0;
background: #238cce;
padding: 5px 0;
cursor: pointer;
text-align: center;
font-size: 13px;
text-transform: uppercase;
color: #fff;
height: 24px;
width: 100%;
@include transition(opacity);
@include transition(background, opacity);
&:hover {
background: lighten(#238cce, 5%);
opacity: 1 !important;
}
&:active {
background: #238cce;
opacity: .7 !important;
}
@include media(3) {
// opacity: 1;
display: none;
}
}
.edit-item {
background: $rating2;
opacity: 1;
&:hover {
background: lighten($rating2, 5%);
}
&:active {
background: $rating2;
}
}

View File

@ -3,22 +3,31 @@ header {
background: linear-gradient(to right, $main1, $main2);
width: 100%;
padding: 25px 0;
position: relative;
z-index: 20;
opacity: 0;
@include media(5) {
padding: 15px 0;
}
.dark & {
opacity: .9;
}
.open-modal & {
padding: 25px 16px 25px 0;
}
.subpage-open & {
background: none;
}
&.active {
transition: opacity .7s ease .1s;
opacity: 1;
}
}
.logo {
float: left;
opacity: .9;
@include media(5) {
width: 80px;
@ -36,6 +45,7 @@ header {
float: right;
margin: 4px 0 0 0;
width: auto;
opacity: .9;
.icon-sort-time,
.icon-sort-star {
@ -101,6 +111,7 @@ header {
margin: 7px 0 0 40px;
list-style: none;
padding: 0;
opacity: .9;
@include media(4) {
clear: left;

View File

@ -5,7 +5,7 @@
position: fixed;
top: 0;
width: 100%;
z-index: 50;
z-index: 250;
}
.modal-wrap {
@ -16,13 +16,31 @@
transform: translateX(-50%);
width: 100%;
box-shadow: 0 5px 20px 0 rgba(#000, .6);
z-index: 100;
z-index: 300;
@include media(4) {
top: 0;
}
}
.modal-wrap-big {
max-width: 1300px;
height: 80%;
.modal-content {
max-height: calc(100% - 45px);
height: 100%;
iframe {
float: left;
}
}
@include media(4) {
height: 100%;
}
}
.modal-header {
background: $main2;
background: linear-gradient(to right, $main1, $main2);

View File

@ -4,7 +4,8 @@
position: absolute;
box-shadow: 0 0 70px 0 rgba(0, 0, 0, .3);
background: #fff;
opacity: .97;
z-index: 200;
opacity: 0;
.dark & {
background: #2f2f2f;
@ -19,9 +20,21 @@
position: fixed;
top: 0;
left: 0;
z-index: 10;
}
}
.subpage-open & {
background: none;
box-shadow: none;
position: absolute;
top: auto;
left: auto;
}
&.active {
transition: opacity .7s ease .1s;
opacity: .97;
}
}
.search-form {
@ -34,6 +47,10 @@
.sticky & {
padding: 0 0 0 50px;
}
.subpage-open & {
padding: 0;
}
}
.search-input {
@ -52,6 +69,10 @@
@include media(5) {
font-size: 16px;
}
.subpage-open & {
color: #fff;
}
}
.icon-search {
@ -61,6 +82,7 @@
position: absolute;
left: 0;
top: 15px;
opacity: .5;
@include transition(left);
@ -71,6 +93,12 @@
.sticky & {
left: 50px;
}
.subpage-open & {
background: url(../../../public/assets/img/search-dark.png);
opacity: 1;
left: 0;
}
}
.icon-logo-small {
@ -96,4 +124,8 @@
opacity: .6;
}
}
.subpage-open & {
display: none;
}
}

View File

@ -0,0 +1,440 @@
.big-teaser-wrap {
position: absolute;
top: 0;
left: 0;
background: $main2;
background: linear-gradient(to right, $main1, $main2);
width: 100%;
//height: 65vh;
height: 600px;
z-index: 10;
box-shadow: 0 0 70px 0 rgba(0,0,0,.5);
.open-modal & {
padding: 0 20px 0 0;
}
@include media(3) {
height: 500px;
}
@include media(4) {
height: 450px;
}
@include media(6) {
height: 550px;
}
}
.button-trailer {
background: $dark;
color: rgba(255,255,255,.9);
&:hover {
background: lighten($dark, 5%);
}
&:active {
background: $dark;
}
@include media(4) {
.without-watchlist & {
//width: 100% !important;
}
}
}
.button-watchlist {
background: #238cce;
color: rgba(255,255,255,.9);
&:hover {
background: lighten(#238cce, 5%);
}
&:active {
background: #238cce;
}
}
.button-tmdb-rating {
background: #00d373;
color: $dark;
i {
font-style: normal;
}
&:hover {
background: lighten(#00d373, 5%);
}
&:active {
background: #00d373;
}
}
.button-imdb-rating {
background: #e3b81f;
color: $dark;
i {
font-style: normal;
}
&:hover {
background: lighten(#e3b81f, 10%);
}
&:active {
background: #e3b81f;
}
}
.icon-trailer {
background: url(../../../public/assets/img/trailer.png);
width: 7px;
height: 8px;
float: left;
margin: 9px 7px 0 0;
@include media(4) {
display: none;
}
}
.icon-watchlist {
background: url(../../../public/assets/img/watchlist.png);
width: 14px;
height: 11px;
float: left;
margin: 7px 7px 0 0;
@include media(4) {
display: none;
}
}
.big-teaser-buttons {
float: left;
clear: both;
margin: 0 0 0 272px;
span,
a {
float: left;
padding: 6px 14px;
font-size: 17px;
cursor: pointer;
text-decoration: none;
@include transition(background);
@include media(4) {
padding: 5px 8px;
font-size: 15px;
}
@include media(5) {
clear: both;
}
@include media(6) {
//width: 50%;
//overflow: hidden;
//text-overflow: ellipsis;
//white-space: nowrap;
//text-align: center;
}
}
@include media(3) {
margin: 0 0 0 200px;
}
@include media(5) {
margin: 0 0 0 -20px;
}
}
.big-teaser-item-data {
float: left;
margin: 0 0 40px 300px;
.item-year,
.item-title,
.item-genre {
float: left;
clear: both;
}
.item-title {
color: #fff;
font-size: 34px;
margin: 0 0 10px 0;
@include media(4) {
font-size: 18px;
}
}
.item-year,
.item-genre {
color: rgba(#fff, .8);
@include media(4) {
font-size: 14px;
}
}
@include media(3) {
margin: 0 0 40px 230px;
}
@include media(5) {
margin: 0 0 20px 0;
}
}
.big-teaser-image {
width: 100%;
height: 100%;
background-size: cover;
background-position: 100% 25%;
position: absolute;
top: 0;
left: 0;
z-index: -1;
opacity: 0;
transition: opacity 1s ease 0s;
.active & {
opacity: .2;
}
}
.big-teaser-data-wrap {
float: left;
width: 100%;
}
.big-teaser-content {
position: absolute;
bottom: 0;
}
.subpage-content {
width: 100%;
//padding: calc(65vh - 90px) 0 0 0;
padding: 520px 0 0 0;
@include media(6) {
padding: 300px 0 0 0;
}
.open-modal & {
padding: 520px 20px 0 0;
}
}
.subpage-sidebar {
float: left;
margin: -330px 0 0 0;
position: relative;
z-index: 50;
@include media(3) {
margin: -360px 0 0 0;
}
@include media(4) {
margin: -450px 0 0 0;
}
@include media(5) {
margin: 0;
}
@include media(6) {
width: 100%;
margin: 0 0 30px 0;
}
}
.subpage-poster-wrap-mobile {
float: left;
position: relative;
display: none;
@include media(5) {
display: block;
}
// todo: remove
.base {
box-shadow: 0 20px 20px 0 rgba(0, 0, 0, .3);
position: absolute;
top: 0;
left: 0;
}
.real {
position: relative;
z-index: 100;
opacity: 0;
transition: opacity 1s ease 0s;
.active & {
opacity: 1;
}
}
}
.subpage-poster-wrap {
float: left;
position: relative;
img {
border: 0;
@include media(3) {
max-width: 200px;
height: auto;
}
}
.base {
box-shadow: 0 20px 20px 0 rgba(0, 0, 0, .3);
position: absolute;
top: 0;
left: 0;
}
.real {
position: relative;
z-index: 100;
opacity: 0;
transition: opacity 1s ease 0s;
.active & {
opacity: 1;
}
}
@include media(5) {
//max-width: 100px;
//height: auto;
display: none;
}
}
.subpage-sidebar-buttons {
float: left;
clear: both;
width: 100%;
position: relative;
z-index: 100;
margin: 30px 0 0 0;
span {
float: left;
clear: both;
}
@include media(6) {
//clear: none;
//width: auto;
}
}
.remove-item,
.refresh-data,
.edit-data {
padding: 10px;
cursor: pointer;
font-size: 13px;
text-transform: uppercase;
color: $dark;
.dark & {
color: #717171;
}
@include transition(color);
&:hover {
color: $rating3;
text-decoration: underline;
}
&:active {
text-decoration: none;
}
}
.refresh-data {
&:hover {
color: #238cce;
text-decoration: underline;
}
&:active {
text-decoration: none;
}
}
.edit-data {
&:hover {
color: $rating2;
text-decoration: underline;
}
&:active {
text-decoration: none;
}
}
.subpage-overview {
width: calc(100% - 310px);
float: right;
//margin: 0 0 0 30px;
padding: 0 0 30px 0;
@include media(6) {
margin: 0;
width: 100%;
}
h2 {
float: left;
color: $main2;
text-transform: uppercase;
font-size: 18px;
margin: 30px 0 10px 0;
@include media(6) {
font-size: 15px;
}
}
p {
float: left;
clear: both;
color: $dark;
font-size: 15px;
line-height: 19pt;
@include media(6) {
font-size: 14px;
}
.dark & {
color: #717171;
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 B

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 B

View File

View File

File diff suppressed because one or more lines are too long