Track more statuses and add redis fallback

This commit is contained in:
DariusIII
2026-04-13 12:37:32 +02:00
parent 51d8a049ed
commit e89d719534
28 changed files with 1259 additions and 75 deletions
+11 -1
View File
@@ -103,7 +103,8 @@ LOG_STACK=single
PASSWORD_HASH=argon2id
BROADCAST_CONNECTION=log
CACHE_STORE=redis
# Use failover_redis_file so Cache falls back to disk when Redis is unreachable (set CACHE_STORE=redis for strict Redis-only)
CACHE_STORE=failover_redis_file
QUEUE_CONNECTION=sync
REDIS_HOST=127.0.0.1
@@ -111,11 +112,20 @@ REDIS_USERNAME=default
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_CLIENT=phpredis
REDIS_CONNECT_TIMEOUT=2
REDIS_READ_TIMEOUT=2
# Status probes: skip Redis check when cache/session/queue do not use Redis (set false to always probe)
STATUS_PROBE_REDIS=true
STATUS_PROBE_REDIS_ONLY_WHEN_USED=true
# Horizon (Redis queue dashboard and workers)
HORIZON_PREFIX=horizon:
SESSION_DRIVER=redis
# Fast TCP probe before other middleware; switch cache+session to file when Redis host is unreachable (set false to disable)
REDIS_FAST_DEGRADE=true
REDIS_TCP_CHECK_SECONDS=0.2
SESSION_DOMAIN=null
SESSION_SECURE_COOKIE=false
SESSION_ENCRYPT=false
+16 -2
View File
@@ -29,7 +29,20 @@ class CheckServiceHealth extends Command
foreach ($services as $service) {
if ($service->endpoint_url === null || $service->endpoint_url === '') {
$rows[] = [$service->name, '—', '—', 'Skipped (no endpoint)'];
if ($service->check_type === 'probe') {
$result = $statusService->checkServiceHealth($service);
$statusService->handleHealthCheckResult($service, $result);
$rows[] = [
$service->name,
strtoupper($service->check_type),
$result['status_code'] ?: '—',
$result['response_time_ms'] ? $result['response_time_ms'].'ms' : '—',
$result['ok'] ? 'Healthy' : $result['reason'],
];
} else {
$rows[] = [$service->name, strtoupper($service->check_type), '—', '—', 'Skipped (no endpoint)'];
}
continue;
}
@@ -39,6 +52,7 @@ class CheckServiceHealth extends Command
$rows[] = [
$service->name,
strtoupper($service->check_type),
$result['status_code'] ?: '—',
$result['response_time_ms'] ? $result['response_time_ms'].'ms' : '—',
$result['ok'] ? 'Healthy' : $result['reason'],
@@ -47,7 +61,7 @@ class CheckServiceHealth extends Command
$statusService->refreshAllServiceUptime($days);
$this->table(['Service', 'HTTP', 'Response', 'Result'], $rows);
$this->table(['Service', 'Type', 'HTTP', 'Response', 'Result'], $rows);
$services = $statusService->getEnabledServices();
$summary = $services->map(fn ($s) => [
+22 -3
View File
@@ -11,6 +11,7 @@ use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class BasePageController extends Controller
@@ -59,7 +60,7 @@ class BasePageController extends Controller
$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', 'trendingMoviesRss', 'trendingShowsRss', 'release', 'reset', 'showLinkRequestForm', 'showStatusPage');
// Load settings as collection with caching (5 minutes)
$this->settings = Cache::remember('site_settings', 300, function () {
$this->settings = $this->rememberWithCacheFallback('site_settings', 300, function () {
return Settings::query()->pluck('value', 'name');
});
@@ -69,7 +70,7 @@ class BasePageController extends Controller
];
// Then add the converted settings array as 'site' with caching
$this->viewData['site'] = Cache::remember('site_settings_converted', 300, function () {
$this->viewData['site'] = $this->rememberWithCacheFallback('site_settings_converted', 300, function () {
return $this->settings->map(function ($value) {
return Settings::convertValue($value);
})->all();
@@ -81,7 +82,7 @@ class BasePageController extends Controller
$userId = Auth::id();
$this->userdata = User::find($userId);
// Cache category exclusions per user (5 minutes)
$this->userdata->categoryexclusions = Cache::remember(
$this->userdata->categoryexclusions = $this->rememberWithCacheFallback(
'user_category_exclusions_'.$userId,
300,
fn () => User::getCategoryExclusionById($userId)
@@ -92,6 +93,24 @@ class BasePageController extends Controller
});
}
/**
* Use the cache when available; if the store is unreachable (e.g. Redis down), run the callback directly.
*
* @param callable(): mixed $callback
*/
private function rememberWithCacheFallback(string $key, int|\DateInterval $ttl, callable $callback): mixed
{
try {
return Cache::remember($key, $ttl, $callback);
} catch (\Throwable $e) {
if (config('app.debug')) {
Log::debug('BasePageController cache bypassed: '.$e->getMessage());
}
return $callback();
}
}
/**
* @return LengthAwarePaginator<int, mixed>
*/
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Symfony\Component\HttpFoundation\Response;
/**
* Laravel's cache "failover" driver still tries Redis on every read/write, so when Redis is down
* each Cache call pays the full connection timeout (very slow + spinners on admin/API-heavy pages).
*
* This middleware runs first (global stack): quick TCP reachability check to the configured Redis
* host/port, then switches default cache + session to file for this request only. terminate()
* restores config for Octane/long-lived workers.
*/
class DegradeWhenRedisUnreachable
{
public function handle(Request $request, Closure $next): Response
{
if (! config('nntmux.redis_fast_degrade', true)) {
return $next($request);
}
if ($this->runningUnitTests()) {
return $next($request);
}
if (! $this->shouldProbeRedis()) {
return $next($request);
}
$target = $this->redisTcpTarget();
if ($target === null) {
return $next($request);
}
if ($this->tcpReachable($target['remote'], (float) config('nntmux.redis_tcp_check_seconds', 0.2))) {
return $next($request);
}
$request->attributes->set('redis_degrade_original_config', [
'cache.default' => config('cache.default'),
'session.driver' => config('session.driver'),
]);
$this->degradeAwayFromRedis();
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
$snapshot = $request->attributes->get('redis_degrade_original_config');
if (! is_array($snapshot)) {
return;
}
foreach ($snapshot as $key => $value) {
Config::set($key, $value);
}
}
private function runningUnitTests(): bool
{
return app()->environment('testing');
}
private function shouldProbeRedis(): bool
{
$cacheDefault = (string) config('cache.default', '');
if ($cacheDefault === 'redis' || $cacheDefault === 'failover_redis_file' || $this->failoverChainContainsRedis($cacheDefault)) {
return true;
}
return config('session.driver') === 'redis';
}
private function failoverChainContainsRedis(string $storeName): bool
{
$store = config('cache.stores.'.$storeName);
if (! is_array($store) || ($store['driver'] ?? '') !== 'failover') {
return false;
}
foreach ($store['stores'] ?? [] as $sub) {
if (strtolower((string) $sub) === 'redis') {
return true;
}
}
return false;
}
private function degradeAwayFromRedis(): void
{
$cacheDefault = (string) config('cache.default', '');
if ($cacheDefault === 'redis'
|| $cacheDefault === 'failover_redis_file'
|| $this->failoverChainContainsRedis($cacheDefault)) {
Config::set('cache.default', 'file');
}
if (config('session.driver') === 'redis') {
Config::set('session.driver', 'file');
}
}
/**
* @return array{remote: string}|null PHP stream remote URL (tcp:// or ssl://)
*/
private function redisTcpTarget(): ?array
{
$cfg = config('database.redis.default');
if (! is_array($cfg)) {
return null;
}
if (! empty($cfg['url'])) {
$parts = parse_url((string) $cfg['url']);
if ($parts === false) {
return null;
}
$host = $parts['host'] ?? null;
if ($host === null || $host === '') {
return null;
}
$port = (int) ($parts['port'] ?? 6379);
$scheme = strtolower((string) ($parts['scheme'] ?? 'tcp'));
return ['remote' => $this->streamRemote($scheme, $host, $port)];
}
$host = (string) ($cfg['host'] ?? '127.0.0.1');
if ($host === '' || str_starts_with($host, '/')) {
return null;
}
$port = (int) ($cfg['port'] ?? 6379);
return ['remote' => $this->streamRemote('tcp', $host, $port)];
}
private function streamRemote(string $scheme, string $host, int $port): string
{
if ($scheme === 'tls' || $scheme === 'ssl' || $scheme === 'rediss') {
return 'ssl://'.$host.':'.$port;
}
$wrapped = str_contains($host, ':') && ! str_starts_with($host, '[')
? '['.$host.']'
: $host;
return 'tcp://'.$wrapped.':'.$port;
}
private function tcpReachable(string $remote, float $timeoutSeconds): bool
{
$errno = 0;
$errstr = '';
$socket = @stream_socket_client(
$remote,
$errno,
$errstr,
$timeoutSeconds,
STREAM_CLIENT_CONNECT
);
if (is_resource($socket)) {
fclose($socket);
return true;
}
return false;
}
}
+4
View File
@@ -14,6 +14,8 @@ use Illuminate\Support\Carbon;
* @property string $name
* @property string $slug
* @property string|null $endpoint_url
* @property string $check_type
* @property string|null $probe_identifier
* @property ServiceStatusEnum $status
* @property Carbon|null $last_checked_at
* @property string $uptime_percentage
@@ -32,6 +34,8 @@ class ServiceStatus extends Model
'name',
'slug',
'endpoint_url',
'check_type',
'probe_identifier',
'status',
'last_checked_at',
'uptime_percentage',
+90 -41
View File
@@ -108,24 +108,24 @@ class NfoService
protected const SETTINGS_CACHE_TTL = 300;
/**
* @var int Number of NFOs to process per batch.
* Lazily loaded from settings + cache when NFO processing runs (see getters).
*/
private int $nzbs;
private ?int $nzbs = null;
/**
* @var int Maximum release size to process NFO (in GB).
* Lazily loaded from settings + cache when NFO processing runs.
*/
protected int $maxSize;
protected ?int $maxSize = null;
/**
* @var int Maximum retry attempts for failed NFO fetches.
* Lazily loaded from settings + cache when NFO processing runs.
*/
private int $maxRetries;
private ?int $maxRetries = null;
/**
* @var int Minimum release size to process NFO (in MB).
* Lazily loaded from settings + cache when NFO processing runs.
*/
protected int $minSize;
protected ?int $minSize = null;
/**
* @var string Temporary path for processing files.
@@ -156,42 +156,86 @@ class NfoService
/**
* Default constructor.
*
* Initializes NFO processing settings from database/config with caching.
*
* @throws \Exception
* Heavy settings (batch limits, sizes, retries) load on first NFO processing use via getters,
* so constructing this service does not hit the cache or database.
*/
public function __construct()
{
$this->echo = (bool) config('nntmux.echocli');
// Cache settings to reduce database queries
// Note: Cast after Cache::remember as cached values may be stored as strings
$this->nzbs = (int) Cache::remember('nfo_maxnfoprocessed', self::SETTINGS_CACHE_TTL, function () {
$value = Settings::settingValue('maxnfoprocessed');
return $value !== '' ? (int) $value : 100;
});
$maxRetries = (int) Cache::remember('nfo_maxnforetries', self::SETTINGS_CACHE_TTL, function () {
return (int) Settings::settingValue('maxnforetries');
});
$this->maxRetries = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC;
$this->maxRetries = max($this->maxRetries, -8);
$this->maxSize = (int) Cache::remember('nfo_maxsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () {
return (int) Settings::settingValue('maxsizetoprocessnfo');
});
$this->minSize = (int) Cache::remember('nfo_minsizetoprocessnfo', self::SETTINGS_CACHE_TTL, function () {
return (int) Settings::settingValue('minsizetoprocessnfo');
});
$this->tmpPath = rtrim((string) config('nntmux.tmp_unrar_path'), '/\\').'/';
$this->unrarPath = config('nntmux_settings.unrar_path') ?: false;
$this->timeoutPath = config('nntmux_settings.timeout_path') ?: false;
$this->timeoutSeconds = (int) (Settings::settingValue('timeoutseconds') ?: 60);
}
private function getNzbs(): int
{
if ($this->nzbs === null) {
$this->nzbs = (int) $this->rememberNfoSetting('nfo_maxnfoprocessed', function () {
$value = Settings::settingValue('maxnfoprocessed');
return $value !== '' ? (int) $value : 100;
});
}
return $this->nzbs;
}
private function getMaxRetries(): int
{
if ($this->maxRetries === null) {
$maxRetries = (int) $this->rememberNfoSetting('nfo_maxnforetries', function () {
return (int) Settings::settingValue('maxnforetries');
});
$computed = $maxRetries >= 0 ? -($maxRetries + 1) : self::NFO_UNPROC;
$this->maxRetries = max($computed, -8);
}
return $this->maxRetries;
}
private function getMaxSize(): int
{
if ($this->maxSize === null) {
$this->maxSize = (int) $this->rememberNfoSetting('nfo_maxsizetoprocessnfo', function () {
return (int) Settings::settingValue('maxsizetoprocessnfo');
});
}
return $this->maxSize;
}
private function getMinSize(): int
{
if ($this->minSize === null) {
$this->minSize = (int) $this->rememberNfoSetting('nfo_minsizetoprocessnfo', function () {
return (int) Settings::settingValue('minsizetoprocessnfo');
});
}
return $this->minSize;
}
/**
* Read a setting through the cache when available; fall back to the callback if the
* cache store is unreachable (e.g. Redis down while CACHE_STORE=redis).
*
* @param callable(): mixed $callback
*/
private function rememberNfoSetting(string $key, callable $callback): mixed
{
try {
return Cache::remember($key, self::SETTINGS_CACHE_TTL, $callback);
} catch (Throwable $e) {
if (config('app.debug')) {
Log::debug('NfoService cache bypassed: '.$e->getMessage());
}
return $callback();
}
}
/**
* Look for a TV Show ID or Movie ID in a string.
*
@@ -688,7 +732,7 @@ class NfoService
$releases = $baseQuery->clone()
->orderBy('nfostatus')
->orderByDesc('postdate')
->limit($this->nzbs)
->limit($this->getNzbs())
->get(['id', 'guid', 'groups_id', 'name']);
$nfoCount = $releases->count();
@@ -775,7 +819,7 @@ class NfoService
private function buildNfoProcessingQuery(string $groupID, string $guidChar): \Illuminate\Database\Eloquent\Builder // @phpstan-ignore class.notFound, missingType.generics, return.phpDocType
{
$query = Release::query()
->whereBetween('nfostatus', [$this->maxRetries, self::NFO_UNPROC]);
->whereBetween('nfostatus', [$this->getMaxRetries(), self::NFO_UNPROC]);
if ($guidChar !== '') {
$query->where('leftguid', $guidChar);
@@ -785,12 +829,12 @@ class NfoService
$query->where('groups_id', $groupID);
}
if ($this->maxSize > 0) {
$query->where('size', '<', $this->maxSize * 1073741824);
if ($this->getMaxSize() > 0) {
$query->where('size', '<', $this->getMaxSize() * 1073741824);
}
if ($this->minSize > 0) {
$query->where('size', '>', $this->minSize * 1048576);
if ($this->getMinSize() > 0) {
$query->where('size', '>', $this->getMinSize() * 1048576);
}
return $query;
@@ -806,7 +850,7 @@ class NfoService
($guidChar === '' ? '' : '['.$guidChar.'] ').
($groupID === '' ? '' : '['.$groupID.'] ').
'Processing '.$nfoCount.
' NFO(s), starting at '.$this->nzbs.
' NFO(s), starting at '.$this->getNzbs().
' * = hidden NFO, + = NFO, - = no NFO, f = download failed.'
);
}
@@ -862,7 +906,7 @@ class NfoService
$query->where('groups_id', $groupID);
}
$releases = $query->limit($this->nzbs)
$releases = $query->limit($this->getNzbs())
->get(['id', 'guid', 'groups_id', 'name']);
if ($releases->isEmpty()) {
@@ -1694,5 +1738,10 @@ class NfoService
Cache::forget('nfo_maxnforetries');
Cache::forget('nfo_maxsizetoprocessnfo');
Cache::forget('nfo_minsizetoprocessnfo');
$this->nzbs = null;
$this->maxRetries = null;
$this->maxSize = null;
$this->minSize = null;
}
}
+37 -1
View File
@@ -10,6 +10,8 @@ use App\Enums\ServiceStatusEnum;
use App\Mail\IncidentDetected;
use App\Models\ServiceIncident;
use App\Models\ServiceStatus;
use App\Services\StatusProbes\ProbeResult;
use App\Services\StatusProbes\ServiceProbeRegistry;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
@@ -18,6 +20,10 @@ use Illuminate\Support\Facades\Mail;
class SiteStatusService
{
public function __construct(
private readonly ServiceProbeRegistry $probeRegistry,
) {}
/**
* @return \Illuminate\Database\Eloquent\Collection<int, ServiceStatus>
*/
@@ -149,8 +155,14 @@ class SiteStatusService
}
$uptime = (($totalMinutes - $downtimeMinutes) / $totalMinutes) * 100.0;
$rounded = round(max(0.0, min(100.0, $uptime)), 2);
return round(max(0.0, min(100.0, $uptime)), 2);
// Preserve visibility of brief outages in long windows (e.g. 30-day SLA).
if ($downtimeMinutes > 0.0 && $rounded >= 100.0) {
return 99.99;
}
return $rounded;
}
/**
@@ -329,6 +341,16 @@ class SiteStatusService
*/
public function checkServiceHealth(ServiceStatus $service): array
{
if ($service->check_type === 'probe') {
if ($service->probe_identifier === null || $service->probe_identifier === '') {
return ['ok' => false, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => IncidentImpactEnum::Major, 'reason' => 'Probe identifier not configured'];
}
return $this->probeResultToHealthArray(
$this->probeRegistry->run($service->probe_identifier)
);
}
$baseUrl = rtrim((string) config('app.url'), '/');
if ($baseUrl === '' || ! str_starts_with($baseUrl, 'http')) {
@@ -360,6 +382,20 @@ class SiteStatusService
return $worstResult ?? ['ok' => false, 'status_code' => 0, 'response_time_ms' => 0, 'impact' => IncidentImpactEnum::Critical, 'reason' => 'All endpoints unreachable'];
}
/**
* @return array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string}
*/
private function probeResultToHealthArray(ProbeResult $result): array
{
return [
'ok' => $result->ok,
'status_code' => 0,
'response_time_ms' => $result->responseTimeMs,
'impact' => $result->impact,
'reason' => $result->reason,
];
}
/**
* @return array{ok: bool, status_code: int, response_time_ms: int, impact: IncidentImpactEnum|null, reason: string}
*/
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes\Contracts;
use App\Services\StatusProbes\ProbeResult;
interface ServiceProbeInterface
{
public function identifier(): string;
public function probe(): ProbeResult;
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
use Illuminate\Support\Facades\DB;
class DatabaseProbe implements ServiceProbeInterface
{
public function identifier(): string
{
return 'database';
}
public function probe(): ProbeResult
{
try {
$start = hrtime(true);
DB::connection()->getPdo();
DB::select('SELECT 1');
$elapsed = (int) ((hrtime(true) - $start) / 1_000_000);
return new ProbeResult(
ok: true,
responseTimeMs: $elapsed,
impact: null,
reason: 'Connected',
);
} catch (\Throwable $e) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: 'Database unreachable: '.\Str::limit($e->getMessage(), 120),
);
}
}
}
+79
View File
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
class DiskProbe implements ServiceProbeInterface
{
public function identifier(): string
{
return 'disk';
}
public function probe(): ProbeResult
{
$mountPoints = (array) config('status-probes.disk.mount_points', ['/']);
$warningThresholdGb = (float) config('status-probes.disk.warning_threshold_gb', 5.0);
$criticalThresholdGb = (float) config('status-probes.disk.critical_threshold_gb', 1.0);
$lowestFreeGb = null;
$worstMount = null;
foreach ($mountPoints as $mountPoint) {
$freeBytes = @disk_free_space((string) $mountPoint);
if ($freeBytes === false) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Major,
reason: sprintf('Unable to read disk usage for mount "%s"', $mountPoint),
);
}
$freeGb = round(((float) $freeBytes) / 1_073_741_824, 2);
if ($lowestFreeGb === null || $freeGb < $lowestFreeGb) {
$lowestFreeGb = $freeGb;
$worstMount = (string) $mountPoint;
}
}
if ($lowestFreeGb === null || $worstMount === null) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Major,
reason: 'No disk mount points configured',
);
}
if ($lowestFreeGb <= $criticalThresholdGb) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: sprintf('Low disk space on %s (%.2f GB free)', $worstMount, $lowestFreeGb),
);
}
if ($lowestFreeGb <= $warningThresholdGb) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Minor,
reason: sprintf('Disk space warning on %s (%.2f GB free)', $worstMount, $lowestFreeGb),
);
}
return new ProbeResult(
ok: true,
responseTimeMs: 0,
impact: null,
reason: sprintf('Disk healthy (lowest: %s %.2f GB free)', $worstMount, $lowestFreeGb),
);
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\NNTP\NNTPService;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
class NntpProbe implements ServiceProbeInterface
{
public function __construct(
private readonly NNTPService $nntp,
) {}
public function identifier(): string
{
return 'nntp';
}
public function probe(): ProbeResult
{
$checkAlternate = (bool) config('status-probes.nntp.check_alternate', false);
try {
$primary = $this->probeConnection(false);
if (! $primary['ok']) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: 'Primary NNTP failed: '.$primary['reason'],
);
}
if ($checkAlternate) {
$alternate = $this->probeConnection(true);
if (! $alternate['ok']) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Major,
reason: 'Alternate NNTP failed: '.$alternate['reason'],
);
}
}
return new ProbeResult(
ok: true,
responseTimeMs: (int) $primary['responseTimeMs'],
impact: null,
reason: $checkAlternate ? 'Primary and alternate NNTP connected' : 'Primary NNTP connected',
);
} catch (\Throwable $e) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: 'NNTP probe failed: '.\Str::limit($e->getMessage(), 120),
);
} finally {
$this->nntp->doQuit();
}
}
/**
* @return array{ok: bool, reason: string, responseTimeMs: int}
*/
private function probeConnection(bool $alternate): array
{
$start = hrtime(true);
$result = $this->nntp->doConnect(compression: false, alternate: $alternate);
$elapsed = (int) ((hrtime(true) - $start) / 1_000_000);
if ($result === true) {
return ['ok' => true, 'reason' => 'Connected', 'responseTimeMs' => $elapsed];
}
if (NNTPService::isError($result)) {
return ['ok' => false, 'reason' => (string) $result->getMessage(), 'responseTimeMs' => $elapsed];
}
return ['ok' => false, 'reason' => 'Unknown connection result', 'responseTimeMs' => $elapsed];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
final readonly class ProbeResult
{
/**
* @param array<string, mixed> $metadata
*/
public function __construct(
public bool $ok,
public int $responseTimeMs,
public ?IncidentImpactEnum $impact,
public string $reason,
public array $metadata = [],
) {}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
use Illuminate\Contracts\Queue\Factory as QueueFactory;
class QueueProbe implements ServiceProbeInterface
{
public function __construct(
private readonly QueueFactory $queue,
) {}
public function identifier(): string
{
return 'queue';
}
public function probe(): ProbeResult
{
try {
$defaultConnection = (string) config('queue.default', 'sync');
$start = hrtime(true);
$queueConnection = $this->queue->connection($defaultConnection);
$size = method_exists($queueConnection, 'size') ? (int) $queueConnection->size() : null;
$elapsed = (int) ((hrtime(true) - $start) / 1_000_000);
if ($defaultConnection === 'sync') {
return new ProbeResult(
ok: true,
responseTimeMs: $elapsed,
impact: null,
reason: 'Queue uses sync driver',
);
}
return new ProbeResult(
ok: true,
responseTimeMs: $elapsed,
impact: null,
reason: sprintf('Queue "%s" reachable', $defaultConnection),
metadata: ['queue_size' => $size],
);
} catch (\Throwable $e) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: 'Queue probe failed: '.\Str::limit($e->getMessage(), 120),
);
}
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class RedisProbe implements ServiceProbeInterface
{
public function identifier(): string
{
return 'redis';
}
public function probe(): ProbeResult
{
if (! config('status-probes.redis.probe', true)) {
return new ProbeResult(
ok: true,
responseTimeMs: 0,
impact: null,
reason: 'Redis probe disabled (STATUS_PROBE_REDIS=false)',
);
}
if (config('status-probes.redis.only_when_used', true) && ! $this->applicationUsesRedis()) {
return new ProbeResult(
ok: true,
responseTimeMs: 0,
impact: null,
reason: 'Skipping: cache/session/queue do not use Redis',
);
}
try {
$start = hrtime(true);
$pong = Redis::connection()->ping();
$elapsed = (int) ((hrtime(true) - $start) / 1_000_000);
$isOk = $pong === true || $pong === '+PONG' || strtoupper((string) $pong) === 'PONG';
if (! $isOk) {
return new ProbeResult(
ok: false,
responseTimeMs: $elapsed,
impact: IncidentImpactEnum::Major,
reason: 'Redis ping returned unexpected response',
metadata: ['response' => $pong],
);
}
return new ProbeResult(
ok: true,
responseTimeMs: $elapsed,
impact: null,
reason: 'Connected',
);
} catch (\Throwable $e) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: 'Redis unreachable: '.Str::limit($e->getMessage(), 120),
);
}
}
private function applicationUsesRedis(): bool
{
$cacheDefault = strtolower((string) config('cache.default', ''));
$sessionDriver = strtolower((string) config('session.driver', ''));
$queueDefault = strtolower((string) config('queue.default', ''));
if ($cacheDefault === 'redis' || $sessionDriver === 'redis' || $queueDefault === 'redis') {
return true;
}
if ($this->cacheFailoverUsesRedis()) {
return true;
}
$pulseIngest = strtolower((string) config('pulse.ingest.driver', ''));
return $pulseIngest === 'redis';
}
/**
* True when the default cache store is a failover chain that includes the redis store.
*/
private function cacheFailoverUsesRedis(): bool
{
$name = (string) config('cache.default', '');
$store = config('cache.stores.'.$name);
if (! is_array($store) || ($store['driver'] ?? '') !== 'failover') {
return false;
}
foreach ($store['stores'] ?? [] as $subStore) {
if (strtolower((string) $subStore) === 'redis') {
return true;
}
}
return false;
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\Search\SearchService;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
class SearchProbe implements ServiceProbeInterface
{
public function __construct(
private readonly SearchService $searchService,
) {}
public function identifier(): string
{
return 'search';
}
public function probe(): ProbeResult
{
try {
$start = hrtime(true);
$available = $this->searchService->isAvailable();
$elapsed = (int) ((hrtime(true) - $start) / 1_000_000);
if (! $available) {
return new ProbeResult(
ok: false,
responseTimeMs: $elapsed,
impact: IncidentImpactEnum::Critical,
reason: sprintf('Search driver "%s" unavailable', $this->searchService->getCurrentDriver()),
);
}
return new ProbeResult(
ok: true,
responseTimeMs: $elapsed,
impact: null,
reason: sprintf('Search driver "%s" available', $this->searchService->getCurrentDriver()),
);
} catch (\Throwable $e) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Critical,
reason: 'Search check failed: '.\Str::limit($e->getMessage(), 120),
);
}
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Services\StatusProbes;
use App\Enums\IncidentImpactEnum;
use App\Services\StatusProbes\Contracts\ServiceProbeInterface;
class ServiceProbeRegistry
{
/**
* @var array<string, ServiceProbeInterface>
*/
private array $probes;
public function __construct(
DatabaseProbe $databaseProbe,
RedisProbe $redisProbe,
SearchProbe $searchProbe,
NntpProbe $nntpProbe,
QueueProbe $queueProbe,
DiskProbe $diskProbe,
) {
$this->probes = [
$databaseProbe->identifier() => $databaseProbe,
$redisProbe->identifier() => $redisProbe,
$searchProbe->identifier() => $searchProbe,
$nntpProbe->identifier() => $nntpProbe,
$queueProbe->identifier() => $queueProbe,
$diskProbe->identifier() => $diskProbe,
];
}
public function run(string $identifier): ProbeResult
{
$probe = $this->probes[$identifier] ?? null;
if ($probe === null) {
return new ProbeResult(
ok: false,
responseTimeMs: 0,
impact: IncidentImpactEnum::Major,
reason: sprintf('Unknown probe identifier "%s"', $identifier),
);
}
return $probe->probe();
}
}
+21 -4
View File
@@ -11,6 +11,7 @@ use App\Models\Settings;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
class GlobalDataComposer
@@ -48,7 +49,7 @@ class GlobalDataComposer
private function resolveData(): array
{
// Cached site settings (shared across all requests)
$siteArray = Cache::remember('site_settings_array', self::CACHE_TTL, function () {
$siteArray = $this->rememberWithCacheFallback('site_settings_array', self::CACHE_TTL, function () {
return Settings::query()
->pluck('value', 'name')
->map(fn ($value) => Settings::convertValue($value))
@@ -61,7 +62,7 @@ class GlobalDataComposer
];
// Cached useful links for sidebar
$viewData['usefulLinks'] = Cache::remember('content_useful_links', self::CACHE_TTL, function () {
$viewData['usefulLinks'] = $this->rememberWithCacheFallback('content_useful_links', self::CACHE_TTL, function () {
return Content::active()
->ofType(Content::TYPE_USEFUL)
->ordered() // @phpstan-ignore method.notFound
@@ -72,7 +73,7 @@ class GlobalDataComposer
$userId = Auth::id();
// User data with category exclusions (short cache per user)
$userdata = Cache::remember('composer_user_'.$userId, self::CACHE_TTL, function () use ($userId) {
$userdata = $this->rememberWithCacheFallback('composer_user_'.$userId, self::CACHE_TTL, function () use ($userId) {
$user = User::find($userId);
$user->categoryexclusions = User::getCategoryExclusionById($userId);
@@ -85,7 +86,7 @@ class GlobalDataComposer
}
// Cached menu categories per user (depends on their exclusions)
$parentcatlist = Cache::remember('menu_user_'.$userId, self::CACHE_TTL, function () use ($userdata) {
$parentcatlist = $this->rememberWithCacheFallback('menu_user_'.$userId, self::CACHE_TTL, function () use ($userdata) {
return Category::getForMenu($userdata->categoryexclusions);
});
@@ -107,4 +108,20 @@ class GlobalDataComposer
return $viewData;
}
/**
* @param callable(): mixed $callback
*/
private function rememberWithCacheFallback(string $key, int|\DateInterval $ttl, callable $callback): mixed
{
try {
return Cache::remember($key, $ttl, $callback);
} catch (\Throwable $e) {
if (config('app.debug')) {
Log::debug('GlobalDataComposer cache bypassed: '.$e->getMessage());
}
return $callback();
}
}
}
+3
View File
@@ -3,6 +3,7 @@
use App\Http\Middleware\BlockAbusiveServices;
use App\Http\Middleware\ClearanceMiddleware;
use App\Http\Middleware\ContentSecurityPolicy;
use App\Http\Middleware\DegradeWhenRedisUnreachable;
use App\Http\Middleware\EnsureAuthenticatedUsersAreVerified;
use App\Http\Middleware\ForceJsonOnAPI;
use App\Http\Middleware\Google2FAMiddleware;
@@ -62,6 +63,8 @@ return Application::configure(basePath: dirname(__DIR__))
'cart/*',
]);
$middleware->prepend(DegradeWhenRedisUnreachable::class);
$middleware->append([
PreventRequestsDuringMaintenance::class,
ForceJsonOnAPI::class,
+103
View File
@@ -0,0 +1,103 @@
<?php
use Illuminate\Support\Str;
return [
/*
|--------------------------------------------------------------------------
| Default Cache Store
|--------------------------------------------------------------------------
|
| Use "failover_redis_file" to keep using Redis when it is up, and
| automatically fall back to the file store when Redis is down (DNS/connect).
|
*/
'default' => env('CACHE_STORE', 'database'),
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'session' => [
'driver' => 'session',
'key' => env('SESSION_CACHE_KEY', '_cache'),
],
'database' => [
'driver' => 'database',
'connection' => env('DB_CACHE_CONNECTION'),
'table' => env('DB_CACHE_TABLE', 'cache'),
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
'memcached' => [
'driver' => 'memcached',
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
'sasl' => [
env('MEMCACHED_USERNAME'),
env('MEMCACHED_PASSWORD'),
],
'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
],
'servers' => [
[
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
'port' => env('MEMCACHED_PORT', 11211),
'weight' => 100,
],
],
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'dynamodb' => [
'driver' => 'dynamodb',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
'endpoint' => env('DYNAMODB_ENDPOINT'),
],
'octane' => [
'driver' => 'octane',
],
'failover' => [
'driver' => 'failover',
'stores' => [
'database',
'array',
],
],
'failover_redis_file' => [
'driver' => 'failover',
'stores' => [
'redis',
'file',
],
],
],
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
];
+6
View File
@@ -87,6 +87,8 @@ return [
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
'timeout' => (float) env('REDIS_CONNECT_TIMEOUT', 2.0),
'read_timeout' => (float) env('REDIS_READ_TIMEOUT', 2.0),
],
'cache' => [
'url' => env('REDIS_URL'),
@@ -95,6 +97,8 @@ return [
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 1),
'timeout' => (float) env('REDIS_CONNECT_TIMEOUT', 2.0),
'read_timeout' => (float) env('REDIS_READ_TIMEOUT', 2.0),
],
'session' => [
'url' => env('REDIS_URL'),
@@ -103,6 +107,8 @@ return [
'password' => env('REDIS_PASSWORD', null),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_CACHE_DB', 2),
'timeout' => (float) env('REDIS_CONNECT_TIMEOUT', 2.0),
'read_timeout' => (float) env('REDIS_READ_TIMEOUT', 2.0),
],
],
+2
View File
@@ -25,4 +25,6 @@ return [
'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'),
'redis_fast_degrade' => (bool) env('REDIS_FAST_DEGRADE', true),
'redis_tcp_check_seconds' => (float) env('REDIS_TCP_CHECK_SECONDS', 0.2),
];
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
return [
'disk' => [
'mount_points' => array_filter(array_map('trim', explode(',', (string) env('STATUS_PROBE_DISK_MOUNT_POINTS', '/')))),
'warning_threshold_gb' => (float) env('STATUS_PROBE_DISK_WARNING_THRESHOLD_GB', 5),
'critical_threshold_gb' => (float) env('STATUS_PROBE_DISK_CRITICAL_THRESHOLD_GB', 1),
],
'nntp' => [
'check_alternate' => (bool) env('STATUS_PROBE_NNTP_CHECK_ALTERNATE', false),
'timeout_seconds' => (int) env('STATUS_PROBE_NNTP_TIMEOUT_SECONDS', 10),
],
'redis' => [
'probe' => (bool) env('STATUS_PROBE_REDIS', true),
'only_when_used' => (bool) env('STATUS_PROBE_REDIS_ONLY_WHEN_USED', true),
],
];
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_statuses', static function (Blueprint $table): void {
$table->string('check_type')->default('http')->after('endpoint_url');
$table->string('probe_identifier')->nullable()->after('check_type');
$table->index('check_type');
$table->index('probe_identifier');
});
DB::table('service_statuses')->update([
'check_type' => 'http',
'probe_identifier' => null,
]);
$now = now();
$nextOrder = (int) DB::table('service_statuses')->max('sort_order') + 1;
$probeServices = [
['name' => 'Database', 'slug' => 'database', 'probe_identifier' => 'database'],
['name' => 'Redis', 'slug' => 'redis', 'probe_identifier' => 'redis'],
['name' => 'Search Engine', 'slug' => 'search', 'probe_identifier' => 'search'],
['name' => 'NNTP', 'slug' => 'nntp', 'probe_identifier' => 'nntp'],
['name' => 'Queue', 'slug' => 'queue', 'probe_identifier' => 'queue'],
['name' => 'Disk Space', 'slug' => 'disk', 'probe_identifier' => 'disk'],
];
foreach ($probeServices as $service) {
DB::table('service_statuses')->updateOrInsert(
['slug' => $service['slug']],
[
'name' => $service['name'],
'endpoint_url' => null,
'check_type' => 'probe',
'probe_identifier' => $service['probe_identifier'],
'status' => 'operational',
'is_enabled' => true,
'sort_order' => $nextOrder++,
'updated_at' => $now,
'created_at' => $now,
]
);
}
}
public function down(): void
{
DB::table('service_statuses')
->whereIn('slug', ['database', 'redis', 'search', 'nntp', 'queue', 'disk'])
->delete();
Schema::table('service_statuses', static function (Blueprint $table): void {
$table->dropIndex(['check_type']);
$table->dropIndex(['probe_identifier']);
$table->dropColumn(['check_type', 'probe_identifier']);
});
}
};
+19 -22
View File
@@ -640,29 +640,26 @@
<!-- System Status -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-4">System Status</h3>
@php
$systemServiceSlugs = ['database', 'redis', 'queue', 'disk'];
$systemServices = $serviceStatuses->keyBy('slug');
@endphp
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Database</span>
<span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full text-sm">
<i class="fas fa-check-circle"></i> Connected
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Cache</span>
<span class="px-3 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full text-sm">
<i class="fas fa-check-circle"></i> Active
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Queue</span>
<span class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm">
<i class="fas fa-check-circle"></i> Running
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Disk Space</span>
<span class="text-sm text-gray-600">{{ $stats['disk_free'] ?? 'N/A' }} available</span>
</div>
@foreach($systemServiceSlugs as $slug)
@php $svc = $systemServices->get($slug); @endphp
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">{{ $svc?->name ?? ucfirst($slug) }}</span>
@if($svc)
<span class="{{ $dashBadge($svc->status->value) }}">
{{ $svc->status->label() }}
</span>
@else
<span class="px-3 py-1 bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-full text-sm">
Not configured
</span>
@endif
</div>
@endforeach
</div>
</div>
+1 -1
View File
@@ -59,4 +59,4 @@ Schedule::call(function () {
})->name('cleanup-api-request-logs')->hourly()->withoutOverlapping();
// Check tmux health and auto-restart if monitor pane is dead
Schedule::command('tmux:health-check --auto-restart')->everyThirtyMinutes()->withoutOverlapping();
Schedule::command('nntmux:check-service-health')->everyFiveMinutes()->withoutOverlapping();
Schedule::command('nntmux:check-service-health')->everyMinute()->withoutOverlapping();
+83
View File
@@ -0,0 +1,83 @@
<?php
namespace Tests\Unit\Models;
use App\Models\AnidbInfo;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AnidbInfoTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_returns_anidb_url_when_anidbid_exists(): void
{
$animeInfo = AnidbInfo::create([
'anidbid' => 12345,
'type' => 'TV Series',
]);
$url = $animeInfo->getAnidbUrl();
$this->assertEquals('https://anidb.net/anime/12345', $url);
}
/** @test */
public function it_returns_anilist_url_when_anilist_id_exists(): void
{
$animeInfo = AnidbInfo::create([
'anidbid' => 1,
'anilist_id' => 9253,
'type' => 'TV Series',
]);
$url = $animeInfo->getAnilistUrl();
$this->assertEquals('https://anilist.co/anime/9253', $url);
}
/** @test */
public function it_returns_myanimelist_url_when_myanimelist_id_exists(): void
{
$animeInfo = AnidbInfo::create([
'anidbid' => 1,
'myanimelist_id' => 9253,
'type' => 'TV Series',
]);
$url = $animeInfo->getMyAnimeListUrl();
$this->assertEquals('https://myanimelist.net/anime/9253', $url);
}
/** @test */
public function it_returns_all_external_links(): void
{
$animeInfo = AnidbInfo::create([
'anidbid' => 12345,
'anilist_id' => 9253,
'myanimelist_id' => 9253,
'type' => 'TV Series',
]);
$links = $animeInfo->getExternalLinks();
$this->assertIsArray($links);
$this->assertArrayHasKey('anidb', $links);
$this->assertArrayHasKey('anilist', $links);
$this->assertArrayHasKey('myanimelist', $links);
}
/** @test */
public function it_checks_if_external_links_exist(): void
{
$animeInfo = AnidbInfo::create([
'anidbid' => 1,
'anilist_id' => 123,
'type' => 'TV Series',
]);
$this->assertTrue($animeInfo->hasExternalLinks());
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\NameFixing;
use App\Services\NameFixing\FileNameCleaner;
use PHPUnit\Framework\TestCase;
class FileNameCleanerTest extends TestCase
{
public function test_clean_for_matching_rejects_url_shortcuts(): void
{
$cleaner = new FileNameCleaner;
$this->assertFalse($cleaner->cleanForMatching('Film ;-)/Extreem Online Contact.url'));
}
public function test_clean_for_matching_rejects_generic_dvd_structure_files(): void
{
$cleaner = new FileNameCleaner;
$this->assertFalse($cleaner->cleanForMatching('Film ;-)/VIDEO_TS/VIDEO_TS.VOB'));
$this->assertFalse($cleaner->cleanForMatching('Film ;-)/VIDEO_TS/VTS_01_1.VOB'));
$this->assertFalse($cleaner->cleanForMatching('Film ;-)/1.jpg'));
}
public function test_clean_for_matching_keeps_useful_cover_names(): void
{
$cleaner = new FileNameCleaner;
$this->assertSame('cover the fisher king', $cleaner->cleanForMatching('Film ;-)/cover the fisher king.jpg'));
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Services\NameFixing;
use App\Services\NameFixing\FilePrioritizer;
use PHPUnit\Framework\TestCase;
class FilePrioritizerTest extends TestCase
{
public function test_prioritize_for_matching_skips_url_and_dvd_structure_noise(): void
{
$prioritizer = new FilePrioritizer;
$prioritized = $prioritizer->prioritizeForMatching([
'Film ;-)/VIDEO_TS/VIDEO_TS.VOB',
'Film ;-)/VIDEO_TS/VTS_01_1.VOB',
'Film ;-)/1.jpg',
'Film ;-)/cover the fisher king.jpg',
'Film ;-)/Extreem Online Contact.url',
'Film ;-)/release.nfo',
]);
$this->assertSame([
'Film ;-)/release.nfo',
'Film ;-)/cover the fisher king.jpg',
], array_values($prioritized));
}
}