improved mismatch handling.
This commit is contained in:
@@ -245,19 +245,31 @@ final class RemoteCommand extends Command
|
||||
try {
|
||||
$result = $server->searchMismatch(id: $id, opts: ['coef' => $percentage]);
|
||||
} catch (Throwable $e) {
|
||||
$this->setOutputContent(['error' => $e->getMessage()], $output, $mode);
|
||||
$this->setOutputContent(
|
||||
[
|
||||
[
|
||||
'error' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
],
|
||||
],
|
||||
$output,
|
||||
$mode
|
||||
);
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (empty($result)) {
|
||||
$this->setOutputContent(
|
||||
[
|
||||
'info' => sprintf(
|
||||
'We are %1$02.2f%3$s sure there are no mis-identified items in library \'%2$s\'.',
|
||||
$percentage,
|
||||
$id,
|
||||
'%',
|
||||
)
|
||||
[
|
||||
'info' => sprintf(
|
||||
'We are %1$02.2f%3$s sure there are no mis-identified items in library \'%2$s\'.',
|
||||
$percentage,
|
||||
$id,
|
||||
'%',
|
||||
)
|
||||
]
|
||||
],
|
||||
$output,
|
||||
$mode
|
||||
|
||||
@@ -508,256 +508,290 @@ class JellyfinServer implements ServerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function searchMismatch(string|int $id, array $opts = []): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$this->checkConfig();
|
||||
|
||||
try {
|
||||
// -- Get Content type.
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
|
||||
http_build_query(
|
||||
[
|
||||
'Recursive' => 'false',
|
||||
'enableUserData' => 'false',
|
||||
'enableImages' => 'false',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$this->logger->debug(sprintf('%s: Requesting list of backend libraries.', $this->name), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$json = json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
$type = null;
|
||||
$found = false;
|
||||
|
||||
foreach (ag($json, 'Items', []) as $section) {
|
||||
if ((string)ag($section, 'Id') !== (string)$id) {
|
||||
continue;
|
||||
}
|
||||
$found = true;
|
||||
$type = ag($section, 'CollectionType', 'unknown');
|
||||
break;
|
||||
}
|
||||
|
||||
if (false === $found) {
|
||||
throw new RuntimeException(sprintf('%s: library id \'%s\' not found.', $this->name, $id));
|
||||
}
|
||||
|
||||
if ('movies' !== $type && 'tvshows' !== $type) {
|
||||
throw new RuntimeException(sprintf('%s: Library id \'%s\' is of unsupported type.', $this->name, $id));
|
||||
}
|
||||
|
||||
$type = $type === 'movies' ? iFace::TYPE_MOVIE : 'Show';
|
||||
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
|
||||
http_build_query(
|
||||
[
|
||||
'parentId' => $id,
|
||||
'enableUserData' => 'false',
|
||||
'enableImages' => 'false',
|
||||
'ExcludeLocationTypes' => 'Virtual',
|
||||
'include' => 'Series,Movie',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$this->logger->debug(sprintf('%s: Sending get library content for id \'%s\'.', $this->name, $id), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$handleRequest = function (string $type, array $item) use (&$list, $opts) {
|
||||
$this->logger->debug(sprintf('%s: Processing %s \'%s\'.', $this->name, $type, ag($item, 'Name')));
|
||||
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($item, 'Id')));
|
||||
|
||||
$possibleTitles = $paths = $locations = $guids = $matches = [];
|
||||
|
||||
$possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'];
|
||||
|
||||
foreach ($possibleTitlesList as $title) {
|
||||
if (null === ($title = ag($item, $title))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = formatName($title);
|
||||
|
||||
if (true === in_array($type, $possibleTitles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$possibleTitles[] = formatName($title);
|
||||
}
|
||||
|
||||
if (null !== ($path = ag($item, 'Path'))) {
|
||||
if (ag($item, 'Type') === 'Movie') {
|
||||
$paths[] = formatName(basename(dirname($path)));
|
||||
$locations[] = dirname($path);
|
||||
} else {
|
||||
$paths[] = formatName(basename($path));
|
||||
$locations[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
if (null !== ($providerIds = ag($item, 'ProviderIds'))) {
|
||||
$guids = $providerIds;
|
||||
}
|
||||
|
||||
foreach ($paths ?? [] as $location) {
|
||||
foreach ($possibleTitles as $title) {
|
||||
if (true === str_contains($location, $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$locationIsAscii = mb_detect_encoding($location, 'ASCII', true);
|
||||
$titleIsAscii = mb_detect_encoding($location, 'ASCII', true);
|
||||
|
||||
if (true === $locationIsAscii && $titleIsAscii) {
|
||||
similar_text($location, $title, $percent);
|
||||
} else {
|
||||
mb_similar_text($location, $title, $percent);
|
||||
}
|
||||
|
||||
if ($percent >= $opts['coef']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'path' => $location,
|
||||
'title' => $title,
|
||||
'similarity' => $percent,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'id' => ag($item, 'Id'),
|
||||
'type' => ucfirst($type),
|
||||
'url' => [(string)$url],
|
||||
'title' => ag($item, $possibleTitlesList, '??'),
|
||||
'year' => ag($item, 'ProductionYear', 0000),
|
||||
'guids' => $guids,
|
||||
'path' => $locations,
|
||||
'matching' => $matches,
|
||||
'comments' => (empty($paths)) ? 'No path found.' : 'Title does not match path.',
|
||||
];
|
||||
|
||||
if (empty($guids)) {
|
||||
$metadata['guids'] = 'None.';
|
||||
}
|
||||
|
||||
if (count($locations) <= 1) {
|
||||
$metadata['path'] = $locations[0];
|
||||
}
|
||||
|
||||
$list[] = $metadata;
|
||||
};
|
||||
|
||||
$it = Items::fromIterable(
|
||||
httpClientChunks($this->http->stream($response)),
|
||||
// -- Get Content type.
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
|
||||
http_build_query(
|
||||
[
|
||||
'pointer' => '/Items',
|
||||
'decoder' => new ErrorWrappingDecoder(
|
||||
new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
|
||||
)
|
||||
'Recursive' => 'false',
|
||||
'enableUserData' => 'false',
|
||||
'enableImages' => 'false',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$this->logger->debug(sprintf('%s: Requesting list of backend libraries.', $this->name), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$requests = [];
|
||||
$json = json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
foreach ($it as $entity) {
|
||||
if ($entity instanceof DecodingError) {
|
||||
$this->logger->warning(
|
||||
sprintf(
|
||||
'%s: Failed to decode one of library id \'%s\' items. %s',
|
||||
$this->name,
|
||||
$id,
|
||||
$entity->getErrorMessage()
|
||||
),
|
||||
[
|
||||
'payload' => $entity->getMalformedJson(),
|
||||
]
|
||||
);
|
||||
$type = null;
|
||||
$found = false;
|
||||
|
||||
foreach (ag($json, 'Items', []) as $section) {
|
||||
if ((string)ag($section, 'Id') !== (string)$id) {
|
||||
continue;
|
||||
}
|
||||
$found = true;
|
||||
$type = ag($section, 'CollectionType', 'unknown');
|
||||
break;
|
||||
}
|
||||
|
||||
if (false === $found) {
|
||||
throw new RuntimeException(sprintf('%s: library id \'%s\' not found.', $this->name, $id));
|
||||
}
|
||||
|
||||
if ('movies' !== $type && 'tvshows' !== $type) {
|
||||
throw new RuntimeException(sprintf('%s: Library id \'%s\' is of unsupported type.', $this->name, $id));
|
||||
}
|
||||
|
||||
$type = $type === 'movies' ? iFace::TYPE_MOVIE : 'Show';
|
||||
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/', $this->user))->withQuery(
|
||||
http_build_query(
|
||||
[
|
||||
'parentId' => $id,
|
||||
'enableUserData' => 'false',
|
||||
'enableImages' => 'false',
|
||||
'ExcludeLocationTypes' => 'Virtual',
|
||||
'include' => 'Series,Movie',
|
||||
]
|
||||
)
|
||||
);
|
||||
|
||||
$this->logger->debug(sprintf('%s: Sending get library content for id \'%s\'.', $this->name, $id), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$handleRequest = function (string $type, array $item) use (&$list, $opts) {
|
||||
$this->logger->debug(sprintf('%s: Processing %s \'%s\'.', $this->name, $type, ag($item, 'Name')));
|
||||
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($item, 'Id')));
|
||||
|
||||
$year = ag($item, 'ProductionYear', null);
|
||||
|
||||
$parseYear = '/\((\d{4})\)/';
|
||||
|
||||
$possibleTitles = $locations = $guids = $matches = [];
|
||||
|
||||
$possibleTitlesList = ['Name', 'OriginalTitle', 'SortName', 'ForcedSortName'];
|
||||
|
||||
foreach ($possibleTitlesList as $title) {
|
||||
if (null === ($title = ag($item, $title))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($entity, 'Id')));
|
||||
$title = formatName($title);
|
||||
|
||||
$this->logger->debug(sprintf('%s: get %s \'%s\' metadata.', $this->name, $type, ag($entity, 'Name')), [
|
||||
'url' => $url
|
||||
]);
|
||||
if (true === in_array($type, $possibleTitles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$requests[] = $this->http->request(
|
||||
'GET',
|
||||
(string)$url,
|
||||
array_replace_recursive($this->getHeaders(), [
|
||||
'user_data' => [
|
||||
'id' => ag($entity, 'Id'),
|
||||
'title' => ag($entity, 'Name'),
|
||||
'type' => $type,
|
||||
]
|
||||
])
|
||||
);
|
||||
$possibleTitles[] = formatName($title);
|
||||
}
|
||||
|
||||
foreach ($requests as $response) {
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
continue;
|
||||
if (null !== ($path = ag($item, 'Path'))) {
|
||||
if (ag($item, 'Type') === 'Movie') {
|
||||
$locations[] = [
|
||||
'l' => dirname($path),
|
||||
'n' => formatName(basename(dirname($path))),
|
||||
];
|
||||
}
|
||||
|
||||
$handleRequest(
|
||||
$response->getInfo('user_data')['type'],
|
||||
json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
$locations[] = [
|
||||
'l' => $path,
|
||||
'n' => formatName(basename($path)),
|
||||
];
|
||||
}
|
||||
|
||||
if (null !== ($providerIds = ag($item, 'ProviderIds'))) {
|
||||
$guids = $providerIds;
|
||||
}
|
||||
|
||||
foreach ($locations ?? [] as $location) {
|
||||
foreach ($possibleTitles as $title) {
|
||||
if (true === str_contains($location['n'], $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isASCII = mb_detect_encoding($location['n'], 'ASCII', true) && mb_detect_encoding(
|
||||
$title,
|
||||
'ASCII',
|
||||
true
|
||||
);
|
||||
|
||||
if (1 === preg_match($parseYear, basename($location['l']), $match)) {
|
||||
$withYear = true;
|
||||
$title = !empty($year) && !str_contains($title, (string)$year) ? $title . ' ' . $year : $title;
|
||||
} else {
|
||||
$withYear = false;
|
||||
}
|
||||
|
||||
if (true === $isASCII) {
|
||||
similar_text($location['n'], $title, $percent);
|
||||
} else {
|
||||
mb_similar_text($location['n'], $title, $percent);
|
||||
}
|
||||
|
||||
if ($percent >= $opts['coef']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'path' => $location['n'],
|
||||
'title' => $title,
|
||||
'type' => $isASCII ? 'ascii' : 'unicode',
|
||||
'methods' => [
|
||||
'similarity' => round($percent, 3),
|
||||
'levenshtein' => round(
|
||||
$isASCII ? levenshtein($location['n'], $title) : mb_levenshtein($location['n'], $title),
|
||||
3
|
||||
),
|
||||
'compareStrings' => compareStrings($location['n'], $title),
|
||||
],
|
||||
'year' => [
|
||||
'inPath' => $withYear,
|
||||
'parsed' => isset($match[1]) ? (int)$match[1] : 'No',
|
||||
'source' => $year ?? 'No',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'id' => ag($item, 'Id'),
|
||||
'type' => ucfirst($type),
|
||||
'url' => [(string)$url],
|
||||
'title' => ag($item, $possibleTitlesList, '??'),
|
||||
'year' => $year ?? 0000,
|
||||
'guids' => $guids,
|
||||
'path' => array_column($locations, 'l') ?? [],
|
||||
'matching' => $matches,
|
||||
'comments' => (empty($locations)) ? 'No path found.' : 'Title does not match path.',
|
||||
];
|
||||
|
||||
if (empty($guids)) {
|
||||
$metadata['guids'] = 'None.';
|
||||
}
|
||||
|
||||
if (!empty($metadata['path']) && count($metadata['path']) <= 1) {
|
||||
$metadata['path'] = $metadata['path'][0];
|
||||
}
|
||||
|
||||
if ('movie' === $type && 2 === count($metadata['path'])) {
|
||||
$metadata['path'] = $metadata['path'][1];
|
||||
}
|
||||
|
||||
$list[] = $metadata;
|
||||
};
|
||||
|
||||
$it = Items::fromIterable(
|
||||
httpClientChunks($this->http->stream($response)),
|
||||
[
|
||||
'pointer' => '/Items',
|
||||
'decoder' => new ErrorWrappingDecoder(
|
||||
new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
$requests = [];
|
||||
|
||||
foreach ($it as $entity) {
|
||||
if ($entity instanceof DecodingError) {
|
||||
$this->logger->warning(
|
||||
sprintf(
|
||||
'%s: Failed to decode one of library id \'%s\' items. %s',
|
||||
$this->name,
|
||||
$id,
|
||||
$entity->getErrorMessage()
|
||||
),
|
||||
[
|
||||
'payload' => $entity->getMalformedJson(),
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$url = $this->url->withPath(sprintf('/Users/%s/items/%s', $this->user, ag($entity, 'Id')));
|
||||
|
||||
$this->logger->debug(sprintf('%s: get %s \'%s\' metadata.', $this->name, $type, ag($entity, 'Name')), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$requests[] = $this->http->request(
|
||||
'GET',
|
||||
(string)$url,
|
||||
array_replace_recursive($this->getHeaders(), [
|
||||
'user_data' => [
|
||||
'id' => ag($entity, 'Id'),
|
||||
'title' => ag($entity, 'Name'),
|
||||
'type' => $type,
|
||||
]
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($requests as $response) {
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (ExceptionInterface|JsonException|\JsonMachine\Exception\InvalidArgumentException $e) {
|
||||
throw new RuntimeException(get_class($e) . ': ' . $e->getMessage(), $e->getCode(), $e);
|
||||
|
||||
$handleRequest(
|
||||
$response->getInfo('user_data')['type'],
|
||||
json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return $list;
|
||||
|
||||
@@ -542,24 +542,280 @@ class PlexServer implements ServerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Throwable
|
||||
*/
|
||||
public function searchMismatch(string|int $id, array $opts = []): array
|
||||
{
|
||||
$list = [];
|
||||
|
||||
$this->checkConfig();
|
||||
|
||||
try {
|
||||
// -- Get Content type.
|
||||
$url = $this->url->withPath('/library/sections/');
|
||||
// -- Get Content type.
|
||||
$url = $this->url->withPath('/library/sections/');
|
||||
|
||||
$this->logger->debug(sprintf('%s: Sending get sections list.', $this->name), [
|
||||
'url' => $url
|
||||
]);
|
||||
$this->logger->debug(sprintf('%s: Sending get sections list.', $this->name), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$json = json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
$type = null;
|
||||
$found = false;
|
||||
|
||||
foreach (ag($json, 'MediaContainer.Directory', []) as $section) {
|
||||
if ((int)ag($section, 'key') !== (int)$id) {
|
||||
continue;
|
||||
}
|
||||
$found = true;
|
||||
$type = ag($section, 'type', 'unknown');
|
||||
break;
|
||||
}
|
||||
|
||||
if (false === $found) {
|
||||
throw new RuntimeException(sprintf('%s: library id \'%s\' not found.', $this->name, $id));
|
||||
}
|
||||
|
||||
if ('movie' !== $type && 'show' !== $type) {
|
||||
throw new RuntimeException(sprintf('%s: Library id \'%s\' is of unsupported type.', $this->name, $id));
|
||||
}
|
||||
|
||||
$query = [
|
||||
'sort' => 'addedAt:asc',
|
||||
'includeGuids' => 1,
|
||||
];
|
||||
|
||||
if (iFace::TYPE_MOVIE === $type) {
|
||||
$query['type'] = 1;
|
||||
}
|
||||
|
||||
$url = $this->url->withPath(sprintf('/library/sections/%d/all', $id))->withQuery(http_build_query($query));
|
||||
|
||||
$this->logger->debug(sprintf('%s: Sending get library content for id \'%s\'.', $this->name, $id), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$handleRequest = function (string $type, array $item) use (&$list, $opts) {
|
||||
$this->logger->debug(sprintf('%s: Processing %s \'%s\'.', $this->name, $type, ag($item, 'title')));
|
||||
|
||||
$url = $this->url->withPath(sprintf('/library/metadata/%d', ag($item, 'ratingKey')));
|
||||
|
||||
$year = ag($item, 'year');
|
||||
|
||||
$parseYear = '/\((\d{4})\)/';
|
||||
|
||||
$locations = [];
|
||||
|
||||
$possibleTitles = $guids = $matches = [];
|
||||
$possibleTitlesList = ['title', 'originalTitle', 'titleSort'];
|
||||
foreach ($possibleTitlesList as $title) {
|
||||
if (null === ($title = ag($item, $title))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = formatName($title);
|
||||
|
||||
if (true === in_array($title, $possibleTitles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$possibleTitles[] = formatName($title);
|
||||
}
|
||||
|
||||
foreach (ag($item, 'Location', []) as $path) {
|
||||
$location = ag($path, 'path');
|
||||
$locations[] = [
|
||||
'l' => $location,
|
||||
'n' => formatName(basename($location)),
|
||||
];
|
||||
}
|
||||
|
||||
foreach (ag($item, 'Media', []) as $leaf) {
|
||||
foreach (ag($leaf, 'Part', []) as $path) {
|
||||
$location = ag($path, 'file');
|
||||
|
||||
$locations[] = [
|
||||
'l' => dirname($location),
|
||||
'n' => formatName(basename(dirname($location))),
|
||||
];
|
||||
|
||||
$locations[] = [
|
||||
'l' => $location,
|
||||
'n' => formatName(basename($location)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$guids[] = ag($item, 'guid', []);
|
||||
|
||||
foreach (ag($item, 'Guid', []) as $guid) {
|
||||
$guids[] = ag($guid, 'id');
|
||||
}
|
||||
|
||||
foreach ($locations ?? [] as $location) {
|
||||
foreach ($possibleTitles as $title) {
|
||||
if (true === str_contains($location['n'], $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$isASCII = mb_detect_encoding($location['n'], 'ASCII', true) && mb_detect_encoding(
|
||||
$title,
|
||||
'ASCII',
|
||||
true
|
||||
);
|
||||
|
||||
if (1 === preg_match($parseYear, basename($location['l']), $match)) {
|
||||
$withYear = true;
|
||||
$title = !empty($year) && !str_contains($title, (string)$year) ? $title . ' ' . $year : $title;
|
||||
} else {
|
||||
$withYear = false;
|
||||
}
|
||||
|
||||
if (true === $isASCII) {
|
||||
similar_text($location['n'], $title, $percent);
|
||||
} else {
|
||||
mb_similar_text($location['n'], $title, $percent);
|
||||
}
|
||||
|
||||
if ($percent >= $opts['coef']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'path' => $location['n'],
|
||||
'title' => $title,
|
||||
'type' => $isASCII ? 'ascii' : 'unicode',
|
||||
'methods' => [
|
||||
'similarity' => round($percent, 3),
|
||||
'levenshtein' => round(
|
||||
$isASCII ? levenshtein($location['n'], $title) : mb_levenshtein($location['n'], $title),
|
||||
3
|
||||
),
|
||||
'compareStrings' => compareStrings($location['n'], $title),
|
||||
],
|
||||
'year' => [
|
||||
'inPath' => $withYear,
|
||||
'parsed' => isset($match[1]) ? (int)$match[1] : 'No',
|
||||
'source' => $year ?? 'No',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'id' => (int)ag($item, 'ratingKey'),
|
||||
'type' => ucfirst($type),
|
||||
'url' => [(string)$url],
|
||||
'title' => ag($item, $possibleTitlesList, '??'),
|
||||
'year' => $year ?? 0000,
|
||||
'guids' => $guids,
|
||||
'path' => array_column($locations, 'l') ?? [],
|
||||
'matching' => $matches,
|
||||
'comments' => (empty($locations)) ? 'No path found.' : 'Title does not match path.',
|
||||
];
|
||||
|
||||
if (empty($guids)) {
|
||||
$metadata['guids'] = 'No external ids found.';
|
||||
}
|
||||
|
||||
if (!empty($metadata['path']) && count($metadata['path']) <= 1) {
|
||||
$metadata['path'] = $metadata['path'][0];
|
||||
}
|
||||
|
||||
if ('movie' === $type && 2 === count($metadata['path'])) {
|
||||
$metadata['path'] = $metadata['path'][1];
|
||||
}
|
||||
|
||||
$list[] = $metadata;
|
||||
};
|
||||
|
||||
$it = Items::fromIterable(
|
||||
httpClientChunks($this->http->stream($response)),
|
||||
[
|
||||
'pointer' => '/MediaContainer/Metadata',
|
||||
'decoder' => new ErrorWrappingDecoder(
|
||||
new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
$requests = [];
|
||||
|
||||
foreach ($it as $entity) {
|
||||
if ($entity instanceof DecodingError) {
|
||||
$this->logger->warning(
|
||||
sprintf(
|
||||
'%s: Failed to decode one of library id \'%s\' items. %s',
|
||||
$this->name,
|
||||
$id,
|
||||
$entity->getErrorMessage()
|
||||
),
|
||||
[
|
||||
'payload' => $entity->getMalformedJson(),
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (iFace::TYPE_MOVIE === $type) {
|
||||
$handleRequest($type, $entity);
|
||||
} else {
|
||||
$url = $this->url->withPath(sprintf('/library/metadata/%d', ag($entity, 'ratingKey')));
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('%s: get %s \'%s\' metadata.', $this->name, $type, ag($entity, 'title')),
|
||||
[
|
||||
'url' => $url
|
||||
]
|
||||
);
|
||||
|
||||
$requests[] = $this->http->request(
|
||||
'GET',
|
||||
(string)$url,
|
||||
array_replace_recursive($this->getHeaders(), [
|
||||
'user_data' => [
|
||||
'id' => ag($entity, 'ratingKey'),
|
||||
'title' => ag($entity, 'title'),
|
||||
'type' => $type,
|
||||
]
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requests as $response) {
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
@@ -567,6 +823,7 @@ class PlexServer implements ServerInterface
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_decode(
|
||||
@@ -575,224 +832,10 @@ class PlexServer implements ServerInterface
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
$type = null;
|
||||
$found = false;
|
||||
|
||||
foreach (ag($json, 'MediaContainer.Directory', []) as $section) {
|
||||
if ((int)ag($section, 'key') !== (int)$id) {
|
||||
continue;
|
||||
}
|
||||
$found = true;
|
||||
$type = ag($section, 'type', 'unknown');
|
||||
break;
|
||||
}
|
||||
|
||||
if (false === $found) {
|
||||
throw new RuntimeException(sprintf('%s: library id \'%s\' not found.', $this->name, $id));
|
||||
}
|
||||
|
||||
if ('movie' !== $type && 'show' !== $type) {
|
||||
throw new RuntimeException(sprintf('%s: Library id \'%s\' is of unsupported type.', $this->name, $id));
|
||||
}
|
||||
|
||||
$query = [
|
||||
'sort' => 'addedAt:asc',
|
||||
'includeGuids' => 1,
|
||||
];
|
||||
|
||||
if (iFace::TYPE_MOVIE === $type) {
|
||||
$query['type'] = 1;
|
||||
}
|
||||
|
||||
$url = $this->url->withPath(sprintf('/library/sections/%d/all', $id))->withQuery(http_build_query($query));
|
||||
|
||||
$this->logger->debug(sprintf('%s: Sending get library content for id \'%s\'.', $this->name, $id), [
|
||||
'url' => $url
|
||||
]);
|
||||
|
||||
$response = $this->http->request('GET', (string)$url, $this->getHeaders());
|
||||
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
throw new RuntimeException(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$handleRequest = function (string $type, array $item) use (&$list, $opts) {
|
||||
$this->logger->debug(sprintf('%s: Processing %s \'%s\'.', $this->name, $type, ag($item, 'title')));
|
||||
|
||||
$url = $this->url->withPath(sprintf('/library/metadata/%d', ag($item, 'ratingKey')));
|
||||
|
||||
$possibleTitles = $paths = $locations = $guids = $matches = [];
|
||||
$possibleTitlesList = ['title', 'originalTitle', 'titleSort'];
|
||||
foreach ($possibleTitlesList as $title) {
|
||||
if (null === ($title = ag($item, $title))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$title = formatName($title);
|
||||
|
||||
if (true === in_array($title, $possibleTitles)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$possibleTitles[] = formatName($title);
|
||||
}
|
||||
|
||||
foreach (ag($item, 'Location', []) as $path) {
|
||||
$paths[] = formatName(basename(ag($path, 'path')));
|
||||
$locations[] = ag($path, 'path');
|
||||
}
|
||||
|
||||
foreach (ag($item, 'Media', []) as $leaf) {
|
||||
foreach (ag($leaf, 'Part', []) as $path) {
|
||||
$paths[] = formatName(basename(dirname(ag($path, 'file'))));
|
||||
$locations[] = dirname(ag($path, 'file'));
|
||||
}
|
||||
}
|
||||
|
||||
$guids[] = ag($item, 'guid', []);
|
||||
|
||||
foreach (ag($item, 'Guid', []) as $guid) {
|
||||
$guids[] = ag($guid, 'id');
|
||||
}
|
||||
|
||||
foreach ($paths ?? [] as $location) {
|
||||
foreach ($possibleTitles as $title) {
|
||||
if (true === str_contains($location, $title)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$locationIsAscii = mb_detect_encoding($location, 'ASCII', true);
|
||||
$titleIsAscii = mb_detect_encoding($location, 'ASCII', true);
|
||||
|
||||
if (true === $locationIsAscii && $titleIsAscii) {
|
||||
similar_text($location, $title, $percent);
|
||||
} else {
|
||||
mb_similar_text($location, $title, $percent);
|
||||
}
|
||||
|
||||
if ($percent >= $opts['coef']) {
|
||||
return;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'path' => $location,
|
||||
'title' => $title,
|
||||
'similarity' => $percent,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$metadata = [
|
||||
'id' => (int)ag($item, 'ratingKey'),
|
||||
'type' => ucfirst($type),
|
||||
'url' => [(string)$url],
|
||||
'title' => ag($item, $possibleTitlesList, '??'),
|
||||
'year' => ag($item, 'year', 0000),
|
||||
'guids' => $guids,
|
||||
'path' => $locations,
|
||||
'matching' => $matches,
|
||||
'comments' => (empty($paths)) ? 'No path found.' : 'Title does not match path.',
|
||||
];
|
||||
|
||||
if (empty($guids)) {
|
||||
$metadata['guids'] = 'No external ids found.';
|
||||
}
|
||||
|
||||
if (count($locations) <= 1) {
|
||||
$metadata['path'] = $locations[0];
|
||||
}
|
||||
|
||||
$list[] = $metadata;
|
||||
};
|
||||
|
||||
$it = Items::fromIterable(
|
||||
httpClientChunks($this->http->stream($response)),
|
||||
[
|
||||
'pointer' => '/MediaContainer/Metadata',
|
||||
'decoder' => new ErrorWrappingDecoder(
|
||||
new ExtJsonDecoder(assoc: true, options: JSON_INVALID_UTF8_IGNORE)
|
||||
)
|
||||
]
|
||||
$handleRequest(
|
||||
$response->getInfo('user_data')['type'],
|
||||
ag($json, 'MediaContainer.Metadata.0', [])
|
||||
);
|
||||
|
||||
$requests = [];
|
||||
|
||||
foreach ($it as $entity) {
|
||||
if ($entity instanceof DecodingError) {
|
||||
$this->logger->warning(
|
||||
sprintf(
|
||||
'%s: Failed to decode one of library id \'%s\' items. %s',
|
||||
$this->name,
|
||||
$id,
|
||||
$entity->getErrorMessage()
|
||||
),
|
||||
[
|
||||
'payload' => $entity->getMalformedJson(),
|
||||
]
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (iFace::TYPE_MOVIE === $type) {
|
||||
$handleRequest($type, $entity);
|
||||
} else {
|
||||
$url = $this->url->withPath(sprintf('/library/metadata/%d', ag($entity, 'ratingKey')));
|
||||
|
||||
$this->logger->debug(
|
||||
sprintf('%s: get %s \'%s\' metadata.', $this->name, $type, ag($entity, 'title')),
|
||||
[
|
||||
'url' => $url
|
||||
]
|
||||
);
|
||||
|
||||
$requests[] = $this->http->request(
|
||||
'GET',
|
||||
(string)$url,
|
||||
array_replace_recursive($this->getHeaders(), [
|
||||
'user_data' => [
|
||||
'id' => ag($entity, 'ratingKey'),
|
||||
'title' => ag($entity, 'title'),
|
||||
'type' => $type,
|
||||
]
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($requests as $response) {
|
||||
if (200 !== $response->getStatusCode()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'%s: Get metadata request for id \'%s\' responded with unexpected http status code \'%d\'.',
|
||||
$this->name,
|
||||
$id,
|
||||
$response->getStatusCode()
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$json = json_decode(
|
||||
json: $response->getContent(),
|
||||
associative: true,
|
||||
flags: JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_IGNORE
|
||||
);
|
||||
|
||||
$handleRequest(
|
||||
$response->getInfo('user_data')['type'],
|
||||
ag($json, 'MediaContainer.Metadata.0', [])
|
||||
);
|
||||
}
|
||||
} catch (ExceptionInterface|JsonException|\JsonMachine\Exception\InvalidArgumentException $e) {
|
||||
$this->logger->error($this->name . ': ' . $e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
|
||||
return $list;
|
||||
|
||||
@@ -701,3 +701,95 @@ if (false === function_exists('mb_similar_text')) {
|
||||
return $similarity;
|
||||
}
|
||||
}
|
||||
|
||||
if (false === function_exists('mb_levenshtein')) {
|
||||
function mb_levenshtein(string $str1, string $str2)
|
||||
{
|
||||
$length1 = mb_strlen($str1, 'UTF-8');
|
||||
$length2 = mb_strlen($str2, 'UTF-8');
|
||||
|
||||
if ($length1 < $length2) {
|
||||
return mb_levenshtein($str2, $str1);
|
||||
}
|
||||
|
||||
if (0 === $length1) {
|
||||
return $length2;
|
||||
}
|
||||
|
||||
if ($str1 === $str2) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$prevRow = range(0, $length2);
|
||||
|
||||
for ($i = 0; $i < $length1; $i++) {
|
||||
$currentRow = [];
|
||||
$currentRow[0] = $i + 1;
|
||||
$c1 = mb_substr($str1, $i, 1, 'UTF-8');
|
||||
|
||||
for ($j = 0; $j < $length2; $j++) {
|
||||
$c2 = mb_substr($str2, $j, 1, 'UTF-8');
|
||||
$insertions = $prevRow[$j + 1] + 1;
|
||||
$deletions = $currentRow[$j] + 1;
|
||||
$substitutions = $prevRow[$j] + (($c1 !== $c2) ? 1 : 0);
|
||||
$currentRow[] = min($insertions, $deletions, $substitutions);
|
||||
}
|
||||
|
||||
$prevRow = $currentRow;
|
||||
}
|
||||
return $prevRow[$length2];
|
||||
}
|
||||
}
|
||||
|
||||
if (false === function_exists('compareStrings')) {
|
||||
function compareStrings(string $str1, string $str2): float
|
||||
{
|
||||
if (empty($str1) || empty($str2)) {
|
||||
return 0.000;
|
||||
}
|
||||
|
||||
$length1 = mb_strlen($str1, 'UTF-8');
|
||||
$length2 = mb_strlen($str2, 'UTF-8');
|
||||
|
||||
if ($length1 < $length2) {
|
||||
return compareStrings($str2, $str1);
|
||||
}
|
||||
|
||||
$ar1 = preg_split('/[^\w\-]+/u', strtolower($str1), flags: PREG_SPLIT_NO_EMPTY);
|
||||
$ar2 = preg_split('/[^\w\-]+/u', strtolower($str2), flags: PREG_SPLIT_NO_EMPTY);
|
||||
|
||||
$ar2Copy = array_values($ar2);
|
||||
|
||||
$matchedIndices = [];
|
||||
$wordMap = [];
|
||||
|
||||
foreach ($ar1 as $k => $w1) {
|
||||
if (isset($wordMap[$w1])) {
|
||||
if ($wordMap[$w1][0] >= $k) {
|
||||
$matchedIndices[$k] = $wordMap[$w1][0];
|
||||
}
|
||||
array_splice($wordMap[$w1], 0, 1);
|
||||
} else {
|
||||
$indices = array_keys($ar2Copy, $w1);
|
||||
$indexCount = count($indices);
|
||||
if ($indexCount) {
|
||||
$matchedIndices[$k] = $indices[0];
|
||||
|
||||
if (1 === $indexCount) {
|
||||
// remove the word at given index from second array so that it won't repeat
|
||||
unset($ar2Copy[$indices[0]]);
|
||||
} else {
|
||||
// remove the word at given indices from second array so that it won't repeat
|
||||
foreach ($indices as $index) {
|
||||
unset($ar2Copy[$index]);
|
||||
}
|
||||
array_splice($indices, 0, 1);
|
||||
$wordMap[$w1] = $indices;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return round(count($matchedIndices) * 100 / count($ar1), 3);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user