mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-29 01:08:56 +00:00
Update XML_Response for API
This commit is contained in:
@@ -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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
* @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<string, mixed>
|
||||
* }
|
||||
*/
|
||||
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<string, mixed>
|
||||
* } $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<string, mixed> $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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Api\V1;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final readonly class ReleaseItemPayloadBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
* @param array<string, mixed> $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<string, mixed>
|
||||
* }
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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' : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Api\V1;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class ReleaseRows
|
||||
{
|
||||
/**
|
||||
* @var list<mixed>|null
|
||||
*/
|
||||
private ?array $rows = null;
|
||||
|
||||
public function __construct(private readonly mixed $releases) {}
|
||||
|
||||
/**
|
||||
* @return list<mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Api\V1;
|
||||
|
||||
final readonly class XmlResponseContext
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
* @param array<string, mixed> $server
|
||||
*/
|
||||
private function __construct(
|
||||
public array $parameters,
|
||||
public mixed $data,
|
||||
public array $server,
|
||||
public mixed $offset,
|
||||
public mixed $type,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $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'],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('<newznab:attr name="files" value="42"/>', $xml);
|
||||
self::assertStringContainsString('<newznab:attr name="title" value="Episode Title"/>', $xml);
|
||||
self::assertStringContainsString('<newznab:attr name="episode" value="7"/>', $xml);
|
||||
self::assertStringContainsString('<newznab:attr name="group" value="alt.binaries.testing"/>', $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('<category id="2000" name="Movies"', $xml);
|
||||
self::assertStringContainsString('<subcat id="2040" name="HD"', $xml);
|
||||
self::assertStringContainsString('<group name="alt.binaries.testing"', $xml);
|
||||
self::assertStringContainsString('<genre id="1" name="Action" categoryid="2000"/>', $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('<register username="tester" password="secret" apikey="test-token"/>', $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<string, mixed> $overrides
|
||||
*/
|
||||
@@ -71,10 +231,13 @@ final class XMLResponseTest extends TestCase
|
||||
], $overrides);
|
||||
}
|
||||
|
||||
private function response(mixed $data): XML_Response
|
||||
/**
|
||||
* @param array<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
private function server(): array
|
||||
{
|
||||
return [
|
||||
'server' => [
|
||||
'title' => 'NNTmux Tests',
|
||||
'strapline' => 'Testing',
|
||||
'email' => 'noreply@example.test',
|
||||
'meta' => 'usenet',
|
||||
'url' => 'https://indexer.example.test',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user