Passed
Push — master ( 1e9ab0...74f1ac )
by Darko
11:16
created

RenameOtherMiscReleases::matchByTitleAndSize()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 23
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 13
c 1
b 0
f 0
dl 0
loc 23
rs 9.8333
cc 3
nc 3
nop 6
1
<?php
2
3
namespace App\Console\Commands;
4
5
use App\Models\Category;
6
use App\Models\Predb;
7
use App\Models\Release;
8
use Blacklight\Categorize;
9
use Blacklight\ColorCLI;
10
use Blacklight\ElasticSearchSiteSearch;
11
use Blacklight\ManticoreSearch;
12
use Illuminate\Console\Command;
13
14
class RenameOtherMiscReleases extends Command
15
{
16
    /**
17
     * The name and signature of the console command.
18
     *
19
     * @var string
20
     */
21
    protected $signature = 'releases:rename-other-misc
22
                                 {--limit= : Maximum number of releases to process}
23
                                 {--dry-run : Show what would be renamed without actually updating}
24
                                 {--show : Display detailed release changes}
25
                                 {--size-tolerance=5 : Size tolerance percentage for matching (default: 5%)}';
26
27
    /**
28
     * The console command description.
29
     *
30
     * @var string
31
     */
32
    protected $description = 'Rename releases in other->misc and other-hashed categories using PreDB entries';
33
34
    protected ?ColorCLI $colorCLI = null;
35
36
    protected int $renamed = 0;
37
38
    protected int $checked = 0;
39
40
    protected int $matched = 0;
41
42
    /**
43
     * Execute the console command.
44
     */
45
    public function handle(): int
46
    {
47
        $this->colorCLI = new ColorCLI;
48
        $categorize = new Categorize;
49
50
        $limit = $this->option('limit');
51
        $dryRun = $this->option('dry-run');
52
        $show = $this->option('show');
53
        $sizeTolerance = (float) $this->option('size-tolerance');
54
55
        if ($limit && ! is_numeric($limit)) {
56
            $this->error('Limit must be a numeric value.');
57
58
            return Command::FAILURE;
59
        }
60
61
        $this->colorCLI->header('Starting rename of releases in other->misc and other-hashed categories');
62
63
        if ($dryRun) {
64
            $this->colorCLI->info('DRY RUN MODE - No changes will be made');
65
        }
66
67
        $startTime = now();
68
69
        try {
70
            // Process releases with exact PreDB matches (title, filename, and size)
71
            $this->info('Step 1: Attempting exact PreDB matches (title/filename + size)...');
72
            $this->processExactMatches($limit, $dryRun, $show, $sizeTolerance, $categorize);
0 ignored issues
show
Bug introduced by
$dryRun of type string is incompatible with the type boolean expected by parameter $dryRun of App\Console\Commands\Ren...::processExactMatches(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

72
            $this->processExactMatches($limit, /** @scrutinizer ignore-type */ $dryRun, $show, $sizeTolerance, $categorize);
Loading history...
Bug introduced by
$show of type string is incompatible with the type boolean expected by parameter $show of App\Console\Commands\Ren...::processExactMatches(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

72
            $this->processExactMatches($limit, $dryRun, /** @scrutinizer ignore-type */ $show, $sizeTolerance, $categorize);
Loading history...
73
74
            // Process releases with title matches only
75
            $this->info('Step 2: Attempting fuzzy title matches...');
76
            $this->processTitleMatches($limit, $dryRun, $show, $categorize);
0 ignored issues
show
Bug introduced by
$show of type string is incompatible with the type boolean expected by parameter $show of App\Console\Commands\Ren...::processTitleMatches(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

76
            $this->processTitleMatches($limit, $dryRun, /** @scrutinizer ignore-type */ $show, $categorize);
Loading history...
Bug introduced by
$dryRun of type string is incompatible with the type boolean expected by parameter $dryRun of App\Console\Commands\Ren...::processTitleMatches(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

76
            $this->processTitleMatches($limit, /** @scrutinizer ignore-type */ $dryRun, $show, $categorize);
Loading history...
77
78
            $duration = now()->diffInSeconds($startTime, true);
79
80
            $this->colorCLI->header('Processing Complete');
81
            $this->colorCLI->primary("Checked: {$this->checked} releases");
82
            $this->colorCLI->primary("Matched: {$this->matched} releases");
83
            $this->colorCLI->primary("Renamed: {$this->renamed} releases");
84
            $this->colorCLI->primary("Duration: {$duration} seconds");
85
86
            return Command::SUCCESS;
87
88
        } catch (\Exception $e) {
89
            $this->error('Error: '.$e->getMessage());
90
            $this->error($e->getTraceAsString());
91
92
            return Command::FAILURE;
93
        }
94
    }
95
96
    /**
97
     * Process releases with exact PreDB matches (title/filename + size).
98
     */
99
    protected function processExactMatches($limit, bool $dryRun, bool $show, float $sizeTolerance, Categorize $categorize): void
100
    {
101
        $query = Release::query()
102
            ->whereIn('categories_id', [Category::OTHER_MISC, Category::OTHER_HASHED])
103
            ->where('predb_id', 0)
104
            ->select(['id', 'guid', 'name', 'searchname', 'size', 'fromname', 'categories_id', 'groups_id']);
105
106
        if ($limit) {
107
            $query->limit((int) $limit);
108
        }
109
110
        $releases = $query->get();
111
        $total = $releases->count();
112
113
        if ($total === 0) {
114
            $this->info('No releases found for exact matching.');
115
116
            return;
117
        }
118
119
        $this->info("Processing {$total} releases for exact matching...");
120
121
        foreach ($releases as $release) {
122
            $this->checked++;
123
124
            // Clean the release name for matching
125
            $cleanName = $this->cleanReleaseName($release->searchname);
126
127
            // Try to match by title and size
128
            $matched = $this->matchByTitleAndSize($release, $cleanName, $dryRun, $show, $sizeTolerance, $categorize);
129
130
            if (! $matched) {
131
                // Try to match by filename and size
132
                $matched = $this->matchByFilenameAndSize($release, $cleanName, $dryRun, $show, $sizeTolerance, $categorize);
133
            }
134
135
            if ($matched) {
136
                $this->matched++;
137
            }
138
139
            if (! $show && $this->checked % 10 === 0) {
140
                $percent = round(($this->checked / $total) * 100, 1);
141
                $this->info(
142
                    "Progress: {$percent}% ({$this->checked}/{$total}) | ".
143
                    "Matched: {$this->matched} | Renamed: {$this->renamed}"
144
                );
145
            }
146
        }
147
148
        if (! $show) {
149
            echo PHP_EOL;
150
        }
151
    }
152
153
    /**
154
     * Process releases with title matches only.
155
     */
156
    protected function processTitleMatches($limit, bool $dryRun, bool $show, Categorize $categorize): void
157
    {
158
        $query = Release::query()
159
            ->whereIn('categories_id', [Category::OTHER_MISC, Category::OTHER_HASHED])
160
            ->where('predb_id', 0)
161
            ->select(['id', 'guid', 'name', 'searchname', 'size', 'fromname', 'categories_id', 'groups_id']);
162
163
        if ($limit) {
164
            $query->limit((int) $limit);
165
        }
166
167
        $releases = $query->get();
168
        $total = $releases->count();
169
170
        if ($total === 0) {
171
            $this->info('No releases found for title matching.');
172
173
            return;
174
        }
175
176
        $this->info("Processing {$total} releases for title matching...");
177
178
        $initialChecked = $this->checked;
179
180
        foreach ($releases as $release) {
181
            $this->checked++;
182
183
            // Clean the release name for matching
184
            $cleanName = $this->cleanReleaseName($release->searchname);
185
186
            // Try fuzzy title matching
187
            $matched = $this->matchByFuzzyTitle($release, $cleanName, $dryRun, $show, $categorize);
188
189
            if ($matched) {
190
                $this->matched++;
191
            }
192
193
            if (! $show && ($this->checked - $initialChecked) % 10 === 0) {
194
                $percent = round((($this->checked - $initialChecked) / $total) * 100, 1);
195
                $this->info(
196
                    "Progress: {$percent}% ({$this->checked}/{$total}) | ".
197
                    "Matched: {$this->matched} | Renamed: {$this->renamed}"
198
                );
199
            }
200
        }
201
202
        if (! $show) {
203
            echo PHP_EOL;
204
        }
205
    }
206
207
    /**
208
     * Match release by title and size in PreDB.
209
     */
210
    protected function matchByTitleAndSize($release, string $cleanName, bool $dryRun, bool $show, float $sizeTolerance, Categorize $categorize): bool
211
    {
212
        if (empty($cleanName)) {
213
            return false;
214
        }
215
216
        // Calculate size range for matching
217
        $sizeMin = $release->size * (1 - ($sizeTolerance / 100));
218
        $sizeMax = $release->size * (1 + ($sizeTolerance / 100));
219
220
        $predb = Predb::query()
221
            ->where('title', $cleanName)
222
            ->where(function ($query) use ($sizeMin, $sizeMax) {
223
                $query->whereNull('size')
224
                    ->orWhereBetween('size', [$sizeMin, $sizeMax]);
225
            })
226
            ->first(['id', 'title', 'size', 'source']);
227
228
        if ($predb) {
229
            return $this->updateReleaseFromPredb($release, $predb, 'Title + Size Match', $dryRun, $show, $categorize);
230
        }
231
232
        return false;
233
    }
234
235
    /**
236
     * Match release by filename and size in PreDB.
237
     */
238
    protected function matchByFilenameAndSize($release, string $cleanName, bool $dryRun, bool $show, float $sizeTolerance, Categorize $categorize): bool
239
    {
240
        if (empty($cleanName)) {
241
            return false;
242
        }
243
244
        // Calculate size range for matching
245
        $sizeMin = $release->size * (1 - ($sizeTolerance / 100));
246
        $sizeMax = $release->size * (1 + ($sizeTolerance / 100));
247
248
        $predb = Predb::query()
249
            ->where('filename', $cleanName)
250
            ->where(function ($query) use ($sizeMin, $sizeMax) {
251
                $query->whereNull('size')
252
                    ->orWhereBetween('size', [$sizeMin, $sizeMax]);
253
            })
254
            ->first(['id', 'title', 'size', 'source']);
255
256
        if ($predb) {
257
            return $this->updateReleaseFromPredb($release, $predb, 'Filename + Size Match', $dryRun, $show, $categorize);
258
        }
259
260
        return false;
261
    }
262
263
    /**
264
     * Match release by fuzzy title matching using search.
265
     */
266
    protected function matchByFuzzyTitle($release, string $cleanName, bool $dryRun, bool $show, Categorize $categorize): bool
267
    {
268
        if (empty($cleanName)) {
269
            return false;
270
        }
271
272
        // Try direct title match first
273
        $predb = Predb::query()
274
            ->where('title', $cleanName)
275
            ->first(['id', 'title', 'size', 'source']);
276
277
        if ($predb) {
278
            return $this->updateReleaseFromPredb($release, $predb, 'Title Match', $dryRun, $show, $categorize);
279
        }
280
281
        // Try filename match
282
        $predb = Predb::query()
283
            ->where('filename', $cleanName)
284
            ->first(['id', 'title', 'size', 'source']);
285
286
        if ($predb) {
287
            return $this->updateReleaseFromPredb($release, $predb, 'Filename Match', $dryRun, $show, $categorize);
288
        }
289
290
        // Try partial title match with LIKE
291
        $searchPattern = '%'.str_replace(['%', '_'], ['\\%', '\\_'], $cleanName).'%';
292
        $predb = Predb::query()
293
            ->where('title', 'LIKE', $searchPattern)
294
            ->first(['id', 'title', 'size', 'source']);
295
296
        if ($predb) {
297
            return $this->updateReleaseFromPredb($release, $predb, 'Partial Title Match', $dryRun, $show, $categorize);
298
        }
299
300
        return false;
301
    }
302
303
    /**
304
     * Update release from PreDB entry.
305
     */
306
    protected function updateReleaseFromPredb($release, $predb, string $matchType, bool $dryRun, bool $show, Categorize $categorize): bool
307
    {
308
        // Get old category name
309
        $oldCategory = Category::query()->where('id', $release->categories_id)->first();
310
        $oldCategoryName = $oldCategory ? $oldCategory->title : "Unknown (ID: {$release->categories_id})";
311
312
        if ($release->searchname === $predb->title) {
313
            // Names already match, just update predb_id
314
            if (! $dryRun) {
315
                Release::where('id', $release->id)->update(['predb_id' => $predb->id]);
316
            }
317
318
            if ($show) {
319
                $this->colorCLI->primary('═══════════════════════════════════════════════════════════');
0 ignored issues
show
Bug introduced by
The method primary() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

319
                $this->colorCLI->/** @scrutinizer ignore-call */ 
320
                                 primary('═══════════════════════════════════════════════════════════');

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
320
                $this->colorCLI->header("Release ID: {$release->id}");
321
                $this->colorCLI->primary("GUID: {$release->guid}");
322
                $this->colorCLI->info("Match Type: {$matchType}");
323
                $this->colorCLI->info("Searchname: {$release->searchname}");
324
                $this->colorCLI->info("Category: {$oldCategoryName}");
325
                $this->colorCLI->info("PreDB Title: {$predb->title}");
326
                $this->colorCLI->info("PreDB Source: {$predb->source}");
327
                $this->colorCLI->warning('Action: Same name, only updating predb_id');
328
                if ($dryRun) {
329
                    $this->colorCLI->info('[DRY RUN - Not actually updated]');
330
                }
331
                $this->colorCLI->primary('═══════════════════════════════════════════════════════════');
332
                echo PHP_EOL;
333
            }
334
335
            return true;
336
        }
337
338
        // Names differ, perform full rename
339
        $oldName = $release->name;
0 ignored issues
show
Unused Code introduced by
The assignment to $oldName is dead and can be removed.
Loading history...
340
        $oldSearchName = $release->searchname;
341
        $newName = $predb->title;
342
        $newCategory = null;
343
        $newCategoryName = $oldCategoryName;
344
345
        if (! $dryRun) {
346
            // Update release
347
            Release::where('id', $release->id)->update([
348
                'name' => $newName,
349
                'searchname' => $newName,
350
                'isrenamed' => 1,
351
                'predb_id' => $predb->id,
352
            ]);
353
354
            // Recategorize if needed
355
            $newCategory = $categorize->determineCategory($release->groups_id, $newName);
356
            if ($newCategory !== null && $newCategory !== $release->categories_id) {
357
                Release::where('id', $release->id)->update(['categories_id' => $newCategory]);
358
                $newCategoryObj = Category::query()->where('id', $newCategory)->first();
359
                if ($newCategoryObj && isset($newCategoryObj->title)) {
360
                    $newCategoryName = $newCategoryObj->title;
361
                } else {
362
                    $newCategoryName = "Unknown (ID: {$newCategory})";
363
                }
364
            }
365
366
            // Update search indexes
367
            if (config('nntmux.elasticsearch_enabled') === true) {
368
                (new ElasticSearchSiteSearch)->updateRelease($release->id);
369
            } else {
370
                (new ManticoreSearch)->updateRelease($release->id);
371
            }
372
        } else {
373
            // Dry run: calculate what the new category would be
374
            $newCategory = $categorize->determineCategory($release->groups_id, $newName);
375
            if ($newCategory !== null && $newCategory !== $release->categories_id) {
376
                $newCategoryObj = Category::query()->where('id', $newCategory)->first();
377
                if ($newCategoryObj && isset($newCategoryObj->title)) {
378
                    $newCategoryName = $newCategoryObj->title;
379
                } else {
380
                    $newCategoryName = "Unknown (ID: {$newCategory})";
381
                }
382
            }
383
        }
384
385
        $this->renamed++;
386
387
        if ($show) {
388
            $this->colorCLI->primary('═══════════════════════════════════════════════════════════');
389
            $this->colorCLI->header("Release ID: {$release->id}");
390
            $this->colorCLI->primary("GUID: {$release->guid}");
391
            $this->colorCLI->info("Match Type: {$matchType}");
392
            echo PHP_EOL;
393
            $this->colorCLI->warning("OLD Searchname: {$oldSearchName}");
394
            $this->colorCLI->warning("OLD Category:   {$oldCategoryName}");
395
            echo PHP_EOL;
396
            $this->colorCLI->header("NEW Searchname: {$newName}");
397
            if ($newCategory !== null && $newCategory !== $release->categories_id) {
398
                $this->colorCLI->header("NEW Category:   {$newCategoryName}");
399
            } else {
400
                $this->colorCLI->info("NEW Category:   {$newCategoryName} (unchanged)");
401
            }
402
            echo PHP_EOL;
403
            $this->colorCLI->info("PreDB Source: {$predb->source}");
404
            if ($dryRun) {
405
                $this->colorCLI->info('[DRY RUN - Not actually updated]');
406
            }
407
            $this->colorCLI->primary('═══════════════════════════════════════════════════════════');
408
            echo PHP_EOL;
409
        }
410
411
        return true;
412
    }
413
414
    /**
415
     * Clean release name for matching.
416
     */
417
    protected function cleanReleaseName(string $name): string
418
    {
419
        // Remove common release group tags and clean up
420
        $cleaned = trim($name);
421
422
        // Remove leading/trailing dots, dashes, underscores
423
        $cleaned = trim($cleaned, '._- ');
424
425
        // Replace multiple spaces with single space
426
        $cleaned = preg_replace('/\s+/', ' ', $cleaned);
427
428
        return $cleaned;
429
    }
430
}
431