-
Notifications
You must be signed in to change notification settings - Fork 7.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(route): add Osu! Latest Ranked Beatmaps (#18085)
* feat(route): add Osu! Latest Ranked Map * feat(route): add difficulty limit to osu latest ranked * feat(route): add modeInTitle param for osu latest rank * fix(route): better doc and rss desc - add route descriptions - add tips about using `difficultyLimit` param - change param name from `includedMode` to `includeMode` * fix(route): Use template for desc, remove title * refactor(route): move latest rank doc to a separate file * fix(route): Revert previous commit, put desc back to ts file. This reverts commit c6b8dc7. * feat(route): add readable config info in feed desc
- Loading branch information
Showing
2 changed files
with
350 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,313 @@ | ||
import { Data, DataItem, Route } from '@/types'; | ||
import path from 'node:path'; | ||
import got from '@/utils/got'; | ||
import { load } from 'cheerio'; | ||
import { parseDate } from '@/utils/parse-date'; | ||
import cache from '@/utils/cache'; | ||
import { config } from '@/config'; | ||
import { art } from '@/utils/render'; | ||
import { getCurrentPath } from '@/utils/helpers'; | ||
const __dirname = getCurrentPath(import.meta.url); | ||
|
||
const actualParametersDescTable = ` | ||
| Name | Default | Description | | ||
| ----------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ||
| \`includeMode\` | All mode | Could be \`osu\`, \`mania\`, \`fruits\` or \`taiko\`. Specify included game mode of beatmaps. Including this paramseter multiple times to specify multiple game modes, e.g.: \`includeMode=osu&includeMode=mania\`. Subscribe to all game modes if not specified | | ||
| \`difficultyLimit\` | None | Lower/upper limit of star rating of the beatmaps in the beatmapset item, e.g.:\`difficultyLimit=U6\`. Checkout tips in descriptions for detailed explaination and examples. | | ||
| \`modeInTitle\` | \`true\` | \`true\` or \`false\` Add mode info into feed title. | ||
`; | ||
|
||
const descriptionDoc: string = ` | ||
Subscribe to the new beatmaps on https://osu.ppy.sh/beatmapsets. | ||
#### Parameter Description | ||
Parameters allows you to: | ||
- Filter game mode | ||
- Limit beatmap difficulty | ||
- Show/hide game mode in feed title | ||
Below is a table of all allowed parameters passed to \`routeParams\` | ||
${actualParametersDescTable} | ||
This actual parameters should be passed as \`routeParams\` in URL Query String format without \`?\`, e.g.: | ||
/osu/latest-ranked/modeInTitle=true&includeMode=osu | ||
:::tip | ||
You could make use of \`difficultyLimit\` paramters to create a "high difficulty/low difficulty only" only feed. | ||
For example, if you only wants to play low star rating beatmap like 1 or 2 star, you could subscribe to: | ||
/osu/latest-ranked/difficultyLimit=U2 | ||
This will filter out all beatmapsets that do not provide at least one beatmap with star rating<=\`2.00\`. | ||
Similarly, you could use lower bound to filter out beatmapsets which don't have at least one beatmap | ||
with star rating higher than a certain threshold. | ||
/osu/latest-ranked/difficultyLimit=L6 | ||
Now all beatmapsets that don't provided at least one beatmap with star rating higher than \`6.00\` will be filtered. | ||
::: | ||
`; | ||
|
||
export const route: Route = { | ||
path: '/latest-ranked/:routeParams?', | ||
categories: ['game'], | ||
example: '/osu/latest-ranked/includeMode=osu&difficultyLimit=L3&difficultyLimit=U7', | ||
features: { | ||
requireConfig: false, | ||
requirePuppeteer: false, | ||
antiCrawler: false, | ||
supportBT: false, | ||
supportPodcast: false, | ||
supportScihub: false, | ||
supportRadar: true, | ||
}, | ||
parameters: { | ||
routeParams: { | ||
description: 'Used to pass route parameters in Query String format. Check out route description for more info.', | ||
default: 'null', | ||
}, | ||
}, | ||
name: 'Latest Ranked Beatmap', | ||
description: descriptionDoc, | ||
maintainers: ['nfnfgo'], | ||
radar: [ | ||
{ | ||
source: ['osu.ppy.sh/beatmapsets'], | ||
}, | ||
], | ||
handler, | ||
}; | ||
|
||
interface Beatmap { | ||
beatmapset_id: number; | ||
difficulty_rating: number; | ||
id: number; | ||
mode: string; | ||
status: string; | ||
total_length: number; | ||
user_id: number; | ||
version: string; | ||
accuracy: number; | ||
ar: number; | ||
bpm: number; | ||
convert: boolean; | ||
count_circles: number; | ||
count_sliders: number; | ||
count_spinners: number; | ||
cs: number; | ||
deleted_at: string | null; | ||
drain: number; | ||
hit_length: number; | ||
is_scoreable: boolean; | ||
last_updated: string; | ||
mode_int: number; | ||
passcount: number; | ||
playcount: number; | ||
ranked: number; | ||
url: string; | ||
checksum: string; | ||
max_combo: number; | ||
} | ||
|
||
interface NominationsSummary { | ||
current: number; | ||
eligible_main_rulesets: string[]; | ||
required_meta: { | ||
main_ruleset: number; | ||
non_main_ruleset: number; | ||
}; | ||
} | ||
|
||
interface Covers { | ||
cover: string; | ||
'cover@2x': string; | ||
card: string; | ||
'card@2x': string; | ||
list: string; | ||
'list@2x': string; | ||
slimcover: string; | ||
'slimcover@2x': string; | ||
} | ||
|
||
interface Availability { | ||
download_disabled: boolean; | ||
more_information: string | null; | ||
} | ||
|
||
interface BeatmapsetInfo { | ||
artist: string; | ||
artist_unicode: string; | ||
covers: Covers; | ||
creator: string; | ||
favourite_count: number; | ||
hype: null | string; | ||
id: number; | ||
nsfw: boolean; | ||
offset: number; | ||
play_count: number; | ||
preview_url: string; | ||
source: string; | ||
spotlight: boolean; | ||
status: string; | ||
title: string; | ||
title_unicode: string; | ||
track_id: number; | ||
user_id: number; | ||
video: boolean; | ||
bpm: number; | ||
can_be_hyped: boolean; | ||
deleted_at: string | null; | ||
discussion_enabled: boolean; | ||
discussion_locked: boolean; | ||
is_scoreable: boolean; | ||
last_updated: string; | ||
legacy_thread_url: string; | ||
nominations_summary: NominationsSummary; | ||
ranked: number; | ||
ranked_date: string; | ||
storyboard: boolean; | ||
submitted_date: string; | ||
tags: string; | ||
availability: Availability; | ||
beatmaps: Beatmap[]; | ||
pack_tags: string[]; | ||
} | ||
|
||
async function handler(ctx): Promise<Data> { | ||
// Parse & retrive searchParams | ||
const pathParams = ctx.req.param('routeParams'); | ||
// Here user actually pass the query using path param, like: `/osu/latest-ranked/includeMode=osu` | ||
// We first retrieve path param part: `includeMode=osu`, then concat it with host to construct a "fake" URL: | ||
// `https://osu.ppy.sh?includeMode=osu` | ||
// Then we use URL.searchParams to parse and retrieve params from this "fake" URL. | ||
const searchParams = new URL(`https://osu.ppy.sh?${pathParams}`).searchParams; // use URL to parse params | ||
const includeModes = searchParams.getAll('includeMode'); | ||
const difficultyLimits = searchParams.getAll('difficultyLimit'); | ||
const modeInTitle = searchParams.get('modeInTitle') ?? 'true'; // show mode name in title, default to true. | ||
|
||
// fetch beatmap JSON info from website within cache | ||
let beatmapsetList = (await cache.tryGet( | ||
'https://osu.ppy.sh/beatmapsets:JSON', | ||
async () => { | ||
const link = 'https://osu.ppy.sh/beatmapsets'; | ||
|
||
const response = await got.get(link); | ||
const $ = load(response.data); | ||
|
||
const beatmapInfo = JSON.parse($('#json-beatmaps').text() ?? '{"beatmapsets": undefined}'); | ||
|
||
const beatmapList: BeatmapsetInfo[] = beatmapInfo.beatmapsets; | ||
|
||
// Failed to fetch, raise error | ||
if (beatmapList === undefined) { | ||
throw new Error('Failed to retrieve JSON beatmap info from osu! website'); | ||
} | ||
|
||
return beatmapList; | ||
}, | ||
config.cache.routeExpire, | ||
false | ||
)) as BeatmapsetInfo[]; | ||
|
||
// Sort beatmap by difficultyRate.desc | ||
// This step is necessary even if difficultyLimit not enabled, since we want the beatmap | ||
// in RSS description sorted when displayed | ||
for (const item of beatmapsetList) { | ||
item.beatmaps.sort((a, b) => a.difficulty_rating - b.difficulty_rating); | ||
} | ||
|
||
// filter beatmapset types | ||
// Note: | ||
// One Osu beatmapset could actually contains several beatmaps with different game mode. | ||
// Here for simplicity we just use the mode of first beatmap in this set for filtering criteria. | ||
if (includeModes?.length && includeModes?.length > 0) { | ||
beatmapsetList = beatmapsetList.filter((bm) => includeModes.includes(bm.beatmaps[0].mode)); | ||
} | ||
|
||
let upperLimit = 99; // Osu! will never have maps with 99+ star rating right? | ||
let lowerLimit = 0; | ||
if (difficultyLimits && difficultyLimits.length > 0 && difficultyLimits.length < 2) { | ||
for (const dfLimit of difficultyLimits) { | ||
if (dfLimit.startsWith('U')) { | ||
upperLimit = Number.parseFloat(dfLimit.substring(1)); | ||
} else if (dfLimit.startsWith('L')) { | ||
lowerLimit = Number.parseFloat(dfLimit.substring(1)); | ||
} | ||
} | ||
|
||
const difficultyRateFilterFunc = (item: BeatmapsetInfo): boolean => { | ||
if (item.beatmaps.at(0)!.difficulty_rating > upperLimit) { | ||
return false; | ||
} | ||
if (item.beatmaps.at(-1)!.difficulty_rating < lowerLimit) { | ||
return false; | ||
} | ||
return true; | ||
}; | ||
|
||
beatmapsetList = beatmapsetList.filter((item) => difficultyRateFilterFunc(item)); | ||
} | ||
|
||
// Returns a user-readble string that shows details about route parameter config | ||
function getReadableFeedConfig(): string { | ||
if (!pathParams) { | ||
return ''; | ||
} | ||
|
||
let readableConf = 'Feed Configurations:\n'; | ||
readableConf += `Game Mode: ${includeModes.length > 0 ? JSON.stringify(includeModes) : 'All modes'}\n`; | ||
readableConf += `Star Rating Limit: Lower=${lowerLimit}, Upper=${upperLimit}`; | ||
return readableConf; | ||
} | ||
|
||
// Construct beatmap feed items | ||
const rssItems: DataItem[] = beatmapsetList.map((beatmapset) => { | ||
// Format publication date using parseDate utility | ||
// Here it make sense to consider the ranked date as the pubDate of this item since this is ranked map RSS | ||
const pubDate = parseDate(beatmapset.ranked_date); | ||
|
||
// Select the best resolution cover (2x if available) | ||
const coverImage = beatmapset.covers['cover@2x'] || beatmapset.covers.cover; | ||
const bannerImage = beatmapset.covers['card@2x'] || beatmapset.covers.card; | ||
|
||
// Readable beatmap total length | ||
const readableTotalLength = `${Math.floor(beatmapset.beatmaps[0].total_length / 60) | ||
.toString() | ||
.padStart(2, '0')}:${(beatmapset.beatmaps[0].total_length % 60).toString().padStart(2, '0')}`; | ||
|
||
const modeLiteralToDisplayNameMap = { | ||
osu: 'Osu!', | ||
fruits: 'Osu!Catch', | ||
taiko: 'Osu!Taiko', | ||
mania: 'Osu!Mania', | ||
}; | ||
|
||
// Create a description with beatmap details and a table of difficulties | ||
const description = art(path.join(__dirname, 'templates/beatmapset.art'), { ...beatmapset, readableTotalLength, modeLiteralToDisplayNameMap }); | ||
|
||
return { | ||
title: `${modeInTitle === 'true' ? `[${modeLiteralToDisplayNameMap[beatmapset.beatmaps[0].mode]}] ` : ``}${beatmapset.title_unicode ?? beatmapset.title}`, | ||
description, | ||
pubDate, | ||
link: `https://osu.ppy.sh/beatmapsets/${beatmapset.id}`, | ||
category: ['osu!', 'game'], | ||
author: [{ name: beatmapset.creator }], | ||
image: coverImage, | ||
banner: bannerImage, | ||
updated: beatmapset.last_updated, | ||
}; | ||
}); | ||
|
||
return { | ||
title: 'Osu! Latest Ranked Map', | ||
link: 'https://osu.ppy.sh/beatmapsets', | ||
description: `Newly ranked beatmaps at https://osu.ppy.sh/beatmapsets.\n${getReadableFeedConfig()}`, | ||
item: rssItems, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
<img src="{{ covers['cover@2x'] || covers.cover }}" alt="{{ title }}" style="max-width: 100%; height: auto;" /> | ||
|
||
<h3>Song Info</h3> | ||
<ul> | ||
<li><strong>English Title:</strong> {{ title }}</li> | ||
<li><strong>Artist:</strong> {{ artist_unicode }} ({{ artist }})</li> | ||
<li><strong>Length:</strong> {{ readableTotalLength }}</li> | ||
<li><strong>BPM:</strong> {{ bpm }}</li> | ||
</ul> | ||
|
||
<h3>Beatmapset Info</h3> | ||
<ul> | ||
<li><strong>Mode:</strong> {{ modeLiteralToDisplayNameMap[beatmaps[0].mode] }}</li> | ||
<li><strong>Creator:</strong> {{ creator }}</li> | ||
</ul> | ||
|
||
<h3>Difficulties</h3> | ||
<table border="1"> | ||
<thead> | ||
<tr> | ||
<th>Version</th> | ||
<th>Rating</th> | ||
<th>AR</th> | ||
<th>Drain</th> | ||
</tr> | ||
</thead> | ||
<tbody> | ||
{{ each beatmaps as beatmap }} | ||
<tr> | ||
<td><a href="{{ beatmap.url }}" target="_blank">{{ beatmap.version }}</a></td> | ||
<td>{{ beatmap.difficulty_rating.toFixed(2) }}</td> | ||
<td>{{ beatmap.ar.toFixed(1) }}</td> | ||
<td>{{ beatmap.drain }}</td> | ||
</tr> | ||
{{ /each }} | ||
</tbody> | ||
</table> |