diff --git a/.env.example b/.env.example index 5c4100af2..b3ae47eec 100644 --- a/.env.example +++ b/.env.example @@ -210,3 +210,5 @@ PRIVATE_PROFILES=true STORE_USER_IP=false FORUM_FRONTEND_ENABLED=false + +STREAM_FORK_OUTPUT=false diff --git a/app/Services/ArchiveProcessingService.php b/app/Services/ArchiveProcessingService.php new file mode 100644 index 000000000..2ccbe9a5b --- /dev/null +++ b/app/Services/ArchiveProcessingService.php @@ -0,0 +1,55 @@ +archiveInfo->setData($compressedData, true); + if (! $ok) { + return ['ok' => false, 'error' => $this->archiveInfo->error ?: 'Unknown error', 'summary' => null, 'is_encrypted' => false]; + } + if ($this->archiveInfo->error !== '') { + return ['ok' => false, 'error' => $this->archiveInfo->error, 'summary' => null, 'is_encrypted' => false]; + } + $summary = $this->archiveInfo->getSummary(true); + $isEncrypted = ! empty($this->archiveInfo->isEncrypted) || (isset($summary['is_encrypted']) && (int) $summary['is_encrypted'] !== 0); + return ['ok' => true, 'error' => null, 'summary' => $summary, 'is_encrypted' => $isEncrypted]; + } + + /** + * Get file list from currently analyzed archive. + */ + public function getFileList(): array + { + return $this->archiveInfo->getArchiveFileList() ?: []; + } + + /** + * Get file contents by name and source from the current archive. + */ + public function getFileData(string $name, string $source): string|false + { + return $this->archiveInfo->getFileData($name, $source); + } + + /** + * Extract file by name from the current archive to destination path. + */ + public function extractFile(string $name, string $destPath): bool + { + return $this->archiveInfo->extractFile($name, $destPath); + } +} + diff --git a/app/Services/MediaProcessingService.php b/app/Services/MediaProcessingService.php new file mode 100644 index 000000000..40abc66d0 --- /dev/null +++ b/app/Services/MediaProcessingService.php @@ -0,0 +1,327 @@ +ffprobe->isValid($videoLocation)) { + $val = $this->ffprobe->format($videoLocation)->get('duration'); + if (is_string($val) || is_numeric($val)) { + $time = (string) $val; + } + } + } catch (\Throwable $e) { + if (config('app.debug') === true) { + Log::debug($e->getMessage()); + } + } + + if (empty($time)) { + return ''; + } + + // Case 1: matches ffmpeg log style `time=.. bitrate=` (optionally with hours) + if (preg_match('/time=(\d{1,2}:\d{1,2}:)?(\d{1,2})\.(\d{1,2})\s*bitrate=/i', $time, $numbers)) { + if ($numbers[3] > 0) { + $numbers[3]--; + } elseif (! empty($numbers[1])) { + $numbers[2]--; + $numbers[3] = '99'; + } + return '00:00:'.str_pad((string) $numbers[2], 2, '0', STR_PAD_LEFT).'.'.str_pad((string) $numbers[3], 2, '0', STR_PAD_LEFT); + } + + // Case 1b: matches `time=MM:SS.xx` (without trailing bitrate) + if (preg_match('/time=(\d{2}):(\d{2})\.(\d{2})/i', $time, $m)) { + $sec = (int) $m[2]; + $hund = (int) $m[3]; + if ($hund > 0) { + $hund--; + } else { + if ($sec > 0) { + $sec--; + $hund = 99; + } + } + return '00:00:'.str_pad((string) $sec, 2, '0', STR_PAD_LEFT).'.'.str_pad((string) $hund, 2, '0', STR_PAD_LEFT); + } + + // Case 2: numeric seconds + if (is_numeric($time)) { + $seconds = (float) $time; + if ($seconds <= 0) { + return ''; + } + $seconds = max(0.0, $seconds - 0.01); + $whole = (int) floor($seconds); + $hund = (int) round(($seconds - $whole) * 100); + $hund = min($hund, 99); + return '00:00:'.str_pad((string) $whole, 2, '0', STR_PAD_LEFT).'.'.str_pad((string) $hund, 2, '0', STR_PAD_LEFT); + } + + return ''; + } + + public function saveJPGSample(string $guid, string $fileLocation): bool + { + $saved = $this->releaseImage->saveImage( + $guid.'_thumb', + $fileLocation, + $this->releaseImage->jpgSavePath, + 650, + 650 + ) === 1; + if ($saved) { + Release::query()->where('guid', $guid)->update(['jpgstatus' => 1]); + } + return $saved; + } + + public function createSampleImage(string $guid, string $fileLocation, string $tmpPath, bool $enabled, int $width = 800, int $height = 600): bool + { + if (! $enabled) { + return false; + } + if (! File::isFile($fileLocation)) { + return false; + } + $fileName = ($tmpPath.'zzzz'.random_int(5, 12).random_int(5, 12).'.jpg'); + $time = $this->getVideoTime($fileLocation); + if ($this->ffprobe->isValid($fileLocation)) { + try { + $this->ffmpeg->open($fileLocation) + ->frame(TimeCode::fromString($time === '' ? '00:00:03:00' : $time)) + ->save($fileName); + } catch (\Throwable $e) { + if (config('app.debug') === true) { + Log::error($e->getMessage()); + } + } + } + if (! File::isFile($fileName)) { + return false; + } + $saved = $this->releaseImage->saveImage( + $guid.'_thumb', + $fileName, + $this->releaseImage->imgSavePath, + $width, + $height + ); + File::delete($fileName); + if ($saved === 1) { + return true; + } + return false; + } + + public function createVideoSample(string $guid, string $fileLocation, string $tmpPath, bool $enabled, int $durationSeconds): bool + { + if (! $enabled) { + return false; + } + if (! File::isFile($fileLocation)) { + return false; + } + $fileName = ($tmpPath.'zzzz'.$guid.'.ogv'); + $newMethod = false; + if ($durationSeconds < 60) { + $time = $this->getVideoTime($fileLocation); + if ($time !== '' && preg_match('/(\d{2}).(\d{2})/', $time, $numbers)) { + $newMethod = true; + if ($numbers[1] <= $durationSeconds) { + $lowestLength = '00:00:00.00'; + } else { + $lowestLength = ($numbers[1] - $durationSeconds); + $end = '.'.$numbers[2]; + $lowestLength = match (strlen((string) $lowestLength)) { + 1 => ('00:00:0'.$lowestLength.$end), + 2 => ('00:00:'.$lowestLength.$end), + default => '00:00:60.00', + }; + } + if ($this->ffprobe->isValid($fileLocation)) { + try { + $video = $this->ffmpeg->open($fileLocation); + $videoSample = $video->clip(TimeCode::fromString($lowestLength), TimeCode::fromSeconds($durationSeconds)); + $format = new Ogg; + $format->setAudioCodec('libvorbis'); + $videoSample->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT); + $videoSample->save($format, $fileName); + } catch (\Throwable $e) { + if (config('app.debug') === true) { + Log::error($e->getMessage()); + } + } + } + } + } + if (! $newMethod && $this->ffprobe->isValid($fileLocation)) { + try { + $video = $this->ffmpeg->open($fileLocation); + $videoSample = $video->clip(TimeCode::fromSeconds(0), TimeCode::fromSeconds($durationSeconds)); + $format = new Ogg; + $format->setAudioCodec('libvorbis'); + $videoSample->filters()->resize(new Dimension(320, -1), ResizeFilter::RESIZEMODE_SCALE_HEIGHT); + $videoSample->save($format, $fileName); + } catch (\Throwable $e) { + if (config('app.debug') === true) { + Log::error($e->getMessage()); + } + } + } + if (! File::isFile($fileName)) { + return false; + } + $newFile = ($this->releaseImage->vidSavePath.$guid.'.ogv'); + if (! @File::move($fileName, $newFile)) { + $copied = @File::copy($fileName, $newFile); + File::delete($fileName); + if (! $copied) { + return false; + } + } + @chmod($newFile, 0764); + Release::query()->where('guid', $guid)->update(['videostatus' => 1]); + return true; + } + + public function addVideoMediaInfo(int $releaseId, string $fileLocation): bool + { + if (! File::isFile($fileLocation)) { + return false; + } + try { + $xmlArray = $this->mediaInfo->getInfo($fileLocation, true); + \App\Models\MediaInfo::addData($releaseId, $xmlArray); + $this->releaseExtra->addFromXml($releaseId, $xmlArray); + return true; + } catch (\Throwable $e) { + Log::debug($e->getMessage()); + return false; + } + } + + public function addAudioInfoAndSample( + Release $release, + string $fileLocation, + string $fileExtension, + bool $processAudioInfo, + bool $processAudioSample, + string $audioSavePath + ): array { + // Mirror original behavior: defaults depend on flags, not file presence + $retVal = ! $processAudioInfo ? true : false; + $audVal = ! $processAudioSample ? true : false; + + // Only proceed with file-dependent operations if file exists + if (File::isFile($fileLocation)) { + if ($processAudioInfo) { + try { + $xmlArray = $this->mediaInfo->getInfo($fileLocation, false); + if ($xmlArray !== null) { + foreach ($xmlArray->getAudios() as $track) { + if ($track->get('album') !== null && $track->get('performer') !== null) { + if ((int) $release->predb_id === 0 && config('nntmux.rename_music_mediainfo')) { + $ext = strtoupper($fileExtension); + if (! empty($track->get('recorded_date')) && preg_match('/(?:19|20)\d\d/', $track->get('recorded_date')->getFullname(), $Year)) { + $newName = $track->get('performer')->getFullName().' - '.$track->get('album')->getFullName().' ('.$Year[0].') '.$ext; + } else { + $newName = $track->get('performer')->getFullName().' - '.$track->get('album')->getFullName().' '.$ext; + } + if ($ext === 'MP3') { + $newCat = Category::MUSIC_MP3; + } elseif ($ext === 'FLAC') { + $newCat = Category::MUSIC_LOSSLESS; + } else { + $newCat = $this->categorize->determineCategory($release->groups_id, $newName, $release->fromname); + } + $newTitle = escapeString(substr($newName, 0, 255)); + Release::whereId($release->id)->update([ + 'searchname' => $newTitle, + 'categories_id' => $newCat['categories_id'] ?? $release->categories_id, + 'iscategorized' => 1, + 'isrenamed' => 1, + 'proc_pp' => 1, + ]); + if (config('nntmux.elasticsearch_enabled') === true) { + $this->elasticsearch->updateRelease($release->id); + } else { + $this->manticore->updateRelease($release->id); + } + } + $this->releaseExtra->addFromXml($release->id, $xmlArray); + $retVal = true; + break; + } + } + } + } catch (\Throwable $e) { + Log::debug($e->getMessage()); + } + } + + if ($processAudioSample) { + $audioFileName = ($release->guid.'.ogg'); + if ($this->ffprobe->isValid($fileLocation)) { + try { + $audioSample = $this->ffmpeg->open($fileLocation); + $format = new Vorbis; + $audioSample->clip(TimeCode::fromSeconds(30), TimeCode::fromSeconds(30)); + $audioSample->save($format, $audioSavePath.$audioFileName); + } catch (\Throwable $e) { + if (config('app.debug') === true) { + Log::error($e->getMessage()); + } + } + } + if (File::isFile($audioSavePath.$audioFileName)) { + @chmod($audioSavePath.$audioFileName, 0764); + Release::query()->where('id', $release->id)->update(['audiostatus' => 1]); + $audVal = true; + } + } + } + + return ['info' => $retVal, 'sample' => $audVal]; + } +} + diff --git a/app/Services/TempWorkspaceService.php b/app/Services/TempWorkspaceService.php new file mode 100644 index 000000000..07991aaad --- /dev/null +++ b/app/Services/TempWorkspaceService.php @@ -0,0 +1,111 @@ +getPathname()); + } + // Delete sub-directories + foreach (File::directories($path) as $dir) { + File::deleteDirectory($dir); + } + if (! $preserveRoot) { + File::deleteDirectory($path); + } + } elseif (File::isFile($path)) { + File::delete($path); + } + } + + /** + * List files under a directory. + * If $pattern is provided, return an array of preg_match($pattern, $relativePath, $matches) arrays, + * where $matches[0] is the ABSOLUTE path for convenience (remaining capture groups preserved if present). + * Otherwise return an array of SplFileInfo. + * + * @return array|\SplFileInfo> + */ + public function listFiles(string $path, string $pattern = ''): array + { + try { + $files = File::allFiles($path); + } catch (\Throwable $e) { + throw new \RuntimeException('ERROR: Could not open temp dir: '.$e->getMessage()); + } + + if ($pattern !== '') { + $filtered = []; + $base = rtrim($path, '/\\'); + foreach ($files as $file) { + $relative = $file->getRelativePathname(); + if (preg_match($pattern, $relative, $matches)) { + // Overwrite full match with absolute path to make downstream file ops straightforward + $matches[0] = $base.'/'.$relative; + $filtered[] = $matches; + } + } + return $filtered; + } + + return $files; + } +} diff --git a/tests/CreatesApplication.php b/tests/CreatesApplication.php new file mode 100644 index 000000000..02c837534 --- /dev/null +++ b/tests/CreatesApplication.php @@ -0,0 +1,23 @@ +make(Kernel::class)->bootstrap(); + + return $app; + } +} + diff --git a/tests/Unit/ArchiveProcessingServiceTest.php b/tests/Unit/ArchiveProcessingServiceTest.php new file mode 100644 index 000000000..d3647405c --- /dev/null +++ b/tests/Unit/ArchiveProcessingServiceTest.php @@ -0,0 +1,78 @@ +shouldReceive('setData')->once()->andReturn(true); + $ai->error = ''; + $ai->shouldReceive('getSummary')->once()->andReturn(['main_type' => 1, 'is_encrypted' => 0]); + + $svc = new ArchiveProcessingService($ai); + $res = $svc->analyze('BINARY'); + + $this->assertTrue($res['ok']); + $this->assertFalse($res['is_encrypted']); + $this->assertIsArray($res['summary']); + } + + #[WithoutErrorHandler] + public function testAnalyzeEncryptedViaSummaryFlag(): void + { + $ai = Mockery::mock(ArchiveInfo::class); + $ai->shouldReceive('setData')->once()->andReturn(true); + $ai->error = ''; + $ai->shouldReceive('getSummary')->once()->andReturn(['main_type' => 1, 'is_encrypted' => 1]); + + $svc = new ArchiveProcessingService($ai); + $res = $svc->analyze('BINARY'); + + $this->assertTrue($res['ok']); + $this->assertTrue($res['is_encrypted']); + } + + #[WithoutErrorHandler] + public function testAnalyzeErrorWhenSetDataFails(): void + { + $ai = Mockery::mock(ArchiveInfo::class); + $ai->shouldReceive('setData')->once()->andReturn(false); + $ai->error = 'Bad data'; + + $svc = new ArchiveProcessingService($ai); + $res = $svc->analyze('BINARY'); + + $this->assertFalse($res['ok']); + $this->assertNotNull($res['error']); + } + + #[WithoutErrorHandler] + public function testGettersProxyToArchiveInfo(): void + { + $ai = Mockery::mock(ArchiveInfo::class); + $ai->shouldReceive('getArchiveFileList')->once()->andReturn([['name' => 'file.txt']]); + $ai->shouldReceive('getFileData')->once()->with('name', 'source')->andReturn('DATA'); + $ai->shouldReceive('extractFile')->once()->with('name', Mockery::type('string'))->andReturn(true); + + $svc = new ArchiveProcessingService($ai); + + $this->assertSame([['name' => 'file.txt']], $svc->getFileList()); + $this->assertSame('DATA', $svc->getFileData('name', 'source')); + $this->assertTrue($svc->extractFile('name', '/tmp/path')); + } +} diff --git a/tests/Unit/MediaProcessingServiceTest.php b/tests/Unit/MediaProcessingServiceTest.php new file mode 100644 index 000000000..8c69c3fad --- /dev/null +++ b/tests/Unit/MediaProcessingServiceTest.php @@ -0,0 +1,139 @@ +instance('files', new Filesystem()); + Facade::setFacadeApplication($container); + + $this->tmpDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'mps_'.uniqid().DIRECTORY_SEPARATOR; + \Illuminate\Support\Facades\File::makeDirectory($this->tmpDir, 0777, true, true); + } + + protected function tearDown(): void + { + if (File::exists($this->tmpDir)) { + File::deleteDirectory($this->tmpDir); + } + Mockery::close(); + parent::tearDown(); + } + + private function makeService( + ?FFMpeg $ffmpeg = null, + ?FFProbe $ffprobe = null, + ?MediaInfo $mediaInfo = null, + ?ReleaseImage $releaseImage = null, + ?ReleaseExtra $releaseExtra = null, + ?ManticoreSearch $manticore = null, + ?ElasticSearchSiteSearch $elastic = null, + ?Categorize $categorize = null + ): MediaProcessingService { + $ffmpeg ??= Mockery::mock(FFMpeg::class); + $ffprobe ??= Mockery::mock(FFProbe::class); + $mediaInfo ??= Mockery::mock(MediaInfo::class); + $releaseImage ??= Mockery::mock(ReleaseImage::class); + $releaseExtra ??= Mockery::mock(ReleaseExtra::class); + $manticore ??= Mockery::mock(ManticoreSearch::class); + $elastic ??= Mockery::mock(ElasticSearchSiteSearch::class); + $categorize ??= Mockery::mock(Categorize::class); + return new MediaProcessingService($ffmpeg, $ffprobe, $mediaInfo, $releaseImage, $releaseExtra, $manticore, $elastic, $categorize); + } + + #[WithoutErrorHandler] + public function testGetVideoTimeParsesDurationString(): void + { + $ffprobe = Mockery::mock(FFProbe::class); + $ffprobe->shouldReceive('isValid')->once()->andReturn(true); + $format = new class { + public function get($key) { + return 'time=00:05.10 bitrate=800k'; + } + }; + $ffprobe->shouldReceive('format')->once()->andReturn($format); + $svc = $this->makeService(null, $ffprobe); + $out = $svc->getVideoTime($this->tmpDir.'vid.avi'); + $this->assertSame('00:00:05.09', $out); + } + + #[WithoutErrorHandler] + public function testCreateSampleImageReturnsTrueWhenSaved(): void + { + $videoFile = $this->tmpDir.'video.avi'; + File::put($videoFile, 'fake'); + + $ffprobe = Mockery::mock(FFProbe::class); + $ffprobe->shouldReceive('isValid')->andReturn(true); + $format = new class { + public function get($key) { return 'time=00:03.10 bitrate=800k'; } + }; + $ffprobe->shouldReceive('format')->andReturn($format); + + $frameMock = new class { + public function save($path) { \file_put_contents($path, 'x'); } + }; + $openMock = new class($frameMock) { + public function __construct(private $frame) {} + public function frame($tc) { return $this->frame; } + }; + $ffmpeg = Mockery::mock(FFMpeg::class); + $ffmpeg->shouldReceive('open')->andReturn($openMock); + + $releaseImage = Mockery::mock(ReleaseImage::class); + $releaseImage->imgSavePath = $this->tmpDir; + $releaseImage->shouldReceive('saveImage')->andReturn(1); + + $svc = $this->makeService($ffmpeg, $ffprobe, null, $releaseImage); + $ok = $svc->createSampleImage('guid123', $videoFile, $this->tmpDir, true); + $this->assertTrue($ok); + } + + #[WithoutErrorHandler] + public function testAddVideoMediaInfoFalseIfFileMissing(): void + { + $svc = $this->makeService(); + $this->assertFalse($svc->addVideoMediaInfo(1, $this->tmpDir.'nope.avi')); + } + + #[WithoutErrorHandler] + public function testAddAudioInfoAndSampleReturnsTrueWhenDisabled(): void + { + $svc = $this->makeService(); + $release = new ReleaseModel(); + $release->id = 1; + $release->guid = 'g'; + $release->predb_id = 0; + $release->categories_id = 0; + $release->groups_id = 0; + $release->fromname = ''; + $res = $svc->addAudioInfoAndSample($release, $this->tmpDir.'nofile.mp3', 'MP3', false, false, $this->tmpDir); + $this->assertTrue($res['info']); + $this->assertTrue($res['sample']); + } +} diff --git a/tests/Unit/TempWorkspaceServiceTest.php b/tests/Unit/TempWorkspaceServiceTest.php new file mode 100644 index 000000000..2862aede7 --- /dev/null +++ b/tests/Unit/TempWorkspaceServiceTest.php @@ -0,0 +1,107 @@ +instance('files', new Filesystem()); + Facade::setFacadeApplication($container); + + $this->svc = new TempWorkspaceService(); + // Unique base path under system temp + $this->base = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'twsvc_'.uniqid(); + File::makeDirectory($this->base, 0777, true, true); + } + + protected function tearDown(): void + { + // Cleanup + if (File::exists($this->base)) { + File::deleteDirectory($this->base); + } + parent::tearDown(); + } + + #[WithoutErrorHandler] + public function testEnsureMainTempPathCreatesAndNamespacesByGuidChar(): void + { + $resolved = $this->svc->ensureMainTempPath($this->base, 'z', ''); + $this->assertStringEndsWith('/z/', str_replace('\\', '/', $resolved)); + $this->assertTrue(File::isDirectory($resolved)); + } + + #[WithoutErrorHandler] + public function testCreateReleaseTempFolderCreatesPerReleaseDir(): void + { + $main = $this->svc->ensureMainTempPath($this->base, '', 'group42'); + $tmp = $this->svc->createReleaseTempFolder($main, 'guid-123'); + $this->assertTrue(File::isDirectory($tmp)); + $this->assertStringEndsWith('/group42/guid-123/', str_replace('\\', '/', $tmp)); + } + + #[WithoutErrorHandler] + public function testListFilesWithAndWithoutPattern(): void + { + $main = $this->svc->ensureMainTempPath($this->base, '', 'grp'); + $release = $this->svc->createReleaseTempFolder($main, 'g1'); + // Create files + File::put($release.'a.txt', 'x'); + File::put($release.'b.jpg', 'y'); + File::makeDirectory($release.'deep', 0777, true, true); + File::put($release.'deep'.DIRECTORY_SEPARATOR.'c.txt', 'z'); + + $all = $this->svc->listFiles($release); + $this->assertNotEmpty($all); + $this->assertTrue(collect($all)->every(fn($f) => method_exists($f, 'getPathname'))); + + $matches = $this->svc->listFiles($release, '/.*\.txt$/i'); + $this->assertNotEmpty($matches); + foreach ($matches as $m) { + $this->assertIsArray($m); + $this->assertArrayHasKey(0, $m); + $this->assertTrue(File::isFile($m[0])); + $this->assertStringEndsWith('.txt', strtolower($m[0])); + } + } + + #[WithoutErrorHandler] + public function testClearDirectoryPreserveRoot(): void + { + $main = $this->svc->ensureMainTempPath($this->base, '', 'grp2'); + File::put($main.'x.bin', 'data'); + File::makeDirectory($main.'sub', 0777, true, true); + File::put($main.'sub'.DIRECTORY_SEPARATOR.'y.bin', 'data'); + + $this->svc->clearDirectory($main, true); + + $this->assertTrue(File::isDirectory($main)); + $this->assertEmpty(File::files($main)); + $this->assertEmpty(File::directories($main)); + } + + #[WithoutErrorHandler] + public function testClearDirectoryDeleteRoot(): void + { + $main = $this->svc->ensureMainTempPath($this->base, '', 'grp3'); + File::put($main.'x.bin', 'data'); + + $this->svc->clearDirectory($main, false); + $this->assertFalse(File::exists($main)); + } +}