1
0
mirror of https://github.com/devfake/flox.git synced 2024-11-14 22:22:39 +01:00

Merge pull request #126 from devfake/feature/api-plex

Plex API
This commit is contained in:
Viktor Geringer 2019-12-25 17:06:56 +01:00 committed by GitHub
commit 0c260b5167
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1353 additions and 50 deletions

View File

@ -1,5 +1,7 @@
Flox
===============
[![Build Status](https://travis-ci.org/devfake/flox.svg?branch=master)](https://travis-ci.org/devfake/flox)
Flox is a self hosted Movie, Series and Animes watch list. It's build on top of Laravel and Vue.js and uses [The Movie Database](https://www.themoviedb.org/) API.
The rating based on an 3-Point system for `good`, `medium` and `bad`.
@ -33,17 +35,19 @@ php artisan flox:db # Running migrations and enter your admin credentials for th
* Set your `CLIENT_URI` in `backend/.env`.
```bash
# CLIENT_URI=/flox/public
http://localhost:8888/flox/public
https://localhost:8888/flox/public
# CLIENT_URI=/subfolder/for/flox/public
http://mydomain.com/subfolder/for/flox/public
https://mydomain.com/subfolder/for/flox/public
# CLIENT_URI=/
http://mydomain.com
https://mydomain.com
```
### Features
- API for Plex.
- Sync movies, shows and watched episodes from Plex to Flox.
- Episode Tracking.
- Suggestions.
- Watchlist.
@ -52,6 +56,17 @@ http://mydomain.com
- Calendar.
- A simple calendar for your episodes and movies.
- Movies and tv shows have different colors for better differentiation. You can also use the arrow keys to jump months forward or backward.
- Reminders.
### Plex
To enable the sync from Plex to Flox, you first need to generate an API-Key in Flox in the settings page. Then enter the Flox API-URL to the webhooks section in Plex.
```
https://YOUR-FLOX-URL/api/plex?token=YOUR-TOKEN
```
If you start a tv show or movie in Plex, Flox will search the item via the title from TMDb and add them into the Flox database. If you rate a movie or tv show in Plex, Flox will also rate the item. Note that rating for seasons or episodes are not supported in Flox. If you rate an movie or tv show, which is not in the Flox database, Flox will also fetch them from TMDb first. If you complete an episode (passing the 90% mark), Flox will also check this episode as seen.
### Update Process

View File

@ -73,6 +73,22 @@
return $query->where('tmdb_id', $tmdbId);
}
/**
* Scope to find the result via episode_number.
*/
public function scopeFindByEpisodeNumber($query, $number)
{
return $query->where('episode_number', $number);
}
/**
* Scope to find the result via season_number.
*/
public function scopeFindBySeasonNumber($query, $number)
{
return $query->where('season_number', $number);
}
/**
* Scope to find the result via src.
*/

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Controllers;
use App\Services\Api\Plex;
class ApiController {
/**
* @var Plex
*/
private $plex;
public function __construct(Plex $plex)
{
$this->plex = $plex;
}
public function plex()
{
$payload = json_decode(request('payload'), true);
$this->plex->handle($payload);
}
}

View File

@ -10,6 +10,7 @@
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class SettingController {
@ -68,6 +69,32 @@
];
}
/**
* @return string
*/
public function generateApiKey()
{
if (isDemo()) {
return response('Success', Response::HTTP_OK);
}
$key = Str::random(24);
Auth::user()->update([
'api_key' => $key,
]);
return $key;
}
/**
* @return string
*/
public function getApiKey()
{
return Auth::user()->api_key;
}
/**
* @return array
*/

View File

