1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Models;
7
8use App\Enums\Ruleset;
9use App\Exceptions\BeatmapProcessorException;
10use App\Exceptions\ImageProcessorServiceException;
11use App\Exceptions\InvariantException;
12use App\Jobs\CheckBeatmapsetCovers;
13use App\Jobs\EsDocument;
14use App\Jobs\Notifications\BeatmapsetDiscussionLock;
15use App\Jobs\Notifications\BeatmapsetDiscussionUnlock;
16use App\Jobs\Notifications\BeatmapsetDisqualify;
17use App\Jobs\Notifications\BeatmapsetLove;
18use App\Jobs\Notifications\BeatmapsetQualify;
19use App\Jobs\Notifications\BeatmapsetRank;
20use App\Jobs\Notifications\BeatmapsetRemoveFromLoved;
21use App\Jobs\Notifications\BeatmapsetResetNominations;
22use App\Jobs\RemoveBeatmapsetBestScores;
23use App\Jobs\RemoveBeatmapsetSoloScores;
24use App\Libraries\BBCodeFromDB;
25use App\Libraries\Beatmapset\BeatmapsetMainRuleset;
26use App\Libraries\Beatmapset\NominateBeatmapset;
27use App\Libraries\Commentable;
28use App\Libraries\Elasticsearch\Indexable;
29use App\Libraries\ImageProcessorService;
30use App\Libraries\StorageUrl;
31use App\Libraries\Transactions\AfterCommit;
32use App\Models\Forum\Post;
33use App\Traits\Memoizes;
34use App\Traits\Validatable;
35use App\Transformers\BeatmapsetTransformer;
36use Cache;
37use Carbon\Carbon;
38use DB;
39use Ds\Set;
40use Illuminate\Database\Eloquent\Builder;
41use Illuminate\Database\Eloquent\Relations\BelongsToMany;
42use Illuminate\Database\Eloquent\SoftDeletes;
43use Illuminate\Database\QueryException;
44
45/**
46 * @property bool $active
47 * @property \Illuminate\Database\Eloquent\Collection $allBeatmaps Beatmap
48 * @property int $approved
49 * @property \Carbon\Carbon|null $approved_date
50 * @property int|null $approvedby_id
51 * @property User|null $approver
52 * @property string $artist
53 * @property string $artist_unicode
54 * @property \Illuminate\Database\Eloquent\Collection $beatmapDiscussions BeatmapDiscussion
55 * @property \Illuminate\Database\Eloquent\Collection $beatmaps Beatmap
56 * @property int $beatmapset_id
57 * @property \Illuminate\Database\Eloquent\Collection $beatmapsetNominations BeatmapsetNomination
58 * @property mixed|null $body_hash
59 * @property float $bpm
60 * @property bool $comment_locked
61 * @property string $commentable_identifier
62 * @property Comment $comments
63 * @property \Carbon\Carbon|null $cover_updated_at
64 * @property string $creator
65 * @property \Illuminate\Database\Eloquent\Collection $defaultBeatmaps Beatmap
66 * @property \Carbon\Carbon|null $deleted_at
67 * @property string|null $difficulty_names
68 * @property bool $discussion_locked
69 * @property string $displaytitle
70 * @property bool $download_disabled
71 * @property string|null $download_disabled_url
72 * @property string[]|null $eligible_main_rulesets
73 * @property bool $epilepsy
74 * @property \Illuminate\Database\Eloquent\Collection $events BeatmapsetEvent
75 * @property int $favourite_count
76 * @property \Illuminate\Database\Eloquent\Collection $favourites FavouriteBeatmapset
77 * @property string|null $filename
78 * @property int $filesize
79 * @property int|null $filesize_novideo
80 * @property Genre $genre
81 * @property int $genre_id
82 * @property mixed|null $header_hash
83 * @property int $hype
84 * @property Language $language
85 * @property int $language_id
86 * @property \Carbon\Carbon $last_update
87 * @property int $nominations
88 * @property bool $nsfw
89 * @property int $offset
90 * @property mixed|null $osz2_hash
91 * @property int $play_count
92 * @property int $previous_queue_duration
93 * @property \Carbon\Carbon|null $queued_at
94 * @property float $rating
95 * @property string $source
96 * @property bool $spotlight
97 * @property int $star_priority
98 * @property bool $storyboard
99 * @property \Carbon\Carbon|null $submit_date
100 * @property string $tags
101 * @property \Carbon\Carbon|null $thread_icon_date
102 * @property int $thread_id
103 * @property string $title
104 * @property string $title_unicode
105 * @property ArtistTrack $track
106 * @property int|null $track_id
107 * @property User $user
108 * @property \Illuminate\Database\Eloquent\Collection $userRatings BeatmapsetUserRating
109 * @property int $user_id
110 * @property int $versions_available
111 * @property bool $video
112 * @property \Illuminate\Database\Eloquent\Collection $watches BeatmapsetWatch
113 */
114class Beatmapset extends Model implements AfterCommit, Commentable, Indexable, Traits\ReportableInterface
115{
116 use Memoizes, SoftDeletes, Traits\CommentableDefaults, Traits\Es\BeatmapsetSearch, Traits\Reportable, Validatable;
117
118 const CASTS = [
119 'active' => 'boolean',
120 'approved_date' => 'datetime',
121 'comment_locked' => 'boolean',
122 'cover_updated_at' => 'datetime',
123 'deleted_at' => 'datetime',
124 'discussion_locked' => 'boolean',
125 'download_disabled' => 'boolean',
126 'eligible_main_rulesets' => 'array',
127 'epilepsy' => 'boolean',
128 'last_update' => 'datetime',
129 'nsfw' => 'boolean',
130 'queued_at' => 'datetime',
131 'spotlight' => 'boolean',
132 'storyboard' => 'boolean',
133 'submit_date' => 'datetime',
134 'thread_icon_date' => 'datetime',
135 'video' => 'boolean',
136 ];
137
138 const HYPEABLE_STATES = [-1, 0, 3];
139
140 const MAX_FIELD_LENGTHS = [
141 'tags' => 1000,
142 ];
143
144 const STATES = [
145 'graveyard' => -2,
146 'wip' => -1,
147 'pending' => 0,
148 'ranked' => 1,
149 'approved' => 2,
150 'qualified' => 3,
151 'loved' => 4,
152 ];
153
154 public $timestamps = false;
155
156 protected $attributes = [
157 'hype' => 0,
158 'nominations' => 0,
159 'previous_queue_duration' => 0,
160 ];
161
162 protected $casts = self::CASTS;
163 protected $primaryKey = 'beatmapset_id';
164 protected $table = 'osu_beatmapsets';
165
166 public static function coverSizes()
167 {
168 static $sizes;
169
170 if ($sizes === null) {
171 $sizes = [];
172 foreach (['cover', 'card', 'list', 'slimcover'] as $shape) {
173 foreach (['', '@2x'] as $scale) {
174 $sizes[] = "{$shape}{$scale}";
175 }
176 }
177 }
178
179 return $sizes;
180 }
181
182 public static function popular()
183 {
184 $ids = cache_remember_mutexed('popularBeatmapsetIds', 300, [], function () {
185 return static::popularIds();
186 });
187
188 return static::whereIn('beatmapset_id', $ids)->orderByField('beatmapset_id', $ids);
189 }
190
191 public static function popularIds()
192 {
193 $recentIds = static::ranked()
194 ->where('approved_date', '>', now()->subDays(30))
195 ->where('nsfw', false)
196 ->select('beatmapset_id');
197
198 return FavouriteBeatmapset::select('beatmapset_id')
199 ->selectRaw('COUNT(*) as cnt')
200 ->whereIn('beatmapset_id', $recentIds)
201 ->where('dateadded', '>', now()->subDays(7))->groupBy('beatmapset_id')
202 ->orderBy('cnt', 'DESC')
203 ->limit(5)
204 ->pluck('beatmapset_id')
205 ->toArray();
206 }
207
208 public static function latestRanked($count = 5)
209 {
210 // TODO: add filtering by game mode after mode-toggle UI/UX happens
211
212 return Cache::remember("beatmapsets_latest_{$count}", 3600, function () use ($count) {
213 // We union here so mysql can use indexes to speed this up
214 $ranked = self::ranked()->active()->where('nsfw', false)->orderBy('approved_date', 'desc')->limit($count);
215 $approved = self::approved()->active()->where('nsfw', false)->orderBy('approved_date', 'desc')->limit($count);
216
217 return $ranked->union($approved)->orderBy('approved_date', 'desc')->limit($count)->get();
218 });
219 }
220
221 public static function removeMetadataText($text)
222 {
223 // TODO: see if can be combined with description extraction thingy without
224 // exploding
225 static $pattern = '/^(.*?)-{15}/s';
226
227 return preg_replace($pattern, '', $text);
228 }
229
230 public static function isValidBackgroundImage(string $path): bool
231 {
232 $dimensions = read_image_properties($path);
233
234 static $validTypes = [
235 IMAGETYPE_GIF,
236 IMAGETYPE_JPEG,
237 IMAGETYPE_PNG,
238 ];
239
240 return isset($dimensions[2]) && in_array($dimensions[2], $validTypes, true);
241 }
242
243 public function beatmapDiscussions()
244 {
245 return $this->hasMany(BeatmapDiscussion::class);
246 }
247
248 public function bssProcessQueues()
249 {
250 return $this->hasMany(BssProcessQueue::class);
251 }
252
253 public function packs(): BelongsToMany
254 {
255 return $this->belongsToMany(BeatmapPack::class, BeatmapPackItem::class);
256 }
257
258 public function recentFavourites($limit = 50)
259 {
260 $favourites = FavouriteBeatmapset::where('beatmapset_id', $this->beatmapset_id)
261 ->with('user')
262 ->whereHas('user', function ($userQuery) {
263 $userQuery->default();
264 })
265 ->orderBy('dateadded', 'desc')
266 ->limit($limit)
267 ->get();
268
269 return $favourites->pluck('user');
270 }
271
272 public function watches()
273 {
274 return $this->hasMany(BeatmapsetWatch::class);
275 }
276
277 public function lastDiscussionTime()
278 {
279 $lastDiscussionUpdate = $this->beatmapDiscussions()->max('updated_at');
280 $lastEventUpdate = $this->events()->max('updated_at');
281
282 $lastDiscussionDate = $lastDiscussionUpdate !== null ? Carbon::parse($lastDiscussionUpdate) : null;
283 $lastEventDate = $lastEventUpdate !== null ? Carbon::parse($lastEventUpdate) : null;
284
285 return max($lastDiscussionDate, $lastEventDate);
286 }
287
288 public function scopeGraveyard($query)
289 {
290 return $query->where('approved', '=', self::STATES['graveyard']);
291 }
292
293 public function scopeLoved($query)
294 {
295 return $query->where('approved', '=', self::STATES['loved']);
296 }
297
298 public function scopeWip($query)
299 {
300 return $query->where('approved', '=', self::STATES['wip']);
301 }
302
303 public function scopeRanked($query)
304 {
305 return $query->where('approved', '=', self::STATES['ranked']);
306 }
307
308 public function scopeApproved($query)
309 {
310 return $query->where('approved', '=', self::STATES['approved']);
311 }
312
313 public function scopeQualified($query)
314 {
315 return $query->where('approved', '=', self::STATES['qualified']);
316 }
317
318 public function scopeDisqualified($query)
319 {
320 // uses the fact that disqualifying sets previous_queue_duration which is otherwise 0.
321 return $query
322 ->where('approved', self::STATES['pending'])
323 ->where('previous_queue_duration', '>', 0);
324 }
325
326 public function scopeNeverQualified($query)
327 {
328 return $query->withStates(['pending', 'wip'])->where('previous_queue_duration', 0);
329 }
330
331 public function scopeWithPackTags(Builder $query): Builder
332 {
333 $idColumn = $this->qualifyColumn('beatmapset_id');
334 $packTagColumn = (new BeatmapPack())->qualifyColumn('tag');
335 $packItemBeatmapsetIdColumn = (new BeatmapPackItem())->qualifyColumn('beatmapset_id');
336 $packQuery = BeatmapPack
337 ::selectRaw("GROUP_CONCAT({$packTagColumn} SEPARATOR ',')")
338 ->default()
339 ->whereRelation(
340 'items',
341 DB::raw($packItemBeatmapsetIdColumn),
342 DB::raw($idColumn),
343 )->toRawSql();
344
345 return $query
346 ->select('*')
347 ->selectRaw("({$packQuery}) as pack_tags");
348 }
349
350 public function scopeWithStates($query, $states)
351 {
352 return $query->whereIn('approved', array_map(fn ($s) => static::STATES[$s], $states));
353 }
354
355 public function scopeActive($query)
356 {
357 return $query->where('active', '=', true);
358 }
359
360 public function scopeHasMode($query, $modeInts)
361 {
362 if (!is_array($modeInts)) {
363 $modeInts = [$modeInts];
364 }
365
366 return $query->whereHas('beatmaps', function ($query) use ($modeInts) {
367 $query->whereIn('playmode', $modeInts);
368 });
369 }
370
371 public function scopeScoreable(Builder $query): void
372 {
373 $query->where('approved', '>', 0);
374 }
375
376 public function scopeToBeRanked(Builder $query, Ruleset $ruleset)
377 {
378 return $query->qualified()
379 ->withoutTrashed()
380 ->withModesForRanking($ruleset->value)
381 ->where('queued_at', '<', now()->subDays($GLOBALS['cfg']['osu']['beatmapset']['minimum_days_for_rank']))
382 ->whereDoesntHave('beatmapDiscussions', fn ($q) => $q->openIssues());
383 }
384
385 public function scopeWithModesForRanking($query, $modeInts)
386 {
387 if (!is_array($modeInts)) {
388 $modeInts = [$modeInts];
389 }
390
391 $query->whereHas('beatmaps', function ($query) use ($modeInts) {
392 $query->whereIn('playmode', $modeInts);
393 })->whereDoesntHave('beatmaps', function ($query) use ($modeInts) {
394 $query->where('playmode', '<', min($modeInts));
395 });
396 }
397
398 // one-time checks
399
400 public function isGraveyard()
401 {
402 return $this->approved === self::STATES['graveyard'];
403 }
404
405 public function isWIP()
406 {
407 return $this->approved === self::STATES['wip'];
408 }
409
410 public function isPending()
411 {
412 return $this->approved === self::STATES['pending'];
413 }
414
415 public function isRanked()
416 {
417 return $this->approved === self::STATES['ranked'];
418 }
419
420 public function isApproved()
421 {
422 return $this->approved === self::STATES['approved'];
423 }
424
425 public function isQualified()
426 {
427 return $this->approved === self::STATES['qualified'];
428 }
429
430 public function isLoved()
431 {
432 return $this->approved === self::STATES['loved'];
433 }
434
435 public function isLoveable()
436 {
437 return $this->approved <= 0;
438 }
439
440 public function isScoreable()
441 {
442 return $this->approved > 0;
443 }
444
445 public function allCoverURLs()
446 {
447 $urls = [];
448 $timestamp = $this->defaultCoverTimestamp();
449 foreach (self::coverSizes() as $size) {
450 $urls[$size] = $this->coverURL($size, $timestamp);
451 }
452
453 return $urls;
454 }
455
456 public function coverURL($coverSize = 'cover', $customTimestamp = null)
457 {
458 $timestamp = $customTimestamp ?? $this->defaultCoverTimestamp();
459
460 return StorageUrl::make(null, $this->coverPath()."{$coverSize}.jpg?{$timestamp}");
461 }
462
463 public function coverPath()
464 {
465 $id = $this->getKey() ?? 0;
466
467 return "beatmaps/{$id}/covers/";
468 }
469
470 public function storeCover($target_filename, $source_path)
471 {
472 \Storage::put($this->coverPath().$target_filename, file_get_contents($source_path));
473 }
474
475 public function downloadLimited()
476 {
477 return $this->download_disabled || $this->download_disabled_url !== null;
478 }
479
480 public function previewURL()
481 {
482 return '//b.ppy.sh/preview/'.$this->beatmapset_id.'.mp3';
483 }
484
485 public function removeCovers()
486 {
487 try {
488 \Storage::deleteDirectory($this->coverPath());
489 } catch (\Exception $e) {
490 // ignore errors
491 }
492
493 $this->update(['cover_updated_at' => $this->freshTimestamp()]);
494 }
495
496 public function fetchBeatmapsetArchive()
497 {
498 $oszFile = tmpfile();
499 $mirror = BeatmapMirror::getRandomFromList($GLOBALS['cfg']['osu']['beatmap_processor']['mirrors_to_use'])
500 ?? throw new \Exception('no available mirror');
501 $url = $mirror->generateURL($this, true);
502
503 if ($url === false) {
504 return false;
505 }
506
507 $curl = curl_init($url);
508 curl_setopt_array($curl, [
509 CURLOPT_FILE => $oszFile,
510 CURLOPT_TIMEOUT => 30,
511 ]);
512 curl_exec($curl);
513
514 if (curl_errno($curl) > 0) {
515 throw new BeatmapProcessorException('Failed downloading osz: '.curl_error($curl));
516 }
517
518 $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
519 // archive file is gone, nothing to do for now
520 if ($statusCode === 302) {
521 return false;
522 }
523 if ($statusCode !== 200) {
524 throw new BeatmapProcessorException('Failed downloading osz: HTTP Error '.$statusCode);
525 }
526
527 try {
528 return new BeatmapsetArchive(get_stream_filename($oszFile));
529 } catch (BeatmapProcessorException $e) {
530 // zip file is broken, nothing to do for now
531 return false;
532 }
533 }
534
535 public function regenerateCovers(array $sizesToRegenerate = null)
536 {
537 if (empty($sizesToRegenerate)) {
538 $sizesToRegenerate = static::coverSizes();
539 }
540
541 $osz = $this->fetchBeatmapsetArchive();
542 if ($osz === false) {
543 return false;
544 }
545
546 // clear existing covers
547 $this->removeCovers();
548
549 $beatmapFilenames = $this->beatmaps->map(function ($beatmap) {
550 return $beatmap->filename;
551 });
552
553 // scan for background images in $beatmapFilenames, with fallback enabled
554 $backgroundFilename = $osz->scanBeatmapsForBackground($beatmapFilenames->toArray(), true);
555
556 if ($backgroundFilename !== false) {
557 $tmpFile = tmpfile();
558 fwrite($tmpFile, $osz->readFile($backgroundFilename));
559 $backgroundImage = get_stream_filename($tmpFile);
560 if (!static::isValidBackgroundImage($backgroundImage)) {
561 return false;
562 }
563
564 // upload original image
565 $this->storeCover('raw.jpg', $backgroundImage);
566 $timestamp = time();
567
568 $processor = new ImageProcessorService();
569
570 // upload optimized full-size version
571 try {
572 $optimized = $processor->optimize($this->coverURL('raw', $timestamp));
573 } catch (ImageProcessorServiceException $e) {
574 if ($e->getCode() === ImageProcessorServiceException::INVALID_IMAGE) {
575 return false;
576 }
577 throw $e;
578 }
579 $this->storeCover('fullsize.jpg', get_stream_filename($optimized));
580
581 // use thumbnailer to generate (and then upload) all our variants
582 foreach ($sizesToRegenerate as $size) {
583 $resized = $processor->resize($this->coverURL('fullsize', $timestamp), $size);
584 $this->storeCover("$size.jpg", get_stream_filename($resized));
585 }
586 }
587
588 $this->update(['cover_updated_at' => $this->freshTimestamp()]);
589 }
590
591 public function allCoverImagesPresent()
592 {
593 foreach ($this->allCoverURLs() as $_size => $url) {
594 if (!check_url($url)) {
595 return false;
596 }
597 }
598
599 return true;
600 }
601
602 public function setApproved($state, $user, ?array $beatmapIds = null)
603 {
604 $currentTime = Carbon::now();
605 $oldScoreable = $this->isScoreable();
606 $approvedState = static::STATES[$state];
607 $beatmaps = $this->beatmaps();
608
609 if ($beatmapIds !== null) {
610 $beatmaps->whereKey($beatmapIds);
611
612 if ($beatmaps->count() !== count($beatmapIds)) {
613 throw new InvariantException('Invalid beatmap IDs');
614 }
615
616 // If the beatmapset will be scoreable, set all of the unspecified
617 // beatmaps currently "WIP" or "pending" to "graveyard". It doesn't
618 // make sense for any beatmaps to be in those states when they
619 // cannot be updated.
620 if ($approvedState > 0) {
621 $this
622 ->beatmaps()
623 ->whereKeyNot($beatmapIds)
624 ->whereIn('approved', [static::STATES['wip'], static::STATES['pending']])
625 ->update(['approved' => static::STATES['graveyard']]);
626 }
627 }
628
629 $beatmaps->update(['approved' => $approvedState]);
630
631 if ($this->isQualified() && $state === 'pending') {
632 $this->previous_queue_duration = ($this->queued_at ?? $this->approved_date)->diffinSeconds();
633 $this->queued_at = null;
634 } elseif ($this->isPending() && $state === 'qualified') {
635 // Check if any beatmaps where added after most recent invalidated nomination.
636 $disqualifyEvent = $this->disqualificationEvent();
637 if (
638 $disqualifyEvent !== null
639 && !(
640 (new Set($this->beatmaps()->pluck('beatmap_id')))
641 ->diff(new Set($disqualifyEvent->comment['beatmap_ids'] ?? []))
642 ->isEmpty())
643 ) {
644 $this->queued_at = $currentTime;
645 } else {
646 // amount of queue time to skip.
647 $maxAdjustment = ($GLOBALS['cfg']['osu']['beatmapset']['minimum_days_for_rank'] - 1) * 24 * 3600;
648 $adjustment = min($this->previous_queue_duration, $maxAdjustment);
649 $this->queued_at = $currentTime->copy()->subSeconds($adjustment);
650
651 // additional penalty for disqualification period, 1 day per week disqualified.
652 if ($disqualifyEvent !== null) {
653 $interval = $currentTime->diffInDays($disqualifyEvent->created_at);
654 $penaltyDays = min($interval / 7, $GLOBALS['cfg']['osu']['beatmapset']['maximum_disqualified_rank_penalty_days']);
655 $this->queued_at = $this->queued_at->addDays($penaltyDays);
656 }
657 }
658 }
659
660 $this->approved = $approvedState;
661
662 if ($this->approved > 0) {
663 $this->approved_date = $currentTime;
664 if ($user !== null) {
665 $this->approvedby_id = $user->user_id;
666 }
667 } else {
668 $this->approved_date = null;
669 $this->approvedby_id = null;
670 }
671
672 $this->save();
673
674 if ($this->isScoreable() !== $oldScoreable || $this->isRanked()) {
675 dispatch(new RemoveBeatmapsetBestScores($this));
676 dispatch(new RemoveBeatmapsetSoloScores($this));
677 }
678
679 if ($this->isScoreable() !== $oldScoreable) {
680 $this->userRatings()->delete();
681 }
682 }
683
684 public function discussionLock($user, $reason)
685 {
686 if ($this->discussion_locked) {
687 return;
688 }
689
690 DB::transaction(function () use ($user, $reason) {
691 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_LOCK, $user, $this, [
692 'reason' => $reason,
693 ])->saveOrExplode();
694 $this->update(['discussion_locked' => true]);
695 (new BeatmapsetDiscussionLock($this, $user))->dispatch();
696 });
697 }
698
699 public function discussionUnlock($user)
700 {
701 if (!$this->discussion_locked) {
702 return;
703 }
704
705 DB::transaction(function () use ($user) {
706 BeatmapsetEvent::log(BeatmapsetEvent::DISCUSSION_UNLOCK, $user, $this)->saveOrExplode();
707 $this->update(['discussion_locked' => false]);
708 (new BeatmapsetDiscussionUnlock($this, $user))->dispatch();
709 });
710 }
711
712 public function disqualifyOrResetNominations(User $user, BeatmapDiscussion $discussion)
713 {
714 $event = BeatmapsetEvent::DISQUALIFY;
715 $notificationClass = BeatmapsetDisqualify::class;
716 if ($this->isPending()) {
717 $event = BeatmapsetEvent::NOMINATION_RESET;
718 $notificationClass = BeatmapsetResetNominations::class;
719 } else if (!$this->isQualified()) {
720 throw new InvariantException('invalid state');
721 }
722
723 $this->getConnection()->transaction(function () use ($discussion, $event, $notificationClass, $user) {
724 $nominators = User::whereIn('user_id', $this->beatmapsetNominations()->current()->select('user_id'))->get();
725 $extraData = ['nominator_ids' => $nominators->pluck('user_id')];
726 if ($event === BeatmapsetEvent::DISQUALIFY) {
727 $extraData['beatmap_ids'] = $this->beatmaps()->pluck('beatmap_id');
728 }
729
730 BeatmapsetEvent::log($event, $user, $discussion, $extraData)->saveOrExplode();
731 foreach ($nominators as $nominator) {
732 BeatmapsetEvent::log(
733 BeatmapsetEvent::NOMINATION_RESET_RECEIVED,
734 $nominator,
735 $discussion,
736 ['source_user_id' => $user->getKey(), 'source_user_username' => $user->username]
737 )->saveOrExplode();
738 }
739
740 $this->beatmapsetNominations()->current()->update(['reset' => true, 'reset_at' => now(), 'reset_user_id' => $user->getKey()]);
741
742 if ($event === BeatmapsetEvent::DISQUALIFY) {
743 $this->setApproved('pending', $user);
744 }
745
746 $this->refreshCache(true);
747
748 (new $notificationClass($this, $user))->dispatch();
749 });
750 }
751
752 public function qualify(User $user)
753 {
754 if (!$this->isPending()) {
755 throw new InvariantException('cannot qualify a beatmapset not in a pending state.');
756 }
757
758 $this->getConnection()->transaction(function () use ($user) {
759 // Reset the queue timer if the beatmapset was previously disqualified,
760 // and any of the current nominators were not part of the most recent disqualified nominations.
761 $disqualifyEvent = $this->events()->disqualifications()->last();
762 if ($disqualifyEvent !== null) {
763 $previousNominators = new Set($disqualifyEvent->comment['nominator_ids']);
764 $currentNominators = new Set($this->beatmapsetNominations()->current()->pluck('user_id'));
765 // Uses xor to make problems during testing stand out, like the number of nominations in the test being wrong.
766 if (!$currentNominators->xor($previousNominators)->isEmpty()) {
767 $this->update(['previous_queue_duration' => 0]);
768 }
769 }
770
771 $this->events()->create(['type' => BeatmapsetEvent::QUALIFY]);
772
773 $this->setApproved('qualified', $user);
774 $this->bssProcessQueues()->create();
775
776 // global event
777 Event::generate('beatmapsetApprove', ['beatmapset' => $this]);
778
779 // enqueue a cover check job to ensure cover images are all present
780 $job = (new CheckBeatmapsetCovers($this))->onQueue('beatmap_high');
781 dispatch($job);
782
783 (new BeatmapsetQualify($this, $user))->dispatch();
784 });
785 }
786
787 public function nominate(User $user, array $playmodes = [])
788 {
789 (new NominateBeatmapset($this, $user, $playmodes))->handle();
790 }
791
792 public function love(User $user, ?array $beatmapIds = null)
793 {
794 if (!$this->isLoveable()) {
795 return [
796 'result' => false,
797 'message' => osu_trans('beatmaps.nominations.incorrect_state'),
798 ];
799 }
800
801 $this->getConnection()->transaction(function () use ($user, $beatmapIds) {
802 $this->events()->create(['type' => BeatmapsetEvent::LOVE, 'user_id' => $user->user_id]);
803
804 $this->setApproved('loved', $user, $beatmapIds);
805 $this->bssProcessQueues()->create();
806
807 Event::generate('beatmapsetApprove', ['beatmapset' => $this]);
808
809 dispatch((new CheckBeatmapsetCovers($this))->onQueue('beatmap_high'));
810
811 (new BeatmapsetLove($this, $user))->dispatch();
812 });
813
814 return [
815 'result' => true,
816 ];
817 }
818
819 public function removeFromLoved(User $user, string $reason)
820 {
821 if (!$this->isLoved()) {
822 return [
823 'result' => false,
824 'message' => osu_trans('beatmaps.nominations.incorrect_state'),
825 ];
826 }
827
828 $this->getConnection()->transaction(function () use ($user, $reason) {
829 BeatmapsetEvent::log(BeatmapsetEvent::REMOVE_FROM_LOVED, $user, $this, compact('reason'))->saveOrExplode();
830
831 $this->setApproved('pending', $user);
832
833 (new BeatmapsetRemoveFromLoved($this, $user))->dispatch();
834 });
835
836 return [
837 'result' => true,
838 ];
839 }
840
841 public function rank()
842 {
843 if (
844 !$this->isQualified()
845 || $this->beatmapDiscussions()->openIssues()->exists()
846 ) {
847 return false;
848 }
849
850 DB::transaction(function () {
851 $this->events()->create(['type' => BeatmapsetEvent::RANK]);
852
853 $this->update(['play_count' => 0]);
854 $this->beatmaps()->update(['playcount' => 0, 'passcount' => 0]);
855 $this->setApproved('ranked', null);
856 $this->bssProcessQueues()->create();
857
858 // global event
859 Event::generate('beatmapsetApprove', ['beatmapset' => $this]);
860
861 // enqueue a cover check job to ensure cover images are all present
862 $job = (new CheckBeatmapsetCovers($this))->onQueue('beatmap_high');
863 dispatch($job);
864
865 (new BeatmapsetRank($this))->dispatch();
866 });
867
868 return true;
869 }
870
871 public function favourite($user)
872 {
873 DB::transaction(function () use ($user) {
874 try {
875 FavouriteBeatmapset::create([
876 'user_id' => $user->user_id,
877 'beatmapset_id' => $this->beatmapset_id,
878 ]);
879 } catch (QueryException $e) {
880 if (is_sql_unique_exception($e)) {
881 return;
882 } else {
883 throw $e;
884 }
885 }
886
887 $this->favourite_count = DB::raw('favourite_count + 1');
888 $this->save();
889 });
890 }
891
892 public function unfavourite($user)
893 {
894 if ($user === null || !$user->hasFavourited($this)) {
895 return;
896 }
897
898 DB::transaction(function () use ($user) {
899 $deleted = $this->favourites()->where('user_id', $user->user_id)
900 ->delete();
901
902 $this->favourite_count = db_unsigned_increment('favourite_count', -$deleted);
903 $this->save();
904 });
905 }
906
907 /*
908 |--------------------------------------------------------------------------
909 | Relationships
910 |--------------------------------------------------------------------------
911 |
912 | One set has many beatmaps, which in turn have many mods
913 | One set has a single creator.
914 |
915 */
916
917 public function allBeatmaps()
918 {
919 return $this->hasMany(Beatmap::class)->withTrashed();
920 }
921
922 public function beatmaps()
923 {
924 return $this->hasMany(Beatmap::class);
925 }
926
927 public function beatmapsetNominations()
928 {
929 return $this->hasMany(BeatmapsetNomination::class);
930 }
931
932 public function beatmapsetNominationsCurrent()
933 {
934 return $this->beatmapsetNominations()->current();
935 }
936
937 public function events()
938 {
939 return $this->hasMany(BeatmapsetEvent::class);
940 }
941
942 public function genre()
943 {
944 return $this->belongsTo(Genre::class, 'genre_id');
945 }
946
947 public function language()
948 {
949 return $this->belongsTo(Language::class, 'language_id');
950 }
951
952 public function getAttribute($key)
953 {
954 return match ($key) {
955 'approved',
956 'approvedby_id',
957 'artist',
958 'beatmapset_id',
959 'body_hash',
960 'bpm',
961 'creator',
962 'difficulty_names',
963 'discussion_enabled',
964 'displaytitle',
965 'download_disabled_url',
966 'favourite_count',
967 'filename',
968 'filesize',
969 'filesize_novideo',
970 'genre_id',
971 'header_hash',
972 'hype',
973 'language_id',
974 'laravel_through_key', // added by hasOneThrough relation in BeatmapDiscussionPost
975 'nominations',
976 'offset',
977 'osz2_hash',
978 'play_count',
979 'previous_queue_duration',
980 'rating',
981 'source',
982 'star_priority',
983 'storyboard_hash',
984 'tags',
985 'thread_id',
986 'title',
987 'track_id',
988 'user_id',
989 'versions_available' => $this->getRawAttribute($key),
990
991 'eligible_main_rulesets' => $this->getArray($key),
992
993 'approved_date',
994 'cover_updated_at',
995 'deleted_at',
996 'last_update',
997 'queued_at',
998 'submit_date',
999 'thread_icon_date' => $this->getTimeFast($key),
1000
1001 'approved_date_json',
1002 'cover_updated_at_json',
1003 'deleted_at_json',
1004 'last_update_json',
1005 'queued_at_json',
1006 'submit_date_json',
1007 'thread_icon_date_json' => $this->getJsonTimeFast($key),
1008
1009 'active',
1010 'comment_locked',
1011 'discussion_locked',
1012 'download_disabled',
1013 'epilepsy',
1014 'nsfw',
1015 'spotlight',
1016 'storyboard',
1017 'video' => (bool) $this->getRawAttribute($key),
1018
1019 'artist_unicode' => $this->getArtistUnicode(),
1020 'commentable_identifier' => $this->getCommentableIdentifierAttribute(),
1021 'pack_tags' => $this->getPackTags(),
1022 'title_unicode' => $this->getTitleUnicode(),
1023
1024 'allBeatmaps',
1025 'approver',
1026 'beatmapDiscussions',
1027 'beatmaps',
1028 'beatmapsetNominations',
1029 'beatmapsetNominationsCurrent',
1030 'bssProcessQueues',
1031 'comments',
1032 'defaultBeatmaps',
1033 'descriptionPost',
1034 'events',
1035 'favourites',
1036 'genre',
1037 'language',
1038 'packs',
1039 'reportedIn',
1040 'topic',
1041 'track',
1042 'user',
1043 'userRatings',
1044 'watches' => $this->getRelationValue($key),
1045 };
1046 }
1047
1048 public function requiredHype()
1049 {
1050 return $GLOBALS['cfg']['osu']['beatmapset']['required_hype'];
1051 }
1052
1053 public function commentLocked(): bool
1054 {
1055 return $this->comment_locked || $this->downloadLimited();
1056 }
1057
1058 public function commentableTitle()
1059 {
1060 return $this->title;
1061 }
1062
1063 public function canBeHyped()
1064 {
1065 return in_array($this->approved, static::HYPEABLE_STATES, true);
1066 }
1067
1068 public function validateHypeBy($user)
1069 {
1070 if ($user === null) {
1071 $message = 'guest';
1072 } else {
1073 if ($this->user_id === $user->getKey()) {
1074 $message = 'owner';
1075 } else {
1076 if ($this->discussion_locked) {
1077 $message = 'discussion_locked';
1078 } else {
1079 $hyped = $this
1080 ->beatmapDiscussions()
1081 ->withoutTrashed()
1082 ->ofType('hype')
1083 ->where('user_id', '=', $user->getKey())
1084 ->exists();
1085
1086 if ($hyped) {
1087 $message = 'hyped';
1088 } elseif ($user->remainingHype() <= 0) {
1089 $message = 'limit_exceeded';
1090 }
1091 }
1092 }
1093 }
1094
1095 if (isset($message)) {
1096 return [
1097 'result' => false,
1098 'message' => osu_trans("model_validation.beatmapset_discussion.hype.{$message}"),
1099 ];
1100 } else {
1101 return ['result' => true];
1102 }
1103 }
1104
1105 public function currentNominationCount()
1106 {
1107 if ($this->isLegacyNominationMode()) {
1108 return $this->beatmapsetNominations()->current()->count();
1109 }
1110
1111 $currentNominations = array_fill_keys($this->playmodesStr(), 0);
1112
1113 $nominations = $this->beatmapsetNominations()->current()->get();
1114 foreach ($nominations as $nomination) {
1115 foreach ($nomination->modes as $mode) {
1116 if (!isset($currentNominations[$mode])) {
1117 continue;
1118 }
1119
1120 $currentNominations[$mode]++;
1121 }
1122 }
1123
1124 return $currentNominations;
1125 }
1126
1127 public function isLegacyNominationMode()
1128 {
1129 return $this->memoize(__FUNCTION__, function () {
1130 return $this->beatmapsetNominations()->current()->whereNull('modes')->exists();
1131 });
1132 }
1133
1134 public function hasNominations()
1135 {
1136 return $this->beatmapsetNominations()->current()->exists();
1137 }
1138
1139 /**
1140 * This will cause additional query if `difficulty_names` column is blank and beatmaps relation isn't preloaded.
1141 */
1142 public function playmodes()
1143 {
1144 $rawPlaymodes = present($this->difficulty_names)
1145 ? collect(explode(',', $this->difficulty_names))
1146 ->map(fn (string $name) => (int) substr($name, strrpos($name, '@') + 1))
1147 : $this->beatmaps->pluck('playmode');
1148
1149 return $rawPlaymodes->unique()->values();
1150 }
1151
1152 public function playmodeCount()
1153 {
1154 return $this->memoize(__FUNCTION__, function () {
1155 return $this->playmodes()->count();
1156 });
1157 }
1158
1159 public function playmodesStr()
1160 {
1161 return array_map(
1162 static function ($ele) {
1163 return Beatmap::modeStr($ele);
1164 },
1165 $this->playmodes()->toArray()
1166 );
1167 }
1168
1169 /**
1170 * Returns all the Rulesets that are eligible to be the main ruleset.
1171 * This will _not_ query the current beatmapset nominations if there is an existing value in `eligible_main_rulesets`
1172 *
1173 * @return string[]
1174 */
1175 public function eligibleMainRulesets(): array
1176 {
1177 $rulesets = $this->eligible_main_rulesets;
1178
1179 if ($rulesets === null) {
1180 $rulesets = (new BeatmapsetMainRuleset($this))->currentEligibleSorted();
1181 $this->update(['eligible_main_rulesets' => $rulesets]);
1182 }
1183
1184 return $rulesets;
1185 }
1186
1187 /**
1188 * Returns the main Ruleset.
1189 * This calls `eligibleMainRulesets()` and has the same nomination querying behaviour.
1190 *
1191 * @return string|null returns the main Ruleset if there is one eligible Ruleset; `null`, otherwise.
1192 */
1193 public function mainRuleset(): ?string
1194 {
1195 $eligible = $this->eligibleMainRulesets();
1196
1197 return count($eligible) === 1 ? $eligible[0] : null;
1198 }
1199
1200 public function rankingQueueStatus()
1201 {
1202 if (!$this->isQualified()) {
1203 return;
1204 }
1205
1206 $modes = $this->playmodes()->toArray();
1207
1208 $queueSize = static::qualified()
1209 ->withModesForRanking($modes)
1210 ->where('queued_at', '<', $this->queued_at)
1211 ->count();
1212 $days = ceil($queueSize / $GLOBALS['cfg']['osu']['beatmapset']['rank_per_day']);
1213
1214 $minDays = $GLOBALS['cfg']['osu']['beatmapset']['minimum_days_for_rank'] - $this->queued_at->diffInDays();
1215 $days = max($minDays, $days);
1216
1217 return [
1218 'eta' => $days > 0 ? Carbon::now()->addDays($days) : null,
1219 'position' => $queueSize + 1,
1220 ];
1221 }
1222
1223 public function disqualificationEvent()
1224 {
1225 return $this->memoize(__FUNCTION__, function () {
1226 return $this->events()->disqualifications()->orderBy('created_at', 'desc')->first();
1227 });
1228 }
1229
1230 public function resetEvent()
1231 {
1232 return $this->memoize(__FUNCTION__, function () {
1233 return $this->events()->disqualificationAndNominationResetEvents()->orderBy('created_at', 'desc')->first();
1234 });
1235 }
1236
1237 public function nominationsByType()
1238 {
1239 $nominations = $this->beatmapsetNominations()
1240 ->current()
1241 ->with('user')
1242 ->get();
1243
1244 $result = [
1245 'full' => [],
1246 'limited' => [],
1247 ];
1248
1249 foreach ($nominations as $nomination) {
1250 $userNominationModes = $nomination->user->nominationModes();
1251 // no permission
1252 if ($userNominationModes === null) {
1253 continue;
1254 }
1255
1256 // legacy nomination, only check group
1257 if ($nomination->modes === null) {
1258 if ($nomination->user->isLimitedBN()) {
1259 $result['limited'][] = null;
1260 } else if ($nomination->user->isBNG() || $nomination->user->isNAT()) {
1261 $result['full'][] = null;
1262 }
1263 } else {
1264 foreach ($nomination->modes as $mode) {
1265 $nominationType = $userNominationModes[$mode] ?? null;
1266 if ($nominationType !== null) {
1267 $result[$nominationType][] = $mode;
1268 }
1269 }
1270 }
1271 }
1272
1273 return $result;
1274 }
1275
1276 public function status()
1277 {
1278 return array_search_null($this->approved, static::STATES);
1279 }
1280
1281 public function defaultDiscussionJson()
1282 {
1283 $this->loadMissing([
1284 'allBeatmaps',
1285 'allBeatmaps.beatmapOwners.user',
1286 'allBeatmaps.user', // TODO: for compatibility only, should migrate user_id to BeatmapOwner.
1287 'beatmapDiscussions.beatmapDiscussionPosts',
1288 'beatmapDiscussions.beatmapDiscussionVotes',
1289 ]);
1290
1291 foreach ($this->allBeatmaps as $beatmap) {
1292 $beatmap->setRelation('beatmapset', $this);
1293 }
1294
1295 $beatmapsByKey = $this->allBeatmaps->keyBy('beatmap_id');
1296
1297 foreach ($this->beatmapDiscussions as $discussion) {
1298 // set relations for priv checks.
1299 $discussion->setRelation('beatmapset', $this);
1300
1301 if ($discussion->beatmap_id !== null) {
1302 $discussion->setRelation('beatmap', $beatmapsByKey[$discussion->beatmap_id]);
1303 }
1304 }
1305
1306 return json_item(
1307 $this,
1308 new BeatmapsetTransformer(),
1309 [
1310 'beatmaps:with_trashed.owners',
1311 'current_user_attributes',
1312 'discussions',
1313 'discussions.current_user_attributes',
1314 'discussions.posts',
1315 'discussions.votes',
1316 'eligible_main_rulesets',
1317 'events',
1318 'nominations',
1319 'related_users',
1320 'related_users.groups',
1321 ]
1322 );
1323 }
1324
1325 public function defaultBeatmaps()
1326 {
1327 return $this->hasMany(Beatmap::class)->default();
1328 }
1329
1330 public function user()
1331 {
1332 return $this->belongsTo(User::class, 'user_id');
1333 }
1334
1335 public function approver()
1336 {
1337 return $this->belongsTo(User::class, 'approvedby_id');
1338 }
1339
1340 public function descriptionPost()
1341 {
1342 return $this->hasOneThrough(
1343 Forum\Post::class,
1344 Forum\Topic::class,
1345 'topic_id',
1346 'post_id',
1347 'thread_id',
1348 'topic_first_post_id',
1349 );
1350 }
1351
1352 public function topic()
1353 {
1354 return $this->belongsTo(Forum\Topic::class, 'thread_id');
1355 }
1356
1357 public function track()
1358 {
1359 return $this->belongsTo(ArtistTrack::class);
1360 }
1361
1362 public function userRatings()
1363 {
1364 return $this->hasMany(BeatmapsetUserRating::class);
1365 }
1366
1367 public function ratingsCount()
1368 {
1369 $ratings = [];
1370
1371 for ($i = 0; $i <= 10; $i++) {
1372 $ratings[$i] = 0;
1373 }
1374
1375 if ($this->relationLoaded('userRatings')) {
1376 foreach ($this->userRatings as $userRating) {
1377 $ratings[$userRating->rating]++;
1378 }
1379 } else {
1380 $userRatings = $this->userRatings()
1381 ->select('rating', \DB::raw('count(*) as count'))
1382 ->groupBy('rating')
1383 ->get();
1384
1385 foreach ($userRatings as $rating) {
1386 $ratings[$rating->rating] = $rating->count;
1387 }
1388 }
1389
1390 return $ratings;
1391 }
1392
1393 public function favourites()
1394 {
1395 return $this->hasMany(FavouriteBeatmapset::class);
1396 }
1397
1398 public function description()
1399 {
1400 return $this->getBBCode()?->toHTML();
1401 }
1402
1403 public function editableDescription()
1404 {
1405 return $this->getBBCode()?->toEditor();
1406 }
1407
1408 public function updateDescription($bbcode, $user)
1409 {
1410 return DB::transaction(function () use ($bbcode, $user) {
1411 $post = $this->descriptionPost;
1412
1413 if ($post === null) {
1414 $forum = Forum\Forum::findOrFail($GLOBALS['cfg']['osu']['forum']['beatmap_description_forum_id']);
1415 $title = $this->artist.' - '.$this->title;
1416
1417 $topic = Forum\Topic::createNew($forum, [
1418 'title' => $title,
1419 'user' => $user,
1420 'body' => '---------------',
1421 ]);
1422 $topic->lock();
1423 $this->update(['thread_id' => $topic->getKey()]);
1424 $post = $topic->firstPost;
1425 }
1426
1427 $split = preg_split('/-{15}/', $post->post_text, 2);
1428
1429 $options = [
1430 'withGallery' => true,
1431 'ignoreLineHeight' => true,
1432 ];
1433
1434 $header = new BBCodeFromDB($split[0], $post->bbcode_uid, $options);
1435 $newBody = $header->toEditor()."---------------\n".ltrim($bbcode);
1436
1437 return $post
1438 ->skipBeatmapPostRestrictions()
1439 ->update([
1440 'post_text' => $newBody,
1441 'post_edit_user' => $user === null ? null : $user->getKey(),
1442 ]);
1443 });
1444 }
1445
1446 private function extractDescription($post)
1447 {
1448 // Any description (after the first match) that matches
1449 // '/-{15}/' within its body doesn't get split anymore,
1450 // and gets stored in $split[1] anyways
1451 $split = preg_split('/-{15}/', $post->post_text, 2);
1452
1453 // Return empty description if the pattern was not found
1454 // (mostly older beatmapsets)
1455 return ltrim($split[1] ?? '');
1456 }
1457
1458 private function getBBCode()
1459 {
1460 $post = $this->descriptionPost ?? new Post();
1461 $description = $this->extractDescription($post);
1462
1463 $options = [
1464 'withGallery' => true,
1465 'ignoreLineHeight' => true,
1466 ];
1467
1468 return new BBCodeFromDB($description, $post->bbcode_uid, $options);
1469 }
1470
1471 public function getDisplayArtist(?User $user)
1472 {
1473 $profileCustomization = UserProfileCustomization::forUser($user);
1474
1475 return $profileCustomization['beatmapset_title_show_original']
1476 ? $this->artist_unicode
1477 : $this->artist;
1478 }
1479
1480 public function getDisplayTitle(?User $user)
1481 {
1482 $profileCustomization = UserProfileCustomization::forUser($user);
1483
1484 return $profileCustomization['beatmapset_title_show_original']
1485 ? $this->title_unicode
1486 : $this->title;
1487 }
1488
1489 public function freshHype()
1490 {
1491 return $this
1492 ->beatmapDiscussions()
1493 ->withoutTrashed()
1494 ->ofType('hype')
1495 ->count();
1496 }
1497
1498 /**
1499 * Refreshes the cached values of the beatmapset.
1500 * Resetting eligible main rulesets should only be tiggered if a change to the beatmapset can cause the main ruleset to change,
1501 * or existing nominations are invalidated.
1502 */
1503 public function refreshCache(bool $resetEligibleMainRulesets = false): void
1504 {
1505 $this->update([
1506 'eligible_main_rulesets' => $resetEligibleMainRulesets ? null : (new BeatmapsetMainRuleset($this))->currentEligibleSorted(),
1507 'hype' => $this->freshHype(),
1508 'nominations' => $this->isLegacyNominationMode() ? $this->currentNominationCount() : array_sum(array_values($this->currentNominationCount())),
1509 ]);
1510 }
1511
1512 public function afterCommit()
1513 {
1514 dispatch(new EsDocument($this));
1515 }
1516
1517 public function notificationCover()
1518 {
1519 return $this->coverURL('card');
1520 }
1521
1522 public function validationErrorsTranslationPrefix(): string
1523 {
1524 return 'beatmapset';
1525 }
1526
1527 public function isValid()
1528 {
1529 $this->validationErrors()->reset();
1530
1531 if ($this->isDirty('language_id') && ($this->language === null || $this->language_id === 0)) {
1532 $this->validationErrors()->add('language_id', 'invalid');
1533 }
1534
1535 if ($this->isDirty('genre_id') && ($this->genre === null || $this->genre_id === 0)) {
1536 $this->validationErrors()->add('genre_id', 'invalid');
1537 }
1538
1539 $this->validateDbFieldLengths();
1540
1541 return $this->validationErrors()->isEmpty();
1542 }
1543
1544 public function save(array $options = [])
1545 {
1546 return $this->isValid() && parent::save($options);
1547 }
1548
1549 public function url()
1550 {
1551 return route('beatmapsets.show', $this);
1552 }
1553
1554 protected function newReportableExtraParams(): array
1555 {
1556 return [
1557 'reason' => 'UnwantedContent',
1558 'user_id' => $this->user_id,
1559 ];
1560 }
1561
1562 protected static function boot()
1563 {
1564 parent::boot();
1565
1566 static::addGlobalScope('active', function ($builder) {
1567 $builder->active();
1568 });
1569 }
1570
1571 private function defaultCoverTimestamp(): string
1572 {
1573 return $this->cover_updated_at?->format('U') ?? '0';
1574 }
1575
1576 private function getArtistUnicode()
1577 {
1578 return $this->getRawAttribute('artist_unicode') ?? $this->artist;
1579 }
1580
1581 private function getPackTags(): array
1582 {
1583 if (array_key_exists('pack_tags', $this->attributes)) {
1584 $rawValue = $this->attributes['pack_tags'];
1585
1586 return $rawValue === null
1587 ? []
1588 : explode(',', $rawValue);
1589 }
1590
1591 return $this->packs()->pluck('tag')->all();
1592 }
1593
1594 private function getTitleUnicode()
1595 {
1596 return $this->getRawAttribute('title_unicode') ?? $this->title;
1597 }
1598}