BookService::getBrowseBy()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 11
rs 10
cc 3
nc 3
nop 0
1
<?php
2
3
namespace App\Services;
4
5
use App\Models\BookInfo;
6
use App\Models\Category;
7
use App\Models\Release;
8
use App\Models\Settings;
9
use Illuminate\Database\Eloquent\Model;
10
use Illuminate\Support\Facades\Cache;
11
use Illuminate\Support\Facades\DB;
12
13
/**
14
 * Service class for book data fetching and processing.
15
 */
16
class BookService
17
{
18
    public bool $echooutput;
19
20
    public ?string $pubkey;
21
22
    public ?string $privkey;
23
24
    public ?string $asstag;
25
26
    public int $bookqty;
27
28
    public int $sleeptime;
29
30
    public string $imgSavePath;
31
32
    public ?string $bookreqids;
33
34
    public string $renamed;
35
36
    public array $failCache;
37
38
    /**
39
     * @throws \Exception
40
     */
41
    public function __construct()
42
    {
43
        $this->echooutput = config('nntmux.echocli');
44
45
        $this->pubkey = Settings::settingValue('amazonpubkey');
46
        $this->privkey = Settings::settingValue('amazonprivkey');
47
        $this->asstag = Settings::settingValue('amazonassociatetag');
48
        $this->bookqty = Settings::settingValue('maxbooksprocessed') !== '' ? (int) Settings::settingValue('maxbooksprocessed') : 300;
49
        $this->sleeptime = Settings::settingValue('amazonsleep') !== '' ? (int) Settings::settingValue('amazonsleep') : 1000;
50
        $this->imgSavePath = storage_path('covers/book/');
51
52
        $this->bookreqids = Category::BOOKS_EBOOK;
53
        $this->renamed = (int) Settings::settingValue('lookupbooks') === 2 ? 'AND isrenamed = 1' : '';
54
55
        $this->failCache = [];
56
    }
57
58
    /**
59
     * Get book info by ID.
60
     */
61
    public function getBookInfo(?int $id): ?Model
62
    {
63
        if ($id === null) {
64
            return null;
65
        }
66
67
        return BookInfo::query()->where('id', $id)->first();
68
    }
69
70
    /**
71
     * Get book info by name using full-text search.
72
     */
73
    public function getBookInfoByName(string $title): ?Model
74
    {
75
        $searchWords = '';
76
        $title = preg_replace(['/( - | -|\(.+\)|\(|\))/', '/[^\w ]+/'], [' ', ''], $title);
77
        $title = trim(trim(preg_replace('/\s\s+/i', ' ', $title)));
78
        foreach (explode(' ', $title) as $word) {
79
            $word = trim(rtrim(trim($word), '-'));
80
            if ($word !== '' && $word !== '-') {
81
                $word = '+'.$word;
82
                $searchWords .= sprintf('%s ', $word);
83
            }
84
        }
85
        $searchWords = trim($searchWords);
86
87
        return BookInfo::search($searchWords)->first();
88
    }
89
90
    /**
91
     * Get book range with pagination.
92
     */
93
    public function getBookRange(int $page, array $cat, int $start, int $num, string $orderBy, array $excludedCats = []): array
94
    {
95
        $page = max(1, $page);
96
        $start = max(0, $start);
97
98
        $browseby = $this->getBrowseBy();
99
        $catsrch = '';
100
        if (\count($cat) > 0 && $cat[0] !== -1) {
101
            $catsrch = Category::getCategorySearch($cat);
102
        }
103
        $exccatlist = '';
104
        if (\count($excludedCats) > 0) {
105
            $exccatlist = ' AND r.categories_id NOT IN ('.implode(',', $excludedCats).')';
106
        }
107
        $order = $this->getBookOrder($orderBy);
108
        $booksql = sprintf(
109
            "
110
				SELECT SQL_CALC_FOUND_ROWS boo.id,
111
					GROUP_CONCAT(r.id ORDER BY r.postdate DESC SEPARATOR ',') AS grp_release_id
112
				FROM bookinfo boo
113
				LEFT JOIN releases r ON boo.id = r.bookinfo_id
114
				WHERE boo.cover = 1
115
				AND boo.title != ''
116
				AND r.passwordstatus %s
117
				%s %s %s
118
				GROUP BY boo.id
119
				ORDER BY %s %s %s",
120
            app(\App\Services\Releases\ReleaseBrowseService::class)->showPasswords(),
121
            $browseby,
122
            $catsrch,
123
            $exccatlist,
124
            $order[0],
125
            $order[1],
126
            ($start === false ? '' : ' LIMIT '.$num.' OFFSET '.$start)
127
        );
128
        $expiresAt = now()->addMinutes(config('nntmux.cache_expiry_medium'));
129
        $booksCache = Cache::get(md5($booksql.$page));
130
        if ($booksCache !== null) {
131
            $books = $booksCache;
132
        } else {
133
            $data = DB::select($booksql);
134
            $books = ['total' => DB::select('SELECT FOUND_ROWS() AS total'), 'result' => $data];
135
            Cache::put(md5($booksql.$page), $books, $expiresAt);
136
        }
137
        $bookIDs = $releaseIDs = [];
138
        if (\is_array($books['result'])) {
139
            foreach ($books['result'] as $book => $id) {
140
                $bookIDs[] = $id->id;
141
                $releaseIDs[] = $id->grp_release_id;
142
            }
143
        }
144
        $sql = sprintf(
145
            '
146
			SELECT
147
				r.id, r.rarinnerfilecount, r.grabs, r.comments, r.totalpart, r.size, r.postdate, r.searchname, r.haspreview, r.passwordstatus, r.guid, df.failed AS failed,
148
			boo.*,
149
			r.bookinfo_id,
150
			g.name AS group_name,
151
			rn.releases_id AS nfoid
152
			FROM releases r
153
			LEFT OUTER JOIN usenet_groups g ON g.id = r.groups_id
154
			LEFT OUTER JOIN release_nfos rn ON rn.releases_id = r.id
155
			LEFT OUTER JOIN dnzb_failures df ON df.release_id = r.id
156
			INNER JOIN bookinfo boo ON boo.id = r.bookinfo_id
157
			WHERE boo.id IN (%s)
158
			AND r.id IN (%s)
159
			%s
160
			GROUP BY boo.id
161
			ORDER BY %s %s',
162
            \is_array($bookIDs) && ! empty($bookIDs) ? implode(',', $bookIDs) : -1,
163
            \is_array($releaseIDs) && ! empty($releaseIDs) ? implode(',', $releaseIDs) : -1,
164
            $catsrch,
165
            $order[0],
166
            $order[1]
167
        );
168
        $return = Cache::get(md5($sql.$page));
169
        if ($return !== null) {
170
            return $return;
171
        }
172
        $return = DB::select($sql);
173
        if (\count($return) > 0) {
174
            $return[0]->_totalcount = $books['total'][0]->total ?? 0;
175
        }
176
        Cache::put(md5($sql.$page), $return, $expiresAt);
177
178
        return $return;
179
    }
180
181
    /**
182
     * Get book order array.
183
     */
184
    public function getBookOrder(string $orderBy): array
185
    {
186
        $order = $orderBy === '' ? 'r.postdate' : $orderBy;
187
        $orderArr = explode('_', $order);
188
        $orderfield = match ($orderArr[0]) {
189
            'title' => 'boo.title',
190
            'author' => 'boo.author',
191
            'publishdate' => 'boo.publishdate',
192
            'size' => 'r.size',
193
            'files' => 'r.totalpart',
194
            'stats' => 'r.grabs',
195
            default => 'r.postdate',
196
        };
197
        $ordersort = (isset($orderArr[1]) && preg_match('/^asc|desc$/i', $orderArr[1])) ? $orderArr[1] : 'desc';
198
199
        return [$orderfield, $ordersort];
200
    }
201
202
    /**
203
     * Get book ordering options.
204
     */
205
    public function getBookOrdering(): array
206
    {
207
        return [
208
            'title_asc',
209
            'title_desc',
210
            'posted_asc',
211
            'posted_desc',
212
            'size_asc',
213
            'size_desc',
214
            'files_asc',
215
            'files_desc',
216
            'stats_asc',
217
            'stats_desc',
218
            'releasedate_asc',
219
            'releasedate_desc',
220
            'author_asc',
221
            'author_desc',
222
        ];
223
    }
224
225
    /**
226
     * Get browse by options.
227
     */
228
    public function getBrowseByOptions(): array
229
    {
230
        return ['author' => 'author', 'title' => 'title'];
231
    }
232
233
    /**
234
     * Get browse by SQL clause.
235
     */
236
    public function getBrowseBy(): string
237
    {
238
        $browseby = ' ';
239
        foreach ($this->getBrowseByOptions() as $bbk => $bbv) {
240
            if (! empty($_REQUEST[$bbk])) {
241
                $bbs = stripslashes($_REQUEST[$bbk]);
242
                $browseby .= ' AND boo.'.$bbv.' '.'LIKE '.escapeString('%'.$bbs.'%');
243
            }
244
        }
245
246
        return $browseby;
247
    }
248
249
    /**
250
     * Update book by ID.
251
     */
252
    public function update(
253
        int $id,
254
        string $title,
255
        ?string $asin,
256
        ?string $url,
257
        ?string $author,
258
        ?string $publisher,
259
        $publishdate,
260
        int $cover
261
    ): bool {
262
        return BookInfo::query()->where('id', $id)->update([
263
            'title' => $title,
264
            'asin' => $asin,
265
            'url' => $url,
266
            'author' => $author,
267
            'publisher' => $publisher,
268
            'publishdate' => $publishdate,
269
            'cover' => $cover,
270
        ]) > 0;
271
    }
272
273
    /**
274
     * Process book releases, 1 category at a time.
275
     *
276
     * @throws \Exception
277
     */
278
    public function processBookReleases(string $groupID = '', string $guidChar = ''): void
279
    {
280
        $bookids = [];
281
        if (ctype_digit((string) $this->bookreqids)) {
282
            $bookids[] = $this->bookreqids;
283
        } else {
284
            $bookids = explode(', ', $this->bookreqids);
0 ignored issues
show
Bug introduced by
It seems like $this->bookreqids can also be of type null; however, parameter $string of explode() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

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

284
            $bookids = explode(', ', /** @scrutinizer ignore-type */ $this->bookreqids);
Loading history...
285
        }
286
287
        $total = \count($bookids);
288
        if ($total > 0) {
289
            foreach ($bookids as $i => $iValue) {
290
                $query = Release::query()
291
                    ->whereNull('bookinfo_id')
292
                    ->whereIn('categories_id', [$iValue])
293
                    ->orderByDesc('postdate')
0 ignored issues
show
Bug introduced by
'postdate' of type string is incompatible with the type Closure|Illuminate\Datab...\Database\Query\Builder expected by parameter $column of Illuminate\Database\Query\Builder::orderByDesc(). ( Ignorable by Annotation )

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

293
                    ->orderByDesc(/** @scrutinizer ignore-type */ 'postdate')
Loading history...
294
                    ->limit($this->bookqty);
295
296
                if ($guidChar !== '') {
297
                    $query->where('leftguid', 'like', $guidChar.'%');
298
                }
299
300
                if ($groupID !== '') {
301
                    $query->where('groups_id', $groupID);
302
                }
303
304
                $this->processBookReleasesHelper(
305
                    $query->get(['searchname', 'id', 'categories_id']), $iValue
306
                );
307
            }
308
        }
309
    }
310
311
    /**
312
     * Process book releases helper.
313
     *
314
     * @throws \Exception
315
     */
316
    protected function processBookReleasesHelper($res, $categoryID): void
317
    {
318
        if ($res->count() > 0) {
319
            if ($this->echooutput) {
320
                cli()->header('Processing '.$res->count().' book release(s) for categories id '.$categoryID);
321
            }
322
323
            $bookId = -2;
324
            foreach ($res as $arr) {
325
                $startTime = now()->timestamp;
326
                $usedAmazon = false;
327
                // audiobooks are also books and should be handled in an identical manor, even though it falls under a music category
328
                if ($arr['categories_id'] === (int) Category::MUSIC_AUDIOBOOK) {
329
                    // audiobook
330
                    $bookInfo = $this->parseTitle($arr['searchname'], $arr['id'], 'audiobook');
331
                } else {
332
                    // ebook
333
                    $bookInfo = $this->parseTitle($arr['searchname'], $arr['id'], 'ebook');
334
                }
335
336
                if ($bookInfo !== false) {
337
                    if ($this->echooutput) {
338
                        cli()->info('Looking up: '.$bookInfo);
339
                    }
340
341
                    // Do a local lookup first
342
                    $bookCheck = $this->getBookInfoByName($bookInfo);
343
344
                    if ($bookCheck === null && \in_array($bookInfo, $this->failCache, false)) {
345
                        // Lookup recently failed, no point trying again
346
                        if ($this->echooutput) {
347
                            cli()->info('Cached previous failure. Skipping.');
348
                        }
349
                        $bookId = -2;
350
                    } elseif ($bookCheck === null) {
351
                        $bookId = $this->updateBookInfo($bookInfo);
352
                        $usedAmazon = true;
353
                        if ($bookId === -2) {
354
                            $this->failCache[] = $bookInfo;
355
                        }
356
                    } else {
357
                        $bookId = $bookCheck['id'];
358
                    }
359
360
                    // Update release.
361
                    Release::query()->where('id', $arr['id'])->update(['bookinfo_id' => $bookId]);
362
                } else { // Could not parse release title.
363
                    Release::query()->where('id', $arr['id'])->update(['bookinfo_id' => $bookId]);
364
                    if ($this->echooutput) {
365
                        echo '.';
366
                    }
367
                }
368
                // Sleep to not flood amazon.
369
                $diff = floor((now()->timestamp - $startTime) * 1000000);
370
                if ($this->sleeptime * 1000 - $diff > 0 && $usedAmazon === true) {
371
                    usleep($this->sleeptime * 1000 - $diff);
0 ignored issues
show
Bug introduced by
$this->sleeptime * 1000 - $diff of type double is incompatible with the type integer expected by parameter $microseconds of usleep(). ( Ignorable by Annotation )

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

371
                    usleep(/** @scrutinizer ignore-type */ $this->sleeptime * 1000 - $diff);
Loading history...
372
                }
373
            }
374
        } elseif ($this->echooutput) {
375
            cli()->header('No book releases to process for categories id '.$categoryID);
376
        }
377
    }
378
379
    /**
380
     * Parse release title.
381
     *
382
     * @return bool|string
383
     */
384
    public function parseTitle($release_name, $releaseID, $releasetype)
385
    {
386
        $a = preg_replace('/\d{1,2} \d{1,2} \d{2,4}|(19|20)\d\d|anybody got .+?[a-z]\? |[ ._-](Novel|TIA)([ ._-]|$)|([ \.])HQ([-\. ])|[\(\)\.\-_ ](AVI|AZW3?|DOC|EPUB|LIT|MOBI|NFO|RETAIL|(si)?PDF|RTF|TXT)[\)\]\.\-_ ](?![a-z0-9])|compleet|DAGSTiDNiNGEN|DiRFiX|\+ extra|r?e ?Books?([\.\-_ ]English|ers)?|azw3?|ePu([bp])s?|html|mobi|^NEW[\.\-_ ]|PDF([\.\-_ ]English)?|Please post more|Post description|Proper|Repack(fix)?|[\.\-_ ](Chinese|English|French|German|Italian|Retail|Scan|Swedish)|^R4 |Repost|Skytwohigh|TIA!+|TruePDF|V413HAV|(would someone )?please (re)?post.+? "|with the authors name right/i', '', $release_name);
387
        $b = preg_replace('/^(As Req |conversion |eq |Das neue Abenteuer \d+|Fixed version( ignore previous post)?|Full |Per Req As Found|(\s+)?R4 |REQ |revised |version |\d+(\s+)?$)|(COMPLETE|INTERNAL|RELOADED| (AZW3|eB|docx|ENG?|exe|FR|Fix|gnv64|MU|NIV|R\d\s+\d{1,2} \d{1,2}|R\d|Req|TTL|UC|v(\s+)?\d))(\s+)?$/i', '', $a);
388
389
        // remove book series from title as this gets more matches on amazon
390
        $c = preg_replace('/ - \[.+\]|\[.+\]/', '', $b);
391
392
        // remove any brackets left behind
393
        $d = preg_replace('/(\(\)|\[\])/', '', $c);
394
        $releasename = trim(preg_replace('/\s\s+/i', ' ', $d));
395
396
        // the default existing type was ebook, this handles that in the same manor as before
397
        if ($releasetype === 'ebook') {
398
            if (preg_match('/^([a-z0-9] )+$|ArtofUsenet|ekiosk|(ebook|mobi).+collection|erotica|Full Video|ImwithJamie|linkoff org|Mega.+pack|^[a-z0-9]+ (?!((January|February|March|April|May|June|July|August|September|O([ck])tober|November|De([cz])ember)))[a-z]+( (ebooks?|The))?$|NY Times|(Book|Massive) Dump|Sexual/i', $releasename)) {
399
                if ($this->echooutput) {
400
                    cli()->headerOver('Changing category to misc books: ').cli()->primary($releasename);
401
                }
402
                Release::query()->where('id', $releaseID)->update(['categories_id' => Category::BOOKS_UNKNOWN]);
403
404
                return false;
405
            }
406
407
            if (preg_match('/^([a-z0-9ü!]+ ){1,2}(N|Vol)?\d{1,4}([abc])?$|^([a-z0-9]+ ){1,2}(Jan( |unar|$)|Feb( |ruary|$)|Mar( |ch|$)|Apr( |il|$)|May(?![a-z0-9])|Jun([ e$])|Jul([ y$])|Aug( |ust|$)|Sep( |tember|$)|O([ck])t( |ober|$)|Nov( |ember|$)|De([cz])( |ember|$))/ui', $releasename) && ! preg_match('/Part \d+/i', $releasename)) {
408
                if ($this->echooutput) {
409
                    cli()->headerOver('Changing category to magazines: ').cli()->primary($releasename);
410
                }
411
                Release::query()->where('id', $releaseID)->update(['categories_id' => Category::BOOKS_MAGAZINES]);
412
413
                return false;
414
            }
415
            if (! empty($releasename) && ! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename)) {
416
                return $releasename;
417
            }
418
419
            return false;
420
        }
421
        if ($releasetype === 'audiobook') {
422
            if (! empty($releasename) && ! preg_match('/^[a-z0-9]+$|^([0-9]+ ){1,}$|Part \d+/i', $releasename)) {
423
                // we can skip category for audiobooks, since we already know it, so as long as the release name is valid return it so that it is postprocessed by amazon.  In the future, determining the type of audiobook could be added (Lecture or book), since we can skip lookups on lectures, but for now handle them all the same way
424
                return $releasename;
425
            }
426
427
            return false;
428
        }
429
430
        return false;
431
    }
