|null $postData * @return array|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|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[] = ''.$ta.''; $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 */ 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 */ 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 $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 ''.$language.''; } 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 $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.jpg"); } return url("/covers/{$coverType}/{$coverId}.jpg"); } // Return placeholder image if no cover type/ID found return asset('assets/images/no-cover.png'); } } if (! function_exists('sanitize')) { /** * @param array $doNotSanitize * @param array $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('/([+\-=&|>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 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 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 $options */ function getCoverURL(array $options = []): string { static $coverCache = []; $defaults = [ 'id' => null, 'suffix' => '-cover.jpg', '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])) { $coverCache[$cacheKey] = file_exists(storage_path('covers/').$fileSpec); } 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 = "\n". '\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('
'); } \Termwind\render("
{$str}
"); } public function info(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function notice(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function warning(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function error(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function primary(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function header(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function alternate(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } public function tmuxOrange(string $str, bool $newline = false): void { if ($newline) { \Termwind\render('
'); } \Termwind\render("
{$str}
"); } 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; } }