@ -2,6 +2,7 @@
namespace App\Http;
use App\Http\Middleware\VerifyApiKey;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
class Kernel extends HttpKernel
@ -53,5 +54,6 @@ class Kernel extends HttpKernel
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'csrf' => \App\Http\Middleware\VerifyCsrfToken::class,
'api_key' => VerifyApiKey::class,
];
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\User;
use Closure;
use Symfony\Component\HttpFoundation\Response;
class VerifyApiKey
{
/**
* @var User
*/
private $user;
public function __construct(User $user)
{
$this->user = $user;
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @return mixed
*/
public function handle($request, Closure $next)
{
if (!$request->token) {
return response(['message' => 'No token provided'], Response::HTTP_UNAUTHORIZED);
}
if (!$this->user->findByApiKey($request->token)->exists()) {
return response(['message' => 'No valid token provided'], Response::HTTP_UNAUTHORIZED);
}
return $next($request);
}
}

View File

@ -55,14 +55,16 @@
*/
public function store($data)
{
return $this->create([
return $this->firstOrCreate([
'tmdb_id' => $data['tmdb_id'],
], [
'title' => $data['title'],
'media_type' => $data['media_type'],
'original_title' => $data['original_title'],
'poster' => $data['poster'] ?? '',
'rating' => 0,
'released' => $data['released'],
'released_timestamp' => Carbon::parse($data['released']),
'overview' => $data['overview'],
'backdrop' => $data['backdrop'],
'tmdb_rating' => $data['tmdb_rating'],
@ -92,6 +94,7 @@
'poster' => '',
'rating' => 0,
'released' => time(),
'released_timestamp' => now(),
'src' => $data['src'],
'subtitles' => $data['subtitles'],
'last_seen_at' => now(),
@ -181,6 +184,14 @@
return $query->where('tmdb_id', $tmdbId);
}
/**
* Scope to find the result by year.
*/
public function scopeFindByYear($query, $year)
{
return $query->whereYear('released_timestamp', $year);
}
/**
* Scope to find the result via fp_name.
*/

View File

@ -0,0 +1,160 @@
<?php
namespace App\Services\Api;
use App\Episode;
use App\Item;
use App\Services\Models\ItemService;
use App\Services\TMDB;
use Symfony\Component\HttpFoundation\Response;
abstract class Api
{
/**
* @var array
*/
protected $data = [];
/**
* @var Item
*/
private $item;
/**
* @var TMDB
*/
private $tmdb;
/**
* @var ItemService
*/
private $itemService;
/**
* @var Episode
*/
private $episode;
public function __construct(Item $item, TMDB $tmdb, ItemService $itemService, Episode $episode)
{
$this->item = $item;
$this->tmdb = $tmdb;
$this->itemService = $itemService;
$this->episode = $episode;
}
public function handle(array $data)
{
logInfo('api data:', $data);
$this->data = $data;
if ($this->abortRequest()) {
abort(Response::HTTP_NOT_IMPLEMENTED);
}
$found = $this->item
->findByTitle($this->getTitle(), $this->getType())
->first();
// Nothing found in our database, so we search in TMDb.
if (!$found) {
$foundFromTmdb = $this->tmdb->search($this->getTitle(), $this->getType());
if (!$foundFromTmdb) {
return false;
}
// The first result is mostly the one we need.
$firstResult = $foundFromTmdb[0];
// Search again in our database with the TMDb ID.
$found = $this->item->findByTmdbId($firstResult['tmdb_id'])->first();
if (!$found) {
$found = $this->itemService->create($firstResult);
}
}
if ($this->shouldRateItem()) {
$found->update([
'rating' => $this->getRating(),
]);
}
if ($this->shouldEpisodeMarkedAsSeen()) {
$episode = $this->episode
->findByTmdbId($found->tmdb_id)
->findByEpisodeNumber($this->getEpisodeNumber())
->findBySeasonNumber($this->getSeasonNumber())
->first();
if ($episode) {
$episode->update([
'seen' => true,
]);
}
}
}
/**
* Abort the complete request if it's not a movie or episode.
*
* @return bool
*/
abstract protected function abortRequest();
/**
* Is it a movie or tv show? Should return 'tv' or 'movie'.
*
* @return string
*/
abstract protected function getType();
/**
* Title for the item (name of the movie or tv show).
*
* @return string
*/
abstract protected function getTitle();
/**
* Rating for flox in a 3-Point system.
*
* 1 = Good.
* 2 = Medium.
* 3 = Bad.
*
* @return int
*/
abstract protected function getRating();
/**
* Check if rating is requested.
*
* @return bool
*/
abstract protected function shouldRateItem();
/**
* Check if seen episode is requested.
*
* @return bool
*/
abstract protected function shouldEpisodeMarkedAsSeen();
/**
* Number of the episode.
*
* @return null|int
*/
abstract protected function getEpisodeNumber();
/**
* Number of the season.
*
* @return null|int
*/
abstract protected function getSeasonNumber();
}

View File

@ -0,0 +1,86 @@
<?php
namespace App\Services\Api;
class Plex extends Api
{
/**
* @inheritDoc
*/
protected function abortRequest()
{
return !in_array($this->data['Metadata']['type'], ['episode', 'show', 'movie']);
}
/**
* @inheritDoc
*/
protected function getType()
{
$type = $this->data['Metadata']['type'];
return in_array($type, ['episode', 'show']) ? 'tv' : 'movie';
}
/**
* @inheritDoc
*/
protected function getTitle()
{
return $this->data['Metadata']['grandparentTitle'] ?? $this->data['Metadata']['title'];
}
/**
* @inheritDoc
*/
protected function shouldRateItem()
{
$type = $this->data['Metadata']['type'];
// Flox has no ratings for seasons or episodes.
return in_array($type, ['show', 'movie']) && $this->data['event'] === 'media.rate';
}
/**
* @inheritDoc
*/
protected function getRating()
{
$rating = $this->data['Metadata']['userRating'];
if ($rating > 7) {
return 1;
}
if ($rating > 4) {
return 2;
}
return 3;
}
/**
* @inheritDoc
*/
protected function shouldEpisodeMarkedAsSeen()
{
return $this->data['event'] === 'media.scrobble' && $this->getType() === 'tv';
}
/**
* @inheritDoc
*/
protected function getEpisodeNumber()
{
return $this->data['Metadata']['index'] ?? null;
}
/**
* @inheritDoc
*/
protected function getSeasonNumber()
{
return $this->data['Metadata']['parentIndex'] ?? null;
}
}

View File

@ -83,11 +83,12 @@
{
Carbon::setLocale(config('app.TRANSLATION'));
$episodes = $this->model->findByTmdbId($tmdbId)->get();
$episodes = $this->model->findByTmdbId($tmdbId)->oldest('episode_number')->get()->groupBy('season_number');
$nextEpisode = $this->model->findByTmdbId($tmdbId)->where('seen', 0)->oldest('season_number')->oldest('episode_number')->first();
return [
'episodes' => $episodes->sortBy('episode_number')->groupBy('season_number'),
'next_episode' => $episodes->where('seen', 0)->first(),
'episodes' => $episodes,
'next_episode' => $nextEpisode,
'spoiler' => Setting::first()->episode_spoiler_protection,
];
}

View File

@ -36,7 +36,7 @@
*
* @param $title
* @param null $mediaType
* @return Collection
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\Response|Collection
*/
public function search($title, $mediaType = null)
{
@ -52,7 +52,7 @@
$tv = collect($this->createItems($response, 'tv'));
}
if( ! $mediaType || $mediaType == 'movies') {
if( ! $mediaType || $mediaType == 'movies' || $mediaType == 'movie') {
$response = $this->fetchSearch($title, 'movie');
$movies = collect($this->createItems($response, 'movie'));
}
@ -272,7 +272,8 @@
'original_title' => $data->original_name ?? $data->original_title,
'poster' => $data->poster_path,
'media_type' => $mediaType,
'released' => $release->getTimestamp(),
'released' => $release->copy()->getTimestamp(),
'released_timestamp' => $release,
'genre_ids' => $data->genre_ids,
'genre' => Genre::whereIn('id', $data->genre_ids)->get(),
'episodes' => [],

View File

@ -14,6 +14,7 @@
protected $fillable = [
'username',
'password',
'api_key',
];
/**
@ -25,4 +26,12 @@
'password',
'remember_token',
];
/**
* Scope to find a user by an api key.
*/
public function scopeFindByApiKey($query, $key)
{
return $query->where('api_key', $key);
}
}

View File

@ -3,7 +3,7 @@
return [
// Current version. Is synced with git releases.
'version' => '2.0.0',
'version' => '2.1.0',
'TRANSLATION' => env('TRANSLATION', 'EN'),
'LOADING_ITEMS' => env('LOADING_ITEMS'),

View File

@ -9,6 +9,7 @@
'username' => $faker->name,
'password' => $password ?: $password = bcrypt('secret'),
'remember_token' => Illuminate\Support\Str::random(10),
'api_key' => null,
];
});
@ -27,7 +28,8 @@
'rating' => 1,
//'genre' => '',
'released' => time(),
'last_seen_at' => Carbon::now(),
'released_timestamp' => now(),
'last_seen_at' => now(),
'src' => null,
];
});

