HummingBirdAnimeClient/src/Command/SyncLists.php

673 lines
17 KiB
PHP
Raw Normal View History

2017-01-27 12:35:28 -05:00
<?php declare(strict_types=1);
/**
2017-02-15 16:13:32 -05:00
* Hummingbird Anime List Client
2017-01-27 12:35:28 -05:00
*
2018-08-22 13:48:27 -04:00
* An API client for Kitsu to manage anime and manga watch lists
2017-01-27 12:35:28 -05:00
*
* PHP version 7
*
2017-02-15 16:13:32 -05:00
* @package HummingbirdAnimeClient
2017-01-27 12:35:28 -05:00
* @author Timothy J. Warren <tim@timshomepage.net>
2018-01-15 14:43:15 -05:00
* @copyright 2015 - 2018 Timothy J. Warren
2017-01-27 12:35:28 -05:00
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
2017-01-27 12:35:28 -05:00
*/
namespace Aviat\AnimeClient\Command;
2018-09-26 22:31:04 -04:00
use Aviat\AnimeClient\API\
{FailedResponseException, JsonAPI, Kitsu\Transformer\MangaListTransformer, ParallelAPIRequest};
use Aviat\AnimeClient\API\Anilist\Transformer\{
AnimeListTransformer as AALT,
MangaListTransformer as AMLT,
};
2018-09-26 22:31:04 -04:00
use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\AnimeClient\Types\{AnimeFormItem, MangaFormItem};
use Aviat\Ion\Json;
2018-01-31 15:44:48 -05:00
use DateTime;
2017-01-27 12:35:28 -05:00
/**
2018-09-26 22:31:04 -04:00
* Syncs list data between Anilist and Kitsu
2017-01-27 12:35:28 -05:00
*/
final class SyncLists extends BaseCommand {
2018-09-26 22:31:04 -04:00
/**
* Model for making requests to Anilist API
* @var \Aviat\AnimeClient\API\Anilist\Model
*/
protected $anilistModel;
2017-02-17 10:55:17 -05:00
/**
* Model for making requests to Kitsu API
2017-02-22 14:46:35 -05:00
* @var \Aviat\AnimeClient\API\Kitsu\Model
2017-02-17 10:55:17 -05:00
*/
2017-01-27 12:35:28 -05:00
protected $kitsuModel;
2017-03-31 13:37:53 -04:00
/**
2018-09-26 22:31:04 -04:00
* Run the Kitsu <=> Anilist sync script
*
* @param array $args
* @param array $options
2018-01-31 15:44:48 -05:00
* @throws \Aviat\Ion\Di\ContainerException
* @throws \Aviat\Ion\Di\NotFoundException
* @return void
*/
2018-01-31 15:44:48 -05:00
public function execute(array $args, array $options = []): void
{
$this->setContainer($this->setupContainer());
$this->setCache($this->container->get('cache'));
2018-09-26 22:31:04 -04:00
$this->anilistModel = $this->container->get('anilist-model');
$this->kitsuModel = $this->container->get('kitsu-model');
$this->sync('anime');
$this->sync('manga');
}
2018-01-31 15:44:48 -05:00
/**
* Attempt to synchronize external apis
*
* @param string $type anime|manga
* @return void
*/
protected function sync(string $type): void
{
$uType = ucfirst($type);
2018-01-16 14:58:07 -05:00
$kitsuCount = 0;
try
{
$kitsuCount = $this->kitsuModel->{"get{$uType}ListCount"}();
}
catch (FailedResponseException $e)
{
dump($e);
}
$this->echoBox("Number of Kitsu {$type} list items: {$kitsuCount}");
$data = $this->diffLists($type);
2018-09-26 22:31:04 -04:00
if ( ! empty($data['addToAnilist']))
{
2018-09-26 22:31:04 -04:00
$count = count($data['addToAnilist']);
$this->echoBox("Adding {$count} missing {$type} list items to Anilist");
$this->updateAnilistListItems($data['addToAnilist'], 'create', $type);
}
2018-09-26 22:31:04 -04:00
if ( ! empty($data['updateAnilist']))
{
2018-09-26 22:31:04 -04:00
$count = count($data['updateAnilist']);
$this->echoBox("Updating {$count} outdated Anilist {$type} list items");
$this->updateAnilistListItems($data['updateAnilist'], 'update', $type);
}
if ( ! empty($data['addToKitsu']))
{
$count = count($data['addToKitsu']);
$this->echoBox("Adding {$count} missing {$type} list items to Kitsu");
$this->updateKitsuListItems($data['addToKitsu'], 'create', $type);
}
if ( ! empty($data['updateKitsu']))
{
$count = count($data['updateKitsu']);
$this->echoBox("Updating {$count} outdated Kitsu {$type} list items");
$this->updateKitsuListItems($data['updateKitsu'], 'update', $type);
}
}
2018-01-31 15:44:48 -05:00
/**
* Filter Kitsu mappings for the specified type
*
* @param array $includes
* @param string $type
* @return array
*/
protected function filterMappings(array $includes, string $type = 'anime'): array
2017-01-27 12:35:28 -05:00
{
$output = [];
foreach($includes as $id => $mapping)
2017-01-27 12:35:28 -05:00
{
if ($mapping['externalSite'] === "myanimelist/{$type}")
{
$output[$id] = $mapping;
}
2017-01-27 12:35:28 -05:00
}
return $output;
}
2018-01-31 15:44:48 -05:00
/**
2018-09-26 22:31:04 -04:00
* Format an Anilist list for comparison
2018-01-31 15:44:48 -05:00
*
* @param string $type
* @return array
*/
2018-09-26 22:31:04 -04:00
protected function formatAnilistList(string $type): array
{
2018-01-31 15:44:48 -05:00
$type = ucfirst($type);
2018-09-26 22:31:04 -04:00
$method = "formatAnilist{$type}List";
2018-01-31 15:44:48 -05:00
return $this->$method();
2018-09-26 22:31:04 -04:00
}
2018-01-31 15:44:48 -05:00
/**
2018-09-26 22:31:04 -04:00
* Format an Anilist anime list for comparison
2018-01-31 15:44:48 -05:00
*
* @return array
*/
2018-09-26 22:31:04 -04:00
protected function formatAnilistAnimeList(): array
{
2018-09-26 22:31:04 -04:00
$anilistList = $this->anilistModel->getSyncList('ANIME');
$anilistTransformer = new AALT();
2018-09-26 22:31:04 -04:00
$transformedAnilist = [];
2018-09-26 22:31:04 -04:00
foreach ($anilistList['data']['MediaListCollection']['lists'] as $list)
{
2018-09-26 22:31:04 -04:00
$newTransformed = $anilistTransformer->untransformCollection($list['entries']);
$transformedAnilist = array_merge($transformedAnilist, $newTransformed);
}
2018-09-26 22:31:04 -04:00
// Key the array by the mal_id for easier reference in the next comparision step
$output = [];
foreach ($transformedAnilist as $item)
2017-01-27 12:35:28 -05:00
{
2018-09-26 22:31:04 -04:00
$output[$item['mal_id']] = $item->toArray();
2017-01-27 12:35:28 -05:00
}
2018-09-26 22:31:04 -04:00
$count = count($output);
$this->echoBox("Number of Anilist anime list items: {$count}");
return $output;
2018-09-26 22:31:04 -04:00
}
2018-01-31 15:44:48 -05:00
/**
2018-09-26 22:31:04 -04:00
* Format an Anilist manga list for comparison
2018-01-31 15:44:48 -05:00
*
* @return array
*/
2018-09-26 22:31:04 -04:00
protected function formatAnilistMangaList(): array
{
2018-09-26 22:31:04 -04:00
$anilistList = $this->anilistModel->getSyncList('MANGA');
$anilistTransformer = new AMLT();
2018-09-26 22:31:04 -04:00
$transformedAnilist = [];
2018-09-26 22:31:04 -04:00
foreach ($anilistList['data']['MediaListCollection']['lists'] as $list)
{
2018-09-26 22:31:04 -04:00
$newTransformed = $anilistTransformer->untransformCollection($list['entries']);
$transformedAnilist = array_merge($transformedAnilist, $newTransformed);
}
2018-09-26 22:31:04 -04:00
// Key the array by the mal_id for easier reference in the next comparision step
$output = [];
foreach ($transformedAnilist as $item)
{
2018-09-26 22:31:04 -04:00
$output[$item['mal_id']] = $item->toArray();
}
2018-09-26 22:31:04 -04:00
$count = count($output);
$this->echoBox("Number of Anilist manga list items: {$count}");
return $output;
2018-09-26 22:31:04 -04:00
}
2018-01-31 15:44:48 -05:00
/**
* Format a kitsu list for the sake of comparision
*
* @param string $type
* @return array
*/
protected function formatKitsuList(string $type = 'anime'): array
{
2018-09-26 22:31:04 -04:00
$data = $this->kitsuModel->{'getFullRaw' . ucfirst($type) . 'List'}();
if (empty($data))
{
return [];
}
$includes = JsonAPI::organizeIncludes($data['included']);
$includes['mappings'] = $this->filterMappings($includes['mappings'], $type);
$output = [];
foreach($data['data'] as $listItem)
{
$id = $listItem['relationships'][$type]['data']['id'];
2017-12-04 16:06:27 -05:00
$potentialMappings = $includes[$type][$id]['relationships']['mappings'];
$malId = NULL;
foreach ($potentialMappings as $mappingId)
{
if (array_key_exists($mappingId, $includes['mappings']))
{
$malId = $includes['mappings'][$mappingId]['externalId'];
}
}
2018-09-26 22:31:04 -04:00
// Skip to the next item if there isn't a Anilist ID
2018-01-31 15:44:48 -05:00
if ($malId === NULL)
{
continue;
}
$output[$listItem['id']] = [
'id' => $listItem['id'],
'malId' => $malId,
'data' => $listItem['attributes'],
];
}
return $output;
}
2018-01-31 15:44:48 -05:00
/**
* Go through lists of the specified type, and determine what kind of action each item needs
*
* @param string $type
* @return array
*/
protected function diffLists(string $type = 'anime'): array
{
// Get libraryEntries with media.mappings from Kitsu
// Organize mappings, and ignore entries without mappings
$kitsuList = $this->formatKitsuList($type);
2018-09-26 22:31:04 -04:00
// Get Anilist list data
$anilistList = $this->formatAnilistList($type);
2018-09-26 22:31:04 -04:00
$itemsToAddToAnilist = [];
$itemsToAddToKitsu = [];
2018-09-26 22:31:04 -04:00
$anilistUpdateItems = [];
2017-03-31 13:37:53 -04:00
$kitsuUpdateItems = [];
2018-09-26 22:31:04 -04:00
$malBlackList = ($type === 'anime')
? [
27821, // Fate/stay night: Unlimited Blade Works - Prologue
29317, // Saekano: How to Raise a Boring Girlfriend Prologue
30514, // Nisekoinogatari
] : [
114638, // Cells at Work: Black
];
$malIds = array_keys($anilistList);
$kitsuMalIds = array_map('intval', array_column($kitsuList, 'malId'));
$missingMalIds = array_diff($malIds, $kitsuMalIds);
$missingMalIds = array_diff($missingMalIds, $malBlackList);
foreach($missingMalIds as $mid)
{
$itemsToAddToKitsu[] = array_merge($anilistList[$mid]['data'], [
'id' => $this->kitsuModel->getKitsuIdFromMALId((string)$mid, $type),
'type' => $type
]);
2018-09-26 22:31:04 -04:00
}
2018-09-26 22:31:04 -04:00
foreach($kitsuList as $kitsuItem)
{
2018-09-26 22:31:04 -04:00
$malId = $kitsuItem['malId'];
if (in_array($malId, $malBlackList))
{
2018-09-26 22:31:04 -04:00
continue;
}
if (array_key_exists($malId, $anilistList))
{
$anilistItem = $anilistList[$malId];
// dump($anilistItem);
$item = $this->compareListItems($kitsuItem, $anilistItem);
2018-01-31 15:44:48 -05:00
if ($item === NULL)
{
continue;
}
2018-01-31 15:44:48 -05:00
if (\in_array('kitsu', $item['updateType'], TRUE))
{
$kitsuUpdateItems[] = $item['data'];
}
2018-09-26 22:31:04 -04:00
if (\in_array('anilist', $item['updateType'], TRUE))
{
2018-09-26 22:31:04 -04:00
$anilistUpdateItems[] = $item['data'];
}
continue;
}
2018-09-26 22:31:04 -04:00
$statusMap = ($type === 'anime') ? AnimeWatchingStatus::class : MangaReadingStatus::class;
// Looks like this item only exists on Kitsu
2018-09-26 22:31:04 -04:00
$kItem = $kitsuItem['data'];
$newItemStatus = ($kItem['reconsuming'] === true) ? 'REPEATING' : $statusMap::KITSU_TO_ANILIST[$kItem['status']];
$itemsToAddToAnilist[] = [
'mal_id' => $malId,
'data' => [
'notes' => $kItem['notes'],
'private' => $kItem['private'],
'progress' => $kItem['progress'],
'repeat' => $kItem['reconsumeCount'],
'score' => $kItem['ratingTwenty'] / 2,
'status' => $newItemStatus,
], // $kitsuItem['data']
];
2018-09-26 22:31:04 -04:00
}
//dump($itemsToAddToAnilist);
//die();
return [
2018-09-26 22:31:04 -04:00
'addToAnilist' => $itemsToAddToAnilist,
'updateAnilist' => $anilistUpdateItems,
2017-03-31 13:37:53 -04:00
'addToKitsu' => $itemsToAddToKitsu,
'updateKitsu' => $kitsuUpdateItems
];
2017-01-27 12:35:28 -05:00
}
2018-01-31 15:44:48 -05:00
/**
* Compare two list items, and return the out of date one, if one exists
*
* @param array $kitsuItem
2018-09-26 22:31:04 -04:00
* @param array $anilistItem
2018-01-31 15:44:48 -05:00
* @return array|null
*/
2018-09-26 22:31:04 -04:00
protected function compareListItems(array $kitsuItem, array $anilistItem): ?array
{
2018-09-26 22:31:04 -04:00
$compareKeys = [
'notes',
'progress',
'rating',
'reconsumeCount',
'reconsuming',
'status',
];
$diff = [];
2018-09-26 22:31:04 -04:00
// Correct differences in notation
$kitsuItem['data']['rating'] = $kitsuItem['data']['ratingTwenty'] / 2;
foreach($compareKeys as $key)
{
2018-09-26 22:31:04 -04:00
$diff[$key] = $kitsuItem['data'][$key] <=> $anilistItem['data'][$key];
}
// No difference? Bail out early
$diffValues = array_values($diff);
$diffValues = array_unique($diffValues);
if (count($diffValues) === 1 && $diffValues[0] === 0)
{
2018-01-31 15:44:48 -05:00
return NULL;
}
$update = [
'id' => $kitsuItem['id'],
'mal_id' => $kitsuItem['malId'],
'data' => []
];
$return = [
'updateType' => []
];
2018-09-26 22:31:04 -04:00
$sameNotes = $diff['notes'] === 0;
$sameStatus = $diff['status'] === 0;
$sameProgress = $diff['progress'] === 0;
$sameRating = $diff['rating'] === 0;
2018-09-26 22:31:04 -04:00
$sameRewatchCount = $diff['reconsumeCount'] === 0;
// If an item is completed, make sure the 'reconsuming' flag is false
if ($kitsuItem['data']['status'] === 'completed' && $kitsuItem['data']['reconsuming'] === TRUE)
{
$update['data']['reconsuming'] = FALSE;
$return['updateType'][] = 'kitsu';
}
// If status is the same, and progress count is different, use greater progress
if ($sameStatus && ( ! $sameProgress))
{
if ($diff['progress'] === 1)
{
$update['data']['progress'] = $kitsuItem['data']['progress'];
2018-09-26 22:31:04 -04:00
$return['updateType'][] = 'anilist';
}
else if($diff['progress'] === -1)
{
2018-09-26 22:31:04 -04:00
$update['data']['progress'] = $anilistItem['data']['progress'];
$return['updateType'][] = 'kitsu';
}
}
2018-09-26 22:31:04 -04:00
// If status is different, go with Kitsu
if ( ! $sameStatus)
{
$update['data']['status'] = $kitsuItem['data']['status'];
$return['updateType'][] = 'anilist';
}
// If status and progress are different, it's a bit more complicated...
// But, at least for now, assume newer record is correct
2018-09-26 22:31:04 -04:00
/* if ( ! ($sameStatus || $sameProgress))
{
if ($dateDiff === 1)
{
$update['data']['status'] = $kitsuItem['data']['status'];
if ((int)$kitsuItem['data']['progress'] !== 0)
{
$update['data']['progress'] = $kitsuItem['data']['progress'];
}
2018-09-26 22:31:04 -04:00
$return['updateType'][] = 'anilist';
}
else if($dateDiff === -1)
{
2018-09-26 22:31:04 -04:00
$update['data']['status'] = $anilistItem['data']['status'];
2018-09-26 22:31:04 -04:00
if ((int)$anilistItem['data']['progress'] !== 0)
{
$update['data']['progress'] = $kitsuItem['data']['progress'];
}
$return['updateType'][] = 'kitsu';
}
2018-09-26 22:31:04 -04:00
}*/
2018-09-26 22:31:04 -04:00
// If rating is different, use the kitsu rating, unless the other rating
// is set, and the kitsu rating is not set
if ( ! $sameRating)
{
2018-09-26 22:31:04 -04:00
if ($kitsuItem['data']['rating'] !== 0)
{
$update['data']['rating'] = $kitsuItem['data']['rating'];
2018-09-26 22:31:04 -04:00
$return['updateType'][] = 'anilist';
}
2018-09-26 22:31:04 -04:00
else
{
$update['data']['rating'] = $anilistItem['data']['rating'];
$return['updateType'][] = 'kitsu';
}
}
// If notes are set, use kitsu, otherwise, set kitsu from anilist
if ( ! $sameNotes)
{
if ($kitsuItem['data']['notes'] !== '')
{
$update['data']['notes'] = $kitsuItem['data']['notes'];
$return['updateType'][] = 'anilist';
}
else
{
$update['data']['notes'] = $anilistItem['data']['notes'];
$return['updateType'][] = 'kitsu';
}
}
// Assume the larger reconsumeCount is correct
if ( ! $sameRewatchCount)
{
if ($diff['reconsumeCount'] === 1)
{
$update['data']['reconsumeCount'] = $kitsuItem['data']['reconsumeCount'];
$return['updateType'][] = 'anilist';
}
else if ($diff['reconsumeCount'] === -1)
{
2018-09-26 22:31:04 -04:00
$update['data']['reconsumeCount'] = $anilistItem['data']['reconsumeCount'];
$return['updateType'][] = 'kitsu';
}
}
// If status is different, use the status of the more recently updated item
2018-09-26 22:31:04 -04:00
/* if ( ! $sameStatus)
{
if ($dateDiff === 1)
{
$update['data']['status'] = $kitsuItem['data']['status'];
2018-09-26 22:31:04 -04:00
$return['updateType'][] = 'anilist';
}
else if ($dateDiff === -1)
{
2018-09-26 22:31:04 -04:00
$update['data']['status'] = $anilistItem['data']['status'];
$return['updateType'][] = 'kitsu';
}
2018-09-26 22:31:04 -04:00
} */
$return['meta'] = [
'kitsu' => $kitsuItem['data'],
2018-09-26 22:31:04 -04:00
'anilist' => $anilistItem['data'],
// 'dateDiff' => $dateDiff,
'diff' => $diff,
];
$return['data'] = $update;
$return['updateType'] = array_unique($return['updateType']);
2018-09-26 22:31:04 -04:00
// Fill in missing data values for update on Anlist
// so I don't have to create a really complex graphql query
// to handle each combination of fields
if ($return['updateType'][0] === 'anilist')
{
$prevData = [
'notes' => $kitsuItem['data']['notes'],
'private' => $kitsuItem['data']['private'],
'progress' => $kitsuItem['data']['progress'],
'rating' => $kitsuItem['data']['rating'],
'reconsumeCount' => $kitsuItem['data']['reconsumeCount'],
'reconsuming' => $kitsuItem['data']['reconsuming'],
'status' => $kitsuItem['data']['status'],
];
$return['data']['data'] = array_merge($prevData, $return['data']['data']);
}
dump($return);
return $return;
}
2018-01-31 15:44:48 -05:00
/**
* Create/Update list items on Kitsu
*
* @param array $itemsToUpdate
* @param string $action
* @param string $type
*/
protected function updateKitsuListItems(array $itemsToUpdate, string $action = 'update', string $type = 'anime'): void
{
$requester = new ParallelAPIRequest();
foreach($itemsToUpdate as $item)
{
2018-09-26 22:31:04 -04:00
$typeClass = '\\Aviat\\AnimeClient\\Types\\' . ucFirst($type) . 'FormItem';
if ($action === 'update')
{
2018-09-26 22:31:04 -04:00
$requester->addRequest(
$this->kitsuModel->updateListItem(new $typeClass($item))
);
}
else if ($action === 'create')
{
$requester->addRequest($this->kitsuModel->createListItem($item));
}
}
$responses = $requester->makeRequests();
foreach($responses as $key => $response)
{
$responseData = Json::decode($response);
2018-01-10 16:43:49 -05:00
$id = $itemsToUpdate[$key]['id'];
2018-01-10 16:34:25 -05:00
if ( ! array_key_exists('errors', $responseData))
{
$verb = ($action === 'update') ? 'updated' : 'created';
$this->echoBox("Successfully {$verb} Kitsu {$type} list item with id: {$id}");
}
else
{
dump($responseData);
$verb = ($action === 'update') ? 'update' : 'create';
$this->echoBox("Failed to {$verb} Kitsu {$type} list item with id: {$id}");
}
}
}
2018-01-31 15:44:48 -05:00
/**
2018-09-26 22:31:04 -04:00
* Create/Update list items on Anilist
2018-01-31 15:44:48 -05:00
*
* @param array $itemsToUpdate
* @param string $action
* @param string $type
*/
2018-09-26 22:31:04 -04:00
protected function updateAnilistListItems(array$itemsToUpdate, string $action = 'update', string $type = 'anime'): void
{
$requester = new ParallelAPIRequest();
2018-09-26 22:31:04 -04:00
$typeClass = '\\Aviat\\AnimeClient\\Types\\' . ucFirst($type) . 'FormItem';
foreach($itemsToUpdate as $item)
{
if ($action === 'update')
2018-09-26 22:31:04 -04:00
{
$requester->addRequest(
$this->anilistModel->updateListItem(new $typeClass($item), $type)
);
}
else if ($action === 'create')
{
2018-09-26 22:31:04 -04:00
$requester->addRequest($this->anilistModel->createFullListItem($item, $type));
}
}
2017-02-17 10:55:17 -05:00
$responses = $requester->makeRequests();
2017-02-17 10:55:17 -05:00
foreach($responses as $key => $response)
{
$id = $itemsToUpdate[$key]['mal_id'];
2018-09-26 22:31:04 -04:00
$responseData = Json::decode($response);
// $id = $itemsToUpdate[$key]['id'];
if ( ! array_key_exists('errors', $responseData))
{
$verb = ($action === 'update') ? 'updated' : 'created';
2018-09-26 22:31:04 -04:00
$this->echoBox("Successfully {$verb} Anilist {$type} list item with id: {$id}");
}
else
{
2018-09-26 22:31:04 -04:00
dump($responseData);
$verb = ($action === 'update') ? 'update' : 'create';
2018-09-26 22:31:04 -04:00
$this->echoBox("Failed to {$verb} Anilist {$type} list item with id: {$id}");
}
}
2018-09-26 22:31:04 -04:00
}
}