diff --git a/.env.development.example b/.env.development.example index 5fe4cab1..77d46b82 100644 --- a/.env.development.example +++ b/.env.development.example @@ -3,6 +3,7 @@ ENV=development USER_ID=1000 HTTP_PORT=80 TIMEZONE="Europe/Berlin" +MIN_RUNTIME_IN_SECONDS_FOR_JOB_PROCESSING=15 # Database DATABASE_HOST="mysql" diff --git a/.env.production.example b/.env.production.example index efdaf917..bad5e1d8 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,6 +1,7 @@ # Enviroment ENV=production TIMEZONE="Europe/Berlin" +MIN_RUNTIME_IN_SECONDS_FOR_JOB_PROCESSING=15 # Database DATABASE_HOST= diff --git a/Makefile b/Makefile index ab8b3961..4d684905 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,9 @@ app_sync_tmdb: app_sync_letterboxd: make exec_app_cmd CMD="php bin/console.php letterboxd:sync $(CSV_PATH)" +app_jobs_process: + make exec_app_cmd CMD="php bin/console.php jobs:process" + # Tests ####### test: test_phpcs test_psalm test_phpstan diff --git a/README.md b/README.md index 260da452..f0d5537d 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ Demo installation can be found [here](https://movary-demo.leepeuker.dev/) (login 2. [Install via docker](#install-via-docker) 3. [Important: First steps](#important-first-steps) 4. [Features](#features) - 1. [Plex Scrobbler](#plex-scrobbler) - 2. [Trakt.tv Sync](#trakttv-sync) - 3. [Tmdb Sync](#tmdb-sync) + 1. [Tmdb Sync](#tmdb-sync) + 2. [Plex Scrobbler](#plex-scrobbler) + 3. [Trakt.tv Sync](#trakttv-sync) 5. [Development](#development) <a name="#about"></a> @@ -111,6 +111,10 @@ DATABASE_PASSWORD= DATABASE_DRIVER=pdo_mysql DATABASE_CHARSET=utf8 +# Minimum number of seconds the job processing worker has to run +# => the smallest possible timeperiode between processing two jobs +MIN_RUNTIME_IN_SECONDS_FOR_JOB_PROCESSING=15 + # https://www.themoviedb.org/settings/api TMDB_API_KEY= @@ -127,6 +131,26 @@ their [docs](https://dockerfile.readthedocs.io/en/latest/content/DockerImages/do ## Features +<a name="#tmdb-sync"></a> + +### tmdb sync + +Update movie (meta) data with themoviedb.org information. +Make sure you have added the variables `TMDB_API_KEY` to the environment. + +Example: + +`docker exec movary php bin/console.php tmdb:sync` + +**Flags:** + +- `--hours` + Only movies which were last synced X hours or longer ago will be synced +- `--threshold` + Maximum number of movies to sync + +<a name="#development"></a> + <a name="#plex-scrobbler"></a> ### Plex Scrobbler @@ -162,26 +186,6 @@ Example (syncing history and ratings for user with id 1): - `--ignore-cache` Use if you want to sync everything from trakt regardless if there was a change since the last sync. -<a name="#tmdb-sync"></a> - -### tmdb sync - -Update movie (meta) data with themoviedb.org information. -Make sure you have added the variables `TMDB_API_KEY` to the environment. - -Example: - -`docker exec movary php bin/console.php tmdb:sync` - -**Flags:** - -- `--hours` - Only movies which were last synced X hours or longer ago will be synced -- `--threshold` - Maximum number of movies to sync - -<a name="#development"></a> - ## Development ### Setup diff --git a/bin/console.php b/bin/console.php index dff65729..39f721ef 100644 --- a/bin/console.php +++ b/bin/console.php @@ -13,5 +13,6 @@ $application->add($container->get(Movary\Command\UserDelete::class)); $application->add($container->get(Movary\Command\UserUpdate::class)); $application->add($container->get(Movary\Command\UserList::class)); +$application->add($container->get(Movary\Command\ProcessJobs::class)); $application->run(); diff --git a/bootstrap.php b/bootstrap.php index 8e79090b..b276d660 100644 --- a/bootstrap.php +++ b/bootstrap.php @@ -15,6 +15,7 @@ \Movary\Command\DatabaseMigrationStatus::class => DI\factory([Factory::class, 'createDatabaseMigrationStatusCommand']), \Movary\Command\DatabaseMigrationMigrate::class => DI\factory([Factory::class, 'createDatabaseMigrationMigrateCommand']), \Movary\Command\DatabaseMigrationRollback::class => DI\factory([Factory::class, 'createDatabaseMigrationRollbackCommand']), + \Movary\Command\ProcessJobs::class => DI\factory([Factory::class, 'createProcessJobCommand']), \Psr\Http\Client\ClientInterface::class => DI\factory([Factory::class, 'createHttpClient']), \Psr\Log\LoggerInterface::class => DI\factory([Factory::class, 'createFileLogger']), \Doctrine\DBAL\Connection::class => DI\factory([Factory::class, 'createDbConnection']), diff --git a/build/php/Dockerfile b/build/php/Dockerfile index a7021c03..e8824577 100644 --- a/build/php/Dockerfile +++ b/build/php/Dockerfile @@ -9,4 +9,5 @@ ARG APPLICATION_VERSION ENV APPLICATION_VERSION=${APPLICATION_VERSION} COPY --chown=application ./ ./ COPY .env.production.example .env +COPY settings/supervisor/movary.conf /opt/docker/etc/supervisor.d/movary.conf RUN composer install --no-dev diff --git a/db/migrations/20220719134322_AddJobQueueTable.php b/db/migrations/20220719134322_AddJobQueueTable.php new file mode 100644 index 00000000..eb9abdb4 --- /dev/null +++ b/db/migrations/20220719134322_AddJobQueueTable.php @@ -0,0 +1,29 @@ +<?php declare(strict_types=1); + +use Phinx\Migration\AbstractMigration; + +final class AddJobQueueTable extends AbstractMigration +{ + public function down() : void + { + $this->execute( + <<<SQL + DROP TABLE `job_queue` + SQL + ); + } + + public function up() : void + { + $this->execute( + <<<SQL + CREATE TABLE `job_queue` ( + `id` INT(10) UNSIGNED NOT NULL AUTO_INCREMENT, + `job` TEXT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (`id`) + ) COLLATE="utf8mb4_unicode_ci" ENGINE=InnoDB + SQL + ); + } +} diff --git a/settings/routes.php b/settings/routes.php index c6d6f6f0..640318d6 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -132,6 +132,16 @@ '/user/delete-account', [\Movary\HttpController\SettingsController::class, 'deleteAccount'] ); + $routeCollector->addRoute( + 'GET', + '/jobs/schedule/trakt-history-sync', + [\Movary\HttpController\JobController::class, 'scheduleTraktHistorySync'] + ); + $routeCollector->addRoute( + 'GET', + '/jobs/schedule/trakt-ratings-sync', + [\Movary\HttpController\JobController::class, 'scheduleTraktRatingsSync'] + ); $routeCollector->addRoute( 'POST', '/user/date-format', diff --git a/settings/supervisor/movary.conf b/settings/supervisor/movary.conf new file mode 100644 index 00000000..47dbd8a1 --- /dev/null +++ b/settings/supervisor/movary.conf @@ -0,0 +1,12 @@ +[program:movary] +command=/usr/local/bin/php /app/bin/console.php jobs:process +numprocs=1 +user=application +autostart=true +autorestart=true +startsecs=1 +startretries=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/src/Application/Service/Tmdb/SyncMovies.php b/src/Application/Service/Tmdb/SyncMovies.php index c924f285..626e6b9d 100644 --- a/src/Application/Service/Tmdb/SyncMovies.php +++ b/src/Application/Service/Tmdb/SyncMovies.php @@ -19,7 +19,7 @@ public function __construct( ) { } - public function syncMovies(?int $maxAgeInHours, ?int $movieCountSyncThreshold) : void + public function syncMovies(?int $maxAgeInHours = null, ?int $movieCountSyncThreshold = null) : void { $movies = $this->movieApi->fetchAllOrderedByLastUpdatedAtTmdbAsc(); diff --git a/src/Application/User/Entity.php b/src/Application/User/Entity.php index e003943d..33943dc5 100644 --- a/src/Application/User/Entity.php +++ b/src/Application/User/Entity.php @@ -9,7 +9,7 @@ private function __construct( private readonly string $passwordHash, private readonly bool $areCoreAccountChangesDisabled, private readonly ?string $plexWebhookUuid, - private readonly ?string $dateFormat, + private readonly ?int $dateFormatId, private readonly ?string $TraktUserName, private readonly ?string $TraktClientId, ) { @@ -22,7 +22,7 @@ public static function createFromArray(array $data) : self $data['password'], (bool)$data['core_account_changes_disabled'], $data['plex_webhook_uuid'], - $data['date_format'], + $data['date_format_id'], $data['trakt_user_name'], $data['trakt_client_id'], ); @@ -33,9 +33,9 @@ public function areCoreAccountChangesDisabled() : bool return $this->areCoreAccountChangesDisabled; } - public function getDateFormat() : ?string + public function getDateFormatId() : ?int { - return $this->dateFormat; + return $this->dateFormatId; } public function getId() : int diff --git a/src/Command/ProcessJobs.php b/src/Command/ProcessJobs.php new file mode 100644 index 00000000..924c221d --- /dev/null +++ b/src/Command/ProcessJobs.php @@ -0,0 +1,82 @@ +<?php declare(strict_types=1); + +namespace Movary\Command; + +use Movary\Worker; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class ProcessJobs extends Command +{ + private const OPTION_NAME_MIN_RUNTIME = 'minRuntime'; + + protected static $defaultName = 'jobs:process'; + + public function __construct( + private readonly Worker\Repository $repository, + private readonly Worker\Service $workerService, + private readonly LoggerInterface $logger, + private readonly ?int $minRuntimeInSeconds = null + ) { + parent::__construct(); + } + + protected function configure() : void + { + $this + ->setDescription('Process job from the queue.') + ->addOption(self::OPTION_NAME_MIN_RUNTIME, 'minRuntime', InputOption::VALUE_REQUIRED, 'Minimum runtime of command.'); + } + + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $minRuntime = $input->getOption(self::OPTION_NAME_MIN_RUNTIME) ?? $this->minRuntimeInSeconds; + + $timeStart = microtime(true); + + $this->generateOutput($output, 'Processing job...'); + + try { + $processedJobType = $this->processJob(); + } catch (\Exception $e) { + $this->logger->error('Could not process job.', ['exception' => $e]); + + return Command::FAILURE; + } + + $processedMessage = 'Nothing to process.'; + if ($processedJobType !== null) { + $processedMessage = 'Processed job of type: ' . $processedJobType; + } + + $this->generateOutput($output, $processedMessage); + + $missingTime = (int)$minRuntime - (microtime(true) - $timeStart); + if ($missingTime > 0) { + $waitTime = max((int)ceil($missingTime * 1000000), 0); + + $this->generateOutput($output, 'Sleeping for ' . $waitTime / 1000000 . ' seconds to reach min runtime...'); + + usleep($waitTime); + } + + return Command::SUCCESS; + } + + private function processJob() : ?string + { + $job = $this->repository->fetchOldestJob(); + + if ($job === null) { + return null; + } + + $this->workerService->processJob($job); + + /** @noinspection PhpUnreachableStatementInspection */ + return $job->getType(); + } +} diff --git a/src/Factory.php b/src/Factory.php index ac9136ae..94611138 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -169,4 +169,20 @@ public static function createTwigFilesystemLoader() : Twig\Loader\FilesystemLoad { return new Twig\Loader\FilesystemLoader(__DIR__ . '/../templates'); } + + public function createProcessJobCommand(ContainerInterface $container, Config $config) : Command\ProcessJobs + { + try { + $minRuntimeInSeconds = $config->getAsInt('MIN_RUNTIME_IN_SECONDS_FOR_JOB_PROCESSING'); + } catch (\OutOfBoundsException) { + $minRuntimeInSeconds = null; + } + + return new Command\ProcessJobs( + $container->get(Worker\Repository::class), + $container->get(Worker\Service::class), + $container->get(LoggerInterface::class), + $minRuntimeInSeconds, + ); + } } diff --git a/src/HttpController/JobController.php b/src/HttpController/JobController.php new file mode 100644 index 00000000..1daa9fe2 --- /dev/null +++ b/src/HttpController/JobController.php @@ -0,0 +1,52 @@ +<?php declare(strict_types=1); + +namespace Movary\HttpController; + +use Movary\Application\User\Service\Authentication; +use Movary\ValueObject\Http\Header; +use Movary\ValueObject\Http\Response; +use Movary\ValueObject\Http\StatusCode; +use Movary\Worker\Service; + +class JobController +{ + public function __construct( + private readonly Authentication $authenticationService, + private readonly Service $workerService + ) { + } + + public function scheduleTraktHistorySync() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createFoundRedirect('/'); + } + + $this->workerService->addTraktHistorySyncJob($this->authenticationService->getCurrentUserId()); + + $_SESSION['scheduledTraktHistorySync'] = true; + + return Response::create( + StatusCode::createSeeOther(), + null, + [Header::createLocation($_SERVER['HTTP_REFERER'])] + ); + } + + public function scheduleTraktRatingsSync() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createFoundRedirect('/'); + } + + $this->workerService->addTraktRatingsSyncJob($this->authenticationService->getCurrentUserId()); + + $_SESSION['scheduledTraktRatingsSync'] = true; + + return Response::create( + StatusCode::createSeeOther(), + null, + [Header::createLocation($_SERVER['HTTP_REFERER'])] + ); + } +} diff --git a/src/HttpController/SettingsController.php b/src/HttpController/SettingsController.php index 0a303aa4..113d6b92 100644 --- a/src/HttpController/SettingsController.php +++ b/src/HttpController/SettingsController.php @@ -105,6 +105,8 @@ public function render() : Response $deletedUserHistory = empty($_SESSION['deletedUserHistory']) === false ? $_SESSION['deletedUserHistory'] : null; $deletedUserRatings = empty($_SESSION['deletedUserRatings']) === false ? $_SESSION['deletedUserRatings'] : null; $dateFormatUpdated = empty($_SESSION['dateFormatUpdated']) === false ? $_SESSION['dateFormatUpdated'] : null; + $scheduledTraktHistorySync = empty($_SESSION['scheduledTraktHistorySync']) === false ? $_SESSION['scheduledTraktHistorySync'] : null; + $scheduledTraktRatingsSync = empty($_SESSION['scheduledTraktRatingsSync']) === false ? $_SESSION['scheduledTraktRatingsSync'] : null; unset( $_SESSION['passwordUpdated'], $_SESSION['passwordErrorCurrentInvalid'], @@ -117,6 +119,8 @@ public function render() : Response $_SESSION['deletedUserHistory'], $_SESSION['deletedUserRatings'], $_SESSION['dateFormatUpdated'], + $_SESSION['scheduledTraktHistorySync'], + $_SESSION['scheduledTraktRatingsSync'], ); $user = $this->userApi->fetchUser($userId); @@ -126,13 +130,15 @@ public function render() : Response $this->twig->render('page/settings.html.twig', [ 'coreAccountChangesDisabled' => $user->areCoreAccountChangesDisabled(), 'dateFormats' => DateFormat::getFormats(), - 'dateFormatSelected' => $user->getDateFormat(), + 'dateFormatSelected' => $user->getDateFormatId(), 'dateFormatUpdated' => $dateFormatUpdated, 'plexWebhookUrl' => $user->getPlexWebhookId() ?? '-', 'passwordErrorNotEqual' => $passwordErrorNotEqual, 'passwordErrorMinLength' => $passwordErrorMinLength, 'passwordErrorCurrentInvalid' => $passwordErrorCurrentInvalid, 'traktCredentialsUpdated' => $traktCredentialsUpdated, + 'traktScheduleHistorySyncSuccessful' => $scheduledTraktHistorySync, + 'traktScheduleRatingsSyncSuccessful' => $scheduledTraktRatingsSync, 'importHistorySuccessful' => $importHistorySuccessful, 'importRatingsSuccessful' => $importRatingsSuccessful, 'passwordUpdated' => $passwordUpdated, diff --git a/src/Worker/Job.php b/src/Worker/Job.php new file mode 100644 index 00000000..5a458e3a --- /dev/null +++ b/src/Worker/Job.php @@ -0,0 +1,84 @@ +<?php declare(strict_types=1); + +namespace Movary\Worker; + +use Ramsey\Uuid\Uuid; + +class Job implements \JsonSerializable +{ + private const TYPE_TMDB_SYNC = 'tmdb_sync'; + + private const TYPE_TRAKT_SYNC_HISTORY = 'trakt_sync_history'; + + private const TYPE_TRAKT_SYNC_RATINGS = 'trakt_sync_ratings'; + + private function __construct( + private readonly string $uuid, + private readonly string $type, + private readonly array $parameters + ) { + } + + public static function createFromArray(array $data) : self + { + return new self( + $data['uuid'], + $data['type'], + $data['parameters'], + ); + } + + public static function createFromParameters(string $uuid, string $type, array $parameters) : self + { + return new self($uuid, $type, $parameters); + } + + public static function createTmdbSync() : self + { + return new self((string)Uuid::uuid4(), self::TYPE_TMDB_SYNC, []); + } + + public static function createTraktHistorySync(int $userId) : self + { + return new self((string)Uuid::uuid4(), self::TYPE_TRAKT_SYNC_HISTORY, ['userId' => $userId]); + } + + public static function createTraktRatingsSync(int $userId) : self + { + return new self((string)Uuid::uuid4(), self::TYPE_TRAKT_SYNC_RATINGS, ['userId' => $userId]); + } + + public function getParameters() : array + { + return $this->parameters; + } + + public function getType() : string + { + return $this->type; + } + + public function isOfTypeTmdbSync() : bool + { + return $this->type === self::TYPE_TMDB_SYNC; + } + + public function isOfTypeTraktSyncHistory() : bool + { + return $this->type === self::TYPE_TRAKT_SYNC_HISTORY; + } + + public function isOfTypeTraktSyncRankings() : bool + { + return $this->type === self::TYPE_TRAKT_SYNC_RATINGS; + } + + public function jsonSerialize() : array + { + return [ + 'uuid' => $this->uuid, + 'type' => $this->type, + 'parameters' => $this->parameters, + ]; + } +} diff --git a/src/Worker/Repository.php b/src/Worker/Repository.php new file mode 100644 index 00000000..c3c95c20 --- /dev/null +++ b/src/Worker/Repository.php @@ -0,0 +1,45 @@ +<?php declare(strict_types=1); + +namespace Movary\Worker; + +use Doctrine\DBAL\Connection; +use Movary\Util\Json; + +class Repository +{ + public function __construct(private readonly Connection $dbConnection) + { + } + + public function addJob(Job $job) : void + { + $this->dbConnection->insert( + 'job_queue', + [ + 'job' => Json::encode($job), + ] + ); + } + + public function fetchOldestJob() : ?Job + { + $this->dbConnection->beginTransaction(); + + $data = $this->dbConnection->fetchAssociative('SELECT * FROM `job_queue` ORDER BY `created_at` LIMIT 1'); + + if ($data === false) { + return null; + } + + $this->deleteJob($data['id']); + + $this->dbConnection->commit(); + + return Job::createFromArray(Json::decode($data['job'])); + } + + private function deleteJob(int $id) : void + { + $this->dbConnection->delete('job_queue', ['id' => $id]); + } +} diff --git a/src/Worker/Service.php b/src/Worker/Service.php new file mode 100644 index 00000000..a0de9520 --- /dev/null +++ b/src/Worker/Service.php @@ -0,0 +1,50 @@ +<?php declare(strict_types=1); + +namespace Movary\Worker; + +use Movary\Application\Service\Tmdb\SyncMovies; +use Movary\Application\Service\Trakt; + +class Service +{ + public function __construct( + private readonly Repository $repository, + private readonly Trakt\SyncWatchedMovies $traktSyncWatchedMovies, + private readonly Trakt\SyncRatings $traktSyncRatings, + private readonly SyncMovies $tmdbSyncMovies + ) { + } + + public function addTmdbSyncJob() : void + { + $job = Job::createTmdbSync(); + + $this->repository->addJob($job); + } + + public function addTraktHistorySyncJob(int $userId) : void + { + $job = Job::createTraktHistorySync($userId); + + $this->repository->addJob($job); + } + + public function addTraktRatingsSyncJob(int $userId) : void + { + $job = Job::createTraktRatingsSync($userId); + + $this->repository->addJob($job); + } + + public function processJob(Job $job) : void + { + $parameters = $job->getParameters(); + + match (true) { + $job->isOfTypeTraktSyncRankings() => $this->traktSyncRatings->execute($parameters['userId']), + $job->isOfTypeTraktSyncHistory() => $this->traktSyncWatchedMovies->execute($parameters['userId']), + $job->isOfTypeTmdbSync() => $this->tmdbSyncMovies->syncMovies(), + default => throw new \RuntimeException('Job type not supported: ' . $job->getType()), + }; + } +} diff --git a/templates/page/settings.html.twig b/templates/page/settings.html.twig index 61f281f3..28d92324 100644 --- a/templates/page/settings.html.twig +++ b/templates/page/settings.html.twig @@ -100,14 +100,24 @@ <form action="/user/trakt" method="post"> <p style="font-size: 0.8rem;margin-bottom: 0.5rem">Username:</p> <div class="input-group input-group-sm mb-3"> - <input type="text" class="form-control" name="traktUserName" placeholder="Enter user name here" value="{{ traktUserName }}" - style="margin-left: 10%;margin-right: 10%;text-align: center;"> + <input type="text" + class="form-control" + name="traktUserName" + placeholder="Enter user name here" + value="{{ traktUserName }}" + style="margin-left: 10%;margin-right: 10%;text-align: center;" + {% if coreAccountChangesDisabled == true %}disabled{% endif %}> </div> <p style="font-size: 0.8rem;margin-bottom: 0.5rem">Client ID:</p> <div class="input-group input-group-sm mb-3"> - <input type="text" class="form-control" name="traktClientId" placeholder="Enter client ID here" value="{{ traktClientId }}" - style="margin-left: 10%;margin-right: 10%;text-align: center;"> + <input type="text" + class="form-control" + name="traktClientId" + placeholder="Enter client ID here" + value="{{ traktClientId }} " + style="margin-left: 10%;margin-right: 10%;text-align: center;" + {% if coreAccountChangesDisabled == true %}disabled{% endif %}> </div> {% if traktCredentialsUpdated == true %} @@ -117,8 +127,22 @@ </div> {% endif %} - <button class="btn btn-primary btn-sm" type="submit">Submit</button> + <button class="btn btn-primary btn-sm" type="submit" {% if coreAccountChangesDisabled == true %}disabled{% endif %}>Submit</button> </form> + + <div style="margin-top: 0.9rem"> + <a class="btn btn-warning btn-sm {% if coreAccountChangesDisabled == true or traktUserName is null or traktClientId is null %}disabled{% endif %}" + href="/jobs/schedule/trakt-history-sync">Schedule history sync</a> + <a class="btn btn-warning btn-sm {% if coreAccountChangesDisabled == true or traktUserName is null or traktClientId is null %}disabled{% endif %}" + href="/jobs/schedule/trakt-ratings-sync">Schedule ratings sync</a> + + {% if traktScheduleHistorySyncSuccessful == true or traktScheduleRatingsSyncSuccessful == true %} + <div class="alert alert-success alert-dismissible" role="alert" style="margin-left: 10%;margin-right: 10%;margin-bottom: 0!important;margin-top: 1rem"> + Trakt {% if traktScheduleHistorySyncSuccessful == true %}history{% else %}ratings{% endif %} sync successfully scheduled. Should be processed soon. + <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> + </div> + {% endif %} + </div> </div> <hr style="margin: 0;padding: 0"> @@ -143,7 +167,7 @@ <form action="/user/import/csv/history" method="post" enctype="multipart/form-data" style="margin-top: 1rem"> <div class="input-group input-group-sm"> <input type="file" class="form-control" name="history" required> - <button class="btn btn-primary" type="submit">Import history.csv</button> + <button class="btn btn-primary" type="submit" {% if coreAccountChangesDisabled == true %}disabled{% endif %}>Import history.csv</button> </div> </form> @@ -164,7 +188,7 @@ <form action="/user/import/csv/ratings" method="post" enctype="multipart/form-data" style="padding-bottom: 0.2rem;margin-top: 1rem"> <div class="input-group input-group-sm"> <input type="file" class="form-control" name="ratings" required> - <button class="btn btn-primary" type="submit">Import rating.csv</button> + <button class="btn btn-primary" type="submit" {% if coreAccountChangesDisabled == true %}disabled{% endif %}>Import rating.csv</button> </div> </form> {% if importRatingsSuccessful == true %} @@ -188,8 +212,10 @@ <h5 style="margin-bottom: 1rem">Delete your data</h5> <div style="margin-bottom: 1rem"> - <a class="btn btn-warning btn-sm" href="/user/delete-history" onclick="confirm('Are you sure you want to delete your watch history?')">Delete history</a> - <a class="btn btn-warning btn-sm" href="/user/delete-ratings" onclick="confirm('Are you sure you want to delete your movie ratings?')">Delete ratings</a> + <a class="btn btn-warning btn-sm {% if coreAccountChangesDisabled == true %}disabled{% endif %}" href="/user/delete-history" + onclick="confirm('Are you sure you want to delete your watch history?')">Delete history</a> + <a class="btn btn-warning btn-sm {% if coreAccountChangesDisabled == true %}disabled{% endif %}" href="/user/delete-ratings" + onclick="confirm('Are you sure you want to delete your movie ratings?')">Delete ratings</a> {% if deletedUserHistory == true %} <div class="alert alert-success alert-dismissible" role="alert" style="margin-left: 10%;margin-right: 10%;margin-bottom: 0.7rem!important;margin-top: 1rem"> History deleted successfully.