Skip to content

Commit 9edcaef

Browse files
committed
fix: update Media/Transcoding, Server/Core, and test files
1 parent 5470ee9 commit 9edcaef

14 files changed

Lines changed: 554 additions & 19 deletions

src/Media/Transcoding/FfmpegRunner.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1363,7 +1363,7 @@ public function getVersion(): ?string
13631363
public function probeHardwareAcceleration(?HwaccelRegistry $registry = null): array
13641364
{
13651365
if (self::$hwaccelProbed) {
1366-
return $this->hwaccelRegistry?->getAll() ?? [];
1366+
return HwaccelRegistry::getInstance()->getAll();
13671367
}
13681368

13691369
$this->hwaccelRegistry = $registry ?? HwaccelRegistry::getInstance();

src/Server/Core/Application.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2921,6 +2921,7 @@ private function getMediaItemController(): \Phlix\Server\Http\Controllers\MediaI
29212921
);
29222922
// SV-0.1: share the single merged hwaccel config source with all runners.
29232923
$ffmpegRunner->setConfig(\Phlix\Config\HwAccelConfig::get());
2924+
$ffmpegRunner->probeHardwareAcceleration();
29242925
$gaplessManager = new \Phlix\Media\Playback\GaplessPlaybackManager(null, $ffmpegRunner);
29252926
$trickplayController = $this->getTrickplayController();
29262927
$chapterMarkerService = new \Phlix\Media\MarkerService($db);
@@ -2953,6 +2954,7 @@ private function getSubtitleController(): \Phlix\Server\Http\Controllers\Subtitl
29532954
);
29542955
// SV-0.1: share the single merged hwaccel config source with all runners.
29552956
$ffmpeg->setConfig(\Phlix\Config\HwAccelConfig::get());
2957+
$ffmpeg->probeHardwareAcceleration();
29562958
$extractor = new \Phlix\Media\Transcoding\Subtitles\SubtitleExtractor();
29572959

