Files
newznab-tmux/app/Extensions/helper/helpers.php
T
2026-07-16 15:57:20 +02:00

1327 lines
45 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\Country as CountryModel;
use App\Models\Release;
use App\Services\Nzb\NzbService;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use STS\ZipStream\Builder;
use STS\ZipStream\Facades\Zip as ZipStream;
use Symfony\Component\Process\Process;
if (! function_exists('getRawHtml')) {
/**
* @param array<string, mixed>|null $postData
* @return array<string, mixed>|false|string
*/
function getRawHtml(string $url, bool|string|null $cookie = false, ?array $postData = null): array|false|string
{
// Check if this is an adult site that needs age verification
$adultSites = [
'adultdvdempire.com',
'adultdvdmarketplace.com',
'aebn.net',
'hotmovies.com',
'popporn.com',
];
$isAdultSite = false;
foreach ($adultSites as $site) {
if (stripos($url, $site) !== false) {
$isAdultSite = true;
break;
}
}
// Standard method
$cookieJar = new CookieJar;
$client = new Client(['headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246']]);
if ($cookie !== false && $cookie !== null && $cookie !== '') {
$cookie = $cookieJar->setCookie(SetCookie::fromString((string) $cookie));
$client = new Client(['cookies' => $cookie, 'headers' => ['User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246']]);
}
try {
$response = $client->get($url)->getBody()->getContents();
$jsonResponse = json_decode($response, true);
if (json_last_error() === JSON_ERROR_NONE) {
$response = $jsonResponse;
}
} catch (RequestException $e) {
if (function_exists('config') && config('app.debug') === true) {
Log::error($e->getMessage());
}
$response = false;
} catch (RuntimeException $e) {
if (function_exists('config') && config('app.debug') === true) {
Log::error($e->getMessage());
}
$response = false;
}
return $response;
}
}
if (! function_exists('makeFieldLinks')) {
/**
* @param array<string, mixed>|object $data Array or model (ArrayAccess)
*
* @throws Exception
*/
function makeFieldLinks(array|object $data, string $field, string $type): string
{
// Check if field exists and is not empty
if (! isset($data[$field]) || empty($data[$field])) {
return '';
}
$tmpArr = explode(', ', $data[$field]);
$newArr = [];
$i = 0;
foreach ($tmpArr as $ta) {
if (trim($ta) === '') {
continue;
}
if ($i > 7) {
break;
}
$newArr[] = '<a href="'.url('/'.ucfirst($type).'?'.$field.'='.urlencode($ta)).'" title="'.$ta.'">'.$ta.'</a>';
$i++;
}
return implode(', ', $newArr);
}
}
if (! function_exists('getUserBrowseOrder')) {
/**
* @return array{0: string, 1: string}
*/
function getUserBrowseOrder(string $orderBy): array
{
$order = ($orderBy === '' ? 'username_desc' : $orderBy);
$orderArr = explode('_', $order);
$orderField = match ($orderArr[0]) {
'email' => 'email',
'host' => 'host',
'createdat' => 'created_at',
'lastlogin' => 'lastlogin',
'apiaccess' => 'apiaccess',
'apirequests' => 'apirequests',
'grabs' => 'grabs',
'roles_id' => 'users_role_id',
'rolechangedate' => 'rolechangedate',
default => 'username',
};
$orderSort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc';
return [$orderField, $orderSort];
}
}
if (! function_exists('getUserBrowseOrdering')) {
/**
* @return array<int, string>
*/
function getUserBrowseOrdering(): array
{
return [
'username_asc',
'username_desc',
'email_asc',
'email_desc',
'host_asc',
'host_desc',
'createdat_asc',
'createdat_desc',
'lastlogin_asc',
'lastlogin_desc',
'apiaccess_asc',
'apiaccess_desc',
'apirequests_asc',
'apirequests_desc',
'grabs_asc',
'grabs_desc',
'role_asc',
'role_desc',
'rolechangedate_asc',
'rolechangedate_desc',
'verification_asc',
'verification_desc',
];
}
}
if (! function_exists('getSimilarName')) {
function getSimilarName(string $name): string
{
return implode(' ', \array_slice(str_word_count(str_replace(['.', '_', '-'], ' ', $name), 2), 0, 2));
}
}
if (! function_exists('human_filesize')) {
function human_filesize(int|float|string $bytes, int $decimals = 0): string
{
$size = ['B', 'kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
$factor = (int) floor((\strlen((string) $bytes) - 1) / 3);
return round((float) sprintf("%.{$decimals}f", $bytes / (1024 ** $factor)), $decimals).@$size[$factor];
}
}
if (! function_exists('bcdechex')) {
function bcdechex(string $dec): string
{
$hex = '';
do {
$last = bcmod($dec, '16');
$hex = dechex((int) $last).$hex;
$dec = bcdiv(bcsub($dec, $last), '16');
} while ($dec > 0);
return $hex;
}
}
if (! function_exists('runCmd')) {
/**
* Run CLI command.
*/
function runCmd(string $command, bool $debug = false): string
{
if ($debug) {
echo '-Running Command: '.PHP_EOL.' '.$command.PHP_EOL;
}
$process = Process::fromShellCommandline('exec '.$command);
$process->setTimeout(1800);
$process->run();
$output = $process->getOutput();
if ($debug) {
echo '-Command Output: '.PHP_EOL.' '.$output.PHP_EOL;
}
return $output;
}
}
if (! function_exists('imdb_id_is_valid')) {
/**
* Check whether an IMDB ID string is a valid, non-zero numeric identifier.
* IMDB IDs are stored as plain digit strings (e.g. "0137523", "1375666", "14688458")
* exactly as provided by upstream sources — no padding, no trimming.
*/
function imdb_id_is_valid(int|string|null $id): bool
{
if ($id === null || $id === '' || $id === 0) {
return false;
}
$s = preg_replace('/\D/', '', trim((string) $id));
return $s !== '' && $s !== '0' && ((int) $s) !== 0;
}
}
if (! function_exists('imdb_id_pending_values')) {
/**
* IMDb values that mean "queued/pending lookup" in legacy release rows.
*
* @return list<string>
*/
function imdb_id_pending_values(): array
{
return ['0', '0000000', '00000000'];
}
}
if (! function_exists('imdb_id_needs_lookup')) {
/**
* Determine whether a release should still be queued for IMDb lookup.
*
* NULL means never attempted. Legacy zero-like sentinels also mean pending.
* Empty string means lookup already attempted and failed, so do not requeue.
*/
function imdb_id_needs_lookup(int|string|null $id): bool
{
if ($id === null) {
return true;
}
$value = trim((string) $id);
return in_array($value, imdb_id_pending_values(), true);
}
}
if (! function_exists('imdb_id_needs_lookup_sql')) {
/**
* Build a reusable SQL predicate for releases that still need IMDb lookup.
*/
function imdb_id_needs_lookup_sql(string $column = 'imdbid'): string
{
$pendingValues = implode(', ', array_map(static fn (string $value): string => escapeString($value), imdb_id_pending_values()));
return sprintf('(%s IS NULL OR %s IN (%s))', $column, $column, $pendingValues);
}
}
if (! function_exists('imdb_id_pad')) {
/**
* @deprecated Use imdb_id_is_valid() instead. This function exists only for backward
* compatibility during the transition period and will be removed.
*/
function imdb_id_pad(int|string|null $id): string
{
if ($id === null || $id === '') {
return '00000000';
}
$s = (string) $id;
$s = trim($s);
if ($s === '' || $s === '0' || $s === '0000000' || $s === '00000000') {
return '00000000';
}
return str_pad((string) (int) $s, 8, '0', STR_PAD_LEFT);
}
}
if (! function_exists('escapeString')) {
function escapeString(mixed $string): string
{
return DB::connection()->getPdo()->quote($string);
}
}
if (! function_exists('regex_display_value')) {
/**
* Decode entity-encoded regex text at the presentation boundary.
*
* Blade should still render the returned value with escaped {{ }} output so regexes that
* contain HTML-looking text remain safe while displaying named groups and quotes readably.
*/
function regex_display_value(mixed $value): string
{
return html_entity_decode((string) ($value ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
}
if (! function_exists('realDuration')) {
function realDuration(mixed $milliseconds): string
{
$time = round($milliseconds / 1000);
return sprintf('%02dh:%02dm:%02ds', floor($time / 3600), floor($time / 60 % 60), $time % 60);
}
}
if (! function_exists('is_it_json')) {
/**
* @throws JsonException
*/
function is_it_json(mixed $isIt): bool
{
if (is_array($isIt)) {
return false;
}
json_decode($isIt, true, 512, JSON_THROW_ON_ERROR);
return json_last_error() === JSON_ERROR_NONE;
}
}
if (! function_exists('getStreamingZip')) {
/**
* @param list<string> $guids
*
* @throws Exception
*/
function getStreamingZip(array $guids = []): Builder
{
$nzb = app(NzbService::class);
$zipped = ZipStream::create(now()->format('Ymdhis').'.zip');
foreach ($guids as $guid) {
$nzbPath = $nzb->nzbPath($guid);
if ($nzbPath) {
$nzbContents = unzipGzipFile($nzbPath);
if ($nzbContents) {
$filename = $guid;
$r = Release::query()->where('guid', $guid)->first();
if ($r) {
$filename = $r['searchname'];
}
$zipped->addRaw($nzbContents, $filename.'.nzb');
}
}
}
return $zipped;
}
}
if (! function_exists('release_flag')) {
// Function inspired by c0r3@newznabforums adds country flags on the browse page.
/**
* @param string $text Text to match against.
* @param string $page Type of page. browse or search.
*/
function release_flag(string $text, string $page): bool|string
{
$code = $language = '';
switch (true) {
case stripos($text, 'Arabic') !== false:
$code = 'PK';
$language = 'Arabic';
break;
case stripos($text, 'Cantonese') !== false:
$code = 'TW';
$language = 'Cantonese';
break;
case preg_match('/Chinese|Mandarin|\bc[hn]\b/i', $text):
$code = 'CN';
$language = 'Chinese';
break;
case preg_match('/\bCzech\b/i', $text):
$code = 'CZ';
$language = 'Czech';
break;
case stripos($text, 'Danish') !== false:
$code = 'DK';
$language = 'Danish';
break;
case stripos($text, 'Finnish') !== false:
$code = 'FI';
$language = 'Finnish';
break;
case preg_match('/Flemish|\b(Dutch|nl)\b|NlSub/i', $text):
$code = 'NL';
$language = 'Dutch';
break;
case preg_match('/French|Vostfr|Multi/i', $text):
$code = 'FR';
$language = 'French';
break;
case preg_match('/German(bed)?|\bger\b/i', $text):
$code = 'DE';
$language = 'German';
break;
case preg_match('/\bGreek\b/i', $text):
$code = 'GR';
$language = 'Greek';
break;
case preg_match('/Hebrew|Yiddish/i', $text):
$code = 'IL';
$language = 'Hebrew';
break;
case preg_match('/\bHindi\b/i', $text):
$code = 'IN';
$language = 'Hindi';
break;
case preg_match('/Hungarian|\bhun\b/i', $text):
$code = 'HU';
$language = 'Hungarian';
break;
case preg_match('/Italian|\bita\b/i', $text):
$code = 'IT';
$language = 'Italian';
break;
case preg_match('/Japanese|\bjp\b/i', $text):
$code = 'JP';
$language = 'Japanese';
break;
case preg_match('/Korean|\bkr\b/i', $text):
$code = 'KR';
$language = 'Korean';
break;
case stripos($text, 'Norwegian') !== false:
$code = 'NO';
$language = 'Norwegian';
break;
case stripos($text, 'Polish') !== false:
$code = 'PL';
$language = 'Polish';
break;
case stripos($text, 'Portuguese') !== false:
$code = 'PT';
$language = 'Portugese';
break;
case stripos($text, 'Romanian') !== false:
$code = 'RO';
$language = 'Romanian';
break;
case stripos($text, 'Spanish') !== false:
$code = 'ES';
$language = 'Spanish';
break;
case preg_match('/Swe(dish|sub)/i', $text):
$code = 'SE';
$language = 'Swedish';
break;
case preg_match('/Tagalog|Filipino/i', $text):
$code = 'PH';
$language = 'Tagalog|Filipino';
break;
case preg_match('/\bThai\b/i', $text):
$code = 'TH';
$language = 'Thai';
break;
case stripos($text, 'Turkish') !== false:
$code = 'TR';
$language = 'Turkish';
break;
case stripos($text, 'Russian') !== false:
$code = 'RU';
$language = 'Russian';
break;
case stripos($text, 'Vietnamese') !== false:
$code = 'VN';
$language = 'Vietnamese';
break;
}
if ($code !== '' && $page === 'browse') {
return '<img title="'.$language.'" alt="'.$language.'" src="'.asset('/assets/images/flags/'.$code.'.png').'"/>';
}
if ($page === 'search') {
if ($code === '') {
return false;
}
return $code;
}
return '';
}
}
if (! function_exists('getReleaseCover')) {
/**
* Get the cover image URL for a release based on its type and ID
*
* @param object|array<string, mixed> $release The release object or array
*/
function getReleaseCover(object|array $release): string
{
$coverType = null;
$coverId = null;
// Helper function to get value from object or array
$getValue = function ($data, $key) {
if (is_array($data)) {
return $data[$key] ?? null;
} elseif (is_object($data)) {
return $data->$key ?? null;
}
return null;
};
$isPositiveId = static function (mixed $value): bool {
return is_numeric($value) && (int) $value > 0;
};
// Determine cover type and ID based on category
$imdbid = $getValue($release, 'imdbid');
$videos_id = $getValue($release, 'videos_id');
$musicinfo_id = $getValue($release, 'musicinfo_id');
$consoleinfo_id = $getValue($release, 'consoleinfo_id');
$bookinfo_id = $getValue($release, 'bookinfo_id');
$gamesinfo_id = $getValue($release, 'gamesinfo_id');
$anidbid = $getValue($release, 'anidbid');
if (! empty($imdbid) && imdb_id_is_valid($imdbid)) {
$coverType = 'movies';
$coverId = (string) $imdbid;
} elseif ($isPositiveId($videos_id)) {
$coverType = 'tvshows';
$coverId = $videos_id;
} elseif ($isPositiveId($musicinfo_id)) {
$coverType = 'music';
$coverId = $musicinfo_id;
} elseif ($isPositiveId($consoleinfo_id)) {
$coverType = 'console';
$coverId = $consoleinfo_id;
} elseif ($isPositiveId($bookinfo_id)) {
$coverType = 'book';
$coverId = $bookinfo_id;
} elseif ($isPositiveId($gamesinfo_id)) {
$coverType = 'games';
$coverId = $gamesinfo_id;
} elseif ($isPositiveId($anidbid)) {
$coverType = 'anime';
$coverId = $anidbid;
}
if ($coverType && $coverId) {
if (in_array($coverType, ['movies', 'anime'], true)) {
return url("/covers/{$coverType}/{$coverId}-cover.webp");
}
return url("/covers/{$coverType}/{$coverId}.webp");
}
// Return placeholder image if no cover type/ID found
return asset('assets/images/no-cover.png');
}
}
if (! function_exists('sanitize')) {
/**
* @param array<string, mixed> $doNotSanitize
* @param array<string, mixed> $phrases
*/
function sanitize(array|string $phrases, array $doNotSanitize = []): string
{
if (! is_array($phrases)) {
$wordArray = explode(' ', str_replace('.', ' ', $phrases));
} else {
$wordArray = $phrases;
}
$keywords = [];
$tempWords = [];
foreach ($wordArray as $words) {
$words = preg_split('/\s+/', $words);
foreach ($words as $st) {
if (Str::startsWith($st, ['!', '+', '-', '?', '*']) && Str::length($st) > 1 && ! preg_match('/([!+?\-*]){2,}/', $st)) {
$str = $st;
} elseif (Str::endsWith($st, ['+', '-', '?', '*']) && Str::length($st) > 1 && ! preg_match('/([!+?\-*]){2,}/', $st)) {
$str = $st;
} else {
$str = in_array($st, $doNotSanitize, true)
? $st
: (preg_replace('/([+\-=&|><!(){}\[\]^"~*?:\\\\\\/])/', '\\\\$1', $st) ?? $st);
}
$tempWords[] = $str;
}
$keywords = $tempWords;
}
return implode(' ', $keywords);
}
}
if (! function_exists('formatBytes')) {
/**
* Format bytes into human-readable file size.
*/
function formatBytes(int|float|null $bytes = 0): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max((int) ($bytes ?? 0), 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= pow(1024, $pow);
return round($bytes, 2).' '.$units[$pow];
}
}
if (! function_exists('csp_nonce')) {
/**
* Generate a CSP nonce for inline scripts
* Uses the Laravel container to ensure the nonce is truly unique per request,
* avoiding issues with static variables in PHP-FPM environments.
*/
function csp_nonce(): string
{
// Use Laravel's container to store the nonce per-request
// This ensures the same nonce is used in both the CSP header and Blade templates
if (! app()->bound('csp_nonce')) {
app()->instance('csp_nonce', base64_encode(random_bytes(16)));
}
return app('csp_nonce');
}
}
if (! function_exists('userDate')) {
/**
* Format a date/time string according to the authenticated user's timezone
*
* @param string|null $date The date to format
* @param string $format The format string (default: 'M d, Y H:i')
* @return string The formatted date in user's timezone
*/
function userDate(?string $date, string $format = 'M d, Y H:i'): string
{
if (empty($date)) {
return '';
}
try {
// Parse the date in the app's timezone (which should be UTC)
// If dates in DB are stored in server timezone, they'll be parsed correctly
$appTimezone = config('app.timezone', 'UTC');
$carbon = Carbon::parse($date, $appTimezone);
// If user is authenticated and has a timezone set, convert to it
if (Auth::check() && Auth::user()->timezone) {
$carbon->setTimezone(Auth::user()->timezone);
}
return $carbon->format($format);
} catch (Exception $e) {
return $date;
}
}
}
if (! function_exists('userDateDiffForHumans')) {
/**
* Format a date/time string as a human-readable diff according to the authenticated user's timezone
*
* @param string|null $date The date to format
* @return string The formatted date diff in user's timezone
*/
function userDateDiffForHumans(?string $date): string
{
if (empty($date)) {
return '';
}
try {
// Parse the date in the app's timezone (which should be UTC)
// If dates in DB are stored in server timezone, they'll be parsed correctly
$appTimezone = config('app.timezone', 'UTC');
$carbon = Carbon::parse($date, $appTimezone);
// If user is authenticated and has a timezone set, convert to it
if (Auth::check() && Auth::user()->timezone) {
$carbon->setTimezone(Auth::user()->timezone);
}
return $carbon->diffForHumans();
} catch (Exception $e) {
return $date;
}
}
}
if (! function_exists('getAvailableTimezones')) {
/**
* Get a list of available timezones grouped by region
*
* @return array<string, mixed> Array of timezones grouped by region
*/
function getAvailableTimezones(): array
{
$timezones = [];
$regions = [
'Africa' => DateTimeZone::AFRICA,
'America' => DateTimeZone::AMERICA,
'Antarctica' => DateTimeZone::ANTARCTICA,
'Arctic' => DateTimeZone::ARCTIC,
'Asia' => DateTimeZone::ASIA,
'Atlantic' => DateTimeZone::ATLANTIC,
'Australia' => DateTimeZone::AUSTRALIA,
'Europe' => DateTimeZone::EUROPE,
'Indian' => DateTimeZone::INDIAN,
'Pacific' => DateTimeZone::PACIFIC,
];
foreach ($regions as $name => $region) {
$timezones[$name] = DateTimeZone::listIdentifiers($region);
}
return $timezones;
}
}
if (! function_exists('countryCode')) {
/**
* Get a country code for a country name.
*/
function countryCode(string $country): string
{
if (\strlen($country) > 2) {
return CountryModel::query()
->where('full_name', $country)
->orWhere('name', $country)
->value('iso_3166_2') ?? '';
}
return '';
}
}
if (! function_exists('unzipGzipFile')) {
/**
* Unzip a gzip file, return the output. Return false on error / empty.
*/
function unzipGzipFile(string $filePath): false|string
{
$string = '';
$gzFile = @gzopen($filePath, 'rb');
if ($gzFile) {
while (! gzeof($gzFile)) {
$temp = gzread($gzFile, 1024);
// Check for empty string.
// Without this the loop would be endless and consume 100% CPU.
// Do not set $string empty here, as the data might still be good.
if (! $temp) {
break;
}
$string .= $temp;
}
gzclose($gzFile);
}
return $string === '' ? false : $string;
}
}
if (! function_exists('streamSslContextOptions')) {
/**
* Creates an array to be used with stream_context_create() to verify openssl certificates
* when connecting to a tls or ssl connection when using stream functions (fopen/file_get_contents/etc).
*
* @param bool $forceIgnore Force ignoring of verification (useful for self-signed certs in development).
* @return array<string, mixed> Stream context options for SSL/TLS connections
*/
function streamSslContextOptions(bool $forceIgnore = false): array
{
$cafile = config('nntmux_ssl.ssl_cafile', '');
$capath = config('nntmux_ssl.ssl_capath', '');
$hasCustomCerts = $cafile !== '' || $capath !== '';
// Base options - either insecure (no certs configured) or configured
$options = [
'verify_peer' => ! $forceIgnore && $hasCustomCerts && config('nntmux_ssl.ssl_verify_peer', false),
'verify_peer_name' => ! $forceIgnore && $hasCustomCerts && config('nntmux_ssl.ssl_verify_host', false),
'allow_self_signed' => $forceIgnore ? true : config('nntmux_ssl.ssl_allow_self_signed', true),
];
// Add certificate paths if configured
if ($hasCustomCerts && ! $forceIgnore) {
if ($cafile !== '') {
$options['cafile'] = $cafile;
}
if ($capath !== '') {
$options['capath'] = $capath;
}
}
// Additional security options for modern TLS
$options['crypto_method'] = STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT;
$options['disable_compression'] = true; // Prevent CRIME attacks
$options['SNI_enabled'] = true; // Enable Server Name Indication
// If we set the transport to tls and the server falls back to ssl,
// the context options would be for tls and would not apply to ssl,
// so set both tls and ssl context in case the server does not support tls.
return ['tls' => $options, 'ssl' => $options];
}
}
if (! function_exists('getCoverURL')) {
/**
* Get cover URL for a release. Uses a short-lived in-memory cache to avoid
* repeated filesystem file_exists() calls for the same cover during a single request.
*
* @param array<string, mixed> $options
*/
function getCoverURL(array $options = []): string
{
static $coverCache = [];
$defaults = [
'id' => null,
'suffix' => '-cover.webp',
'type' => '',
];
$options += $defaults;
$fileSpecTemplate = '%s/%s%s';
$fileSpec = '';
if (! empty($options['id']) && \in_array(
$options['type'],
['anime', 'audio', 'audiosample', 'book', 'console', 'games', 'movies', 'music', 'preview', 'sample', 'tvrage', 'video', 'xxx'],
false
)
) {
$fileSpec = sprintf($fileSpecTemplate, $options['type'], $options['id'], $options['suffix']);
$cacheKey = $options['type'].':'.$options['id'];
if (! isset($coverCache[$cacheKey])) {
$canonicalPath = storage_path('covers/').$fileSpec;
$legacyPath = preg_replace('/\.webp$/i', '.jpg', $canonicalPath);
$coverCache[$cacheKey] = file_exists($canonicalPath)
|| (is_string($legacyPath) && file_exists($legacyPath));
}
if (! $coverCache[$cacheKey]) {
$fileSpec = sprintf($fileSpecTemplate, $options['type'], 'no', $options['suffix']);
}
}
return $fileSpec;
}
}
if (! function_exists('fileInfo')) {
/**
* Return file type/info using magic numbers.
* Try using `file` program where available, fallback to using PHP's finfo class.
*
* @param string $path Path to the file / folder to check.
* @return string File info. Empty string on failure.
*
* @throws Exception
*/
function fileInfo(string $path): string
{
$magicPath = config('nntmux_settings.magic_file_path');
if ($magicPath !== null && Illuminate\Support\Facades\Process::run('which file')->successful()) {
$magicSwitch = " -m $magicPath";
$output = runCmd('file'.$magicSwitch.' -b "'.$path.'"');
} else {
$fileInfo = $magicPath === null ? finfo_open(FILEINFO_RAW) : finfo_open(FILEINFO_RAW, $magicPath);
$output = finfo_file($fileInfo, $path);
if (empty($output)) {
$output = '';
}
finfo_close($fileInfo);
}
return $output;
}
}
if (! function_exists('cp437toUTF')) {
/**
* Convert Code page 437 chars to UTF.
*/
function cp437toUTF(string $string): string
{
return iconv('CP437', 'UTF-8//IGNORE//TRANSLIT', $string);
}
}
if (! function_exists('imdb_trailers')) {
/**
* Fetches an embeddable video to a IMDB trailer from http://www.traileraddict.com.
*/
function imdb_trailers(mixed $imdbID): string
{
$xml = getRawHtml('https://api.traileraddict.com/?imdb='.$imdbID);
if ($xml !== false && preg_match('#(v\.traileraddict\.com/\d+)#i', $xml, $html)) {
return 'https://'.$html[1];
}
return '';
}
}
if (! function_exists('apiErrorDetails')) {
/**
* @return array{code:int,message:string,status:int,header:string}
*/
function apiErrorDetails(int $errorCode = 900, string $errorText = ''): array
{
[$defaultText, $status, $errorHeader] = match ($errorCode) {
100 => ['Incorrect user credentials', 401, 'HTTP/1.1 401 Unauthorized'],
101 => ['Account suspended', 403, 'HTTP/1.1 403 Forbidden'],
102 => ['Insufficient privileges/not authorized', 401, 'HTTP/1.1 401 Unauthorized'],
103 => ['Registration denied', 403, 'HTTP/1.1 403 Forbidden'],
104 => ['Registrations are closed', 403, 'HTTP/1.1 403 Forbidden'],
105 => ['Invalid registration (Email Address Taken)', 403, 'HTTP/1.1 403 Forbidden'],
106 => ['Invalid registration (Email Address Bad Format)', 403, 'HTTP/1.1 403 Forbidden'],
107 => ['Registration Failed (Data error)', 400, 'HTTP/1.1 400 Bad Request'],
200 => ['Missing parameter', 400, 'HTTP/1.1 400 Bad Request'],
201 => ['Incorrect parameter', 400, 'HTTP/1.1 400 Bad Request'],
202 => ['No such function', 404, 'HTTP/1.1 404 Not Found'],
203 => ['Function not available', 400, 'HTTP/1.1 400 Bad Request'],
300 => ['No such item', 404, 'HTTP/1.1 404 Not Found'],
310 => ['Item already exists', 409, 'HTTP/1.1 409 Conflict'],
500 => ['Request limit reached', 429, 'HTTP/1.1 429 Too Many Requests'],
501 => ['Download limit reached', 429, 'HTTP/1.1 429 Too Many Requests'],
600 => ['Failed to load NZB', 400, 'HTTP/1.1 400 Bad Request'],
601 => ['NZB is duplicate', 409, 'HTTP/1.1 409 Conflict'],
602 => ['NZB is for a non-existent group', 400, 'HTTP/1.1 400 Bad Request'],
603 => ['NZB failed to write to disk', 500, 'HTTP/1.1 500 Internal Server Error'],
910 => ['API disabled', 401, 'HTTP/1.1 401 Unauthorized'],
default => ['Unknown error', 400, 'HTTP/1.1 400 Bad Request'],
};
return [
'code' => $errorCode,
'message' => $errorText !== '' ? $errorText : $defaultText,
'status' => $status,
'header' => $errorHeader,
];
}
}
if (! function_exists('apiJsonError')) {
function apiJsonError(int $errorCode = 900, string $errorText = ''): mixed
{
$error = apiErrorDetails($errorCode, $errorText);
return response()
->json(['error' => $error['message']], $error['status'])
->header('X-NNTmux', 'API ERROR ['.$error['code'].'] '.$error['message']);
}
}
if (! function_exists('showApiError')) {
function showApiError(int $errorCode = 900, string $errorText = ''): mixed
{
$error = apiErrorDetails($errorCode, $errorText);
$errorText = $error['message'];
$response =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n".
'<error code="'.$errorCode.'" description="'.$errorText."\"/>\n";
return response($response, $error['status'])->header('Content-type', 'text/xml')->header('Content-Length', (string) strlen($response))->header('X-NNTmux', 'API ERROR ['.$errorCode.'] '.$errorText)->header('HTTP/1.1', $error['header']);
}
}
if (! function_exists('getRange')) {
function getRange(string $tableName): LengthAwarePaginator // @phpstan-ignore missingType.generics
{
$range = DB::table($tableName);
return $range->orderByDesc('created_at')->paginate(config('nntmux.items_per_page'));
}
}
if (! function_exists('isValidNewznabNzb')) {
/**
* Validate if the content is a valid Newznab NZB file.
*
* @param string $content The NZB file content
* @return bool True if valid NZB, false otherwise
*/
function isValidNewznabNzb(string $content): bool
{
// Check if content starts with valid XML declaration and contains NZB namespace
if (empty($content)) {
return false;
}
// Try to load as XML
libxml_use_internal_errors(true);
$xml = @simplexml_load_string($content);
if ($xml === false) {
libxml_clear_errors();
return false;
}
// Check for NZB namespace or nzb root element
$namespaces = $xml->getNamespaces(true);
$hasNzbNamespace = isset($namespaces['']) && str_contains($namespaces[''], 'nzb');
$isNzbRoot = strtolower($xml->getName()) === 'nzb';
libxml_clear_errors();
return $hasNzbNamespace || $isNzbRoot;
}
}
if (! function_exists('cli')) {
/**
* Get the CLI helper instance for colored console output.
*
* This replaces the Blacklight\ColorCLI class with a helper function approach.
* Usage: cli()->primary('message'), cli()->error('message'), etc.
*
* @return object The CLI helper object with all output methods
*/
function cli(): object
{
static $instance = null;
if ($instance === null) {
$instance = new class
{
public int $lastMessageLength = 0;
public function debug(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-gray'>{$str}</div>");
}
public function info(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-magenta'>{$str}</div>");
}
public function notice(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-blue'>{$str}</div>");
}
public function warning(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-yellow'>{$str}</div>");
}
public function error(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-red'>{$str}</div>");
}
public function primary(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-green'>{$str}</div>");
}
public function header(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-yellow'>{$str}</div>");
}
public function alternate(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-magenta font-bold'>{$str}</div>");
}
public function tmuxOrange(string $str, bool $newline = false): void
{
if ($newline) {
\Termwind\render('<br/>');
}
\Termwind\render("<div class='text-yellow font-bold'>{$str}</div>");
}
public function primaryOver(string $str): void
{
echo "\033[32m{$str}\033[0m";
}
public function headerOver(string $str): void
{
echo "\033[33m{$str}\033[0m";
}
public function alternateOver(string $str): void
{
echo "\033[1;35m{$str}\033[0m";
}
public function warningOver(string $str): void
{
echo "\033[31m{$str}\033[0m";
}
public function progress(): object
{
return new class
{
private int $total = 0;
private int $current = 0;
private string $label = '';
public function total(int $total): self
{
$this->total = $total;
return $this;
}
public function current(int $current, ?string $label = null): void
{
$this->current = $current;
if ($label !== null) {
$this->label = $label;
}
$this->display();
}
public function advance(int $step = 1, ?string $label = null): void
{
$this->current += $step;
if ($label !== null) {
$this->label = $label;
}
$this->display();
}
private function display(): void
{
$percentage = $this->total > 0 ? (int) (($this->current / $this->total) * 100) : 0;
$bar = str_repeat('=', (int) ($percentage / 2));
$spaces = str_repeat(' ', 50 - (int) ($percentage / 2));
$label = $this->label ? " {$this->label}" : '';
echo "\r[{$bar}{$spaces}] {$percentage}%{$label}";
if ($this->current >= $this->total) {
echo "\n";
}
}
};
}
/**
* Apply ANSI color code to a string and return it (does not render)
*/
public function ansiString(string $string, string $color): string
{
$colors = [
'black' => '0;30',
'red' => '0;31',
'green' => '0;32',
'yellow' => '0;33',
'blue' => '0;34',
'magenta' => '0;35',
'cyan' => '0;36',
'white' => '0;37',
];
$code = $colors[$color] ?? '0;37';
return "\033[{$code}m{$string}\033[0m";
}
public function overWriteHeader(string $message, bool $reset = false): void
{
if ($reset) {
$this->lastMessageLength = 0;
}
echo str_repeat(\chr(8), $this->lastMessageLength);
echo str_repeat(' ', $this->lastMessageLength);
echo str_repeat(\chr(8), $this->lastMessageLength);
$this->lastMessageLength = \strlen($message);
$this->headerOver($message);
}
public function overWritePrimary(string $message, bool $reset = false): void
{
if ($reset) {
$this->lastMessageLength = 0;
}
echo str_repeat(\chr(8), $this->lastMessageLength);
echo str_repeat(' ', $this->lastMessageLength);
echo str_repeat(\chr(8), $this->lastMessageLength);
$this->lastMessageLength = \strlen($message);
$this->primaryOver($message);
}
public function overWrite(string $message, bool $reset = false): void
{
if ($reset) {
$this->lastMessageLength = 0;
}
echo str_repeat(\chr(8), $this->lastMessageLength);
echo str_repeat(' ', $this->lastMessageLength);
echo str_repeat(\chr(8), $this->lastMessageLength);
$this->lastMessageLength = \strlen($message);
echo $message;
}
public function appendWrite(string $message): void
{
echo $message;
$this->lastMessageLength += \strlen($message);
}
public function percentString(int $cur, int $total): string
{
$percent = 100 * $cur / $total;
$formatString = '% '.\strlen((string) $total).'d/%d (% 2d%%)';
return sprintf($formatString, $cur, $total, $percent);
}
public function percentString2(int $first, int $last, int $total): string
{
$percent1 = 100 * ($first - 1) / $total;
$percent2 = 100 * $last / $total;
$formatString = '% '.\strlen((string) $total).'d-% '.\strlen((string) $total).'d/%d (% 2d%%-% 3d%%)';
return sprintf($formatString, $first, $last, $total, $percent1, $percent2);
}
/**
* Convert seconds to minutes or hours, appending type at the end.
*/
public function convertTime(int $seconds): string
{
if ($seconds > 3600) {
return round($seconds / 3600).' hour(s)';
}
if ($seconds > 60) {
return round($seconds / 60).' minute(s)';
}
return $seconds.' second(s)';
}
/**
* Convert seconds to a timer, 00h:00m:00s.
*/
public function convertTimer(int $seconds): string
{
return ' '.sprintf('%02dh:%02dm:%02ds', floor($seconds / 3600), floor(($seconds / 60) % 60), $seconds % 60);
}
/**
* Sleep for x seconds, printing timer on screen.
*/
public function showSleep(int $seconds): void
{
for ($i = $seconds; $i >= 0; $i--) {
$this->overWriteHeader('Sleeping for '.$i.' seconds.');
sleep(1);
}
echo PHP_EOL;
}
};
}
return $instance;
}
}