error('The releases table does not exist.'); return self::FAILURE; } $this->dryRun = (bool) $this->option('dry-run'); if ($this->dryRun) { $this->info('Dry run: no rows are modified.'); } $leftGuids = $this->syncLeftGuids(); $invalid = $this->collectInvalidGuidIds(); $duplicates = $this->collectDuplicateGuidIds(); $this->table(['Issue', 'Releases'], [ ['leftguid out of sync with guid', (string) $leftGuids], ['guid is not a UUID', (string) count($invalid)], ['guid duplicated case-insensitively', (string) count($duplicates)], ]); $blocking = array_values(array_unique([...$invalid, ...$duplicates])); if ($blocking === []) { $this->info($this->dryRun ? 'No guid problems found. Re-run without --dry-run to apply the leftguid sync.' : 'Release guids are consistent.'); return self::SUCCESS; } $this->reportBlockingReleases($blocking); return self::FAILURE; } /** * Print the releases that must be resolved by hand before the migration can run. * * @param list $ids */ private function reportBlockingReleases(array $ids): void { $this->error(count($ids).' releases have a guid that blocks the normalization migration.'); $this->warn( 'These are not repaired automatically. A guid determines the release NZB path and is published in ' .'download links, so a new guid orphans the existing NZB file and breaks every link and download_stats ' .'row that references the old value. Resolve them deliberately: delete the affected releases, or assign ' .'new guids and relocate the matching NZB files yourself.' ); $sample = array_slice($ids, 0, 20); $rows = DB::table('releases') ->select(['id', 'guid', 'name']) ->whereIn('id', $sample) ->orderBy('id') ->get(); $this->table(['Release ID', 'Guid', 'Name'], $rows->map(static fn ($row): array => [ (string) $row->id, (string) $row->guid, Str::limit((string) $row->name, 60), ])->all()); if (count($ids) > count($sample)) { $this->line('… and '.(count($ids) - count($sample)).' more.'); } } /** * Point `leftguid` at the first character of `guid` wherever they disagree. * Safe on its own: no file or index state depends on `leftguid`. */ private function syncLeftGuids(): int { $chunk = $this->chunkSize(); $total = 0; $lastId = 0; do { $rows = DB::table('releases') ->select(['id', 'guid']) ->where('id', '>', $lastId) ->whereRaw('LOWER(leftguid) <> LOWER(SUBSTR(guid, 1, 1))') ->orderBy('id') ->limit($chunk) ->get(); foreach ($rows as $row) { $lastId = max($lastId, (int) $row->id); $total++; if (! $this->dryRun) { DB::table('releases') ->where('id', (int) $row->id) ->update(['leftguid' => substr((string) $row->guid, 0, 1)]); } } if ($rows->count() < $chunk) { break; } } while (true); return $total; } /** @return list */ private function collectInvalidGuidIds(): array { $ids = []; DB::table('releases')->select(['id', 'guid'])->orderBy('id')->chunk($this->chunkSize(), function ($rows) use (&$ids): void { foreach ($rows as $row) { if (! is_string($row->guid) || preg_match(self::UUID_PATTERN, $row->guid) !== 1) { $ids[] = (int) $row->id; } } }); return $ids; } /** * Every release that shares a case-insensitive guid with a lower-id release. * The lowest id is treated as the owner of the guid and is not reported. * * @return list */ private function collectDuplicateGuidIds(): array { $duplicated = DB::table('releases') ->selectRaw('LOWER(guid) AS normalized_guid') ->groupByRaw('LOWER(guid)') ->havingRaw('COUNT(*) > 1') ->pluck('normalized_guid'); $ids = []; foreach ($duplicated->chunk(500) as $batch) { $rows = DB::table('releases') ->select(['id', 'guid']) ->whereIn(DB::raw('LOWER(guid)'), $batch->all()) ->orderBy('id') ->get(); $seen = []; foreach ($rows as $row) { $key = strtolower((string) $row->guid); if (isset($seen[$key])) { $ids[] = (int) $row->id; continue; } $seen[$key] = true; } } sort($ids); return $ids; } private function chunkSize(): int { return max(100, min(10000, (int) $this->option('chunk'))); } }