From 0ef106b0529c56ec44500965a0775489f1bbfcf2 Mon Sep 17 00:00:00 2001 From: Lee Peuker Date: Wed, 13 Jul 2022 20:23:17 +0200 Subject: [PATCH] Set trakt client per user and make it configurable via settings page --- .env.development.example | 4 - .env.production.example | 4 - README.md | 13 ++- bin/console.php | 1 + bootstrap.php | 1 - ...0713163724_AddTraktClientIdToUserTable.php | 24 ++++++ docker-compose.override.yml.example | 2 - settings/routes.php | 5 ++ src/Api/Trakt/Api.php | 12 +-- src/Api/Trakt/Client.php | 5 +- .../Trakt/Exception/TraktClientIdNotSet.php | 7 ++ .../Service/Trakt/PlaysPerDateFetcher.php | 4 +- src/Application/Service/Trakt/SyncRatings.php | 11 ++- .../Service/Trakt/SyncWatchedMovies.php | 15 +++- src/Application/User/Api.php | 14 +++- src/Application/User/Repository.php | 26 +++++- src/Command/ChangeUserPassword.php | 7 +- src/Command/ChangeUserTraktId.php | 53 ++++++++++++ src/Command/SyncTmdb.php | 1 + src/Command/SyncTrakt.php | 6 ++ src/Factory.php | 10 +-- src/HttpController/MovieController.php | 4 +- src/HttpController/PlexController.php | 2 +- src/HttpController/SettingsController.php | 31 ++++++- templates/page/settings.html.twig | 80 ++++++++++++------- 25 files changed, 259 insertions(+), 83 deletions(-) create mode 100644 db/migrations/20220713163724_AddTraktClientIdToUserTable.php create mode 100644 src/Application/Service/Trakt/Exception/TraktClientIdNotSet.php create mode 100644 src/Command/ChangeUserTraktId.php diff --git a/.env.development.example b/.env.development.example index 639f9287..5fe4cab1 100644 --- a/.env.development.example +++ b/.env.development.example @@ -16,10 +16,6 @@ DATABASE_CHARSET=utf8 # Tmdb api TMDB_API_KEY= -# Trakt.tv api -TRAKT_USERNAME= -TRAKT_CLIENT_ID= - # Letterboxed LETTERBOXD_RATINGS_CSV_PATH="tmp/ratings.csv" diff --git a/.env.production.example b/.env.production.example index b2900ffc..efdaf917 100644 --- a/.env.production.example +++ b/.env.production.example @@ -14,10 +14,6 @@ DATABASE_CHARSET=utf8 # Tmdb api TMDB_API_KEY= -# Trakt.tv api -TRAKT_USERNAME= -TRAKT_CLIENT_ID= - # Logging LOG_FILE="tmp/app.log" LOG_LEVEL=warning diff --git a/README.md b/README.md index 123d44a7..e8bea0ca 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,6 @@ DATABASE_CHARSET=utf8 # https://www.themoviedb.org/settings/api TMDB_API_KEY= -# https://trakt.tv/oauth/applications -TRAKT_USERNAME= -TRAKT_CLIENT_ID= - TIMEZONE="Europe/Berlin" LOG_FILE="tmp/app.log" @@ -129,12 +125,13 @@ Add the generated url as a [webhook to plex](https://support.plex.tv/articles/11 ### trakt.tv sync -You can sync your watchhistory and ratings from trakt.tv. -Make sure you have added the variables `TRAKT_USERNAME` and `TRAKT_CLIENT_ID` to the environment. +You can sync your watch history and ratings from trakt.tv. -Example: +The user used in the sync process must have a trakt client id set (can be set via web UI on the settings page or via cli `movary:user:change-trakt-client-id`). + +Example (syncing history and ratings for user with id 1): -`docker exec movary php bin/console.php movary:sync-trakt --ratings --history` +`docker exec movary php bin/console.php movary:sync-trakt --ratings --history --userId=1` **Flags:** diff --git a/bin/console.php b/bin/console.php index 7b5d5a48..cf608d84 100644 --- a/bin/console.php +++ b/bin/console.php @@ -9,5 +9,6 @@ $application->add($container->get(Movary\Command\CreateUser::class)); $application->add($container->get(Movary\Command\ChangeUserPassword::class)); $application->add($container->get(Movary\Command\DatabaseMigration::class)); +$application->add($container->get(Movary\Command\ChangeUserTraktId::class)); $application->run(); diff --git a/bootstrap.php b/bootstrap.php index e33bc01f..847ca5c5 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -9,7 +9,6 @@ [ \Movary\ValueObject\Config::class => DI\factory([Factory::class, 'createConfig']), \Movary\Api\Trakt\Api::class => DI\factory([Factory::class, 'createTraktApi']), - \Movary\Api\Trakt\Client::class => DI\factory([Factory::class, 'createTraktApiClient']), \Movary\Api\Tmdb\Client::class => DI\factory([Factory::class, 'createTmdbApiClient']), \Movary\HttpController\SettingsController::class => DI\factory([Factory::class, 'createSettingsController']), \Movary\ValueObject\Http\Request::class => DI\factory([Factory::class, 'createCurrentHttpRequest']), diff --git a/db/migrations/20220713163724_AddTraktClientIdToUserTable.php b/db/migrations/20220713163724_AddTraktClientIdToUserTable.php new file mode 100644 index 00000000..470ffe64 --- /dev/null +++ b/db/migrations/20220713163724_AddTraktClientIdToUserTable.php @@ -0,0 +1,24 @@ +execute( + <<execute( + <<addRoute( + 'POST', + '/user/trakt', + [\Movary\HttpController\SettingsController::class, 'updateTrakt'] + ); $routeCollector->addRoute( 'DELETE', '/user/plex-webhook-id', diff --git a/src/Api/Trakt/Api.php b/src/Api/Trakt/Api.php index a0f1570e..1cdca3ab 100644 --- a/src/Api/Trakt/Api.php +++ b/src/Api/Trakt/Api.php @@ -20,23 +20,23 @@ public function fetchUniqueCachedTraktIds(int $userId) : array return $this->cacheWatchedService->fetchAllUniqueTraktIds($userId); } - public function fetchUserMovieHistoryByMovieId(TraktId $traktId) : User\Movie\History\DtoList + public function fetchUserMovieHistoryByMovieId(string $clientId, TraktId $traktId) : User\Movie\History\DtoList { - $responseData = $this->client->get(sprintf('/users/%s/history/movies/%d', $this->username, $traktId->asInt())); + $responseData = $this->client->get($clientId, sprintf('/users/%s/history/movies/%d', $this->username, $traktId->asInt())); return User\Movie\History\DtoList::createFromArray($responseData); } - public function fetchUserMoviesRatings() : User\Movie\Rating\DtoList + public function fetchUserMoviesRatings(string $clientId,) : User\Movie\Rating\DtoList { - $responseData = $this->client->get(sprintf('/users/%s/ratings/movies', $this->username)); + $responseData = $this->client->get($clientId, sprintf('/users/%s/ratings/movies', $this->username)); return User\Movie\Rating\DtoList::createFromArray($responseData); } - public function fetchUserMoviesWatched() : User\Movie\Watched\DtoList + public function fetchUserMoviesWatched(string $clientId) : User\Movie\Watched\DtoList { - $responseData = $this->client->get(sprintf('/users/%s/watched/movies', $this->username)); + $responseData = $this->client->get($clientId, sprintf('/users/%s/watched/movies', $this->username)); return User\Movie\Watched\DtoList::createFromArray($responseData); } diff --git a/src/Api/Trakt/Client.php b/src/Api/Trakt/Client.php index 454b70ca..4d5e919b 100644 --- a/src/Api/Trakt/Client.php +++ b/src/Api/Trakt/Client.php @@ -14,11 +14,10 @@ class Client public function __construct( private readonly ClientInterface $httpClient, - private readonly string $clientId ) { } - public function get(string $relativeUrl) : array + public function get(string $clientId, string $relativeUrl) : array { $request = new Request( 'GET', @@ -26,7 +25,7 @@ public function get(string $relativeUrl) : array [ 'Content-Type' => 'application/json', 'trakt-api-version' => self::TRAKT_API_VERSION, - 'trakt-api-key' => $this->clientId, + 'trakt-api-key' => $clientId, ] ); diff --git a/src/Application/Service/Trakt/Exception/TraktClientIdNotSet.php b/src/Application/Service/Trakt/Exception/TraktClientIdNotSet.php new file mode 100644 index 00000000..8892195e --- /dev/null +++ b/src/Application/Service/Trakt/Exception/TraktClientIdNotSet.php @@ -0,0 +1,7 @@ +traktApi->fetchUserMovieHistoryByMovieId($traktId) as $movieHistoryEntry) { + foreach ($this->traktApi->fetchUserMovieHistoryByMovieId($traktClientId, $traktId) as $movieHistoryEntry) { $watchDate = Date::createFromDateTime($movieHistoryEntry->getWatchedAt()); $playsPerDates->incrementPlaysForDate($watchDate); diff --git a/src/Application/Service/Trakt/SyncRatings.php b/src/Application/Service/Trakt/SyncRatings.php index 3532b093..b688fc40 100644 --- a/src/Application/Service/Trakt/SyncRatings.php +++ b/src/Application/Service/Trakt/SyncRatings.php @@ -4,6 +4,7 @@ use Movary\Api; use Movary\Application; +use Movary\Application\Service\Trakt\Exception\TraktClientIdNotSet; use Movary\ValueObject\PersonalRating; class SyncRatings @@ -12,13 +13,19 @@ public function __construct( private readonly Application\Movie\Api $movieApi, private readonly Api\Trakt\Api $traktApi, private readonly Api\Trakt\Cache\User\Movie\Rating\Service $traktApiCacheUserMovieRatingService, - private readonly Application\SyncLog\Repository $scanLogRepository + private readonly Application\SyncLog\Repository $scanLogRepository, + private readonly Application\User\Api $userApi, ) { } public function execute(int $userId, bool $overwriteExistingData = false) : void { - $this->traktApiCacheUserMovieRatingService->set($userId, $this->traktApi->fetchUserMoviesRatings()); + $traktClientId = $this->userApi->findTraktClientId($userId); + if ($traktClientId === null) { + throw new TraktClientIdNotSet(); + } + + $this->traktApiCacheUserMovieRatingService->set($userId, $this->traktApi->fetchUserMoviesRatings($traktClientId)); foreach ($this->movieApi->fetchAll() as $movie) { $traktId = $movie->getTraktId(); diff --git a/src/Application/Service/Trakt/SyncWatchedMovies.php b/src/Application/Service/Trakt/SyncWatchedMovies.php index 06780815..07f18151 100644 --- a/src/Application/Service/Trakt/SyncWatchedMovies.php +++ b/src/Application/Service/Trakt/SyncWatchedMovies.php @@ -5,6 +5,7 @@ use Movary\Api; use Movary\Api\Trakt\ValueObject\Movie\TraktId; use Movary\Application; +use Movary\Application\Service\Trakt\Exception\TraktClientIdNotSet; use Movary\ValueObject\Date; use Psr\Log\LoggerInterface; @@ -18,12 +19,18 @@ public function __construct( private readonly PlaysPerDateFetcher $playsPerDateFetcher, private readonly Application\Service\Tmdb\SyncMovie $tmdbMovieSync, private readonly Application\SyncLog\Repository $scanLogRepository, + private readonly Application\User\Api $userApi ) { } public function execute(int $userId, bool $overwriteExistingData = false, bool $ignoreCache = false) : void { - $watchedMovies = $this->traktApi->fetchUserMoviesWatched(); + $traktClientId = $this->userApi->findTraktClientId($userId); + if ($traktClientId === null) { + throw new TraktClientIdNotSet(); + } + + $watchedMovies = $this->traktApi->fetchUserMoviesWatched($traktClientId); foreach ($watchedMovies as $watchedMovie) { $traktId = $watchedMovie->getMovie()->getTraktId(); @@ -34,7 +41,7 @@ public function execute(int $userId, bool $overwriteExistingData = false, bool $ continue; } - $this->syncMovieHistory($userId, $traktId, $movie, $overwriteExistingData); + $this->syncMovieHistory($traktClientId, $userId, $traktId, $movie, $overwriteExistingData); $this->traktApiCacheUserMovieWatchedService->setOne($userId, $traktId, $watchedMovie->getLastUpdated()); } @@ -88,9 +95,9 @@ private function isWatchedCacheUpToDate(int $userId, Api\Trakt\ValueObject\User\ return $cacheLastUpdated !== null && $watchedMovie->getLastUpdated()->isEqual($cacheLastUpdated) === true; } - private function syncMovieHistory(int $userId, TraktId $traktId, Application\Movie\Entity $movie, bool $overwriteExistingData) : void + private function syncMovieHistory(string $traktClientId, int $userId, TraktId $traktId, Application\Movie\Entity $movie, bool $overwriteExistingData) : void { - $traktHistoryEntries = $this->playsPerDateFetcher->fetchTraktPlaysPerDate($traktId); + $traktHistoryEntries = $this->playsPerDateFetcher->fetchTraktPlaysPerDate($traktClientId, $traktId); foreach ($this->movieApi->fetchHistoryByMovieId($movie->getId(), $userId) as $localHistoryEntry) { $localHistoryEntryDate = Date::createFromString($localHistoryEntry['watched_at']); diff --git a/src/Application/User/Api.php b/src/Application/User/Api.php index e870ad57..2984c86f 100644 --- a/src/Application/User/Api.php +++ b/src/Application/User/Api.php @@ -20,9 +20,14 @@ public function deletePlexWebhookId(int $userId) : void $this->repository->setPlexWebhookId($userId, null); } - public function findPlexWebhookIdByUserId(int $userId) : ?string + public function findPlexWebhookId(int $userId) : ?string { - return $this->repository->findPlexWebhookIdByUserId($userId); + return $this->repository->findPlexWebhookId($userId); + } + + public function findTraktClientId(int $userId) : ?string + { + return $this->repository->findTraktClientId($userId); } public function findUserIdByPlexWebhookId(string $webhookId) : ?int @@ -49,4 +54,9 @@ public function updatePassword(int $userId, string $newPassword) : void $this->repository->updatePassword($userId, $passwordHash); } + + public function updateTraktClientId(int $userId, ?string $traktClientId) : void + { + $this->repository->updateTraktClientId($userId, $traktClientId); + } } diff --git a/src/Application/User/Repository.php b/src/Application/User/Repository.php index 3a80ab1c..28cc0988 100644 --- a/src/Application/User/Repository.php +++ b/src/Application/User/Repository.php @@ -56,7 +56,7 @@ public function findAuthTokenExpirationDate(string $token) : ?DateTime return DateTime::createFromString($expirationDate); } - public function findPlexWebhookIdByUserId(int $userId) : ?string + public function findPlexWebhookId(int $userId) : ?string { $plexWebhookId = $this->dbConnection->fetchOne('SELECT `plex_webhook_uuid` FROM `user` WHERE `id` = ?', [$userId]); @@ -67,6 +67,17 @@ public function findPlexWebhookIdByUserId(int $userId) : ?string return $plexWebhookId; } + public function findTraktClientId(int $userId) : ?string + { + $plexWebhookId = $this->dbConnection->fetchOne('SELECT `trakt_client_id` FROM `user` WHERE `id` = ?', [$userId]); + + if ($plexWebhookId === false) { + return null; + } + + return $plexWebhookId; + } + public function findUserByEmail(string $email) : ?Entity { $data = $this->dbConnection->fetchAssociative('SELECT * FROM `user` WHERE `email` = ?', [$email]); @@ -136,4 +147,17 @@ public function updatePassword(int $userId, string $passwordHash) : void ] ); } + + public function updateTraktClientId(int $userId, ?string $traktClientId) : void + { + $this->dbConnection->update( + 'user', + [ + 'trakt_client_id' => $traktClientId, + ], + [ + 'id' => $userId, + ] + ); + } } diff --git a/src/Command/ChangeUserPassword.php b/src/Command/ChangeUserPassword.php index 79c971cd..6f0b1eef 100644 --- a/src/Command/ChangeUserPassword.php +++ b/src/Command/ChangeUserPassword.php @@ -36,14 +36,15 @@ protected function execute(InputInterface $input, OutputInterface $output) : int try { $this->userApi->updatePassword($userId, $password); } catch (\Throwable $t) { - $this->logger->error('Could not change admin password.', ['exception' => $t]); + $this->logger->error('Could not change password.', ['exception' => $t]); - $this->generateOutput($output, 'Could not update password'); + $this->generateOutput($output, 'Could not update password.'); return Command::FAILURE; } - $this->generateOutput($output, 'Updated password'); + $this->generateOutput($output, 'Updated password.'); + return Command::SUCCESS; } } diff --git a/src/Command/ChangeUserTraktId.php b/src/Command/ChangeUserTraktId.php new file mode 100644 index 00000000..739ea7fa --- /dev/null +++ b/src/Command/ChangeUserTraktId.php @@ -0,0 +1,53 @@ +setDescription('Change user trakt client id.') + ->addArgument('userId', InputArgument::REQUIRED, 'ID of user') + ->addArgument('traktClientId', InputArgument::REQUIRED, 'New trakt client id for user'); + } + + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $userId = (int)$input->getArgument('userId'); + $traktClientId = $input->getArgument('traktClientId'); + + if (empty($traktClientId) === true) { + $traktClientId = null; + } + + try { + $this->userApi->updateTraktClientId($userId, $traktClientId); + } catch (\Throwable $t) { + $this->logger->error('Could not change trakt client id.', ['exception' => $t]); + + $this->generateOutput($output, 'Could not update trakt client id.'); + + return Command::FAILURE; + } + + $this->generateOutput($output, 'Updated trakt client id.'); + return Command::SUCCESS; + } +} diff --git a/src/Command/SyncTmdb.php b/src/Command/SyncTmdb.php index ff30c904..51ca4e92 100644 --- a/src/Command/SyncTmdb.php +++ b/src/Command/SyncTmdb.php @@ -46,6 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output) : int $this->generateOutput($output, 'Syncing movie meta data done.'); } catch (\Throwable $t) { + $this->generateOutput($output, 'ERROR: Could not complete tmdb sync.'); $this->logger->error('Could not complete tmdb sync.', ['exception' => $t]); return Command::FAILURE; diff --git a/src/Command/SyncTrakt.php b/src/Command/SyncTrakt.php index e5f900ee..fb10b13c 100644 --- a/src/Command/SyncTrakt.php +++ b/src/Command/SyncTrakt.php @@ -2,6 +2,7 @@ namespace Movary\Command; +use Movary\Application\Service\Trakt\Exception\TraktClientIdNotSet; use Movary\Application\Service\Trakt\SyncRatings; use Movary\Application\Service\Trakt\SyncWatchedMovies; use Psr\Log\LoggerInterface; @@ -67,7 +68,12 @@ protected function execute(InputInterface $input, OutputInterface $output) : int $this->syncRatings($output, $userId, $overwriteExistingData); } } + } catch (TraktClientIdNotSet $t) { + $this->generateOutput($output, 'ERROR: User as no trakt client id set.'); + + return Command::FAILURE; } catch (\Throwable $t) { + $this->generateOutput($output, 'ERROR: Could not complete trakt sync.'); $this->logger->error('Could not complete trakt sync.', ['exception' => $t]); return Command::FAILURE; diff --git a/src/Factory.php b/src/Factory.php index 778a570f..3b3fd653 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -11,6 +11,7 @@ use Movary\Api\Trakt; use Movary\Api\Trakt\Cache\User\Movie\Watched; use Movary\Application\SyncLog; +use Movary\Application\User\Api; use Movary\Application\User\Service\Authentication; use Movary\Command; use Movary\HttpController\SettingsController; @@ -94,6 +95,7 @@ public static function createSettingsController(ContainerInterface $container, C $container->get(Twig\Environment::class), $container->get(SyncLog\Repository::class), $container->get(Authentication::class), + $container->get(Api::class), $applicationVersion ); } @@ -115,14 +117,6 @@ public static function createTraktApi(ContainerInterface $container, Config $con ); } - public static function createTraktApiClient(ContainerInterface $container, Config $config) : Trakt\Client - { - return new Trakt\Client( - $container->get(ClientInterface::class), - $config->getAsString('TRAKT_CLIENT_ID') - ); - } - public static function createTwigEnvironment(ContainerInterface $container) : Twig\Environment { $twig = new Twig\Environment($container->get(Twig\Loader\LoaderInterface::class)); diff --git a/src/HttpController/MovieController.php b/src/HttpController/MovieController.php index 3166e5d4..75bc66c7 100644 --- a/src/HttpController/MovieController.php +++ b/src/HttpController/MovieController.php @@ -78,8 +78,6 @@ public function updateRating(Request $request) : Response $this->movieApi->updateUserRating($movieId, $_SESSION['userId'], $personalRating); - return Response::create( - StatusCode::createNoContent(), - ); + return Response::create(StatusCode::createNoContent()); } } diff --git a/src/HttpController/PlexController.php b/src/HttpController/PlexController.php index ddaecb19..fb1d2bec 100644 --- a/src/HttpController/PlexController.php +++ b/src/HttpController/PlexController.php @@ -41,7 +41,7 @@ public function getPlexWebhookId() : Response return Response::createFoundRedirect('/'); } - $plexWebhookId = $this->userApi->findPlexWebhookIdByUserId($_SESSION['userId']); + $plexWebhookId = $this->userApi->findPlexWebhookId($_SESSION['userId']); return Response::createJson(Json::encode(['id' => $plexWebhookId])); } diff --git a/src/HttpController/SettingsController.php b/src/HttpController/SettingsController.php index d902e4b2..6dccd492 100644 --- a/src/HttpController/SettingsController.php +++ b/src/HttpController/SettingsController.php @@ -3,7 +3,10 @@ namespace Movary\HttpController; use Movary\Application\SyncLog\Repository; +use Movary\Application\User; use Movary\Application\User\Service\Authentication; +use Movary\ValueObject\Http\Header; +use Movary\ValueObject\Http\Request; use Movary\ValueObject\Http\Response; use Movary\ValueObject\Http\StatusCode; use Twig\Environment; @@ -14,6 +17,7 @@ public function __construct( private readonly Environment $twig, private readonly Repository $syncLogRepository, private readonly Authentication $authenticationService, + private readonly User\Api $userApi, private readonly ?string $applicationVersion = null, ) { } @@ -24,10 +28,13 @@ public function render() : Response return Response::createFoundRedirect('/'); } + $userId = $this->authenticationService->getCurrentUserId(); + return Response::create( StatusCode::createOk(), $this->twig->render('page/settings.html.twig', [ - 'plexWebhookUrl' => $this->applicationVersion ?? '-', + 'plexWebhookUrl' => $this->userApi->findPlexWebhookId($userId) ?? '-', + 'traktClientId' => $this->userApi->findTraktClientId($userId), 'applicationVersion' => $this->applicationVersion ?? '-', 'lastSyncTrakt' => $this->syncLogRepository->findLastTraktSync() ?? '-', 'lastSyncTmdb' => $this->syncLogRepository->findLastTmdbSync() ?? '-', @@ -35,4 +42,26 @@ public function render() : Response ]), ); } + + public function updateTrakt(Request $request) : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createFoundRedirect('/'); + } + + $traktClientId = $request->getPostParameters()['traktClientId']; + $userId = $this->authenticationService->getCurrentUserId(); + + if (empty($traktClientId) === true) { + $traktClientId = null; + } + + $this->userApi->updateTraktClientId($userId, $traktClientId); + + return Response::create( + StatusCode::createSeeOther(), + null, + [Header::createLocation($_SERVER['HTTP_REFERER'])] + ); + } } diff --git a/templates/page/settings.html.twig b/templates/page/settings.html.twig index 501bb177..c0300902 100644 --- a/templates/page/settings.html.twig +++ b/templates/page/settings.html.twig @@ -17,44 +17,68 @@ {{ include('component/navbar.html.twig') }}
-
Export
+
+
trakt.tv
- history.csv - ratings.csv +

To generate a client id visit this url here.

-
+
+

Client ID:

+
+ +
+ +
-
Import
+
-
-
- - -
-
+
-
+
+
Plex webhook url:
-
-
- - -
-
+

-

+ + +
-
+
+
-
Plex webhook url:
-

-

- - +
Export & Import
-
+ Export: history.csv + Export: ratings.csv -

Last trakt.tv sync: {{ lastSyncTrakt }}

-

Last themoviedb.org sync: {{ lastSyncTmdb }}

-

Last letterboxd sync: {{ lastSyncLetterboxd }}

-

Current application version: {{ applicationVersion }}

+
+
+ + +
+
+ +
+ +
+
+ + +
+
+
+ +
+
+ +

Last trakt.tv sync: {{ lastSyncTrakt }}

+

Last themoviedb.org sync: {{ lastSyncTmdb }}

+

Last letterboxd sync: {{ lastSyncLetterboxd }}

+

Current application version: {{ applicationVersion }} +

+ +
{% endblock %}