Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ NUXT_PUBLIC_APP_NAME="osu!dash"
NUXT_PUBLIC_OSU_API_BASE="https://osu.ppy.sh/api/v2"
NUXT_OSU_CLIENT_ID="your_client_id"
NUXT_OSU_CLIENT_SECRET="your_client_secret"
OSU_REPLAY_PATH="/path/to/your/osu/exports"
NUXT_ENABLE_REPLAY_WATCHER="false"
20 changes: 12 additions & 8 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ type ActivityPayload = {
created_at?: string
}

const runtimeConfig = useRuntimeConfig()
const appVersion = computed(() => String((runtimeConfig.public as any)?.gitHash || 'unknown'))

const fallbackUser: UserPayload = {
username: 'chlokun',
avatar: 'https://a.ppy.sh/6829235?1680000000.jpeg',
Expand Down Expand Up @@ -190,7 +193,8 @@ const flagSvgs = import.meta.glob('../node_modules/flagpack-core/svg/m/*.svg', {
const flagMap = Object.fromEntries(
Object.entries(flagSvgs).map(([path, url]) => {
const match = path.match(/\/([A-Za-z]{2})\.svg$/)
return [match ? match[1].toUpperCase() : path, url as string]
const code = match?.[1]
return [code ? code.toUpperCase() : path, url as string]
})
) as Record<string, string>

Expand Down Expand Up @@ -750,7 +754,7 @@ const onGraphLeave = () => {
<div
v-for="(stat, idx) in stats"
:key="stat.label"
class="silky-card silky-in group rounded-2xl border border-white/10 bg-zinc-900/60 p-5 text-left transition-all duration-500 ease-out hover:-translate-y-1 hover:border-white/30 hover:bg-white/10"
class="silky-card silky-in rounded-2xl border border-white/10 bg-zinc-900/60 p-5 text-left"
:style="{ '--i': idx + 1 }"
>
<p class="text-xs uppercase tracking-[0.24em] text-zinc-500">{{ stat.label }}</p>
Expand Down Expand Up @@ -885,7 +889,7 @@ const onGraphLeave = () => {
>
<button
v-if="score.deep_stats"
class="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/5 text-zinc-500 backdrop-blur transition hover:border-white/60 hover:text-white hover:shadow-[0_0_14px_rgba(255,255,255,0.6)]"
class="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/5 text-zinc-500 backdrop-blur transition hover:border-white/60 hover:text-white hover:shadow-[0_0_14px_rgba(255,255,255,0.6)] cursor-pointer"
aria-label="Open deep stats"
@click.stop="openDetail(score)"
>
Expand Down Expand Up @@ -946,7 +950,7 @@ const onGraphLeave = () => {
</div>
<button
v-if="score.deep_stats"
class="text-zinc-500 group-hover:text-white transition-colors p-2"
class="text-zinc-500 group-hover:text-white transition-colors p-2 cursor-pointer"
aria-label="Open deep stats"
@click.stop="openDetail(score)"
>
Expand All @@ -973,7 +977,7 @@ const onGraphLeave = () => {
>
<button
v-if="score.deep_stats"
class="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/5 text-zinc-500 backdrop-blur transition hover:border-white/60 hover:text-white hover:shadow-[0_0_14px_rgba(255,255,255,0.6)]"
class="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/5 text-zinc-500 backdrop-blur transition hover:border-white/60 hover:text-white hover:shadow-[0_0_14px_rgba(255,255,255,0.6)] cursor-pointer"
aria-label="Open deep stats"
@click.stop="openDetail(score)"
>
Expand Down Expand Up @@ -1018,7 +1022,7 @@ const onGraphLeave = () => {
>
<button
v-if="score.deep_stats"
class="absolute right-3 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/5 text-zinc-500 backdrop-blur transition hover:border-white/60 hover:text-white hover:shadow-[0_0_14px_rgba(255,255,255,0.6)]"
class="absolute right-3 top-1/2 -translate-y-1/2 inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/5 text-zinc-500 backdrop-blur transition hover:border-white/60 hover:text-white hover:shadow-[0_0_14px_rgba(255,255,255,0.6)] cursor-pointer"
aria-label="Open deep stats"
@click.stop="openDetail(score)"
>
Expand Down Expand Up @@ -1166,7 +1170,7 @@ const onGraphLeave = () => {
</div>
</div>
<button
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs text-zinc-200 transition-all duration-300 hover:border-white/30 hover:text-white disabled:opacity-60 shrink-0"
class="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs text-zinc-200 transition-all duration-300 hover:border-white/30 hover:text-white disabled:opacity-60 shrink-0 cursor-pointer"
:disabled="pending"
@click="() => refresh()"
aria-label="Refresh profile"
Expand All @@ -1176,7 +1180,7 @@ const onGraphLeave = () => {
</button>
</div>
<div class="flex items-center gap-3 flex-wrap justify-end text-zinc-400 sm:text-right text-left">
<span class="text-xs">osu!dash: 36886eb</span>
<span class="text-xs">osu!dash: {{ appVersion }}</span>
<span v-if="isDev" class="text-[11px]">Dev Mode: Cache Bypassed</span>
</div>
</div>
Expand Down
232 changes: 232 additions & 0 deletions app/utils/lazer/replayParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import path from 'node:path'
import { ScoreDecoder } from 'osu-parsers'

export type ReplayHits = {
count300: number
count100: number
count50: number
countMiss: number
countGeki?: number
countKatu?: number
}

export type ParsedLazerReplay = {
filePath: string
fileName: string
score: any
info: any
frames: any[]
beatmapHash: string | null
replayTotalScore: number | null
replayHits: ReplayHits
replayMods: number
replayModsList: string[]
replayAcc: number
computedReplayRank: string
lifeBarEnd: number | null
passed: boolean | null
replayTimestamp: number | null
}

const MOD_BITMASK: Record<number, string> = {
1: 'NF',
2: 'EZ',
4: 'TD',
8: 'HD',
16: 'HR',
32: 'SD',
64: 'DT',
128: 'RL',
256: 'HT',
512: 'NC',
1024: 'FL',
2048: 'AT',
4096: 'SO',
8192: 'AP',
16384: 'PF'
}

export function isValidBeatmapHash(hash: string | null | undefined): hash is string {
if (!hash) return false
const h = String(hash).toLowerCase()
return /^[0-9a-f]{32}$/.test(h)
}

export function modsToArray(mods: number): string[] {
if (!mods) return []
const out: string[] = []
for (const [maskRaw, label] of Object.entries(MOD_BITMASK)) {
const mask = Number(maskRaw)
if ((mods & mask) !== 0) out.push(label)
}
if (out.includes('NC')) return out.filter((m) => m !== 'DT')
return out
}

function clampInt(n: unknown): number {
const v = typeof n === 'number' ? n : Number(n)
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.trunc(v))
}

export function readReplayHits(score: any): ReplayHits {
const info = score?.info || score?.scoreInfo || score?.metadata || {}
const source = info?.statistics || info?.hitCounts || info?.hits || info

return {
count300: clampInt(source?.count300 ?? source?.count_300 ?? source?.n300 ?? source?.perfect ?? source?.hit300),
count100: clampInt(source?.count100 ?? source?.count_100 ?? source?.n100 ?? source?.good ?? source?.hit100),
count50: clampInt(source?.count50 ?? source?.count_50 ?? source?.n50 ?? source?.meh ?? source?.hit50),
countMiss: clampInt(source?.countMiss ?? source?.count_miss ?? source?.nmiss ?? source?.miss ?? source?.hitMiss),
countGeki: clampInt(source?.countGeki ?? source?.count_geki ?? source?.ngeki ?? source?.geki),
countKatu: clampInt(source?.countKatu ?? source?.count_katu ?? source?.nkatu ?? source?.katu)
}
}

export function readReplayMods(score: any): number {
const info = score?.info || score?.scoreInfo || score?.metadata || {}
return clampInt(info?.mods ?? info?.mod ?? score?.mods ?? score?.mod)
}

export function calculateAccuracy(h: ReplayHits): number {
const totalHits = h.count300 + h.count100 + h.count50 + h.countMiss
if (!totalHits) return 0
const numerator = 300 * h.count300 + 100 * h.count100 + 50 * h.count50
return (numerator / (300 * totalHits)) * 100
}

export function calculateRank(h: ReplayHits, mods: number): string {
const totalHits = h.count300 + h.count100 + h.count50 + h.countMiss
const accPct = calculateAccuracy(h)
const ratio300 = totalHits ? h.count300 / totalHits : 0
const ratio50 = totalHits ? h.count50 / totalHits : 0
const hasMiss = h.countMiss > 0

let base: 'SS' | 'S' | 'A' | 'B' | 'C' | 'D'
if (!hasMiss && accPct >= 100 - 1e-9) {
base = 'SS'
} else if (!hasMiss && ratio300 > 0.9 && ratio50 < 0.01) {
base = 'S'
} else if ((ratio300 > 0.8 && !hasMiss) || ratio300 > 0.9) {
base = 'A'
} else if ((ratio300 > 0.7 && !hasMiss) || ratio300 > 0.8) {
base = 'B'
} else if (ratio300 > 0.6) {
base = 'C'
} else {
base = 'D'
}

const MOD_HD = 8
const MOD_FL = 1024
const silver = (mods & MOD_HD) !== 0 || (mods & MOD_FL) !== 0
if (silver && base === 'SS') return 'SSH'
if (silver && base === 'S') return 'SH'
return base
}

export function parseLifeBarEndPercent(lifeBar: unknown): number | null {
if (!lifeBar) return null

if (Array.isArray(lifeBar)) {
const last = lifeBar.at(-1)
const v = (last as any)?.life ?? (last as any)?.value ?? (last as any)?.hp ?? null
const n = typeof v === 'number' ? v : Number(v)
return Number.isFinite(n) ? n : null
}

if (typeof lifeBar === 'string') {
const parts = lifeBar
.split(',')
.map((p) => p.trim())
.filter(Boolean)
if (!parts.length) return null
const last = parts[parts.length - 1]!
const seg = last.split('|')
if (seg.length < 2) return null
const n = Number(seg[1])
return Number.isFinite(n) ? n : null
}

return null
}

export function normalizeTimestampMs(ts: number | string | Date | null | undefined): number | null {
if (ts == null) return null
if (ts instanceof Date) return ts.getTime()
if (typeof ts === 'string') {
const parsed = Date.parse(ts)
return Number.isFinite(parsed) ? parsed : null
}
if (typeof ts === 'number') {
return ts < 1e12 ? ts * 1000 : ts
}
return null
}

export function extractBeatmapHash(score: any, filePath: string): string | null {
const info = score?.info || score?.scoreInfo || score?.metadata
const direct =
info?.beatmapHash ||
info?.beatmapHashMD5 ||
info?.beatmapMd5 ||
info?.beatmap_md5 ||
info?.beatmap_checksum ||
score?.beatmap_hash ||
score?.beatmapHash ||
score?.beatmapMd5 ||
score?.hash ||
score?.md5 ||
null

if (direct && direct !== 'n/a') return direct

const fileName = path.basename(filePath, path.extname(filePath))
return fileName || null
}

export async function parseLazerReplay(filePath: string): Promise<ParsedLazerReplay> {
const decoder = new ScoreDecoder()
const score = (await decoder.decodeFromPath(filePath, true)) as any

const info: any = score?.info || {}
const beatmapHashRaw = extractBeatmapHash(score, filePath)
const beatmapHash = beatmapHashRaw ? String(beatmapHashRaw).toLowerCase() : null
const replayTotalScore = info?.totalScore ?? info?.score ?? (score as any)?.score ?? null

const lifeBarEnd =
parseLifeBarEndPercent(score?.replay?.lifeBar) ??
parseLifeBarEndPercent((score as any)?.replay?.life_bar) ??
parseLifeBarEndPercent((score as any)?.lifeBar) ??
parseLifeBarEndPercent((score as any)?.life_bar) ??
parseLifeBarEndPercent(info?.lifeBar) ??
parseLifeBarEndPercent(info?.life_bar)
const passed = lifeBarEnd != null ? lifeBarEnd > 0 : null

const frames = Array.isArray(score?.replay?.frames) ? score.replay.frames : []
const replayTimestamp = normalizeTimestampMs(info?.timestamp ?? (score as any)?.timestamp)

const replayHits = readReplayHits(score)
const replayMods = readReplayMods(score)
const replayModsList = modsToArray(replayMods)
const replayAcc = Math.round(calculateAccuracy(replayHits) * 100) / 100
const computedReplayRank = passed === false ? 'F' : calculateRank(replayHits, replayMods)

return {
filePath,
fileName: path.basename(filePath),
score,
info,
frames,
beatmapHash,
replayTotalScore,
replayHits,
replayMods,
replayModsList,
replayAcc,
computedReplayRank,
lifeBarEnd,
passed,
replayTimestamp
}
}
Loading