diff --git a/app/Services/CollectionCleanupService.php b/app/Services/CollectionCleanupService.php index 3914e22c5..86b80dd02 100644 --- a/app/Services/CollectionCleanupService.php +++ b/app/Services/CollectionCleanupService.php @@ -11,6 +11,8 @@ use Illuminate\Support\Str; class CollectionCleanupService { + private const MAX_SQL_ROWS_PER_STATEMENT = 500; + /** * Maximum number of retries for a lock-related DB error before giving up. */ @@ -50,28 +52,27 @@ class CollectionCleanupService ), true); } - // Batch-delete old collections using a safe id-subselect to avoid read-then-delete races. - // The DELETE here is single-table (no JOIN), so cross-table deadlocks - // are not a concern; the helper still gives us bounded retries with - // jittered backoff and re-throws non-lock errors instead of swallowing them. + // Batch-delete old collections using select-then-delete so we can + // explicitly remove parts/binaries/collections even when FK cascades + // are not present in the runtime schema. $cutoff = now()->subHours(Settings::settingValue('partretentionhours')); $batchDeleted = 0; do { - $affected = $this->retryOnLockError( - fn (): int => DB::affectingStatement( - 'DELETE FROM collections WHERE id IN ( - SELECT id FROM ( - SELECT id FROM collections WHERE dateadded < ? ORDER BY id LIMIT 500 - ) AS x - )', - [$cutoff] - ), - 'Cleanup', - $echoCLI, - ); + $ids = DB::table('collections') + ->where('dateadded', '<', $cutoff) + ->orderBy('id') + ->limit(self::MAX_SQL_ROWS_PER_STATEMENT) + ->pluck('id') + ->all(); + + if ($ids === []) { + break; + } + + $affected = $this->deleteCollectionsAndDescendants($ids, 'Cleanup', $echoCLI); $batchDeleted += $affected; - if ($affected < 500) { + if ($affected < self::MAX_SQL_ROWS_PER_STATEMENT) { break; } // Brief pause to reduce pressure on the lock manager in busy systems. @@ -125,7 +126,7 @@ class CollectionCleanupService if ($echoCLI) { cli()->primary( 'Finished deleting '.$missedDeleted.' collections missed after NZB creation in '.($totalTime).Str::plural(' second', (int) $totalTime). - PHP_EOL.'Removed '.number_format($deletedCount).' parts/binaries/collection rows in '.$totalTime.Str::plural(' second', (int) $totalTime), + PHP_EOL.'Removed '.number_format($deletedCount).' collections (with related binaries/parts) in '.$totalTime.Str::plural(' second', (int) $totalTime), true ); } @@ -146,7 +147,7 @@ class CollectionCleanupService { $deleted = 0; $maxBatches = 20; // hard cap per cycle; bounded backlog drain - $batchSize = 500; + $batchSize = self::MAX_SQL_ROWS_PER_STATEMENT; for ($i = 0; $i < $maxBatches; $i++) { $ids = DB::table('collections as c') @@ -162,11 +163,7 @@ class CollectionCleanupService break; } - $affected = $this->retryOnLockError( - fn (): int => DB::table('collections')->whereIn('id', $ids)->delete(), - 'Orphan cleanup', - $echoCLI, - ); + $affected = $this->deleteCollectionsAndDescendants($ids, 'Orphan cleanup', $echoCLI); $deleted += $affected; if ($affected < $batchSize) { @@ -199,7 +196,7 @@ class CollectionCleanupService { $deleted = 0; $maxBatches = 20; - $batchSize = 500; + $batchSize = self::MAX_SQL_ROWS_PER_STATEMENT; for ($i = 0; $i < $maxBatches; $i++) { $ids = DB::table('collections as c') @@ -214,11 +211,7 @@ class CollectionCleanupService break; } - $affected = $this->retryOnLockError( - fn (): int => DB::table('collections')->whereIn('id', $ids)->delete(), - 'Missed-NZB cleanup', - $echoCLI, - ); + $affected = $this->deleteCollectionsAndDescendants($ids, 'Missed-NZB cleanup', $echoCLI); $deleted += $affected; if ($affected < $batchSize) { @@ -230,6 +223,51 @@ class CollectionCleanupService return $deleted; } + /** + * Explicitly delete parts, binaries, then collections for the given IDs. + * This path does not rely on DB-level cascade constraints. + * + * @param list $collectionIds + */ + public function deleteCollectionsAndDescendants( + array $collectionIds, + string $label = 'CBP cleanup', + bool $echoCLI = false, + ): int { + if ($collectionIds === []) { + return 0; + } + + $deletedCollections = 0; + + foreach (array_chunk($collectionIds, self::MAX_SQL_ROWS_PER_STATEMENT) as $chunk) { + $placeholders = implode(',', array_fill(0, count($chunk), '?')); + $deletedCollections += $this->retryOnLockError( + fn (): int => DB::transaction( + function () use ($chunk, $placeholders): int { + DB::statement( + "DELETE FROM parts WHERE binaries_id IN (SELECT id FROM binaries WHERE collections_id IN ({$placeholders}))", + $chunk + ); + DB::statement( + "DELETE FROM binaries WHERE collections_id IN ({$placeholders})", + $chunk + ); + + return (int) DB::affectingStatement( + "DELETE FROM collections WHERE id IN ({$placeholders})", + $chunk + ); + } + ), + $label, + $echoCLI, + ); + } + + return $deletedCollections; + } + /** * Run a DB write inside a bounded retry loop that only swallows transient * InnoDB lock errors (deadlock 1213, lock wait timeout 1205). Any other diff --git a/app/Services/Nzb/NzbService.php b/app/Services/Nzb/NzbService.php index 3471ae3fa..8689e1031 100644 --- a/app/Services/Nzb/NzbService.php +++ b/app/Services/Nzb/NzbService.php @@ -9,6 +9,7 @@ use App\Models\Collection; use App\Models\Part; use App\Models\Release; use App\Models\Settings; +use App\Services\CollectionCleanupService; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; @@ -57,8 +58,9 @@ class NzbService protected string $siteCommentString; - public function __construct() - { + public function __construct( + private readonly CollectionCleanupService $collectionCleanupService, + ) { try { $nzbSplitLevel = (int) Settings::settingValue('nzbsplitlevel'); } catch (QueryException $e) { @@ -217,9 +219,11 @@ class NzbService // Delete CBP (Collections, Binaries, Parts) for release that has its NZB created. // Use a transaction to ensure cascading deletes complete properly. try { - DB::transaction(function () use ($release) { - Collection::query()->where('releases_id', $release->id)->delete(); - }); + $this->collectionCleanupService->deleteCollectionsAndDescendants( + $collectionIds, + 'NZB cleanup', + false + ); } catch (\Throwable $e) { // Log the error but don't fail the NZB creation since the file was written successfully Log::warning('Failed to delete collections for release '.$release->id.': '.$e->getMessage()); diff --git a/app/Services/ReleaseCreationService.php b/app/Services/ReleaseCreationService.php index 58dc3bf35..1c4c3a04a 100644 --- a/app/Services/ReleaseCreationService.php +++ b/app/Services/ReleaseCreationService.php @@ -21,7 +21,8 @@ use Illuminate\Support\Str; class ReleaseCreationService { public function __construct( - private readonly ReleaseCleaningService $releaseCleaning + private readonly ReleaseCleaningService $releaseCleaning, + private readonly CollectionCleanupService $collectionCleanupService, ) {} /** @@ -171,9 +172,11 @@ class ReleaseCreationService } } } else { - DB::transaction(static function () use ($collection) { - Collection::query()->where('collectionhash', $collection->collectionhash)->delete(); - }, 10); + $this->collectionCleanupService->deleteCollectionsAndDescendants( + [$collection->id], + 'Duplicate cleanup', + $echoCLI + ); $duplicate++; } diff --git a/app/Services/ReleaseProcessingService.php b/app/Services/ReleaseProcessingService.php index 695814985..802df2f1a 100644 --- a/app/Services/ReleaseProcessingService.php +++ b/app/Services/ReleaseProcessingService.php @@ -85,11 +85,11 @@ final class ReleaseProcessingService $this->releaseCleaning = $releaseCleaning ?? new ReleaseCleaningService; $this->releaseManagement = $releaseManagement ?? app(ReleaseManagementService::class); $this->releaseImage = $releaseImage ?? new ReleaseImageService; - - $this->releaseCreationService = $releaseCreationService - ?? new ReleaseCreationService($this->releaseCleaning); $this->collectionCleanupService = $collectionCleanupService ?? new CollectionCleanupService; + + $this->releaseCreationService = $releaseCreationService + ?? new ReleaseCreationService($this->releaseCleaning, $this->collectionCleanupService); $this->postProcessService = $postProcessService; $this->settings = $this->loadSettings(); @@ -432,21 +432,22 @@ final class ReleaseProcessingService $stats = ['minSize' => 0, 'maxSize' => 0, 'minFiles' => 0, 'par2Only' => 0]; // Delete collections where ALL binaries are par2 files (no actual content) - DB::transaction(static function () use (&$stats): void { - $par2OnlyCollectionIds = DB::table('collections as c') - ->join('binaries as b', 'c.id', '=', 'b.collections_id') - ->where('c.filecheck', CollectionFileCheckStatus::Sized->value) - ->where('c.filesize', '>', 0) - ->groupBy('c.id') - ->havingRaw("COUNT(b.id) = SUM(CASE WHEN b.name REGEXP '\\\\.(vol[0-9]+\\\\+[0-9]+\\\\.par2|par2)' THEN 1 ELSE 0 END)") - ->pluck('c.id'); + $par2OnlyCollectionIds = DB::table('collections as c') + ->join('binaries as b', 'c.id', '=', 'b.collections_id') + ->where('c.filecheck', CollectionFileCheckStatus::Sized->value) + ->where('c.filesize', '>', 0) + ->groupBy('c.id') + ->havingRaw("COUNT(b.id) = SUM(CASE WHEN b.name REGEXP '\\\\.(vol[0-9]+\\\\+[0-9]+\\\\.par2|par2)' THEN 1 ELSE 0 END)") + ->pluck('c.id') + ->all(); - if ($par2OnlyCollectionIds->isNotEmpty()) { - $stats['par2Only'] += Collection::query() - ->whereIn('id', $par2OnlyCollectionIds) - ->delete(); - } - }, 10); + if ($par2OnlyCollectionIds !== []) { + $stats['par2Only'] += $this->collectionCleanupService->deleteCollectionsAndDescendants( + $par2OnlyCollectionIds, + 'Par2-only cleanup', + $this->echoCLI + ); + } foreach ($groupIDs as $grpID) { $groupSettings = UsenetGroup::getGroupByID($grpID['id']); @@ -457,32 +458,48 @@ final class ReleaseProcessingService continue; } - DB::transaction(function () use ($groupMinSize, $groupMinFiles, &$stats): void { - $effectiveMinSize = max($groupMinSize, $this->settings->minSizeToFormRelease); - if ($effectiveMinSize > 0) { - $stats['minSize'] += Collection::query() - ->where('filecheck', CollectionFileCheckStatus::Sized->value) - ->where('filesize', '>', 0) - ->where('filesize', '<', $effectiveMinSize) - ->delete(); - } + $effectiveMinSize = max($groupMinSize, $this->settings->minSizeToFormRelease); + if ($effectiveMinSize > 0) { + $ids = Collection::query() + ->where('filecheck', CollectionFileCheckStatus::Sized->value) + ->where('filesize', '>', 0) + ->where('filesize', '<', $effectiveMinSize) + ->pluck('id') + ->all(); + $stats['minSize'] += $this->collectionCleanupService->deleteCollectionsAndDescendants( + $ids, + 'Min-size cleanup', + $this->echoCLI + ); + } - if ($this->settings->maxSizeToFormRelease > 0) { - $stats['maxSize'] += Collection::query() - ->where('filecheck', CollectionFileCheckStatus::Sized->value) - ->where('filesize', '>', $this->settings->maxSizeToFormRelease) - ->delete(); - } + if ($this->settings->maxSizeToFormRelease > 0) { + $ids = Collection::query() + ->where('filecheck', CollectionFileCheckStatus::Sized->value) + ->where('filesize', '>', $this->settings->maxSizeToFormRelease) + ->pluck('id') + ->all(); + $stats['maxSize'] += $this->collectionCleanupService->deleteCollectionsAndDescendants( + $ids, + 'Max-size cleanup', + $this->echoCLI + ); + } - $effectiveMinFiles = max($groupMinFiles, $this->settings->minFilesToFormRelease); - if ($effectiveMinFiles > 0) { - $stats['minFiles'] += Collection::query() - ->where('filecheck', CollectionFileCheckStatus::Sized->value) - ->where('filesize', '>', 0) - ->where('totalfiles', '<', $effectiveMinFiles) - ->delete(); - } - }, 10); + $effectiveMinFiles = max($groupMinFiles, $this->settings->minFilesToFormRelease); + if ($effectiveMinFiles > 0) { + $ids = Collection::query() + ->where('filecheck', CollectionFileCheckStatus::Sized->value) + ->where('filesize', '>', 0) + ->where('totalfiles', '<', $effectiveMinFiles) + ->pluck('id') + ->all(); + $stats['minFiles'] += $this->collectionCleanupService->deleteCollectionsAndDescendants( + $ids, + 'Min-files cleanup', + $this->echoCLI + ); + } } $this->outputCollectionDeleteStats($stats, $startTime); @@ -993,20 +1010,24 @@ final class ReleaseProcessingService do { try { - $groupCondition = $groupID !== 0 ? 'AND groups_id = ? ' : ''; - $batchLimit = self::BATCH_SIZE; + $query = DB::table('collections') + ->where('added', '<', $cutoff) + ->orderBy('id') + ->limit(self::BATCH_SIZE); + if ($groupID !== 0) { + $query->where('groups_id', '=', $groupID); + } - $sql = <<pluck('id')->all(); + if ($ids === []) { + break; + } - $params = $groupID !== 0 ? [$cutoff, $groupID] : [$cutoff]; - $affected = DB::affectingStatement($sql, $params); + $affected = $this->collectionCleanupService->deleteCollectionsAndDescendants( + $ids, + 'Stuck collections cleanup', + $this->echoCLI + ); break; } catch (Throwable $e) { $attempt++; diff --git a/database/migrations/2026_05_06_093900_restore_cbp_foreign_keys.php b/database/migrations/2026_05_06_093900_restore_cbp_foreign_keys.php new file mode 100644 index 000000000..8df2e74ef --- /dev/null +++ b/database/migrations/2026_05_06_093900_restore_cbp_foreign_keys.php @@ -0,0 +1,79 @@ +dropForeignKeyIfExists('parts', 'FK_binaries'); + $this->dropForeignKeyIfExists('binaries', 'FK_Collections'); + + DB::statement( + 'ALTER TABLE `binaries` + ADD CONSTRAINT `FK_Collections` + FOREIGN KEY (`collections_id`) + REFERENCES `collections` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE' + ); + + DB::statement( + 'ALTER TABLE `parts` + ADD CONSTRAINT `FK_binaries` + FOREIGN KEY (`binaries_id`) + REFERENCES `binaries` (`id`) + ON DELETE CASCADE + ON UPDATE CASCADE' + ); + } + + public function down(): void + { + if (DB::getDriverName() === 'sqlite') { + return; + } + + if (! Schema::hasTable('binaries') || ! Schema::hasTable('parts')) { + return; + } + + $this->dropForeignKeyIfExists('parts', 'FK_binaries'); + $this->dropForeignKeyIfExists('binaries', 'FK_Collections'); + } + + private function dropForeignKeyIfExists(string $table, string $constraintName): void + { + $exists = DB::select( + 'SELECT CONSTRAINT_NAME + FROM information_schema.TABLE_CONSTRAINTS + WHERE CONSTRAINT_TYPE = "FOREIGN KEY" + AND TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND CONSTRAINT_NAME = ?', + [$table, $constraintName] + ); + + if ($exists !== []) { + DB::statement("ALTER TABLE `{$table}` DROP FOREIGN KEY `{$constraintName}`"); + } + } +}; diff --git a/tests/Feature/CbpCleanupServiceTest.php b/tests/Feature/CbpCleanupServiceTest.php new file mode 100644 index 000000000..1521157db --- /dev/null +++ b/tests/Feature/CbpCleanupServiceTest.php @@ -0,0 +1,283 @@ + 'sqlite', 'database.connections.sqlite.database' => ':memory:']); + DB::purge(); + DB::reconnect(); + DB::connection()->getPdo()->sqliteCreateFunction('UNIX_TIMESTAMP', static fn (?string $value): int => strtotime((string) $value)); + + $this->createTables(); + $this->seedSettings(); + } + + public function test_retention_cleanup_deletes_parts_binaries_and_collections_without_fk_cascades(): void + { + DB::table('collections')->insert([ + 'id' => 100, + 'subject' => 'Retention.Release', + 'fromname' => 'poster@example.com', + 'date' => now()->subHours(10)->format('Y-m-d H:i:s'), + 'dateadded' => now()->subHours(10)->format('Y-m-d H:i:s'), + 'added' => now()->subHours(10)->format('Y-m-d H:i:s'), + 'xref' => 'alt.test:123', + 'groups_id' => 1, + 'totalfiles' => 1, + 'filesize' => 500, + 'filecheck' => CollectionFileCheckStatus::Sized->value, + 'collectionhash' => 'retention-hash', + 'collection_regexes_id' => 0, + 'releases_id' => null, + 'noise' => '', + ]); + DB::table('binaries')->insert([ + 'id' => 1000, + 'name' => 'Retention.Release.par2', + 'collections_id' => 100, + 'totalparts' => 1, + ]); + DB::table('parts')->insert([ + 'binaries_id' => 1000, + 'number' => 1, + 'messageid' => '', + 'partnumber' => 1, + 'size' => 10, + ]); + + app(CollectionCleanupService::class)->deleteFinishedAndOrphans(false); + + $this->assertSame(0, DB::table('parts')->count()); + $this->assertSame(0, DB::table('binaries')->count()); + $this->assertSame(0, DB::table('collections')->count()); + } + + public function test_nzb_creation_cleans_up_collection_binary_and_parts_explicitly(): void + { + DB::table('releases')->insert([ + 'id' => 1, + 'name' => 'Nzb.Release', + 'searchname' => 'Nzb.Release', + 'totalpart' => 1, + 'groups_id' => 1, + 'adddate' => now()->format('Y-m-d H:i:s'), + 'guid' => str_repeat('a', 36), + 'leftguid' => 'a', + 'postdate' => now()->format('Y-m-d H:i:s'), + 'fromname' => 'poster@example.com', + 'size' => 500, + 'passwordstatus' => 0, + 'haspreview' => -1, + 'categories_id' => 1, + 'nfostatus' => -1, + 'nzbstatus' => NzbService::NZB_NONE, + 'isrenamed' => 1, + 'iscategorized' => 1, + 'predb_id' => 0, + 'source' => null, + 'nzb_guid' => null, + ]); + + DB::table('collections')->insert([ + 'id' => 200, + 'subject' => 'Nzb.Release', + 'fromname' => 'poster@example.com', + 'date' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'dateadded' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'added' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'xref' => 'alt.test:200', + 'groups_id' => 1, + 'totalfiles' => 1, + 'filesize' => 500, + 'filecheck' => CollectionFileCheckStatus::Inserted->value, + 'collectionhash' => 'nzb-hash', + 'collection_regexes_id' => 0, + 'releases_id' => 1, + 'noise' => '', + ]); + DB::table('binaries')->insert([ + 'id' => 2000, + 'name' => 'Nzb.Release yEnc', + 'collections_id' => 200, + 'totalparts' => 1, + ]); + DB::table('parts')->insert([ + 'binaries_id' => 2000, + 'number' => 1, + 'messageid' => '', + 'partnumber' => 1, + 'size' => 10, + ]); + + $release = Release::query()->findOrFail(1); + $release->setRelation('category', (object) ['title' => 'Misc', 'parent' => (object) ['title' => 'Other']]); + + $written = app(NzbService::class)->writeNzbForReleaseId($release); + + $this->assertTrue($written); + $this->assertSame(0, DB::table('parts')->count()); + $this->assertSame(0, DB::table('binaries')->count()); + $this->assertSame(0, DB::table('collections')->count()); + $this->assertSame(1, (int) DB::table('releases')->where('id', 1)->value('nzbstatus')); + } + + public function test_duplicate_release_path_cleans_up_collection_binary_and_parts(): void + { + DB::table('releases')->insert([ + 'id' => 2, + 'name' => 'Duplicate.Release', + 'searchname' => 'Duplicate.Release', + 'totalpart' => 1, + 'groups_id' => 1, + 'adddate' => now()->format('Y-m-d H:i:s'), + 'guid' => str_repeat('b', 36), + 'leftguid' => 'b', + 'postdate' => now()->format('Y-m-d H:i:s'), + 'fromname' => 'poster@example.com', + 'size' => 1000, + 'passwordstatus' => 0, + 'haspreview' => -1, + 'categories_id' => 1, + 'nfostatus' => -1, + 'nzbstatus' => NzbService::NZB_NONE, + 'isrenamed' => 1, + 'iscategorized' => 1, + 'predb_id' => 0, + 'source' => null, + 'nzb_guid' => null, + ]); + + DB::table('collections')->insert([ + 'id' => 300, + 'subject' => 'Duplicate.Release', + 'fromname' => 'poster@example.com', + 'date' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'dateadded' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'added' => now()->subHours(1)->format('Y-m-d H:i:s'), + 'xref' => 'alt.test:300', + 'groups_id' => 1, + 'totalfiles' => 1, + 'filesize' => 1000, + 'filecheck' => CollectionFileCheckStatus::Sized->value, + 'collectionhash' => 'duplicate-hash', + 'collection_regexes_id' => 0, + 'releases_id' => null, + 'noise' => '', + ]); + DB::table('binaries')->insert([ + 'id' => 3000, + 'name' => 'Duplicate.Release yEnc', + 'collections_id' => 300, + 'totalparts' => 1, + ]); + DB::table('parts')->insert([ + 'binaries_id' => 3000, + 'number' => 1, + 'messageid' => '', + 'partnumber' => 1, + 'size' => 10, + ]); + + $service = new ReleaseCreationService( + app(ReleaseCleaningService::class), + app(CollectionCleanupService::class) + ); + $result = $service->createReleases(null, 10, false); + + $this->assertSame(['added' => 0, 'dupes' => 1], $result); + $this->assertSame(0, DB::table('parts')->count()); + $this->assertSame(0, DB::table('binaries')->count()); + $this->assertSame(0, DB::table('collections')->count()); + } + + private function seedSettings(): void + { + $settings = [ + 'partretentionhours' => '1', + 'nzbsplitlevel' => '1', + 'check_passworded_rars' => '0', + 'categorizeforeign' => '1', + 'catwebdl' => '1', + ]; + + foreach ($settings as $name => $value) { + DB::table('settings')->insert(['name' => $name, 'value' => $value]); + } + } + + private function createTables(): void + { + DB::statement('CREATE TABLE settings (name VARCHAR(255) PRIMARY KEY, value TEXT)'); + DB::statement('CREATE TABLE usenet_groups (id INTEGER PRIMARY KEY, name VARCHAR(255))'); + DB::statement('CREATE TABLE categories (id INTEGER PRIMARY KEY, title VARCHAR(255), parent_categories_id INTEGER NULL)'); + DB::statement('CREATE TABLE releases ( + id INTEGER PRIMARY KEY, + name VARCHAR(255), + searchname VARCHAR(255), + totalpart INTEGER, + groups_id INTEGER, + adddate DATETIME NULL, + guid VARCHAR(64), + leftguid VARCHAR(1), + postdate DATETIME NULL, + fromname VARCHAR(255), + size INTEGER, + passwordstatus INTEGER, + haspreview INTEGER, + categories_id INTEGER, + nfostatus INTEGER, + nzbstatus INTEGER, + isrenamed INTEGER, + iscategorized INTEGER, + predb_id INTEGER, + source VARCHAR(255) NULL, + nzb_guid BLOB NULL + )'); + DB::statement('CREATE TABLE collections ( + id INTEGER PRIMARY KEY, + subject VARCHAR(255), + fromname VARCHAR(255), + date DATETIME NULL, + dateadded DATETIME NULL, + added DATETIME NULL, + xref TEXT, + groups_id INTEGER, + totalfiles INTEGER, + filesize INTEGER, + filecheck INTEGER, + collectionhash VARCHAR(255), + collection_regexes_id INTEGER, + releases_id INTEGER NULL, + noise VARCHAR(64) + )'); + DB::statement('CREATE TABLE binaries ( + id INTEGER PRIMARY KEY, + name VARCHAR(255), + collections_id INTEGER, + totalparts INTEGER + )'); + DB::statement('CREATE TABLE parts ( + binaries_id INTEGER, + number INTEGER, + messageid VARCHAR(255), + partnumber INTEGER, + size INTEGER + )'); + DB::table('usenet_groups')->insert(['id' => 1, 'name' => 'alt.test']); + DB::table('categories')->insert(['id' => 1, 'title' => 'Misc', 'parent_categories_id' => null]); + } +}