From e11318eb2c0a4a96f016f260a158eca054e31466 Mon Sep 17 00:00:00 2001 From: DariusIII Date: Sun, 12 Jul 2026 10:20:19 +0200 Subject: [PATCH] Update XML_Response for API --- app/Http/Controllers/Api/XML_Response.php | 380 ++++-------------- .../Api/V1/ReleaseItemPayloadBuilder.php | 222 ++++++++++ app/Services/Api/V1/ReleaseRows.php | 77 ++++ app/Services/Api/V1/XmlResponseContext.php | 43 ++ tests/Unit/Api/XMLResponseTest.php | 255 +++++++++++- 5 files changed, 659 insertions(+), 318 deletions(-) create mode 100644 app/Services/Api/V1/ReleaseItemPayloadBuilder.php create mode 100644 app/Services/Api/V1/ReleaseRows.php create mode 100644 app/Services/Api/V1/XmlResponseContext.php diff --git a/app/Http/Controllers/Api/XML_Response.php b/app/Http/Controllers/Api/XML_Response.php index d8af37e1d..19b80b298 100644 --- a/app/Http/Controllers/Api/XML_Response.php +++ b/app/Http/Controllers/Api/XML_Response.php @@ -26,8 +26,9 @@ declare(strict_types=1); namespace App\Http\Controllers\Api; use App\Models\Category; -use Illuminate\Support\Carbon; -use Illuminate\Support\Collection; +use App\Services\Api\V1\ReleaseItemPayloadBuilder; +use App\Services\Api\V1\ReleaseRows; +use App\Services\Api\V1\XmlResponseContext; /** * Class XMLReturn. @@ -42,12 +43,16 @@ class XML_Response /** * The RSS namespace used for the output. */ - protected string $namespace; + protected string $namespace = 'newznab'; + + protected ReleaseRows $releaseRows; /** * The trailing URL parameters on the request. + * + * @var array */ - protected mixed $parameters; + protected array $parameters; /** * The release we are adding to the stream. @@ -61,8 +66,10 @@ class XML_Response /** * The various server variables and active categories. + * + * @var array */ - protected mixed $server; + protected array $server; /** * The XML formatting operation we are returning. @@ -83,20 +90,13 @@ class XML_Response */ public function __construct(array $options = []) { - $defaults = [ - 'Parameters' => null, - 'Data' => null, - 'Server' => null, - 'Offset' => null, - 'Type' => null, - ]; - $options += $defaults; - - $this->parameters = $options['Parameters']; - $this->releases = $options['Data']; - $this->server = $options['Server']; - $this->offset = $options['Offset']; - $this->type = $options['Type']; + $context = XmlResponseContext::fromLegacyOptions($options); + $this->parameters = $context->parameters; + $this->releases = $context->data; + $this->server = $context->server; + $this->offset = $context->offset; + $this->type = $context->type; + $this->releaseRows = new ReleaseRows($this->releases); $this->xml = new \XMLWriter; $this->xml->openMemory(); @@ -221,9 +221,7 @@ class XML_Response $response['item'] = []; foreach ($this->releaseRows() as $release) { - $this->release = $release; - $item = $this->buildReleaseArray(); - $response['item'][] = $item; + $response['item'][] = $this->releasePayload($release); } return $response; @@ -234,46 +232,12 @@ class XML_Response */ protected function releaseRows(): array { - if ($this->releases === null || $this->releases === false || $this->releases === []) { - return []; - } - - if ($this->releases instanceof Collection) { - return $this->releases->values()->all(); - } - - if (\is_array($this->releases)) { - return array_values($this->releases); - } - - if ($this->releases instanceof \Traversable) { - return array_values(iterator_to_array($this->releases)); - } - - if (\is_object($this->releases)) { - return [$this->releases]; - } - - return []; + return $this->releaseRows->rows(); } protected function totalRows(): int { - $releases = $this->releaseRows(); - if ($releases === []) { - return 0; - } - - $firstRelease = $releases[0]; - if (\is_object($firstRelease) && isset($firstRelease->_totalrows)) { - return (int) $firstRelease->_totalrows; - } - - if (\is_array($firstRelease) && isset($firstRelease['_totalrows'])) { - return (int) $firstRelease['_totalrows']; - } - - return \count($releases); + return $this->releaseRows->totalRows(); } /** @@ -283,114 +247,25 @@ class XML_Response */ protected function buildReleaseArray(): array { - $serverUrl = $this->server['server']['url']; - $delParam = ((int) $this->parameters['del'] === 1 ? '&del=1' : ''); - - $item = [ - 'title' => $this->release->searchname, - 'guid' => $serverUrl.'/details/'.$this->release->guid, - 'link' => $serverUrl.'/getnzb?id='.$this->release->guid.'.nzb&r='.$this->parameters['token'].$delParam, - 'comments' => $serverUrl.'/details/'.$this->release->guid.'#comments', - 'pubDate' => date(DATE_RSS, strtotime((string) $this->release->adddate)), - 'category' => $this->release->category_name, - 'description' => $this->release->searchname, - ]; - - if (! isset($this->parameters['dl']) || (int) $this->parameters['dl'] === 1) { - $item['enclosure'] = [ - 'url' => $serverUrl.'/getnzb?id='.$this->release->guid.'.nzb&r='.$this->parameters['token'].$delParam, - 'length' => $this->release->size, - 'type' => 'application/x-nzb', - ]; - } - - // Attributes - $attrs = [ - 'category' => $this->release->categories_id, - 'size' => $this->release->size, - ]; - - if (! empty($this->release->coverurl)) { - $attrs['coverurl'] = $serverUrl.'/covers/'.$this->release->coverurl; - } - - if ((int) $this->parameters['extended'] === 1) { - $attrs['files'] = $this->release->totalpart; - - if ((isset($this->release->videos_id) && $this->release->videos_id > 0) || (isset($this->release->tv_episodes_id) && $this->release->tv_episodes_id > 0)) { - $attrs = array_merge($attrs, $this->buildTvAttrArray()); - } - - if (isset($this->release->imdbid) && imdb_id_is_valid($this->release->imdbid)) { - $attrs['imdb'] = $this->release->imdbid; - } - if (isset($this->release->anidbid) && $this->release->anidbid > 0) { - $attrs['anidbid'] = $this->release->anidbid; - } - if (isset($this->release->predb_id) && $this->release->predb_id > 0) { - $attrs['prematch'] = '1'; - } - if (isset($this->release->nfostatus) && (int) $this->release->nfostatus === 1) { - $attrs['info'] = $serverUrl.'api?t=info&id='.$this->release->guid.'&r='.$this->parameters['token']; - } - - $attrs['grabs'] = $this->release->grabs; - $attrs['comments'] = $this->release->comments; - $attrs['password'] = $this->release->passwordstatus; - $attrs['usenetdate'] = Carbon::parse($this->release->postdate)->toRssString(); - if (! empty($this->release->group_name)) { - $attrs['group'] = $this->release->group_name; - } - } - - $item['attr'] = $attrs; - - return $item; + return $this->releasePayload($this->release); } /** - * Build TV attributes as array (scalar-safe). - * - * @return array + * @return array{ + * title: mixed, + * guid: string, + * link: string, + * comments: string, + * pubDate: string, + * category: mixed, + * description: mixed, + * enclosure?: array{url: string, length: mixed, type: string}, + * attr: array + * } */ - protected function buildTvAttrArray(): array + protected function releasePayload(mixed $release): array { - $attrs = []; - - if (! empty($this->release->title)) { - $attrs['title'] = $this->release->title; - } - if (isset($this->release->series) && $this->release->series > 0) { - $attrs['season'] = $this->release->series; - } - $episodeNum = $this->getScalarOrRelationValue('episode', 'episode'); - if (! empty($episodeNum) && $episodeNum > 0) { - $attrs['episode'] = $episodeNum; - } - if (! empty($this->release->firstaired)) { - $attrs['tvairdate'] = $this->release->firstaired; - } - if (isset($this->release->tvdb) && $this->release->tvdb > 0) { - $attrs['tvdbid'] = $this->release->tvdb; - } - if (isset($this->release->trakt) && $this->release->trakt > 0) { - $attrs['traktid'] = $this->release->trakt; - } - if (isset($this->release->tvrage) && $this->release->tvrage > 0) { - $attrs['tvrageid'] = $this->release->tvrage; - $attrs['rageid'] = $this->release->tvrage; - } - if (isset($this->release->tvmaze) && $this->release->tvmaze > 0) { - $attrs['tvmazeid'] = $this->release->tvmaze; - } - if (isset($this->release->imdb) && imdb_id_is_valid($this->release->imdb)) { - $attrs['imdbid'] = $this->release->imdb; - } - if (isset($this->release->tmdb) && $this->release->tmdb > 0) { - $attrs['tmdbid'] = $this->release->tmdb; - } - - return $attrs; + return (new ReleaseItemPayloadBuilder($this->parameters, $this->server, $this->namespace))->build($release); } /** @@ -526,14 +401,14 @@ class XML_Response protected function writeCategoryListing(): void { $this->xml->startElement('categories'); - foreach ($this->server['categories'] as $this->parameters) { + foreach (($this->server['categories'] ?? []) as $category) { $this->xml->startElement('category'); - $this->writeXmlAttribute('id', $this->parameters['id']); - $this->writeXmlAttribute('name', html_entity_decode((string) $this->parameters['title'])); - if (! empty($this->parameters['description'])) { - $this->writeXmlAttribute('description', html_entity_decode((string) $this->parameters['description'])); + $this->writeXmlAttribute('id', $category['id']); + $this->writeXmlAttribute('name', html_entity_decode((string) $category['title'])); + if (! empty($category['description'])) { + $this->writeXmlAttribute('description', html_entity_decode((string) $category['description'])); } - foreach ($this->parameters['categories'] as $c) { + foreach (($category['categories'] ?? []) as $c) { $this->xml->startElement('subcat'); $this->writeXmlAttribute('id', $c['id']); $this->writeXmlAttribute('name', html_entity_decode((string) $c['title'])); @@ -652,7 +527,7 @@ class XML_Response $this->xml->endElement(); } - public function includeTotalRows(): void + protected function includeTotalRows(): void { $this->xml->startElement($this->namespace.':response'); $this->writeXmlAttribute('offset', $this->offset); @@ -660,7 +535,7 @@ class XML_Response $this->xml->endElement(); } - public function includeLimits(): void + protected function includeLimits(): void { $this->xml->startElement($this->namespace.':apilimits'); $this->writeXmlAttribute('apicurrent', $this->parameters['requests']); @@ -679,170 +554,65 @@ class XML_Response /** * Loop through the releases and add their info to the XML stream. */ - public function includeReleases(): void + protected function includeReleases(): void { foreach ($this->releaseRows() as $this->release) { + $payload = $this->releasePayload($this->release); $this->xml->startElement('item'); - $this->includeReleaseMain(); - $this->setZedAttributes(); + $this->includeReleaseMain($payload); + $this->setZedAttributes($payload['attr']); $this->xml->endElement(); } } /** - * Writes the primary release information. + * @param array{ + * title: mixed, + * guid: string, + * link: string, + * comments: string, + * pubDate: string, + * category: mixed, + * description: mixed, + * enclosure?: array{url: string, length: mixed, type: string}, + * attr: array + * } $payload */ - public function includeReleaseMain(): void + protected function includeReleaseMain(array $payload): void { - $this->writeXmlElement('title', $this->release->searchname); + $this->writeXmlElement('title', $payload['title']); $this->xml->startElement('guid'); $this->writeXmlAttribute('isPermaLink', 'true'); - $this->writeXmlText("{$this->server['server']['url']}/details/{$this->release->guid}"); + $this->writeXmlText($payload['guid']); $this->xml->endElement(); - $this->writeXmlElement( - 'link', - "{$this->server['server']['url']}/getnzb?id={$this->release->guid}.nzb". - "&r={$this->parameters['token']}". - ((int) $this->parameters['del'] === 1 ? '&del=1' : '') - ); - $this->writeXmlElement('comments', "{$this->server['server']['url']}/details/{$this->release->guid}#comments"); - $this->writeXmlElement('pubDate', date(DATE_RSS, strtotime((string) $this->release->adddate))); - $this->writeXmlElement('category', $this->release->category_name); + $this->writeXmlElement('link', $payload['link']); + $this->writeXmlElement('comments', $payload['comments']); + $this->writeXmlElement('pubDate', $payload['pubDate']); + $this->writeXmlElement('category', $payload['category']); if ($this->namespace === 'newznab') { - $this->writeXmlElement('description', $this->release->searchname); + $this->writeXmlElement('description', $payload['description']); } else { $this->writeRssCdata(); } - if (! isset($this->parameters['dl']) || (isset($this->parameters['dl']) && (int) $this->parameters['dl'] === 1)) { + if (isset($payload['enclosure'])) { $this->xml->startElement('enclosure'); - $this->writeXmlAttribute( - 'url', - "{$this->server['server']['url']}/getnzb?id={$this->release->guid}.nzb". - "&r={$this->parameters['token']}". - ((int) $this->parameters['del'] === 1 ? '&del=1' : '') - ); - $this->writeXmlAttribute('length', $this->release->size); - $this->writeXmlAttribute('type', 'application/x-nzb'); + $this->writeXmlAttribute('url', $payload['enclosure']['url']); + $this->writeXmlAttribute('length', $payload['enclosure']['length']); + $this->writeXmlAttribute('type', $payload['enclosure']['type']); $this->xml->endElement(); } } /** * Writes the Zed (newznab) specific attributes. - */ - protected function setZedAttributes(): void - { - $this->writeZedAttr('category', $this->release->categories_id); - $this->writeZedAttr('size', $this->release->size); - if (! empty($this->release->coverurl)) { - $this->writeZedAttr( - 'coverurl', - $this->server['server']['url']."/covers/{$this->release->coverurl}" - ); - } - - if ((int) $this->parameters['extended'] === 1) { - $this->writeZedAttr('files', $this->release->totalpart); - if (((isset($this->release->videos_id) && $this->release->videos_id > 0) || (isset($this->release->tv_episodes_id) && $this->release->tv_episodes_id > 0)) && $this->namespace === 'newznab') { - $this->setTvAttr(); - } - - if (isset($this->release->imdbid) && imdb_id_is_valid($this->release->imdbid)) { - $this->writeZedAttr('imdb', $this->release->imdbid); - } - if (isset($this->release->anidbid) && $this->release->anidbid > 0) { - $this->writeZedAttr('anidbid', $this->release->anidbid); - } - if (isset($this->release->predb_id) && $this->release->predb_id > 0) { - $this->writeZedAttr('prematch', '1'); - } - if (isset($this->release->nfostatus) && (int) $this->release->nfostatus === 1) { - $this->writeZedAttr( - 'info', - $this->server['server']['url']. - "api?t=info&id={$this->release->guid}&r={$this->parameters['token']}" - ); - } - - $this->writeZedAttr('grabs', $this->release->grabs); - $this->writeZedAttr('comments', $this->release->comments); - $this->writeZedAttr('password', $this->release->passwordstatus); - $this->writeZedAttr('usenetdate', Carbon::parse($this->release->postdate)->toRssString()); - if (! empty($this->release->group_name)) { - $this->writeZedAttr('group', $this->release->group_name); - } - } - } - - /** - * Writes the TV Specific attributes. - * Uses scalar-safe access to avoid N+1 lazy loading when release data - * comes from raw SQL queries (stdClass with flat columns) vs Eloquent models. - */ - protected function setTvAttr(): void - { - if (! empty($this->release->title)) { - $this->writeZedAttr('title', $this->release->title); - } - if (isset($this->release->series) && $this->release->series > 0) { - $this->writeZedAttr('season', $this->release->series); - } - // episode can be a scalar (from raw SQL JOIN) or an Eloquent relation object - $episodeNum = $this->getScalarOrRelationValue('episode', 'episode'); - if (! empty($episodeNum) && $episodeNum > 0) { - $this->writeZedAttr('episode', $episodeNum); - } - if (! empty($this->release->firstaired)) { - $this->writeZedAttr('tvairdate', $this->release->firstaired); - } - if (isset($this->release->tvdb) && $this->release->tvdb > 0) { - $this->writeZedAttr('tvdbid', $this->release->tvdb); - } - if (isset($this->release->trakt) && $this->release->trakt > 0) { - $this->writeZedAttr('traktid', $this->release->trakt); - } - if (isset($this->release->tvrage) && $this->release->tvrage > 0) { - $this->writeZedAttr('tvrageid', $this->release->tvrage); - $this->writeZedAttr('rageid', $this->release->tvrage); - } - if (isset($this->release->tvmaze) && $this->release->tvmaze > 0) { - $this->writeZedAttr('tvmazeid', $this->release->tvmaze); - } - if (isset($this->release->imdb) && imdb_id_is_valid($this->release->imdb)) { - $this->writeZedAttr('imdbid', $this->release->imdb); - } - if (isset($this->release->tmdb) && $this->release->tmdb > 0) { - $this->writeZedAttr('tmdbid', $this->release->tmdb); - } - } - - /** - * Safely get a value that may be a scalar (from raw SQL) or a property on a related object. - * Prevents N+1 lazy loading when accessing Eloquent relation properties in a loop. * - * @param string $property The property name on the release (may be scalar or object) - * @param string $subProperty The sub-property to access if $property is an object - * @return mixed The scalar value, or null if not available + * @param array $attributes */ - protected function getScalarOrRelationValue(string $property, string $subProperty): mixed + protected function setZedAttributes(array $attributes): void { - $value = $this->release->$property ?? null; - - if ($value === null) { - return null; + foreach ($attributes as $name => $value) { + $this->writeZedAttr((string) $name, $value); } - - // If it's a scalar (from raw SQL JOIN), return directly - if (is_scalar($value)) { - return $value; - } - - // If it's an object (Eloquent relation), access the sub-property - if (is_object($value)) { - return $value->$subProperty ?? null; - } - - return null; } /** diff --git a/app/Services/Api/V1/ReleaseItemPayloadBuilder.php b/app/Services/Api/V1/ReleaseItemPayloadBuilder.php new file mode 100644 index 000000000..a70ddca04 --- /dev/null +++ b/app/Services/Api/V1/ReleaseItemPayloadBuilder.php @@ -0,0 +1,222 @@ + $parameters + * @param array $server + */ + public function __construct( + private array $parameters, + private array $server, + private string $namespace = 'newznab', + ) {} + + /** + * @return array{ + * title: mixed, + * guid: string, + * link: string, + * comments: string, + * pubDate: string, + * category: mixed, + * description: mixed, + * enclosure?: array{url: string, length: mixed, type: string}, + * attr: array + * } + */ + public function build(mixed $release): array + { + $serverUrl = $this->serverUrl(); + $guid = (string) $this->value($release, 'guid'); + $searchName = $this->value($release, 'searchname'); + $downloadUrl = $serverUrl.'/getnzb?id='.$guid.'.nzb&r='.$this->parameter('token').$this->delParam(); + + $payload = [ + 'title' => $searchName, + 'guid' => $serverUrl.'/details/'.$guid, + 'link' => $downloadUrl, + 'comments' => $serverUrl.'/details/'.$guid.'#comments', + 'pubDate' => date(DATE_RSS, strtotime((string) $this->value($release, 'adddate'))), + 'category' => $this->value($release, 'category_name'), + 'description' => $searchName, + ]; + + if (! isset($this->parameters['dl']) || (int) $this->parameters['dl'] === 1) { + $payload['enclosure'] = [ + 'url' => $downloadUrl, + 'length' => $this->value($release, 'size'), + 'type' => 'application/x-nzb', + ]; + } + + $payload['attr'] = $this->attributes($release, $serverUrl); + + return $payload; + } + + /** + * @return array + */ + private function attributes(mixed $release, string $serverUrl): array + { + $attributes = [ + 'category' => $this->value($release, 'categories_id'), + 'size' => $this->value($release, 'size'), + ]; + + $coverUrl = $this->value($release, 'coverurl'); + if (! empty($coverUrl)) { + $attributes['coverurl'] = $serverUrl.'/covers/'.$coverUrl; + } + + if ((int) $this->parameter('extended') !== 1) { + return $attributes; + } + + $attributes['files'] = $this->value($release, 'totalpart'); + + if ($this->namespace === 'newznab' && $this->hasVideoInfo($release)) { + $attributes = array_merge($attributes, $this->tvAttributes($release)); + } + + $imdbId = $this->value($release, 'imdbid'); + if ($imdbId !== null && imdb_id_is_valid($imdbId)) { + $attributes['imdb'] = $imdbId; + } + + $anidbId = $this->value($release, 'anidbid'); + if ($anidbId !== null && $anidbId > 0) { + $attributes['anidbid'] = $anidbId; + } + + $predbId = $this->value($release, 'predb_id'); + if ($predbId !== null && $predbId > 0) { + $attributes['prematch'] = '1'; + } + + if ((int) ($this->value($release, 'nfostatus') ?? 0) === 1) { + $attributes['info'] = $serverUrl.'api?t=info&id='.$this->value($release, 'guid').'&r='.$this->parameter('token'); + } + + $attributes['grabs'] = $this->value($release, 'grabs'); + $attributes['comments'] = $this->value($release, 'comments'); + $attributes['password'] = $this->value($release, 'passwordstatus'); + $attributes['usenetdate'] = Carbon::parse($this->value($release, 'postdate'))->toRssString(); + + $groupName = $this->value($release, 'group_name'); + if (! empty($groupName)) { + $attributes['group'] = $groupName; + } + + return $attributes; + } + + /** + * @return array + */ + private function tvAttributes(mixed $release): array + { + $attributes = []; + + $title = $this->value($release, 'title'); + if (! empty($title)) { + $attributes['title'] = $title; + } + + $series = $this->value($release, 'series'); + if ($series !== null && $series > 0) { + $attributes['season'] = $series; + } + + $episodeNum = $this->scalarOrRelationValue($release, 'episode', 'episode'); + if (! empty($episodeNum) && $episodeNum > 0) { + $attributes['episode'] = $episodeNum; + } + + $firstAired = $this->value($release, 'firstaired'); + if (! empty($firstAired)) { + $attributes['tvairdate'] = $firstAired; + } + + foreach ([ + 'tvdb' => 'tvdbid', + 'trakt' => 'traktid', + 'tvmaze' => 'tvmazeid', + 'tmdb' => 'tmdbid', + ] as $source => $target) { + $value = $this->value($release, $source); + if ($value !== null && $value > 0) { + $attributes[$target] = $value; + } + } + + $tvrage = $this->value($release, 'tvrage'); + if ($tvrage !== null && $tvrage > 0) { + $attributes['tvrageid'] = $tvrage; + $attributes['rageid'] = $tvrage; + } + + $imdb = $this->value($release, 'imdb'); + if ($imdb !== null && imdb_id_is_valid($imdb)) { + $attributes['imdbid'] = $imdb; + } + + return $attributes; + } + + private function hasVideoInfo(mixed $release): bool + { + return ($this->value($release, 'videos_id') !== null && $this->value($release, 'videos_id') > 0) + || ($this->value($release, 'tv_episodes_id') !== null && $this->value($release, 'tv_episodes_id') > 0); + } + + private function scalarOrRelationValue(mixed $release, string $property, string $subProperty): mixed + { + $value = $this->value($release, $property); + + if ($value === null) { + return null; + } + + if (\is_scalar($value)) { + return $value; + } + + return \is_object($value) ? ($value->{$subProperty} ?? null) : null; + } + + private function value(mixed $release, string $key): mixed + { + if (\is_array($release) && \array_key_exists($key, $release)) { + return $release[$key]; + } + + if (\is_object($release) && isset($release->{$key})) { + return $release->{$key}; + } + + return null; + } + + private function parameter(string $key): mixed + { + return $this->parameters[$key] ?? ''; + } + + private function serverUrl(): string + { + return (string) ($this->server['server']['url'] ?? ''); + } + + private function delParam(): string + { + return (int) $this->parameter('del') === 1 ? '&del=1' : ''; + } +} diff --git a/app/Services/Api/V1/ReleaseRows.php b/app/Services/Api/V1/ReleaseRows.php new file mode 100644 index 000000000..cb571c194 --- /dev/null +++ b/app/Services/Api/V1/ReleaseRows.php @@ -0,0 +1,77 @@ +|null + */ + private ?array $rows = null; + + public function __construct(private readonly mixed $releases) {} + + /** + * @return list + */ + public function rows(): array + { + if ($this->rows !== null) { + return $this->rows; + } + + if ($this->releases === null || $this->releases === false || $this->releases === []) { + return $this->rows = []; + } + + if ($this->releases instanceof Collection) { + return $this->rows = $this->releases->values()->all(); + } + + if (\is_array($this->releases)) { + return $this->rows = array_values($this->releases); + } + + if ($this->releases instanceof \Traversable) { + return $this->rows = array_values(iterator_to_array($this->releases)); + } + + if (\is_object($this->releases)) { + return $this->rows = [$this->releases]; + } + + return $this->rows = []; + } + + public function totalRows(): int + { + $rows = $this->rows(); + if ($rows === []) { + return 0; + } + + $totalRows = $this->value($rows[0], '_totalrows'); + if ($totalRows !== null) { + return (int) $totalRows; + } + + return \count($rows); + } + + private function value(mixed $row, string $key): mixed + { + if (\is_array($row) && \array_key_exists($key, $row)) { + return $row[$key]; + } + + if (\is_object($row) && isset($row->{$key})) { + return $row->{$key}; + } + + return null; + } +} diff --git a/app/Services/Api/V1/XmlResponseContext.php b/app/Services/Api/V1/XmlResponseContext.php new file mode 100644 index 000000000..a767a7dc5 --- /dev/null +++ b/app/Services/Api/V1/XmlResponseContext.php @@ -0,0 +1,43 @@ + $parameters + * @param array $server + */ + private function __construct( + public array $parameters, + public mixed $data, + public array $server, + public mixed $offset, + public mixed $type, + ) {} + + /** + * @param array $options + */ + public static function fromLegacyOptions(array $options): self + { + $defaults = [ + 'Parameters' => [], + 'Data' => null, + 'Server' => [], + 'Offset' => null, + 'Type' => null, + ]; + $options += $defaults; + + return new self( + parameters: \is_array($options['Parameters']) ? $options['Parameters'] : [], + data: $options['Data'], + server: \is_array($options['Server']) ? $options['Server'] : [], + offset: $options['Offset'], + type: $options['Type'], + ); + } +} diff --git a/tests/Unit/Api/XMLResponseTest.php b/tests/Unit/Api/XMLResponseTest.php index 47043c29b..eba412a80 100644 --- a/tests/Unit/Api/XMLResponseTest.php +++ b/tests/Unit/Api/XMLResponseTest.php @@ -5,6 +5,8 @@ declare(strict_types=1); namespace Tests\Unit\Api; use App\Http\Controllers\Api\XML_Response; +use App\Models\Category; +use Illuminate\Support\Collection; use PHPUnit\Framework\TestCase; final class XMLResponseTest extends TestCase @@ -55,6 +57,164 @@ final class XMLResponseTest extends TestCase self::assertSame('Single Release', $array['item'][0]['title']); } + public function test_api_xml_and_array_use_same_extended_release_payload(): void + { + $release = $this->release([ + 'totalpart' => 42, + 'videos_id' => 99, + 'title' => 'Episode Title', + 'series' => 3, + 'episode' => (object) ['episode' => 7], + 'firstaired' => '2026-05-20', + 'tvdb' => 111, + 'trakt' => 222, + 'tvrage' => 333, + 'tvmaze' => 444, + 'tmdb' => 555, + 'imdb' => '1234567', + 'imdbid' => '7654321', + 'anidbid' => 123, + 'predb_id' => 456, + 'nfostatus' => 1, + 'grabs' => 5, + 'comments' => 6, + 'passwordstatus' => 0, + 'postdate' => '2026-05-28 08:30:00', + 'group_name' => 'alt.binaries.testing', + ]); + + $array = $this->response([$release], ['extended' => '1'])->returnArray(); + $xml = $this->response([$release], ['extended' => '1'])->returnXML(); + + self::assertIsArray($array); + self::assertSame('Episode Title', $array['item'][0]['attr']['title']); + self::assertSame(42, $array['item'][0]['attr']['files']); + self::assertSame(7, $array['item'][0]['attr']['episode']); + self::assertSame('alt.binaries.testing', $array['item'][0]['attr']['group']); + + self::assertIsString($xml); + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); + self::assertStringContainsString('', $xml); + } + + public function test_api_rows_are_normalized_from_supported_payload_shapes(): void + { + $arrayRow = (array) $this->release([ + 'searchname' => 'Array Release', + '_totalrows' => null, + ]); + $collection = new Collection([$this->release(['searchname' => 'Collection Release', '_totalrows' => null])]); + + $arrayResponse = $this->response([$arrayRow])->returnArray(); + $collectionResponse = $this->response($collection)->returnArray(); + $emptyResponse = $this->response([])->returnArray(); + + self::assertIsArray($arrayResponse); + self::assertSame('Array Release', $arrayResponse['item'][0]['title']); + + self::assertIsArray($collectionResponse); + self::assertSame('Collection Release', $collectionResponse['item'][0]['title']); + + self::assertIsArray($emptyResponse); + self::assertSame(0, $emptyResponse['total']); + self::assertSame([], $emptyResponse['item']); + } + + public function test_api_rows_memoize_traversables_for_total_and_items(): void + { + $generator = (static function (): \Generator { + yield (object) [ + 'searchname' => 'First Generator Release', + 'guid' => 'first-generator-guid', + 'adddate' => '2026-05-29 12:00:00', + 'category_name' => 'Movies > HD', + 'categories_id' => 2040, + 'size' => 111, + '_totalrows' => 7, + ]; + yield (object) [ + 'searchname' => 'Second Generator Release', + 'guid' => 'second-generator-guid', + 'adddate' => '2026-05-29 13:00:00', + 'category_name' => 'Movies > HD', + 'categories_id' => 2040, + 'size' => 222, + '_totalrows' => 7, + ]; + })(); + + $array = $this->response($generator)->returnArray(); + + self::assertIsArray($array); + self::assertSame(7, $array['total']); + self::assertCount(2, $array['item']); + self::assertSame('Second Generator Release', $array['item'][1]['title']); + } + + public function test_caps_xml_and_array_include_categories_groups_and_genres(): void + { + $array = $this->capsResponse()->returnArray(); + $xml = $this->capsResponse()->returnXML(); + + self::assertIsArray($array); + self::assertSame('NNTmux Tests', $array['server']['title']); + self::assertSame('Movies', $array['categories'][0]['title']); + self::assertSame('alt.binaries.testing', $array['groups'][0]['name']); + self::assertSame('Action', $array['genres'][0]['name']); + + self::assertIsString($xml); + self::assertStringContainsString('', $xml); + } + + public function test_registration_xml_and_array_match_legacy_shape(): void + { + $array = $this->registrationResponse()->returnArray(); + $xml = $this->registrationResponse()->returnXML(); + + self::assertSame([ + 'username' => 'tester', + 'password' => 'secret', + 'apikey' => 'test-token', + ], $array); + + self::assertIsString($xml); + self::assertStringContainsString('', $xml); + } + + public function test_rss_xml_removes_invalid_control_characters_from_cdata(): void + { + $release = $this->release([ + 'searchname' => "RSS\x1fRelease", + 'group_name' => 'alt.binaries.testing', + 'fromname' => 'poster', + 'postdate' => '2026-05-28 08:30:00', + 'passwordstatus' => 0, + 'nfostatus' => 0, + 'parentid' => Category::MOVIE_ROOT, + 'imdbid' => '', + 'musicinfo_id' => 0, + 'consoleinfo_id' => 0, + ]); + + $xml = $this->response([$release], ['uid' => 1], 'rss')->returnXML(); + + self::assertIsString($xml); + self::assertStringNotContainsString("\x1f", $xml); + self::assertStringContainsString('RSSRelease', $xml); + + $previous = libxml_use_internal_errors(true); + $parsed = simplexml_load_string($xml); + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + self::assertNotFalse($parsed); + } + /** * @param array $overrides */ @@ -71,10 +231,13 @@ final class XMLResponseTest extends TestCase ], $overrides); } - private function response(mixed $data): XML_Response + /** + * @param array $parameterOverrides + */ + private function response(mixed $data, array $parameterOverrides = [], string $type = 'api'): XML_Response { return new XML_Response([ - 'Parameters' => [ + 'Parameters' => array_merge([ 'extended' => '0', 'del' => '0', 'token' => 'test-token', @@ -84,19 +247,85 @@ final class XMLResponseTest extends TestCase 'downloadlimit' => 100, 'oldestapi' => '', 'oldestgrab' => '', - ], + ], $parameterOverrides), 'Data' => $data, - 'Server' => [ - 'server' => [ - 'title' => 'NNTmux Tests', - 'strapline' => 'Testing', - 'email' => 'noreply@example.test', - 'meta' => 'usenet', - 'url' => 'https://indexer.example.test', - ], - ], + 'Server' => $this->server(), 'Offset' => 0, - 'Type' => 'api', + 'Type' => $type, ]); } + + private function capsResponse(): XML_Response + { + return new XML_Response([ + 'Parameters' => [], + 'Data' => null, + 'Server' => array_merge($this->server(), [ + 'limits' => [ + 'max' => 100, + 'default' => 100, + ], + 'registration' => [ + 'available' => 'yes', + 'open' => 'yes', + ], + 'searching' => [ + 'search' => ['available' => 'yes', 'supportedParams' => 'q'], + ], + 'categories' => [ + [ + 'id' => 2000, + 'title' => 'Movies', + 'description' => 'Movie releases', + 'categories' => [ + ['id' => 2040, 'title' => 'HD', 'description' => 'HD Movies'], + ], + ], + ], + 'groups' => [ + [ + 'name' => 'alt.binaries.testing', + 'description' => 'Testing', + 'lastupdate' => 'Fri, 29 May 2026 12:00:00 +0000', + ], + ], + 'genres' => [ + ['id' => 1, 'name' => 'Action', 'categoryid' => 2000], + ], + ]), + 'Offset' => 0, + 'Type' => 'caps', + ]); + } + + private function registrationResponse(): XML_Response + { + return new XML_Response([ + 'Parameters' => [ + 'username' => 'tester', + 'password' => 'secret', + 'token' => 'test-token', + ], + 'Data' => null, + 'Server' => $this->server(), + 'Offset' => 0, + 'Type' => 'reg', + ]); + } + + /** + * @return array + */ + private function server(): array + { + return [ + 'server' => [ + 'title' => 'NNTmux Tests', + 'strapline' => 'Testing', + 'email' => 'noreply@example.test', + 'meta' => 'usenet', + 'url' => 'https://indexer.example.test', + ], + ]; + } }