432
433
    /**
434
     * Update book info from external sources.
435
     *
436
     * @return false|int|string
437
     *
438
     * @throws \Exception
439
     */
440
    public function updateBookInfo(string $bookInfo = '', $amazdata = null)
441
    {
442
        $ri = new ReleaseImageService;
443
444
        $bookId = -2;
445
446
        $book = false;
447
        if ($bookInfo !== '') {
448
            if (! $book) {
0 ignored issues
show
introduced by
The condition $book is always false.
Loading history...
449
                cli()->info('Fetching data from iTunes for '.$bookInfo);
450
                $book = $this->fetchItunesBookProperties($bookInfo);
451
            } elseif ($amazdata !== null) {
452
                $book = $amazdata;
453
            }
454
        }
455
456
        if (empty($book)) {
457
            return false;
458
        }
459
460
        $check = BookInfo::query()->where('asin', $book['asin'])->first();
461
        if ($check === null) {
462
            $bookId = BookInfo::query()->insertGetId(
463
                [
464
                    'title' => $book['title'],
465
                    'author' => $book['author'],
466
                    'asin' => $book['asin'],
467
                    'isbn' => $book['isbn'],
468
                    'ean' => $book['ean'],
469
                    'url' => $book['url'],
470
                    'salesrank' => $book['salesrank'],
471
                    'publisher' => $book['publisher'],
472
                    'publishdate' => $book['publishdate'],
473
                    'pages' => $book['pages'],
474
                    'overview' => $book['overview'],
475
                    'genre' => $book['genre'],
476
                    'cover' => $book['cover'],
477
                    'created_at' => now(),
478
                    'updated_at' => now(),
479
                ]
480
            );
481
        } else {
482
            if ($check !== null) {
483
                $bookId = $check['id'];
484
            }
485
            BookInfo::query()->where('id', $bookId)->update(
486
                [
487
                    'title' => $book['title'],
488
                    'author' => $book['author'],
489
                    'asin' => $book['asin'],
490
                    'isbn' => $book['isbn'],
491
                    'ean' => $book['ean'],
492
                    'url' => $book['url'],
493
                    'salesrank' => $book['salesrank'],
494
                    'publisher' => $book['publisher'],
495
                    'publishdate' => $book['publishdate'],
496
                    'pages' => $book['pages'],
497
                    'overview' => $book['overview'],
498
                    'genre' => $book['genre'],
499
                    'cover' => $book['cover'],
500
                ]
501
            );
502
        }
503
504
        if ($bookId && $bookId !== -2) {
505
            if ($this->echooutput) {
506
                cli()->header('Added/updated book: ');
507
                if ($book['author'] !== '') {
508
                    cli()->alternateOver('   Author: ').cli()->primary($book['author']);
509
                }
510
                cli()->alternateOver('   Title: ').cli()->primary(' '.$book['title']);
511
                if ($book['genre'] !== 'null') {
512
                    cli()->alternateOver('   Genre: ').cli()->primary(' '.$book['genre']);
513
                }
514
            }
515
516
            $book['cover'] = $ri->saveImage($bookId, $book['coverurl'], $this->imgSavePath, 250, 250);
517
        } elseif ($this->echooutput) {
518
            cli()->header('Nothing to update: ').
519
            cli()->header($book['author'].
520
                ' - '.
521
                $book['title']);
522
        }
523
524
        return $bookId;
525
    }
