mirror of
https://github.com/NNTmux/newznab-tmux.git
synced 2026-08-28 17:01:16 +00:00
Refactor releases table
This commit is contained in:
@@ -45,7 +45,7 @@ class AdditionalProcessingDiagnose extends Command
|
||||
]);
|
||||
$this->table(['Preflight', 'Value'], [
|
||||
['Database driver', (string) $report['database_driver']],
|
||||
['Covering claim index', (string) ($report['indexes']['covering_index'] ?? 'missing')],
|
||||
['Claim queue index', (string) ($report['indexes']['claim_queue_index'] ?? 'missing')],
|
||||
['Temp path', (string) $report['temp_path']['path']],
|
||||
['Temp path writable', $report['temp_path']['writable'] ? 'yes' : 'no'],
|
||||
]);
|
||||
|
||||
@@ -86,7 +86,6 @@ class NntmuxResetPostProcessing extends Command
|
||||
'haspreview' => -1,
|
||||
'jpgstatus' => 0,
|
||||
'videostatus' => 0,
|
||||
'audiostatus' => 0,
|
||||
'nfostatus' => -1,
|
||||
]
|
||||
);
|
||||
@@ -366,7 +365,6 @@ class NntmuxResetPostProcessing extends Command
|
||||
'haspreview' => -1,
|
||||
'jpgstatus' => 0,
|
||||
'videostatus' => 0,
|
||||
'audiostatus' => 0,
|
||||
'nfostatus' => -1,
|
||||
]);
|
||||
Search::updateRelease((int) $releases->id);
|
||||
|
||||
@@ -75,7 +75,6 @@ class ProcessAdditionalGuid extends Command
|
||||
'haspreview' => -1,
|
||||
'jpgstatus' => 0,
|
||||
'videostatus' => 0,
|
||||
'audiostatus' => 0,
|
||||
'nfostatus' => -1,
|
||||
]);
|
||||
Search::updateRelease((int) $release->id);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
#[Signature('releases:optimize-preflight {--json : Output machine-readable JSON}')]
|
||||
#[Description('Read-only safety and data-volume report for the releases normalization migration')]
|
||||
class ReleasesOptimizePreflight extends Command
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const array REMOVED_INDEXES = [
|
||||
'ix_releases_adddate_only',
|
||||
'ix_releases_videos_id',
|
||||
'ix_releases_movieinfo_id',
|
||||
'ix_releases_imdbid',
|
||||
'ix_releases_tv_search_covering',
|
||||
'ix_releases_passwordstatus',
|
||||
'ix_releases_haspreview_passwordstatus',
|
||||
'ix_releases_postdate_searchname',
|
||||
'ix_releases_predb_id_searchname',
|
||||
'ix_releases_size_cat',
|
||||
'ix_releases_add_pp_claim_queue',
|
||||
'ix_releases_nzb_creation_queue',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const array NEW_INDEXES = [
|
||||
'ux_releases_guid',
|
||||
'ix_releases_predb_id',
|
||||
'ix_releases_size',
|
||||
'ix_releases_add_pp_claim_queue',
|
||||
'ix_releases_nzb_creation_group_queue',
|
||||
'ix_releases_nzb_creation_global_queue',
|
||||
];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $report = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! Schema::hasTable('releases')) {
|
||||
return $this->finish([
|
||||
'ok' => false,
|
||||
'database_driver' => DB::getDriverName(),
|
||||
'blockers' => [['code' => 'missing-releases-table', 'count' => 1]],
|
||||
]);
|
||||
}
|
||||
|
||||
$identifierCounts = $this->identifierCounts();
|
||||
$blockers = [];
|
||||
foreach ($identifierCounts as $code => $count) {
|
||||
if ($count > 0) {
|
||||
$blockers[] = ['code' => $code, 'count' => $count];
|
||||
}
|
||||
}
|
||||
|
||||
$indexes = array_values(array_filter(array_map(
|
||||
static fn (array $index): ?string => isset($index['name']) ? (string) $index['name'] : null,
|
||||
Schema::getIndexes('releases'),
|
||||
)));
|
||||
sort($indexes);
|
||||
|
||||
$this->report = [
|
||||
'ok' => $blockers === [],
|
||||
'database_driver' => DB::getDriverName(),
|
||||
'releases' => [
|
||||
'rows' => DB::table('releases')->count(),
|
||||
...$identifierCounts,
|
||||
],
|
||||
'storage' => $this->storage(),
|
||||
'migration_data' => [
|
||||
'nzb_password_rows' => $this->countNonEmpty('releases', 'nzb_password'),
|
||||
'nzb_creation_failure_rows' => $this->retryStateCount(),
|
||||
'visible_comment_rows' => $this->visibleCommentCount(),
|
||||
'release_comment_counter_mismatches' => $this->commentCounterMismatchCount(),
|
||||
],
|
||||
'discarded_data' => $this->discardedDataCounts(),
|
||||
'indexes' => [
|
||||
'present' => $indexes,
|
||||
'scheduled_for_removal' => array_values(array_intersect(self::REMOVED_INDEXES, $indexes)),
|
||||
'already_present_replacements' => array_values(array_intersect(self::NEW_INDEXES, $indexes)),
|
||||
],
|
||||
'blockers' => $blockers,
|
||||
];
|
||||
|
||||
return $this->finish($this->report);
|
||||
}
|
||||
|
||||
/** @return array<string, int> */
|
||||
private function identifierCounts(): array
|
||||
{
|
||||
$duplicateIds = DB::query()->fromSub(
|
||||
DB::table('releases')->select('id')->groupBy('id')->havingRaw('COUNT(*) > 1'),
|
||||
'duplicate_release_ids',
|
||||
)->count();
|
||||
$duplicateGuids = DB::query()->fromSub(
|
||||
DB::table('releases')->selectRaw('LOWER(guid) AS normalized_guid')->groupByRaw('LOWER(guid)')->havingRaw('COUNT(*) > 1'),
|
||||
'duplicate_release_guids',
|
||||
)->count();
|
||||
|
||||
if (in_array(DB::getDriverName(), ['mariadb', 'mysql'], true)) {
|
||||
$invalidGuids = DB::table('releases')
|
||||
->whereNull('guid')
|
||||
->orWhereRaw("guid NOT REGEXP '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$'")
|
||||
->count();
|
||||
} else {
|
||||
$invalidGuids = DB::table('releases')->pluck('guid')->filter(
|
||||
static fn (mixed $guid): bool => ! is_string($guid)
|
||||
|| preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $guid) !== 1,
|
||||
)->count();
|
||||
}
|
||||
|
||||
return [
|
||||
'duplicate_ids' => $duplicateIds,
|
||||
'invalid_guids' => $invalidGuids,
|
||||
'case_insensitive_duplicate_guids' => $duplicateGuids,
|
||||
'leftguid_mismatches' => DB::table('releases')
|
||||
->whereRaw('LOWER(leftguid) <> LOWER(SUBSTR(guid, 1, 1))')
|
||||
->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{data_bytes: int|null, index_bytes: int|null, total_bytes: int|null, required_free_bytes: int|null} */
|
||||
private function storage(): array
|
||||
{
|
||||
if (! in_array(DB::getDriverName(), ['mariadb', 'mysql'], true)) {
|
||||
return ['data_bytes' => null, 'index_bytes' => null, 'total_bytes' => null, 'required_free_bytes' => null];
|
||||
}
|
||||
|
||||
$tableName = DB::getTablePrefix().'releases';
|
||||
$row = DB::selectOne(
|
||||
'SELECT DATA_LENGTH AS data_bytes, INDEX_LENGTH AS index_bytes
|
||||
FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?',
|
||||
[$tableName],
|
||||
);
|
||||
$dataBytes = isset($row->data_bytes) ? (int) $row->data_bytes : 0;
|
||||
$indexBytes = isset($row->index_bytes) ? (int) $row->index_bytes : 0;
|
||||
$totalBytes = $dataBytes + $indexBytes;
|
||||
|
||||
return [
|
||||
'data_bytes' => $dataBytes,
|
||||
'index_bytes' => $indexBytes,
|
||||
'total_bytes' => $totalBytes,
|
||||
'required_free_bytes' => $totalBytes * 2,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, int|null> */
|
||||
private function discardedDataCounts(): array
|
||||
{
|
||||
$counts = [];
|
||||
foreach ([
|
||||
'updatetime' => ['not_null', null],
|
||||
'gid' => ['non_empty', null],
|
||||
'source' => ['not_null', null],
|
||||
'proc_sorter' => ['non_zero', null],
|
||||
'audiostatus' => ['non_zero', null],
|
||||
] as $column => [$condition]) {
|
||||
$counts['releases.'.$column] = $this->countByCondition('releases', $column, $condition);
|
||||
}
|
||||
|
||||
foreach (['gid', 'cid', 'shareid', 'siteid'] as $column) {
|
||||
$counts['release_comments.'.$column] = $this->countByCondition('release_comments', $column, 'non_empty');
|
||||
}
|
||||
foreach (['issynced', 'shared', 'sourceid'] as $column) {
|
||||
$counts['release_comments.'.$column] = $this->countByCondition(
|
||||
'release_comments',
|
||||
$column,
|
||||
$column === 'sourceid' ? 'not_null' : 'non_zero',
|
||||
);
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function retryStateCount(): int
|
||||
{
|
||||
if (! Schema::hasColumn('releases', 'nzb_creation_attempts')) {
|
||||
return Schema::hasTable('release_nzb_creation_failures')
|
||||
? DB::table('release_nzb_creation_failures')->count()
|
||||
: 0;
|
||||
}
|
||||
|
||||
return DB::table('releases')->where(function ($query): void {
|
||||
$query->where('nzb_creation_attempts', '>', 0)->orWhereNotNull('nzb_creation_last_error');
|
||||
})->count();
|
||||
}
|
||||
|
||||
private function visibleCommentCount(): int
|
||||
{
|
||||
return Schema::hasTable('release_comments')
|
||||
? DB::table('release_comments')->where('isvisible', 1)->count()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private function commentCounterMismatchCount(): int
|
||||
{
|
||||
if (! Schema::hasTable('release_comments') || ! Schema::hasColumn('releases', 'comments')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return DB::query()->fromSub(
|
||||
DB::table('releases as r')
|
||||
->leftJoin('release_comments as rc', function ($join): void {
|
||||
$join->on('rc.releases_id', '=', 'r.id')->where('rc.isvisible', 1);
|
||||
})
|
||||
->select('r.id', 'r.comments')
|
||||
->selectRaw('COUNT(rc.id) AS visible_comments')
|
||||
->groupBy('r.id', 'r.comments')
|
||||
->havingRaw('r.comments <> COUNT(rc.id)'),
|
||||
'comment_counter_mismatches',
|
||||
)->count();
|
||||
}
|
||||
|
||||
private function countNonEmpty(string $table, string $column): int
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
|
||||
return $table === 'releases' && Schema::hasTable('release_nzb_passwords')
|
||||
? DB::table('release_nzb_passwords')->count()
|
||||
: 0;
|
||||
}
|
||||
|
||||
return DB::table($table)->whereNotNull($column)->where($column, '<>', '')->count();
|
||||
}
|
||||
|
||||
private function countByCondition(string $table, string $column, string $condition): ?int
|
||||
{
|
||||
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, $column)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = DB::table($table);
|
||||
|
||||
return match ($condition) {
|
||||
'non_empty' => $query->whereNotNull($column)->where($column, '<>', '')->count(),
|
||||
'non_zero' => $query->where($column, '<>', 0)->count(),
|
||||
default => $query->whereNotNull($column)->count(),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $report */
|
||||
private function finish(array $report): int
|
||||
{
|
||||
if ($this->option('json')) {
|
||||
$this->line((string) json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
} else {
|
||||
$this->renderHumanReport($report);
|
||||
}
|
||||
|
||||
return ($report['ok'] ?? false) ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $report */
|
||||
private function renderHumanReport(array $report): void
|
||||
{
|
||||
if (! isset($report['releases'])) {
|
||||
$this->error('The releases table does not exist.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->table(['Safety check', 'Count'], [
|
||||
['Duplicate IDs', $report['releases']['duplicate_ids']],
|
||||
['Invalid GUIDs', $report['releases']['invalid_guids']],
|
||||
['Case-insensitive duplicate GUIDs', $report['releases']['case_insensitive_duplicate_guids']],
|
||||
['Mismatched leftguid values', $report['releases']['leftguid_mismatches']],
|
||||
]);
|
||||
$this->table(['Migration data', 'Rows'], array_map(
|
||||
static fn (string $key, int $value): array => [$key, $value],
|
||||
array_keys($report['migration_data']),
|
||||
array_values($report['migration_data']),
|
||||
));
|
||||
|
||||
if ($report['ok']) {
|
||||
$this->info('Releases optimization preflight passed.');
|
||||
} else {
|
||||
$this->error('Releases optimization preflight failed. Resolve every blocker before migration.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class DetailsController extends BasePageController
|
||||
}
|
||||
|
||||
if ($this->isPostBack($request)) {
|
||||
ReleaseComment::addComment($data['id'], $data['gid'], $request->input('txtAddComment'), $this->userdata->id, $request->ip());
|
||||
ReleaseComment::addComment((int) $data['id'], (string) $request->input('txtAddComment'), (int) $this->userdata->id, $request->ip());
|
||||
|
||||
return redirect()->route('details', ['guid' => $guid])->with('success', 'Comment posted successfully!');
|
||||
}
|
||||
|
||||
+12
-1
@@ -132,6 +132,18 @@ class Release extends Model
|
||||
return $this->hasOne(ReleaseNfo::class, 'releases_id');
|
||||
}
|
||||
|
||||
/** @return HasOne<ReleaseNzbPassword, $this> */
|
||||
public function nzbPassword(): HasOne
|
||||
{
|
||||
return $this->hasOne(ReleaseNzbPassword::class, 'releases_id');
|
||||
}
|
||||
|
||||
/** @return HasOne<ReleaseNzbCreationFailure, $this> */
|
||||
public function nzbCreationFailure(): HasOne
|
||||
{
|
||||
return $this->hasOne(ReleaseNzbCreationFailure::class, 'releases_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<ReleaseComment, $this>
|
||||
*/
|
||||
@@ -212,7 +224,6 @@ class Release extends Model
|
||||
'isrenamed' => $parameters['isrenamed'],
|
||||
'iscategorized' => 1,
|
||||
'predb_id' => $parameters['predb_id'],
|
||||
'source' => $parameters['source'] ?? null,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* App\Models\ReleaseComment.
|
||||
@@ -16,34 +17,20 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property int $releases_id FK to releases.id
|
||||
* @property string $text
|
||||
* @property bool $isvisible
|
||||
* @property bool $issynced
|
||||
* @property string|null $gid
|
||||
* @property string|null $cid
|
||||
* @property string $text_hash
|
||||
* @property string $username
|
||||
* @property int $users_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property string|null $host
|
||||
* @property bool $shared
|
||||
* @property string $shareid
|
||||
* @property string $siteid
|
||||
* @property int|null $sourceid
|
||||
* @property-read Release $release
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereCid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereCreatedAt($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereGid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereHost($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereIssynced($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereIsvisible($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereReleasesId($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereShared($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereShareid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereSiteid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereSourceid($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereText($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereTextHash($value)
|
||||
* @method static \Illuminate\Database\Eloquent\Builder|\App\Models\ReleaseComment whereUpdatedAt($value)
|
||||
@@ -108,13 +95,18 @@ class ReleaseComment extends Model
|
||||
/**
|
||||
* Delete single comment on the site.
|
||||
*/
|
||||
public static function deleteComment(mixed $id): void
|
||||
public static function deleteComment(int $id): void
|
||||
{
|
||||
$res = self::getCommentById($id);
|
||||
if ($res) {
|
||||
self::query()->where('id', $id)->delete();
|
||||
self::updateReleaseCommentCount($res['gid']);
|
||||
}
|
||||
DB::transaction(function () use ($id): void {
|
||||
$comment = self::query()->find($id, ['id', 'releases_id']);
|
||||
if ($comment === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$releaseId = (int) $comment->releases_id;
|
||||
$comment->delete();
|
||||
self::updateReleaseCommentCount($releaseId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,31 +116,31 @@ class ReleaseComment extends Model
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public static function addComment(mixed $id, mixed $gid, mixed $text, mixed $userid, mixed $host): int
|
||||
public static function addComment(int $releaseId, string $text, int $userId, ?string $host): int
|
||||
{
|
||||
if (config('nntmux:settings.store_user_ip') === false) {
|
||||
$host = '';
|
||||
}
|
||||
|
||||
$username = User::query()->where('id', $userid)->first(['username']);
|
||||
$username = User::query()->where('id', $userId)->first(['username']);
|
||||
$username = ($username === null ? 'ANON' : $username['username']);
|
||||
|
||||
$comid = self::query()
|
||||
->insertGetId(
|
||||
return DB::transaction(function () use ($releaseId, $text, $userId, $host, $username): int {
|
||||
$commentId = self::query()->insertGetId(
|
||||
[
|
||||
'releases_id' => $id,
|
||||
'gid' => $gid,
|
||||
'releases_id' => $releaseId,
|
||||
'text' => $text,
|
||||
'users_id' => $userid,
|
||||
'users_id' => $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
'host' => $host,
|
||||
'username' => $username,
|
||||
]
|
||||
);
|
||||
self::updateReleaseCommentCount($id);
|
||||
self::updateReleaseCommentCount($releaseId);
|
||||
|
||||
return $comid;
|
||||
return $commentId;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,10 +159,13 @@ class ReleaseComment extends Model
|
||||
/**
|
||||
* Update the denormalised count of comments for a release.
|
||||
*/
|
||||
public static function updateReleaseCommentCount(mixed $gid): void
|
||||
public static function updateReleaseCommentCount(int $releaseId): void
|
||||
{
|
||||
$commentCount = self::query()->where('gid', '=', 'releases.gid')->where('isvisible', '=', 1)->count('id');
|
||||
Release::query()->where('gid', $gid)->update(['comments' => $commentCount]);
|
||||
$commentCount = self::query()
|
||||
->where('releases_id', $releaseId)
|
||||
->where('isvisible', 1)
|
||||
->count('id');
|
||||
Release::query()->whereKey($releaseId)->update(['comments' => $commentCount]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $releases_id
|
||||
* @property int $attempts
|
||||
* @property string|null $last_error
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
* @property-read Release $release
|
||||
*/
|
||||
class ReleaseNzbCreationFailure extends Model
|
||||
{
|
||||
protected $primaryKey = 'releases_id';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'int';
|
||||
|
||||
/** @var array<string> */
|
||||
protected $guarded = [];
|
||||
|
||||
/** @return array<string, string> */
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['attempts' => 'integer'];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Release, $this> */
|
||||
public function release(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Release::class, 'releases_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $releases_id
|
||||
* @property string $password
|
||||
* @property-read Release $release
|
||||
*/
|
||||
class ReleaseNzbPassword extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = 'releases_id';
|
||||
|
||||
public $incrementing = false;
|
||||
|
||||
protected $keyType = 'int';
|
||||
|
||||
/** @var array<string> */
|
||||
protected $guarded = [];
|
||||
|
||||
/** @return BelongsTo<Release, $this> */
|
||||
public function release(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Release::class, 'releases_id');
|
||||
}
|
||||
}
|
||||
@@ -23,8 +23,9 @@ final class AdditionalProcessingDiagnostics
|
||||
'haspreview',
|
||||
'nzbstatus',
|
||||
'leftguid',
|
||||
'additional_pp_claimed_at',
|
||||
'postdate',
|
||||
'id',
|
||||
'additional_pp_claimed_at',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -150,12 +151,12 @@ final class AdditionalProcessingDiagnostics
|
||||
|
||||
/**
|
||||
* @param list<array{code: string, message: string}> $warnings
|
||||
* @return array{required_columns: list<string>, present: list<string>, covering_index: string|null, missing: bool}
|
||||
* @return array{required_columns: list<string>, present: list<string>, claim_queue_index: string|null, missing: bool}
|
||||
*/
|
||||
private function indexes(array &$warnings): array
|
||||
{
|
||||
$present = [];
|
||||
$coveringIndex = null;
|
||||
$claimQueueIndex = null;
|
||||
|
||||
if (Schema::hasTable('releases')) {
|
||||
foreach (Schema::getIndexes('releases') as $index) {
|
||||
@@ -169,13 +170,13 @@ final class AdditionalProcessingDiagnostics
|
||||
$present[] = $name;
|
||||
}
|
||||
if ($columns === self::REQUIRED_CLAIM_INDEX_COLUMNS) {
|
||||
$coveringIndex = $name;
|
||||
$claimQueueIndex = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($present);
|
||||
$missing = $coveringIndex === null;
|
||||
$missing = $claimQueueIndex === null;
|
||||
if ($missing) {
|
||||
$warnings[] = $this->warning(
|
||||
'missing-index',
|
||||
@@ -186,7 +187,7 @@ final class AdditionalProcessingDiagnostics
|
||||
return [
|
||||
'required_columns' => self::REQUIRED_CLAIM_INDEX_COLUMNS,
|
||||
'present' => $present,
|
||||
'covering_index' => $coveringIndex,
|
||||
'claim_queue_index' => $claimQueueIndex,
|
||||
'missing' => $missing,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -390,7 +390,6 @@ class MediaExtractionService
|
||||
}
|
||||
|
||||
@chmod($this->config->audioSavePath.$audioFileName, 0764);
|
||||
Release::query()->where('id', $context->release->id)->update(['audiostatus' => 1]);
|
||||
$result['audioSample'] = true;
|
||||
$context->foundAudioSample = true;
|
||||
}
|
||||
|
||||
@@ -352,7 +352,7 @@ class ReleaseUpdateService
|
||||
'PAR2, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_par2' => 1],
|
||||
'Filenames, ', 'file matched source: ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_files' => 1],
|
||||
'PreDB FT Exact, ' => ['isrenamed' => 1, 'iscategorized' => 1],
|
||||
'sorter, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_sorter' => 1],
|
||||
'sorter, ' => ['isrenamed' => 1, 'iscategorized' => 1],
|
||||
'UID, ', 'Mediainfo, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_uid' => 1],
|
||||
'PAR2 hash, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_hash16k' => 1],
|
||||
'SRR, ' => ['isrenamed' => 1, 'iscategorized' => 1, 'proc_srr' => 1],
|
||||
|
||||
@@ -17,10 +17,6 @@ final class NzbCreationCandidateQuery
|
||||
|
||||
public const string CLAIM_TOKEN_COLUMN = 'nzb_creation_claim_token';
|
||||
|
||||
public const string ATTEMPTS_COLUMN = 'nzb_creation_attempts';
|
||||
|
||||
public const string LAST_ERROR_COLUMN = 'nzb_creation_last_error';
|
||||
|
||||
/**
|
||||
* @return Builder<Release>
|
||||
*/
|
||||
@@ -83,12 +79,17 @@ final class NzbCreationCandidateQuery
|
||||
]);
|
||||
}
|
||||
|
||||
return Release::query()
|
||||
$releaseQuery = Release::query()
|
||||
->whereIn('id', $ids)
|
||||
->with('category.parent')
|
||||
->select(self::selectableColumns($columns, $supportsClaims))
|
||||
->orderByRaw(self::idOrderExpression($ids))
|
||||
->get();
|
||||
->orderByRaw(self::idOrderExpression($ids));
|
||||
|
||||
if (self::supportsFailureState()) {
|
||||
$releaseQuery->with('nzbCreationFailure');
|
||||
}
|
||||
|
||||
return $releaseQuery->get();
|
||||
}, 3);
|
||||
}
|
||||
|
||||
@@ -116,31 +117,12 @@ final class NzbCreationCandidateQuery
|
||||
}
|
||||
|
||||
return Schema::hasColumn('releases', self::CLAIMED_AT_COLUMN)
|
||||
&& Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN)
|
||||
&& Schema::hasColumn('releases', self::ATTEMPTS_COLUMN)
|
||||
&& Schema::hasColumn('releases', self::LAST_ERROR_COLUMN);
|
||||
&& Schema::hasColumn('releases', self::CLAIM_TOKEN_COLUMN);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function failureUpdateValues(string $reason, bool $incrementAttempts): array
|
||||
public static function supportsFailureState(): bool
|
||||
{
|
||||
if (! self::supportsClaims()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = [
|
||||
self::CLAIMED_AT_COLUMN => null,
|
||||
self::CLAIM_TOKEN_COLUMN => null,
|
||||
self::LAST_ERROR_COLUMN => mb_substr($reason, 0, 1000),
|
||||
];
|
||||
|
||||
if ($incrementAttempts) {
|
||||
$values[self::ATTEMPTS_COLUMN] = DB::raw(self::ATTEMPTS_COLUMN.' + 1');
|
||||
}
|
||||
|
||||
return $values;
|
||||
return Schema::hasTable('release_nzb_creation_failures');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -183,8 +165,6 @@ final class NzbCreationCandidateQuery
|
||||
static fn (string $column): bool => ! in_array($column, [
|
||||
self::CLAIMED_AT_COLUMN,
|
||||
self::CLAIM_TOKEN_COLUMN,
|
||||
self::ATTEMPTS_COLUMN,
|
||||
self::LAST_ERROR_COLUMN,
|
||||
], true),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Services\Nzb;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseNzbCreationFailure;
|
||||
use App\Models\Settings;
|
||||
use App\Services\Binaries\BinariesConfig;
|
||||
use App\Services\CollectionCleanupService;
|
||||
@@ -262,8 +263,16 @@ class NzbService
|
||||
return NzbCreationResult::transient("Final NZB file is missing or unreadable: {$path}", $collectionIds, $path);
|
||||
}
|
||||
|
||||
// Mark release as having NZB.
|
||||
$release->update($this->successfulReleaseUpdateValues());
|
||||
DB::transaction(function () use ($release): void {
|
||||
$release->update($this->successfulReleaseUpdateValues());
|
||||
|
||||
if (NzbCreationCandidateQuery::supportsFailureState()) {
|
||||
ReleaseNzbCreationFailure::query()
|
||||
->where('releases_id', $release->id)
|
||||
->delete();
|
||||
$release->unsetRelation('nzbCreationFailure');
|
||||
}
|
||||
});
|
||||
} catch (Throwable $e) {
|
||||
return NzbCreationResult::transient('Failed to write NZB file: '.$e->getMessage(), $collectionIds, $path);
|
||||
} finally {
|
||||
@@ -729,8 +738,6 @@ class NzbService
|
||||
|
||||
if (NzbCreationCandidateQuery::supportsClaims()) {
|
||||
$values += [
|
||||
NzbCreationCandidateQuery::ATTEMPTS_COLUMN => 0,
|
||||
NzbCreationCandidateQuery::LAST_ERROR_COLUMN => null,
|
||||
NzbCreationCandidateQuery::CLAIMED_AT_COLUMN => null,
|
||||
NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN => null,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Category;
|
||||
use App\Models\Collection;
|
||||
use App\Models\MusicInfo;
|
||||
use App\Models\Release;
|
||||
use App\Models\ReleaseNzbCreationFailure;
|
||||
use App\Models\Settings;
|
||||
use App\Models\UsenetGroup;
|
||||
use App\Services\Binaries\BinariesConfig;
|
||||
@@ -690,7 +691,6 @@ final class ReleaseProcessingService
|
||||
'groups_id',
|
||||
'postdate',
|
||||
'nzbstatus',
|
||||
NzbCreationCandidateQuery::ATTEMPTS_COLUMN,
|
||||
NzbCreationCandidateQuery::CLAIM_TOKEN_COLUMN,
|
||||
];
|
||||
|
||||
@@ -754,24 +754,34 @@ final class ReleaseProcessingService
|
||||
|
||||
private function nextNzbCreationAttempt(Release $release): int
|
||||
{
|
||||
return ((int) ($release->{NzbCreationCandidateQuery::ATTEMPTS_COLUMN} ?? 0)) + 1;
|
||||
$failureState = $release->relationLoaded('nzbCreationFailure')
|
||||
? $release->getRelation('nzbCreationFailure')
|
||||
: null;
|
||||
|
||||
return ($failureState instanceof ReleaseNzbCreationFailure ? $failureState->attempts : 0) + 1;
|
||||
}
|
||||
|
||||
private function recordNzbCreationRetry(Release $release, NzbCreationResult $result): void
|
||||
{
|
||||
$values = NzbCreationCandidateQuery::failureUpdateValues($result->reason, true);
|
||||
if ($values !== []) {
|
||||
Release::query()
|
||||
->where('id', $release->id)
|
||||
->update($values);
|
||||
}
|
||||
$nextAttempt = $this->nextNzbCreationAttempt($release);
|
||||
ReleaseNzbCreationFailure::query()->upsert(
|
||||
[[
|
||||
'releases_id' => (int) $release->id,
|
||||
'attempts' => $nextAttempt,
|
||||
'last_error' => mb_substr($result->reason, 0, 1000),
|
||||
]],
|
||||
['releases_id'],
|
||||
['attempts', 'last_error'],
|
||||
);
|
||||
$failure = ReleaseNzbCreationFailure::query()->findOrFail((int) $release->id);
|
||||
$release->setRelation('nzbCreationFailure', $failure);
|
||||
|
||||
Log::channel('nzb_creation')->warning('NZB creation failed; release will be retried', [
|
||||
'release_id' => $release->id,
|
||||
'guid' => $release->guid,
|
||||
'failure_type' => $result->failureType,
|
||||
'reason' => $result->reason,
|
||||
'next_attempt' => $this->nextNzbCreationAttempt($release),
|
||||
'next_attempt' => $nextAttempt,
|
||||
'max_attempts' => self::NZB_CREATION_MAX_ATTEMPTS,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
<?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
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const array REMOVED_RELEASE_COLUMNS = [
|
||||
'updatetime',
|
||||
'gid',
|
||||
'source',
|
||||
'nzb_password',
|
||||
'nzb_creation_attempts',
|
||||
'nzb_creation_last_error',
|
||||
'proc_sorter',
|
||||
'audiostatus',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const array REMOVED_COMMENT_COLUMNS = [
|
||||
'gid',
|
||||
'cid',
|
||||
'issynced',
|
||||
'shared',
|
||||
'shareid',
|
||||
'siteid',
|
||||
'sourceid',
|
||||
];
|
||||
|
||||
/** @var list<string> */
|
||||
private const array REMOVED_RELEASE_INDEXES = [
|
||||
'ix_releases_guid',
|
||||
'ix_releases_adddate_only',
|
||||
'ix_releases_videos_id',
|
||||
'ix_releases_movieinfo_id',
|
||||
'ix_releases_imdbid',
|
||||
'ix_releases_tv_search_covering',
|
||||
'ix_releases_passwordstatus',
|
||||
'ix_releases_haspreview_passwordstatus',
|
||||
'ix_releases_postdate_searchname',
|
||||
'ix_releases_predb_id_searchname',
|
||||
'ix_releases_size_cat',
|
||||
'ix_releases_add_pp_claim_queue',
|
||||
'ix_releases_nzb_creation_queue',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('releases')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->assertReleaseIdentifiersAreSafe();
|
||||
$this->createSparseTables();
|
||||
$this->backfillSparseTables();
|
||||
$this->recountVisibleComments();
|
||||
|
||||
if ($this->isMariaDbOrMySql()) {
|
||||
$this->rebuildReleasesForMariaDb();
|
||||
} else {
|
||||
$this->normalizeReleasesForPortableDatabase();
|
||||
}
|
||||
|
||||
$this->addSparseForeignKeys();
|
||||
|
||||
if (Schema::hasTable('release_comments')) {
|
||||
Schema::table('release_comments', function (Blueprint $table): void {
|
||||
foreach (self::REMOVED_COMMENT_COLUMNS as $column) {
|
||||
if (Schema::hasColumn('release_comments', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('releases')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dropSparseForeignKeys();
|
||||
|
||||
if (Schema::hasTable('release_comments')) {
|
||||
Schema::table('release_comments', function (Blueprint $table): void {
|
||||
$table->string('gid', 32)->nullable();
|
||||
$table->string('cid', 32)->nullable();
|
||||
$table->boolean('issynced')->default(false);
|
||||
$table->boolean('shared')->default(false);
|
||||
$table->string('shareid', 40)->default('');
|
||||
$table->string('siteid', 40)->default('');
|
||||
$table->unsignedBigInteger('sourceid')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->isMariaDbOrMySql()) {
|
||||
$this->restoreReleasesForMariaDb();
|
||||
} else {
|
||||
$this->restoreReleasesForPortableDatabase();
|
||||
}
|
||||
|
||||
$this->restoreSparseDataToReleases();
|
||||
Schema::dropIfExists('release_nzb_creation_failures');
|
||||
Schema::dropIfExists('release_nzb_passwords');
|
||||
}
|
||||
|
||||
private function createSparseTables(): void
|
||||
{
|
||||
if (! Schema::hasTable('release_nzb_passwords')) {
|
||||
Schema::create('release_nzb_passwords', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('releases_id')->primary();
|
||||
$table->string('password', 255);
|
||||
if (! $this->isMariaDbOrMySql()) {
|
||||
$table->foreign('releases_id', 'FK_rnp_releases')
|
||||
->references('id')->on('releases')
|
||||
->cascadeOnUpdate()->cascadeOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('release_nzb_creation_failures')) {
|
||||
Schema::create('release_nzb_creation_failures', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('releases_id')->primary();
|
||||
$table->unsignedSmallInteger('attempts')->default(0);
|
||||
$table->text('last_error')->nullable();
|
||||
$table->timestamps();
|
||||
if (! $this->isMariaDbOrMySql()) {
|
||||
$table->foreign('releases_id', 'FK_rncf_releases')
|
||||
->references('id')->on('releases')
|
||||
->cascadeOnUpdate()->cascadeOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function addSparseForeignKeys(): void
|
||||
{
|
||||
if (! $this->isMariaDbOrMySql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
'ALTER TABLE '.$this->table('release_nzb_passwords')
|
||||
.' ADD CONSTRAINT `FK_rnp_releases` FOREIGN KEY (`releases_id`) REFERENCES '
|
||||
.$this->table('releases').' (`id`) ON DELETE CASCADE ON UPDATE CASCADE'
|
||||
);
|
||||
DB::statement(
|
||||
'ALTER TABLE '.$this->table('release_nzb_creation_failures')
|
||||
.' ADD CONSTRAINT `FK_rncf_releases` FOREIGN KEY (`releases_id`) REFERENCES '
|
||||
.$this->table('releases').' (`id`) ON DELETE CASCADE ON UPDATE CASCADE'
|
||||
);
|
||||
}
|
||||
|
||||
private function dropSparseForeignKeys(): void
|
||||
{
|
||||
if (! $this->isMariaDbOrMySql()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('release_nzb_passwords')) {
|
||||
DB::statement('ALTER TABLE '.$this->table('release_nzb_passwords').' DROP FOREIGN KEY `FK_rnp_releases`');
|
||||
}
|
||||
if (Schema::hasTable('release_nzb_creation_failures')) {
|
||||
DB::statement('ALTER TABLE '.$this->table('release_nzb_creation_failures').' DROP FOREIGN KEY `FK_rncf_releases`');
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillSparseTables(): void
|
||||
{
|
||||
if (Schema::hasColumn('releases', 'nzb_password')) {
|
||||
DB::table('releases')
|
||||
->select(['id', 'nzb_password'])
|
||||
->whereNotNull('nzb_password')
|
||||
->where('nzb_password', '<>', '')
|
||||
->orderBy('id')
|
||||
->chunkById(1000, function ($releases): void {
|
||||
$rows = [];
|
||||
foreach ($releases as $release) {
|
||||
$rows[] = [
|
||||
'releases_id' => (int) $release->id,
|
||||
'password' => (string) $release->nzb_password,
|
||||
];
|
||||
}
|
||||
DB::table('release_nzb_passwords')->upsert($rows, ['releases_id'], ['password']);
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('releases', 'nzb_creation_attempts')) {
|
||||
DB::table('releases')
|
||||
->select(['id', 'nzb_creation_attempts', 'nzb_creation_last_error'])
|
||||
->where(function ($query): void {
|
||||
$query->where('nzb_creation_attempts', '>', 0)
|
||||
->orWhereNotNull('nzb_creation_last_error');
|
||||
})
|
||||
->orderBy('id')
|
||||
->chunkById(1000, function ($releases): void {
|
||||
$now = now();
|
||||
$rows = [];
|
||||
foreach ($releases as $release) {
|
||||
$rows[] = [
|
||||
'releases_id' => (int) $release->id,
|
||||
'attempts' => (int) $release->nzb_creation_attempts,
|
||||
'last_error' => $release->nzb_creation_last_error,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
DB::table('release_nzb_creation_failures')->upsert(
|
||||
$rows,
|
||||
['releases_id'],
|
||||
['attempts', 'last_error', 'updated_at'],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private function recountVisibleComments(): void
|
||||
{
|
||||
if (! Schema::hasTable('release_comments') || ! Schema::hasColumn('releases', 'comments')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
'UPDATE '.$this->table('releases').' SET `comments` = (SELECT COUNT(*) FROM '
|
||||
.$this->table('release_comments').' WHERE '
|
||||
.$this->table('release_comments').'.`releases_id` = '
|
||||
.$this->table('releases').'.`id` AND '
|
||||
.$this->table('release_comments').'.`isvisible` = 1)'
|
||||
);
|
||||
}
|
||||
|
||||
private function rebuildReleasesForMariaDb(): void
|
||||
{
|
||||
$specifications = ['DROP PRIMARY KEY', 'ADD PRIMARY KEY (`id`)'];
|
||||
foreach (self::REMOVED_RELEASE_INDEXES as $index) {
|
||||
if ($this->indexExists($index)) {
|
||||
$specifications[] = 'DROP INDEX `'.$index.'`';
|
||||
}
|
||||
}
|
||||
|
||||
$specifications[] = 'MODIFY `guid` CHAR(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL';
|
||||
$specifications[] = "MODIFY `leftguid` CHAR(1) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL COMMENT 'The first letter of the release guid'";
|
||||
foreach (self::REMOVED_RELEASE_COLUMNS as $column) {
|
||||
if (Schema::hasColumn('releases', $column)) {
|
||||
$specifications[] = 'DROP COLUMN `'.$column.'`';
|
||||
}
|
||||
}
|
||||
|
||||
$specifications = [
|
||||
...$specifications,
|
||||
'ADD UNIQUE INDEX `ux_releases_guid` (`guid`)',
|
||||
'ADD INDEX `ix_releases_predb_id` (`predb_id`)',
|
||||
'ADD INDEX `ix_releases_size` (`size`)',
|
||||
'ADD INDEX `ix_releases_add_pp_claim_queue` (`passwordstatus`, `haspreview`, `nzbstatus`, `leftguid`, `postdate` DESC, `id`, `additional_pp_claimed_at`)',
|
||||
'ADD INDEX `ix_releases_nzb_creation_group_queue` (`nzbstatus`, `groups_id`, `postdate` DESC, `id`, `nzb_creation_claimed_at`)',
|
||||
'ADD INDEX `ix_releases_nzb_creation_global_queue` (`nzbstatus`, `postdate` DESC, `id`, `nzb_creation_claimed_at`)',
|
||||
];
|
||||
|
||||
DB::statement('ALTER TABLE '.$this->table('releases').' '.implode(', ', $specifications));
|
||||
}
|
||||
|
||||
private function normalizeReleasesForPortableDatabase(): void
|
||||
{
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
foreach (self::REMOVED_RELEASE_INDEXES as $index) {
|
||||
if ($this->indexExists($index)) {
|
||||
$table->dropIndex($index);
|
||||
}
|
||||
}
|
||||
foreach (self::REMOVED_RELEASE_COLUMNS as $column) {
|
||||
if (Schema::hasColumn('releases', $column)) {
|
||||
$table->dropColumn($column);
|
||||
}
|
||||
}
|
||||
$table->unique('guid', 'ux_releases_guid');
|
||||
$table->index('predb_id', 'ix_releases_predb_id');
|
||||
$table->index('size', 'ix_releases_size');
|
||||
$table->index(
|
||||
['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'],
|
||||
'ix_releases_add_pp_claim_queue',
|
||||
);
|
||||
$table->index(
|
||||
['nzbstatus', 'groups_id', 'postdate', 'id', 'nzb_creation_claimed_at'],
|
||||
'ix_releases_nzb_creation_group_queue',
|
||||
);
|
||||
$table->index(
|
||||
['nzbstatus', 'postdate', 'id', 'nzb_creation_claimed_at'],
|
||||
'ix_releases_nzb_creation_global_queue',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private function restoreReleasesForMariaDb(): void
|
||||
{
|
||||
$specifications = ['DROP PRIMARY KEY', 'ADD PRIMARY KEY (`id`, `categories_id`)'];
|
||||
foreach ([
|
||||
'ux_releases_guid',
|
||||
'ix_releases_predb_id',
|
||||
'ix_releases_size',
|
||||
'ix_releases_add_pp_claim_queue',
|
||||
'ix_releases_nzb_creation_group_queue',
|
||||
'ix_releases_nzb_creation_global_queue',
|
||||
] as $index) {
|
||||
if ($this->indexExists($index)) {
|
||||
$specifications[] = 'DROP INDEX `'.$index.'`';
|
||||
}
|
||||
}
|
||||
|
||||
$specifications = [
|
||||
...$specifications,
|
||||
'MODIFY `guid` VARCHAR(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL',
|
||||
"MODIFY `leftguid` CHAR(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'The first letter of the release guid'",
|
||||
'ADD COLUMN `updatetime` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP AFTER `adddate`',
|
||||
'ADD COLUMN `gid` VARCHAR(32) NULL AFTER `updatetime`',
|
||||
'ADD COLUMN `source` SMALLINT UNSIGNED NULL',
|
||||
'ADD COLUMN `nzb_password` VARCHAR(255) NULL',
|
||||
'ADD COLUMN `nzb_creation_attempts` SMALLINT UNSIGNED NOT NULL DEFAULT 0 AFTER `nzb_creation_claim_token`',
|
||||
'ADD COLUMN `nzb_creation_last_error` TEXT NULL AFTER `nzb_creation_attempts`',
|
||||
'ADD COLUMN `proc_sorter` TINYINT(1) NOT NULL DEFAULT 0 AFTER `proc_pp`',
|
||||
'ADD COLUMN `audiostatus` TINYINT(1) NOT NULL DEFAULT 0 AFTER `videostatus`',
|
||||
'ADD INDEX `ix_releases_guid` (`guid`)',
|
||||
'ADD INDEX `ix_releases_adddate_only` (`adddate`)',
|
||||
'ADD INDEX `ix_releases_videos_id` (`videos_id`)',
|
||||
'ADD INDEX `ix_releases_movieinfo_id` (`movieinfo_id`)',
|
||||
'ADD INDEX `ix_releases_imdbid` (`imdbid`)',
|
||||
'ADD INDEX `ix_releases_tv_search_covering` (`passwordstatus`, `categories_id`, `postdate` DESC, `videos_id`, `tv_episodes_id`, `groups_id`)',
|
||||
'ADD INDEX `ix_releases_passwordstatus` (`passwordstatus`)',
|
||||
'ADD INDEX `ix_releases_haspreview_passwordstatus` (`haspreview`, `passwordstatus`)',
|
||||
'ADD INDEX `ix_releases_postdate_searchname` (`postdate`, `searchname`)',
|
||||
'ADD INDEX `ix_releases_predb_id_searchname` (`predb_id`, `searchname`)',
|
||||
'ADD INDEX `ix_releases_size_cat` (`size`, `categories_id`, `passwordstatus`)',
|
||||
'ADD INDEX `ix_releases_add_pp_claim_queue` (`passwordstatus`, `haspreview`, `nzbstatus`, `leftguid`, `additional_pp_claimed_at`, `postdate` DESC)',
|
||||
'ADD INDEX `ix_releases_nzb_creation_queue` (`nzbstatus`, `groups_id`, `leftguid`, `nzb_creation_claimed_at`, `postdate` DESC)',
|
||||
];
|
||||
|
||||
DB::statement('ALTER TABLE '.$this->table('releases').' '.implode(', ', $specifications));
|
||||
}
|
||||
|
||||
private function restoreReleasesForPortableDatabase(): void
|
||||
{
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
foreach ([
|
||||
'ux_releases_guid',
|
||||
'ix_releases_predb_id',
|
||||
'ix_releases_size',
|
||||
'ix_releases_add_pp_claim_queue',
|
||||
'ix_releases_nzb_creation_group_queue',
|
||||
'ix_releases_nzb_creation_global_queue',
|
||||
] as $index) {
|
||||
if ($this->indexExists($index)) {
|
||||
$table->dropIndex($index);
|
||||
}
|
||||
}
|
||||
|
||||
$table->timestamp('updatetime')->useCurrent();
|
||||
$table->string('gid', 32)->nullable();
|
||||
$table->unsignedSmallInteger('source')->nullable();
|
||||
$table->string('nzb_password')->nullable();
|
||||
$table->unsignedSmallInteger('nzb_creation_attempts')->default(0);
|
||||
$table->text('nzb_creation_last_error')->nullable();
|
||||
$table->boolean('proc_sorter')->default(false);
|
||||
$table->boolean('audiostatus')->default(false);
|
||||
$table->index('guid', 'ix_releases_guid');
|
||||
});
|
||||
}
|
||||
|
||||
private function restoreSparseDataToReleases(): void
|
||||
{
|
||||
if (Schema::hasTable('release_nzb_passwords')) {
|
||||
DB::table('release_nzb_passwords')->orderBy('releases_id')->chunkById(1000, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('releases')->where('id', $row->releases_id)->update(['nzb_password' => $row->password]);
|
||||
}
|
||||
}, 'releases_id');
|
||||
}
|
||||
|
||||
if (Schema::hasTable('release_nzb_creation_failures')) {
|
||||
DB::table('release_nzb_creation_failures')->orderBy('releases_id')->chunkById(1000, function ($rows): void {
|
||||
foreach ($rows as $row) {
|
||||
DB::table('releases')->where('id', $row->releases_id)->update([
|
||||
'nzb_creation_attempts' => $row->attempts,
|
||||
'nzb_creation_last_error' => $row->last_error,
|
||||
]);
|
||||
}
|
||||
}, 'releases_id');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertReleaseIdentifiersAreSafe(): void
|
||||
{
|
||||
$duplicateIds = DB::query()->fromSub(
|
||||
DB::table('releases')->select('id')->groupBy('id')->havingRaw('COUNT(*) > 1'),
|
||||
'duplicate_release_ids',
|
||||
)->count();
|
||||
|
||||
if ($this->isMariaDbOrMySql()) {
|
||||
$invalidGuids = DB::table('releases')
|
||||
->whereNull('guid')
|
||||
->orWhereRaw("guid NOT REGEXP '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$'")
|
||||
->count();
|
||||
} else {
|
||||
$invalidGuids = DB::table('releases')->pluck('guid')->filter(
|
||||
static fn (mixed $guid): bool => ! is_string($guid)
|
||||
|| preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iD', $guid) !== 1,
|
||||
)->count();
|
||||
}
|
||||
|
||||
$duplicateGuids = DB::query()->fromSub(
|
||||
DB::table('releases')->selectRaw('LOWER(guid) AS normalized_guid')->groupByRaw('LOWER(guid)')->havingRaw('COUNT(*) > 1'),
|
||||
'duplicate_release_guids',
|
||||
)->count();
|
||||
$leftGuidMismatches = DB::table('releases')
|
||||
->whereRaw('LOWER(leftguid) <> LOWER(SUBSTR(guid, 1, 1))')
|
||||
->count();
|
||||
|
||||
$blockers = array_filter([
|
||||
'duplicate release IDs' => $duplicateIds,
|
||||
'invalid release GUIDs' => $invalidGuids,
|
||||
'case-insensitive duplicate release GUIDs' => $duplicateGuids,
|
||||
'mismatched release leftguid values' => $leftGuidMismatches,
|
||||
]);
|
||||
|
||||
if ($blockers !== []) {
|
||||
throw new RuntimeException('Releases optimization preflight failed: '.json_encode($blockers, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
}
|
||||
|
||||
private function indexExists(string $name): bool
|
||||
{
|
||||
foreach (Schema::getIndexes('releases') as $index) {
|
||||
if (($index['name'] ?? null) === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function isMariaDbOrMySql(): bool
|
||||
{
|
||||
return in_array(DB::getDriverName(), ['mariadb', 'mysql'], true);
|
||||
}
|
||||
|
||||
private function table(string $name): string
|
||||
{
|
||||
return '`'.DB::getTablePrefix().$name.'`';
|
||||
}
|
||||
};
|
||||
+1153
-284
File diff suppressed because it is too large
Load Diff
+1153
-284
File diff suppressed because it is too large
Load Diff
@@ -69,6 +69,7 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
parent::setUp();
|
||||
|
||||
$this->tempNzbPath = sys_get_temp_dir().'/nntmux-nzb-reliability-'.uniqid('', true);
|
||||
mkdir($this->tempNzbPath, 0777, true);
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
|
||||
config(['nntmux_settings.path_to_nzbs' => $this->tempNzbPath]);
|
||||
DB::purge();
|
||||
@@ -140,8 +141,8 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
|
||||
$this->assertSame(0, $service->createNZBs(null));
|
||||
$this->assertSame(1, DB::table('releases')->count());
|
||||
$this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('nzb_creation_attempts'));
|
||||
$this->assertSame('Temporary filesystem failure.', DB::table('releases')->where('id', 1)->value('nzb_creation_last_error'));
|
||||
$this->assertSame(1, (int) DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('attempts'));
|
||||
$this->assertSame('Temporary filesystem failure.', DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('last_error'));
|
||||
$this->assertNull(DB::table('releases')->where('id', 1)->value('nzb_creation_claimed_at'));
|
||||
$this->assertSame(1, DB::table('collections')->count());
|
||||
$this->assertSame('NZB creation failed; release will be retried', $nzbCreationLogger->warnings[0]['message']);
|
||||
@@ -174,6 +175,7 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
$this->assertSame(0, DB::table('collections')->count());
|
||||
$this->assertSame(0, DB::table('binaries')->count());
|
||||
$this->assertSame(0, DB::table('parts')->count());
|
||||
$this->assertSame(0, DB::table('release_nzb_creation_failures')->count());
|
||||
$this->assertSame('Deleting release after NZB creation failure', $nzbCreationLogger->warnings[0]['message']);
|
||||
$this->assertSame(1, $nzbCreationLogger->warnings[0]['context']['release_id']);
|
||||
$this->assertSame(str_repeat('a', 36), $nzbCreationLogger->warnings[0]['context']['guid']);
|
||||
@@ -203,6 +205,13 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
public function test_writer_streams_multiple_keyset_pages_in_segment_order(): void
|
||||
{
|
||||
$this->insertRelease(1, 'h');
|
||||
DB::table('release_nzb_creation_failures')->insert([
|
||||
'releases_id' => 1,
|
||||
'attempts' => 1,
|
||||
'last_error' => 'Earlier failure.',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
$this->insertWritableCbp(200, 2000, 1);
|
||||
DB::table('parts')->where('binaries_id', 2000)->delete();
|
||||
DB::table('parts')->insert([
|
||||
@@ -222,6 +231,7 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
$this->assertLessThan(strpos($xml, 'part02@example.test'), strpos($xml, 'part01@example.test'));
|
||||
$this->assertLessThan(strpos($xml, 'part03@example.test'), strpos($xml, 'part02@example.test'));
|
||||
$this->assertStringContainsString('<group>alt.test</group>', $xml);
|
||||
$this->assertSame(0, DB::table('release_nzb_creation_failures')->count());
|
||||
}
|
||||
|
||||
public function test_stale_temporary_nzb_cleanup_deletes_only_old_temp_files(): void
|
||||
@@ -277,9 +287,17 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
'nzbstatus' => NzbService::NZB_NONE,
|
||||
'nzb_creation_claimed_at' => $claimedAt?->format('Y-m-d H:i:s'),
|
||||
'nzb_creation_claim_token' => $claimedAt === null ? null : 'claimed',
|
||||
'nzb_creation_attempts' => $attempts,
|
||||
'nzb_creation_last_error' => null,
|
||||
]);
|
||||
|
||||
if ($attempts > 0) {
|
||||
DB::table('release_nzb_creation_failures')->insert([
|
||||
'releases_id' => $id,
|
||||
'attempts' => $attempts,
|
||||
'last_error' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function insertCbp(int $collectionId, int $binaryId, int $releaseId): void
|
||||
@@ -337,7 +355,7 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
foreach (['parts', 'binaries', 'collections', 'releases', 'categories', 'root_categories', 'usenet_groups', 'settings'] as $table) {
|
||||
foreach (['parts', 'binaries', 'collections', 'release_nzb_creation_failures', 'releases', 'categories', 'root_categories', 'usenet_groups', 'settings'] as $table) {
|
||||
DB::statement("DROP TABLE IF EXISTS {$table}");
|
||||
}
|
||||
|
||||
@@ -355,9 +373,15 @@ class NzbCreationReliabilityTest extends TestCase
|
||||
postdate DATETIME NULL,
|
||||
nzbstatus INTEGER,
|
||||
nzb_creation_claimed_at DATETIME NULL,
|
||||
nzb_creation_claim_token VARCHAR(64) NULL,
|
||||
nzb_creation_attempts INTEGER DEFAULT 0,
|
||||
nzb_creation_last_error TEXT NULL
|
||||
nzb_creation_claim_token VARCHAR(64) NULL
|
||||
)');
|
||||
DB::statement('CREATE TABLE release_nzb_creation_failures (
|
||||
releases_id INTEGER PRIMARY KEY,
|
||||
attempts INTEGER DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
created_at DATETIME NULL,
|
||||
updated_at DATETIME NULL,
|
||||
FOREIGN KEY (releases_id) REFERENCES releases(id) ON DELETE CASCADE
|
||||
)');
|
||||
DB::statement('CREATE TABLE usenet_groups (id INTEGER PRIMARY KEY, name VARCHAR(255))');
|
||||
DB::statement('CREATE TABLE collections (
|
||||
|
||||
@@ -352,7 +352,7 @@ class PostProcessRunnerAdditionalThreadsTest extends TestCase
|
||||
$this->assertContains('oversized-capacity', $warningCodes);
|
||||
}
|
||||
|
||||
public function test_additional_diagnostics_recognizes_the_covering_claim_index(): void
|
||||
public function test_additional_diagnostics_recognizes_the_claim_queue_index(): void
|
||||
{
|
||||
Schema::table('releases', function (Blueprint $table): void {
|
||||
$table->index([
|
||||
@@ -360,8 +360,9 @@ class PostProcessRunnerAdditionalThreadsTest extends TestCase
|
||||
'haspreview',
|
||||
'nzbstatus',
|
||||
'leftguid',
|
||||
'additional_pp_claimed_at',
|
||||
'postdate',
|
||||
'id',
|
||||
'additional_pp_claimed_at',
|
||||
], 'ix_releases_add_pp_claim_queue');
|
||||
});
|
||||
|
||||
@@ -370,7 +371,7 @@ class PostProcessRunnerAdditionalThreadsTest extends TestCase
|
||||
|
||||
$this->assertSame(0, $status);
|
||||
$this->assertFalse($report['indexes']['missing']);
|
||||
$this->assertSame('ix_releases_add_pp_claim_queue', $report['indexes']['covering_index']);
|
||||
$this->assertSame('ix_releases_add_pp_claim_queue', $report['indexes']['claim_queue_index']);
|
||||
$this->assertNotContains('missing-index', array_column($report['warnings'], 'code'));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\ReleaseComment;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PDO;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ReleaseCommentLifecycleTest extends TestCase
|
||||
{
|
||||
private string $databasePath = '';
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
$this->databasePath = sys_get_temp_dir().'/nntmux-release-comments.sqlite';
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
$pdo = new PDO('sqlite:'.$this->databasePath);
|
||||
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
|
||||
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
|
||||
putenv('APP_ENV=testing');
|
||||
putenv('DB_CONNECTION=sqlite');
|
||||
putenv('DB_DATABASE='.$this->databasePath);
|
||||
$_ENV['APP_ENV'] = $_SERVER['APP_ENV'] = 'testing';
|
||||
$_ENV['DB_CONNECTION'] = $_SERVER['DB_CONNECTION'] = 'sqlite';
|
||||
$_ENV['DB_DATABASE'] = $_SERVER['DB_DATABASE'] = $this->databasePath;
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
DB::statement('CREATE TABLE users (id INTEGER PRIMARY KEY, username VARCHAR(255), deleted_at DATETIME NULL)');
|
||||
DB::statement('CREATE TABLE releases (id INTEGER PRIMARY KEY, comments INTEGER DEFAULT 0)');
|
||||
DB::statement('CREATE TABLE release_comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, releases_id INTEGER NOT NULL, text VARCHAR(2000),
|
||||
isvisible INTEGER DEFAULT 1, username VARCHAR(255), users_id INTEGER,
|
||||
created_at DATETIME, updated_at DATETIME, host VARCHAR(45)
|
||||
)');
|
||||
DB::table('users')->insert(['id' => 7, 'username' => 'commenter']);
|
||||
DB::table('releases')->insert(['id' => 10, 'comments' => 99]);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
DB::disconnect();
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_add_delete_and_visible_only_recount_use_release_id(): void
|
||||
{
|
||||
$commentId = ReleaseComment::addComment(10, 'Visible comment', 7, '127.0.0.1');
|
||||
DB::table('release_comments')->insert([
|
||||
'releases_id' => 10,
|
||||
'text' => 'Hidden comment',
|
||||
'isvisible' => 0,
|
||||
'username' => 'commenter',
|
||||
'users_id' => 7,
|
||||
]);
|
||||
ReleaseComment::updateReleaseCommentCount(10);
|
||||
|
||||
$this->assertSame(1, (int) DB::table('releases')->where('id', 10)->value('comments'));
|
||||
$this->assertDatabaseHas('release_comments', [
|
||||
'id' => $commentId,
|
||||
'releases_id' => 10,
|
||||
'text' => 'Visible comment',
|
||||
]);
|
||||
|
||||
ReleaseComment::deleteComment($commentId);
|
||||
|
||||
$this->assertSame(0, (int) DB::table('releases')->where('id', 10)->value('comments'));
|
||||
$this->assertDatabaseMissing('release_comments', ['id' => $commentId]);
|
||||
}
|
||||
|
||||
public function test_details_controller_submission_no_longer_reads_or_passes_gid(): void
|
||||
{
|
||||
$controller = file_get_contents(app_path('Http/Controllers/DetailsController.php'));
|
||||
|
||||
$this->assertIsString($controller);
|
||||
$this->assertStringContainsString('ReleaseComment::addComment((int) $data[\'id\']', $controller);
|
||||
$this->assertStringNotContainsString("\$data['gid']", $controller);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PDO;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ReleasesOptimizePreflightTest extends TestCase
|
||||
{
|
||||
private string $databasePath = '';
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
$this->databasePath = sys_get_temp_dir().'/nntmux-releases-preflight.sqlite';
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
$pdo = new PDO('sqlite:'.$this->databasePath);
|
||||
$pdo->exec('CREATE TABLE settings (name VARCHAR PRIMARY KEY, value TEXT NULL)');
|
||||
$pdo->exec("INSERT INTO settings VALUES ('categorizeforeign', '0'), ('catwebdl', '0')");
|
||||
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||
$this->setEnvironmentValue('DB_CONNECTION', 'sqlite');
|
||||
$this->setEnvironmentValue('DB_DATABASE', $this->databasePath);
|
||||
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config(['database.default' => 'sqlite', 'database.connections.sqlite.database' => $this->databasePath]);
|
||||
DB::purge();
|
||||
DB::reconnect();
|
||||
$this->createSchema();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
DB::disconnect();
|
||||
if (file_exists($this->databasePath)) {
|
||||
unlink($this->databasePath);
|
||||
}
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_valid_data_reports_storage_and_every_migration_data_category(): void
|
||||
{
|
||||
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0', [
|
||||
'nzb_password' => 'secret',
|
||||
'nzb_creation_attempts' => 1,
|
||||
'nzb_creation_last_error' => 'temporary',
|
||||
'source' => 2,
|
||||
]);
|
||||
DB::table('release_comments')->insert([
|
||||
['id' => 1, 'releases_id' => 1, 'isvisible' => 1, 'gid' => 'legacy'],
|
||||
['id' => 2, 'releases_id' => 1, 'isvisible' => 0, 'gid' => null],
|
||||
]);
|
||||
|
||||
$status = Artisan::call('releases:optimize-preflight', ['--json' => true]);
|
||||
$report = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertSame(0, $status);
|
||||
$this->assertTrue($report['ok']);
|
||||
$this->assertSame(1, $report['migration_data']['nzb_password_rows']);
|
||||
$this->assertSame(1, $report['migration_data']['nzb_creation_failure_rows']);
|
||||
$this->assertSame(1, $report['migration_data']['visible_comment_rows']);
|
||||
$this->assertSame(1, $report['migration_data']['release_comment_counter_mismatches']);
|
||||
$this->assertSame(1, $report['discarded_data']['releases.source']);
|
||||
$this->assertSame(1, $report['discarded_data']['release_comments.gid']);
|
||||
$this->assertNull($report['storage']['total_bytes']);
|
||||
}
|
||||
|
||||
public function test_every_identifier_blocker_fails_preflight(): void
|
||||
{
|
||||
$scenarios = [
|
||||
'duplicate_ids' => function (): void {
|
||||
$this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', '0');
|
||||
$this->insertRelease(1, '11234567-89ab-cdef-0123-456789abcdef', '1');
|
||||
},
|
||||
'invalid_guids' => fn () => $this->insertRelease(1, 'not-a-guid', 'n'),
|
||||
'case_insensitive_duplicate_guids' => function (): void {
|
||||
$this->insertRelease(1, 'abcdef01-2345-6789-abcd-ef0123456789', 'a');
|
||||
$this->insertRelease(2, 'ABCDEF01-2345-6789-ABCD-EF0123456789', 'A');
|
||||
},
|
||||
'leftguid_mismatches' => fn () => $this->insertRelease(1, '01234567-89ab-cdef-0123-456789abcdef', 'f'),
|
||||
];
|
||||
|
||||
foreach ($scenarios as $expectedBlocker => $seed) {
|
||||
DB::table('release_comments')->delete();
|
||||
DB::table('releases')->delete();
|
||||
$seed();
|
||||
|
||||
$status = Artisan::call('releases:optimize-preflight', ['--json' => true]);
|
||||
$report = json_decode(Artisan::output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertSame(1, $status, $expectedBlocker);
|
||||
$this->assertContains($expectedBlocker, array_column($report['blockers'], 'code'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
private function insertRelease(int $id, string $guid, string $leftguid, array $overrides = []): void
|
||||
{
|
||||
DB::table('releases')->insert([
|
||||
'id' => $id,
|
||||
'guid' => $guid,
|
||||
'leftguid' => $leftguid,
|
||||
'comments' => 0,
|
||||
'nzb_password' => null,
|
||||
'nzb_creation_attempts' => 0,
|
||||
'nzb_creation_last_error' => null,
|
||||
'updatetime' => now(),
|
||||
'gid' => null,
|
||||
'source' => null,
|
||||
'proc_sorter' => 0,
|
||||
'audiostatus' => 0,
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS release_comments');
|
||||
DB::statement('DROP TABLE IF EXISTS releases');
|
||||
DB::statement('CREATE TABLE releases (
|
||||
id INTEGER NOT NULL,
|
||||
guid VARCHAR(40), leftguid CHAR(1), comments INTEGER DEFAULT 0,
|
||||
nzb_password VARCHAR(255), nzb_creation_attempts INTEGER DEFAULT 0,
|
||||
nzb_creation_last_error TEXT, updatetime DATETIME, gid VARCHAR(32),
|
||||
source INTEGER, proc_sorter INTEGER DEFAULT 0, audiostatus INTEGER DEFAULT 0
|
||||
)');
|
||||
DB::statement('CREATE TABLE release_comments (
|
||||
id INTEGER PRIMARY KEY, releases_id INTEGER NOT NULL, isvisible INTEGER DEFAULT 1,
|
||||
gid VARCHAR(32), cid VARCHAR(32), issynced INTEGER DEFAULT 0, shared INTEGER DEFAULT 0,
|
||||
shareid VARCHAR(40) DEFAULT \'\', siteid VARCHAR(40) DEFAULT \'\', sourceid INTEGER
|
||||
)');
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
putenv("{$key}={$value}");
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -276,7 +276,7 @@ final class AdditionalCandidateQueryMariaDbTest extends TestCase
|
||||
additional_pp_claimed_at TIMESTAMP NULL,
|
||||
additional_pp_claim_token VARCHAR(64) NULL,
|
||||
KEY ix_releases_haspreview_passwordstatus (haspreview, passwordstatus),
|
||||
KEY ix_releases_add_pp_claim_queue (passwordstatus, haspreview, nzbstatus, leftguid, additional_pp_claimed_at, postdate DESC)
|
||||
KEY ix_releases_add_pp_claim_queue (passwordstatus, haspreview, nzbstatus, leftguid, postdate DESC, id, additional_pp_claimed_at)
|
||||
) ENGINE=InnoDB
|
||||
SQL);
|
||||
DB::table('settings')->insert([
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use App\Services\Nzb\NzbCreationCandidateQuery;
|
||||
use Dotenv\Dotenv;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class NzbCreationClaimPlanMariaDbTest extends TestCase
|
||||
{
|
||||
private string $tablePrefix;
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
Dotenv::createMutable(dirname(__DIR__, 2))->safeLoad();
|
||||
$this->tablePrefix = 'nzb_claim_'.getmypid().'_'.bin2hex(random_bytes(4)).'_';
|
||||
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||
$this->setEnvironmentValue('DB_URL', null);
|
||||
$this->setEnvironmentValue('DB_CONNECTION', 'mariadb');
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$app->make('config')->set('database.connections.mariadb.prefix', $this->tablePrefix);
|
||||
$app->make('db')->purge('mariadb');
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
if (DB::getDriverName() !== 'mariadb') {
|
||||
$this->markTestSkipped('MariaDB integration test.');
|
||||
}
|
||||
$this->createSchema();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->tablePrefix) && preg_match('/^nzb_claim_\d+_[a-f0-9]{8}_$/', $this->tablePrefix) === 1) {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
foreach (['release_nzb_creation_failures', 'releases', 'categories', 'root_categories', 'settings'] as $table) {
|
||||
DB::statement('DROP TABLE IF EXISTS `'.$this->table($table).'`');
|
||||
}
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
}
|
||||
DB::disconnect();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function grouped_and_global_claim_plans_use_their_indexes_without_filesort(): void
|
||||
{
|
||||
$releases = $this->table('releases');
|
||||
DB::statement(<<<SQL
|
||||
INSERT INTO `{$releases}` (id, guid, groups_id, categories_id, nzbstatus, postdate, nzb_creation_claimed_at)
|
||||
SELECT seq, CONCAT('guid-', seq), MOD(seq, 20) + 1, 1,
|
||||
IF(MOD(seq, 25) = 0, 1, 0), DATE_SUB(NOW(), INTERVAL seq SECOND),
|
||||
IF(MOD(seq, 997) = 0, NOW(), NULL)
|
||||
FROM seq_1_to_50000
|
||||
SQL);
|
||||
DB::statement("ANALYZE TABLE `{$releases}`");
|
||||
|
||||
$groupPlan = $this->explain(<<<SQL
|
||||
SELECT id FROM `{$releases}`
|
||||
WHERE nzbstatus = 0 AND groups_id = 7
|
||||
AND (nzb_creation_claimed_at IS NULL OR nzb_creation_claimed_at < DATE_SUB(NOW(), INTERVAL 300 SECOND))
|
||||
ORDER BY postdate DESC, id ASC LIMIT 100 FOR UPDATE
|
||||
SQL);
|
||||
$globalPlan = $this->explain(<<<SQL
|
||||
SELECT id FROM `{$releases}`
|
||||
WHERE nzbstatus = 0
|
||||
AND (nzb_creation_claimed_at IS NULL OR nzb_creation_claimed_at < DATE_SUB(NOW(), INTERVAL 300 SECOND))
|
||||
ORDER BY postdate DESC, id ASC LIMIT 100 FOR UPDATE
|
||||
SQL);
|
||||
|
||||
$this->assertStringContainsString('ix_releases_nzb_creation_group_queue', $groupPlan);
|
||||
$this->assertStringContainsString('ix_releases_nzb_creation_global_queue', $globalPlan);
|
||||
$this->assertStringNotContainsString('filesort', strtolower($groupPlan));
|
||||
$this->assertStringNotContainsString('filesort', strtolower($globalPlan));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function workers_cannot_claim_the_same_nzb_rows(): void
|
||||
{
|
||||
DB::table('releases')->insert(array_map(static fn (int $id): array => [
|
||||
'id' => $id,
|
||||
'guid' => 'claim-'.$id,
|
||||
'groups_id' => 1,
|
||||
'categories_id' => 1,
|
||||
'nzbstatus' => 0,
|
||||
'postdate' => now()->subSeconds($id),
|
||||
], range(1, 10)));
|
||||
|
||||
$first = NzbCreationCandidateQuery::claimBatch(1, 5, 'worker-one', ['id', 'categories_id']);
|
||||
$second = NzbCreationCandidateQuery::claimBatch(1, 5, 'worker-two', ['id', 'categories_id']);
|
||||
|
||||
$this->assertSame([1, 2, 3, 4, 5], $first->pluck('id')->all());
|
||||
$this->assertSame([6, 7, 8, 9, 10], $second->pluck('id')->all());
|
||||
$this->assertSame([], array_intersect($first->pluck('id')->all(), $second->pluck('id')->all()));
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
DB::statement('CREATE TABLE `'.$this->table('settings').'` (name VARCHAR(255) PRIMARY KEY, value TEXT) ENGINE=InnoDB');
|
||||
DB::statement('CREATE TABLE `'.$this->table('root_categories').'` (id INT PRIMARY KEY, title VARCHAR(255)) ENGINE=InnoDB');
|
||||
DB::statement('CREATE TABLE `'.$this->table('categories').'` (id INT PRIMARY KEY, title VARCHAR(255), root_categories_id INT) ENGINE=InnoDB');
|
||||
DB::statement(<<<SQL
|
||||
CREATE TABLE `{$this->table('releases')}` (
|
||||
id INT UNSIGNED PRIMARY KEY, guid VARCHAR(64) NOT NULL, groups_id INT UNSIGNED NOT NULL,
|
||||
categories_id INT NOT NULL, nzbstatus TINYINT NOT NULL DEFAULT 0, postdate DATETIME,
|
||||
nzb_creation_claimed_at TIMESTAMP NULL, nzb_creation_claim_token VARCHAR(64) NULL,
|
||||
KEY ix_releases_nzb_creation_group_queue (nzbstatus, groups_id, postdate DESC, id, nzb_creation_claimed_at),
|
||||
KEY ix_releases_nzb_creation_global_queue (nzbstatus, postdate DESC, id, nzb_creation_claimed_at)
|
||||
) ENGINE=InnoDB
|
||||
SQL);
|
||||
DB::statement('CREATE TABLE `'.$this->table('release_nzb_creation_failures').'` (
|
||||
releases_id INT UNSIGNED PRIMARY KEY, attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL,
|
||||
FOREIGN KEY (releases_id) REFERENCES `'.$this->table('releases').'` (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB');
|
||||
DB::table('settings')->insert([
|
||||
['name' => 'categorizeforeign', 'value' => '0'],
|
||||
['name' => 'catwebdl', 'value' => '0'],
|
||||
['name' => 'releaseprocessingtimeout', 'value' => '120'],
|
||||
]);
|
||||
DB::table('root_categories')->insert(['id' => 1, 'title' => 'Other']);
|
||||
DB::table('categories')->insert(['id' => 1, 'title' => 'Misc', 'root_categories_id' => 1]);
|
||||
}
|
||||
|
||||
private function explain(string $sql): string
|
||||
{
|
||||
$row = DB::selectOne('EXPLAIN FORMAT=JSON '.$sql);
|
||||
|
||||
return (string) reset($row);
|
||||
}
|
||||
|
||||
private function table(string $name): string
|
||||
{
|
||||
return $this->tablePrefix.$name;
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
|
||||
return;
|
||||
}
|
||||
putenv("{$key}={$value}");
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Integration;
|
||||
|
||||
use Dotenv\Dotenv;
|
||||
use Illuminate\Contracts\Console\Kernel;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class ReleaseSchemaOptimizationMigrationMariaDbTest extends TestCase
|
||||
{
|
||||
private string $tablePrefix;
|
||||
|
||||
public function createApplication()
|
||||
{
|
||||
Dotenv::createMutable(dirname(__DIR__, 2))->safeLoad();
|
||||
$this->tablePrefix = 'release_opt_'.getmypid().'_'.bin2hex(random_bytes(4)).'_';
|
||||
$this->setEnvironmentValue('APP_ENV', 'testing');
|
||||
$this->setEnvironmentValue('DB_URL', null);
|
||||
$this->setEnvironmentValue('DB_CONNECTION', 'mariadb');
|
||||
$app = require __DIR__.'/../../bootstrap/app.php';
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
$app->make('config')->set('database.connections.mariadb.prefix', $this->tablePrefix);
|
||||
$app->make('db')->purge('mariadb');
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
if (DB::getDriverName() !== 'mariadb') {
|
||||
$this->markTestSkipped('MariaDB integration test.');
|
||||
}
|
||||
$this->createSchema();
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (isset($this->tablePrefix) && preg_match('/^release_opt_\d+_[a-f0-9]{8}_$/', $this->tablePrefix) === 1) {
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
foreach (['release_files', 'release_comments', 'release_nzb_creation_failures', 'release_nzb_passwords', 'releases'] as $table) {
|
||||
DB::statement('DROP TABLE IF EXISTS `'.$this->table($table).'`');
|
||||
}
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
}
|
||||
DB::disconnect();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function migration_up_and_down_preserve_sparse_state_and_incoming_foreign_keys(): void
|
||||
{
|
||||
$this->seedLegacyData();
|
||||
$migration = $this->migration();
|
||||
|
||||
$migration->up();
|
||||
|
||||
$this->assertTrue(Schema::hasTable('release_nzb_passwords'));
|
||||
$this->assertTrue(Schema::hasTable('release_nzb_creation_failures'));
|
||||
$this->assertSame('secret-one', DB::table('release_nzb_passwords')->where('releases_id', 1)->value('password'));
|
||||
$this->assertSame(2, (int) DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('attempts'));
|
||||
$this->assertSame('temporary', DB::table('release_nzb_creation_failures')->where('releases_id', 1)->value('last_error'));
|
||||
$this->assertSame(['id'], $this->primaryKeyColumns());
|
||||
$this->assertSame('ascii_general_ci', $this->columnCollation('guid'));
|
||||
$this->assertSame('char(36)', strtolower($this->columnType('guid')));
|
||||
$this->assertSame('ascii_general_ci', $this->columnCollation('leftguid'));
|
||||
$this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('comments'));
|
||||
$this->assertFalse(Schema::hasColumn('releases', 'nzb_password'));
|
||||
$this->assertFalse(Schema::hasColumn('releases', 'proc_sorter'));
|
||||
$this->assertFalse(Schema::hasColumn('release_comments', 'gid'));
|
||||
$this->assertSame(
|
||||
['passwordstatus', 'haspreview', 'nzbstatus', 'leftguid', 'postdate', 'id', 'additional_pp_claimed_at'],
|
||||
$this->indexColumns('ix_releases_add_pp_claim_queue'),
|
||||
);
|
||||
$this->assertSame(
|
||||
['nzbstatus', 'groups_id', 'postdate', 'id', 'nzb_creation_claimed_at'],
|
||||
$this->indexColumns('ix_releases_nzb_creation_group_queue'),
|
||||
);
|
||||
$this->assertSame(
|
||||
['nzbstatus', 'postdate', 'id', 'nzb_creation_claimed_at'],
|
||||
$this->indexColumns('ix_releases_nzb_creation_global_queue'),
|
||||
);
|
||||
|
||||
try {
|
||||
DB::table('releases')->insert($this->releaseRow(3, strtoupper('01234567-89ab-cdef-0123-456789abcdef')));
|
||||
$this->fail('ASCII case-insensitive GUID uniqueness was not enforced.');
|
||||
} catch (QueryException) {
|
||||
$this->assertSame(2, DB::table('releases')->count());
|
||||
}
|
||||
|
||||
DB::table('releases')->where('id', 2)->delete();
|
||||
$this->assertDatabaseMissing('release_nzb_passwords', ['releases_id' => 2]);
|
||||
DB::table('release_files')->insert(['releases_id' => 1, 'name' => 'kept.nzb']);
|
||||
|
||||
$migration->down();
|
||||
|
||||
$this->assertSame(['id', 'categories_id'], $this->primaryKeyColumns());
|
||||
$this->assertTrue(Schema::hasColumn('releases', 'nzb_password'));
|
||||
$this->assertTrue(Schema::hasColumn('releases', 'nzb_creation_attempts'));
|
||||
$this->assertTrue(Schema::hasColumn('release_comments', 'gid'));
|
||||
$this->assertSame('secret-one', DB::table('releases')->where('id', 1)->value('nzb_password'));
|
||||
$this->assertSame(2, (int) DB::table('releases')->where('id', 1)->value('nzb_creation_attempts'));
|
||||
$this->assertSame('temporary', DB::table('releases')->where('id', 1)->value('nzb_creation_last_error'));
|
||||
$this->assertFalse(Schema::hasTable('release_nzb_passwords'));
|
||||
$this->assertFalse(Schema::hasTable('release_nzb_creation_failures'));
|
||||
$this->assertDatabaseHas('release_files', ['releases_id' => 1, 'name' => 'kept.nzb']);
|
||||
}
|
||||
|
||||
private function createSchema(): void
|
||||
{
|
||||
$releases = $this->table('releases');
|
||||
$comments = $this->table('release_comments');
|
||||
$files = $this->table('release_files');
|
||||
DB::statement(<<<SQL
|
||||
CREATE TABLE `{$releases}` (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT, categories_id INT NOT NULL DEFAULT 10,
|
||||
name VARCHAR(255) NOT NULL DEFAULT '', searchname VARCHAR(255) NOT NULL DEFAULT '',
|
||||
groups_id INT UNSIGNED NOT NULL DEFAULT 0, size BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
postdate DATETIME NULL, adddate DATETIME NULL, updatetime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
gid VARCHAR(32) NULL, guid VARCHAR(40) NOT NULL, leftguid CHAR(1) NOT NULL,
|
||||
videos_id INT UNSIGNED NOT NULL DEFAULT 0, tv_episodes_id INT NOT NULL DEFAULT 0,
|
||||
imdbid VARCHAR(100) NULL, movieinfo_id INT NULL, predb_id INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
comments INT NOT NULL DEFAULT 0, passwordstatus SMALLINT NOT NULL DEFAULT -1,
|
||||
haspreview TINYINT NOT NULL DEFAULT 0, nzbstatus TINYINT NOT NULL DEFAULT 0,
|
||||
source SMALLINT UNSIGNED NULL, nzb_password VARCHAR(255) NULL,
|
||||
proc_pp TINYINT NOT NULL DEFAULT 0, proc_sorter TINYINT NOT NULL DEFAULT 0,
|
||||
videostatus TINYINT NOT NULL DEFAULT 0, audiostatus TINYINT NOT NULL DEFAULT 0,
|
||||
additional_pp_claimed_at TIMESTAMP NULL, additional_pp_claim_token VARCHAR(64) NULL,
|
||||
nzb_creation_claimed_at TIMESTAMP NULL, nzb_creation_claim_token VARCHAR(64) NULL,
|
||||
nzb_creation_attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0, nzb_creation_last_error TEXT NULL,
|
||||
PRIMARY KEY (id, categories_id),
|
||||
KEY ix_releases_guid (guid), KEY ix_releases_adddate_only (adddate),
|
||||
KEY ix_releases_videos_id (videos_id), KEY ix_releases_movieinfo_id (movieinfo_id),
|
||||
KEY ix_releases_imdbid (imdbid),
|
||||
KEY ix_releases_tv_search_covering (passwordstatus, categories_id, postdate DESC, videos_id, tv_episodes_id, groups_id),
|
||||
KEY ix_releases_passwordstatus (passwordstatus),
|
||||
KEY ix_releases_haspreview_passwordstatus (haspreview, passwordstatus),
|
||||
KEY ix_releases_postdate_searchname (postdate, searchname),
|
||||
KEY ix_releases_predb_id_searchname (predb_id, searchname),
|
||||
KEY ix_releases_size_cat (size, categories_id, passwordstatus),
|
||||
KEY ix_releases_add_pp_claim_queue (passwordstatus, haspreview, nzbstatus, leftguid, additional_pp_claimed_at, postdate DESC),
|
||||
KEY ix_releases_nzb_creation_queue (nzbstatus, groups_id, leftguid, nzb_creation_claimed_at, postdate DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 COLLATE=utf8mb3_unicode_ci
|
||||
SQL);
|
||||
DB::statement(<<<SQL
|
||||
CREATE TABLE `{$comments}` (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT, releases_id INT UNSIGNED NOT NULL,
|
||||
text VARCHAR(2000) NOT NULL DEFAULT '', isvisible TINYINT NOT NULL DEFAULT 1,
|
||||
issynced TINYINT NOT NULL DEFAULT 0, gid VARCHAR(32), cid VARCHAR(32),
|
||||
shared TINYINT NOT NULL DEFAULT 0, shareid VARCHAR(40) NOT NULL DEFAULT '',
|
||||
siteid VARCHAR(40) NOT NULL DEFAULT '', sourceid BIGINT UNSIGNED NULL,
|
||||
PRIMARY KEY (id), CONSTRAINT fk_opt_comments FOREIGN KEY (releases_id)
|
||||
REFERENCES `{$releases}` (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB
|
||||
SQL);
|
||||
DB::statement(<<<SQL
|
||||
CREATE TABLE `{$files}` (
|
||||
releases_id INT UNSIGNED NOT NULL, name VARCHAR(255) NOT NULL,
|
||||
PRIMARY KEY (releases_id, name), CONSTRAINT fk_opt_files FOREIGN KEY (releases_id)
|
||||
REFERENCES `{$releases}` (id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB
|
||||
SQL);
|
||||
}
|
||||
|
||||
private function seedLegacyData(): void
|
||||
{
|
||||
DB::table('releases')->insert([
|
||||
$this->releaseRow(1, '01234567-89ab-cdef-0123-456789abcdef', [
|
||||
'nzb_password' => 'secret-one',
|
||||
'nzb_creation_attempts' => 2,
|
||||
'nzb_creation_last_error' => 'temporary',
|
||||
'comments' => 99,
|
||||
]),
|
||||
$this->releaseRow(2, 'abcdef01-2345-6789-abcd-ef0123456789', ['nzb_password' => 'secret-two']),
|
||||
]);
|
||||
DB::table('release_comments')->insert([
|
||||
['releases_id' => 1, 'text' => 'visible', 'isvisible' => 1],
|
||||
['releases_id' => 1, 'text' => 'hidden', 'isvisible' => 0],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $overrides */
|
||||
private function releaseRow(int $id, string $guid, array $overrides = []): array
|
||||
{
|
||||
return [
|
||||
'id' => $id,
|
||||
'categories_id' => 10,
|
||||
'name' => 'Release '.$id,
|
||||
'searchname' => 'Release '.$id,
|
||||
'groups_id' => 1,
|
||||
'size' => 1000,
|
||||
'postdate' => '2026-08-13 00:00:00',
|
||||
'adddate' => '2026-08-13 00:00:00',
|
||||
'guid' => $guid,
|
||||
'leftguid' => strtolower($guid[0]),
|
||||
'comments' => 0,
|
||||
'nzb_password' => null,
|
||||
'nzb_creation_attempts' => 0,
|
||||
'nzb_creation_last_error' => null,
|
||||
...$overrides,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function primaryKeyColumns(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (object $row): string => (string) $row->COLUMN_NAME,
|
||||
DB::select(
|
||||
'SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = \'PRIMARY\'
|
||||
ORDER BY ORDINAL_POSITION',
|
||||
[$this->table('releases')],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function indexColumns(string $index): array
|
||||
{
|
||||
$rows = DB::select('SHOW INDEX FROM `'.$this->table('releases').'` WHERE Key_name = ?', [$index]);
|
||||
usort($rows, static fn (object $left, object $right): int => (int) $left->Seq_in_index <=> (int) $right->Seq_in_index);
|
||||
|
||||
return array_map(
|
||||
static fn (object $row): string => (string) $row->Column_name,
|
||||
$rows,
|
||||
);
|
||||
}
|
||||
|
||||
private function columnCollation(string $column): string
|
||||
{
|
||||
return (string) DB::selectOne(
|
||||
'SELECT COLLATION_NAME FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||
[$this->table('releases'), $column],
|
||||
)->COLLATION_NAME;
|
||||
}
|
||||
|
||||
private function columnType(string $column): string
|
||||
{
|
||||
return (string) DB::selectOne(
|
||||
'SELECT COLUMN_TYPE FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?',
|
||||
[$this->table('releases'), $column],
|
||||
)->COLUMN_TYPE;
|
||||
}
|
||||
|
||||
private function migration(): Migration
|
||||
{
|
||||
/** @var Migration $migration */
|
||||
$migration = require database_path('migrations/2026_08_13_001652_normalize_and_optimize_releases_table.php');
|
||||
|
||||
return $migration;
|
||||
}
|
||||
|
||||
private function table(string $name): string
|
||||
{
|
||||
return $this->tablePrefix.$name;
|
||||
}
|
||||
|
||||
private function setEnvironmentValue(string $key, ?string $value): void
|
||||
{
|
||||
if ($value === null) {
|
||||
putenv($key);
|
||||
unset($_ENV[$key], $_SERVER[$key]);
|
||||
|
||||
return;
|
||||
}
|
||||
putenv("{$key}={$value}");
|
||||
$_ENV[$key] = $_SERVER[$key] = $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user