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}