526
527
    /**
528
     * Fetch book properties from iTunes.
529
     *
530
     * @return array|bool
531
     */
532
    public function fetchItunesBookProperties(string $bookInfo)
533
    {
534
        $itunes = new ItunesService;
535
        $iTunesBook = $itunes->findEbook($bookInfo);
536
537
        if ($iTunesBook === null) {
538
            cli()->notice('Could not find a match on iTunes!');
539
540
            return false;
541
        }
542
543
        cli()->info('Found matching title: '.$iTunesBook['name']);
544
545
        $book = [
546
            'title' => $iTunesBook['name'],
547
            'author' => $iTunesBook['author'],
548
            'asin' => $iTunesBook['id'],
549
            'isbn' => 'null',
550
            'ean' => 'null',
551
            'url' => $iTunesBook['store_url'],
552
            'salesrank' => '',
553
            'publisher' => '',
554
            'pages' => '',
555
            'coverurl' => ! empty($iTunesBook['cover']) ? $iTunesBook['cover'] : '',
556
            'genre' => is_array($iTunesBook['genres']) ? implode(', ', $iTunesBook['genres']) : $iTunesBook['genre'],
557
            'overview' => strip_tags($iTunesBook['description'] ?? ''),
558
            'publishdate' => $iTunesBook['release_date'],
559
        ];
560
561
        if (! empty($book['coverurl'])) {
562
            $book['cover'] = 1;
563
        } else {
564
            $book['cover'] = 0;
565
        }
566
567
        return $book;
568
    }
569
}
570