Skip to content

Commit 18f3baf

Browse files
committed
feat: add support for plex new nfo agent
1 parent cbf5f82 commit 18f3baf

7 files changed

Lines changed: 271 additions & 14 deletions

File tree

FAQ.md

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -258,21 +258,23 @@ If there are no errors, the database has been repaired successfully. And you can
258258

259259
# Which Providers id `GUIDs` supported by for PlexClient?
260260

261-
* tvdb://(id) `New plex agent`
262-
* imdb://(id) `New plex agent`
263-
* tmdb://(id) `New plex agent`
264-
* com.plexapp.agents.imdb://(id)?lang=en `(Legacy plex agent)`
265-
* com.plexapp.agents.tmdb://(id)?lang=en `(Legacy plex agent)`
266-
* com.plexapp.agents.themoviedb://(id)?lang=en `(Legacy plex agent)`
267-
* com.plexapp.agents.thetvdb://(seriesId)?lang=en `(Legacy plex agent)`
268-
* com.plexapp.agents.xbmcnfo://(id)?lang=en `(XBMC NFO Movies agent)`
269-
* com.plexapp.agents.xbmcnfotv://(id)?lang=en `(XBMC NFO TV agent)`
261+
* tvdb://(id)
262+
* imdb://(id)
263+
* tmdb://(id)
264+
* tv.plex.agents.nfo.movie://movie/(provider)_(id) `Where the provider one of supported providers`
265+
* tv.plex.agents.nfo.series://(show|episode)/(provider)_(id) `Where the provider one of supported providers`
266+
267+
## Legacy Plex Agents
268+
269+
* com.plexapp.agents.imdb://(id)?lang=en
270+
* com.plexapp.agents.tmdb://(id)?lang=en
271+
* com.plexapp.agents.themoviedb://(id)?lang=en
272+
* com.plexapp.agents.thetvdb://(seriesId)?lang=en
273+
* com.plexapp.agents.xbmcnfo://(id)?lang=en `(XBMC NFO Movies agent) -> imdb|tmdb://(movieId)?lang=en`
274+
* com.plexapp.agents.xbmcnfotv://(id)?lang=en `(XBMC NFO TV agent) -> tvdb://(serisId)?lang=en`
270275
* com.plexapp.agents.hama://(db)\d?-(id)?lang=en `(HAMA multi source db agent mainly for anime)`
271-
* com.plexapp.agents.ytinforeader://(id)
272-
?lang=en [ytinforeader.bundle](https://github.com/arabcoders/plex-ytdlp-info-reader-agent)
273-
With [jp_scanner.py](https://github.com/arabcoders/plex-daily-scanner) as scanner.
274-
* com.plexapp.agents.cmdb://(id)
275-
?lang=en [cmdb.bundle](https://github.com/arabcoders/cmdb.bundle) `(User custom metadata database)`.
276+
* com.plexapp.agents.ytinforeader://(id)?lang=en [ytinforeader.bundle](https://github.com/arabcoders/plex-ytdlp-info-reader-agent) With [jp_scanner.py](https://github.com/arabcoders/plex-daily-scanner) as scanner.
277+
* com.plexapp.agents.cmdb://(id)?lang=en [cmdb.bundle](https://github.com/arabcoders/cmdb.bundle) `(User custom metadata database)`.
276278

277279
---
278280

src/Backends/Plex/PlexClient.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ class PlexClient implements iClient
105105
'com.plexapp.agents.cmdb',
106106
'tv.plex.agents.movie',
107107
'tv.plex.agents.series',
108+
'tv.plex.agents.nfo.movie',
109+
'tv.plex.agents.nfo.series',
108110
];
109111

110112
/**

src/Backends/Plex/PlexGuid.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ final class PlexGuid implements iGuid
4545
'com.plexapp.agents.cmdb',
4646
];
4747

48+
/**
49+
* @var array<array-key,string> List of native plex NFO agents.
50+
*/
51+
private array $guidNfo = [
52+
'tv.plex.agents.nfo.movie',
53+
'tv.plex.agents.nfo.series',
54+
];
55+
4856
/**
4957
* @var array<array-key,string> List of local plex agents.
5058
*/
@@ -359,6 +367,10 @@ private function ListExternalIds(array $guids, array $context = [], bool $log =
359367
$val = $this->parseLegacyAgent(guid: $val, context: $context, log: $log);
360368
}
361369

370+
if (true === str_starts_with($val, 'tv.plex.agents.nfo.')) {
371+
$val = $this->parseNfoAgent(guid: $val, context: $context, log: $log);
372+
}
373+
362374
if (false === str_contains($val, '://')) {
363375
if (true === $log) {
364376
$this->logger->info("PlexGuid: Unable to parse '{backend}: {agent}' identifier.", [
@@ -398,6 +410,10 @@ private function ListExternalIds(array $guids, array $context = [], bool $log =
398410

399411
// -- Plex in their infinite wisdom, sometimes report two keys for same data source.
400412
if (null !== ($guid[$this->guidMapper[$key]] ?? null)) {
413+
if ($guid[$this->guidMapper[$key]] === $value) {
414+
continue;
415+
}
416+
401417
if (true === $log) {
402418
$this->logger->warning(
403419
"PlexGuid: '{client}: {backend}' reported multiple ids for same data source '{key}: {ids}' for {item.type} '{item.id}: {item.title}'.",
@@ -514,6 +530,74 @@ private function parseLegacyAgent(string $guid, array $context = [], bool $log =
514530
}
515531
}
516532

533+
/**
534+
* Parse native Plex NFO agents.
535+
*
536+
* Typed NFO GUIDs include the source in the last path segment, such as
537+
* `tv.plex.agents.nfo.movie://movie/tmdb_383498`. We normalize those to the
538+
* same `<source>://<id>` format the rest of the parser already understands.
539+
* Fallback NFO ids like `...://movie/858024` do not identify the source and
540+
* are intentionally left untouched.
541+
*
542+
* @param string $guid Guid to parse.
543+
* @param array $context Context data.
544+
* @param bool $log Log errors. default true.
545+
*
546+
* @return string The parsed GUID.
547+
*/
548+
private function parseNfoAgent(string $guid, array $context = [], bool $log = true): string
549+
{
550+
if (false === in_array(before($guid, '://'), $this->guidNfo, true)) {
551+
return $guid;
552+
}
553+
554+
try {
555+
$payload = after($guid, '://');
556+
$token = trim((string) basename($payload));
557+
558+
if ('' === $token || false === str_contains($token, '_')) {
559+
return $guid;
560+
}
561+
562+
[$source, $sourceId] = explode('_', $token, 2);
563+
$source = strtolower(trim($source));
564+
$sourceId = trim($sourceId);
565+
566+
if ('' === $source || '' === $sourceId || null === ($this->guidMapper[$source] ?? null)) {
567+
return $guid;
568+
}
569+
570+
return $source . '://' . before($sourceId, '?');
571+
} catch (Throwable $e) {
572+
if (true === $log) {
573+
$this->logger->error(
574+
message: "PlexGuid: Exception '{error.kind}' was thrown unhandled during '{client}: {backend}' parsing NFO agent '{agent}' identifier. Error '{error.message}' at '{error.file}:{error.line}.",
575+
context: [
576+
'backend' => $this->context->backendName,
577+
'client' => $this->context->clientName,
578+
'error' => [
579+
'kind' => $e::class,
580+
'line' => $e->getLine(),
581+
'message' => $e->getMessage(),
582+
'file' => after($e->getFile(), ROOT_PATH),
583+
],
584+
'agent' => $guid,
585+
'exception' => [
586+
'file' => $e->getFile(),
587+
'line' => $e->getLine(),
588+
'kind' => get_class($e),
589+
'message' => $e->getMessage(),
590+
'trace' => $e->getTrace(),
591+
],
592+
...$context,
593+
],
594+
);
595+
}
596+
597+
return $guid;
598+
}
599+
}
600+
517601
/**
518602
* Get the Plex Guid configuration.
519603
*
@@ -524,6 +608,7 @@ public function getConfig(): array
524608
return [
525609
'guidMapper' => $this->guidMapper,
526610
'guidLegacy' => $this->guidLegacy,
611+
'guidNfo' => $this->guidNfo,
527612
'guidLocal' => $this->guidLocal,
528613
'guidReplacer' => $this->guidReplacer,
529614
];

tests/Backends/Plex/GetLibrariesListTest.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,37 @@ public function test_401_response_with_invalid_token(): void
152152
$this->assertNull($response->response);
153153
$this->assertTrue($response->error->hasException());
154154
}
155+
156+
public function test_nfo_agents_are_supported(): void
157+
{
158+
$payload = [
159+
'MediaContainer' => [
160+
'Directory' => [
161+
[
162+
'key' => '1',
163+
'title' => 'NFO Movies',
164+
'type' => 'movie',
165+
'agent' => 'tv.plex.agents.nfo.movie',
166+
'scanner' => 'Plex Movie Scanner',
167+
],
168+
[
169+
'key' => '2',
170+
'title' => 'NFO Shows',
171+
'type' => 'show',
172+
'agent' => 'tv.plex.agents.nfo.series',
173+
'scanner' => 'Plex TV Series',
174+
],
175+
],
176+
],
177+
];
178+
179+
$resp = new MockResponse(json_encode($payload), ['http_code' => 200]);
180+
$client = new MockHttpClient($resp);
181+
$response = new GetLibrariesList($client, $this->logger)($this->context);
182+
183+
$this->assertTrue($response->status);
184+
$this->assertCount(2, $response->response);
185+
$this->assertTrue((bool) $response->response[0]['supported']);
186+
$this->assertTrue((bool) $response->response[1]['supported']);
187+
}
155188
}

tests/Backends/Plex/ImportTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,4 +98,43 @@ public function test_import_empty_libraries(): void
9898
$this->assertTrue($result->isSuccessful());
9999
$this->assertSame([], $result->response);
100100
}
101+
102+
public function test_import_nfo_library_select_includes_selected(): void
103+
{
104+
$sections = [
105+
'MediaContainer' => [
106+
'Directory' => [
107+
[
108+
'key' => '5',
109+
'title' => 'NFO Movies',
110+
'type' => 'movie',
111+
'agent' => 'tv.plex.agents.nfo.movie',
112+
],
113+
],
114+
],
115+
];
116+
117+
$http = $this->makeHttpClient(
118+
$this->makeResponse($sections),
119+
new MockResponse('', [
120+
'http_code' => 200,
121+
'response_headers' => ['X-Plex-Container-Total-Size' => '1'],
122+
]),
123+
);
124+
$context = $this->makeContext([Options::LIBRARY_SELECT => ['5']]);
125+
$action = new Import($http, $this->logger);
126+
127+
$result = $action(
128+
$context,
129+
new PlexGuid($this->logger),
130+
$context->userContext->mapper,
131+
null,
132+
[],
133+
);
134+
135+
$this->assertTrue($result->isSuccessful());
136+
$this->assertCount(1, $result->response);
137+
$logContext = $result->response[0]->extras['logContext'] ?? [];
138+
$this->assertSame(5, (int) ag($logContext, 'library.id'));
139+
}
101140
}

tests/Backends/Plex/PlexGuidTest.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ public function test_isLocal()
351351
$this->getClass()->isLocal('com.plexapp.agents.imdb://123456/1/1'),
352352
'Assert that the GUID is not local.'
353353
);
354+
355+
$this->assertFalse(
356+
$this->getClass()->isLocal('tv.plex.agents.nfo.movie://movie/tmdb_383498'),
357+
'Assert that typed NFO GUIDs are treated as non-local external ids.'
358+
);
354359
}
355360

356361
public function test_has()
@@ -392,10 +397,23 @@ public function test_parse()
392397
], $context),
393398
'Assert that the GUID exists.');
394399

400+
$this->assertEquals([
401+
Guid::GUID_IMDB => 'tt1234567',
402+
Guid::GUID_TMDB => '383498',
403+
Guid::GUID_TVDB => '121361',
404+
],
405+
$this->getClass()->parse([
406+
['id' => 'tv.plex.agents.nfo.movie://movie/tmdb_383498'],
407+
['id' => 'tv.plex.agents.nfo.movie://movie/imdb_tt1234567'],
408+
['id' => 'tv.plex.agents.nfo.series://show/tvdb_121361'],
409+
], $context),
410+
'Assert that typed NFO GUIDs are normalized into supported external ids.');
411+
395412
$this->assertEquals([], $this->getClass()->parse([
396413
['id' => ''],
397414
['id' => 'com.plexapp.agents.none://123456'],
398415
['id' => 'com.plexapp.agents.imdb'],
416+
['id' => 'tv.plex.agents.nfo.movie://movie/858024'],
399417
], $context), 'Assert that the GUID does not exist. for invalid GUIDs.');
400418
}
401419

@@ -434,6 +452,16 @@ public function test_get()
434452
['id' => 'com.plexapp.agents.imdb://2'],
435453
], $context), 'Assert only the the oldest ID is returned for numeric GUIDs.');
436454

455+
$this->assertEquals([Guid::GUID_TVDB => '84871'], $this->getClass()->get([
456+
['id' => 'tvdb://84871'],
457+
['id' => 'tv.plex.agents.nfo.series://episode/tvdb_84871'],
458+
], $context), 'Assert typed NFO GUIDs do not conflict with identical canonical GUIDs.');
459+
460+
$this->assertFalse(
461+
$this->logged(Level::Warning, 'reported multiple ids', true),
462+
'Assert identical canonical and NFO GUIDs do not raise duplicate warnings.'
463+
);
464+
437465
// -- as we cache the ignore list for each user now,
438466
// -- and no longer rely on config.ignore key, we needed a workaround to update the ignore list
439467
is_ignored_id(

tests/Backends/Plex/ToEntityTest.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,4 +77,72 @@ public function __invoke(\App\Backends\Common\Context $context, string|int $id,
7777
$this->assertInstanceOf(StateInterface::class, $result->response);
7878
$this->assertNotEmpty($result->response->parent);
7979
}
80+
81+
public function test_to_entity_uses_top_level_typed_nfo_guid(): void
82+
{
83+
$context = $this->makeContext();
84+
$item = [
85+
'ratingKey' => '42',
86+
'type' => 'movie',
87+
'title' => 'NFO Movie',
88+
'addedAt' => 1000,
89+
'guid' => 'tv.plex.agents.nfo.movie://movie/tmdb_383498',
90+
];
91+
92+
$action = new ToEntity(new PlexGuid($this->logger));
93+
$result = $action($context, $item);
94+
95+
$this->assertTrue($result->isSuccessful());
96+
$this->assertInstanceOf(StateInterface::class, $result->response);
97+
$this->assertSame('383498', $result->response->guids['guid_tmdb'] ?? null);
98+
}
99+
100+
public function test_to_entity_episode_uses_show_level_typed_nfo_guid_when_parent_has_no_guid_list(): void
101+
{
102+
$context = $this->makeContext();
103+
$item = [
104+
'ratingKey' => '11',
105+
'type' => 'episode',
106+
'title' => 'Pilot',
107+
'grandparentTitle' => 'Test Show',
108+
'parentIndex' => 1,
109+
'index' => 1,
110+
'addedAt' => 1000,
111+
'Guid' => [
112+
['id' => 'tvdb://84871'],
113+
],
114+
'grandparentRatingKey' => 'show-1',
115+
];
116+
117+
$showPayload = [
118+
'MediaContainer' => [
119+
'Metadata' => [
120+
[
121+
'ratingKey' => 'show-1',
122+
'type' => 'show',
123+
'title' => 'Test Show',
124+
'guid' => 'tv.plex.agents.nfo.series://show/tvdb_72408',
125+
],
126+
],
127+
],
128+
];
129+
130+
Container::add(GetMetaData::class, fn() => new class($showPayload) {
131+
public function __construct(private array $payload)
132+
{
133+
}
134+
135+
public function __invoke(\App\Backends\Common\Context $context, string|int $id, array $opts = []): Response
136+
{
137+
return new Response(status: true, response: $this->payload);
138+
}
139+
});
140+
141+
$action = new ToEntity(new PlexGuid($this->logger));
142+
$result = $action($context, $item);
143+
144+
$this->assertTrue($result->isSuccessful());
145+
$this->assertInstanceOf(StateInterface::class, $result->response);
146+
$this->assertSame('72408', $result->response->parent['guid_tvdb'] ?? null);
147+
}
80148
}

0 commit comments

Comments
 (0)