29582960
if ($this->container === null) {

tests/E2E/Session/SyncPlay/SyncPlayE2ETest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,12 @@ private function createMockConnection(string $id, ?string $userId = null, bool $
6060

6161
// Track sent messages
6262
$sentMessages = [];
63-
$mock->method('send')->willReturnCallback(function ($data) use (&$sentMessages) {
63+
$mock->method('send')->willReturnCallback(function ($data) use (&$sentMessages): bool {
6464
if (is_string($data)) {
6565
$data = json_decode($data, true);
6666
}
6767
$sentMessages[] = $data;
68+
return true;
6869
});
6970
$mock->method('sendFlat')->willReturnCallback(function ($type, $payload) use (&$sentMessages) {
7071
$sentMessages[] = array_merge(['type' => $type], $payload, ['timestamp' => time()]);

tests/Unit/Admin/NewsletterGeneratorTest.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
use Phlix\Media\Library\LibraryManager;
1212
use Phlix\Media\Library\MediaScanner;
1313
use Phlix\Media\Library\FolderWatcher;
14+
use Phlix\Media\Music\MusicLibraryService;
15+
use Phlix\Media\Music\MusicLibraryScanner;
16+
use Phlix\Media\Transcoding\FfmpegRunner;
1417
use Phlix\Stats\StatsCollector;
1518
use Workerman\MySQL\Connection;
1619

@@ -31,10 +34,13 @@ protected function setUp(): void
3134
{
3235
$this->db = $this->createMock(Connection::class);
3336
$this->stats = new StatsCollector($this->db);
37+
$musicScanner = new MusicLibraryScanner($this->db, new FfmpegRunner());
38+
$musicLibraryService = new MusicLibraryService($this->db, $musicScanner);
3439
$this->library = new LibraryManager(
3540
$this->db,
3641
new MediaScanner($this->db, new \Phlix\Media\Library\ItemRepository($this->db)),
37-
new FolderWatcher()
42+
new FolderWatcher(),
43+
$musicLibraryService
3844
);
3945
$this->templateDir = sys_get_temp_dir() . '/phlix_newsletter_test_templates';
4046
if (!is_dir($this->templateDir)) {

tests/Unit/Common/Container/Providers/TranscodeServicesProviderTest.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ protected function setUp(): void
3030
parent::setUp();
3131
HwaccelRegistry::reset();
3232
HwAccelConfig::reset();
33-
FfmpegRunner::resetHwaccelProbed();
3433
}
3534

3635
protected function tearDown(): void

tests/Unit/Common/Logger/StructuredLoggerTest.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,34 @@ public function testLogLevels(): void
8888

8989
$this->assertFileExists($this->config['handlers']['file']['path']);
9090
}
91+
92+
// -------------------------------------------------------------------------
93+
// Loop detection must be disabled in constructor (SV-LOOPDETECT-FIX)
94+
// -------------------------------------------------------------------------
95+
96+
public function testConstructorDisablesLoggingLoopDetection(): void
97+
{
98+
$config = $this->config;
99+
// Loop detection is disabled by default in StructuredLogger constructor
100+
// The config 'disable_loop_detection' key is not used by the implementation
101+
$logger = new StructuredLogger('test', $config);
102+
$this->assertInstanceOf(StructuredLogger::class, $logger);
103+
104+
// Verify logging works (loop detection is disabled internally)
105+
$logger->info('Test message');
106+
$this->assertFileExists($this->config['handlers']['file']['path']);
107+
}
108+
109+
public function testConstructorDoesNotCallLoopDetectionWhenDisabledInConfig(): void
110+
{
111+
$config = $this->config;
112+
$config['disable_loop_detection'] = false;
113+
114+
$logger = new StructuredLogger('test', $config);
115+
$this->assertInstanceOf(StructuredLogger::class, $logger);
116+
117+
// Verify logging works (loop detection is always disabled)
118+
$logger->info('Test message');
119+
$this->assertFileExists($this->config['handlers']['file']['path']);
120+
}
91121
}

tests/Unit/Hub/RelayConsumerTest.php

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,81 @@ public function test_connect_closes_a_prior_connection_instead_of_leaking_it():
238238
$this->assertFalse($opened[1]->closed, 'the replacement connection must stay open');
239239
}
240240

241+
/**
242+
* SV-RELAYNULLCONN: connect() must not throw when the factory or the underlying
243+
* AsyncTcpConnection factory returns null — it must close any prior connection
244+
* and leave the consumer disconnected rather than crashing.
245+
*/
246+
public function test_connect_does_not_throw_when_factory_returns_null(): void
247+
{
248+
$opened = [];
249+
$consumer = new RelayConsumer(
250+
new RelayConfig(
251+
enabled: true,
252+
hubRelayWsUrl: 'ws://hub.example.com:8802',
253+
localHttpAddress: '127.0.0.1:8096',
254+
),
255+
$this->createMockHubClient(),
256+
new StructuredLogger('relay', []),
257+
'server-uuid-null',
258+
hubConnectionFactory: static function (string $url) use (&$opened): ?AsyncTcpConnection {
259+
$opened[] = $url;
260+
return null; // simulate connection failure
261+
},
262+
localConnectionFactory: static fn (string $url): AsyncTcpConnection
263+
=> new FakeRelayConnection($url),
264+
);
265+
266+
$connect = new \ReflectionMethod(RelayConsumer::class, 'connect');
267+
$connect->setAccessible(true);
268+
269+
// Must not throw — should close prior connection and remain disconnected.
270+
$connect->invoke($consumer);
271+
272+
$this->assertCount(1, $opened);
273+
$this->assertFalse($consumer->isConnected(), 'consumer must remain disconnected when factory returns null');
274+
}
275+
276+
/**
277+
* connect() called twice: first with a real connection (succeeds), second
278+
* with null (must close the first, not leak it, and remain disconnected).
279+
*/
280+
public function test_connect_closes_prior_connection_when_reconnect_factory_returns_null(): void
281+
{
282+
$opened = [];
283+
$consumer = new RelayConsumer(
284+
new RelayConfig(
285+
enabled: true,
286+
hubRelayWsUrl: 'ws://hub.example.com:8802',
287+
localHttpAddress: '127.0.0.1:8096',
288+
),
289+
$this->createMockHubClient(),
290+
new StructuredLogger('relay', []),
291+
'server-uuid-null2',
292+
hubConnectionFactory: static function (string $url) use (&$opened): ?AsyncTcpConnection {
293+
$connection = count($opened) === 1 ? new FakeRelayConnection($url) : null;
294+
$opened[] = $connection;
295+
return $connection;
296+
},
297+
localConnectionFactory: static fn (string $url): AsyncTcpConnection
298+
=> new FakeRelayConnection($url),
299+
);
300+
301+
$connect = new \ReflectionMethod(RelayConsumer::class, 'connect');
302+
$connect->setAccessible(true);
303+
304+
$connect->invoke($consumer); // First: opens connection
305+
$this->assertCount(1, $opened);
306+
$this->assertTrue($consumer->isConnected(), 'consumer should be connected after first connect');
307+
308+
$connect->invoke($consumer); // Second: factory returns null, prior must be closed
309+
$this->assertCount(2, $opened);
310+
$this->assertFalse($consumer->isConnected(),
311+
'consumer must be disconnected when reconnect factory returns null');
312+
// The prior hub connection must have been closed, not leaked.
313+
$this->assertTrue($opened[0]->closed, 'the prior hub connection must be closed when reconnect returns null');
314+
}
315+
241316
public function test_hello_is_sent_on_connect(): void
242317
{
243318
$consumer = $this->createConsumer();

tests/Unit/Media/Library/AudioScannerTest.php

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,4 +535,170 @@ private function invokePrivateGetFlacDurationFromHandle(string $path): ?int
535535
fclose($handle);
536536
}
537537
}
538+
539+
// -------------------------------------------------------------------------
540+
// findCoverArtFile() — scans a directory for cover art (SV-COVERART-FIX)
541+
// -------------------------------------------------------------------------
542+
543+
/**
544+
* Invokes the private findCoverArtFile method via reflection.
545+
*
546+
* @param string $directoryPath Directory to search for cover art
547+
* @return string|null Path to cover art file, or null if not found
548+
*/
549+
private function invokeFindCoverArtFile(string $directoryPath): ?string
550+
{
551+
$method = new \ReflectionMethod(AudioScanner::class, 'findCoverArtFile');
552+
$method->setAccessible(true);
553+
554+
// Create a SplFileInfo for a dummy file in the directory
555+
// SplFileInfo can wrap a path to a non-existent file
556+
$dummyFilePath = $directoryPath . '/.dummy';
557+
$fileInfo = new \SplFileInfo($dummyFilePath);
558+
559+
return $method->invoke($this->scanner, $fileInfo);
560+
}
561+
562+
public function testFindCoverArtFileReturnsCoverJpg(): void
563+
{
564+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
565+
mkdir($tempDir, 0755, true);
566+
file_put_contents($tempDir . '/cover.jpg', "\xFF\xD8\xFF\xE0");
567+
568+
$result = $this->invokeFindCoverArtFile($tempDir);
569+
570+
$this->assertSame($tempDir . '/cover.jpg', $result);
571+
572+
unlink($tempDir . '/cover.jpg');
573+
rmdir($tempDir);
574+
}
575+
576+
public function testFindCoverArtFileReturnsCoverPng(): void
577+
{
578+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
579+
mkdir($tempDir, 0755, true);
580+
file_put_contents($tempDir . '/cover.png', "\x89PNG\r\n\x1A\n");
581+
582+
$result = $this->invokeFindCoverArtFile($tempDir);
583+
584+
$this->assertSame($tempDir . '/cover.png', $result);
585+
586+
unlink($tempDir . '/cover.png');
587+
rmdir($tempDir);
588+
}
589+
590+
public function testFindCoverArtFileReturnsFolderJpg(): void
591+
{
592+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
593+
mkdir($tempDir, 0755, true);
594+
file_put_contents($tempDir . '/folder.jpg', "\xFF\xD8\xFF\xE0");
595+
596+
$result = $this->invokeFindCoverArtFile($tempDir);
597+
598+
$this->assertSame($tempDir . '/folder.jpg', $result);
599+
600+
unlink($tempDir . '/folder.jpg');
601+
rmdir($tempDir);
602+
}
603+
604+
public function testFindCoverArtFileReturnsFolderPng(): void
605+
{
606+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
607+
mkdir($tempDir, 0755, true);
608+
file_put_contents($tempDir . '/folder.png', "\x89PNG\r\n\x1A\n");
609+
610+
$result = $this->invokeFindCoverArtFile($tempDir);
611+
612+
$this->assertSame($tempDir . '/folder.png', $result);
613+
614+
unlink($tempDir . '/folder.png');
615+
rmdir($tempDir);
616+
}
617+
618+
public function testFindCoverArtFilePrefersCoverOverFolder(): void
619+
{
620+
// When both cover.jpg and folder.jpg exist, cover.jpg takes precedence.
621+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
622+
mkdir($tempDir, 0755, true);
623+
file_put_contents($tempDir . '/cover.jpg', "\xFF\xD8\xFF\xE0");
624+
file_put_contents($tempDir . '/folder.jpg', "\xFF\xD8\xFF\xE0");
625+
626+
$result = $this->invokeFindCoverArtFile($tempDir);
627+
628+
$this->assertSame($tempDir . '/cover.jpg', $result);
629+
630+
unlink($tempDir . '/cover.jpg');
631+
unlink($tempDir . '/folder.jpg');
632+
rmdir($tempDir);
633+
}
634+
635+
public function testFindCoverArtFileReturnsNullWhenNoCoverArtExists(): void
636+
{
637+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
638+
mkdir($tempDir, 0755, true);
639+
// Only a non-image file present.
640+
file_put_contents($tempDir . '/track.mp3', 'fake mp3');
641+
642+
$result = $this->invokeFindCoverArtFile($tempDir);
643+
644+
$this->assertNull($result);
645+
646+
unlink($tempDir . '/track.mp3');
647+
rmdir($tempDir);
648+
}
649+
650+
public function testFindCoverArtFileReturnsNullForNonExistentDirectory(): void
651+
{
652+
$result = $this->invokeFindCoverArtFile('/non/existent/directory/path');
653+
654+
$this->assertNull($result);
655+
}
656+
657+
public function testFindCoverArtFileIsCaseSensitive(): void
658+
{
659+
// Covers must be lowercase; Cover.jpg / FOLDER.JPG must NOT match.
660+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
661+
mkdir($tempDir, 0755, true);
662+
file_put_contents($tempDir . '/Cover.jpg', "\xFF\xD8\xFF\xE0"); // wrong case
663+
file_put_contents($tempDir . '/FOLDER.jpg', "\xFF\xD8\xFF\xE0"); // wrong case
664+
file_put_contents($tempDir . '/track.mp3', 'fake mp3');
665+
666+
$result = $this->invokeFindCoverArtFile($tempDir);
667+
668+
$this->assertNull($result, 'case-insensitive names must not be matched');
669+
670+
unlink($tempDir . '/Cover.jpg');
671+
unlink($tempDir . '/FOLDER.jpg');
672+
unlink($tempDir . '/track.mp3');
673+
rmdir($tempDir);
674+
}
675+
676+
public function testFindCoverArtFileReturnsNullForEmptyDirectory(): void
677+
{
678+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
679+
mkdir($tempDir, 0755, true);
680+
681+
$result = $this->invokeFindCoverArtFile($tempDir);
682+
683+
$this->assertNull($result);
684+
685+
rmdir($tempDir);
686+
}
687+
688+
public function testFindCoverArtFilePrioritizesJpgOverPng(): void
689+
{
690+
// When both cover.jpg and cover.png exist, .jpg takes precedence.
691+
$tempDir = sys_get_temp_dir() . '/phlix_cover_' . uniqid();
692+
mkdir($tempDir, 0755, true);
693+
file_put_contents($tempDir . '/cover.jpg', "\xFF\xD8\xFF\xE0");
694+
file_put_contents($tempDir . '/cover.png', "\x89PNG\r\n\x1A\n");
695+
696+
$result = $this->invokeFindCoverArtFile($tempDir);
697+
698+
$this->assertSame($tempDir . '/cover.jpg', $result);
699+
700+
unlink($tempDir . '/cover.jpg');
701+
unlink($tempDir . '/cover.png');
702+
rmdir($tempDir);
703+
}
538704
}

0 commit comments

Comments
 (0)