the browser-facing portion of osu!
at master 16 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\Http\Controllers; 7 8use App\Enums\Ruleset; 9use App\Exceptions\InvariantException; 10use App\Libraries\BeatmapDifficultyAttributes; 11use App\Libraries\Beatmapset\ChangeBeatmapOwners; 12use App\Libraries\Score\BeatmapScores; 13use App\Libraries\Score\UserRank; 14use App\Libraries\Search\ScoreSearch; 15use App\Libraries\Search\ScoreSearchParams; 16use App\Models\Beatmap; 17use App\Models\User; 18use App\Transformers\BeatmapTransformer; 19use App\Transformers\ScoreTransformer; 20 21/** 22 * @group Beatmaps 23 */ 24class BeatmapsController extends Controller 25{ 26 const DEFAULT_API_INCLUDES = ['beatmapset.ratings', 'failtimes', 'max_combo', 'owners']; 27 const DEFAULT_SCORE_INCLUDES = ['user', 'user.country', 'user.cover', 'user.team']; 28 29 public function __construct() 30 { 31 parent::__construct(); 32 33 $this->middleware('require-scopes:public'); 34 } 35 36 private static function assertSupporterOnlyOptions(?User $currentUser, string $type, array $mods): void 37 { 38 $isSupporter = $currentUser !== null && $currentUser->isSupporter(); 39 if ($type !== 'global' && !$isSupporter) { 40 throw new InvariantException(osu_trans('errors.supporter_only')); 41 } 42 if (!empty($mods) && !is_api_request() && !$isSupporter) { 43 throw new InvariantException(osu_trans('errors.supporter_only')); 44 } 45 } 46 47 private static function beatmapScores(string $id, ?string $scoreTransformerType, ?bool $isLegacy): array 48 { 49 $beatmap = Beatmap::findOrFail($id); 50 if ($beatmap->approved <= 0) { 51 return ['scores' => []]; 52 } 53 54 $params = get_params(request()->all(), null, [ 55 'limit:int', 56 'mode', 57 'mods:string[]', 58 'type:string', 59 ], ['null_missing' => true]); 60 61 $rulesetId = static::getRulesetId($params['mode']) ?? $beatmap->playmode; 62 $mods = array_values(array_filter($params['mods'] ?? [])); 63 $type = presence($params['type'], 'global'); 64 $currentUser = \Auth::user(); 65 66 static::assertSupporterOnlyOptions($currentUser, $type, $mods); 67 68 $esFetch = new BeatmapScores([ 69 'beatmap_ids' => [$beatmap->getKey()], 70 'is_legacy' => $isLegacy, 71 'limit' => $params['limit'], 72 'mods' => $mods, 73 'ruleset_id' => $rulesetId, 74 'type' => $type, 75 'user' => $currentUser, 76 ]); 77 $scores = $esFetch->all()->loadMissing(['beatmap', 'user.country', 'user.team', 'processHistory']); 78 $userScore = $esFetch->userBest(); 79 $scoreTransformer = new ScoreTransformer($scoreTransformerType); 80 81 $results = [ 82 'scores' => json_collection( 83 $scores, 84 $scoreTransformer, 85 static::DEFAULT_SCORE_INCLUDES 86 ), 87 ]; 88 89 if (isset($userScore)) { 90 $results['user_score'] = [ 91 'position' => $esFetch->rank($userScore), 92 'score' => json_item($userScore, $scoreTransformer, static::DEFAULT_SCORE_INCLUDES), 93 ]; 94 // TODO: remove this old camelCased json field 95 $results['userScore'] = $results['user_score']; 96 } 97 98 return $results; 99 } 100 101 private static function getRulesetId(?string $rulesetName): ?int 102 { 103 if ($rulesetName === null) { 104 return null; 105 } 106 107 return Ruleset::tryFromName($rulesetName)?->value 108 ?? throw new InvariantException('invalid mode specified'); 109 } 110 111 /** 112 * Get Beatmap Attributes 113 * 114 * Returns difficulty attributes of beatmap with specific mode and mods combination. 115 * 116 * --- 117 * 118 * ### Response format 119 * 120 * Field | Type 121 * ---------- | ---- 122 * Attributes | [DifficultyAttributes](#beatmapdifficultyattributes) 123 * 124 * @urlParam beatmap integer required Beatmap id. Example: 2 125 * @bodyParam mods integer|string[]|Mod[] Mod combination. Can be either a bitset of mods, array of mod acronyms, or array of mods. Defaults to no mods. Example: 1 126 * @bodyParam ruleset Ruleset Ruleset of the difficulty attributes. Only valid if it's the beatmap ruleset or the beatmap can be converted to the specified ruleset. Defaults to ruleset of the specified beatmap. Example: osu 127 * @bodyParam ruleset_id integer The same as `ruleset` but in integer form. No-example 128 * 129 * @response { 130 * "attributes": { 131 * "max_combo": 100, 132 * ... 133 * } 134 * } 135 */ 136 public function attributes($id) 137 { 138 $beatmap = Beatmap::whereHas('beatmapset')->findOrFail($id); 139 140 $params = get_params(request()->all(), null, [ 141 'mods:any', 142 'ruleset:string', 143 'ruleset_id:int', 144 ], ['null_missing' => true]); 145 146 $rulesetId = $params['ruleset_id']; 147 abort_if( 148 $rulesetId !== null && Beatmap::modeStr($rulesetId) === null, 149 422, 150 'invalid ruleset_id specified' 151 ); 152 153 if ($rulesetId === null && $params['ruleset'] !== null) { 154 $rulesetId = Beatmap::modeInt($params['ruleset']); 155 abort_if($rulesetId === null, 422, 'invalid ruleset specified'); 156 } 157 158 if ($rulesetId === null) { 159 $rulesetId = $beatmap->playmode; 160 } else { 161 abort_if( 162 !$beatmap->canBeConvertedTo($rulesetId), 163 422, 164 "specified beatmap can't be converted to the specified ruleset" 165 ); 166 } 167 168 if (isset($params['mods'])) { 169 if (is_numeric($params['mods'])) { 170 $params['mods'] = app('mods')->bitsetToIds((int) $params['mods']); 171 } 172 if (is_array($params['mods'])) { 173 if (count($params['mods']) > 0 && is_string(array_first($params['mods']))) { 174 $params['mods'] = array_map(fn ($m) => ['acronym' => $m], $params['mods']); 175 } 176 177 $mods = app('mods')->parseInputArray($rulesetId, $params['mods']); 178 } else { 179 abort(422, 'invalid mods specified'); 180 } 181 } 182 183 return ['attributes' => BeatmapDifficultyAttributes::get($beatmap->getKey(), $rulesetId, $mods ?? [])]; 184 } 185 186 /** 187 * Get Beatmaps 188 * 189 * Returns a list of beatmaps. 190 * 191 * --- 192 * 193 * ### Response format 194 * 195 * Field | Type | Description 196 * -------- | ------------------------------------- | ----------- 197 * beatmaps | [BeatmapExtended](#beatmapextended)[] | Includes `beatmapset` (with `ratings`), `failtimes`, `max_combo`, and `owners`. 198 * 199 * @queryParam ids[] integer Beatmap IDs to be returned. Specify once for each beatmap ID requested. Up to 50 beatmaps can be requested at once. Example: 1 200 * 201 * @response { 202 * "beatmaps": [ 203 * { 204 * "id": 1, 205 * // Other Beatmap attributes... 206 * } 207 * ] 208 * } 209 */ 210 public function index() 211 { 212 $ids = array_slice(get_arr(request('ids'), 'get_int') ?? [], 0, 50); 213 214 if (count($ids) > 0) { 215 $beatmaps = Beatmap 216 ::whereIn('beatmap_id', $ids) 217 ->whereHas('beatmapset') 218 ->with([ 219 'beatmapOwners.user', 220 'beatmapset', 221 'beatmapset.userRatings' => fn ($q) => $q->select('beatmapset_id', 'rating'), 222 'failtimes', 223 ])->withMaxCombo() 224 ->orderBy('beatmap_id') 225 ->get(); 226 } 227 228 return [ 229 'beatmaps' => json_collection($beatmaps ?? [], new BeatmapTransformer(), static::DEFAULT_API_INCLUDES), 230 ]; 231 } 232 233 /** 234 * Lookup Beatmap 235 * 236 * Returns beatmap. 237 * 238 * --- 239 * 240 * ### Response format 241 * 242 * See [Get Beatmap](#get-beatmap) 243 * 244 * @queryParam checksum A beatmap checksum. 245 * @queryParam filename A filename to lookup. 246 * @queryParam id A beatmap ID to lookup. 247 * 248 * @response "See Beatmap object section" 249 */ 250 public function lookup() 251 { 252 static $keyMap = [ 253 'checksum' => 'checksum', 254 'filename' => 'filename', 255 'id' => 'beatmap_id', 256 ]; 257 258 $params = get_params(request()->all(), null, ['checksum:string', 'filename:string', 'id:int']); 259 260 foreach ($params as $key => $value) { 261 $beatmap = Beatmap::whereHas('beatmapset')->firstWhere($keyMap[$key], $value); 262 263 if ($beatmap !== null) { 264 break; 265 } 266 } 267 268 if (!isset($beatmap)) { 269 abort(404); 270 } 271 272 return json_item($beatmap, new BeatmapTransformer(), static::DEFAULT_API_INCLUDES); 273 } 274 275 /** 276 * Get Beatmap 277 * 278 * Gets beatmap data for the specified beatmap ID. 279 * 280 * --- 281 * 282 * ### Response format 283 * 284 * Returns [BeatmapExtended](#beatmapextended) object. 285 * Following attributes are included in the response object when applicable, 286 * 287 * Attribute | Notes 288 * ---------- | ----- 289 * beatmapset | Includes ratings property. 290 * failtimes | | 291 * max_combo | | 292 * 293 * @urlParam beatmap integer required The ID of the beatmap. 294 * 295 * @response "See Beatmap object section." 296 */ 297 public function show($id) 298 { 299 $beatmap = Beatmap::whereHas('beatmapset')->findOrFail($id); 300 301 if (is_api_request()) { 302 return json_item($beatmap, new BeatmapTransformer(), static::DEFAULT_API_INCLUDES); 303 } 304 305 $beatmapset = $beatmap->beatmapset; 306 307 if ($beatmapset === null) { 308 abort(404); 309 } 310 311 $beatmapRuleset = $beatmap->mode; 312 if ($beatmapRuleset === 'osu') { 313 $params = get_params(request()->all(), null, [ 314 'm:int', // legacy parameter 315 'mode', // legacy parameter 316 'ruleset', 317 ], ['null_missing' => true]); 318 319 $ruleset = ( 320 Ruleset::tryFromName($params['ruleset']) 321 ?? Ruleset::tryFromName($params['mode']) 322 ?? Ruleset::tryFrom($params['m'] ?? Ruleset::NULL) 323 )?->legacyName(); 324 } 325 326 $ruleset ??= $beatmapRuleset; 327 328 return ujs_redirect(route('beatmapsets.show', ['beatmapset' => $beatmapset->getKey()]).'#'.$ruleset.'/'.$beatmap->getKey()); 329 } 330 331 /** 332 * Get Beatmap scores 333 * 334 * Returns the top scores for a beatmap. Depending on user preferences, this may only show legacy scores. 335 * 336 * --- 337 * 338 * ### Response Format 339 * 340 * Returns [BeatmapScores](#beatmapscores). `Score` object inside includes `user` and the included `user` includes `country` and `cover`. 341 * 342 * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). 343 * 344 * @queryParam legacy_only integer Whether or not to exclude lazer scores. Defaults to 0. Example: 0 345 * @queryParam mode The [Ruleset](#ruleset) to get scores for. 346 * @queryParam mods An array of matching Mods, or none // TODO. 347 * @queryParam type Beatmap score ranking type // TODO. 348 */ 349 public function scores($id) 350 { 351 return static::beatmapScores( 352 $id, 353 null, 354 // TODO: change to imported name after merge with other PRs 355 \App\Libraries\Search\ScoreSearchParams::showLegacyForUser(\Auth::user()), 356 ); 357 } 358 359 /** 360 * Get Beatmap scores (non-legacy) 361 * 362 * Returns the top scores for a beatmap. 363 * 364 * --- 365 * 366 * ### Response Format 367 * 368 * Returns [BeatmapScores](#beatmapscores). `Score` object inside includes `user` and the included `user` includes `country` and `cover`. 369 * 370 * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). 371 * 372 * @queryParam mode The [Ruleset](#ruleset) to get scores for. 373 * @queryParam mods An array of matching Mods, or none // TODO. 374 * @queryParam type Beatmap score ranking type // TODO. 375 */ 376 public function soloScores($id) 377 { 378 return static::beatmapScores($id, ScoreTransformer::TYPE_SOLO, null); 379 } 380 381 public function updateOwner($id) 382 { 383 $beatmap = Beatmap::findOrFail($id); 384 $newUserIds = get_arr(request('user_ids'), 'get_int'); 385 386 (new ChangeBeatmapOwners($beatmap, $newUserIds ?? [], \Auth::user()))->handle(); 387 388 return $beatmap->beatmapset->defaultDiscussionJson(); 389 } 390 391 /** 392 * Get a User Beatmap score 393 * 394 * Return a [User](#user)'s score on a Beatmap 395 * 396 * --- 397 * 398 * ### Response Format 399 * 400 * Returns [BeatmapUserScore](#beatmapuserscore) 401 * 402 * The position returned depends on the requested mode and mods. 403 * 404 * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). 405 * @urlParam user integer required Id of the [User](#user). 406 * 407 * @queryParam legacy_only integer Whether or not to exclude lazer scores. Defaults to 0. Example: 0 408 * @queryParam mode The [Ruleset](#ruleset) to get scores for. 409 * @queryParam mods An array of matching Mods, or none // TODO. 410 */ 411 public function userScore($beatmapId, $userId) 412 { 413 $beatmap = Beatmap::scoreable()->findOrFail($beatmapId); 414 415 $params = get_params(request()->all(), null, [ 416 'mode:string', 417 'mods:string[]', 418 ]); 419 420 $rulesetId = static::getRulesetId($params['mode'] ?? null) ?? $beatmap->playmode; 421 $mods = array_values(array_filter($params['mods'] ?? [])); 422 423 $baseParams = ScoreSearchParams::fromArray([ 424 'beatmap_ids' => [$beatmap->getKey()], 425 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), 426 'limit' => 1, 427 'mods' => $mods, 428 'ruleset_id' => $rulesetId, 429 'sort' => 'score_desc', 430 'user_id' => (int) $userId, 431 ]); 432 $score = (new ScoreSearch($baseParams))->records()->first(); 433 abort_if($score === null, 404); 434 435 $rankParams = clone $baseParams; 436 $rankParams->beforeScore = $score; 437 $rankParams->userId = null; 438 $rank = UserRank::getRank($rankParams); 439 440 return [ 441 'position' => $rank, 442 'score' => json_item( 443 $score, 444 new ScoreTransformer(), 445 ['beatmap.owners', ...static::DEFAULT_SCORE_INCLUDES] 446 ), 447 ]; 448 } 449 450 /** 451 * Get a User Beatmap scores 452 * 453 * Return a [User](#user)'s scores on a Beatmap 454 * 455 * --- 456 * 457 * ### Response Format 458 * 459 * Field | Type 460 * ------ | ---- 461 * scores | [Score](#score)[] 462 * 463 * @urlParam beatmap integer required Id of the [Beatmap](#beatmap). 464 * @urlParam user integer required Id of the [User](#user). 465 * 466 * @queryParam legacy_only integer Whether or not to exclude lazer scores. Defaults to 0. Example: 0 467 * @queryParam mode (deprecated) The [Ruleset](#ruleset) to get scores for. Defaults to beatmap ruleset. No-example 468 * @queryParam ruleset The [Ruleset](#ruleset) to get scores for. Defaults to beatmap ruleset. Example: osu 469 */ 470 public function userScoreAll($beatmapId, $userId) 471 { 472 $beatmap = Beatmap::scoreable()->findOrFail($beatmapId); 473 $ruleset = presence(get_string(request('ruleset'))) ?? presence(get_string(request('mode'))); 474 if ($ruleset !== null) { 475 $rulesetId = Beatmap::modeInt($ruleset) ?? abort(404, 'unknown ruleset name'); 476 } 477 $params = ScoreSearchParams::fromArray([ 478 'beatmap_ids' => [$beatmap->getKey()], 479 'is_legacy' => ScoreSearchParams::showLegacyForUser(\Auth::user()), 480 'ruleset_id' => $rulesetId ?? $beatmap->playmode, 481 'sort' => 'score_desc', 482 'user_id' => (int) $userId, 483 ]); 484 $scores = (new ScoreSearch($params))->records()->loadMissing('processHistory'); 485 486 return [ 487 'scores' => json_collection($scores, new ScoreTransformer()), 488 ]; 489 } 490}