Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Jellyfin export #469

Merged
merged 4 commits into from
Aug 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bin/console.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
35 changes: 24 additions & 11 deletions docs/features/jellyfin.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,40 @@ 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.

!!! Info

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 <userId>
```

- `php bin/console.php jellyfin:cache:refresh <userId>`
- `php bin/console.php jellyfin:cache:delete <userId>`
6 changes: 6 additions & 0 deletions public/js/component/modal-job.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
63 changes: 60 additions & 3 deletions public/js/settings-integration-jellyfin.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -249,14 +251,69 @@ 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')

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')

return
}

addAlert('alertJellyfinExportHistoryDiv', 'History export could not be scheduled', 'danger')

throw new Error(`HTTP error! status: ${response.status}`)
}

addAlert('alertJellyfinExportHistoryDiv', 'History export scheduled', 'success')
exportHistoryModal.hide()
}

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')
}
10 changes: 10 additions & 0 deletions settings/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down
6 changes: 3 additions & 3 deletions src/Api/Jellyfin/Cache/JellyfinCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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]);
}
}
}
4 changes: 4 additions & 0 deletions src/Api/Jellyfin/Cache/JellyfinCacheRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 51 additions & 6 deletions src/Api/Jellyfin/JellyfinApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
) {
}
Expand Down Expand Up @@ -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,
);
}
}
Expand All @@ -121,6 +130,7 @@ private function setMovieWatchState(
JellyfinAuthenticationData $jellyfinAuthentication,
Dto\JellyfinMovieDto $jellyfinMovie,
bool $watched,
?Date $lastWatchDate,
) : void {
$relativeUrl = RelativeUrl::create(
sprintf(
Expand All @@ -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,
],
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Command/JellyfinCacheDelete.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down
2 changes: 1 addition & 1 deletion src/Command/JellyfinCacheRefresh.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
}

Expand Down
62 changes: 62 additions & 0 deletions src/Command/JellyfinExport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php declare(strict_types=1);

namespace Movary\Command;

use Movary\Domain\Movie\History\MovieHistoryApi;
use Movary\JobQueue\JobQueueApi;
use Movary\Service\Jellyfin\JellyfinMoviesExporter;
use Movary\ValueObject\JobStatus;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;

class JellyfinExport extends Command
{
private const OPTION_NAME_USER_ID = 'userId';

protected static $defaultName = 'jellyfin:export';

public function __construct(
private readonly JellyfinMoviesExporter $jellyfinMoviesExporter,
private readonly JobQueueApi $jobQueueApi,
private readonly MovieHistoryApi $movieHistoryApi,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}

protected function configure() : void
{
$this->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;
}
}
Loading