Fix multiple issues and enhance templates

This commit is contained in:
DariusIII
2025-10-26 22:12:10 +01:00
parent 4f5b3bacc2
commit 1c5d75f104
76 changed files with 3043 additions and 918 deletions
@@ -0,0 +1,90 @@
<?php
namespace App\Console\Commands;
use App\Models\UserActivityStat;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Carbon\Carbon;
use Illuminate\Console\Command;
class BackfillUserActivityStats extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'nntmux:backfill-user-activity-stats
{--days=30 : Number of days to backfill}
{--force : Force backfill even if data already exists}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Backfill user activity stats from existing user_downloads and user_requests data';
/**
* Execute the console command.
*/
public function handle(): int
{
$days = (int) $this->option('days');
$force = $this->option('force');
$this->info("Backfilling user activity stats for the last {$days} days...");
$startDate = Carbon::now()->subDays($days - 1)->startOfDay();
$progressBar = $this->output->createProgressBar($days);
$statsCollected = 0;
$statsSkipped = 0;
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
// Check if stats already exist for this date
if (! $force && UserActivityStat::where('stat_date', $date)->exists()) {
$statsSkipped++;
$progressBar->advance();
continue;
}
// Count downloads for the date
$downloadsCount = UserDownload::query()
->whereRaw('DATE(timestamp) = ?', [$date])
->count();
// Count API hits for the date
$apiHitsCount = UserRequest::query()
->whereRaw('DATE(timestamp) = ?', [$date])
->count();
// Store or update the stats
UserActivityStat::updateOrCreate(
['stat_date' => $date],
[
'downloads_count' => $downloadsCount,
'api_hits_count' => $apiHitsCount,
]
);
$statsCollected++;
$progressBar->advance();
}
$progressBar->finish();
$this->newLine(2);
$this->info('Backfill complete!');
$this->info("Stats collected: {$statsCollected}");
if ($statsSkipped > 0) {
$this->info("Stats skipped (already existed): {$statsSkipped}");
}
return Command::SUCCESS;
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class CaptchaStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'captcha:status';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check CAPTCHA configuration status';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->info('CAPTCHA Configuration Status');
$this->info('================================');
$this->newLine();
$provider = config('captcha.provider', 'recaptcha');
$this->line("Active Provider: <fg=cyan>{$provider}</>");
$this->newLine();
// Check reCAPTCHA
$this->info('Google reCAPTCHA:');
$recaptchaEnabled = config('captcha.recaptcha.enabled');
$recaptchaSitekey = config('captcha.recaptcha.sitekey');
$recaptchaSecret = config('captcha.recaptcha.secret');
$this->line(' Enabled: '.($recaptchaEnabled ? '<fg=green>Yes</>' : '<fg=red>No</>'));
$this->line(' Site Key: '.(! empty($recaptchaSitekey) ? '<fg=green>Configured</>' : '<fg=red>Missing</>'));
$this->line(' Secret: '.(! empty($recaptchaSecret) ? '<fg=green>Configured</>' : '<fg=red>Missing</>'));
$this->newLine();
// Check Turnstile
$this->info('Cloudflare Turnstile:');
$turnstileEnabled = config('captcha.turnstile.enabled');
$turnstileSitekey = config('captcha.turnstile.sitekey');
$turnstileSecret = config('captcha.turnstile.secret');
$this->line(' Enabled: '.($turnstileEnabled ? '<fg=green>Yes</>' : '<fg=red>No</>'));
$this->line(' Site Key: '.(! empty($turnstileSitekey) ? '<fg=green>Configured</>' : '<fg=red>Missing</>'));
$this->line(' Secret: '.(! empty($turnstileSecret) ? '<fg=green>Configured</>' : '<fg=red>Missing</>'));
$this->newLine();
// Validation
$recaptchaReady = $recaptchaEnabled && ! empty($recaptchaSitekey) && ! empty($recaptchaSecret);
$turnstileReady = $turnstileEnabled && ! empty($turnstileSitekey) && ! empty($turnstileSecret);
if ($recaptchaReady && $turnstileReady) {
$this->error('⚠ WARNING: Both providers are enabled!');
$this->warn('Only one CAPTCHA provider should be enabled at a time.');
$this->warn("The system will use: {$provider}");
$this->newLine();
}
if ($provider === 'recaptcha' && $recaptchaReady) {
$this->info('✓ reCAPTCHA is properly configured and active');
} elseif ($provider === 'turnstile' && $turnstileReady) {
$this->info('✓ Turnstile is properly configured and active');
} elseif ($provider === 'recaptcha' && ! $recaptchaReady) {
$this->error('✗ reCAPTCHA is selected but not properly configured');
} elseif ($provider === 'turnstile' && ! $turnstileReady) {
$this->error('✗ Turnstile is selected but not properly configured');
} else {
$this->warn('⚠ No CAPTCHA provider is active');
}
$this->newLine();
$this->comment('To change providers, update CAPTCHA_PROVIDER in your .env file');
$this->comment('Then run: php artisan config:clear');
return Command::SUCCESS;
}
}
+3
View File
@@ -7,6 +7,7 @@ use App\Models\GrabStat;
use App\Models\ReleaseStat;
use App\Models\RoleStat;
use App\Models\SignupStat;
use App\Models\UserActivityStat;
use Illuminate\Console\Command;
class CollectStats extends Command
@@ -41,6 +42,8 @@ class CollectStats extends Command
$this->info('New users by month collected.');
RoleStat::insertUsersByRole();
$this->info('Users by role collected.');
UserActivityStat::collectDailyStats();
$this->info('User activity stats collected.');
$this->info('Site stats collected.');
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Console\Commands;
use App\Services\SystemMetricsService;
use Illuminate\Console\Command;
class CollectSystemMetrics extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'metrics:collect {--cleanup : Clean up old metrics}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Collect and store current system metrics (CPU and RAM usage)';
protected SystemMetricsService $metricsService;
/**
* Create a new command instance.
*/
public function __construct(SystemMetricsService $metricsService)
{
parent::__construct();
$this->metricsService = $metricsService;
}
/**
* Execute the console command.
*/
public function handle(): int
{
try {
if ($this->option('cleanup')) {
$this->info('Cleaning up old metrics...');
$deleted = $this->metricsService->cleanupOldMetrics();
$this->info("Deleted {$deleted} old metric records.");
}
$this->info('Collecting system metrics...');
$this->metricsService->collectMetrics();
$this->info('System metrics collected successfully.');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('Failed to collect system metrics: '.$e->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Console\Commands;
use App\Models\Release;
use Blacklight\NameFixer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class FindSizeMismatchedReleases extends Command
{
protected $signature = 'nntmux:find-size-mismatches {--threshold=20} {--limit=100} {--season-pack} {--direction=any} {--rename}';
protected $description = 'Find releases where size differs significantly from release_files total. Use --direction=bigger|smaller|any';
public function handle()
{
$threshold = $this->option('threshold'); // Percentage difference threshold
$limit = $this->option('limit');
$checkSeasonPack = $this->option('season-pack');
$direction = $this->option('direction');
$shouldRename = $this->option('rename');
$nameFixer = new NameFixer;
$query = Release::query()
->select([
'releases.id',
'releases.searchname',
'releases.name',
'releases.groups_id',
'releases.categories_id',
DB::raw('releases.size / POW(1024, 3) as release_size'),
DB::raw('SUM(release_files.size) / POW(1024, 3) as files_total_size'),
DB::raw('(releases.size - SUM(release_files.size)) / POW(1024, 3) as size_diff'),
DB::raw('((releases.size - SUM(release_files.size)) / releases.size * 100) as diff_percent'),
])
->join('release_files', 'releases.id', '=', 'release_files.releases_id')
->where('releases.searchname', 'REGEXP', 'S[0-9]{1,3}E[0-9]{1,3}')
->groupBy('releases.id');
// Apply direction filter
if ($direction === 'bigger') {
$query->having('size_diff', '>', 0)
->having('diff_percent', '>', $threshold);
} elseif ($direction === 'smaller') {
$query->having('size_diff', '<', 0)
->having('diff_percent', '<', -$threshold);
} else {
$query->having(DB::raw('ABS(diff_percent)'), '>', $threshold);
}
// Order by ID if renaming, otherwise by diff_percent
$query->orderBy($shouldRename ? 'releases.id' : 'diff_percent', $shouldRename ? 'asc' : 'desc');
if ($limit > 0) {
$query->limit($limit);
}
$mismatches = $query->get();
if ($checkSeasonPack) {
$mismatches = $mismatches->filter(function ($release) use ($nameFixer) {
return $nameFixer->isSeasonPack($release->name);
});
}
if ($mismatches->isEmpty()) {
$this->info('No releases found with size mismatches above '.$threshold.'%'
.($checkSeasonPack ? ' that are season packs' : ''));
return;
}
if ($shouldRename) {
$this->info("\nAttempting to rename ".$mismatches->count()." releases...\n");
foreach ($mismatches as $release) {
$this->attemptRename($release, $nameFixer);
}
$this->outputReleaseIdsAsCsv($mismatches);
return;
}
// Regular table output for non-rename mode
$headers = ['Release ID', 'Searchname', 'Release Size', 'Files Total', 'Difference', 'Diff %'];
$rows = $mismatches->map(function ($release) {
return [
$release->id,
$release->searchname,
number_format($release->release_size, 2).' GiB',
number_format($release->files_total_size, 2).' GiB',
number_format($release->size_diff, 2).' GiB',
number_format($release->diff_percent, 2).'%',
];
});
$this->table($headers, $rows);
$this->info("\nFound ".$mismatches->count().' releases with size mismatches above '.$threshold.'%');
$this->outputReleaseIdsAsCsv($mismatches);
}
private function attemptRename(Release $release, NameFixer $nameFixer): ?string
{
if (preg_match(NameFixer::PREDB_REGEX, $this->stripDomainFromString($release->name), $matches)) {
$newName = $matches[1];
if ($newName) {
$nameFixer->updateRelease(
release: $release,
name: $newName,
method: 'size-mismatch / season pack',
echo: true,
type: '',
nameStatus: 1,
show: '1',
preId: 0
);
return $newName;
}
}
return null;
}
private function outputReleaseIdsAsCsv($mismatches): void
{
$releaseIds = $mismatches->pluck('id')->join(',');
$this->line("\nRelease IDs in CSV format:");
$this->line($releaseIds);
}
private function stripDomainFromString(string $str): string
{
return preg_replace("/www\.[^\s]+\.[a-z]{2,4}/i", '', $str);
}
}
+9 -3
View File
@@ -18,7 +18,8 @@ class ImportNzbs extends Command
{--folder= : Import folder path}
{--filename : Use filename true or false}
{--delete : Delete files after import}
{--delete-failed : Delete files after failed import}';
{--delete-failed : Delete files after failed import}
{--source= : Source of the NZB files}';
/**
* The console command description.
@@ -48,6 +49,11 @@ class ImportNzbs extends Command
} else {
$deleteFailedNZB = false;
}
if ($this->option('source')) {
$source = $this->option('source');
} else {
$source = 1;
}
$importFolder = $this->option('folder');
$folders = File::directories($importFolder);
if (empty($folders)) {
@@ -56,7 +62,7 @@ class ImportNzbs extends Command
$NZBImport = new NZBImport;
try {
$NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB);
$NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source);
} catch (FileNotFoundException $e) {
$this->error($e->getMessage());
}
@@ -67,7 +73,7 @@ class ImportNzbs extends Command
$NZBImport = new NZBImport;
try {
$NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB);
$NZBImport->beginImport($files, $useNzbName, $deleteNZB, $deleteFailedNZB, $source);
} catch (FileNotFoundException $e) {
$this->error($e->getMessage());
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
class NntmuxESReindex extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'nntmux:reindex_es';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Reindex elasticsearch releases and predb indexes';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*
* @return int
*/
public function handle()
{
passthru('php '.app()->/* @scrutinizer ignore-call */ path().'/../misc/elasticsearch/create_es_indexes.php');
passthru('php '.app()->/* @scrutinizer ignore-call */ path().'/../misc/elasticsearch/populate_es_indexes.php releases');
passthru('php '.app()->/* @scrutinizer ignore-call */ path().'/../misc/elasticsearch/populate_es_indexes.php predb');
}
}
+1
View File
@@ -78,6 +78,7 @@ class NntmuxResetDb extends Command
'audio_data',
'release_subtitles',
'video_data',
'media_infos',
'releases',
'anidb_titles',
'anidb_info',
@@ -51,6 +51,14 @@ class NntmuxResetPostProcessing extends Command
*/
public function handle(): void
{
// Allow resetting categories only if environment is local and category is 'misc'
if (app()->environment() !== 'local' && ((isset($this->option('category')['0']) && $this->option('category')[0] !== 'misc') || ! isset($this->option('category')['0']))) {
$this->error('This command can only be run in local environment');
return;
}
$raw = (array) $this->option('category');
if (empty($raw)) {
$qry = Release::query()->select(['id'])->get();
+122 -35
View File
@@ -213,20 +213,16 @@ if (! function_exists('runCmd')) {
}
if (! function_exists('escapeString')) {
/**
* @return string
*/
function escapeString($string)
function escapeString($string): string
{
return DB::connection()->getPdo()->quote($string);
}
}
if (! function_exists('realDuration')) {
/**
* @return string
*/
function realDuration($milliseconds)
function realDuration($milliseconds): string
{
$time = round($milliseconds / 1000);
@@ -236,45 +232,44 @@ if (! function_exists('realDuration')) {
if (! function_exists('is_it_json')) {
/**
* @param array|string $isIt
* @return bool
* @throws JsonException
*/
function is_it_json($isIt)
function is_it_json($isIt): bool
{
if (is_array($isIt)) {
return false;
}
json_decode($isIt, true);
json_decode($isIt, true, 512, JSON_THROW_ON_ERROR);
return json_last_error() === JSON_ERROR_NONE;
}
}
/**
* @throws Exception
*/
function getStreamingZip(array $guids = [])
{
$nzb = new NZB;
$zipped = ZipStream::create(now()->format('Ymdhis').'.zip');
foreach ($guids as $guid) {
$nzbPath = $nzb->NZBPath($guid);
if ($nzbPath) {
$nzbContents = Utility::unzipGzipFile($nzbPath);
if ($nzbContents) {
$filename = $guid;
$r = Release::query()->where('guid', $guid)->first();
if ($r !== null) {
$filename = $r->searchname;
if (! function_exists('getStreamingZip')) {
/**
* @throws Exception
*/
function getStreamingZip(array $guids = []): STS\ZipStream\Builder
{
$nzb = new NZB;
$zipped = ZipStream::create(now()->format('Ymdhis').'.zip');
foreach ($guids as $guid) {
$nzbPath = $nzb->NZBPath($guid);
if ($nzbPath) {
$nzbContents = Utility::unzipGzipFile($nzbPath);
if ($nzbContents) {
$filename = $guid;
$r = Release::query()->where('guid', $guid)->first();
if ($r) {
$filename = $r['searchname'];
}
$zipped->addRaw($nzbContents, $filename.'.nzb');
}
$zipped->addRaw($nzbContents, $filename.'.nzb');
}
}
}
return $zipped;
return $zipped;
}
}
if (! function_exists('release_flag')) {
@@ -282,9 +277,8 @@ if (! function_exists('release_flag')) {
/**
* @param string $text Text to match against.
* @param string $page Type of page. browse or search.
* @return bool|string
*/
function release_flag($text, $page)
function release_flag(string $text, string $page): bool|string
{
$code = $language = '';
@@ -547,3 +541,96 @@ if (! function_exists('csp_nonce')) {
return $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 = \Illuminate\Support\Carbon::parse($date, $appTimezone);
// If user is authenticated and has a timezone set, convert to it
if (\Illuminate\Support\Facades\Auth::check() && \Illuminate\Support\Facades\Auth::user()->timezone) {
$carbon->setTimezone(\Illuminate\Support\Facades\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 = \Illuminate\Support\Carbon::parse($date, $appTimezone);
// If user is authenticated and has a timezone set, convert to it
if (\Illuminate\Support\Facades\Auth::check() && \Illuminate\Support\Facades\Auth::user()->timezone) {
$carbon->setTimezone(\Illuminate\Support\Facades\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;
}
}
@@ -3,16 +3,20 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\BasePageController;
use App\Services\SystemMetricsService;
use App\Services\UserStatsService;
class AdminPageController extends BasePageController
{
protected UserStatsService $userStatsService;
public function __construct(UserStatsService $userStatsService)
protected SystemMetricsService $systemMetricsService;
public function __construct(UserStatsService $userStatsService, SystemMetricsService $systemMetricsService)
{
parent::__construct();
$this->userStatsService = $userStatsService;
$this->systemMetricsService = $systemMetricsService;
}
/**
@@ -25,8 +29,10 @@ class AdminPageController extends BasePageController
// Get user statistics
$userStats = [
'users_by_role' => $this->userStatsService->getUsersByRole(),
'downloads_per_day' => $this->userStatsService->getDownloadsPerDay(7),
'api_hits_per_day' => $this->userStatsService->getApiHitsPerDay(7),
'downloads_per_hour' => $this->userStatsService->getDownloadsPerHour(168), // Last 7 days in hours
'downloads_per_minute' => $this->userStatsService->getDownloadsPerMinute(60),
'api_hits_per_hour' => $this->userStatsService->getApiHitsPerHour(168), // Last 7 days in hours
'api_hits_per_minute' => $this->userStatsService->getApiHitsPerMinute(60),
'summary' => $this->userStatsService->getSummaryStats(),
'top_downloaders' => $this->userStatsService->getTopDownloaders(5),
];
@@ -36,6 +42,7 @@ class AdminPageController extends BasePageController
'meta_description' => 'Admin home page',
'userStats' => $userStats,
'stats' => $this->getDefaultStats(),
'systemMetrics' => $this->getSystemMetrics(),
]));
}
@@ -76,4 +83,274 @@ class AdminPageController extends BasePageController
return 'N/A';
}
}
/**
* Get system metrics (CPU and RAM usage)
*/
protected function getSystemMetrics(): array
{
$cpuUsage = $this->getCpuUsage();
$ramUsage = $this->getRamUsage();
$cpuInfo = $this->getCpuInfo();
$loadAverage = $this->getLoadAverage();
// Get historical data from database - both hourly (24h) and daily (30d)
$cpuHistory24h = $this->systemMetricsService->getHourlyMetrics('cpu', 24);
$cpuHistory30d = $this->systemMetricsService->getDailyMetrics('cpu', 30);
$ramHistory24h = $this->systemMetricsService->getHourlyMetrics('ram', 24);
$ramHistory30d = $this->systemMetricsService->getDailyMetrics('ram', 30);
return [
'cpu' => [
'current' => $cpuUsage,
'label' => 'CPU Usage',
'history_24h' => $cpuHistory24h,
'history_30d' => $cpuHistory30d,
'cores' => $cpuInfo['cores'],
'threads' => $cpuInfo['threads'],
'model' => $cpuInfo['model'],
'load_average' => $loadAverage,
],
'ram' => [
'used' => $ramUsage['used'],
'total' => $ramUsage['total'],
'percentage' => $ramUsage['percentage'],
'label' => 'RAM Usage',
'history_24h' => $ramHistory24h,
'history_30d' => $ramHistory30d,
],
];
}
/**
* Get current CPU usage percentage
*/
protected function getCpuUsage(): float
{
try {
if (PHP_OS_FAMILY === 'Windows') {
// Windows command
$output = shell_exec('wmic cpu get loadpercentage');
if ($output) {
preg_match('/\d+/', $output, $matches);
return $matches[0] ?? 0;
}
} else {
// Linux command - get load average and convert to percentage
$load = sys_getloadavg();
if ($load !== false) {
$cpuCount = $this->getCpuCount();
return round(($load[0] / $cpuCount) * 100, 2);
}
}
} catch (\Exception $e) {
\Log::warning('Could not get CPU usage: '.$e->getMessage());
}
return 0;
}
/**
* Get number of CPU cores
*/
protected function getCpuCount(): int
{
try {
if (PHP_OS_FAMILY === 'Windows') {
$output = shell_exec('wmic cpu get NumberOfLogicalProcessors');
if ($output) {
preg_match('/\d+/', $output, $matches);
return (int) ($matches[0] ?? 1);
}
} else {
$cpuinfo = file_get_contents('/proc/cpuinfo');
preg_match_all('/^processor/m', $cpuinfo, $matches);
return count($matches[0]) ?: 1;
}
} catch (\Exception $e) {
return 1;
}
return 1;
}
/**
* Get detailed CPU information (cores, threads, model)
*/
protected function getCpuInfo(): array
{
$info = [
'cores' => 0,
'threads' => 0,
'model' => 'Unknown',
];
try {
if (PHP_OS_FAMILY === 'Windows') {
// Get number of cores
$coresOutput = shell_exec('wmic cpu get NumberOfCores');
if ($coresOutput) {
preg_match('/\d+/', $coresOutput, $matches);
$info['cores'] = (int) ($matches[0] ?? 0);
}
// Get number of logical processors (threads)
$threadsOutput = shell_exec('wmic cpu get NumberOfLogicalProcessors');
if ($threadsOutput) {
preg_match('/\d+/', $threadsOutput, $matches);
$info['threads'] = (int) ($matches[0] ?? 0);
}
// Get CPU model
$modelOutput = shell_exec('wmic cpu get Name');
if ($modelOutput) {
$lines = explode("\n", trim($modelOutput));
if (isset($lines[1])) {
$info['model'] = trim($lines[1]);
}
}
} else {
// Linux
$cpuinfo = file_get_contents('/proc/cpuinfo');
// Get number of physical cores
preg_match_all('/^cpu cores\s*:\s*(\d+)/m', $cpuinfo, $coresMatches);
if (! empty($coresMatches[1])) {
$info['cores'] = (int) $coresMatches[1][0];
}
// Get number of logical processors (threads)
preg_match_all('/^processor/m', $cpuinfo, $processorMatches);
$info['threads'] = count($processorMatches[0]) ?: 0;
// Get CPU model
preg_match('/^model name\s*:\s*(.+)$/m', $cpuinfo, $modelMatches);
if (! empty($modelMatches[1])) {
$info['model'] = trim($modelMatches[1]);
}
// If cores is 0, try to get from physical id count
if ($info['cores'] === 0) {
preg_match_all('/^physical id\s*:\s*(\d+)/m', $cpuinfo, $physicalMatches);
$uniquePhysical = ! empty($physicalMatches[1]) ? count(array_unique($physicalMatches[1])) : 1;
$info['cores'] = (int) ($info['threads'] / $uniquePhysical);
}
}
} catch (\Exception $e) {
\Log::warning('Could not get CPU info: '.$e->getMessage());
}
return $info;
}
/**
* Get system load average
*/
protected function getLoadAverage(): array
{
$loadAvg = [
'1min' => 0,
'5min' => 0,
'15min' => 0,
];
try {
if (PHP_OS_FAMILY === 'Windows') {
// Windows doesn't have load average, use CPU queue length instead
$output = shell_exec('wmic path Win32_PerfFormattedData_PerfOS_System get ProcessorQueueLength');
if ($output) {
preg_match('/\d+/', $output, $matches);
$queueLength = (int) ($matches[0] ?? 0);
// Approximate load average
$loadAvg['1min'] = round($queueLength / 2, 2);
$loadAvg['5min'] = round($queueLength / 2, 2);
$loadAvg['15min'] = round($queueLength / 2, 2);
}
} else {
// Linux has native load average
$load = sys_getloadavg();
if ($load !== false) {
$loadAvg['1min'] = round($load[0], 2);
$loadAvg['5min'] = round($load[1], 2);
$loadAvg['15min'] = round($load[2], 2);
}
}
} catch (\Exception $e) {
\Log::warning('Could not get load average: '.$e->getMessage());
}
return $loadAvg;
}
/**
* Get RAM usage information
*/
protected function getRamUsage(): array
{
try {
if (PHP_OS_FAMILY === 'Windows') {
// Windows command
$output = shell_exec('wmic OS get FreePhysicalMemory,TotalVisibleMemorySize /Value');
if ($output) {
preg_match('/FreePhysicalMemory=(\d+)/', $output, $free);
preg_match('/TotalVisibleMemorySize=(\d+)/', $output, $total);
if (isset($free[1]) && isset($total[1])) {
$freeKb = (float) $free[1];
$totalKb = (float) $total[1];
$usedKb = $totalKb - $freeKb;
return [
'used' => round($usedKb / 1024 / 1024, 2),
'total' => round($totalKb / 1024 / 1024, 2),
'percentage' => round(($usedKb / $totalKb) * 100, 2),
];
}
}
} else {
// Linux command
$meminfo = file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $meminfo, $total);
preg_match('/MemAvailable:\s+(\d+)/', $meminfo, $available);
if (isset($total[1]) && isset($available[1])) {
$totalKb = (float) $total[1];
$availableKb = (float) $available[1];
$usedKb = $totalKb - $availableKb;
return [
'used' => round($usedKb / 1024 / 1024, 2),
'total' => round($totalKb / 1024 / 1024, 2),
'percentage' => round(($usedKb / $totalKb) * 100, 2),
];
}
}
} catch (\Exception $e) {
\Log::warning('Could not get RAM usage: '.$e->getMessage());
}
return [
'used' => 0,
'total' => 0,
'percentage' => 0,
];
}
/**
* Get minute-to-minute user activity data (API endpoint)
*/
public function getUserActivityMinutes()
{
$downloadsPerMinute = $this->userStatsService->getDownloadsPerMinute(60);
$apiHitsPerMinute = $this->userStatsService->getApiHitsPerMinute(60);
return response()->json([
'downloads' => $downloadsPerMinute,
'api_hits' => $apiHitsPerMinute,
]);
}
}
@@ -4,13 +4,11 @@ namespace App\Http\Controllers\Admin;
use App\Http\Controllers\BasePageController;
use App\Models\Category;
use App\Models\DownloadStat;
use App\Models\GrabStat;
use App\Models\ReleaseStat;
use App\Models\RoleStat;
use App\Models\Settings;
use App\Models\SignupStat;
use Blacklight\utility\Utility;
use Illuminate\Http\Request;
class AdminSiteController extends BasePageController
@@ -22,7 +20,6 @@ class AdminSiteController extends BasePageController
*/
public function edit(Request $request)
{
$this->setAdminPrefs();
$meta_title = $title = 'Site Edit';
$error = '';
@@ -35,41 +32,12 @@ class AdminSiteController extends BasePageController
if ($request->missing('book_reqids')) {
$request->merge(['book_reqids' => []]);
}
$ret = Settings::settingsUpdate($request->all());
if (\is_int($ret)) {
if ($ret === Settings::ERR_BADUNRARPATH) {
$error = 'The unrar path does not point to a valid binary';
} elseif ($ret === Settings::ERR_BADFFMPEGPATH) {
$error = 'The ffmpeg path does not point to a valid binary';
} elseif ($ret === Settings::ERR_BADMEDIAINFOPATH) {
$error = 'The mediainfo path does not point to a valid binary';
} elseif ($ret === Settings::ERR_BADNZBPATH) {
$error = 'The nzb path does not point to a valid directory';
} elseif ($ret === Settings::ERR_DEEPNOUNRAR) {
$error = 'Deep password check requires a valid path to unrar binary';
} elseif ($ret === Settings::ERR_BADTMPUNRARPATH) {
$error = 'The temp unrar path is not a valid directory';
} elseif ($ret === Settings::ERR_BADLAMEPATH) {
$error = 'The lame path is not a valid directory';
} elseif ($ret === Settings::ERR_SABCOMPLETEPATH) {
$error = 'The sab complete path is not a valid directory';
}
}
Settings::settingsUpdate($request->all());
if ($error === '') {
return redirect()->to('admin/site-edit')->with('success', 'Settings updated successfully');
}
return redirect()->to('admin/site-edit')->with('success', 'Settings updated successfully');
$site = (object) $request->all();
break;
case 'view':
default:
// Load all settings from database into an object
$allSettings = Settings::all();
$site = new \stdClass;
foreach ($allSettings as $setting) {
$site->{$setting->name} = $setting->value;
}
break;
}
@@ -88,51 +56,78 @@ class AdminSiteController extends BasePageController
$book_reqids_ids = array_map(fn ($value) => (int) $value, $book_reqids_ids);
// convert from a list to an array as we need to use an array, but the Settings table only saves strings
$books_selected = explode(',', Settings::settingValue('book_reqids'));
$bookReqidsValue = Settings::settingValue('book_reqids') ?? '';
$books_selected = $bookReqidsValue !== '' ? explode(',', $bookReqidsValue) : [];
// convert from a string array to an int array
$books_selected = array_map(fn ($value) => (int) $value, $books_selected);
// convert from a string array to an int array, filtering out empty values
$books_selected = array_map(fn ($value) => (int) trim($value), array_filter($books_selected));
$compress_headers_warning = ! str_contains(config('settings.nntp_server'), 'astra') ? 'compress_headers_warning' : '';
$this->viewData = array_merge($this->viewData, [
'site' => $site,
'settings' => Settings::toTree(),
'error' => $error,
'yesno_ids' => [1, 0],
'yesno_names' => ['Yes', 'No'],
'passwd_ids' => [1, 0],
'passwd_names' => ['Deep (requires unrar)', 'None'],
'langlist_ids' => [0, 2, 3, 1],
'langlist_names' => ['English', 'Danish', 'French', 'German'],
'imdblang_ids' => ['en', 'da', 'nl', 'fi', 'fr', 'de', 'it', 'tlh', 'no', 'po', 'ru', 'es', 'sv'],
'imdblang_names' => ['English', 'Danish', 'Dutch', 'Finnish', 'French', 'German', 'Italian', 'Klingon', 'Norwegian', 'Polish', 'Russian', 'Spanish', 'Swedish'],
'yesno' => [
'ids' => [1, 0],
'names' => ['Yes', 'No'],
],
'passwd' => [
'ids' => [1, 0],
'names' => ['Deep (requires unrar)', 'None'],
],
'langlist' => [
'ids' => [0, 2, 3, 1],
'names' => ['English', 'Danish', 'French', 'German'],
],
'imdblang' => [
'ids' => ['en', 'da', 'nl', 'fi', 'fr', 'de', 'it', 'tlh', 'no', 'po', 'ru', 'es', 'sv'],
'names' => ['English', 'Danish', 'Dutch', 'Finnish', 'French', 'German', 'Italian', 'Klingon', 'Norwegian', 'Polish', 'Russian', 'Spanish', 'Swedish'],
],
'newgroupscan_names' => ['Days', 'Posts'],
'registerstatus_ids' => [Settings::REGISTER_STATUS_OPEN, Settings::REGISTER_STATUS_INVITE, Settings::REGISTER_STATUS_CLOSED],
'registerstatus_names' => ['Open', 'Invite', 'Closed'],
'passworded_ids' => [0, 1],
'passworded_names' => ['Hide passworded', 'Show everything'],
'lookuplanguage_iso' => ['en', 'de', 'es', 'fr', 'it', 'nl', 'pt', 'sv'],
'lookuplanguage_names' => ['English', 'Deutsch', 'Español', 'Français', 'Italiano', 'Nederlands', 'Português', 'Svenska'],
'imdb_urls' => [0, 1],
'imdburl_names' => ['imdb.com', 'akas.imdb.com'],
'lookupbooks_ids' => [0, 1, 2],
'lookupbooks_names' => ['Disabled', 'Lookup All Books', 'Lookup Renamed Books'],
'lookupgames_ids' => [0, 1, 2],
'lookupgames_names' => ['Disabled', 'Lookup All Consoles', 'Lookup Renamed Consoles'],
'lookupmusic_ids' => [0, 1, 2],
'lookupmusic_names' => ['Disabled', 'Lookup All Music', 'Lookup Renamed Music'],
'lookupmovies_ids' => [0, 1, 2],
'lookupmovies_names' => ['Disabled', 'Lookup All Movies', 'Lookup Renamed Movies'],
'lookuptv_ids' => [0, 1, 2],
'lookuptv_names' => ['Disabled', 'Lookup All TV', 'Lookup Renamed TV'],
'lookup_reqids_ids' => [0, 1, 2],
'lookup_reqids_names' => ['Disabled', 'Lookup Request IDs', 'Lookup Request IDs Threaded'],
'coversPath' => config('nntmux_settings.covers_path'),
'book_reqids_ids' => $book_reqids_ids,
'book_reqids_names' => $book_reqids_names,
'book_reqids_selected' => $books_selected,
'themelist' => Utility::getThemesList(),
'registerstatus' => [
'ids' => [Settings::REGISTER_STATUS_OPEN, Settings::REGISTER_STATUS_INVITE, Settings::REGISTER_STATUS_CLOSED],
'names' => ['Open', 'Invite', 'Closed'],
],
'passworded' => [
'ids' => [0, 1],
'names' => ['Hide passworded', 'Show everything'],
],
'lookuplanguage' => [
'iso' => ['en', 'de', 'es', 'fr', 'it', 'nl', 'pt', 'sv'],
'names' => ['English', 'Deutsch', 'Español', 'Français', 'Italiano', 'Nederlands', 'Português', 'Svenska'],
],
'imdb_urls' => [
'ids' => [0, 1],
'names' => ['imdb.com', 'akas.imdb.com'],
],
'lookupbooks' => [
'ids' => [0, 1, 2],
'names' => ['Disabled', 'Lookup All Books', 'Lookup Renamed Books'],
],
'lookupgames' => [
'ids' => [0, 1, 2],
'names' => ['Disabled', 'Lookup All Consoles', 'Lookup Renamed Consoles'],
],
'lookupmusic' => [
'ids' => [0, 1, 2],
'names' => ['Disabled', 'Lookup All Music', 'Lookup Renamed Music'],
],
'lookupmovies' => [
'ids' => [0, 1, 2],
'names' => ['Disabled', 'Lookup All Movies', 'Lookup Renamed Movies'],
],
'lookuptv' => [
'ids' => [0, 1, 2],
'names' => ['Disabled', 'Lookup All TV', 'Lookup Renamed TV'],
],
'lookup_reqids' => [
'ids' => [0, 1, 2],
'names' => ['Disabled', 'Lookup Request IDs', 'Lookup Request IDs Threaded'],
],
'book_reqids' => [
'ids' => $book_reqids_ids,
'names' => $book_reqids_names,
'selected' => $books_selected,
],
'compress_headers_warning' => $compress_headers_warning,
'title' => $title,
'meta_title' => $meta_title,
@@ -146,19 +141,16 @@ class AdminSiteController extends BasePageController
*/
public function stats()
{
$this->setAdminPrefs();
$meta_title = $title = 'Site Stats';
$topGrabs = GrabStat::getTopGrabbers();
$topDownloads = DownloadStat::getTopDownloads();
$recent = ReleaseStat::getRecentlyAdded();
$usersByMonth = SignupStat::getUsersByMonth();
$usersByRole = RoleStat::getUsersByRole();
$this->viewData = array_merge($this->viewData, [
'topgrabs' => $topGrabs,
'topdownloads' => $topDownloads,
'recent' => $recent,
'usersbymonth' => $usersByMonth,
'usersbyrole' => $usersByRole,
@@ -3,7 +3,6 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\BasePageController;
use App\Jobs\SendAccountChangedEmail;
use App\Models\Invitation;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
@@ -153,12 +152,18 @@ class AdminUserController extends BasePageController
if ($request->input('password') !== null) {
User::updatePassword($editedUser->id, $request->input('password'));
}
if ($request->input('rolechangedate') !== null) {
User::updateUserRoleChangeDate($editedUser->id, $request->input('rolechangedate'));
// Handle rolechangedate - update if has value, clear if empty
if ($request->has('rolechangedate')) {
$roleChangeDate = $request->input('rolechangedate');
if (! empty($roleChangeDate)) {
User::updateUserRoleChangeDate($editedUser->id, $roleChangeDate);
} else {
// Clear the rolechangedate if empty string is provided
$editedUser->update(['rolechangedate' => null]);
}
}
if ($request->input('role') !== null) {
$editedUser->refresh();
SendAccountChangedEmail::dispatch($editedUser)->onQueue('emails');
}
}
+64 -6
View File
@@ -12,6 +12,7 @@ use App\Models\UsenetGroup;
use App\Models\User;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Blacklight\NZB;
use Blacklight\Releases;
use Blacklight\utility\Utility;
use Illuminate\Contracts\Foundation\Application;
@@ -19,6 +20,8 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -66,6 +69,9 @@ class ApiController extends BasePageController
case 'info':
$function = 'n';
break;
case 'nzbadd':
$function = 'nzbAdd';
break;
default:
return Utility::showApiError(202, 'No such function ('.$request->input('t').')');
}
@@ -82,12 +88,12 @@ class ApiController extends BasePageController
if ($function !== 'c' && $function !== 'r') {
if ($request->missing('apikey') || ($request->has('apikey') && empty($request->input('apikey')))) {
return Utility::showApiError(200, 'Missing parameter (apikey)');
} else {
$apiKey = $request->input('apikey');
$res = User::getByRssToken($apiKey);
if ($res === null) {
return Utility::showApiError(100, 'Incorrect user credentials (wrong API key)');
}
}
$apiKey = $request->input('apikey');
$res = User::getByRssToken($apiKey);
if ($res === null) {
return Utility::showApiError(100, 'Incorrect user credentials (wrong API key)');
}
if ($res->hasRole('Disabled')) {
@@ -314,6 +320,58 @@ class ApiController extends BasePageController
} else {
return Utility::showApiError(300, 'Release does not exist.');
}
break;
//
// nzb add request
// curl -X POST -F "file=@./The.File.nzb" "https://www.tabula-rasa.pw/api/V1/api?t=nzbadd&apikey=xxx"
//
case 'nzbAdd':
if (! User::canPost($uid)) {
return response('User does not have permission to post', 403);
}
if ($request->missing('file')) {
return response('Missing parameter (file is required for adding an NZB)', 400);
}
if ($request->missing('apikey')) {
return response('Missing parameter (apikey is required for adding an NZB)', 400);
}
if (! $request->hasFile('file')) {
return response('Missing parameter (file is required for adding an NZB)', 400);
}
UserRequest::addApiRequest($apiKey, $request->getRequestUri());
$nzbFile = $request->file('file');
// Save the file to the server, get the name without the extension.
if (File::isFile($nzbFile)) {
// We need to check if file is an actual nzb file.
if ($nzbFile->getClientOriginalExtension() !== 'nzb') {
return response('File is not an NZB file', 400);
}
// Check if the file is proper xml nzb file.
if (! Utility::isValidNewznabNzb($nzbFile->getContent())) {
return response('File is not a valid Newznab NZB file', 400);
}
if (! File::isDirectory(config('nntmux.nzb_upload_folder'))) {
@File::makeDirectory(config('nntmux.nzb_upload_folder'), 0775, true);
}
if (File::put(config('nntmux.nzb_upload_folder').$nzbFile->getClientOriginalName(), $nzbFile->getContent())) {
Log::channel('nzb_upload')->info('NZB file uploaded by API: '.$nzbFile->getClientOriginalName());
return response('NZB file uploaded successfully', 200);
}
Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName());
} else {
Log::channel('nzb_upload')->warning('NZB file uploaded by API failed: '.$nzbFile->getClientOriginalName());
return response('NZB file upload failed', 500);
}
break;
// Capabilities request.
+1
View File
@@ -98,6 +98,7 @@ class RSS extends ApiController
public function getShowsRss(int $limit, int $userID = 0, array $excludedCats = [], int $airDate = -1)
{
$sql = sprintf(
"SELECT DISTINCT r.searchname, r.guid, r.postdate, r.categories_id, r.size, r.totalpart, r.fromname, r.passwordstatus, r.grabs, r.comments, r.adddate, r.videos_id, r.tv_episodes_id, v.id, v.title, g.name AS group_name, CONCAT(cp.title, '-', c.title) AS category_name, COALESCE(cp.id,0) AS parentid FROM releases r INNER JOIN user_series us ON us.videos_id = r.videos_id AND us.users_id = %d LEFT JOIN categories c ON c.id = r.categories_id INNER JOIN root_categories cp ON cp.id = c.root_categories_id LEFT JOIN usenet_groups g ON g.id = r.groups_id LEFT OUTER JOIN videos v ON v.id = r.videos_id LEFT OUTER JOIN tv_episodes tve ON tve.id = r.tv_episodes_id WHERE (us.categories IS NULL OR us.categories = '' OR us.categories = 'NULL' OR FIND_IN_SET(r.categories_id, REPLACE(us.categories,'|',',')) > 0)%s%s AND r.categories_id BETWEEN %d AND %d AND r.passwordstatus %s ORDER BY postdate DESC %s",
$userID,
(\count($excludedCats) ? ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')' : ''),
@@ -440,7 +440,6 @@ class XML_Response
if ((int) $this->parameters['extended'] === 1) {
$this->writeZedAttr('files', $this->release->totalpart);
$this->writeZedAttr('poster', $this->release->fromname);
if (($this->release->videos_id > 0 || $this->release->tv_episodes_id > 0) && $this->namespace === 'newznab') {
$this->setTvAttr();
}
+11 -6
View File
@@ -12,7 +12,7 @@ use Illuminate\View\View;
class BasePageController extends Controller
{
public Settings $settings;
public \Illuminate\Support\Collection $settings;
public string $title = '';
@@ -48,17 +48,22 @@ class BasePageController extends Controller
*/
public function __construct()
{
$this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'release', 'reset', 'showLinkRequestForm');
$this->middleware(['auth', 'web', '2fa'])->except('api', 'contact', 'showContactForm', 'callback', 'btcPayCallback', 'getNzb', 'terms', 'privacyPolicy', 'capabilities', 'movie', 'apiSearch', 'tv', 'details', 'failed', 'showRssDesc', 'fullFeedRss', 'categoryFeedRss', 'cartRss', 'myMoviesRss', 'myShowsRss', 'release', 'reset', 'showLinkRequestForm');
// Buffer settings/DB connection.
$this->settings = new Settings;
// Load settings as collection
$this->settings = Settings::query()->pluck('value', 'name');
// Initialize view data
// Initialize view data FIRST with serverroot
$this->viewData = [
'serverroot' => url('/'),
'site' => $this->settings,
];
// Then add the converted settings array as 'site'
// Using array assignment instead of constructor assignment to ensure it persists
$this->viewData['site'] = $this->settings->map(function ($value) {
return Settings::convertValue($value);
})->all();
// Initialize userdata property for controllers that need it
$this->middleware(function ($request, $next) {
if (Auth::check()) {
+11 -2
View File
@@ -74,6 +74,7 @@ class BrowseController extends BasePageController
$results = $this->paginate($rslt ?? [], $rslt[0]->_totalcount ?? 0, config('nntmux.items_per_page'), $page, $request->url(), $request->query());
$covgroup = '';
$shows = false;
if ($category === -1 && $grp === -1) {
$catname = 'All';
} elseif ($category !== -1 && $grp === -1) {
@@ -92,6 +93,8 @@ class BrowseController extends BasePageController
$covgroup = 'music';
} elseif ($cdata->root_categories_id === Category::BOOKS_ROOT) {
$covgroup = 'books';
} elseif ($cdata->root_categories_id === Category::TV_ROOT) {
$shows = true;
}
}
} else {
@@ -112,7 +115,7 @@ class BrowseController extends BasePageController
}
}
$this->viewData = array_merge($this->viewData, [
$viewData = [
'parentcat' => ucfirst($parentCategory),
'category' => $category,
'catname' => $catname,
@@ -122,7 +125,13 @@ class BrowseController extends BasePageController
'meta_title' => $meta_title,
'meta_keywords' => 'browse,nzb,description,details',
'meta_description' => 'Browse for Nzbs',
], $orderByUrls);
];
if ($shows) {
$viewData['shows'] = true;
}
$this->viewData = array_merge($this->viewData, $viewData, $orderByUrls);
return view('browse.index', $this->viewData);
}
@@ -17,7 +17,7 @@ class BtcPaymentController extends BasePageController
{
$hashCheck = 'sha256='.hash_hmac('sha256', $request->getContent(), config('nntmux.btcpay_webhook_secret'));
if ($hashCheck !== $request->header('btcpay-sig')) {
Log::error('BTCPay webhook hash check failed: '.$request->header('btcpay-sig'));
Log::channel('btc_payment')->error('BTCPay webhook hash check failed: '.$request->header('btcpay-sig'));
return response('Not Found', 404);
}
@@ -28,7 +28,7 @@ class BtcPaymentController extends BasePageController
if ($user) {
$checkOrder = Payment::query()->where('invoice_id', '=', $payload['invoiceId'])->where('payment_status', '=', 'Settled')->first();
if ($checkOrder !== null) {
Log::error('Duplicate BTCPay webhook: '.$payload['webhookId']);
Log::channel('btc_payment')->error('Duplicate BTCPay webhook: '.$payload['webhookId']);
return response('OK', 200);
}
@@ -50,7 +50,7 @@ class BtcPaymentController extends BasePageController
return response('OK', 200);
}
Log::error('User not found for BTCPay webhook: '.$payload['metadata']['buyerEmail']);
Log::channel('btc_payment')->error('User not found for BTCPay webhook: '.$payload['metadata']['buyerEmail']);
return response('Not Found', 404);
}
@@ -67,12 +67,12 @@ class BtcPaymentController extends BasePageController
User::updateUserRole($user->id, $matches['role']);
User::updateUserRoleChangeDate($user->id, null, $matches['addYears']);
$checkOrder->update(['invoice_status' => 'Settled']);
Log::info('User: '.$user->username.' upgraded to '.$matches['role'].' for BTCPay webhook: '.$checkOrder->webhook_id);
Log::channel('btc_payment')->info('User: '.$user->username.' upgraded to '.$matches['role'].' for BTCPay webhook: '.$checkOrder->webhook_id);
return response('OK', 200);
}
Log::error('User not found for BTCPay webhook: '.$checkOrder->webhook_id);
Log::channel('btc_payment')->error('User not found for BTCPay webhook: '.$checkOrder->webhook_id);
return response('Not Found', 404);
}
+4 -5
View File
@@ -12,10 +12,8 @@ class ContactUsController extends BasePageController
*/
public function contact(ContactContactURequest $request)
{
if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) {
$this->validate($request, [
'g-recaptcha-response' => 'required|captcha',
]);
if (\App\Support\CaptchaHelper::isEnabled()) {
$this->validate($request, \App\Support\CaptchaHelper::getValidationRules());
}
$msg = '';
@@ -25,8 +23,9 @@ class ContactUsController extends BasePageController
$mailTo = config('mail.from.address');
$mailBody = 'Values submitted from contact form: ';
$captchaFieldName = \App\Support\CaptchaHelper::getResponseFieldName();
foreach ($request->all() as $key => $value) {
if ($key !== 'submit' && $key !== '_token' && $key !== 'g-recaptcha-response') {
if ($key !== 'submit' && $key !== '_token' && $key !== 'g-recaptcha-response' && $key !== 'cf-turnstile-response') {
$mailBody .= "$key : $value".PHP_EOL;
}
}
+40 -17
View File
@@ -10,10 +10,13 @@ use Blacklight\NZB;
use Blacklight\utility\Utility;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
class GetNzbController extends BasePageController
{
/**
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Foundation\Application|\Illuminate\Http\JsonResponse|\Illuminate\Http\Response|\STS\ZipStream\ZipStream|\Symfony\Component\HttpFoundation\StreamedResponse
*
* @throws \Exception
*/
public function getNzb(Request $request, $guid = null)
@@ -27,6 +30,7 @@ class GetNzbController extends BasePageController
// Page is accessible only by the rss token, or logged in users.
if ($request->user()) {
$uid = $this->userdata->id;
$userName = $this->userdata->username;
$maxDownloads = $this->userdata->role->downloadrequests;
$rssToken = $this->userdata->api_token;
if ($this->userdata->hasRole('Disabled')) {
@@ -43,6 +47,7 @@ class GetNzbController extends BasePageController
}
$uid = $res['id'];
$userName = $res->username;
$rssToken = $res['api_token'];
$maxDownloads = $res->role->downloadrequests;
if ($res->hasRole('Disabled')) {
@@ -82,6 +87,8 @@ class GetNzbController extends BasePageController
}
}
Log::channel('zipped')->info('User '.$userName.' downloaded zipped files from site with IP: '.$request->ip());
return $zip;
}
@@ -127,27 +134,43 @@ class GetNzbController extends BasePageController
$headers['X-DNZB-NFO'] = url('/nfo/'.$request->input('id'));
}
$headers['X-DNZB-RCode'] = '200';
$headers['X-DNZB-RText'] = 'OK, NZB content follows.';
$headers += ['X-DNZB-RCode' => '200',
'X-DNZB-RText' => 'OK, NZB content follows.', ];
// Raising this value may increase performance
$buffer_size = 1000000;
$bytes = '696';
// Open our file (in binary mode)
$zd = gzopen($nzbPath, 'rb');
$insert = ' <file poster="_ZWsSmvpc3Cl@HmhGEn.16m'.$uid.'" date="'.now()->timestamp.'" subject="&quot;release.nfo&quot; yEnc (1/1) 546">
<groups>
<group>alt.binaries.test</group>
</groups>
<segments>
<segment bytes="'.$bytes.'" number="1">o9AgxKI0_40wnnFGymLkxrDxt@EggKOp4.48R</segment>
</segments>
</file>';
$final = '';
// Keep repeating until the end of the input file
while (! gzeof($zd)) {
// Read buffer-size bytes
$contents = gzread($zd, $buffer_size);
$tmp = preg_replace('/file poster=\"/i', 'file poster="'.$uid.'-', $contents, 10);
$final .= $tmp;
}
$final = preg_replace('/<\/nzb>/i', $insert.PHP_EOL.'</nzb>'.PHP_EOL, $final, 1);
gzclose($zd);
// Sanitize file name
$cleanName = str_replace([',', ' ', '/', '\\'], '_', $relData['searchname']);
// Stream the file content
return response()->streamDownload(function () use ($nzbPath) {
$bufferSize = 1000000; // 1 MB chunks
$gz = gzopen($nzbPath, 'rb');
if (! $gz) {
throw new RuntimeException('Failed to open gzipped file for streaming.');
}
while (! gzeof($gz)) {
echo gzread($gz, $bufferSize);
flush(); // Ensure chunks are sent immediately
}
gzclose($gz);
return response()->streamDownload(function () use ($final) {
echo $final;
}, $cleanName.'.nzb', $headers);
}
}
+89
View File
@@ -78,6 +78,7 @@ class MovieController extends BasePageController
'meta_title' => 'Browse Movies',
'meta_keywords' => 'browse,nzb,description,details',
'meta_description' => 'Browse for Movies',
'movie_layout' => $this->userdata->movie_layout ?? 2,
]);
// Return the appropriate view
@@ -215,4 +216,92 @@ class MovieController extends BasePageController
return response()->json(['message' => 'Invalid movie ID.'], 400);
}
/**
* Show trending movies (top 15 most downloaded in last 48 hours)
*
* @throws \Exception
*/
public function showTrending(Request $request)
{
$movie = new Movie(['Settings' => $this->settings]);
// Cache key for trending movies (48 hours)
$cacheKey = 'trending_movies_top_15_48h';
// Get trending movies from cache or calculate (refresh every hour)
$trendingMovies = \Illuminate\Support\Facades\Cache::remember($cacheKey, 3600, function () {
// Calculate timestamp for 48 hours ago
$fortyEightHoursAgo = \Illuminate\Support\Carbon::now()->subHours(48);
// Get movies with their download counts from last 48 hours
// Join with user_downloads to get actual download timestamps
$query = \Illuminate\Support\Facades\DB::table('movieinfo as m')
->join('releases as r', 'm.imdbid', '=', 'r.imdbid')
->leftJoin('user_downloads as ud', 'r.id', '=', 'ud.releases_id')
->select([
'm.imdbid',
'm.title',
'm.year',
'm.rating',
'm.plot',
'm.genre',
'm.cover',
'm.tmdbid',
'm.traktid',
\Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT ud.id) as total_downloads'),
\Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT r.id) as release_count'),
])
->where('m.title', '!=', '')
->where('m.imdbid', '!=', '0000000')
->where('ud.timestamp', '>=', $fortyEightHoursAgo)
->groupBy('m.imdbid', 'm.title', 'm.year', 'm.rating', 'm.plot', 'm.genre', 'm.cover', 'm.tmdbid', 'm.traktid')
->havingRaw('COUNT(DISTINCT ud.id) > 0')
->orderByDesc('total_downloads')
->limit(15)
->get();
// Process the results
return $query->map(function ($item) {
// Add cover image URL using helper function
$coverArray = [
'imdbid' => $item->imdbid,
'tmdbid' => $item->tmdbid,
'cover' => $item->cover,
];
$item->cover = getReleaseCover($coverArray);
return $item;
});
});
$this->viewData = array_merge($this->viewData, [
'trendingMovies' => $trendingMovies,
'meta_title' => 'Trending Movies - Last 48 Hours',
'meta_keywords' => 'trending,movies,popular,downloads,recent',
'meta_description' => 'Browse the most popular and downloaded movies in the last 48 hours',
]);
return view('movies.trending', $this->viewData);
}
/**
* Update user's movie layout preference
*/
public function updateLayout(Request $request)
{
$request->validate([
'layout' => 'required|integer|in:1,2',
]);
$user = auth()->user();
if ($user) {
$user->movie_layout = (int) $request->input('layout');
$user->save();
return response()->json(['success' => true, 'layout' => $user->movie_layout]);
}
return response()->json(['success' => false, 'message' => 'User not authenticated'], 401);
}
}
@@ -74,20 +74,12 @@ class PasswordSecurityController extends Controller
$user->passwordSecurity->google2fa_enable = 1;
$user->passwordSecurity->save();
// Check if we should redirect to profile page
if ($request->has('redirect_to_profile')) {
return redirect()->to('profileedit#security')->with('success_2fa', '2FA is Enabled Successfully.');
}
return redirect()->to('2fa')->with('success', '2FA is Enabled Successfully.');
// Always redirect to profile page after enabling 2FA
return redirect()->to('profileedit#security')->with('success_2fa', '2FA is Enabled Successfully.');
}
// Check if we should redirect to profile page on failure as well
if ($request->has('redirect_to_profile')) {
return redirect()->to('profileedit#security')->with('error_2fa', 'Invalid Verification Code, Please try again.');
}
return redirect()->to('2fa')->with('error', 'Invalid Verification Code, Please try again.');
// Always redirect to profile page on failure as well
return redirect()->to('profileedit#security')->with('error_2fa', 'Invalid Verification Code, Please try again.');
}
public function cancelSetup(Request $request): RedirectResponse
@@ -107,25 +99,20 @@ class PasswordSecurityController extends Controller
public function disable2fa(Disable2faPasswordSecurityRequest $request): \Illuminate\Routing\Redirector|RedirectResponse|\Illuminate\Contracts\Foundation\Application
{
if (! (Hash::check($request->get('current-password'), $request->user()->password))) {
// Password doesn't match
if ($request->has('redirect_to_profile') || $request->has('from_profile')) {
return redirect()->to('profileedit#security')->with('error_2fa', 'Your password does not match with your account password. Please try again.');
}
return redirect()->back()->with('error', 'Your password does not match with your account password. Please try again.');
// Password doesn't match - always redirect to profile page with error
return redirect()->to('profileedit#security')->with('error_2fa', 'Your password does not match with your account password. Please try again.');
}
$validatedData = $request->validated();
$user = $request->user();
$user->passwordSecurity->google2fa_enable = 0;
$user->passwordSecurity->save();
// Check if this request is from the profile edit page
if ($request->has('redirect_to_profile') || $request->has('from_profile')) {
return redirect()->to('profileedit#security')->with('success_2fa', '2FA is now Disabled.');
// Delete the password security record entirely to fully disable 2FA
if ($user->passwordSecurity) {
$user->passwordSecurity->delete();
}
return redirect()->to('2fa')->with('success', '2FA is now Disabled.');
// Always redirect to profile page after disabling 2FA
return redirect()->to('profileedit#security')->with('success_2fa', '2FA is now Disabled.');
}
/**
@@ -256,17 +243,7 @@ class PasswordSecurityController extends Controller
->withErrors(['msg' => 'User not found. Please login again.']);
}
$theme = 'Gentele';
$meta_title = 'Two Factor Authentication';
$meta_keywords = 'Two Factor Authentication, 2FA';
$meta_description = 'Two Factor Authentication Verification';
app('smarty.view')->assign(compact('meta_title', 'meta_keywords', 'meta_description', 'user'));
// Create a response with the rendered content instead of directly outputting
$content = app('smarty.view')->fetch($theme.'/2fa_verify.tpl');
return response($content);
return view('auth.2fa_verify', compact('user'));
}
/**
@@ -150,6 +150,15 @@ class ProfileController extends BasePageController
}
}
// Update timezone preference
if ($request->has('timezone')) {
$timezoneValue = $request->input('timezone');
$validTimezones = array_merge(['UTC'], ...array_values(getAvailableTimezones()));
if (in_array($timezoneValue, $validTimezones)) {
User::where('id', $userid)->update(['timezone' => $timezoneValue]);
}
}
// Handle Console permission
if ($request->has('viewconsole')) {
if (! $this->userdata->hasDirectPermission('view console')) {
+26 -1
View File
@@ -103,12 +103,37 @@ class SearchController extends BasePageController
$searchVars[$searchVarKey] = ($request->has($searchVarKey) ? (string) $request->input($searchVarKey) : '');
}
// Map new form field names to old internal names
if ($request->has('minage')) {
$searchVars['searchadvdaysnew'] = (string) $request->input('minage');
}
if ($request->has('maxage')) {
$searchVars['searchadvdaysold'] = (string) $request->input('maxage');
}
if ($request->has('group')) {
$searchVars['searchadvgroups'] = (string) $request->input('group');
}
if ($request->has('minsize')) {
$searchVars['searchadvsizefrom'] = (string) $request->input('minsize');
}
if ($request->has('maxsize')) {
$searchVars['searchadvsizeto'] = (string) $request->input('maxsize');
}
// Map basic search field to advanced search when in advanced mode
if ($request->has('search') && $searchType === 'advanced') {
$searchVars['searchadvr'] = (string) $request->input('search');
}
// Map basic category field to advanced category when in advanced mode
if ($request->has('t') && $searchType === 'advanced') {
$searchVars['searchadvcat'] = (string) $request->input('t');
}
$searchVars['selectedgroup'] = $searchVars['searchadvgroups'];
$searchVars['selectedcat'] = $searchVars['searchadvcat'];
$searchVars['selectedsizefrom'] = $searchVars['searchadvsizefrom'];
$searchVars['selectedsizeto'] = $searchVars['searchadvsizeto'];
if ($searchType !== 'basic' && $request->missing('id') && $request->missing('subject') && $request->anyFilled(['searchadvr', 'searchadvsubject', 'searchadvfilename', 'searchadvposter'])) {
if ($searchType !== 'basic' && $request->missing('id') && $request->missing('subject') && $request->anyFilled(['searchadvr', 'searchadvsubject', 'searchadvfilename', 'searchadvposter', 'minage', 'maxage', 'group', 'minsize', 'maxsize', 'search'])) {
$orderByString = '';
foreach ($searchVars as $searchVarKey => $searchVar) {
$orderByString .= "&$searchVarKey=".htmlentities($searchVar, ENT_QUOTES | ENT_HTML5);
+57
View File
@@ -175,4 +175,61 @@ class SeriesController extends BasePageController
return view('series.viewserieslist', $this->viewData);
}
}
/**
* Show trending TV shows (top 15 most downloaded in last 48 hours)
*
* @throws \Exception
*/
public function showTrending(Request $request)
{
// Cache key for trending TV shows (48 hours)
$cacheKey = 'trending_tv_top_15_48h';
// Get trending TV shows from cache or calculate (refresh every hour)
$trendingShows = \Illuminate\Support\Facades\Cache::remember($cacheKey, 3600, function () {
// Calculate timestamp for 48 hours ago
$fortyEightHoursAgo = \Illuminate\Support\Carbon::now()->subHours(48);
// Get TV shows with their download counts from last 48 hours
// Join with user_downloads to get actual download timestamps
$query = \Illuminate\Support\Facades\DB::table('videos as v')
->join('tv_info as ti', 'v.id', '=', 'ti.videos_id')
->join('releases as r', 'v.id', '=', 'r.videos_id')
->leftJoin('user_downloads as ud', 'r.id', '=', 'ud.releases_id')
->select([
'v.id',
'v.title',
'v.started',
'v.tvdb',
'v.tvmaze',
'v.trakt',
'v.tmdb',
'v.countries_id',
'ti.summary',
'ti.image',
\Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT ud.id) as total_downloads'),
\Illuminate\Support\Facades\DB::raw('COUNT(DISTINCT r.id) as release_count'),
])
->where('v.type', 0) // 0 = TV
->where('v.title', '!=', '')
->where('ud.timestamp', '>=', $fortyEightHoursAgo)
->groupBy('v.id', 'v.title', 'v.started', 'v.tvdb', 'v.tvmaze', 'v.trakt', 'v.tmdb', 'v.countries_id', 'ti.summary', 'ti.image')
->havingRaw('COUNT(DISTINCT ud.id) > 0')
->orderByDesc('total_downloads')
->limit(15)
->get();
return $query;
});
$this->viewData = array_merge($this->viewData, [
'trendingShows' => $trendingShows,
'meta_title' => 'Trending TV Shows - Last 48 Hours',
'meta_keywords' => 'trending,tv,shows,series,popular,downloads,recent',
'meta_description' => 'Browse the most popular and downloaded TV shows in the last 48 hours',
]);
return view('series.trending', $this->viewData);
}
}
+2 -10
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests\Auth;
use App\Support\CaptchaHelper;
use Illuminate\Foundation\Http\FormRequest;
class LoginLoginRequest extends FormRequest
@@ -11,15 +12,6 @@ class LoginLoginRequest extends FormRequest
*/
public function rules(): array
{
if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) {
return [
'g-recaptcha-response' => [
'required',
'captcha',
],
];
} else {
return [];
}
return CaptchaHelper::getValidationRules();
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Requests\Auth;
use App\Support\CaptchaHelper;
use Illuminate\Foundation\Http\FormRequest;
class RegisterRegisterRequest extends FormRequest
@@ -11,15 +12,6 @@ class RegisterRegisterRequest extends FormRequest
*/
public function rules(): array
{
if (config('captcha.enabled') === true && (! empty(config('captcha.secret')) && ! empty(config('captcha.sitekey')))) {
return [
'g-recaptcha-response' => [
'required',
'captcha',
],
];
} else {
return [];
}
return CaptchaHelper::getValidationRules();
}
}
@@ -2,6 +2,7 @@
namespace App\Http\Requests\Auth;
use App\Support\CaptchaHelper;
use Illuminate\Foundation\Http\FormRequest;
class ShowLinkRequestFormForgotPasswordRequest extends FormRequest
@@ -13,6 +14,6 @@ class ShowLinkRequestFormForgotPasswordRequest extends FormRequest
*/
public function rules()
{
return ['g-recaptcha-response' => 'required|captcha'];
return CaptchaHelper::getValidationRules();
}
}
+2 -4
View File
@@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Support\CaptchaHelper;
use Illuminate\Foundation\Http\FormRequest;
class ContactContactURequest extends FormRequest
@@ -11,9 +12,6 @@ class ContactContactURequest extends FormRequest
*/
public function rules(): array
{
return ['g-recaptcha-response' => [
'required',
'captcha',
], ];
return CaptchaHelper::getValidationRules();
}
}
+8
View File
@@ -28,5 +28,13 @@ class UpdateUserLoggedIn
'host' => $event->ip,
]
);
// Log the user login event
\Log::channel('user_login')->info('User logged in', [
'user_id' => $event->user->id,
'username' => $event->user->username,
'ip' => $event->ip,
'time' => now(),
]);
}
}
+1 -1
View File
@@ -22,6 +22,6 @@ class GrabStat extends Model
public static function getTopGrabbers(): array
{
return self::query()->select(['username', 'grabs'])->get()->toArray();
return self::query()->select(['username', 'grabs'])->orderByDesc('grabs')->limit(10)->get()->toArray();
}
}
+6
View File
@@ -24,6 +24,12 @@ class MediaInfo extends Model
return;
}
$mediaUniqueId = $mediainfoArray->get('unique_id');
// If unique_id is not present or is 0 or 0x0, we don't want to store it, as it's not unique
if ($mediaUniqueId === null || $mediaUniqueId === '0' || $mediaUniqueId === '0x0' || $mediaUniqueId === 0) {
$mediaUniqueId = null;
}
self::insertOrIgnore([
'releases_id' => $id,
'movie_name' => $mediainfoArray->get('movie_name') ?? null,
+3 -7
View File
@@ -98,11 +98,6 @@ class Release extends Model
return $this->belongsTo(TvEpisode::class, 'tv_episodes_id');
}
public function movieInfo(): BelongsTo
{
return $this->belongsTo(MovieInfo::class, 'movieinfo_id');
}
/**
* Insert a single release returning the ID on success or false on failure.
*
@@ -135,6 +130,7 @@ class Release extends Model
'isrenamed' => $parameters['isrenamed'],
'iscategorized' => 1,
'predb_id' => $parameters['predb_id'],
'source' => $parameters['source'] ?? null,
'ishashed' => $parameters['ishashed'] ?? 0,
]
);
@@ -361,9 +357,9 @@ class Release extends Model
$release->parent_category = $release->category->parent->title ?? null;
$release->sub_category = $release->category->title ?? null;
$release->category_name = $release->parent_category.' > '.$release->sub_category;
$release->category_ids = $release->category->parentid.','.$release->category->id;
$release->category_ids = $release->category ? ($release->category->parentid.','.$release->category->id) : '';
$release->group_names = $release->releaseGroup->map(function ($relGroup) {
return $relGroup->group->name;
return $relGroup->group ? $relGroup->group->name : null;
})->implode(',');
});
+22 -7
View File
@@ -14,19 +14,34 @@ class ReleaseStat extends Model
public static function insertRecentlyAdded(): void
{
$categories = Category::query()->with('parent')->where('r.adddate', '>', now()->subWeek())->select([
'root_categories_id', DB::raw('COUNT(r.id) as count'), 'title',
])->join('releases as r', 'r.categories_id', '=',
'categories.id')->groupBy('title')->orderByDesc('count')->get();
$categories = Category::query()
->with('parent')
->where('r.adddate', '>', now()->subWeek())
->select([
'categories.id',
'root_categories_id',
DB::raw('COUNT(r.id) as count'),
'categories.title',
])
->join('releases as r', 'r.categories_id', '=', 'categories.id')
->whereNotIn('categories.id', [10, 20]) // Exclude OTHER_MISC and OTHER_HASHED
->groupBy('categories.id', 'root_categories_id', 'categories.title')
->orderByDesc('count')
->get();
foreach ($categories as $category) {
// Build the category display name with root category prefix
$categoryDisplay = $category->parent
? $category->parent->title.' > '.$category->title
: $category->title;
// Check if we already have the information and if we do just update the count
if (self::query()->where('category', $category->title)->exists()) {
self::query()->where('category', $category->title)->update(['count' => $category->count]);
if (self::query()->where('category', $categoryDisplay)->exists()) {
self::query()->where('category', $categoryDisplay)->update(['count' => $category->count]);
continue;
}
self::query()->create(['category' => $category->title, 'count' => $category->count]);
self::query()->create(['category' => $categoryDisplay, 'count' => $category->count]);
}
}
+17 -3
View File
@@ -13,14 +13,28 @@ class SignupStat extends Model
public static function insertUsersByMonth(): void
{
$users = User::query()->whereNotNull('created_at')->where('created_at', '<>', '0000-00-00 00:00:00')->selectRaw("DATE_FORMAT(created_at, '%M %Y') as mth, COUNT(id) as num")->groupBy(['mth'])->orderByDesc('created_at')->get();
$users = User::query()
->whereNotNull('created_at')
->where('created_at', '<>', '0000-00-00 00:00:00')
->selectRaw("DATE_FORMAT(created_at, '%Y-%m-01') as sort_date, DATE_FORMAT(created_at, '%M %Y') as mth, COUNT(id) as num")
->groupBy(['sort_date', 'mth'])
->orderByDesc('sort_date')
->get();
foreach ($users as $user) {
self::updateOrCreate(['month' => $user->mth], ['signups' => $user->num]);
self::updateOrCreate(
['month' => $user->mth],
['signups' => $user->num, 'sort_date' => $user->sort_date]
);
}
}
public static function getUsersByMonth(): array
{
return self::query()->select(['month', 'signups'])->get()->toArray();
return self::query()
->select(['month', 'signups'])
->orderByDesc('sort_date')
->get()
->toArray();
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class SystemMetric extends Model
{
use HasFactory;
protected $fillable = [
'metric_type',
'value',
'recorded_at',
];
protected $casts = [
'value' => 'float',
'recorded_at' => 'datetime',
];
/**
* Scope to get CPU metrics
*/
public function scopeCpu($query)
{
return $query->where('metric_type', 'cpu');
}
/**
* Scope to get RAM metrics
*/
public function scopeRam($query)
{
return $query->where('metric_type', 'ram');
}
/**
* Scope to get metrics for a specific time period
*/
public function scopeForPeriod($query, int $hours)
{
return $query->where('recorded_at', '>=', now()->subHours($hours));
}
/**
* Scope to get metrics for a specific date range
*/
public function scopeBetweenDates($query, $startDate, $endDate)
{
return $query->whereBetween('recorded_at', [$startDate, $endDate]);
}
/**
* Clean up old metrics (older than specified days)
*/
public static function cleanupOldMetrics(int $days = 60): int
{
return static::where('recorded_at', '<', now()->subDays($days))->delete();
}
}
+8
View File
@@ -238,6 +238,14 @@ class User extends Authenticatable
return $this->hasMany(ReleaseComment::class, 'users_id');
}
/**
* Get the user's timezone or default to UTC
*/
public function getTimezone(): string
{
return $this->timezone ?? 'UTC';
}
/**
* @throws \Exception
*/
+134
View File
@@ -0,0 +1,134 @@
<?php
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class UserActivityStat extends Model
{
use HasFactory;
protected $guarded = [];
protected $casts = [
'stat_date' => 'date',
];
/**
* Collect and store user activity stats for a specific date
* This aggregates data from user_downloads and user_requests tables
*/
public static function collectDailyStats(?string $date = null): void
{
$statDate = $date ? Carbon::parse($date)->format('Y-m-d') : Carbon::yesterday()->format('Y-m-d');
// Count downloads for the date
$downloadsCount = UserDownload::query()
->whereRaw('DATE(timestamp) = ?', [$statDate])
->count();
// Count API hits for the date
$apiHitsCount = UserRequest::query()
->whereRaw('DATE(timestamp) = ?', [$statDate])
->count();
// Store or update the stats
self::updateOrCreate(
['stat_date' => $statDate],
[
'downloads_count' => $downloadsCount,
'api_hits_count' => $apiHitsCount,
]
);
}
/**
* Get download stats for the last N days
*/
public static function getDownloadsPerDay(int $days = 30): array
{
$startDate = Carbon::now()->subDays($days - 1)->format('Y-m-d');
$stats = self::query()
->select('stat_date', 'downloads_count')
->where('stat_date', '>=', $startDate)
->orderBy('stat_date', 'asc')
->get()
->keyBy('stat_date');
// Fill in missing days with zero counts
$result = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
$stat = $stats->get($date);
$result[] = [
'date' => Carbon::parse($date)->format('M d'),
'count' => $stat ? $stat->downloads_count : 0,
];
}
return $result;
}
/**
* Get API hits stats for the last N days
*/
public static function getApiHitsPerDay(int $days = 30): array
{
$startDate = Carbon::now()->subDays($days - 1)->format('Y-m-d');
$stats = self::query()
->select('stat_date', 'api_hits_count')
->where('stat_date', '>=', $startDate)
->orderBy('stat_date', 'asc')
->get()
->keyBy('stat_date');
// Fill in missing days with zero counts
$result = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
$stat = $stats->get($date);
$result[] = [
'date' => Carbon::parse($date)->format('M d'),
'count' => $stat ? $stat->api_hits_count : 0,
];
}
return $result;
}
/**
* Get total downloads for the last N days
*/
public static function getTotalDownloads(int $days = 7): int
{
return self::query()
->where('stat_date', '>=', Carbon::now()->subDays($days)->format('Y-m-d'))
->sum('downloads_count');
}
/**
* Get total API hits for the last N days
*/
public static function getTotalApiHits(int $days = 7): int
{
return self::query()
->where('stat_date', '>=', Carbon::now()->subDays($days)->format('Y-m-d'))
->sum('api_hits_count');
}
/**
* Cleanup old stats (keep last N days)
*/
public static function cleanupOldStats(int $keepDays = 90): int
{
$cutoffDate = Carbon::now()->subDays($keepDays)->format('Y-m-d');
return self::query()
->where('stat_date', '<', $cutoffDate)
->delete();
}
}
+272 -22
View File
@@ -3,6 +3,7 @@
namespace App\Services;
use App\Models\User;
use App\Models\UserActivityStat;
use App\Models\UserDownload;
use App\Models\UserRequest;
use Carbon\Carbon;
@@ -31,26 +32,108 @@ class UserStatsService
}
/**
* Get downloads per day for the last 7 days
* Get downloads per day for the last N days
* Uses aggregated stats from user_activity_stats table for dates older than 2 days
* Uses live data from user_downloads table for recent days
*/
public function getDownloadsPerDay(int $days = 7): array
{
$startDate = Carbon::now()->subDays($days - 1)->startOfDay();
$twoDaysAgo = Carbon::now()->subDays(2)->startOfDay();
$result = [];
// For historical data (older than 2 days), use aggregated stats
if ($days > 2) {
$historicalStartDate = $startDate->format('Y-m-d');
$historicalEndDate = $twoDaysAgo->copy()->subDay()->format('Y-m-d');
$historicalStats = UserActivityStat::query()
->select('stat_date', 'downloads_count')
->where('stat_date', '>=', $historicalStartDate)
->where('stat_date', '<=', $historicalEndDate)
->orderBy('stat_date', 'asc')
->get()
->keyBy('stat_date');
// Add historical data
$currentDate = $startDate->copy();
while ($currentDate->lt($twoDaysAgo)) {
$dateStr = $currentDate->format('Y-m-d');
$stat = $historicalStats->get($dateStr);
$result[] = [
'date' => $currentDate->format('M d'),
'count' => $stat ? $stat->downloads_count : 0,
];
$currentDate->addDay();
}
}
// For recent data (last 2 days), use live data from user_downloads
$downloads = UserDownload::query()
->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count'))
->where('timestamp', '>=', $startDate)
->where('timestamp', '>=', $twoDaysAgo)
->groupBy(DB::raw('DATE(timestamp)'))
->orderBy('date', 'asc')
->get();
->get()
->keyBy('date');
// Fill in missing days with zero counts
$result = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
$found = $downloads->firstWhere('date', $date);
// Add recent data
$currentDate = $twoDaysAgo->copy();
$now = Carbon::now();
while ($currentDate->lte($now)) {
$dateStr = $currentDate->format('Y-m-d');
$found = $downloads->get($dateStr);
$result[] = [
'date' => Carbon::parse($date)->format('M d'),
'date' => $currentDate->format('M d'),
'count' => $found ? $found->count : 0,
];
$currentDate->addDay();
}
return $result;
}
/**
* Get downloads per hour for the last N hours
* Uses live data from user_downloads table
*/
public function getDownloadsPerHour(int $hours = 168): array
{
$startTime = Carbon::now()->subHours($hours - 1)->startOfHour();
$downloads = UserDownload::query()
->select(
DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00") as hour'),
DB::raw('COUNT(*) as count')
)
->where('timestamp', '>=', $startTime)
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00")'))
->orderBy('hour', 'asc')
->get()
->keyBy('hour');
// Fill in missing hours with zero counts
$result = [];
for ($i = $hours - 1; $i >= 0; $i--) {
$time = Carbon::now()->subHours($i)->startOfHour();
$hourKey = $time->format('Y-m-d H:00:00');
$found = $downloads->get($hourKey);
// Format label based on how recent the hour is
$now = Carbon::now();
if ($time->isToday()) {
$label = $time->format('H:i');
} elseif ($time->isYesterday()) {
$label = 'Yesterday '.$time->format('H:i');
} elseif ($time->diffInDays($now) < 7) {
$label = $time->format('D H:i');
} else {
$label = $time->format('M d H:i');
}
$result[] = [
'time' => $label,
'count' => $found ? $found->count : 0,
];
}
@@ -59,28 +142,173 @@ class UserStatsService
}
/**
* Get API hits per day for the last 7 days
* Note: This tracks actual API requests from user_requests table
* Get downloads per minute for the last N minutes
*/
public function getDownloadsPerMinute(int $minutes = 60): array
{
$startTime = Carbon::now()->subMinutes($minutes);
$downloads = UserDownload::query()
->select(
DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'),
DB::raw('COUNT(*) as count')
)
->where('timestamp', '>=', $startTime)
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
->orderBy('minute', 'asc')
->get();
// Fill in missing minutes with zero counts
$result = [];
for ($i = $minutes - 1; $i >= 0; $i--) {
$time = Carbon::now()->subMinutes($i);
$minuteKey = $time->format('Y-m-d H:i:00');
$found = $downloads->firstWhere('minute', $minuteKey);
$result[] = [
'time' => $time->format('H:i'),
'count' => $found ? $found->count : 0,
];
}
return $result;
}
/**
* Get API hits per day for the last N days
* Uses aggregated stats from user_activity_stats table for dates older than 2 days
* Uses live data from user_requests table for recent days
*/
public function getApiHitsPerDay(int $days = 7): array
{
$startDate = Carbon::now()->subDays($days - 1)->startOfDay();
$twoDaysAgo = Carbon::now()->subDays(2)->startOfDay();
$result = [];
// For historical data (older than 2 days), use aggregated stats
if ($days > 2) {
$historicalStartDate = $startDate->format('Y-m-d');
$historicalEndDate = $twoDaysAgo->copy()->subDay()->format('Y-m-d');
$historicalStats = UserActivityStat::query()
->select('stat_date', 'api_hits_count')
->where('stat_date', '>=', $historicalStartDate)
->where('stat_date', '<=', $historicalEndDate)
->orderBy('stat_date', 'asc')
->get()
->keyBy('stat_date');
// Add historical data
$currentDate = $startDate->copy();
while ($currentDate->lt($twoDaysAgo)) {
$dateStr = $currentDate->format('Y-m-d');
$stat = $historicalStats->get($dateStr);
$result[] = [
'date' => $currentDate->format('M d'),
'count' => $stat ? $stat->api_hits_count : 0,
];
$currentDate->addDay();
}
}
// For recent data (last 2 days), use live data from user_requests
$apiHits = UserRequest::query()
->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count'))
->where('timestamp', '>=', $twoDaysAgo)
->groupBy(DB::raw('DATE(timestamp)'))
->orderBy('date', 'asc')
->get()
->keyBy('date');
// Add recent data
$currentDate = $twoDaysAgo->copy();
$now = Carbon::now();
while ($currentDate->lte($now)) {
$dateStr = $currentDate->format('Y-m-d');
$found = $apiHits->get($dateStr);
$result[] = [
'date' => $currentDate->format('M d'),
'count' => $found ? $found->count : 0,
];
$currentDate->addDay();
}
return $result;
}
/**
* Get API hits per hour for the last N hours
* Uses live data from user_requests table
*/
public function getApiHitsPerHour(int $hours = 168): array
{
$startTime = Carbon::now()->subHours($hours - 1)->startOfHour();
$apiHits = UserRequest::query()
->select(
DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00") as hour'),
DB::raw('COUNT(*) as count')
)
->where('timestamp', '>=', $startTime)
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:00:00")'))
->orderBy('hour', 'asc')
->get()
->keyBy('hour');
// Fill in missing hours with zero counts
$result = [];
for ($i = $hours - 1; $i >= 0; $i--) {
$time = Carbon::now()->subHours($i)->startOfHour();
$hourKey = $time->format('Y-m-d H:00:00');
$found = $apiHits->get($hourKey);
// Format label based on how recent the hour is
$now = Carbon::now();
if ($time->isToday()) {
$label = $time->format('H:i');
} elseif ($time->isYesterday()) {
$label = 'Yesterday '.$time->format('H:i');
} elseif ($time->diffInDays($now) < 7) {
$label = $time->format('D H:i');
} else {
$label = $time->format('M d H:i');
}
$result[] = [
'time' => $label,
'count' => $found ? $found->count : 0,
];
}
return $result;
}
/**
* Get API hits per minute for the last N minutes
*/
public function getApiHitsPerMinute(int $minutes = 60): array
{
$startTime = Carbon::now()->subMinutes($minutes);
// Track actual API requests from user_requests table
$apiHits = UserRequest::query()
->select(DB::raw('DATE(timestamp) as date'), DB::raw('COUNT(*) as count'))
->where('timestamp', '>=', $startDate)
->groupBy(DB::raw('DATE(timestamp)'))
->orderBy('date', 'asc')
->select(
DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00") as minute'),
DB::raw('COUNT(*) as count')
)
->where('timestamp', '>=', $startTime)
->groupBy(DB::raw('DATE_FORMAT(timestamp, "%Y-%m-%d %H:%i:00")'))
->orderBy('minute', 'asc')
->get();
// Fill in missing days with zero counts
// Fill in missing minutes with zero counts
$result = [];
for ($i = $days - 1; $i >= 0; $i--) {
$date = Carbon::now()->subDays($i)->format('Y-m-d');
$found = $apiHits->firstWhere('date', $date);
for ($i = $minutes - 1; $i >= 0; $i--) {
$time = Carbon::now()->subMinutes($i);
$minuteKey = $time->format('Y-m-d H:i:00');
$found = $apiHits->firstWhere('minute', $minuteKey);
$result[] = [
'date' => Carbon::parse($date)->format('M d'),
'time' => $time->format('H:i'),
'count' => $found ? $found->count : 0,
];
}
@@ -90,17 +318,39 @@ class UserStatsService
/**
* Get summary statistics
* Uses aggregated stats for weekly totals where possible
*/
public function getSummaryStats(): array
{
$today = Carbon::now()->startOfDay();
$twoDaysAgo = Carbon::now()->subDays(2)->startOfDay();
$sevenDaysAgo = Carbon::now()->subDays(7)->startOfDay();
// For weekly stats, combine aggregated historical data + live recent data
$historicalDownloads = UserActivityStat::query()
->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d'))
->where('stat_date', '<', $twoDaysAgo->format('Y-m-d'))
->sum('downloads_count');
$recentDownloads = UserDownload::query()
->where('timestamp', '>=', $twoDaysAgo)
->count();
$historicalApiHits = UserActivityStat::query()
->where('stat_date', '>=', $sevenDaysAgo->format('Y-m-d'))
->where('stat_date', '<', $twoDaysAgo->format('Y-m-d'))
->sum('api_hits_count');
$recentApiHits = UserRequest::query()
->where('timestamp', '>=', $twoDaysAgo)
->count();
return [
'total_users' => User::whereNull('deleted_at')->count(),
'downloads_today' => UserDownload::where('timestamp', '>=', $today)->count(),
'downloads_week' => UserDownload::where('timestamp', '>=', Carbon::now()->subDays(7))->count(),
'downloads_week' => $historicalDownloads + $recentDownloads,
'api_hits_today' => UserRequest::query()->where('timestamp', '>=', $today)->count(),
'api_hits_week' => UserRequest::query()->where('timestamp', '>=', Carbon::now()->subDays(7))->count(),
'api_hits_week' => $historicalApiHits + $recentApiHits,
];
}
-6
View File
@@ -31,7 +31,6 @@ class ApiTransformer extends TransformerAbstract
'added' => Carbon::parse($releases->adddate)->toRssString(),
'size' => $releases->size,
'files' => $releases->totalpart,
'poster' => $releases->fromname,
'imdbid' => $releases->imdbid !== null && $releases->imdbid !== 0 ? $releases->imdbid : $this->null(),
'tmdbid' => $releases->tmdbid !== null && $releases->tmdbid !== 0 ? $releases->tmdbid : $this->null(),
'traktid' => $releases->traktid !== null && $releases->traktid !== 0 ? $releases->traktid : $this->null(),
@@ -39,7 +38,6 @@ class ApiTransformer extends TransformerAbstract
'comments' => $releases->comments !== 0 ? $releases->comments : $this->null(),
'password' => $releases->passwordstatus,
'usenetdate' => Carbon::parse($releases->postdate)->toRssString(),
'group' => $releases->group_name,
];
}
@@ -53,7 +51,6 @@ class ApiTransformer extends TransformerAbstract
'added' => Carbon::parse($releases->adddate)->toRssString(),
'size' => $releases->size,
'files' => $releases->totalpart,
'poster' => $releases->fromname,
'episode_title' => $releases->title ?? $this->null(),
'season' => $releases->series ?? $this->null(),
'episode' => $releases->episode ?? $this->null(),
@@ -68,7 +65,6 @@ class ApiTransformer extends TransformerAbstract
'comments' => $releases->comments !== 0 ? $releases->comments : $this->null(),
'password' => $releases->passwordstatus,
'usenetdate' => Carbon::parse($releases->postdate)->toRssString(),
'group' => $releases->group_name,
];
}
@@ -81,12 +77,10 @@ class ApiTransformer extends TransformerAbstract
'added' => Carbon::parse($releases->adddate)->toRssString(),
'size' => $releases->size,
'files' => $releases->totalpart,
'poster' => $releases->fromname,
'grabs' => $releases->grabs !== 0 ? $releases->grabs : $this->null(),
'comments' => $releases->comments !== 0 ? $releases->comments : $this->null(),
'password' => $releases->passwordstatus,
'usenetdate' => Carbon::parse($releases->postdate)->toRssString(),
'group' => $releases->group_name,
];
}
}
-6
View File
@@ -34,13 +34,11 @@ class DetailsTransformer extends TransformerAbstract
'added' => Carbon::parse($releases->adddate)->toRssString(),
'size' => $releases->size,
'files' => $releases->totalpart,
'poster' => $releases->fromname,
'imdbid' => $releases->imdbid,
'grabs' => $releases->grabs,
'comments' => $releases->comments,
'password' => $releases->passwordstatus,
'usenetdate' => Carbon::parse($releases->postdate)->toRssString(),
'group' => $releases->group_name,
];
}
@@ -54,7 +52,6 @@ class DetailsTransformer extends TransformerAbstract
'added' => Carbon::parse($releases->adddate)->toRssString(),
'size' => $releases->size,
'files' => $releases->totalpart,
'poster' => $releases->fromname,
'tvairdate' => $releases->firstaired,
'tvdbid' => $releases->tvdb,
'traktid' => $releases->trakt,
@@ -66,7 +63,6 @@ class DetailsTransformer extends TransformerAbstract
'comments' => $releases->comments,
'password' => $releases->passwordstatus,
'usenetdate' => Carbon::parse($releases->postdate)->toRssString(),
'group' => $releases->group_name,
];
}
@@ -79,12 +75,10 @@ class DetailsTransformer extends TransformerAbstract
'added' => Carbon::parse($releases->adddate)->toRssString(),
'size' => $releases->size,
'files' => $releases->totalpart,
'poster' => $releases->fromname,
'grabs' => $releases->grabs,
'comments' => $releases->comments,
'password' => $releases->passwordstatus,
'usenetdate' => Carbon::parse($releases->postdate)->toRssString(),
'group' => $releases->group_name,
];
}
}
+6 -2
View File
@@ -16,11 +16,15 @@ class GlobalDataComposer
*/
public function compose(View $view): void
{
$settings = new Settings;
// Load settings as array with type conversions (empty strings -> null, numeric strings -> numbers)
$siteArray = Settings::query()
->pluck('value', 'name')
->map(fn ($value) => Settings::convertValue($value))
->all();
$viewData = [
'serverroot' => url('/'),
'site' => $settings,
'site' => $siteArray, // Now it's a proper array, not a Settings model
'theme' => 'Gentele',
];
+3 -1
View File
@@ -47,6 +47,8 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->web([
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\TrustedDevice2FAMiddleware::class, // Add our new trusted device middleware
\App\Http\Middleware\ContentSecurityPolicy::class, // Add CSP middleware for security
\App\Http\Middleware\SetUserTimezone::class, // Set user timezone
]);
$middleware->throttleApi('60,1');
@@ -62,5 +64,5 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions) {
//
\Sentry\Laravel\Integration::handles($exceptions);
})->create();
Generated
+39 -39
View File
@@ -1364,26 +1364,26 @@
},
{
"name": "doctrine/sql-formatter",
"version": "1.5.2",
"version": "1.5.3",
"source": {
"type": "git",
"url": "https://github.com/doctrine/sql-formatter.git",
"reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8"
"reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/d6d00aba6fd2957fe5216fe2b7673e9985db20c8",
"reference": "d6d00aba6fd2957fe5216fe2b7673e9985db20c8",
"url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/a8af23a8e9d622505baa2997465782cbe8bb7fc7",
"reference": "a8af23a8e9d622505baa2997465782cbe8bb7fc7",
"shasum": ""
},
"require": {
"php": "^8.1"
},
"require-dev": {
"doctrine/coding-standard": "^12",
"ergebnis/phpunit-slow-test-detector": "^2.14",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5"
"doctrine/coding-standard": "^14",
"ergebnis/phpunit-slow-test-detector": "^2.20",
"phpstan/phpstan": "^2.1.31",
"phpunit/phpunit": "^10.5.58"
},
"bin": [
"bin/sql-formatter"
@@ -1413,9 +1413,9 @@
],
"support": {
"issues": "https://github.com/doctrine/sql-formatter/issues",
"source": "https://github.com/doctrine/sql-formatter/tree/1.5.2"
"source": "https://github.com/doctrine/sql-formatter/tree/1.5.3"
},
"time": "2025-01-24T11:45:48+00:00"
"time": "2025-10-26T09:35:14+00:00"
},
{
"name": "dragonmantank/cron-expression",
@@ -3053,16 +3053,16 @@
},
{
"name": "laravel/framework",
"version": "v12.35.0",
"version": "v12.35.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef"
"reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef",
"reference": "9583ef9e405a71d5b8c04ff6efd05a7ef9a5baef",
"url": "https://api.github.com/repos/laravel/framework/zipball/d6d6e3cb68238e2fb25b440f222442adef5a8a15",
"reference": "d6d6e3cb68238e2fb25b440f222442adef5a8a15",
"shasum": ""
},
"require": {
@@ -3268,7 +3268,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
"time": "2025-10-21T15:15:41+00:00"
"time": "2025-10-23T15:25:03+00:00"
},
{
"name": "laravel/horizon",
@@ -8714,16 +8714,16 @@
},
{
"name": "sentry/sentry",
"version": "4.17.0",
"version": "4.17.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "62927369a572efc27ddbd89e466e17788329224b"
"reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/62927369a572efc27ddbd89e466e17788329224b",
"reference": "62927369a572efc27ddbd89e466e17788329224b",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/5c696b8de57e841a2bf3b6f6eecfd99acfdda80c",
"reference": "5c696b8de57e841a2bf3b6f6eecfd99acfdda80c",
"shasum": ""
},
"require": {
@@ -8786,7 +8786,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.17.0"
"source": "https://github.com/getsentry/sentry-php/tree/4.17.1"
},
"funding": [
{
@@ -8798,7 +8798,7 @@
"type": "custom"
}
],
"time": "2025-10-20T12:57:02+00:00"
"time": "2025-10-23T15:19:24+00:00"
},
{
"name": "sentry/sentry-laravel",
@@ -14667,16 +14667,16 @@
},
{
"name": "driftingly/rector-laravel",
"version": "2.1.0",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/driftingly/rector-laravel.git",
"reference": "efb636a08dfddfa2a3f4527b1dd970a898a075a4"
"reference": "abc336cbf06f53d90ab74cecfd319379fc55d408"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/efb636a08dfddfa2a3f4527b1dd970a898a075a4",
"reference": "efb636a08dfddfa2a3f4527b1dd970a898a075a4",
"url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/abc336cbf06f53d90ab74cecfd319379fc55d408",
"reference": "abc336cbf06f53d90ab74cecfd319379fc55d408",
"shasum": ""
},
"require": {
@@ -14696,9 +14696,9 @@
"description": "Rector upgrades rules for Laravel Framework",
"support": {
"issues": "https://github.com/driftingly/rector-laravel/issues",
"source": "https://github.com/driftingly/rector-laravel/tree/2.1.0"
"source": "https://github.com/driftingly/rector-laravel/tree/2.1.1"
},
"time": "2025-10-12T21:51:39+00:00"
"time": "2025-10-23T13:53:44+00:00"
},
{
"name": "ergebnis/composer-normalize",
@@ -15298,16 +15298,16 @@
},
{
"name": "friendsofphp/php-cs-fixer",
"version": "v3.89.0",
"version": "v3.89.1",
"source": {
"type": "git",
"url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
"reference": "4dd6768cb7558440d27d18f54909eee417317ce9"
"reference": "f34967da2866ace090a2b447de1f357356474573"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/4dd6768cb7558440d27d18f54909eee417317ce9",
"reference": "4dd6768cb7558440d27d18f54909eee417317ce9",
"url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/f34967da2866ace090a2b447de1f357356474573",
"reference": "f34967da2866ace090a2b447de1f357356474573",
"shasum": ""
},
"require": {
@@ -15389,7 +15389,7 @@
],
"support": {
"issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.0"
"source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.89.1"
},
"funding": [
{
@@ -15397,7 +15397,7 @@
"type": "github"
}
],
"time": "2025-10-18T19:30:16+00:00"
"time": "2025-10-24T12:05:10+00:00"
},
{
"name": "hamcrest/hamcrest-php",
@@ -17296,16 +17296,16 @@
},
{
"name": "rector/rector",
"version": "2.2.4",
"version": "2.2.5",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "904f12f23858ef54ec5782b05cb2979b703cb185"
"reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/904f12f23858ef54ec5782b05cb2979b703cb185",
"reference": "904f12f23858ef54ec5782b05cb2979b703cb185",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/fb9418af7777dfb1c87a536dc58398b5b07c74b9",
"reference": "fb9418af7777dfb1c87a536dc58398b5b07c74b9",
"shasum": ""
},
"require": {
@@ -17344,7 +17344,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.2.4"
"source": "https://github.com/rectorphp/rector/tree/2.2.5"
},
"funding": [
{
@@ -17352,7 +17352,7 @@
"type": "github"
}
],
"time": "2025-10-22T07:50:23+00:00"
"time": "2025-10-23T11:22:37+00:00"
},
{
"name": "sebastian/cli-parser",
+16
View File
@@ -4,6 +4,22 @@ use Illuminate\Support\Facades\Facade;
return [
/*
|--------------------------------------------------------------------------
| Application Timezone
|--------------------------------------------------------------------------
|
| Here you may specify the default timezone for your application, which
| will be used by the PHP date and date-time functions. We have gone
| ahead and set this to a sensible default for you out of the box.
|
| IMPORTANT: This should always be UTC to ensure consistent date storage.
| User-specific timezones are handled at the display layer.
|
*/
'timezone' => env('APP_TIMEZONE', 'UTC'),
'aliases' => Facade::defaultAliases()->merge([
'RedisManager' => Illuminate\Support\Facades\Redis::class,
'UserVerification' => Jrean\UserVerification\Facades\UserVerification::class,
+71 -1
View File
@@ -2,6 +2,76 @@
return [
'cloud' => env('FILESYSTEM_CLOUD', 's3'),
/*
|--------------------------------------------------------------------------
| Default Filesystem Disk
|--------------------------------------------------------------------------
|
| Here you may specify the default filesystem disk that should be used
| by the framework. The "local" disk, as well as a variety of cloud
| based disks are available to your application for file storage.
|
*/
'default' => env('FILESYSTEM_DISK', 'local'),
/*
|--------------------------------------------------------------------------
| Filesystem Disks
|--------------------------------------------------------------------------
|
| Below you may configure as many filesystem disks as necessary, and you
| may even configure multiple disks for the same driver. Examples for
| most supported storage drivers are configured here for reference.
|
| Supported drivers: "local", "ftp", "sftp", "s3"
|
*/
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
'endpoint' => env('AWS_ENDPOINT'),
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
'throw' => false,
],
],
/*
|--------------------------------------------------------------------------
| Symbolic Links
|--------------------------------------------------------------------------
|
| Here you may configure the symbolic links that will be created when the
| `storage:link` Artisan command is executed. The array keys should be
| the locations of the links and the values should be their targets.
|
*/
'links' => [
public_path('storage') => storage_path('app/public'),
],
];
+114
View File
@@ -28,6 +28,120 @@ return [
'permission' => 0775,
'locking' => false,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'papertrail' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => SyslogUdpHandler::class,
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
],
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
],
'zipped' => [
'driver' => 'daily',
'path' => storage_path('logs/zipped.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'scrapers' => [
'driver' => 'daily',
'path' => storage_path('logs/scrapers.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'failed_login' => [
'driver' => 'daily',
'path' => storage_path('logs/failed_login.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'crc_oso' => [
'driver' => 'daily',
'path' => storage_path('logs/crc_oso.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'btc_payment' => [
'driver' => 'daily',
'path' => storage_path('logs/btc_payment.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'nzb_upload' => [
'driver' => 'daily',
'path' => storage_path('logs/nzb_upload.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'filename_rename' => [
'driver' => 'daily',
'path' => storage_path('logs/filename_rename.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'nzb_import' => [
'driver' => 'daily',
'path' => storage_path('logs/nzb_import.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'user_login' => [
'driver' => 'daily',
'path' => storage_path('logs/user_login.log'),
'level' => 'debug',
'days' => 7,
'bubble' => true,
'permission' => 0775,
'locking' => false,
],
'flare' => [
'driver' => 'flare',
+3
View File
@@ -14,6 +14,7 @@ return [
'admin_username' => env('ADMIN_USER', 'admin'),
'admin_password' => env('ADMIN_PASS', 'admin'),
'admin_email' => env('ADMIN_EMAIL', 'admin@example.com'),
'crc_token' => env('CRC_TOKEN', null),
'multiprocessing_max_child_time' => env('NN_MULTIPROCESSING_MAX_CHILD_TIME', 1800),
'stream_fork_output' => env('STREAM_FORK_OUTPUT', false),
'purge_inactive_users' => env('PURGE_INACTIVE_USERS', false),
@@ -22,4 +23,6 @@ return [
'btcpay_webhook_secret' => env('BTCPAY_SECRET'),
'tmp_unrar_path' => env('TEMP_UNRAR_PATH', storage_path('tmp/unrar/')),
'tmp_unzip_path' => env('TEMP_UNZIP_PATH', storage_path('tmp/unzip/')),
'nzb_import_folder' => env('NZB_IMPORT_FOLDER'),
'nzb_upload_folder' => env('NZB_UPLOAD_FOLDER'),
];
-2
View File
@@ -1218,7 +1218,6 @@ CREATE TABLE `users` (
`updated_at` timestamp NULL DEFAULT NULL,
`verified` tinyint(1) NOT NULL DEFAULT 0,
`verification_token` varchar(255) DEFAULT NULL,
`bad_user` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_users_api_token` (`api_token`),
KEY `ix_user_roles` (`roles_id`)
@@ -1403,7 +1402,6 @@ INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (86,'2023_12_08_191
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (87,'2024_01_06_173518_create_payments_table',1);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (88,'2019_08_14_123627_create_poster_renames_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (89,'2019_08_15_145634_add_source_to_releases_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (90,'2019_10_03_112445_add_bad_user_to_users_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (91,'2024_01_11_203725_create_predb_crcs_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (92,'2024_01_12_193533_alter_filedate_column_predb_crcs_table',2);
INSERT INTO `migrations` (`id`, `migration`, `batch`) VALUES (93,'2024_01_12_194256_add_back_timestamps_column_to__predb_crcs_table',2);
+191
View File
@@ -363,6 +363,38 @@ html.dark #tmuxForm select.select-other {
width: 30px;
}
/* Checkbox visibility improvements for dark mode */
input[type="checkbox"].form-checkbox,
input[type="checkbox"].cart-checkbox {
border-width: 2px;
}
@media (prefers-color-scheme: dark) {
input[type="checkbox"].form-checkbox,
input[type="checkbox"].cart-checkbox {
background-color: #374151;
border-color: #6b7280;
}
input[type="checkbox"].form-checkbox:checked,
input[type="checkbox"].cart-checkbox:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
}
html.dark input[type="checkbox"].form-checkbox,
html.dark input[type="checkbox"].cart-checkbox {
background-color: #374151;
border-color: #6b7280;
}
html.dark input[type="checkbox"].form-checkbox:checked,
html.dark input[type="checkbox"].cart-checkbox:checked {
background-color: #3b82f6;
border-color: #3b82f6;
}
/* Admin Groups Management - Fade Out Animation */
.fade-out {
transition: opacity 0.3s ease;
@@ -471,6 +503,16 @@ table tbody tr:last-child {
text-decoration: underline;
}
/* Preview Modal Styles */
#previewModal {
display: none;
z-index: 9999 !important;
}
#previewModal:not(.hidden) {
display: flex;
}
.prose a:hover {
color: #1d4ed8;
}
@@ -535,6 +577,146 @@ table tbody tr:last-child {
margin-bottom: 16px;
}
.confirmation-modal-icon {
width: 48px;
height: 48px;
background-color: #fee2e2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-icon {
background-color: #7f1d1d;
}
}
html.dark .confirmation-modal-icon {
background-color: #7f1d1d;
}
.confirmation-modal-icon i {
color: #ef4444;
font-size: 24px;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-icon i {
color: #fca5a5;
}
}
html.dark .confirmation-modal-icon i {
color: #fca5a5;
}
.confirmation-modal-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-title {
color: #f3f4f6;
}
}
html.dark .confirmation-modal-title {
color: #f3f4f6;
}
.confirmation-modal-body {
color: #6b7280;
margin-bottom: 24px;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-body {
color: #d1d5db;
}
}
html.dark .confirmation-modal-body {
color: #d1d5db;
}
.confirmation-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.confirmation-modal-footer button {
padding: 10px 20px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
font-size: 14px;
}
.btn-cancel {
background-color: #f3f4f6;
color: #374151;
}
.btn-cancel:hover {
background-color: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
.btn-cancel {
background-color: #374151;
color: #d1d5db;
}
.btn-cancel:hover {
background-color: #4b5563;
}
}
html.dark .btn-cancel {
background-color: #374151;
color: #d1d5db;
}
html.dark .btn-cancel:hover {
background-color: #4b5563;
}
.btn-confirm {
background-color: #ef4444;
color: white;
}
.btn-confirm:hover {
background-color: #dc2626;
}
@media (prefers-color-scheme: dark) {
.btn-confirm {
background-color: #dc2626;
}
.btn-confirm:hover {
background-color: #b91c1c;
}
}
html.dark .btn-confirm {
background-color: #dc2626;
}
html.dark .btn-confirm:hover {
background-color: #b91c1c;
}
@keyframes fadeIn {
from {
opacity: 0;
@@ -544,6 +726,15 @@ table tbody tr:last-child {
}
}
@keyframes fadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
+572 -38
View File
@@ -34,6 +34,7 @@ document.addEventListener('DOMContentLoaded', function() {
initProfileEdit();
initDetailsPageImageModal();
initAddToCart();
initMoviesLayoutToggle();
});
// Event delegation for dynamically added elements
@@ -986,6 +987,201 @@ function initMediainfoAndFilelist() {
// Cart and Multi-select functionality
function initCartFunctionality() {
// Global showConfirmation function for cart page
window.showConfirmation = function(message, onConfirm) {
const modal = document.createElement('div');
modal.className = 'confirmation-modal';
modal.innerHTML = `
<div class="confirmation-modal-content">
<div class="confirmation-modal-header">
<div class="confirmation-modal-icon">
<i class="fa fa-exclamation-triangle"></i>
</div>
<h3 class="confirmation-modal-title">Confirm Deletion</h3>
</div>
<div class="confirmation-modal-body">
${message}
</div>
<div class="confirmation-modal-footer">
<button class="btn-cancel">Cancel</button>
<button class="btn-confirm">Delete</button>
</div>
</div>
`;
document.body.appendChild(modal);
const cancelBtn = modal.querySelector('.btn-cancel');
const confirmBtn = modal.querySelector('.btn-confirm');
function closeModal() {
modal.style.animation = 'fadeOut 0.2s ease-out';
setTimeout(() => modal.remove(), 200);
}
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) {
closeModal();
}
});
confirmBtn.addEventListener('click', function() {
closeModal();
onConfirm();
});
// Focus on cancel button by default
setTimeout(() => cancelBtn.focus(), 100);
// ESC key to close
const escHandler = function(e) {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
};
// Cart page specific functionality
const checkAll = document.getElementById('check-all');
const checkboxes = document.querySelectorAll('.cart-checkbox');
if (checkAll && checkboxes.length > 0) {
// Function to update the check-all checkbox state
function updateCheckAllState() {
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
checkAll.checked = checkedCount === checkboxes.length;
checkAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
}
// Handle check-all checkbox change
checkAll.addEventListener('change', function() {
const isChecked = this.checked;
checkboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
});
// Handle individual checkbox changes
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', updateCheckAllState);
});
// Initialize the check-all state on page load
updateCheckAllState();
// Download selected
document.querySelectorAll('.nzb_multi_operations_download_cart').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
if (selected.length === 0) {
if (typeof showToast === 'function') {
showToast('Please select at least one item', 'error');
} else {
alert('Please select at least one item');
}
return;
}
// Download all selected NZBs
selected.forEach(guid => {
window.open('/getnzb?id=' + guid, '_blank');
});
if (typeof showToast === 'function') {
showToast('Downloading ' + selected.length + ' item(s)', 'success');
}
});
});
// Delete selected
document.querySelectorAll('.nzb_multi_operations_cartdelete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
if (selected.length === 0) {
if (typeof showToast === 'function') {
showToast('Please select at least one item', 'error');
} else {
alert('Please select at least one item');
}
return;
}
showConfirmation(
`Are you sure you want to delete <strong>${selected.length}</strong> item(s) from your cart?`,
function() {
// Delete via AJAX
fetch('/cart/delete/' + selected.join(','), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
}
})
.then(response => {
if (response.ok) {
if (typeof showToast === 'function') {
showToast('Items deleted successfully', 'success');
}
setTimeout(() => window.location.reload(), 1000);
} else {
if (typeof showToast === 'function') {
showToast('Failed to delete items', 'error');
} else {
alert('Failed to delete items');
}
}
})
.catch(error => {
console.error('Error:', error);
if (typeof showToast === 'function') {
showToast('Failed to delete items', 'error');
} else {
alert('Failed to delete items');
}
});
}
);
});
});
// Individual delete confirmation
document.querySelectorAll('.cart-delete-link').forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const releaseName = this.getAttribute('data-release-name');
const deleteUrl = this.getAttribute('data-delete-url');
showConfirmation(
`Are you sure you want to remove <strong>${releaseName}</strong> from your cart?`,
function() {
if (typeof showToast === 'function') {
showToast('Removing item from cart...', 'info');
}
setTimeout(() => {
window.location.href = deleteUrl;
}, 500);
}
);
});
});
}
// Select all checkbox functionality
const selectAllCheckbox = document.getElementById('chkSelectAll');
if (selectAllCheckbox) {
@@ -1766,8 +1962,8 @@ function escapeHtml(text) {
// TinyMCE Initialization for Admin Content Pages
function initTinyMCE() {
// Only initialize if TinyMCE editor element exists
if (!document.getElementById('body')) {
// Only initialize if TinyMCE editor element exists (check for #body or .tinymce-editor)
if (!document.getElementById('body') && !document.querySelector('.tinymce-editor')) {
return;
}
@@ -1782,6 +1978,7 @@ function initTinyMCE() {
// Get API key from textarea data attribute or create meta tag
const bodyTextarea = document.getElementById('body');
const tinymceEditors = document.querySelectorAll('.tinymce-editor');
let apiKey = 'no-api-key';
// Try to get from existing meta tag first
@@ -1794,6 +1991,14 @@ function initTinyMCE() {
apiKey = window.NNTmuxConfig.tinymceApiKey;
} else if (bodyTextarea && bodyTextarea.dataset.tinymceApiKey) {
apiKey = bodyTextarea.dataset.tinymceApiKey;
} else if (tinymceEditors.length > 0) {
// Check if any tinymce-editor has the API key
for (let i = 0; i < tinymceEditors.length; i++) {
if (tinymceEditors[i].dataset.tinymceApiKey) {
apiKey = tinymceEditors[i].dataset.tinymceApiKey;
break;
}
}
}
// Create and append meta tag
@@ -1838,7 +2043,7 @@ function initTinyMCE() {
const apiKey = apiKeyMeta ? apiKeyMeta.content : 'no-api-key';
return {
selector: '#body',
selector: '#body, .tinymce-editor',
height: 500,
menubar: true,
skin: darkMode ? 'oxide-dark' : 'oxide',
@@ -1918,18 +2123,25 @@ function initTinyMCE() {
function doInitTinyMCE() {
// Initialize TinyMCE
tinymce.init(getTinyMCEConfig()).then(function(editors) {
if (editors && editors[0]) {
console.log('TinyMCE initialized successfully');
if (editors && editors.length > 0) {
console.log('TinyMCE initialized successfully for ' + editors.length + ' editor(s)');
// Add form submission handler to sync content
const textarea = document.getElementById('body');
if (textarea && textarea.form) {
textarea.form.addEventListener('submit', function(e) {
// Sync all TinyMCE editors before form submission
tinymce.triggerSave();
console.log('TinyMCE content synced to textarea before form submission');
});
}
// Add form submission handler to sync content for all editors
editors.forEach(function(editor) {
const textarea = document.getElementById(editor.id);
if (textarea && textarea.form) {
// Remove any existing listeners to avoid duplicates
const form = textarea.form;
if (!form.hasAttribute('data-tinymce-handler')) {
form.addEventListener('submit', function(e) {
// Sync all TinyMCE editors before form submission
tinymce.triggerSave();
console.log('TinyMCE content synced to textareas before form submission');
});
form.setAttribute('data-tinymce-handler', 'true');
}
}
});
}
}).catch(function(error) {
console.error('TinyMCE initialization failed:', error);
@@ -1939,17 +2151,27 @@ function initTinyMCE() {
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.attributeName === 'class') {
const editor = tinymce.get('body');
if (editor) {
// Save current content
const content = editor.getContent();
// Remove the editor
tinymce.remove('#body');
// Get all active TinyMCE editors
const allEditors = tinymce.editors;
if (allEditors && allEditors.length > 0) {
// Save content from all editors
const editorContents = {};
allEditors.forEach(function(editor) {
editorContents[editor.id] = editor.getContent();
});
// Remove all editors
tinymce.remove();
// Reinitialize with new theme
tinymce.init(getTinyMCEConfig()).then(function(editors) {
// Restore content
if (editors && editors[0]) {
editors[0].setContent(content);
// Restore content to each editor
if (editors && editors.length > 0) {
editors.forEach(function(editor) {
if (editorContents[editor.id]) {
editor.setContent(editorContents[editor.id]);
}
});
}
});
}
@@ -1972,14 +2194,16 @@ function initTinyMCE() {
})
.catch(function(error) {
console.error('Failed to load TinyMCE:', error);
// Show error message to user
const bodyTextarea = document.getElementById('body');
if (bodyTextarea && bodyTextarea.parentElement) {
const errorDiv = document.createElement('div');
errorDiv.className = 'mt-2 p-3 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded';
errorDiv.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>TinyMCE editor failed to load. Please refresh the page or check your internet connection.';
bodyTextarea.parentElement.insertBefore(errorDiv, bodyTextarea.nextSibling);
}
// Show error message to user for all TinyMCE textareas
const textareas = document.querySelectorAll('#body, .tinymce-editor');
textareas.forEach(function(textarea) {
if (textarea && textarea.parentElement) {
const errorDiv = document.createElement('div');
errorDiv.className = 'mt-2 p-3 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 text-red-800 dark:text-red-200 rounded';
errorDiv.innerHTML = '<i class="fas fa-exclamation-triangle mr-2"></i>TinyMCE editor failed to load. Please refresh the page or check your internet connection.';
textarea.parentElement.insertBefore(errorDiv, textarea.nextSibling);
}
});
});
}
@@ -2308,17 +2532,19 @@ function initAdminDashboardCharts() {
cpu24h: null,
ram24h: null,
cpu30d: null,
ram30d: null
ram30d: null,
downloadsMinute: null,
apiHitsMinute: null
};
// Downloads Chart
// Downloads Chart (Hourly)
const downloadsCtx = document.getElementById('downloadsChart');
if (downloadsCtx) {
const downloadsData = JSON.parse(downloadsCtx.getAttribute('data-chart-data') || '[]');
new Chart(downloadsCtx, {
type: 'bar',
data: {
labels: downloadsData.map(d => d.date),
labels: downloadsData.map(d => d.time || d.date),
datasets: [{
label: 'Downloads',
data: downloadsData.map(d => d.count),
@@ -2364,7 +2590,10 @@ function initAdminDashboardCharts() {
},
x: {
ticks: {
color: textColor
color: textColor,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 24
},
grid: {
display: false
@@ -2375,14 +2604,14 @@ function initAdminDashboardCharts() {
});
}
// API Hits Chart
// API Hits Chart (Hourly)
const apiHitsCtx = document.getElementById('apiHitsChart');
if (apiHitsCtx) {
const apiHitsData = JSON.parse(apiHitsCtx.getAttribute('data-chart-data') || '[]');
new Chart(apiHitsCtx, {
type: 'line',
data: {
labels: apiHitsData.map(d => d.date),
labels: apiHitsData.map(d => d.time || d.date),
datasets: [{
label: 'API Hits',
data: apiHitsData.map(d => d.count),
@@ -2434,7 +2663,158 @@ function initAdminDashboardCharts() {
},
x: {
ticks: {
color: textColor
color: textColor,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 24
},
grid: {
color: gridColor,
drawBorder: false
}
}
}
}
});
}
// Downloads Per Minute Chart
const downloadsMinuteCtx = document.getElementById('downloadsMinuteChart');
if (downloadsMinuteCtx) {
const downloadsMinuteData = JSON.parse(downloadsMinuteCtx.getAttribute('data-chart-data') || '[]');
window.adminCharts.downloadsMinute = new Chart(downloadsMinuteCtx, {
type: 'line',
data: {
labels: downloadsMinuteData.map(d => d.time),
datasets: [{
label: 'Downloads',
data: downloadsMinuteData.map(d => d.count),
backgroundColor: 'rgba(34, 197, 94, 0.1)',
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 2,
pointHoverRadius: 4,
pointBackgroundColor: 'rgba(34, 197, 94, 1)',
pointBorderColor: '#ffffff',
pointBorderWidth: 1,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff',
titleColor: textColor,
bodyColor: textColor,
borderColor: gridColor,
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return 'Downloads: ' + context.parsed.y.toLocaleString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: textColor,
precision: 0
},
grid: {
color: gridColor,
drawBorder: false
}
},
x: {
ticks: {
color: textColor,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 12
},
grid: {
color: gridColor,
drawBorder: false
}
}
}
}
});
}
// API Hits Per Minute Chart
const apiHitsMinuteCtx = document.getElementById('apiHitsMinuteChart');
if (apiHitsMinuteCtx) {
const apiHitsMinuteData = JSON.parse(apiHitsMinuteCtx.getAttribute('data-chart-data') || '[]');
window.adminCharts.apiHitsMinute = new Chart(apiHitsMinuteCtx, {
type: 'line',
data: {
labels: apiHitsMinuteData.map(d => d.time),
datasets: [{
label: 'API Hits',
data: apiHitsMinuteData.map(d => d.count),
backgroundColor: 'rgba(147, 51, 234, 0.1)',
borderColor: 'rgba(147, 51, 234, 1)',
borderWidth: 2,
fill: true,
tension: 0.4,
pointRadius: 2,
pointHoverRadius: 4,
pointBackgroundColor: 'rgba(147, 51, 234, 1)',
pointBorderColor: '#ffffff',
pointBorderWidth: 1,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff',
titleColor: textColor,
bodyColor: textColor,
borderColor: gridColor,
borderWidth: 1,
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return 'API Hits: ' + context.parsed.y.toLocaleString();
}
}
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
color: textColor,
precision: 0
},
grid: {
color: gridColor,
drawBorder: false
}
},
x: {
ticks: {
color: textColor,
maxRotation: 45,
minRotation: 45,
maxTicksLimit: 12
},
grid: {
color: gridColor,
@@ -2850,6 +3230,30 @@ function startSystemMetricsAutoRefresh() {
});
}
// Update user activity minute charts
function updateUserActivityMinutes() {
fetch('/admin/api/user-activity/minutes')
.then(response => response.json())
.then(data => {
// Update Downloads Per Minute chart
if (window.adminCharts && window.adminCharts.downloadsMinute) {
window.adminCharts.downloadsMinute.data.labels = data.downloads.map(d => d.time);
window.adminCharts.downloadsMinute.data.datasets[0].data = data.downloads.map(d => d.count);
window.adminCharts.downloadsMinute.update('none');
}
// Update API Hits Per Minute chart
if (window.adminCharts && window.adminCharts.apiHitsMinute) {
window.adminCharts.apiHitsMinute.data.labels = data.api_hits.map(d => d.time);
window.adminCharts.apiHitsMinute.data.datasets[0].data = data.api_hits.map(d => d.count);
window.adminCharts.apiHitsMinute.update('none');
}
})
.catch(error => {
console.error('Error fetching user activity minutes:', error);
});
}
// Initial update
updateCurrentMetrics();
@@ -2858,6 +3262,11 @@ function startSystemMetricsAutoRefresh() {
// Update historical charts every 5 minutes (to reduce load)
setInterval(updateHistoricalCharts, 300000);
// Update user activity minute charts every 60 seconds (1 minute)
if (window.adminCharts && (window.adminCharts.downloadsMinute || window.adminCharts.apiHitsMinute)) {
setInterval(updateUserActivityMinutes, 60000);
}
}
// Admin Groups Management
@@ -3829,3 +4238,128 @@ window.confirmBulkAction = function(event) {
return true;
};
// Movies Layout Toggle Functionality
function initMoviesLayoutToggle() {
const layoutToggle = document.getElementById('layoutToggle');
const layoutToggleText = document.getElementById('layoutToggleText');
const moviesGrid = document.getElementById('moviesGrid');
if (!layoutToggle || !layoutToggleText || !moviesGrid) {
return; // Not on movies page
}
// Get saved layout preference from data attribute (from database)
// 1 = 1-column, 2 = 2-columns
let currentLayout = parseInt(moviesGrid.dataset.userLayout) || 2;
// Apply the saved layout on page load
applyLayout(currentLayout);
// Toggle layout on button click
layoutToggle.addEventListener('click', function() {
if (currentLayout === 2) {
currentLayout = 1;
} else {
currentLayout = 2;
}
// Save preference to database via AJAX
fetch('/movies/update-layout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content
},
body: JSON.stringify({
layout: currentLayout
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Apply layout after successful save
applyLayout(currentLayout);
}
})
.catch(error => {
console.error('Error saving layout preference:', error);
// Still apply layout even if save failed
applyLayout(currentLayout);
});
});
function applyLayout(layout) {
// Get all movie poster images and placeholder divs
const posterImages = moviesGrid.querySelectorAll('img[alt], .bg-gray-200.dark\\:bg-gray-700');
// Get all release card containers
const releaseCardContainers = moviesGrid.querySelectorAll('.release-card-container');
if (layout === 1) {
// 1-column layout: larger images (w-48 h-72, original size)
moviesGrid.classList.remove('lg:grid-cols-2');
moviesGrid.classList.add('grid-cols-1');
layoutToggleText.textContent = '1 Column';
layoutToggle.querySelector('i').className = 'fas fa-th-list mr-2';
// Update image sizes to larger
posterImages.forEach(el => {
el.classList.remove('w-32', 'h-48');
el.classList.add('w-48', 'h-72');
});
// Update release card layout for 1-column view - side by side on all screens
releaseCardContainers.forEach(container => {
// Change container to flex-row with items on sides
container.classList.remove('space-y-2');
container.classList.add('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
// Make info wrapper take available space
const infoWrapper = container.querySelector('.release-info-wrapper');
if (infoWrapper) {
infoWrapper.classList.add('flex-1', 'min-w-0');
}
// Make actions wrapper stay on the right in a horizontal row
const actionsWrapper = container.querySelector('.release-actions');
if (actionsWrapper) {
actionsWrapper.classList.remove('flex-wrap');
actionsWrapper.classList.add('flex-shrink-0', 'flex-row', 'items-center');
}
});
} else {
// 2-column layout: smaller images (w-32 h-48)
moviesGrid.classList.remove('grid-cols-1');
moviesGrid.classList.add('lg:grid-cols-2');
layoutToggleText.textContent = '2 Columns';
layoutToggle.querySelector('i').className = 'fas fa-th-large mr-2';
// Update image sizes to smaller
posterImages.forEach(el => {
el.classList.remove('w-48', 'h-72');
el.classList.add('w-32', 'h-48');
});
// Update release card layout for 2-column view - stacked vertically
releaseCardContainers.forEach(container => {
// Change back to stacked layout
container.classList.add('space-y-2');
container.classList.remove('flex', 'flex-row', 'items-start', 'justify-between', 'gap-3');
// Remove special styling from info wrapper
const infoWrapper = container.querySelector('.release-info-wrapper');
if (infoWrapper) {
infoWrapper.classList.remove('flex-1', 'min-w-0');
}
// Restore normal flex-wrap for actions wrapper
const actionsWrapper = container.querySelector('.release-actions');
if (actionsWrapper) {
actionsWrapper.classList.add('flex-wrap', 'items-center');
actionsWrapper.classList.remove('flex-shrink-0', 'flex-row');
}
});
}
}
}
+26 -4
View File
@@ -216,10 +216,10 @@
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-chart-bar mr-2 text-green-600 dark:text-green-400"></i>
Downloads (Last 7 Days)
Downloads (Last 7 Days - Hourly)
</h4>
<div class="chart-container">
<canvas id="downloadsChart" data-chart-data="{{ json_encode($userStats['downloads_per_day']) }}"></canvas>
<canvas id="downloadsChart" data-chart-data="{{ json_encode($userStats['downloads_per_hour']) }}"></canvas>
</div>
</div>
@@ -227,10 +227,32 @@
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-chart-area mr-2 text-purple-600 dark:text-purple-400"></i>
API Hits (Last 7 Days)
API Hits (Last 7 Days - Hourly)
</h4>
<div class="chart-container">
<canvas id="apiHitsChart" data-chart-data="{{ json_encode($userStats['api_hits_per_day']) }}"></canvas>
<canvas id="apiHitsChart" data-chart-data="{{ json_encode($userStats['api_hits_per_hour']) }}"></canvas>
</div>
</div>
<!-- Downloads Per Minute Chart -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-chart-line mr-2 text-green-600 dark:text-green-400"></i>
Downloads (Last 60 Minutes)
</h4>
<div class="chart-container">
<canvas id="downloadsMinuteChart" data-chart-data="{{ json_encode($userStats['downloads_per_minute']) }}"></canvas>
</div>
</div>
<!-- API Hits Per Minute Chart -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
<h4 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4 flex items-center">
<i class="fas fa-chart-line mr-2 text-purple-600 dark:text-purple-400"></i>
API Hits (Last 60 Minutes)
</h4>
<div class="chart-container">
<canvas id="apiHitsMinuteChart" data-chart-data="{{ json_encode($userStats['api_hits_per_minute']) }}"></canvas>
</div>
</div>
</div>
+2 -2
View File
@@ -115,7 +115,7 @@
value="{{ $movie['imdbid'] ?? $movie->imdbid ?? '' }}"
readonly
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-gray-100 dark:bg-gray-700 dark:text-gray-300">
<a href="https://www.imdb.com/title/tt{{ $movie['imdbid'] ?? $movie->imdbid ?? '' }}"
<a href="{{ $site['dereferrer_link'] }}https://www.imdb.com/title/tt{{ $movie['imdbid'] ?? $movie->imdbid ?? '' }}"
target="_blank"
class="px-4 py-2 bg-yellow-500 text-white rounded-md hover:bg-yellow-600">
<i class="fa fa-external-link"></i> View on IMDB
@@ -320,7 +320,7 @@
@foreach($movielist as $movie)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-200">
<a href="https://www.imdb.com/title/tt{{ $movie->imdbid }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">
<a href="{{ $site['dereferrer_link'] }}https://www.imdb.com/title/tt{{ $movie->imdbid }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ $movie->imdbid }}
</a>
</td>
+1 -1
View File
@@ -86,7 +86,7 @@
@foreach($movielist as $movie)
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-200">
<a href="https://www.imdb.com/title/tt{{ $movie->imdbid }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">
<a href="{{ $site['dereferrer_link'] }}https://www.imdb.com/title/tt{{ $movie->imdbid }}" target="_blank" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ $movie->imdbid }}
</a>
</td>
@@ -112,7 +112,7 @@
<div class="flex items-center">
<i class="fa fa-calendar-plus-o text-gray-400 mr-2"></i>
<span title="{{ $release->adddate }}">
{{ \Carbon\Carbon::parse($release->adddate)->format('Y-m-d H:i') }}
{{ userDate($release->adddate, 'Y-m-d H:i') }}
</span>
</div>
</td>
@@ -120,7 +120,7 @@
<div class="flex items-center">
<i class="fa fa-calendar text-gray-400 mr-2"></i>
<span title="{{ $release->postdate }}">
{{ \Carbon\Carbon::parse($release->postdate)->format('Y-m-d H:i') }}
{{ userDate($release->postdate, 'Y-m-d H:i') }}
</span>
</div>
</td>
@@ -70,10 +70,10 @@
{{ $release->totalpart ?? 0 }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ \Carbon\Carbon::parse($release->adddate)->format('Y-m-d H:i') }}
{{ userDate($release->adddate, 'Y-m-d H:i') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ \Carbon\Carbon::parse($release->postdate)->format('Y-m-d H:i') }}
{{ userDate($release->postdate, 'Y-m-d H:i') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ $release->grabs ?? 0 }}
+4 -21
View File
@@ -106,9 +106,10 @@
<label for="tandc" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
<i class="fa fa-gavel mr-1"></i>Terms and Conditions
</label>
<textarea id="tandc" name="tandc" rows="5"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">{{ $site['tandc'] ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">Text displayed in the terms and conditions page.</p>
<textarea id="tandc" name="tandc" rows="15"
data-tinymce-api-key="{{ config('tinymce.api_key', 'no-api-key') }}"
class="tinymce-editor w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">{{ $site['tandc'] ?? '' }}</textarea>
<p class="mt-1 text-sm text-gray-500">Text displayed in the terms and conditions page. Use the rich text editor to format your content.</p>
</div>
</div>
</div>
@@ -521,24 +522,6 @@
</div>
</div>
<!-- Path Settings -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Path Settings</h2>
<div class="space-y-4">
<div>
<label for="nzbpath" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">NZB Path</label>
<input type="text" id="nzbpath" name="nzbpath" value="{{ $site['nzbpath'] ?? '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<p class="mt-1 text-sm text-gray-500">Path where NZB files are stored</p>
</div>
<div>
<label for="coverspath" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Covers Path</label>
<input type="text" id="coverspath" name="coverspath" value="{{ $coversPath ?? '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
<p class="mt-1 text-sm text-gray-500">Path where cover images are stored</p>
</div>
</div>
</div>
<!-- Password Settings -->
<div class="border-b border-gray-200 dark:border-gray-700 pb-6">
+1 -37
View File
@@ -51,42 +51,6 @@
</div>
@endif
<!-- Top Downloads -->
@if(!empty($topdownloads) && count($topdownloads) > 0)
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl shadow-md overflow-hidden border border-gray-300 dark:border-gray-700 hover:shadow-lg transition-shadow duration-300">
<div class="bg-gradient-to-r from-purple-400 to-purple-500 px-6 py-4">
<div class="flex items-center space-x-3">
<div class="flex items-center justify-center w-10 h-10 bg-purple-700 bg-opacity-40 rounded-lg">
<i class="fa fa-download text-purple-100 text-lg"></i>
</div>
<h2 class="text-xl font-bold text-white">Top Downloads</h2>
</div>
</div>
<div class="p-6">
<div class="space-y-3">
@foreach($topdownloads as $index => $download)
<div class="flex items-center justify-between p-4 bg-white dark:bg-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors duration-200 border border-gray-200 dark:border-transparent">
<div class="flex items-center space-x-4 flex-1 min-w-0">
<div class="flex items-center justify-center w-8 h-8 rounded-full bg-purple-400 dark:bg-purple-600 text-white font-bold text-sm flex-shrink-0 shadow-sm">
{{ $index + 1 }}
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-700 dark:text-gray-100 truncate" title="{{ $download['searchname'] }}">
{{ $download['searchname'] }}
</p>
</div>
</div>
<div class="flex items-center space-x-2 ml-4 flex-shrink-0">
<span class="px-4 py-2 bg-purple-600 dark:bg-purple-500 text-white rounded-full text-sm font-bold shadow-md">
{{ number_format($download['grabs']) }}
</span>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endif
<!-- Recently Added -->
@if(!empty($recent) && count($recent) > 0)
@@ -182,7 +146,7 @@
@endif
</div>
@if(empty($topgrabs) && empty($topdownloads) && empty($recent) && empty($usersbymonth) && empty($usersbyrole))
@if(empty($topgrabs) && empty($recent) && empty($usersbymonth) && empty($usersbyrole))
<div class="bg-gray-50 dark:bg-gray-800 rounded-xl shadow-md p-12 text-center border border-gray-300 dark:border-gray-700">
<div class="flex justify-center mb-6">
<div class="flex items-center justify-center w-24 h-24 bg-gradient-to-br from-gray-200 to-gray-300 dark:from-gray-700 dark:to-gray-600 rounded-full">
@@ -111,6 +111,7 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Host</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Country</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Verified</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Bad User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
+4 -2
View File
@@ -48,6 +48,8 @@
<div class="flex flex-wrap gap-2 text-sm">
<a href="{{ route('series') }}" class="text-blue-600 dark:text-blue-400 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 dark:hover:text-blue-300" title="View available TV series">Series List</a>
<span class="text-gray-400 dark:text-gray-500">|</span>
<a href="{{ route('trending-tv') }}" class="text-orange-600 dark:text-orange-400 hover:text-orange-800 dark:hover:text-orange-300" title="View trending TV shows"><i class="fas fa-fire mr-1"></i>Trending TV</a>
<span class="text-gray-400 dark:text-gray-500">|</span>
<a href="{{ route('myshows') }}" class="text-blue-600 dark:text-blue-400 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 dark:hover:text-blue-300" title="Manage your shows">Manage My Shows</a>
<span class="text-gray-400 dark:text-gray-500">|</span>
<a href="{{ url('/rss/myshows?dl=1&i=' . auth()->id() . '&api_token=' . auth()->user()->api_token) }}" class="text-blue-600 dark:text-blue-400 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 dark:hover:text-blue-300" title="RSS Feed">RSS Feed</a>
@@ -194,7 +196,7 @@
@endif
@if(!empty($result->postdate))
<span class="inline-flex items-center px-2 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<i class="fas fa-calendar mr-1"></i> Posted: {{ \Carbon\Carbon::parse($result->postdate)->format('M d, Y H:i') }}
<i class="fas fa-calendar mr-1"></i> Posted: {{ userDate($result->postdate, 'M d, Y H:i') }}
</span>
@endif
@if(!empty($result->fromname))
@@ -212,7 +214,7 @@
</span>
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400 dark:text-gray-400">
{{ \Carbon\Carbon::parse($result->adddate)->diffForHumans() }}
{{ userDateDiffForHumans($result->adddate) }}
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400 dark:text-gray-400">
{{ $result->size_formatted ?? number_format($result->size / 1073741824, 2) . ' GB' }}
-421
View File
@@ -139,424 +139,3 @@
</div>
</div>
@endsection
@push('styles')
<style>
/* Confirmation Modal Styles */
.confirmation-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 99999;
animation: fadeIn 0.2s ease-out;
}
.confirmation-modal-content {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 90%;
padding: 24px;
animation: slideUp 0.3s ease-out;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-content {
background: #1f2937;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
}
.confirmation-modal-header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
.confirmation-modal-icon {
width: 48px;
height: 48px;
background-color: #fee2e2;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 16px;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-icon {
background-color: #7f1d1d;
}
}
.confirmation-modal-icon i {
color: #ef4444;
font-size: 24px;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-icon i {
color: #fca5a5;
}
}
.confirmation-modal-title {
font-size: 20px;
font-weight: 600;
color: #1f2937;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-title {
color: #f3f4f6;
}
}
.confirmation-modal-body {
color: #6b7280;
margin-bottom: 24px;
line-height: 1.6;
}
@media (prefers-color-scheme: dark) {
.confirmation-modal-body {
color: #d1d5db;
}
}
.confirmation-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
}
.confirmation-modal-footer button {
padding: 10px 20px;
border-radius: 6px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
font-size: 14px;
}
.btn-cancel {
background-color: #f3f4f6;
color: #374151;
}
.btn-cancel:hover {
background-color: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
.btn-cancel {
background-color: #374151;
color: #d1d5db;
}
.btn-cancel:hover {
background-color: #4b5563;
}
}
.btn-confirm {
background-color: #ef4444;
color: white;
}
.btn-confirm:hover {
background-color: #dc2626;
}
@media (prefers-color-scheme: dark) {
.btn-confirm {
background-color: #dc2626;
}
.btn-confirm:hover {
background-color: #b91c1c;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
</style>
@endpush
@push('scripts')
<script>
console.log('Script tag loaded');
document.addEventListener('DOMContentLoaded', function() {
'use strict';
console.log('Cart page JavaScript initialized');
console.log('showToast available:', typeof showToast);
// Show custom confirmation modal
function showConfirmation(message, onConfirm) {
console.log('showConfirmation called with message:', message);
const modal = document.createElement('div');
modal.className = 'confirmation-modal';
modal.innerHTML = `
<div class="confirmation-modal-content">
<div class="confirmation-modal-header">
<div class="confirmation-modal-icon">
<i class="fa fa-exclamation-triangle"></i>
</div>
<h3 class="confirmation-modal-title">Confirm Deletion</h3>
</div>
<div class="confirmation-modal-body">
${message}
</div>
<div class="confirmation-modal-footer">
<button class="btn-cancel">Cancel</button>
<button class="btn-confirm">Delete</button>
</div>
</div>
`;
document.body.appendChild(modal);
console.log('Modal added to DOM, body children:', document.body.children.length);
const cancelBtn = modal.querySelector('.btn-cancel');
const confirmBtn = modal.querySelector('.btn-confirm');
function closeModal() {
modal.style.animation = 'fadeOut 0.2s ease-out';
setTimeout(() => modal.remove(), 200);
}
cancelBtn.addEventListener('click', function() {
console.log('Cancel clicked');
closeModal();
});
modal.addEventListener('click', function(e) {
if (e.target === modal) {
console.log('Backdrop clicked');
closeModal();
}
});
confirmBtn.addEventListener('click', function() {
console.log('Confirm clicked');
closeModal();
onConfirm();
});
// Focus on cancel button by default
setTimeout(() => cancelBtn.focus(), 100);
// ESC key to close
const escHandler = function(e) {
if (e.key === 'Escape') {
console.log('ESC pressed');
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
// Check all checkbox functionality
const checkAll = document.getElementById('check-all');
const checkboxes = document.querySelectorAll('.cart-checkbox');
console.log('Check-all element:', checkAll);
console.log('Found checkboxes:', checkboxes.length);
// Function to update the check-all checkbox state
function updateCheckAllState() {
if (!checkAll || checkboxes.length === 0) return;
const checkedCount = Array.from(checkboxes).filter(cb => cb.checked).length;
checkAll.checked = checkedCount === checkboxes.length;
checkAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
console.log('Check-all state updated:', { checkedCount, total: checkboxes.length, checked: checkAll.checked });
}
// Handle check-all checkbox change
if (checkAll) {
console.log('Setting up check-all listener');
checkAll.addEventListener('change', function() {
console.log('Check-all changed, new state:', this.checked);
const isChecked = this.checked;
checkboxes.forEach(checkbox => {
checkbox.checked = isChecked;
});
console.log('Updated all checkboxes to:', isChecked);
});
}
// Handle individual checkbox changes
checkboxes.forEach(checkbox => {
checkbox.addEventListener('change', function() {
console.log('Individual checkbox changed');
updateCheckAllState();
});
});
// Initialize the check-all state on page load
updateCheckAllState();
// Download selected
document.querySelectorAll('.nzb_multi_operations_download_cart').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
console.log('Download button clicked');
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
if (selected.length === 0) {
if (typeof showToast === 'function') {
showToast('Please select at least one item', 'error');
} else {
alert('Please select at least one item');
}
return;
}
// Download all selected NZBs
selected.forEach(guid => {
window.open('/getnzb?id=' + guid, '_blank');
});
if (typeof showToast === 'function') {
showToast('Downloading ' + selected.length + ' item(s)', 'success');
}
});
});
// Delete selected
document.querySelectorAll('.nzb_multi_operations_cartdelete').forEach(btn => {
btn.addEventListener('click', function(e) {
e.preventDefault();
console.log('Bulk delete button clicked');
const selected = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.value);
if (selected.length === 0) {
if (typeof showToast === 'function') {
showToast('Please select at least one item', 'error');
} else {
alert('Please select at least one item');
}
return;
}
showConfirmation(
`Are you sure you want to delete <strong>${selected.length}</strong> item(s) from your cart?`,
function() {
console.log('Deletion confirmed, sending request...');
// Delete via AJAX
fetch('/cart/delete/' + selected.join(','), {
method: 'POST',
headers: {
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
}
})
.then(response => {
console.log('Delete response:', response);
if (response.ok) {
if (typeof showToast === 'function') {
showToast('Items deleted successfully', 'success');
}
setTimeout(() => window.location.reload(), 1000);
} else {
if (typeof showToast === 'function') {
showToast('Failed to delete items', 'error');
} else {
alert('Failed to delete items');
}
}
})
.catch(error => {
console.error('Error:', error);
if (typeof showToast === 'function') {
showToast('Failed to delete items', 'error');
} else {
alert('Failed to delete items');
}
});
}
);
});
});
// Individual delete confirmation
const deleteLinks = document.querySelectorAll('.cart-delete-link');
console.log('Found', deleteLinks.length, 'delete links');
deleteLinks.forEach(function(link) {
link.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
console.log('Delete link clicked');
const releaseName = this.getAttribute('data-release-name');
const deleteUrl = this.getAttribute('data-delete-url');
console.log('Release name:', releaseName);
console.log('Delete URL:', deleteUrl);
showConfirmation(
`Are you sure you want to remove <strong>${releaseName}</strong> from your cart?`,
function() {
console.log('Navigating to:', deleteUrl);
if (typeof showToast === 'function') {
showToast('Removing item from cart...', 'info');
}
setTimeout(() => {
window.location.href = deleteUrl;
}, 500);
}
);
});
});
console.log('All event listeners attached');
});
</script>
@endpush
+3 -3
View File
@@ -184,7 +184,7 @@
<div class="flex items-center text-gray-500 text-sm mb-3">
<i class="fa fa-clock-o mr-2"></i>
<span>Added {{ \Carbon\Carbon::parse($result->adddate)->diffForHumans() }}</span>
<span>Added {{ userDateDiffForHumans($result->adddate) }}</span>
</div>
<div class="flex flex-wrap gap-2 text-xs mb-3">
@@ -194,8 +194,8 @@
</span>
@endif
@if(!empty($result->postdate))
<span class="inline-flex items-center px-2 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<i class="fas fa-calendar mr-1"></i> Posted: {{ \Carbon\Carbon::parse($result->postdate)->format('M d, Y H:i') }}
<span>
<i class="fas fa-calendar mr-1"></i> Posted: {{ userDate($result->postdate, 'M d, Y H:i') }}
</span>
@endif
@if(!empty($result->fromname))
+4 -4
View File
@@ -229,7 +229,7 @@
<div>
<dt class="text-sm font-medium text-gray-600 dark:text-gray-400">TVDB</dt>
<dd class="mt-1">
<a href="https://thetvdb.com/?tab=series&id={{ $showTvdb }}" target="_blank" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
<a href="{{ $site['dereferrer_link'] }}https://thetvdb.com/?tab=series&id={{ $showTvdb }}" target="_blank" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
View on TVDB <i class="fas fa-external-link-alt text-xs"></i>
</a>
</dd>
@@ -880,7 +880,7 @@
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Added</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ \Carbon\Carbon::parse($release->adddate)->format('M d, Y H:i') }}</dd>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ userDate($release->adddate, 'M d, Y H:i') }}</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Group</dt>
@@ -888,7 +888,7 @@
</div>
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Posted</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ \Carbon\Carbon::parse($release->postdate)->format('M d, Y H:i') }}</dd>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ userDate($release->postdate, 'M d, Y H:i') }}</dd>
</div>
@if(!empty($release->fromname))
<div>
@@ -908,7 +908,7 @@
<div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">IMDB</dt>
<dd class="mt-1">
<a href="https://www.imdb.com/title/tt{{ $release->imdbid }}" target="_blank" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
<a href="{{ $site['dereferrer_link'] }}https://www.imdb.com/title/tt{{ $release->imdbid }}" target="_blank" class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300">
View on IMDB <i class="fas fa-external-link-alt text-xs"></i>
</a>
</dd>
+1 -1
View File
@@ -54,7 +54,7 @@
<aside id="sidebar" class="hidden md:flex md:flex-col w-64 bg-gray-900 dark:bg-gray-950 text-white transition-all duration-300">
<div class="flex items-center justify-between p-4 border-b border-gray-800 dark:border-gray-700">
<a href="{{ $site['home_link'] ?? url('/') }}" class="flex items-center space-x-3">
<img src="{{ asset('assets/images/newznab-logo.svg') }}" alt="Newznab Logo" class="w-12 h-12" aria-hidden="true">
<img src="{{ asset('assets/images/logo.svg') }}" alt="{{ config('app.name') }} Logo" class="w-12 h-12" aria-hidden="true">
<span class="text-xl font-semibold">{{ config('app.name') }}</span>
</a>
</div>
+54 -38
View File
@@ -23,6 +23,18 @@
<!-- Movies Filter Section -->
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900 border-b border-gray-200">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-800 dark:text-gray-200">Filter Movies</h2>
<div class="flex gap-2">
<!-- Layout Toggle Button -->
<button id="layoutToggle" class="inline-flex items-center px-4 py-2 bg-gray-600 dark:bg-gray-700 text-white rounded-lg hover:bg-gray-700 dark:hover:bg-gray-800 transition shadow-md" title="Toggle layout">
<i class="fas {{ ($movie_layout ?? 2) == 1 ? 'fa-th-list' : 'fa-th-large' }} mr-2"></i> <span id="layoutToggleText">{{ ($movie_layout ?? 2) == 1 ? '1 Column' : '2 Columns' }}</span>
</button>
<a href="{{ route('trending-movies') }}" class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-orange-500 to-red-600 text-white rounded-lg hover:from-orange-600 hover:to-red-700 transition shadow-md">
<i class="fas fa-fire mr-2"></i> View Trending Movies
</a>
</div>
</div>
<form method="get" action="{{ route('Movies') }}" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
@@ -84,32 +96,32 @@
</div>
</div>
<div class="space-y-4">
<div id="moviesGrid" class="grid {{ ($movie_layout ?? 2) == 1 ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2' }} gap-4" data-user-layout="{{ $movie_layout ?? 2 }}">
@foreach($results as $result)
@php
// Get the first GUID from the comma-separated list
$guid = isset($result->grp_release_guid) ? explode(',', $result->grp_release_guid)[0] : null;
@endphp
<div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<div class="flex flex-col md:flex-row">
<div class="flex flex-row">
<!-- Movie Poster -->
<div class="flex-shrink-0">
@if($guid)
<a href="{{ url('/details/' . $guid) }}" class="block">
@if(isset($result->cover) && $result->cover)
<img src="{{ $result->cover }}" alt="{{ $result->title }}" class="w-48 h-72 object-cover rounded w-192 h-288">
<img src="{{ $result->cover }}" alt="{{ $result->title }}" class="{{ ($movie_layout ?? 2) == 1 ? 'w-48 h-72' : 'w-32 h-48' }} object-cover">
@else
<div class="w-48 h-72 bg-gray-200 dark:bg-gray-700 flex items-center justify-center rounded w-192 h-288">
<i class="fas fa-film text-gray-400 text-3xl"></i>
<div class="{{ ($movie_layout ?? 2) == 1 ? 'w-48 h-72' : 'w-32 h-48' }} bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<i class="fas fa-film text-gray-400 text-2xl"></i>
</div>
@endif
</a>
@else
@if(isset($result->cover) && $result->cover)
<img src="{{ $result->cover }}" alt="{{ $result->title }}" class="w-48 h-72 object-cover rounded w-192 h-288">
<img src="{{ $result->cover }}" alt="{{ $result->title }}" class="{{ ($movie_layout ?? 2) == 1 ? 'w-48 h-72' : 'w-32 h-48' }} object-cover">
@else
<div class="w-48 h-72 bg-gray-200 dark:bg-gray-700 flex items-center justify-center rounded w-192 h-288">
<i class="fas fa-film text-gray-400 text-3xl"></i>
<div class="{{ ($movie_layout ?? 2) == 1 ? 'w-48 h-72' : 'w-32 h-48' }} bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<i class="fas fa-film text-gray-400 text-2xl"></i>
</div>
@endif
@endif
@@ -154,17 +166,17 @@
<!-- External Links -->
<div class="flex items-center gap-3 mt-2 text-xs">
@if(isset($result->imdbid) && $result->imdbid)
<a href="https://www.imdb.com/title/tt{{ $result->imdbid }}" target="_blank" class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 transition">
<a href="{{ $site['dereferrer_link'] }}https://www.imdb.com/title/tt{{ $result->imdbid }}" target="_blank" class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 rounded hover:bg-yellow-200 transition">
<i class="fab fa-imdb mr-1"></i> IMDb
</a>
@endif
@if(isset($result->tmdbid) && $result->tmdbid)
<a href="https://www.themoviedb.org/movie/{{ $result->tmdbid }}" target="_blank" class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 transition">
<a href="{{ $site['dereferrer_link'] }}https://www.themoviedb.org/movie/{{ $result->tmdbid }}" target="_blank" class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 rounded hover:bg-blue-200 transition">
<i class="fas fa-film mr-1"></i> TMDb
</a>
@endif
@if(isset($result->traktid) && $result->traktid)
<a href="https://trakt.tv/movies/{{ $result->traktid }}" target="_blank" class="inline-flex items-center px-2 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 transition">
<a href="{{ $site['dereferrer_link'] }}https://trakt.tv/movies/{{ $result->traktid }}" target="_blank" class="inline-flex items-center px-2 py-1 bg-red-100 text-red-800 rounded hover:bg-red-200 transition">
<i class="fas fa-heart mr-1"></i> Trakt
</a>
@endif
@@ -238,13 +250,31 @@
<div class="space-y-2">
@foreach($releaseNames as $index => $releaseName)
@if($releaseName && isset($releaseGuids[$index]))
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-3 border border-gray-200">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-2">
<div class="flex-1 min-w-0">
<a href="{{ url('/details/' . $releaseGuids[$index]) }}" class="text-sm text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:text-blue-400 font-medium block truncate" title="{{ $releaseName }}">
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-2 border border-gray-200">
<div class="release-card-container {{ ($movie_layout ?? 2) == 1 ? 'flex flex-row items-start justify-between gap-3' : 'space-y-2' }}">
<div class="release-info-wrapper {{ ($movie_layout ?? 2) == 1 ? 'flex-1 min-w-0' : '' }}">
<!-- Release Name -->
<a href="{{ url('/details/' . $releaseGuids[$index]) }}" class="text-sm text-gray-800 dark:text-gray-200 hover:text-blue-600 dark:hover:text-blue-400 font-medium block break-all" title="{{ $releaseName }}">
{{ $releaseName }}
</a>
<div class="flex flex-wrap items-center gap-2 mt-1">
<!-- Info Badges -->
<div class="flex flex-wrap items-center gap-1.5 mt-2">
@if(isset($releaseSizes[$index]))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
<i class="fas fa-hdd mr-1"></i>{{ number_format($releaseSizes[$index] / 1073741824, 2) }} GB
</span>
@endif
@if(isset($releasePostDates[$index]))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
<i class="fas fa-calendar-alt mr-1"></i>{{ userDate($releasePostDates[$index],'M d, Y H:i') }}
</span>
@endif
@if(isset($releaseAddDates[$index]))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300">
<i class="fas fa-plus-circle mr-1"></i>{{ userDateDiffForHumans($releaseAddDates[$index]) }}
</span>
@endif
@if(isset($releaseHasPreview[$index]) && $releaseHasPreview[$index] == 1)
<button type="button"
class="preview-badge inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-800 dark:text-purple-200 hover:bg-purple-200 dark:hover:bg-purple-800 transition cursor-pointer"
@@ -275,32 +305,17 @@
</span>
@endif
</div>
<div class="flex flex-wrap items-center gap-3 mt-1 text-xs text-gray-500">
@if(isset($releaseSizes[$index]))
<span>
<i class="fas fa-hdd mr-1"></i>{{ number_format($releaseSizes[$index] / 1073741824, 2) }} GB
</span>
@endif
@if(isset($releasePostDates[$index]))
<span>
<i class="fas fa-calendar-alt mr-1"></i>Posted: {{ \Carbon\Carbon::parse($releasePostDates[$index])->format('M d, Y') }}
</span>
@endif
@if(isset($releaseAddDates[$index]))
<span>
<i class="fas fa-plus-circle mr-1"></i>Added: {{ \Carbon\Carbon::parse($releaseAddDates[$index])->diffForHumans() }}
</span>
@endif
</div>
</div>
<div class="flex gap-2 flex-shrink-0">
<a href="{{ url('/getnzb/' . $releaseGuids[$index]) }}" class="inline-flex items-center px-3 py-1 bg-green-600 dark:bg-green-700 text-white text-xs font-medium rounded hover:bg-green-700 dark:hover:bg-green-800 transition">
<!-- Action Buttons -->
<div class="release-actions flex {{ ($movie_layout ?? 2) == 1 ? 'flex-shrink-0 flex-row items-center' : 'flex-wrap items-center' }} gap-1.5">
<a href="{{ url('/getnzb/' . $releaseGuids[$index]) }}" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-600 dark:bg-green-700 text-white hover:bg-green-700 dark:hover:bg-green-800 transition">
<i class="fas fa-download mr-1"></i> Download
</a>
<button class="add-to-cart inline-flex items-center px-3 py-1 bg-blue-600 dark:bg-blue-700 text-white text-xs font-medium rounded hover:bg-blue-700 dark:hover:bg-blue-800 transition" data-guid="{{ $releaseGuids[$index] }}">
<button class="add-to-cart inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-600 dark:bg-blue-700 text-white hover:bg-blue-700 dark:hover:bg-blue-800 transition" data-guid="{{ $releaseGuids[$index] }}">
<i class="fas fa-shopping-cart mr-1"></i> Cart
</button>
<a href="{{ url('/details/' . $releaseGuids[$index]) }}" class="inline-flex items-center px-3 py-1 bg-gray-600 text-white text-xs font-medium rounded hover:bg-gray-700 transition">
<a href="{{ url('/details/' . $releaseGuids[$index]) }}" class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-800 transition">
<i class="fas fa-info-circle mr-1"></i> Details
</a>
</div>
@@ -331,7 +346,7 @@
@endif
<!-- Preview/Sample Image Modal -->
<div id="previewModal" class="hidden fixed inset-0 bg-black bg-opacity-75 items-center justify-center p-4" style="display: none; z-index: 9999 !important;">
<div id="previewModal" class="hidden fixed inset-0 bg-black bg-opacity-75 items-center justify-center p-4">
<div class="relative max-w-4xl w-full">
<button type="button" onclick="closePreviewModal()" class="absolute top-4 right-4 text-white hover:text-gray-300 text-3xl font-bold z-10">
<i class="fas fa-times"></i>
@@ -349,5 +364,6 @@
<!-- NFO Modal -->
@include('partials.nfo-modal')
</div>
@endsection
@@ -108,17 +108,17 @@
<h2 class="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3">External Links</h2>
<div class="flex flex-wrap gap-3">
@if(!empty($movie['imdbid'] ?? null))
<a href="https://www.imdb.com/title/tt{{ $movie['imdbid'] }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-yellow-100 text-yellow-800 rounded-lg hover:bg-yellow-200 transition">
<a href="{{ $site['dereferrer_link'] }}https://www.imdb.com/title/tt{{ $movie['imdbid'] }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-yellow-100 text-yellow-800 rounded-lg hover:bg-yellow-200 transition">
<i class="fab fa-imdb mr-2 text-xl"></i> View on IMDb
</a>
@endif
@if(!empty($movie['tmdbid'] ?? null))
<a href="https://www.themoviedb.org/movie/{{ $movie['tmdbid'] }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200 transition">
<a href="{{ $site['dereferrer_link'] }}https://www.themoviedb.org/movie/{{ $movie['tmdbid'] }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-blue-100 text-blue-800 rounded-lg hover:bg-blue-200 transition">
<i class="fas fa-film mr-2"></i> View on TMDb
</a>
@endif
@if(!empty($movie['traktid'] ?? null))
<a href="https://trakt.tv/movies/{{ $movie['traktid'] }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-red-100 text-red-800 rounded-lg hover:bg-red-200 transition">
<a href="{{ $site['dereferrer_link'] }}https://trakt.tv/movies/{{ $movie['traktid'] }}" target="_blank" class="inline-flex items-center px-4 py-2 bg-red-100 text-red-800 rounded-lg hover:bg-red-200 transition">
<i class="fas fa-heart mr-2"></i> View on Trakt
</a>
@endif
@@ -162,9 +162,9 @@
<span>
<i class="fas fa-hdd mr-1"></i>{{ number_format($release['size'] / 1073741824, 2) }} GB
</span>
@if($release['adddate'])
@if($release['postdate'])
<span>
<i class="fas fa-plus-circle mr-1"></i>Added: {{ \Carbon\Carbon::parse($release['adddate'])->diffForHumans() }}
<i class="fas fa-calendar-alt mr-1"></i>Posted: {{ userDate($release['postdate'], 'M d, Y H:i') }}
</span>
@endif
</div>
@@ -20,6 +20,10 @@
<i class="fa fa-film fa-fw mr-2"></i>
<span>Movies</span>
</a>
<a href="{{ route('trending-movies') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-800 rounded transition">
<i class="fa fa-fire fa-fw mr-2"></i>
<span>Trending Movies</span>
</a>
@endif
@if(auth()->check() && auth()->user()->musicview && auth()->user()->can('view audio'))
<a href="{{ route('Audio') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-800 rounded transition">
@@ -38,6 +42,10 @@
<i class="fa fa-television fa-fw mr-2"></i>
<span>TV</span>
</a>
<a href="{{ route('trending-tv') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-800 rounded transition">
<i class="fa fa-fire fa-fw mr-2"></i>
<span>Trending TV</span>
</a>
@endif
@if(auth()->check() && auth()->user()->xxxview && auth()->user()->can('view adult'))
<a href="{{ route('XXX') }}" class="flex items-center px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-800 rounded transition">
@@ -103,6 +111,26 @@
</div>
</div>
<!-- Extend/Upgrade Account -->
@auth
@php
$userRole = auth()->user()->roles->first()?->name ?? 'user';
@endphp
@if($userRole !== 'Admin')
@if($userRole === 'User')
<a href="https://simplegate.space/apps/3MjgKvosMZtc2sSxiRBwadDCn1zA/pos" target="_blank" class="flex items-center px-4 py-3 text-white hover:bg-gray-800 rounded transition mt-4">
<i class="fa fa-arrow-up fa-fw mr-3"></i>
<span>Upgrade Your Account</span>
</a>
@else
<a href="https://simplegate.space/apps/3MjgKvosMZtc2sSxiRBwadDCn1zA/pos" target="_blank" class="flex items-center px-4 py-3 text-white hover:bg-gray-800 rounded transition mt-4">
<i class="fa fa-clock fa-fw mr-3"></i>
<span>Extend Your Account</span>
</a>
@endif
@endif
@endauth
<!-- Sign Out -->
@auth
<a href="{{ route('logout') }}" data-logout class="flex items-center px-4 py-3 text-white hover:bg-gray-800 rounded transition mt-4">
+36 -6
View File
@@ -37,7 +37,7 @@
<div>
<label for="email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Address</label>
<input type="email" name="email" id="email" value="{{ old('email', $user->email) }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('email') border-red-500 @enderror">
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 @error('email') border-red-500 dark:border-red-600 @enderror">
@error('email')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
@@ -47,8 +47,8 @@
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Password (leave blank to keep current)</label>
<input type="password" name="password" id="password"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('password') border-red-500 @enderror">
<p class="mt-1 text-xs text-gray-500">Must contain at least 8 characters, including uppercase, lowercase, numbers and special characters</p>
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 @error('password') border-red-500 dark:border-red-600 @enderror">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Must contain at least 8 characters, including uppercase, lowercase, numbers and special characters</p>
@error('password')
<p class="mt-2 text-sm text-red-600">{{ $message }}</p>
@enderror
@@ -58,7 +58,7 @@
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Confirm Password</label>
<input type="password" name="password_confirmation" id="password_confirmation"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500">
</div>
<!-- Theme Preference -->
@@ -112,6 +112,38 @@
</p>
</div>
<!-- Timezone Preference -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">
<i class="fas fa-clock mr-2 text-blue-600 dark:text-blue-400"></i>Timezone Preference
</h3>
<div>
<label for="timezone" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Your Timezone
</label>
<select name="timezone" id="timezone"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
@php
$timezones = getAvailableTimezones();
$currentTimezone = old('timezone', $user->timezone ?? 'UTC');
@endphp
<option value="UTC" {{ $currentTimezone === 'UTC' ? 'selected' : '' }}>UTC (Coordinated Universal Time)</option>
@foreach($timezones as $region => $tzList)
<optgroup label="{{ $region }}">
@foreach($tzList as $tz)
<option value="{{ $tz }}" {{ $currentTimezone === $tz ? 'selected' : '' }}>
{{ str_replace('_', ' ', $tz) }}
</option>
@endforeach
</optgroup>
@endforeach
</select>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1"></i>All dates and times will be displayed in your selected timezone. Current server time: {{ now()->format('Y-m-d H:i:s T') }}
</p>
</div>
</div>
<!-- View Preferences -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Cover View Preferences</h3>
@@ -365,5 +397,3 @@
</div>
</div>
@endsection
+28 -1
View File
@@ -125,10 +125,37 @@
</div>
@endif
<div class="flex pb-3">
<div class="flex border-b border-gray-200 dark:border-gray-700 pb-3">
<div class="w-1/3 text-gray-600">Grabs</div>
<div class="w-2/3 font-semibold text-green-600">{{ number_format($user->grabs ?? 0) }}</div>
</div>
<div class="flex pb-3">
<div class="w-1/3 text-gray-600">Timezone</div>
<div class="w-2/3">
@php
$userTimezone = $user->timezone ?? 'UTC';
$timezoneDisplay = str_replace('_', ' ', $userTimezone);
// Get current time in user's timezone
try {
$currentTime = \Carbon\Carbon::now($userTimezone)->format('H:i');
$currentDate = \Carbon\Carbon::now($userTimezone)->format('M d, Y');
} catch (\Exception $e) {
$currentTime = \Carbon\Carbon::now('UTC')->format('H:i');
$currentDate = \Carbon\Carbon::now('UTC')->format('M d, Y');
}
@endphp
<div class="flex items-center">
<i class="fa fa-clock text-blue-600 dark:text-blue-400 mr-2"></i>
<span class="font-medium">{{ $timezoneDisplay }}</span>
</div>
<div class="mt-1 text-sm text-gray-500">
<i class="fa fa-calendar-alt text-gray-400 mr-1"></i>
Current time: {{ $currentDate }} {{ $currentTime }}
</div>
</div>
</div>
</div>
</div>
</div>
+6 -4
View File
@@ -58,6 +58,8 @@
</div>
@if(request('search_type') == 'adv')
<!-- Hidden field to maintain advanced search mode -->
<input type="hidden" name="search_type" value="adv">
<!-- Advanced Search Options -->
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4 mb-4">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">Advanced Options</h3>
@@ -229,7 +231,7 @@
@endif
@if(!empty($result->postdate))
<span class="inline-flex items-center px-2 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<i class="fas fa-calendar mr-1"></i> Posted: {{ \Carbon\Carbon::parse($result->postdate)->format('M d, Y H:i') }}
<i class="fas fa-calendar mr-1"></i> Posted: {{ userDate($result->postdate, 'M d, Y H:i') }}
</span>
@endif
@if(!empty($result->fromname))
@@ -245,8 +247,8 @@
{{ $result->category_name ?? 'Other' }}
</span>
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{{ \Carbon\Carbon::parse($result->adddate)->diffForHumans() }}
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400 dark:text-gray-400">
{{ userDateDiffForHumans($result->adddate) }}
</td>
<td class="px-3 py-4 whitespace-nowrap text-sm text-gray-600 dark:text-gray-400">
{{ number_format($result->size / 1073741824, 2) }} GB
@@ -319,7 +321,7 @@
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
{{ $result->category_name ?? 'Other' }}
</span>
<span><i class="fas fa-clock mr-1"></i>{{ \Carbon\Carbon::parse($result->postdate)->diffForHumans() }}</span>
<span><i class="fas fa-clock mr-1"></i>{{ userDateDiffForHumans($result->postdate) }}</span>
<span><i class="fas fa-hdd mr-1"></i>{{ number_format($result->size / 1073741824, 2) }} GB</span>
<span><i class="fas fa-file mr-1"></i>{{ $result->totalpart ?? 0 }} files</span>
<span title="Grabs"><i class="fas fa-download text-green-600 dark:text-green-400 mr-1"></i>{{ $result->grabs ?? 0 }}</span>
+3 -3
View File
@@ -253,11 +253,11 @@
<i class="fa fa-hdd-o mr-1"></i>{{ formatBytes($release->size) }}
</span>
<span>
<i class="fa fa-clock-o mr-1"></i>Added: {{ \Carbon\Carbon::parse($release->adddate)->diffForHumans() }}
<i class="fa fa-clock-o mr-1"></i>Added: {{ userDateDiffForHumans($release->adddate) }}
</span>
@if(!empty($release->postdate))
<span class="inline-flex items-center px-2 py-0.5 rounded bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
<i class="fas fa-calendar mr-1"></i> Posted: {{ \Carbon\Carbon::parse($release->postdate)->format('M d, Y H:i') }}
<span>
<i class="fas fa-calendar mr-1"></i> Posted: {{ userDate($release->postdate, 'M d, Y H:i') }}
</span>
@endif
@if(!empty($release->fromname))
@@ -35,6 +35,9 @@
<!-- Action buttons and search -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-4">
<div class="flex gap-2">
<a href="{{ route('trending-tv') }}" class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-orange-500 to-red-600 text-white rounded-lg hover:from-orange-600 hover:to-red-700 transition shadow-md">
<i class="fas fa-fire mr-2"></i> View Trending TV Shows
</a>
<a class="px-4 py-2 bg-blue-600 dark:bg-blue-700 text-white rounded hover:bg-blue-700 dark:hover:bg-blue-800 inline-flex items-center" href="{{ route('myshows') }}" title="List my watched shows">
<i class="fa fa-list mr-2"></i>My Shows
</a>
+1 -2
View File
@@ -16,8 +16,7 @@ use App\Http\Controllers\Api\ApiInformController;
use App\Http\Controllers\Api\ApiV2Controller;
Route::prefix('v1')->group(function () {
Route::get('api', [ApiController::class, 'api']);
Route::post('api', [ApiController::class, 'api']);
Route::match(['post', 'get'], 'api', [ApiController::class, 'api']);
});
Route::prefix('v2')->group(function () {
+8
View File
@@ -31,6 +31,14 @@ Schedule::command('cloudflare:reload')->daily();
Schedule::command('cache:prune-stale-tags')->hourly();
Schedule::command('nntmux:collect-stats')->hourly();
Schedule::command('nntmux:populate-steam-apps')->monthly();
// Collect system metrics every 5 minutes
Schedule::command('metrics:collect')->everyFiveMinutes()->withoutOverlapping();
// Cleanup old system metrics daily (keep last 60 days)
Schedule::command('metrics:collect --cleanup')->dailyAt('03:00');
// Cleanup old user activity stats weekly (keep last 90 days)
Schedule::call(function () {
\App\Models\UserActivityStat::cleanupOldStats(90);
})->weeklyOn(1, '04:00');
if (config('nntmux.purge_inactive_users') === true) {
Schedule::job(new RemoveInactiveAccounts)->daily();
Schedule::job(new PurgeDeletedAccounts)->daily();
+18 -1
View File
@@ -36,6 +36,7 @@ use App\Http\Controllers\Admin\AdminSiteController;
use App\Http\Controllers\Admin\AdminTmuxController;
use App\Http\Controllers\Admin\AdminUserController;
use App\Http\Controllers\Admin\DeletedUsersController;
use App\Http\Controllers\Admin\SystemMetricsController;
use App\Http\Controllers\AdultController;
use App\Http\Controllers\AjaxController;
use App\Http\Controllers\AnimeController;
@@ -85,7 +86,7 @@ Route::match(['GET', 'POST'], '/', [ContentController::class, 'show'])->name('ho
Route::get('register', [RegisterController::class, 'showRegistrationForm'])->name('register');
Route::post('register', [RegisterController::class, 'register'])->name('register.post');
Route::match(['GET', 'POST'], 'forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('forgottenpassword')->withoutMiddleware(['auth', 'VerifyCsrfToken', 'web']);
Route::match(['GET', 'POST'], 'forgottenpassword', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('forgottenpassword')->withoutMiddleware(['auth']);
Route::match(['GET', 'POST'], 'terms-and-conditions', [TermsController::class, 'terms'])->name('terms-and-conditions');
Route::match(['GET', 'POST'], 'privacy-policy', [PrivacyPolicyController::class, 'privacyPolicy'])->name('privacy-policy');
@@ -94,6 +95,7 @@ Route::post('login', [LoginController::class, 'login'])->name('login.post');
Route::match(['GET', 'POST'], 'logout', [LoginController::class, 'logout'])->name('logout');
Route::get('2fa/verify', [PasswordSecurityController::class, 'getVerify2fa'])->name('2fa.verify');
Route::post('2fa/verify', [PasswordSecurityController::class, 'verify2fa'])->name('2fa.post');
Route::post('2faVerify', [PasswordSecurityController::class, 'verify2fa'])->name('2faVerify');
Route::middleware('isVerified')->group(function () {
@@ -126,10 +128,12 @@ Route::middleware('isVerified')->group(function () {
Route::middleware('clearance')->group(function () {
Route::match(['GET', 'POST'], 'Games', [GamesController::class, 'show'])->name('Games');
Route::match(['GET', 'POST'], 'trending-movies', [MovieController::class, 'showTrending'])->name('trending-movies');
Route::match(['GET', 'POST'], 'movie/{imdbid}', [MovieController::class, 'showMovie'])->name('movie.view');
Route::match(['GET', 'POST'], 'Movies/{id?}', [MovieController::class, 'showMovies'])->name('Movies');
Route::match(['GET', 'POST'], 'movie', [MovieController::class, 'showMovies'])->name('movie');
Route::match(['GET', 'POST'], 'movietrailers', [MovieController::class, 'showTrailer'])->name('movietrailers');
Route::post('movies/update-layout', [MovieController::class, 'updateLayout'])->name('movies.update-layout');
Route::match(['GET', 'POST'], 'Audio/{id?}', [MusicController::class, 'show'])->name('Audio');
Route::match(['GET', 'POST'], 'Console/{id?}', [ConsoleController::class, 'show'])->name('Console');
Route::match(['GET', 'POST'], 'XXX/{id?}', [AdultController::class, 'show'])->name('XXX');
@@ -152,6 +156,7 @@ Route::middleware('isVerified')->group(function () {
Route::match(['GET', 'POST'], 'filelist/{guid}', [FileListController::class, 'show'])->name('filelist');
Route::get('api/release/{guid}/filelist', [\App\Http\Controllers\Api\FileListApiController::class, 'getFileList'])->name('api.filelist');
Route::match(['GET', 'POST'], 'series/{id?}', [SeriesController::class, 'index'])->name('series');
Route::match(['GET', 'POST'], 'trending-tv', [SeriesController::class, 'showTrending'])->name('trending-tv');
Route::match(['GET', 'POST'], 'ajax_profile', [AjaxController::class, 'profile'])->name('ajax_profile');
Route::match(['GET', 'POST'], '2fa', [PasswordSecurityController::class, 'show2faForm'])->name('2fa');
Route::get('2fa/enable', [PasswordSecurityController::class, 'showEnable2faForm'])->name('2fa.enable');
@@ -169,6 +174,14 @@ Route::middleware('isVerified')->group(function () {
Route::middleware('role:Admin', '2fa')->prefix('admin')->group(function () {
Route::get('index', [AdminPageController::class, 'index'])->name('admin.index');
// System Metrics API endpoints
Route::get('api/system-metrics/current', [SystemMetricsController::class, 'getCurrentMetrics'])->name('admin.api.metrics.current');
Route::get('api/system-metrics/historical', [SystemMetricsController::class, 'getHistoricalMetrics'])->name('admin.api.metrics.historical');
// User Activity API endpoints
Route::get('api/user-activity/minutes', [AdminPageController::class, 'getUserActivityMinutes'])->name('admin.api.user-activity.minutes');
Route::post('anidb-delete/{id}', [AdminAnidbController::class, 'destroy'])->name('admin.anidb-delete');
Route::match(['GET', 'POST'], 'anidb-edit/{id}', [AdminAnidbController::class, 'edit'])->name('admin.anidb-edit');
Route::get('anidb-list', [AdminAnidbController::class, 'index'])->name('admin.anidb-list');
@@ -238,6 +251,10 @@ Route::middleware('role_or_permission:Admin|Moderator|edit release')->prefix('ad
Route::match(['GET', 'POST'], 'release-edit', [AdminReleasesController::class, 'edit'])->name('admin.release-edit');
});
// Redirect btcpay route to btc payment server
Route::get('btcpay', function () {
return redirect()->to('https://simplegate.space/apps/3MjgKvosMZtc2sSxiRBwadDCn1zA/pos');
})->name('btcpay');
// Invitation management routes
Route::prefix('invitations')->name('invitations.')->group(function () {
Route::get('/', [InvitationController::class, 'index'])->name('index');