mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
1327 lines
45 KiB
PHP
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;
|
|
}
|
|
}
|