From ffb7b2497eb6713dcbd5fccbc34dadf5f1fda706 Mon Sep 17 00:00:00 2001 From: abdulmohsen Date: Tue, 31 May 2022 00:35:45 +0300 Subject: [PATCH] improved mismatch handling. --- src/Commands/Servers/RemoteCommand.php | 26 +- src/Libs/Servers/JellyfinServer.php | 488 ++++++++++++------------ src/Libs/Servers/PlexServer.php | 493 ++++++++++++++----------- src/Libs/helpers.php | 92 +++++ 4 files changed, 640 insertions(+), 459 deletions(-) diff --git a/src/Commands/Servers/RemoteCommand.php b/src/Commands/Servers/RemoteCommand.php index 5a9cecec..b70295af 100644 --- a/src/Commands/Servers/RemoteCommand.php +++ b/src/Commands/Servers/RemoteCommand.php @@ -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 diff --git a/src/Libs/Servers/JellyfinServer.php b/src/Libs/Servers/JellyfinServer.php index 7bc0e507..235eec5a 100644 --- a/src/Libs/Servers/JellyfinServer.php +++ b/src/Libs/Servers/JellyfinServer.php @@ -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; diff --git a/src/Libs/Servers/PlexServer.php b/src/Libs/Servers/PlexServer.php index 0e0072a0..94815323 100644 --- a/src/Libs/Servers/PlexServer.php +++ b/src/Libs/Servers/PlexServer.php @@ -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; diff --git a/src/Libs/helpers.php b/src/Libs/helpers.php index e99f83c5..2f52f507 100644 --- a/src/Libs/helpers.php +++ b/src/Libs/helpers.php @@ -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); + } +}