From 0cfea90acb26d40c38d3af053cb77ce35cf3d98e Mon Sep 17 00:00:00 2001 From: Lee Peuker Date: Fri, 4 Aug 2023 12:54:23 +0200 Subject: [PATCH 1/4] Add basic Jellyfin export/import job trigger setup --- public/js/component/modal-job.js | 6 ++ public/js/settings-integration-jellyfin.js | 56 ++++++++++++++++++- settings/routes.php | 10 ++++ src/Domain/Movie/MovieRepository.php | 4 ++ src/Factory.php | 1 + src/HttpController/JobController.php | 47 ++++++++++++++++ src/JobQueue/JobQueueApi.php | 11 +++- src/ValueObject/JobType.php | 27 ++++++--- .../settings-integration-jellyfin.html.twig | 50 +++++++++++++---- 9 files changed, 191 insertions(+), 21 deletions(-) diff --git a/public/js/component/modal-job.js b/public/js/component/modal-job.js index 229dedc3..7ff077f4 100644 --- a/public/js/component/modal-job.js +++ b/public/js/component/modal-job.js @@ -51,6 +51,12 @@ function setJobModalTitle(jobType) { case 'plex_import_watchlist': title = 'Watchlist imports'; break; + case 'jellyfin_import_history': + title = 'History imports'; + break; + case 'jellyfin_export_history': + title = 'History exports'; + break; default: throw new Error('Not supported job type: ' + jobType); } diff --git a/public/js/settings-integration-jellyfin.js b/public/js/settings-integration-jellyfin.js index ef451e83..09d6f7b3 100644 --- a/public/js/settings-integration-jellyfin.js +++ b/public/js/settings-integration-jellyfin.js @@ -249,14 +249,64 @@ async function updateSyncOptions() { }) }).then(response => { if (!response.ok) { - addAlert('alertJellyfinSyncDiv', 'Could not update sync options', 'danger') + addAlert('alertJellyfinSyncOptionsDiv', 'Could not update sync options', 'danger') return } - addAlert('alertJellyfinSyncDiv', 'Sync options were updated', 'success') + addAlert('alertJellyfinSyncOptionsDiv', 'Sync options were updated', 'success') }).catch(function (error) { console.log(error) - addAlert('alertJellyfinSyncDiv', 'Could not update sync options', 'danger') + addAlert('alertJellyfinSyncOptionsDiv', 'Could not update sync options', 'danger') }); } + +async function exportJellyfin() { + const response = await fetch( + '/jobs/schedule/jellyfin-export-history', + {'method': 'get'} + ).catch(function (error) { + addAlert('alertJellyfinExportHistoryDiv', 'History export could not be scheduled', 'danger') + + throw new Error(`HTTP error! status: ${response.status}`) + }); + + if (!response.ok) { + if (response.status === 400) { + addAlert('alertJellyfinExportHistoryDiv', await response.text(), 'danger') + + return + } + + addAlert('alertJellyfinExportHistoryDiv', 'History export could not be scheduled', 'danger') + + throw new Error(`HTTP error! status: ${response.status}`) + } + + addAlert('alertJellyfinExportHistoryDiv', 'History export scheduled', 'success') +} + +async function importJellyfin() { + const response = await fetch( + '/jobs/schedule/jellyfin-import-history', + {'method': 'get'} + ).catch(function (error) { + addAlert('alertJellyfinImportHistoryDiv', 'History import could not be scheduled', 'danger') + + throw new Error(`HTTP error! status: ${response.status}`) + }); + + if (!response.ok) { + if (response.status === 400) { + addAlert('alertJellyfinImportHistoryDiv', await response.text(), 'danger') + + return + } + + addAlert('alertJellyfinImportHistoryDiv', 'History import could not be scheduled', 'danger') + + throw new Error(`HTTP error! status: ${response.status}`) + } + + addAlert('alertJellyfinImportHistoryDiv', 'History import scheduled', 'success') +} diff --git a/settings/routes.php b/settings/routes.php index c8251a15..715e78bd 100644 --- a/settings/routes.php +++ b/settings/routes.php @@ -94,6 +94,16 @@ '/jobs/schedule/plex-watchlist-sync', [\Movary\HttpController\JobController::class, 'schedulePlexWatchlistImport'], ); + $routeCollector->addRoute( + 'GET', + '/jobs/schedule/jellyfin-import-history', + [\Movary\HttpController\JobController::class, 'scheduleJellyfinImportHistory'], + ); + $routeCollector->addRoute( + 'GET', + '/jobs/schedule/jellyfin-export-history', + [\Movary\HttpController\JobController::class, 'scheduleJellyfinExportHistory'], + ); ############ # Settings # diff --git a/src/Domain/Movie/MovieRepository.php b/src/Domain/Movie/MovieRepository.php index 3a491ba0..92b56247 100644 --- a/src/Domain/Movie/MovieRepository.php +++ b/src/Domain/Movie/MovieRepository.php @@ -286,6 +286,10 @@ public function fetchHistoryByMovieId(int $movieId, int $userId) : array public function fetchTmdbIdsWithWatchHistoryByUserId(int $userId, array $movieIds) : array { + if (count($movieIds) === 0) { + return []; + } + $placeholders = trim(str_repeat('?, ', count($movieIds)), ', '); return $this->dbConnection->fetchFirstColumn( diff --git a/src/Factory.php b/src/Factory.php index d3cc4d40..306eee79 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -195,6 +195,7 @@ public static function createJobController(ContainerInterface $container) : JobC return new JobController( $container->get(Authentication::class), $container->get(JobQueueApi::class), + $container->get(UserApi::class), $container->get(LetterboxdCsvValidator::class), $container->get(SessionWrapper::class), self::createDirectoryStorageApp() diff --git a/src/HttpController/JobController.php b/src/HttpController/JobController.php index 1fb6c334..e6dc1671 100644 --- a/src/HttpController/JobController.php +++ b/src/HttpController/JobController.php @@ -2,8 +2,10 @@ namespace Movary\HttpController; +use Movary\Api\Jellyfin\Exception\JellyfinInvalidAuthentication; use Movary\Api\Plex\Exception\PlexAuthenticationMissing; use Movary\Domain\User\Service\Authentication; +use Movary\Domain\User\UserApi; use Movary\JobQueue\JobQueueApi; use Movary\Service\Letterboxd\Service\LetterboxdCsvValidator; use Movary\Util\Json; @@ -20,6 +22,7 @@ class JobController public function __construct( private readonly Authentication $authenticationService, private readonly JobQueueApi $jobQueueApi, + private readonly UserApi $userApi, private readonly LetterboxdCsvValidator $letterboxdImportHistoryFileValidator, private readonly SessionWrapper $sessionWrapper, private readonly string $appStorageDirectory, @@ -165,6 +168,50 @@ public function schedulePlexWatchlistImport() : Response ); } + public function scheduleJellyfinImportHistory() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $currentUserId = $this->authenticationService->getCurrentUserId(); + + $jellyfinAuthentication = $this->userApi->findJellyfinAuthentication($currentUserId); + if ($jellyfinAuthentication === null) { + return Response::createBadRequest(JellyfinInvalidAuthentication::create()->getMessage()); + } + + $this->jobQueueApi->addJellyfinImportMoviesJob($currentUserId); + + return Response::create( + StatusCode::createSeeOther(), + null, + [Header::createLocation($_SERVER['HTTP_REFERER'])], + ); + } + + public function scheduleJellyfinExportHistory() : Response + { + if ($this->authenticationService->isUserAuthenticated() === false) { + return Response::createSeeOther('/'); + } + + $currentUserId = $this->authenticationService->getCurrentUserId(); + + $jellyfinAuthentication = $this->userApi->findJellyfinAuthentication($currentUserId); + if ($jellyfinAuthentication === null) { + return Response::createBadRequest(JellyfinInvalidAuthentication::create()->getMessage()); + } + + $this->jobQueueApi->addJellyfinExportMoviesJob($currentUserId); + + return Response::create( + StatusCode::createSeeOther(), + null, + [Header::createLocation($_SERVER['HTTP_REFERER'])], + ); + } + public function scheduleTraktHistorySync() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { diff --git a/src/JobQueue/JobQueueApi.php b/src/JobQueue/JobQueueApi.php index 460105cc..c49cb49f 100644 --- a/src/JobQueue/JobQueueApi.php +++ b/src/JobQueue/JobQueueApi.php @@ -49,7 +49,7 @@ public function addTmdbMovieSyncJob(JobStatus $jobStatus) : int return $this->repository->addJob(JobType::createTmdbMovieSync(), $jobStatus); } - public function addJellyfinExportMoviesJob(int $userId, array $movieIds, ?JobStatus $jobStatus = null) : int + public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], ?JobStatus $jobStatus = null) : int { return $this->repository->addJob( JobType::createJellyfinExportMovies(), @@ -61,6 +61,15 @@ public function addJellyfinExportMoviesJob(int $userId, array $movieIds, ?JobSta ); } + public function addJellyfinImportMoviesJob(int $userId, ?JobStatus $jobStatus = null) : int + { + return $this->repository->addJob( + JobType::createJellyfinImportMovies(), + $jobStatus ?? JobStatus::createWaiting(), + $userId, + ); + } + public function addTmdbPersonSyncJob(JobStatus $createDone) : int { return $this->repository->addJob(JobType::createTmdbPersonSyncJob(), $createDone); diff --git a/src/ValueObject/JobType.php b/src/ValueObject/JobType.php index 4b4d1eef..c1154de9 100644 --- a/src/ValueObject/JobType.php +++ b/src/ValueObject/JobType.php @@ -12,7 +12,9 @@ class JobType implements \JsonSerializable private const TYPE_LETTERBOXD_IMPORT_HISTORY = 'letterboxd_import_history'; - private const TYPE_JELLYFIN_EXPORT_MOVIES = 'jellyfin_export_movies'; + private const TYPE_JELLYFIN_EXPORT_HISTORY = 'jellyfin_export_history'; + + private const TYPE_JELLYFIN_IMPORT_HISTORY = 'jellyfin_import_history'; private const TYPE_LETTERBOXD_IMPORT_RATINGS = 'letterboxd_import_ratings'; @@ -38,7 +40,8 @@ private function __construct(private readonly string $type) self::TYPE_TRAKT_IMPORT_RATINGS, self::TYPE_IMDB_SYNC, self::TYPE_PLEX_IMPORT_WATCHLIST, - self::TYPE_JELLYFIN_EXPORT_MOVIES, + self::TYPE_JELLYFIN_EXPORT_HISTORY, + self::TYPE_JELLYFIN_IMPORT_HISTORY, ]) === false) { throw new RuntimeException('Not supported job type: ' . $this->type); } @@ -54,14 +57,19 @@ public static function createImdbSync() : self return new self(self::TYPE_IMDB_SYNC); } - public static function createLetterboxdImportHistory() : self + public static function createJellyfinExportMovies() : self { - return new self(self::TYPE_LETTERBOXD_IMPORT_HISTORY); + return new self(self::TYPE_JELLYFIN_EXPORT_HISTORY); } - public static function createJellyfinExportMovies() : self + public static function createJellyfinImportMovies() : self { - return new self(self::TYPE_JELLYFIN_EXPORT_MOVIES); + return new self(self::TYPE_JELLYFIN_IMPORT_HISTORY); + } + + public static function createLetterboxdImportHistory() : self + { + return new self(self::TYPE_LETTERBOXD_IMPORT_HISTORY); } public static function createLetterboxdImportRatings() : self @@ -111,7 +119,12 @@ public function __toString() : string public function isOfTypeJellyfinExportMovies() : bool { - return $this->type === self::TYPE_JELLYFIN_EXPORT_MOVIES; + return $this->type === self::TYPE_JELLYFIN_EXPORT_HISTORY; + } + + public function isOfTypeJellyfinImportMovies() : bool + { + return $this->type === self::TYPE_JELLYFIN_IMPORT_HISTORY; } public function isOfTypeLetterboxdImportHistory() : bool diff --git a/templates/page/settings-integration-jellyfin.html.twig b/templates/page/settings-integration-jellyfin.html.twig index b5e49c28..5d3caffd 100644 --- a/templates/page/settings-integration-jellyfin.html.twig +++ b/templates/page/settings-integration-jellyfin.html.twig @@ -6,6 +6,7 @@ {% block scripts %} + {% endblock %} {% block stylesheets %} @@ -210,6 +211,38 @@
Jellyfin sync
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + +
+
- +
-
- -
+
- + + + {{ include('component/modal-job.html.twig') }} {% endblock %} From ed488c31fae7f7382690b552b3fb800a6a0ab640 Mon Sep 17 00:00:00 2001 From: Lee Peuker Date: Sat, 5 Aug 2023 13:32:53 +0200 Subject: [PATCH 2/4] Finish export --- public/js/settings-integration-jellyfin.js | 7 +++ src/Api/Jellyfin/Cache/JellyfinCache.php | 6 +- .../Cache/JellyfinCacheRepository.php | 4 ++ src/Api/Jellyfin/JellyfinApi.php | 57 +++++++++++++++++-- src/Domain/Movie/History/MovieHistoryApi.php | 21 ++++++- src/Domain/Movie/MovieRepository.php | 42 +++++++++++++- src/HttpController/JobController.php | 6 +- src/JobQueue/JobQueueApi.php | 3 +- .../Jellyfin/JellyfinMoviesExporter.php | 19 +++++-- src/ValueObject/Date.php | 5 ++ .../settings-integration-jellyfin.html.twig | 53 ++++++++++++++++- 11 files changed, 202 insertions(+), 21 deletions(-) diff --git a/public/js/settings-integration-jellyfin.js b/public/js/settings-integration-jellyfin.js index 09d6f7b3..ae7c3352 100644 --- a/public/js/settings-integration-jellyfin.js +++ b/public/js/settings-integration-jellyfin.js @@ -1,3 +1,5 @@ +const exportHistoryModal = new bootstrap.Modal('#exportHistoryModal', {keyboard: false}) + function regenerateJellyfinWebhook() { if (confirm('Do you really want to regenerate the webhook url?') === false) { return @@ -268,9 +270,13 @@ async function exportJellyfin() { ).catch(function (error) { addAlert('alertJellyfinExportHistoryDiv', 'History export could not be scheduled', 'danger') + exportHistoryModal.hide() + throw new Error(`HTTP error! status: ${response.status}`) }); + exportHistoryModal.hide() + if (!response.ok) { if (response.status === 400) { addAlert('alertJellyfinExportHistoryDiv', await response.text(), 'danger') @@ -284,6 +290,7 @@ async function exportJellyfin() { } addAlert('alertJellyfinExportHistoryDiv', 'History export scheduled', 'success') + exportHistoryModal.hide() } async function importJellyfin() { diff --git a/src/Api/Jellyfin/Cache/JellyfinCache.php b/src/Api/Jellyfin/Cache/JellyfinCache.php index 19a3a4f4..12b81661 100644 --- a/src/Api/Jellyfin/Cache/JellyfinCache.php +++ b/src/Api/Jellyfin/Cache/JellyfinCache.php @@ -78,7 +78,7 @@ public function loadFromJellyfin(int $userId) : void if ($cachedMovie !== null) { $this->repository->updateCacheEntry($userId, $jellyfinMovieDto); - $this->logger->debug('Jellyfin cache: Updated movie', [ + $this->logger->info('Jellyfin cache: Updated movie', [ 'userId' => $userId, 'jellyfinItemId' => $jellyfinItemId, 'watched' => $jellyfinMovieDto->getWatched() @@ -89,7 +89,7 @@ public function loadFromJellyfin(int $userId) : void $this->repository->addCacheEntry($userId, $jellyfinMovieDto); - $this->logger->debug('Jellyfin cache: Added movie', [ + $this->logger->info('Jellyfin cache: Added movie', [ 'userId' => $userId, 'jellyfinItemId' => $jellyfinItemId, 'watched' => $jellyfinMovieDto->getWatched() @@ -112,7 +112,7 @@ private function removeOutdatedCache(int $userId, JellyfinMovieDtoList $latestCa $this->repository->deleteCacheEntry($userId, $cachedJellyfinItemId); - $this->logger->debug('Jellyfin cache: Removed movie', ['userId' => $userId, 'jellyfinItemId' => $cachedJellyfinItemId]); + $this->logger->info('Jellyfin cache: Removed movie', ['userId' => $userId, 'jellyfinItemId' => $cachedJellyfinItemId]); } } } diff --git a/src/Api/Jellyfin/Cache/JellyfinCacheRepository.php b/src/Api/Jellyfin/Cache/JellyfinCacheRepository.php index 18359c93..f2afc47d 100644 --- a/src/Api/Jellyfin/Cache/JellyfinCacheRepository.php +++ b/src/Api/Jellyfin/Cache/JellyfinCacheRepository.php @@ -59,6 +59,10 @@ public function fetchJellyfinMoviesByUserId(int $userId) : JellyfinMovieDtoList public function fetchJellyfinMoviesByTmdbIds(int $userId, array $tmdbIds) : JellyfinMovieDtoList { + if (count($tmdbIds) === 0) { + return JellyfinMovieDtoList::create(); + } + $placeholders = trim(str_repeat('?, ', count($tmdbIds)), ', '); $result = $this->dbConnection->fetchAllAssociative( diff --git a/src/Api/Jellyfin/JellyfinApi.php b/src/Api/Jellyfin/JellyfinApi.php index e4c96741..41f4189c 100644 --- a/src/Api/Jellyfin/JellyfinApi.php +++ b/src/Api/Jellyfin/JellyfinApi.php @@ -9,8 +9,10 @@ use Movary\Api\Jellyfin\Dto\JellyfinUserId; use Movary\Api\Jellyfin\Exception\JellyfinInvalidAuthentication; use Movary\Api\Jellyfin\Exception\JellyfinServerUrlMissing; +use Movary\Domain\Movie\History\MovieHistoryApi; use Movary\Domain\User\UserApi; use Movary\Service\ServerSettings; +use Movary\ValueObject\Date; use Movary\ValueObject\RelativeUrl; use Movary\ValueObject\Url; use Psr\Log\LoggerInterface; @@ -22,6 +24,7 @@ public function __construct( private readonly ServerSettings $serverSettings, private readonly UserApi $userApi, private readonly JellyfinCache $jellyfinMovieCache, + private readonly MovieHistoryApi $movieHistoryApi, private readonly LoggerInterface $logger, ) { } @@ -105,13 +108,19 @@ public function setMoviesWatchState(int $userId, array $watchedTmdbIds, array $u } $combinedTmdbIds = array_merge($watchedTmdbIds, $unwatchedTmdbIds); + $tmdbIdsToLastWatchDateMap = $this->movieHistoryApi->fetchTmdbIdsToLastWatchDatesMap($userId, $watchedTmdbIds); foreach ($this->jellyfinMovieCache->fetchJellyfinMoviesByTmdbIds($userId, $combinedTmdbIds) as $jellyfinMovie) { + $tmdbId = $jellyfinMovie->getTmdbId(); + $watched = in_array($tmdbId, $watchedTmdbIds); + $lastWatchDate = $tmdbIdsToLastWatchDateMap[$tmdbId] ?? null; + $this->setMovieWatchState( $userId, $jellyfinAuthentication, $jellyfinMovie, - in_array($jellyfinMovie->getTmdbId(), $watchedTmdbIds), + $watched, + $lastWatchDate, ); } } @@ -121,6 +130,7 @@ private function setMovieWatchState( JellyfinAuthenticationData $jellyfinAuthentication, Dto\JellyfinMovieDto $jellyfinMovie, bool $watched, + ?Date $lastWatchDate, ) : void { $relativeUrl = RelativeUrl::create( sprintf( @@ -132,19 +142,54 @@ private function setMovieWatchState( $url = $jellyfinAuthentication->getServerUrl()->appendRelativeUrl($relativeUrl); - if ($watched === true) { - $this->jellyfinClient->post($url, jellyfinAccessToken: $jellyfinAuthentication->getAccessToken()); - } else { + $currentLastWatchDateJellyfin = $jellyfinMovie->getLastWatchDate(); + if ($watched === true && + $currentLastWatchDateJellyfin !== null && + $lastWatchDate !== null && + $currentLastWatchDateJellyfin->isEqual($lastWatchDate) === true) { + $this->logger->debug( + 'Jellyfin sync: Skipped movie watch state update, no change', + [ + 'userId' => $userId, + 'tmdbId' => $jellyfinMovie->getJellyfinItemId(), + 'itemId' => $jellyfinMovie->getJellyfinItemId(), + 'watchedState' => $watched, + 'lastWatchDate' => (string)$lastWatchDate, + ], + ); + + return; + } + + if ($watched === false) { $this->jellyfinClient->delete($url, jellyfinAccessToken: $jellyfinAuthentication->getAccessToken()); + + $this->logger->info( + 'Jellyfin sync: Movie watch state deleted', + [ + 'userId' => $userId, + 'tmdbId' => $jellyfinMovie->getJellyfinItemId(), + 'itemId' => $jellyfinMovie->getJellyfinItemId(), + ], + ); + + return; } + $this->jellyfinClient->post( + $url, + ['datePlayed' => (string)$lastWatchDate], + jellyfinAccessToken: $jellyfinAuthentication->getAccessToken(), + ); + $this->logger->info( 'Jellyfin sync: Movie watch state updated', [ 'userId' => $userId, - 'tmdbId' => $jellyfinMovie->getJellyfinItemId(), + 'tmdbId' => $jellyfinMovie->getTmdbId(), 'itemId' => $jellyfinMovie->getJellyfinItemId(), - 'watchedState' => $watched + 'oldLastWatchDate' => (string)$currentLastWatchDateJellyfin, + 'newLastWatchDate' => (string)$lastWatchDate, ], ); } diff --git a/src/Domain/Movie/History/MovieHistoryApi.php b/src/Domain/Movie/History/MovieHistoryApi.php index bcf61ae2..5fefc4a6 100644 --- a/src/Domain/Movie/History/MovieHistoryApi.php +++ b/src/Domain/Movie/History/MovieHistoryApi.php @@ -226,9 +226,26 @@ public function fetchMostWatchedReleaseYears(int $userId) : array return $this->movieRepository->fetchMostWatchedReleaseYears($userId); } - public function fetchTmdbIdsWithWatchHistoryByUserId(int $userId, array $movieIds) : array + public function fetchTmdbIdsToLastWatchDatesMap(int $userId, array $tmdbIds) : array { - return $this->movieRepository->fetchTmdbIdsWithWatchHistoryByUserId($userId, $movieIds); + $map = []; + + foreach ($this->movieRepository->fetchTmdbIdsToLastWatchDatesMap($userId, $tmdbIds) as $row) { + $map[$row['tmdb_id']] = Date::createFromString($row['latest_watched_at']); + } + + return $map; + } + + public function fetchTmdbIdsWithWatchHistoryByUserIdAndMovieIds(int $userId, array $movieIds) : array + { + return $this->movieRepository->fetchTmdbIdsWithWatchHistoryByUserIdAndMovieIds($userId, $movieIds); + } + + + public function fetchMovieIdsWithWatchHistoryByUserId(int $userId) : array + { + return $this->movieRepository->fetchMovieIdsWithWatchHistoryByUserId($userId); } public function fetchTmdbIdsWithoutWatchHistoryByUserId(int $userId, array $movieIds) : array diff --git a/src/Domain/Movie/MovieRepository.php b/src/Domain/Movie/MovieRepository.php index 92b56247..dbda5096 100644 --- a/src/Domain/Movie/MovieRepository.php +++ b/src/Domain/Movie/MovieRepository.php @@ -284,7 +284,43 @@ public function fetchHistoryByMovieId(int $movieId, int $userId) : array ); } - public function fetchTmdbIdsWithWatchHistoryByUserId(int $userId, array $movieIds) : array + public function fetchTmdbIdsToLastWatchDatesMap(int $userId, array $tmdbIds) : array + { + if (count($tmdbIds) === 0) { + return []; + } + + $placeholders = trim(str_repeat('?, ', count($tmdbIds)), ', '); + + return $this->dbConnection->fetchAllAssociative( + <<dbConnection->fetchFirstColumn( + <<dbConnection->fetchFirstColumn( diff --git a/src/HttpController/JobController.php b/src/HttpController/JobController.php index e6dc1671..d95e9382 100644 --- a/src/HttpController/JobController.php +++ b/src/HttpController/JobController.php @@ -190,7 +190,7 @@ public function scheduleJellyfinImportHistory() : Response ); } - public function scheduleJellyfinExportHistory() : Response + public function scheduleJellyfinExportHistory(Request $request) : Response { if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createSeeOther('/'); @@ -203,7 +203,9 @@ public function scheduleJellyfinExportHistory() : Response return Response::createBadRequest(JellyfinInvalidAuthentication::create()->getMessage()); } - $this->jobQueueApi->addJellyfinExportMoviesJob($currentUserId); + $forceExport = $request->getGetParameters()['forceExport'] ?? false; + + $this->jobQueueApi->addJellyfinExportMoviesJob($currentUserId, force: (bool)$forceExport); return Response::create( StatusCode::createSeeOther(), diff --git a/src/JobQueue/JobQueueApi.php b/src/JobQueue/JobQueueApi.php index c49cb49f..ca15c789 100644 --- a/src/JobQueue/JobQueueApi.php +++ b/src/JobQueue/JobQueueApi.php @@ -49,7 +49,7 @@ public function addTmdbMovieSyncJob(JobStatus $jobStatus) : int return $this->repository->addJob(JobType::createTmdbMovieSync(), $jobStatus); } - public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], ?JobStatus $jobStatus = null) : int + public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], bool $force = false, ?JobStatus $jobStatus = null) : int { return $this->repository->addJob( JobType::createJellyfinExportMovies(), @@ -57,6 +57,7 @@ public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], ?J $userId, parameters: [ 'movieIds' => $movieIds, + 'force' => $force, ], ); } diff --git a/src/Service/Jellyfin/JellyfinMoviesExporter.php b/src/Service/Jellyfin/JellyfinMoviesExporter.php index e3b0f0d5..b85601c2 100644 --- a/src/Service/Jellyfin/JellyfinMoviesExporter.php +++ b/src/Service/Jellyfin/JellyfinMoviesExporter.php @@ -23,14 +23,25 @@ public function executeJob(JobEntity $job) : void } $movieIds = $job->getParameters()['movieIds'] ?? []; + $forceExport = true; - $this->exportMoviesWatchStateToJellyfin($userId, $movieIds); + if (count($movieIds) === 0) { + $movieIds = $this->movieHistoryApi->fetchMovieIdsWithWatchHistoryByUserId($userId); + + $forceExport = false; + } + + $this->exportMoviesWatchStateToJellyfin($userId, $movieIds, $forceExport); } - private function exportMoviesWatchStateToJellyfin(int $userId, array $movieIds) : void + private function exportMoviesWatchStateToJellyfin(int $userId, array $movieIds, bool $removeWatchDates) : void { - $watchedTmdbIds = $this->movieHistoryApi->fetchTmdbIdsWithWatchHistoryByUserId($userId, $movieIds); - $unwatchedTmdbIds = $this->movieHistoryApi->fetchTmdbIdsWithoutWatchHistoryByUserId($userId, $movieIds); + $watchedTmdbIds = $this->movieHistoryApi->fetchTmdbIdsWithWatchHistoryByUserIdAndMovieIds($userId, $movieIds); + + $unwatchedTmdbIds = []; + if ($removeWatchDates === true) { + $unwatchedTmdbIds = $this->movieHistoryApi->fetchTmdbIdsWithoutWatchHistoryByUserId($userId, $movieIds); + } $this->jellyfinApi->setMoviesWatchState($userId, $watchedTmdbIds, $unwatchedTmdbIds); } diff --git a/src/ValueObject/Date.php b/src/ValueObject/Date.php index d975ac18..ee689407 100644 --- a/src/ValueObject/Date.php +++ b/src/ValueObject/Date.php @@ -65,6 +65,11 @@ public function getDifferenceInYears(Date $date) : int return (new \DateTime($this->date))->diff((new \DateTime($date->date)))->y; } + public function isEqual(self $lastWatchDate) : bool + { + return $this->date === $lastWatchDate->date; + } + public function jsonSerialize() : string { return $this->date; diff --git a/templates/page/settings-integration-jellyfin.html.twig b/templates/page/settings-integration-jellyfin.html.twig index 5d3caffd..637e8734 100644 --- a/templates/page/settings-integration-jellyfin.html.twig +++ b/templates/page/settings-integration-jellyfin.html.twig @@ -225,7 +225,11 @@ - @@ -237,7 +241,11 @@ - @@ -262,5 +270,46 @@ {{ include('component/modal-job.html.twig') }} + + + + +
{% endblock %} From 41512903df9ab193ac3621460cb477ba0d81514e Mon Sep 17 00:00:00 2001 From: Lee Peuker Date: Sat, 5 Aug 2023 13:43:25 +0200 Subject: [PATCH 3/4] Refactoring and cleanup --- src/HttpController/JobController.php | 6 ++-- src/JobQueue/JobQueueApi.php | 3 +- .../settings-integration-jellyfin.html.twig | 30 +++++++++---------- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/HttpController/JobController.php b/src/HttpController/JobController.php index d95e9382..e6dc1671 100644 --- a/src/HttpController/JobController.php +++ b/src/HttpController/JobController.php @@ -190,7 +190,7 @@ public function scheduleJellyfinImportHistory() : Response ); } - public function scheduleJellyfinExportHistory(Request $request) : Response + public function scheduleJellyfinExportHistory() : Response { if ($this->authenticationService->isUserAuthenticated() === false) { return Response::createSeeOther('/'); @@ -203,9 +203,7 @@ public function scheduleJellyfinExportHistory(Request $request) : Response return Response::createBadRequest(JellyfinInvalidAuthentication::create()->getMessage()); } - $forceExport = $request->getGetParameters()['forceExport'] ?? false; - - $this->jobQueueApi->addJellyfinExportMoviesJob($currentUserId, force: (bool)$forceExport); + $this->jobQueueApi->addJellyfinExportMoviesJob($currentUserId); return Response::create( StatusCode::createSeeOther(), diff --git a/src/JobQueue/JobQueueApi.php b/src/JobQueue/JobQueueApi.php index ca15c789..c49cb49f 100644 --- a/src/JobQueue/JobQueueApi.php +++ b/src/JobQueue/JobQueueApi.php @@ -49,7 +49,7 @@ public function addTmdbMovieSyncJob(JobStatus $jobStatus) : int return $this->repository->addJob(JobType::createTmdbMovieSync(), $jobStatus); } - public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], bool $force = false, ?JobStatus $jobStatus = null) : int + public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], ?JobStatus $jobStatus = null) : int { return $this->repository->addJob( JobType::createJellyfinExportMovies(), @@ -57,7 +57,6 @@ public function addJellyfinExportMoviesJob(int $userId, array $movieIds = [], bo $userId, parameters: [ 'movieIds' => $movieIds, - 'force' => $force, ], ); } diff --git a/templates/page/settings-integration-jellyfin.html.twig b/templates/page/settings-integration-jellyfin.html.twig index 637e8734..3a0bdb55 100644 --- a/templates/page/settings-integration-jellyfin.html.twig +++ b/templates/page/settings-integration-jellyfin.html.twig @@ -219,21 +219,21 @@ -
- -
- - -
+{#
#} + +{#
#} +{# #} +{# #} +{#
#}
From a6cb9d8dc0923b3c73817e82db52a5cf064a5316 Mon Sep 17 00:00:00 2001 From: Lee Peuker Date: Sat, 5 Aug 2023 14:28:45 +0200 Subject: [PATCH 4/4] Add cli export command and update docs --- bin/console.php | 1 + docs/features/jellyfin.md | 35 +++++++---- src/Command/JellyfinCacheDelete.php | 2 +- src/Command/JellyfinCacheRefresh.php | 2 +- src/Command/JellyfinExport.php | 62 +++++++++++++++++++ src/Command/TmdbMovieSync.php | 4 +- .../Jellyfin/JellyfinMoviesExporter.php | 2 +- 7 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 src/Command/JellyfinExport.php diff --git a/bin/console.php b/bin/console.php index 8673c671..d092c771 100644 --- a/bin/console.php +++ b/bin/console.php @@ -27,5 +27,6 @@ $application->add($container->get(Movary\Command\ImdbSync::class)); $application->add($container->get(Movary\Command\JellyfinCacheDelete::class)); $application->add($container->get(Movary\Command\JellyfinCacheRefresh::class)); +$application->add($container->get(Movary\Command\JellyfinExport::class)); $application->run(); diff --git a/docs/features/jellyfin.md b/docs/features/jellyfin.md index 039e9652..aea39472 100644 --- a/docs/features/jellyfin.md +++ b/docs/features/jellyfin.md @@ -35,9 +35,13 @@ When an authentication is removed from Movary, the token will be deleted in Mova ## Sync -### Automatic Sync +General notes: -#### Description +- Movies are matched via tmdb id. Movies without a tmdb id in Jellyfin are ignored. +- Movies will be updated in all Jellyfin libraries. +- Backup your Jellyfin database regularly in case something goes wrong! + +### Automatic sync You can keep your Jellyfin libraries automatically up to date with your latest Movary watch history changes. @@ -45,17 +49,26 @@ You can keep your Jellyfin libraries automatically up to date with your latest M Jellyfin [authentication](#authentication) is required. +If the automatic sync is enabled (e.g. on `/settings/integrations/jellyfin`) new watch dates added to Movary are automatically pushed as plays to Jellyfin. +If a movie has its last watch date removed in Movary it is set to unwatched in Jellyfin. + +### Export + +#### Description -If the automatic sync is enabled, new plays added to Movary are automatically pushed to Jellyfin and the movies are marked as watched. -If a movie has its last play removed, Movary will set the movie to unwatched in Jellyfin. +You can export your Movary watch dates as plays to Jellyfin. + +!!! Info + + Jellyfin [authentication](#authentication) is required. -Notes: +Movary will compare its movie watch dates against the Jellyfin movie plays. +Movies not marked as watched in Jellyfin but with watch dates in Movary are marked as watched and get the latest watch date set as the last play date. +Movies already marked as watched are updated with the latest watch date as the last play date if the dates are not the same. -- can be enabled on the Jellyfin integration settings page -- movies will be updated in all Jellyfin libraries they exist -- only movies with a tmdb id in Jellyfin are supported and handled +#### CLI command -#### CLI commands +```shell +php bin/console.php jellyfin:export +``` -- `php bin/console.php jellyfin:cache:refresh ` -- `php bin/console.php jellyfin:cache:delete ` diff --git a/src/Command/JellyfinCacheDelete.php b/src/Command/JellyfinCacheDelete.php index c7a47456..2300d001 100644 --- a/src/Command/JellyfinCacheDelete.php +++ b/src/Command/JellyfinCacheDelete.php @@ -24,7 +24,7 @@ public function __construct( protected function configure() : void { - $this->setDescription('Delete the local cache of Jellyfin movies') + $this->setDescription('Delete the local cache of Jellyfin movies.') ->addArgument(self::OPTION_NAME_USER_ID, InputArgument::REQUIRED, 'Id of user.'); } diff --git a/src/Command/JellyfinCacheRefresh.php b/src/Command/JellyfinCacheRefresh.php index 55bcfbb7..ac099cf6 100644 --- a/src/Command/JellyfinCacheRefresh.php +++ b/src/Command/JellyfinCacheRefresh.php @@ -24,7 +24,7 @@ public function __construct( protected function configure() : void { - $this->setDescription('Refresh the local cache of Jellyfin movies') + $this->setDescription('Refresh the local cache of Jellyfin movies.') ->addArgument(self::OPTION_NAME_USER_ID, InputArgument::REQUIRED, 'Id of user.'); } diff --git a/src/Command/JellyfinExport.php b/src/Command/JellyfinExport.php new file mode 100644 index 00000000..e8f9aab1 --- /dev/null +++ b/src/Command/JellyfinExport.php @@ -0,0 +1,62 @@ +setDescription('Export Movary watch dates as plays to Jellyfin.') + ->addArgument(self::OPTION_NAME_USER_ID, InputArgument::REQUIRED, 'Id of user.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) : int + { + $userId = (int)$input->getArgument(self::OPTION_NAME_USER_ID); + + $jobId = $this->jobQueueApi->addJellyfinExportMoviesJob($userId, jobStatus: JobStatus::createInProgress()); + + try { + $this->generateOutput($output, 'Exporting movie watch dates to Jellyfin...'); + + $this->jellyfinMoviesExporter->exportMoviesWatchStateToJellyfin( + $userId, + $this->movieHistoryApi->fetchMovieIdsWithWatchHistoryByUserId($userId), false, + ); + } catch (Throwable $t) { + $this->generateOutput($output, 'ERROR: Could not complete Jellyfin export.'); + $this->logger->error('Could not complete Jellyfin export', ['exception' => $t]); + + $this->jobQueueApi->updateJobStatus($jobId, JobStatus::createFailed()); + + return Command::FAILURE; + } + + $this->generateOutput($output, 'Exporting movie watch dates to Jellyfin done.'); + + return Command::SUCCESS; + } +} diff --git a/src/Command/TmdbMovieSync.php b/src/Command/TmdbMovieSync.php index 37cddcb7..17975f59 100644 --- a/src/Command/TmdbMovieSync.php +++ b/src/Command/TmdbMovieSync.php @@ -54,8 +54,6 @@ protected function execute(InputInterface $input, OutputInterface $output) : int $this->syncMovieDetails->syncMovies($maxAgeInHours, $maxSyncsThreshold, $movieIds); $this->jobQueueApi->updateJobStatus($jobId, JobStatus::createDone()); - - $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]); @@ -65,6 +63,8 @@ protected function execute(InputInterface $input, OutputInterface $output) : int return Command::FAILURE; } + $this->generateOutput($output, 'Syncing movie meta data done.'); + return Command::SUCCESS; } } diff --git a/src/Service/Jellyfin/JellyfinMoviesExporter.php b/src/Service/Jellyfin/JellyfinMoviesExporter.php index b85601c2..77a512c0 100644 --- a/src/Service/Jellyfin/JellyfinMoviesExporter.php +++ b/src/Service/Jellyfin/JellyfinMoviesExporter.php @@ -34,7 +34,7 @@ public function executeJob(JobEntity $job) : void $this->exportMoviesWatchStateToJellyfin($userId, $movieIds, $forceExport); } - private function exportMoviesWatchStateToJellyfin(int $userId, array $movieIds, bool $removeWatchDates) : void + public function exportMoviesWatchStateToJellyfin(int $userId, array $movieIds, bool $removeWatchDates) : void { $watchedTmdbIds = $this->movieHistoryApi->fetchTmdbIdsWithWatchHistoryByUserIdAndMovieIds($userId, $movieIds);