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

move genres into own table (#104)

* create genres table and translate media_type

* parse genres from TMDb and modify artisan db command

* parse genres on migration

* Save genres in new table (via relation)

* Implode genres as string.
Update genres in refresh.

* add tests for genres

* fix icon for src

* display genres on own page

include few ui tweaks
This commit is contained in:
Viktor Geringer 2018-03-11 21:08:44 +01:00 committed by GitHub
parent c3dda14427
commit 1a1753181f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
60 changed files with 9492 additions and 209 deletions

View File

@ -2,37 +2,53 @@
namespace App\Console\Commands;
use App\Services\Models\GenreService;
use App\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB as LaravelDB;
class DB extends Command {
protected $signature = 'flox:db {username?} {password?}';
private $genreService;
protected $signature = 'flox:db {--fresh : Whether all data should be reset} {username?} {password?}';
protected $description = 'Create database migrations and admin account';
public function __construct()
public function __construct(GenreService $genreService)
{
parent::__construct();
$this->genreService = $genreService;
}
public function handle()
{
if($this->option('fresh')) {
$this->alert('ALL DATA WILL BE REMOVED');
}
try {
$this->createMigrations();
} catch(\Exception $e) {
$this->error('Can not connect to the database. Error: ' . $e->getMessage());
$this->error('Make sure your database credentials in .env are correct');
return;
return false;
}
$this->createUser();
}
private function createMigrations()
{
$this->info('TRYING TO MIGRATE DATABASE');
$this->call('migrate', ['--force' => true]);
if($this->option('fresh')) {
$this->call('migrate:fresh', ['--force' => true]);
} else {
$this->call('migrate', ['--force' => true]);
}
$this->info('MIGRATION COMPLETED');
}
@ -41,6 +57,10 @@
$username = $this->ask('Enter your admin username', $this->argument("username"));
$password = $this->ask('Enter your admin password', $this->argument("password"));
if($this->option('fresh')) {
LaravelDB::table('users')->delete();
}
$user = new User();
$user->username = $username;
$user->password = bcrypt($password);

View File

@ -32,7 +32,7 @@
public function getReleaseEpisodeHumanFormatAttribute()
{
$now = Carbon::now();
$now = now();
$release = Carbon::createFromTimestamp($this->release_episode);
if($release > $now) {

20
backend/app/Genre.php Normal file
View File

@ -0,0 +1,20 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Genre extends Model {
public $timestamps = false;
protected $fillable = [
'name',
'id',
];
public function scopeFindByName($query, $genre)
{
return $query->where('name', $genre);
}
}

View File

@ -9,6 +9,7 @@
use App\Services\Storage;
use App\Setting;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Input;
use Symfony\Component\HttpFoundation\Response;
@ -71,30 +72,27 @@
$data = json_decode(file_get_contents($file));
$this->importItems($data, $itemService);
$this->importSettings($data);
$this->importEpisodes($data);
$this->importAlternativeTitles($data);
$this->importSettings($data);
$this->importItems($data, $itemService);
}
private function importItems($data, ItemService $itemService)
{
logInfo("Import Movies");
if(isset($data->items)) {
$this->item->truncate();
DB::table('items')->delete();
foreach($data->items as $item) {
logInfo("Importing", [$item->title]);
// Fallback if export was from an older version of flox (<= 1.2.2).
if( ! isset($item->last_seen_at)) {
$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->downloadImages($item['poster'], $item['backdrop']);
}
$this->item->create((array) $item);
}
@ -107,7 +105,9 @@
{
logInfo("Import Tv Shows");
if(isset($data->episodes)) {
$this->episodes->truncate();
foreach($data->episodes as $episode) {
logInfo("Importing", [$episode->name]);
$this->episodes->create((array) $episode);
@ -119,7 +119,9 @@
private function importAlternativeTitles($data)
{
if(isset($data->alternative_titles)) {
$this->alternativeTitles->truncate();
foreach($data->alternative_titles as $title) {
$this->alternativeTitles->create((array) $title);
}
@ -129,7 +131,9 @@
private function importSettings($data)
{
if(isset($data->settings)) {
$this->settings->truncate();
foreach($data->settings as $setting) {
$this->settings->create((array) $setting);
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Genre;
use App\Services\Models\GenreService;
class GenreController {
private $genreService;
private $genre;
public function __construct(GenreService $genreService, Genre $genre)
{
$this->genreService = $genreService;
$this->genre = $genre;
}
public function allGenres()
{
return $this->genre->all();
}
public function updateGenreLists()
{
$this->genreService->updateGenreLists();
}
}

View File

@ -78,11 +78,6 @@
$alternativeTitle->update();
}
public function updateGenre()
{
$this->itemService->updateGenre();
}
public function toggleEpisode($id)
{
if( ! $this->episodeService->toggleSeen($id)) {

View File

@ -23,6 +23,11 @@
{
return $this->tmdb->suggestions($mediaType, $tmdbId);
}
public function genre($genre)
{
return $this->tmdb->byGenre($genre);
}
public function trending()
{

View File

@ -2,12 +2,18 @@
namespace App;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
class Item extends Model {
protected $dates = ['last_seen'];
protected $dates = [
'last_seen_at',
'refreshed_at',
'created_at',
'updated_at',
];
protected $with = ['genre'];
protected $fillable = [
'tmdb_id',
@ -16,8 +22,8 @@
'poster',
'media_type',
'rating',
//'genre',
'released',
'genre',
'fp_name',
'src',
'subtitles',
@ -32,6 +38,7 @@
'youtube_key',
'slug',
'watchlist',
'refreshed_at',
];
/**
@ -50,14 +57,14 @@
'poster' => $data['poster'] ? $data['poster'] : '',
'rating' => 0,
'released' => $data['released'],
'genre' => $data['genre'],
//'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(),
'last_seen_at' => now(),
'slug' => $data['slug'],
]);
}
@ -79,7 +86,7 @@
'released' => time(),
'src' => $data['src'],
'subtitles' => $data['subtitles'],
'last_seen_at' => Carbon::now(),
'last_seen_at' => now(),
]);
}
@ -90,13 +97,18 @@
public function updateLastSeenAt($tmdbId)
{
return $this->where('tmdb_id', $tmdbId)->update([
'last_seen_at' => Carbon::now(),
'last_seen_at' => now(),
]);
}
/*
* Relations
*/
public function genre()
{
return $this->belongsToMany(Genre::class);
}
public function episodes()
{
@ -125,6 +137,13 @@
/*
* Scopes
*/
public function scopeFindByGenreId($query, $genreId)
{
return $query->orWhereHas('genre', function($query) use ($genreId) {
$query->where('genre_id', $genreId);
});
}
public function scopeFindByTmdbId($query, $tmdbId)
{

View File

@ -7,7 +7,6 @@
use App\Services\Models\EpisodeService;
use App\Services\Models\ItemService;
use App\Setting;
use Carbon\Carbon;
use GuzzleHttp\Client;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB;
@ -51,6 +50,7 @@
public function fetch()
{
logInfo("Making request to flox-file-parser...");
$timestamp = $this->lastFetched()['last_fetch_to_file_parser'];
$fpUrl = config('services.fp.host') . ':' . config('services.fp.port');
$fpUri = '/fetch/' . $timestamp;
@ -77,7 +77,7 @@
foreach((array) $files as $type => $items) {
$this->itemCategory = $type;
foreach($items as $item) {
try {
logInfo("Updating data");
@ -100,6 +100,7 @@
*
* @param $item
* @return bool|mixed|void
* @throws \Exception
*/
private function handleStatus($item)
{
@ -241,7 +242,7 @@
// Otherwise create a new item from the result.
$created = $this->itemService->create($firstResult);
return $this->store($item, $created->tmdb_id);
}
@ -376,7 +377,7 @@
private function updateLastFetched()
{
Setting::first()->update([
'last_fetch_to_file_parser' => Carbon::now(),
'last_fetch_to_file_parser' => now(),
]);
}

View File

@ -18,8 +18,6 @@
* @param Model $model
* @param TMDB $tmdb
* @param Item $item
* @internal param ItemService $itemService
* @internal param Item $item
*/
public function __construct(Model $model, TMDB $tmdb, Item $item)
{

View File

@ -0,0 +1,55 @@
<?php
namespace App\Services\Models;
use App\Genre as Model;
use App\Services\TMDB;
use Illuminate\Support\Facades\DB;
class GenreService {
private $model;
private $tmdb;
/**
* @param Model $model
* @param TMDB $tmdb
*/
public function __construct(Model $model, TMDB $tmdb)
{
$this->model = $model;
$this->tmdb = $tmdb;
}
/**
* Sync the pivot table genre_item.
*
* @param $item
* @param $ids
*/
public function sync($item, $ids)
{
$item->genre()->sync($ids);
}
/**
* Update the genres table.
*/
public function updateGenreLists()
{
$genres = $this->tmdb->getGenreLists();
DB::beginTransaction();
foreach($genres as $mediaType) {
foreach($mediaType->genres as $genre) {
$this->model->firstOrCreate(
['id' => $genre->id],
['name' => $genre->name]
);
}
}
DB::commit();
}
}

View File

@ -8,6 +8,7 @@
use App\Services\TMDB;
use GuzzleHttp\Client;
use App\Setting;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
class ItemService {
@ -19,6 +20,7 @@
private $episodeService;
private $imdb;
private $setting;
private $genreService;
/**
* @param Model $model
@ -26,6 +28,7 @@
* @param Storage $storage
* @param AlternativeTitleService $alternativeTitleService
* @param EpisodeService $episodeService
* @param GenreService $genreService
* @param IMDB $imdb
* @param Setting $setting
*/
@ -35,6 +38,7 @@
Storage $storage,
AlternativeTitleService $alternativeTitleService,
EpisodeService $episodeService,
GenreService $genreService,
IMDB $imdb,
Setting $setting
){
@ -45,6 +49,7 @@
$this->episodeService = $episodeService;
$this->imdb = $imdb;
$this->setting = $setting;
$this->genreService = $genreService;
}
/**
@ -53,16 +58,21 @@
*/
public function create($data)
{
DB::beginTransaction();
$data = $this->makeDataComplete($data);
$item = $this->model->store($data);
$this->episodeService->create($item);
$this->genreService->sync($item, $data['genre_ids']);
$this->alternativeTitleService->create($item);
$this->storage->downloadImages($item->poster, $item->backdrop);
return $item;
DB::commit();
return $item->fresh();
}
/**
@ -110,9 +120,11 @@
public function refreshAll()
{
increaseTimeLimit();
$this->genreService->updateGenreLists();
$this->model->all()->each(function($item) {
$this->refresh($item->id);
return $this->model->orderBy('refreshed_at')->get()->each(function($item) {
return $this->refresh($item->id, true);
});
}
@ -160,6 +172,11 @@
$this->episodeService->create($item);
$this->alternativeTitleService->create($item);
$this->genreService->sync(
$item,
collect($details->genres)->pluck('id')->all()
);
$this->storage->downloadImages($item->poster, $item->backdrop);
}
@ -322,26 +339,7 @@
{
return $this->model->findByTitle($title)->with('latestEpisode')->withCount('episodesWithSrc')->get();
}
/**
* Parse full genre list of all movies and tv shows in our database and save them.
*/
public function updateGenre()
{
increaseTimeLimit();
$items = $this->model->all();
$items->each(function($item) {
$genres = $this->tmdb->details($item->tmdb_id, $item->media_type)->genres;
$data = collect($genres)->pluck('name')->all();
$item->update([
'genre' => implode($data, ', '),
]);
});
}
/**
* See if we can find a item by title, fp_name, tmdb_id or src in our database.
*

View File

@ -3,6 +3,7 @@
namespace App\Services;
use App\Services\Models\ItemService;
use Symfony\Component\HttpFoundation\Response;
class Subpage {
@ -22,6 +23,11 @@
}
$found = $this->tmdb->details($tmdbId, $mediaType);
if( ! (array) $found) {
return response('Not found', Response::HTTP_NOT_FOUND);
}
$found->genre_ids = collect($found->genres)->pluck('id')->all();
$item = $this->tmdb->createItem($found, $mediaType);

View File

@ -2,6 +2,7 @@
namespace App\Services;
use App\Genre;
use App\Item;
use Carbon\Carbon;
use GuzzleHttp\Client;
@ -15,7 +16,7 @@
private $client;
private $apiKey;
private $translation;
private $base = 'https://api.themoviedb.org';
/**
@ -33,7 +34,7 @@
/**
* Search TMDb by 'title'.
*
* @param $title
* @param $title
* @param null $mediaType
* @return Collection
*/
@ -169,25 +170,58 @@
return $this->filterItems($cache);
}
/**
* Search TMDb by genre.
*
* @param $genre
* @return array
*/
public function byGenre($genre)
{
$genreId = Genre::findByName($genre)->firstOrFail()->id;
$cache = Cache::remember('genre-' . $genre, $this->untilEndOfDay(), function() use ($genreId) {
$responseMovies = $this->requestTmdb($this->base . '/3/discover/movie', ['with_genres' => $genreId]);
$responseTv = $this->requestTmdb($this->base . '/3/discover/tv', ['with_genres' => $genreId]);
$movies = collect($this->createItems($responseMovies, 'movie'));
$tv = collect($this->createItems($responseTv, 'tv'));
return $tv->merge($movies)->shuffle();
});
//$inDB = Item::findByGenreId($genreId)->get();
return $this->filterItems($cache, $genreId);
}
/**
* Merge the response with items from our database.
*
* @param Collection $items
* @param null $genreId
* @return array
*/
private function filterItems($items)
private function filterItems($items, $genreId = null)
{
$allId = $items->pluck('tmdb_id');
// Get all movies/tv shows from trending/upcoming that are already in the database.
$inDB = Item::whereIn('tmdb_id', $allId)->withCount('episodesWithSrc')->get()->toArray();
// Get all movies / tv shows that are already in our database.
$searchInDB = Item::whereIn('tmdb_id', $allId)->withCount('episodesWithSrc');
if($genreId) {
$searchInDB->findByGenreId($genreId);
}
// Remove all inDB movies / tv shows from trending / upcoming.
$filtered = $items->filter(function($item) use ($inDB) {
return ! in_array($item['tmdb_id'], array_column($inDB, 'tmdb_id'));
$foundInDB = $searchInDB->get()->toArray();
// Remove them from the TMDb response.
$filtered = $items->filter(function($item) use ($foundInDB) {
return ! in_array($item['tmdb_id'], array_column($foundInDB, 'tmdb_id'));
});
$merged = $filtered->merge($inDB);
$merged = $filtered->merge($foundInDB);
// Reset array keys to display inDB items first.
return array_values($merged->reverse()->toArray());
@ -199,8 +233,8 @@
}
/**
* @param $response
* @param $mediaType
* @param $response
* @param $mediaType
* @return array
*/
private function createItems($response, $mediaType)
@ -222,7 +256,7 @@
);
$title = $data->name ?? $data->title;
return [
'tmdb_id' => $data->id,
'title' => $title,
@ -231,7 +265,8 @@
'poster' => $data->poster_path,
'media_type' => $mediaType,
'released' => $release->getTimestamp(),
'genre' => $this->parseGenre($data->genre_ids),
'genre_ids' => $data->genre_ids,
'genre' => Genre::whereIn('id', $data->genre_ids)->get(),
'episodes' => [],
'overview' => $data->overview,
'backdrop' => $data->backdrop_path,
@ -246,12 +281,12 @@
'api_key' => $this->apiKey,
'language' => strtolower($this->translation)
], $query);
try {
$response = $this->client->get($url, [
'query' => $query
]);
if($this->hasLimitRemaining($response)) {
return $response;
}
@ -367,57 +402,16 @@
}
/**
* Create genre string from genre_ids.
*
* @param $ids
* @return string
* Get the lists of genres from TMDb for tv shows and movies.
*/
private function parseGenre($ids)
{
$genre = [];
foreach($ids as $id) {
$genre[] = $this->genreList()[$id] ?? '';
}
return implode($genre, ', ');
}
/**
* Current genre list from TMDb.
*
* @return array
*/
private function genreList()
public function getGenreLists()
{
$movies = $this->requestTmdb($this->base . '/3/genre/movie/list');
$tv = $this->requestTmdb($this->base . '/3/genre/tv/list');
return [
28 => 'Action',
12 => 'Adventure',
16 => 'Animation',
35 => 'Comedy',
80 => 'Crime',
99 => 'Documentary',
18 => 'Drama',
10751 => 'Family',
14 => 'Fantasy',
36 => 'History',
27 => 'Horror',
10402 => 'Music',
9648 => 'Mystery',
10749 => 'Romance',
878 => 'Sci-Fi',
10770 => 'TV Movie',
53 => 'Thriller',
10752 => 'War',
37 => 'Western',
10759 => 'Action & Adventure',
10762 => 'Kids',
10763 => 'News',
10764 => 'Reality',
10765 => 'Sci-Fi & Fantasy',
10766 => 'Soap',
10767 => 'Talk',
10768 => 'War & Politics',
'movies' => json_decode($movies->getBody()),
'tv' => json_decode($tv->getBody()),
];
}
@ -429,7 +423,7 @@
{
if($response->getStatusCode() == 429) {
return false;
}
}
return ((int) $response->getHeader('X-RateLimit-Remaining')[0]) > 1;
}
@ -439,6 +433,6 @@
*/
private function untilEndOfDay()
{
return Carbon::now()->secondsUntilEndOfDay() / 60;
return now()->secondsUntilEndOfDay() / 60;
}
}

View File

@ -19,4 +19,11 @@
'show_watchlist_everywhere',
'show_ratings',
];
protected $casts = [
'show_date' => 'boolean',
'show_genre' => 'boolean',
'episode_spoiler_protection' => 'boolean',
'show_watchlist_everywhere' => 'boolean',
];
}

View File

@ -25,7 +25,7 @@
return [
'poster' => '',
'rating' => 1,
'genre' => '',
//'genre' => '',
'released' => time(),
'last_seen_at' => Carbon::now(),
'src' => null,

View File

@ -14,10 +14,5 @@ class AddReleaseDatesToEpisodes extends Migration
});
}
public function down()
{
Schema::table('episodes', function (Blueprint $table) {
//
});
}
public function down() {}
}

View File

@ -0,0 +1,37 @@
<?php
use App\Services\Models\GenreService;
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateGenresTable extends Migration
{
private $genreService;
public function __construct()
{
$this->genreService = app(GenreService::class);
}
public function up()
{
Schema::create('genres', function (Blueprint $table) {
$table->integer('id');
$table->string('name');
});
if( ! app()->runningUnitTests()) {
try {
$this->genreService->updateGenreLists();
} catch (\Exception $e) {
echo 'Can not connect to the TMDb Service on "CreateGenresTable". Error: ' . $e->getMessage();
echo 'Make sure you set your TMDb API Key in .env';
abort(500);
}
}
}
public function down() {}
}

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateGenreItemTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('genre_item', function (Blueprint $table) {
$table->increments('id');
$table->integer('item_id');
$table->integer('genre_id');
});
// We dont need the genre column more.
Schema::table('items', function (Blueprint $table) {
$table->dropColumn('genre');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('genre_item');
}
}

View File

@ -12,6 +12,8 @@
Route::get('/imdb-rating/{imdbId}', 'SubpageController@imdbRating');
Route::get('/suggestions/{tmdbID}/{mediaType}', 'TMDBController@suggestions');
Route::get('/genres', 'GenreController@allGenres');
Route::get('/genre/{genre}', 'TMDBController@genre');
Route::get('/trending', 'TMDBController@trending');
Route::get('/upcoming', 'TMDBController@upcoming');
Route::get('/now-playing', 'TMDBController@nowPlaying');
@ -28,6 +30,8 @@
Route::post('/watchlist', 'ItemController@watchlist');
Route::patch('/update-alternative-titles/{tmdbId?}', 'ItemController@updateAlternativeTitles');
Route::patch('/update-genre', 'ItemController@updateGenre');
// todo: in patch
Route::get('/update-genre-lists', 'GenreController@updateGenreLists');
Route::patch('/toggle-episode/{id}', 'ItemController@toggleEpisode');
Route::patch('/toggle-season', 'ItemController@toggleSeason');
Route::patch('/change-rating/{itemId}', 'ItemController@changeRating');

View File

@ -3,7 +3,8 @@
namespace Tests;
use Illuminate\Contracts\Console\Kernel;
use Illuminate\Support\Facades\Hash;
trait CreatesApplication {
/**
@ -16,7 +17,9 @@
$app = require __DIR__ . '/../bootstrap/app.php';
$app->make(Kernel::class)->bootstrap();
Hash::setRounds(4);
return $app;
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace Tests\Services;
use App\Genre;
use App\Item;
use App\Services\Models\GenreService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
use Tests\Traits\Factories;
use Tests\Traits\Fixtures;
use Tests\Traits\Mocks;
class GenreServiceTest extends TestCase {
use RefreshDatabase;
use Factories;
use Fixtures;
use Mocks;
/** @test */
public function it_should_update_the_genre_table()
{
$this->createGuzzleMock(
$this->tmdbFixtures('movie/genres'),
$this->tmdbFixtures('tv/genres')
);
$service = app(GenreService::class);
$genresBeforeUpdate = Genre::all();
$service->updateGenreLists();
$genresAfterUpdate = Genre::all();
$this->assertCount(0, $genresBeforeUpdate);
$this->assertCount(27, $genresAfterUpdate);
}
/** @test */
public function it_should_sync_genres_for_an_item()
{
$genreIds = [28, 12, 16];
$this->createGuzzleMock(
$this->tmdbFixtures('movie/genres'),
$this->tmdbFixtures('tv/genres')
);
$item = $this->createMovie();
$service = app(GenreService::class);
$service->updateGenreLists();
$itemBeforeUpdate = Item::first();
$service->sync($item, $genreIds);
$itemAfterUpdate = Item::first();
$this->assertCount(0, $itemBeforeUpdate->genre);
$this->assertCount(count($genreIds), $itemAfterUpdate->genre);
}
}

View File

@ -162,38 +162,6 @@
$this->assertNotNull($item1);
$this->assertNull($item2);
}
/** @test */
public function it_should_update_genre_for_a_movie()
{
$user = $this->createUser();
$this->createMovie();
$this->createGuzzleMock($this->tmdbFixtures('movie/details'));
$withoutGenre = Item::find(1);
$this->actingAs($user)->patchJson('api/update-genre');
$withGenre = Item::find(1);
$this->assertEmpty($withoutGenre->genre);
$this->assertNotEmpty($withGenre->genre);
}
/** @test */
public function it_should_update_genre_for_a_tv_show()
{
$user = $this->createUser();
$this->createTv();
$this->createGuzzleMock($this->tmdbFixtures('tv/details'));
$withoutGenre = Item::find(1);
$this->actingAs($user)->patchJson('api/update-genre');
$withGenre = Item::find(1);
$this->assertEmpty($withoutGenre->genre);
$this->assertNotEmpty($withGenre->genre);
}
/** @test */
public function it_should_parse_correct_imdb_id()

View File

@ -53,6 +53,24 @@
$this->assertTrue($hasTv);
$this->assertFalse($hasMovie);
}
/** @test */
public function it_should_fetch_all_genres()
{
$this->createGuzzleMock(
$this->tmdbFixtures('movie/genres'),
$this->tmdbFixtures('tv/genres')
);
$tmdb = app(TMDB::class);
$genres = $tmdb->getGenreLists();
$this->assertArrayHasKey('tv', $genres);
$this->assertArrayHasKey('movies', $genres);
$this->assertCount(16, $genres['tv']->genres);
$this->assertCount(19, $genres['movies']->genres);
}
/** @test */
public function it_should_only_search_for_movies()

View File

@ -6,5 +6,6 @@
"media_type": "movie",
"released": 1464185524,
"genre": "Adventure, Fantasy, Action",
"genre_ids": [1,2,3],
"episodes": []
}
}

View File

@ -6,5 +6,6 @@
"media_type": "tv",
"released": 1303051450,
"genre": "Sci-Fi & Fantasy, Action & Adventure, Drama",
"genre_ids": [1,2,3],
"episodes": []
}
}

View File

@ -0,0 +1,80 @@
{
"genres": [
{
"id": 28,
"name": "Action"
},
{
"id": 12,
"name": "Adventure"
},
{
"id": 16,
"name": "Animation"
},
{
"id": 35,
"name": "Comedy"
},
{
"id": 80,
"name": "Crime"
},
{
"id": 99,
"name": "Documentary"
},
{
"id": 18,
"name": "Drama"
},
{
"id": 10751,
"name": "Family"
},
{
"id": 14,
"name": "Fantasy"
},
{
"id": 36,
"name": "History"
},
{
"id": 27,
"name": "Horror"
},
{
"id": 10402,
"name": "Music"
},
{
"id": 9648,
"name": "Mystery"
},
{
"id": 10749,
"name": "Romance"
},
{
"id": 878,
"name": "Science Fiction"
},
{
"id": 10770,
"name": "TV Movie"
},
{
"id": 53,
"name": "Thriller"
},
{
"id": 10752,
"name": "War"
},
{
"id": 37,
"name": "Western"
}
]
}

View File

@ -0,0 +1,68 @@
{
"genres": [
{
"id": 10759,
"name": "Action & Adventure"
},
{
"id": 16,
"name": "Animation"
},
{
"id": 35,
"name": "Comedy"
},
{
"id": 80,
"name": "Crime"
},
{
"id": 99,
"name": "Documentary"
},
{
"id": 18,
"name": "Drama"
},
{
"id": 10751,
"name": "Family"
},
{
"id": 10762,
"name": "Kids"
},
{
"id": 9648,
"name": "Mystery"
},
{
"id": 10763,
"name": "News"
},
{
"id": 10764,
"name": "Reality"
},
{
"id": 10765,
"name": "Sci-Fi & Fantasy"
},
{
"id": 10766,
"name": "Soap"
},
{
"id": 10767,
"name": "Talk"
},
{
"id": 10768,
"name": "War & Politics"
},
{
"id": 37,
"name": "Western"
}
]
}

View File

@ -1,5 +1,13 @@
{
"presets": ["es2015", "stage-2"],
"plugins": ["transform-runtime"],
"plugins": [
"transform-runtime",
["component", [
{
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}
]]
],
"comments": false
}
}

View File

@ -3,13 +3,17 @@ require('../resources/sass/app.scss');
import Vue from 'vue';
import { mapState, mapActions, mapMutations } from 'vuex'
import { Checkbox } from 'element-ui';
Vue.use(Checkbox);
import SiteHeader from './components/Header.vue';
import SiteFooter from './components/Footer.vue';
import Login from './components/Login.vue';
import Modal from './components/Modal/Index.vue';
import router from './routes';
import store from './store/index';
import store from './store';
const App = new Vue({
store,

View File

@ -1,6 +1,6 @@
<template>
<main>
<div class="content-submenu">
<div class="content-submenu" v-if=" ! loading && items.length">
<div class="sort-wrap no-select">
<div class="sort-direction" @click="setUserSortDirection()">
<i v-if="userSortDirection == 'asc'">&#8593;</i>
@ -139,4 +139,4 @@
}
}
}
</script>
</script>

View File

@ -22,10 +22,12 @@
</div>
<div class="item-content">
<span v-if="date == 1" class="item-year">{{ released }} <i>{{ localItem.media_type }}</i></span>
<i class="item-has-src" v-if="hasSrc"></i>
<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>
<span v-if="date == 1" class="item-year">{{ released }} <i>{{ lang(localItem.media_type) }}</i></span>
<router-link :to="{ name: `subpage-${localItem.media_type}`, params: { tmdbId: localItem.tmdb_id }}" class="item-title" :title="localItem.title">
<i class="item-has-src" v-if="hasSrc"></i>
{{ localItem.title }}
</router-link>
<span v-if="genre == 1" class="item-genre">{{ genreAsString(localItem.genre) }}</span>
</div>
</div>
</transition>

View File

@ -1,18 +1,20 @@
<template>
<div class="settings-box no-select" v-if=" ! loading">
<div class="settings-box element-ui-checkbox no-select" v-if=" ! loading">
<div class="setting-box">
<input type="checkbox" value="genre" v-model="genre" id="genre" @change="updateOptions"><label for="genre">{{ lang('display genre') }}</label>
<el-checkbox v-model="genre" @change="updateOptions">{{ lang('display genre') }}</el-checkbox>
</div>
<div class="setting-box">
<input type="checkbox" value="date" v-model="date" id="date" @change="updateOptions"><label for="date">{{ lang('display date') }}</label>
<el-checkbox v-model="date" @change="updateOptions">{{ lang('display date') }}</el-checkbox>
</div>
<div class="setting-box">
<input type="checkbox" value="spoiler" v-model="spoiler" id="spoiler" @change="updateOptions"><label for="spoiler">{{ lang('spoiler') }}</label>
<el-checkbox v-model="spoiler" @change="updateOptions">{{ lang('spoiler') }}</el-checkbox>
</div>
<div class="setting-box">
<input type="checkbox" value="watchlist" v-model="watchlist" id="watchlist" @change="updateOptions"><label for="watchlist">{{ lang('show watchlist') }}</label>
<el-checkbox v-model="watchlist" @change="updateOptions">{{ lang('show watchlist') }}</el-checkbox>
</div>
<div class="setting-box select-box">
<label for="ratings">{{ lang('show own ratings') }}</label>
<select id="ratings" v-model="ratings" @change="updateOptions">
@ -63,6 +65,7 @@
const data = response.data;
this.SET_LOADING(false);
console.log(data)
this.genre = data.genre;
this.date = data.date;
this.spoiler = data.spoiler;

View File

@ -18,9 +18,11 @@
<!-- todo: move to own component -->
<div class="big-teaser-item-data">
<span class="item-year">{{ released }}, <i>{{ item.media_type }}</i></span>
<span class="item-year">{{ released }}, <i>{{ lang(item.media_type) }}</i></span>
<span class="item-title">{{ item.title }}</span>
<span class="item-genre">{{ item.genre }}</span>
<span class="item-genre">
<router-link :key="genre.id" :to="'/genre/' + genre.name" v-for="genre in item.genre">{{ genre.name }}</router-link>
</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> {{ lang('watch trailer') }}</span>
@ -207,8 +209,9 @@
this.disableLoading();
this.fetchImdbRating();
}, error => {
alert(error);
console.log(error);
this.SET_LOADING(false);
this.$router.push('/');
});
},

View File

@ -1,8 +1,23 @@
<template>
<main :class="{'display-suggestions': path === 'suggestions'}">
<div class="content-submenu" v-if=" ! loading && items.length">
<div class="sort-wrap no-select" v-if="isGenrePage">
<div class="filter-wrap">
<span class="current-filter" @click="toggleShowGenres()">{{ currentGenre }} <span class="arrow-down"></span></span>
<ul class="all-filters" :class="{active: showFilters}">
<router-link :to="'/genre/' + genre.name" v-if="genre.name !== currentGenre" v-for="genre in genres" :key="genre.id">{{ genre.name }}</router-link>
</ul>
</div>
<div class="show-watchlist-items element-ui-checkbox" @click="toggleWatchlistItems()">
<el-checkbox v-model="showWatchlistItems">Watchlist</el-checkbox>
</div>
</div>
</div>
<div class="wrap-content" v-if=" ! loading">
<div class="items-wrap">
<Item v-for="(item, index) in items"
v-if=" ! item.watchlist || (showWatchlistItems && item.watchlist)"
:item="item"
:key="index"
:genre="true"
@ -35,6 +50,10 @@
data() {
return {
items: [],
genres: [],
isGenrePage: false,
currentGenre: '',
showWatchlistItems: false,
path: '',
displayRatings: null
}
@ -42,12 +61,13 @@
computed: {
...mapState({
loading: state => state.loading
loading: state => state.loading,
showFilters: state => state.showFilters
})
},
methods: {
...mapMutations([ 'SET_LOADING' ]),
...mapMutations([ 'SET_LOADING', 'SET_SHOW_FILTERS' ]),
...mapActions([ 'setPageTitle' ]),
init() {
@ -57,6 +77,9 @@
switch(this.path) {
case 'suggestions':
return this.initSuggestions();
case 'genre':
this.isGenrePage = true;
return this.initContentByGenre();
case 'trending':
case 'upcoming':
case 'now-playing':
@ -64,6 +87,38 @@
}
},
toggleWatchlistItems() {
this.showWatchlistItems = ! this.showWatchlistItems;
},
toggleShowGenres() {
this.SET_SHOW_FILTERS( ! this.showFilters);
},
initAllGenres() {
http(`${config.api}/genres`).then(response => {
this.genres = response.data;
}, error => {
console.log(error);
})
},
initContentByGenre() {
this.initAllGenres();
this.currentGenre = this.$route.params.genre;
this.setPageTitle(this.lang('genre'));
http(`${config.api}/genre/${this.currentGenre}`).then(response => {
this.items = response.data;
this.SET_LOADING(false);
}, error => {
console.log(error);
this.$router.push('/')
})
},
initSuggestions() {
const tmdbID = this.$route.query.for;
const type = this.$route.query.type;

View File

@ -27,7 +27,7 @@
<script>
import Search from './Search.vue';
import MiscHelper from '../helpers/misc';
import store from '../store/index';
import store from '../store';
import { mapActions, mapState } from 'vuex'

View File

@ -15,6 +15,14 @@ export default {
});
}
},
genreAsString(genre) {
if(typeof genre == 'object') {
return genre.map(item => item.name).join(', ');
}
return genre
},
displaySeason(item) {
return item.media_type == 'tv' && item.rating != null && item.tmdb_id && ! item.watchlist;
@ -68,4 +76,4 @@ export default {
return '01';
}
}
}
}

View File

@ -21,7 +21,7 @@ export default new Router({
{ path: '/movies', component: Content, name: 'movie' },
{ path: '/tv', component: Content, name: 'tv' },
{ path: '/watchlist/:type?', component: Content, name: 'watchlist' },
{ path: '/movies/:tmdbId/:slug?', component: Subpage, name: 'subpage-movie', props: {mediaType: 'movie'} },
{ path: '/tv/:tmdbId/:slug?', component: Subpage, name: 'subpage-tv', props: {mediaType: 'tv'} },
@ -31,6 +31,7 @@ export default new Router({
{ path: '/trending', component: TMDBContent, name: 'trending' },
{ path: '/upcoming', component: TMDBContent, name: 'upcoming' },
{ path: '/now-playing', component: TMDBContent, name: 'now-playing' },
{ path: '/genre/:genre', component: TMDBContent, name: 'genre' },
{ path: '*', redirect: '/' }
]

8688
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,9 @@
"axios": "^0.17.0",
"babel-runtime": "^6.26.0",
"debounce": "^1.1.0",
"element-ui": "^2.0.10",
"vue": "^2.5.2",
"vue-checkbox-radio": "^0.6.0",
"vue-router": "^3.0.1",
"vuex": "^3.0.0"
},
@ -16,6 +18,7 @@
"autoprefixer": "^7.1.6",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-plugin-component": "^1.0.0",
"babel-plugin-transform-runtime": "^6.23.0",
"babel-preset-es2015": "^6.24.1",
"babel-preset-stage-2": "^6.24.1",

View File

@ -9,7 +9,7 @@
<title>Flox</title>
<link rel="stylesheet" href="{{ url('assets/app.css') }}">
<link href="{{ url('assets/favicon.ico?v=3') }}" rel="icon" type="image/x-icon">
</head>
<body
data-url="{{ url('/') }}"
@ -32,7 +32,7 @@
<site-footer></site-footer>
@endif
</div>
<script src="{{ url('assets/vendor.js') }}"></script>
<script src="{{ url('assets/app.js') }}"></script>

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "تلفاز",
"movies": "أفلام",
"movie": "Movie",
"last seen": "آخر المشاهدة",
"own rating": "Own Rating",
"title": "Title",

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "TV",
"movies": "Movies",
"movie": "Movie",
"last seen": "Sidst set",
"own rating": "Own Rating",
"title": "Title",

View File

@ -20,6 +20,7 @@
"now playing": "Aktuell",
"tv": "TV",
"movies": "Filme",
"movie": "Film",
"last seen": "Zuletzt gesehen",
"own rating": "Eigene Bewertung",
"title": "Titel",

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "TV",
"movies": "Ταινίες",
"movie": "Movie",
"last seen": "Τελευταία προβολή",
"own rating": "Own Rating",
"title": "Title",

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "TV",
"movies": "Movies",
"movie": "Movie",
"last seen": "Last seen",
"own rating": "Own Rating",
"title": "Title",

View File

@ -19,6 +19,7 @@
"now playing": "Now Playing",
"tv": "TV",
"movies": "Películas",
"movie": "Movie",
"last seen": "Última vista",
"own rating": "Own Rating",
"title": "Title",

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "Séries TV",
"movies": "Films",
"movie": "Movie",
"last seen": "Dernier film/épisode vu",
"own rating": "Own Rating",
"title": "Title",

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "TV",
"movies": "Films",
"movie": "Movie",
"last seen": "Laatst gezien",
"own rating": "Own Rating",
"title": "Title",

View File

@ -20,6 +20,7 @@
"now playing": "Now Playing",
"tv": "Сериалы",
"movies": "Фильмы",
"movie": "Movie",
"last seen": "Последние просмотренные",
"own rating": "Own Rating",
"title": "Title",

29
client/resources/sass/_element-ui.scss vendored Normal file
View File

@ -0,0 +1,29 @@
@import '../../node_modules/element-ui/lib/theme-chalk/checkbox.css';
.element-ui-checkbox {
.el-checkbox__inner {
background: transparent;
border-radius: 0 !important;
transition: none;
border: 1px solid darken(#626262, 10%) !important;
&:after {
transition: none;
}
}
.el-checkbox__label {
color: #888 !important;
padding-left: 7px;
.dark & {
color: #626262 !important;
}
}
.el-checkbox__input.is-checked .el-checkbox__inner,
.el-checkbox__input.is-indeterminate .el-checkbox__inner {
border: 1px solid transparent !important;
background: $main2;
}
}

View File

@ -4,6 +4,7 @@ $dark: #484848;
$rating1: #6bb01a;
$rating2: #da9527;
$rating3: #cd2727;
$gradient: linear-gradient(to right, $main1, $main2);
@mixin transition($type, $type2: '', $type3: '') {
@if $type3 != '' {

View File

@ -6,10 +6,12 @@
'shake',
'base',
'element-ui',
'components/header',
'components/search',
'components/content',
'components/subpage',
'components/login',
'components/footer',
'components/modal';
'components/modal';

View File

@ -139,6 +139,10 @@ main {
//border: 1px solid $main2;
}
}
&:active {
box-shadow: 0 0 2px 2px $main1;
}
}
.item-image {
@ -224,7 +228,8 @@ main {
.item-has-src {
float: left;
margin: 5px 0 0 0;
//margin: 5px 0 0 0;
margin: 7px 6px 0 0;
font-style: normal;
width: 12px;
height: 9px;
@ -444,7 +449,7 @@ main {
right: 0;
opacity: 0;
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
padding: 3px 6px;
cursor: pointer;
text-transform: uppercase;
@ -493,7 +498,8 @@ main {
visibility: visible;
}
.fade-enter {
.fade-enter,
.fade-leave {
top: -10px;
opacity: 0;
visibility: hidden;
@ -582,7 +588,8 @@ main {
position: relative;
}
.current-filter {
.current-filter,
.show-watchlist-items {
float: left;
padding: 7px 9px;
color: #888;
@ -593,6 +600,10 @@ main {
}
}
.show-watchlist-items {
cursor: pointer;
}
.arrow-down {
display: inline-block;
width: 0;
@ -614,6 +625,8 @@ main {
//display: none;
opacity: 0;
visibility: hidden;
max-height: 300px;
overflow-y: auto;
@include transition(opacity, top);
@ -624,12 +637,14 @@ main {
visibility: visible;
}
li {
li,
a {
float: left;
padding: 5px 10px;
color: rgba(#fff, .9);
font-size: 14px;
width: 100%;
text-decoration: none;
border-bottom: 1px solid rgba(#fff, .3);
@include transition(background);
@ -873,7 +888,7 @@ main {
.setting-btn {
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
color: #fff;
font-size: 17px;
border: 0;

View File

@ -3,7 +3,7 @@ footer {
width: 100%;
float: left;
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
.open-modal & {
padding: 40px 16px 0 0;
@ -91,4 +91,4 @@ footer {
&:active {
opacity: .6;
}
}
}

View File

@ -10,7 +10,7 @@
header {
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
width: 100%;
padding: 25px 0;
position: relative;

View File

@ -11,7 +11,7 @@
width: 100%;
height: 30px;
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
}
.logo-login {
@ -38,7 +38,7 @@
input[type="submit"] {
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
color: #fff;
font-size: 17px;
border: 0;
@ -64,4 +64,4 @@
color: $rating3;
font-size: 14px;
}
}
}

View File

@ -43,7 +43,7 @@
.modal-header {
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
color: #fff;
float: left;
font-size: 20px;
@ -261,4 +261,4 @@
&.seen i {
background: url(../../../public/assets/img/seen-active.png) no-repeat;
}
}
}

View File

@ -24,7 +24,7 @@
top: 0;
left: 0;
background: $main2;
background: linear-gradient(to right, $main1, $main2);
background: $gradient;
width: 100%;
//height: 65vh;
//height: 600px;
@ -206,13 +206,36 @@
.item-genre {
color: rgba(#fff, .8);
a {
color: rgba(#fff, .8);
text-decoration: none;
background: rgba($dark, .5);
font-size: 14px;
padding: 3px 7px;
margin: 0 5px 0 0;
@include transition(background);
&:last-child {
margin: 0;
}
&:hover {
background: $dark;
}
&:active {
background: rgba($dark, .7);
}
}
i {
font-style: normal;
text-transform: uppercase;
}
@include media(4) {
font-size: 14px;
//font-size: 14px;
}
}
@ -260,6 +283,7 @@
.subpage-content {
width: 100%;
float: left;
padding: 0 0 50px 0;
//padding: calc(65vh - 90px) 0 0 0;
//padding: 520px 0 0 0;

View File

@ -31,7 +31,7 @@ module.exports = {
exclude: /node_modules/
},
{
test: /\.(png|jpg|svg)$/,
test: /\.(png|jpg|svg|woff|woff2|eot|ttf)$/,
use: {
loader: 'url-loader',
options: {
@ -42,7 +42,7 @@ module.exports = {
}
},
{
test: /\.scss$/,
test: /\.(scss|css)$/,
use: ExtractTextPlugin.extract({
fallback: 'style-loader',
use: ['css-loader', 'postcss-loader', 'sass-loader']