View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddApiKeyToUsers extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->string('api_key')->nullable()->index();
});
}
public function down(){}
}

View File

@ -0,0 +1,30 @@
<?php
use App\Item;
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddReleasedTimestampToItems extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('items', function (Blueprint $table) {
$table->timestamp('released_timestamp')->nullable();
});
Item::query()->each(function (Item $item) {
$item->update([
'released_timestamp' => Carbon::parse($item->released),
]);
});
}
public function down(){}
}

View File

@ -23,10 +23,16 @@
Route::patch('/refresh-all', 'ItemController@refreshAll');
Route::get('/settings', 'SettingController@settings');
Route::middleware('api_key')->group(function() {
Route::post('plex', 'ApiController@plex');
});
Route::middleware('auth')->group(function() {
Route::get('/check-update', 'SettingController@checkForUpdate');
Route::get('/version', 'SettingController@getVersion');
Route::get('/api-key', 'SettingController@getApiKey');
Route::patch('/settings/refresh', 'SettingController@updateRefresh');
Route::patch('/settings/api-key', 'SettingController@generateApiKey');
Route::patch('/settings/reminders-send-to', 'SettingController@updateRemindersSendTo');
Route::patch('/settings/reminder-options', 'SettingController@updateReminderOptions');
Route::patch('/settings', 'SettingController@updateSettings');

View File

@ -0,0 +1,191 @@
<?php
namespace Tests\Services\Api;
use App\Episode;
use App\Item;
use App\Services\Api\Plex;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Tests\TestCase;
use Tests\Traits\Factories;
use Tests\Traits\Fixtures;
use Tests\Traits\Mocks;
class ApiTest extends TestCase
{
use RefreshDatabase;
use Mocks;
use Factories;
use Fixtures;
public $apiClass;
public function setUp(): void
{
parent::setUp();
$this->createStorageDownloadsMock();
$this->createImdbRatingMock();
}
/** @test */
public function token_needs_to_be_provided()
{
$response = $this->postJson('api/plex');
$response->assertJson(['message' => 'No token provided']);
$response->assertStatus(Response::HTTP_UNAUTHORIZED);
}
/** @test */
public function valid_token_needs_to_be_provided()
{
$mock = $this->mock(Plex::class);
$mock->shouldReceive('handle')->once()->andReturn(null);
$user = $this->createUser(['api_key' => Str::random(24)]);
$responseBefore = $this->postJson('api/plex', ['token' => 'not-valid']);
$responseAfter = $this->postJson('api/plex', ['token' => $user->api_key, 'payload' => '[]']);
$responseBefore->assertJson(['message' => 'No valid token provided']);
$responseBefore->assertStatus(Response::HTTP_UNAUTHORIZED);
$responseAfter->assertSuccessful();
}
public function it_should_abort_the_request($fixture)
{
$api = app($this->apiClass);
try {
$api->handle($this->apiFixtures($fixture));
} catch (HttpException $exception) {
$this->assertTrue(true);
}
}
public function it_should_create_a_new_movie($fixture)
{
$this->createGuzzleMock(
$this->tmdbFixtures('movie/movie'),
$this->tmdbFixtures('movie/details'),
$this->tmdbFixtures('movie/alternative_titles')
);
$api = app($this->apiClass);
$itemsBefore = Item::all();
$api->handle($this->apiFixtures($fixture));
$itemsAfter = Item::all();
$this->assertCount(0, $itemsBefore);
$this->assertCount(1, $itemsAfter);
}
public function it_should_not_create_a_new_movie_if_it_exists($fixture)
{
$this->createMovie();
$api = app($this->apiClass);
$itemsBefore = Item::all();
$api->handle($this->apiFixtures($fixture));
$itemsAfter = Item::all();
$this->assertCount(1, $itemsBefore);
$this->assertCount(1, $itemsAfter);
}
public function it_should_create_a_new_tv_show($fixture)
{
$this->createGuzzleMock(
$this->tmdbFixtures('tv/tv'),
$this->tmdbFixtures('tv/details'),
$this->tmdbFixtures('tv/alternative_titles')
);
$this->createTmdbEpisodeMock();
$api = app($this->apiClass);
$itemsBefore = Item::all();
$api->handle($this->apiFixtures($fixture));
$itemsAfter = Item::all();
$this->assertCount(0, $itemsBefore);
$this->assertCount(1, $itemsAfter);
}
public function it_should_not_create_a_new_tv_show_if_it_exists($fixture)
{
$this->createTv();
$api = app($this->apiClass);
$itemsBefore = Item::all();
$api->handle($this->apiFixtures($fixture));
$itemsAfter = Item::all();
$this->assertCount(1, $itemsBefore);
$this->assertCount(1, $itemsAfter);
}
public function it_should_rate_a_movie($fixture, $shouldHaveRating)
{
$this->createMovie();
$api = app($this->apiClass);
$movieBefore = Item::first();
$api->handle($this->apiFixtures($fixture));
$movieAfter = Item::first();
$this->assertEquals(1, $movieBefore->rating);
$this->assertEquals($shouldHaveRating, $movieAfter->rating);
}
public function it_should_rate_a_tv_show($fixture, $shouldHaveRating)
{
$this->createTv();
$api = app($this->apiClass);
$tvBefore = Item::first();
$api->handle($this->apiFixtures($fixture));
$tvAfter = Item::first();
$this->assertEquals(1, $tvBefore->rating);
$this->assertEquals($shouldHaveRating, $tvAfter->rating);
}
public function it_should_mark_an_episode_as_seen($fixture)
{
$this->createTv();
$api = app($this->apiClass);
$seenEpisodesBefore = Episode::where('seen', true)->get();
$api->handle($this->apiFixtures($fixture));
$seenEpisodesAfter = Episode::where('seen', true)->get();
$this->assertCount(0, $seenEpisodesBefore);
$this->assertCount(1, $seenEpisodesAfter);
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Tests\Services\Api;
interface ApiTestInterface
{
public function setUp(): void;
public function it_should_abort_the_request();
public function it_should_create_a_new_movie();
public function it_should_not_create_a_new_movie_if_it_exists();
public function it_should_create_a_new_tv_show();
public function it_should_not_create_a_new_tv_show_if_it_exists();
public function it_should_rate_a_movie();
public function it_should_rate_a_tv_show();
public function it_should_mark_an_episode_as_seen();
}

View File

@ -0,0 +1,73 @@
<?php
namespace Tests\Services\Api;
use Tests\Fixtures\FakeApi;
use Tests\TestCase;
class FakeApiTest extends TestCase implements ApiTestInterface
{
/**
* @var ApiTest
*/
private $apiTest;
public function setUp(): void
{
parent::setUp();
$this->apiTest = app(ApiTest::class);
$this->apiTest->apiClass = FakeApi::class;
$this->apiTest->setUp();
}
/** @test */
public function it_should_abort_the_request()
{
$this->apiTest->it_should_abort_the_request('fake/abort.json');
}
/** @test */
public function it_should_create_a_new_movie()
{
$this->apiTest->it_should_create_a_new_movie('fake/movie.json');
}
/** @test */
public function it_should_not_create_a_new_movie_if_it_exists()
{
$this->apiTest->it_should_not_create_a_new_movie_if_it_exists('fake/movie.json');
}
/** @test */
public function it_should_create_a_new_tv_show()
{
$this->apiTest->it_should_create_a_new_tv_show('fake/tv.json');
}
/** @test */
public function it_should_not_create_a_new_tv_show_if_it_exists()
{
$this->apiTest->it_should_not_create_a_new_tv_show_if_it_exists('fake/tv.json');
}
/** @test */
public function it_should_rate_a_movie()
{
$this->apiTest->it_should_rate_a_movie('fake/movie_rating.json', 2);
}
/** @test */
public function it_should_rate_a_tv_show()
{
$this->apiTest->it_should_rate_a_tv_show('fake/tv_rating.json', 3);
}
/** @test */
public function it_should_mark_an_episode_as_seen()
{
$this->apiTest->it_should_mark_an_episode_as_seen('fake/episode_seen.json');
}
}

View File

@ -0,0 +1,73 @@
<?php
namespace Tests\Services\Api;
use App\Services\Api\Plex;
use Tests\TestCase;
class PlexApiTest extends TestCase implements ApiTestInterface
{
/**
* @var ApiTest
*/
private $apiTest;
public function setUp(): void
{
parent::setUp();
$this->apiTest = app(ApiTest::class);
$this->apiTest->apiClass = Plex::class;
$this->apiTest->setUp();
}
/** @test */
public function it_should_abort_the_request()
{
$this->apiTest->it_should_abort_the_request('plex/abort.json');
}
/** @test */
public function it_should_create_a_new_movie()
{
$this->apiTest->it_should_create_a_new_movie('plex/movie.json');
}
/** @test */
public function it_should_not_create_a_new_movie_if_it_exists()
{
$this->apiTest->it_should_not_create_a_new_movie_if_it_exists('plex/movie.json');
}
/** @test */
public function it_should_create_a_new_tv_show()
{
$this->apiTest->it_should_create_a_new_tv_show('plex/tv.json');
}
/** @test */
public function it_should_not_create_a_new_tv_show_if_it_exists()
{
$this->apiTest->it_should_not_create_a_new_tv_show_if_it_exists('plex/tv.json');
}
/** @test */
public function it_should_rate_a_movie()
{
$this->apiTest->it_should_rate_a_movie('plex/movie_rating.json', 2);
}
/** @test */
public function it_should_rate_a_tv_show()
{
$this->apiTest->it_should_rate_a_tv_show('plex/tv_rating.json', 3);
}
/** @test */
public function it_should_mark_an_episode_as_seen()
{
$this->apiTest->it_should_mark_an_episode_as_seen('plex/episode_seen.json');
}
}

View File

@ -95,4 +95,22 @@
$this->assertEquals(1, $newSettings->daily_reminder);
$this->assertEquals(1, $newSettings->weekly_reminder);
}
/** @test */
public function user_can_generate_a_new_api_key()
{
$apiKeyBefore = $this->user->api_key;
$this->actingAs($this->user)->patchJson('api/settings/api-key');
$apiKeyAfter = $this->user->api_key;
$this->actingAs($this->user)->patchJson('api/settings/api-key');
$apiKeyAfterSecond = $this->user->api_key;
$this->assertNull($apiKeyBefore);
$this->assertNotNull($apiKeyAfter);
$this->assertNotEquals($apiKeyAfterSecond, $apiKeyAfter);
}
}

View File

@ -9,9 +9,9 @@
trait Factories {
public function createUser()
public function createUser($custom = [])
{
return factory(User::class)->create();
return factory(User::class)->create($custom);
}
public function createSetting()

View File

@ -24,6 +24,11 @@
return collect(json_decode(file_get_contents(__DIR__ . '/../fixtures/flox/' . $type . '.json')))->toArray();
}
protected function apiFixtures($path)
{
return collect(json_decode(file_get_contents(__DIR__ . '/../fixtures/api/' . $path), true))->toArray();
}
protected function getMovieSrc()
{
return '/movies/Warcraft.2016.720p.WEB-DL/Warcraft.2016.720p.WEB-DL.mkv';

73
backend/tests/fixtures/FakeApi.php vendored Normal file
View File

@ -0,0 +1,73 @@
<?php
namespace Tests\Fixtures;
use App\Services\Api\Api;
class FakeApi extends Api
{
/**
* @inheritDoc
*/
protected function abortRequest()
{
return $this->data['data']['abort'];
}
/**
* @inheritDoc
*/
protected function getType()
{
return $this->data['data']['type'];
}
/**
* @inheritDoc
*/
protected function getTitle()
{
return $this->data['data']['title'];
}
/**
* @inheritDoc
*/
protected function getRating()
{
return $this->data['data']['rating'];
}
/**
* @inheritDoc
*/
protected function shouldRateItem()
{
return $this->data['data']['rate'];
}
/**
* @inheritDoc
*/
protected function shouldEpisodeMarkedAsSeen()
{
return $this->data['data']['seen'];
}
/**
* @inheritDoc
*/
protected function getEpisodeNumber()
{
return $this->data['data']['episode'];
}
/**
* @inheritDoc
*/
protected function getSeasonNumber()
{
return $this->data['data']['season'];
}
}

View File

@ -0,0 +1,5 @@
{
"data": {
"abort": true
}
}

View File

@ -0,0 +1,12 @@
{
"data": {
"abort": false,
"type": "tv",
"title": "Game of Thrones",
"rate": false,
"seen": true,
"rating": null,
"episode": 2,
"season": 1
}
}

View File

@ -0,0 +1,12 @@
{
"data": {
"abort": false,
"type": "movie",
"title": "Warcraft",
"rate": false,
"seen": false,
"rating": null,
"episode": null,
"season": null
}
}

View File

@ -0,0 +1,12 @@
{
"data": {
"abort": false,
"type": "movie",
"title": "Warcraft",
"rate": true,
"seen": false,
"rating": 2,
"episode": null,
"season": null
}
}

12
backend/tests/fixtures/api/fake/tv.json vendored Normal file
View File

@ -0,0 +1,12 @@
{
"data": {
"abort": false,
"type": "tv",
"title": "Game of Thrones",
"rate": false,
"seen": false,
"rating": null,
"episode": null,
"season": null
}
}

View File

@ -0,0 +1,12 @@
{
"data": {
"abort": false,
"type": "tv",
"title": "Game of Thrones",
"rate": true,
"seen": false,
"rating": 3,
"episode": null,
"season": null
}
}

View File

@ -0,0 +1,45 @@
{
"event": "media.play",
"user": true,
"owner": true,
"Account": {},
"Server": {},
"Player": {},
"Metadata": {
"librarySectionType": "",
"ratingKey": "",
"key": "",
"parentRatingKey": "",
"grandparentRatingKey": "",
"guid": "",
"parentGuid": "",
"grandparentGuid": "",
"type": "podcast",
"title": "a title here",
"titleSort": "",
"grandparentKey": "",
"parentKey": "",
"grandparentTitle": "Game of Thrones",
"parentTitle": "Season 1",
"contentRating": "",
"summary": "",
"index": 2,
"parentIndex": 1,
"rating": null,
"userRating": null,
"viewCount": null,
"lastViewedAt": null,
"lastRatedAt": null,
"year": null,
"thumb": "",
"art": "",
"parentThumb": "",
"grandparentThumb": "",
"grandparentArt": "",
"grandparentTheme": "",
"originallyAvailableAt": "",
"addedAt": null,
"updatedAt": null,
"Writer": []
}
}

View File

@ -0,0 +1,45 @@
{
"event": "media.scrobble",
"user": true,
"owner": true,
"Account": {},
"Server": {},
"Player": {},
"Metadata": {
"librarySectionType": "",
"ratingKey": "",
"key": "",
"parentRatingKey": "",
"grandparentRatingKey": "",
"guid": "",
"parentGuid": "",
"grandparentGuid": "",
"type": "episode",
"title": "",
"titleSort": "",
"grandparentKey": "",
"parentKey": "",
"grandparentTitle": "Game of Thrones",
"parentTitle": "",
"contentRating": "",
"summary": "",
"index": 2,
"parentIndex": 1,
"rating": null,
"userRating": null,
"viewCount": null,
"lastViewedAt": null,
"lastRatedAt": null,
"year": null,
"thumb": "",
"art": "",
"parentThumb": "",
"grandparentThumb": "",
"grandparentArt": "",
"grandparentTheme": "",
"originallyAvailableAt": "",
"addedAt": null,
"updatedAt": null,
"Writer": []
}
}

View File

@ -0,0 +1,43 @@
{
"event": "media.play",
"user": true,
"owner": true,
"Account": {},
"Server": {},
"Player": {},
"Metadata": {
"librarySectionType": "",
"ratingKey": "",
"key": "",
"guid": "",
"studio": "",
"type": "movie",
"title": "Warcraft",
"contentRating": "",
"summary": "",
"rating": null,
"audienceRating": null,
"userRating": null,
"viewCount": null,
"lastViewedAt": null,
"lastRatedAt": null,
"year": null,
"tagline": "",
"thumb": "",
"art": "",
"duration": null,
"originallyAvailableAt": "",
"addedAt": null,
"updatedAt": null,
"audienceRatingImage": "",
"primaryExtraKey": "",
"ratingImage": "",
"Genre": [],
"Director": [],
"Writer": [],
"Producer": [],
"Country": [],
"Role": [],
"Similar": []
}
}

View File

@ -0,0 +1,44 @@
{
"rating": "",
"event": "media.rate",
"user": true,
"owner": true,
"Account": {},
"Server": {},
"Player": {},
"Metadata": {
"librarySectionType": "",
"ratingKey": "",
"key": "",
"guid": "",
"studio": "",
"type": "movie",
"title": "Warcraft",
"contentRating": "",
"summary": "",
"rating": null,
"audienceRating": null,
"userRating": 5,
"viewCount": null,
"lastViewedAt": null,
"lastRatedAt": null,
"year": null,
"tagline": "",
"thumb": "",
"art": "",
"duration": null,
"originallyAvailableAt": "",
"addedAt": null,
"updatedAt": null,
"audienceRatingImage": "",
"primaryExtraKey": "",
"ratingImage": "",
"Genre": [],
"Director": [],
"Writer": [],
"Producer": [],
"Country": [],
"Role": [],
"Similar": []
}
}

45
backend/tests/fixtures/api/plex/tv.json vendored Normal file
View File

@ -0,0 +1,45 @@
{
"event": "media.play",
"user": true,
"owner": true,
"Account": {},
"Server": {},
"Player": {},
"Metadata": {
"librarySectionType": "",
"ratingKey": "",
"key": "",
"parentRatingKey": "",
"grandparentRatingKey": "",
"guid": "",
"parentGuid": "",
"grandparentGuid": "",
"type": "episode",
"title": "",
"titleSort": "",
"grandparentKey": "",
"parentKey": "",
"grandparentTitle": "Game of Thrones",
"parentTitle": "",
"contentRating": "",
"summary": "",
"index": 2,
"parentIndex": 1,
"rating": null,
"userRating": null,
"viewCount": null,
"lastViewedAt": null,
"lastRatedAt": null,
"year": null,
"thumb": "",
"art": "",
"parentThumb": "",
"grandparentThumb": "",
"grandparentArt": "",
"grandparentTheme": "",
"originallyAvailableAt": "",
"addedAt": null,
"updatedAt": null,
"Writer": []
}
}

View File

@ -0,0 +1,42 @@
{
"rating": "",
"event": "media.rate",
"user": true,
"owner": true,
"Account": {},
"Server": {},
"Player": {},
"Metadata": {
"librarySectionType": "",
"ratingKey": "",
"key": "",
"guid": "",
"studio": "",
"type": "show",
"title": "Game of Thrones",
"contentRating": "",
"summary": "",
"index": null,
"rating": null,
"userRating": 1,
"viewCount": null,
"lastViewedAt": null,
"lastRatedAt": null,
"year": null,
"thumb": "",
"art": "",
"banner": "",
"theme": "",
"duration": null,
"originallyAvailableAt": "",
"leafCount": null,
"viewedLeafCount": null,
"childCount": null,
"addedAt": null,
"updatedAt": null,
"Genre": [],
"Role": [],
"Similar": [],
"Location": []
}
}

View File

@ -0,0 +1,68 @@
<template>
<div class="settings-box element-ui-checkbox no-select" v-if=" ! loading">
<div class="login-error" v-if="config.env === 'demo'"><span>Data cannot be changed in the demo</span></div>
<form class="login-form" @submit.prevent="generateApiKey()">
<span class="update-check">API-Key</span>
<input type="text" v-model="api_key" readonly>
<input type="submit" :value="'Generate new key'">
</form>
</div>
</template>
<script>
import { mapState, mapMutations } from 'vuex';
import MiscHelper from '../../../helpers/misc';
import http from 'axios';
export default {
mixins: [MiscHelper],
created() {
this.fetchApiKey();
},
data() {
return {
config: window.config,
api_key: '',
success: false,
}
},
computed: {
...mapState({
loading: state => state.loading
})
},
methods: {
...mapMutations([ 'SET_LOADING' ]),
fetchApiKey() {
this.SET_LOADING(true);
http(`${config.api}/api-key`).then(response => {
this.api_key = response.data;
this.SET_LOADING(false);
});
},
generateApiKey() {
this.SET_LOADING(true);
http.patch(`${config.api}/settings/api-key`, {}).then((response) => {
this.api_key = response.data;
this.SET_LOADING(false);
}, error => {
alert(error.message);
})
}
}
}
</script>

View File

@ -9,6 +9,7 @@
<span :class="{active: activeTab == 'backup'}" @click="changeActiveTab('backup')">{{ lang('tab backup') }}</span>
<span :class="{active: activeTab == 'refresh'}" @click="changeActiveTab('refresh')">{{ lang('refresh') }}</span>
<span :class="{active: activeTab == 'reminders'}" @click="changeActiveTab('reminders')">{{ lang('reminders') }}</span>
<span :class="{active: activeTab == 'api_key'}" @click="changeActiveTab('api_key')">API</span>
</div>
<span class="loader fullsize-loader" v-if="loading"><i></i></span>
@ -19,6 +20,7 @@
<misc v-if="activeTab == 'misc'"></misc>
<refresh v-if="activeTab == 'refresh'"></refresh>
<reminders v-if="activeTab == 'reminders'"></reminders>
<api v-if="activeTab == 'api_key'"></api>
</div>
</main>
@ -31,6 +33,7 @@
import Misc from './Misc.vue';
import Refresh from './Refresh.vue';
import Reminders from './Reminders.vue';
import Api from './Api.vue';
import { mapState, mapActions } from 'vuex';
import MiscHelper from '../../../helpers/misc';
@ -43,7 +46,7 @@
},
components: {
User, Options, Backup, Misc, Refresh, Reminders
User, Options, Backup, Misc, Refresh, Reminders, Api
},
data() {

View File

@ -70,8 +70,13 @@ main {
//@include media(3) { lost-column: 1/5; }
@include media(3) { lost-column: 1/3; }
//@include media(4) { lost-column: 1/4; }
@include media(5) { lost-column: 1/2; }
@include media(6) { lost-column: 1; }
@include media(5) {
lost-column: 1/2;
display: flex;
align-items: center;
flex-direction: column;
}
@include media(6) { }
}
.show-ratings-never {

@ -1 +0,0 @@
Subproject commit 5761befea76867c898e743eb3876c843f6671039

26
public/assets/app.css vendored
View File

@ -2134,7 +2134,17 @@ main {
clear: both; } }
@media (max-width: 620px) {
.item-wrap {
width: calc(99.9% * 1/2 - (30px - 30px * 1/2)); }
width: calc(99.9% * 1/2 - (30px - 30px * 1/2));
display: -webkit-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-ms-flex-align: center;
align-items: center;
-webkit-box-orient: vertical;
-webkit-box-direction: normal;
-ms-flex-direction: column;
flex-direction: column; }
.item-wrap:nth-child(1n) {
float: left;
margin-right: 30px;
@ -2146,20 +2156,6 @@ main {
float: right; }
.item-wrap:nth-child(2n + 1) {
clear: both; } }
@media (max-width: 450px) {
.item-wrap {
width: calc(99.9% * 1 - (30px - 30px * 1)); }
.item-wrap:nth-child(1n) {
float: left;
margin-right: 30px;
clear: none; }
.item-wrap:last-child {
margin-right: 0; }
.item-wrap:nth-child(NaNn) {
margin-right: 0;
float: right; }
.item-wrap:nth-child(NaNn + 1) {
clear: both; } }
.show-ratings-never .rating-0,
.show-ratings-never .rating-1,

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long