the browser-facing portion of osu!
at master 50 kB view raw
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}