From aa63336f51211396a6adafaef666d5a69a266d76 Mon Sep 17 00:00:00 2001 From: hakureiyoru <768754163@qq.com> Date: Thu, 18 Jun 2026 16:21:41 +0800 Subject: [PATCH] feat: maidata hash lookup --- public/i18n/en.json | 7 + public/i18n/ja.json | 7 + public/i18n/ko.json | 7 + public/i18n/zh.json | 7 + src/components/user/ChartUploader.tsx | 227 +++++++++++++++++++++++++- src/config/api.ts | 1 + 6 files changed, 253 insertions(+), 3 deletions(-) diff --git a/public/i18n/en.json b/public/i18n/en.json index f44f6a0..cdc1300 100644 --- a/public/i18n/en.json +++ b/public/i18n/en.json @@ -142,6 +142,13 @@ "AvatarSettings": "Avatar Settings", "PersonalIntro": "Personal Introduction", "NoFileSelected": "No file selected for ", + "MaidataHashChecking": "Checking whether this chart already exists...", + "MaidataHashNotFound": "No existing chart found", + "MaidataAlreadyExists": "This chart already exists on the site", + "MaidataAlreadyExistsWithTitle": "This chart already exists on the site", + "MaidataWillInheritScores": "This upload will inherit previous scores", + "MaidataHashLookupFailed": "Failed to query maidata.txt hash", + "View": "View", "ManageYourCharts": "Manage your uploaded charts", "ModifyPersonalInfo": "Modify personal information and settings", "ViewYourHomePage": "View your personal homepage", diff --git a/public/i18n/ja.json b/public/i18n/ja.json index c7a4f31..548f1fd 100644 --- a/public/i18n/ja.json +++ b/public/i18n/ja.json @@ -142,6 +142,13 @@ "AvatarSettings": "アバター設定", "PersonalIntro": "自己紹介", "NoFileSelected": "ファイルが選択されていません: ", + "MaidataHashChecking": "この譜面が既に存在するか確認しています...", + "MaidataHashNotFound": "既存の譜面は見つかりませんでした", + "MaidataAlreadyExists": "この譜面はすでにサイト上に存在します", + "MaidataAlreadyExistsWithTitle": "この譜面はすでにサイト上に存在します", + "MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます", + "MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました", + "View": "表示", "ManageYourCharts": "アップロードした譜面を管理", "ModifyPersonalInfo": "個人情報と設定を変更", "ViewYourHomePage": "個人ホームページを表示", diff --git a/public/i18n/ko.json b/public/i18n/ko.json index 436f7f6..846de6e 100644 --- a/public/i18n/ko.json +++ b/public/i18n/ko.json @@ -142,6 +142,13 @@ "AvatarSettings": "아바타 설정", "PersonalIntro": "자기소개", "NoFileSelected": "파일이 선택되지 않았습니다: ", + "MaidataHashChecking": "이 채보가 이미 존재하는지 확인하는 중...", + "MaidataHashNotFound": "기존 채보를 찾지 못했습니다", + "MaidataAlreadyExists": "이 채보는 이미 사이트에 존재합니다", + "MaidataAlreadyExistsWithTitle": "이 채보는 이미 사이트에 존재합니다", + "MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다", + "MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다", + "View": "보기", "ManageYourCharts": "내가 올린 채보를 관리하세요", "ModifyPersonalInfo": "개인 정보와 설정을 수정하세요", "ViewYourHomePage": "내 개인 홈을 확인하세요", diff --git a/public/i18n/zh.json b/public/i18n/zh.json index f90c908..1f3f60b 100644 --- a/public/i18n/zh.json +++ b/public/i18n/zh.json @@ -144,6 +144,13 @@ "AvatarSettings": "头像设置", "PersonalIntro": "个人简介", "NoFileSelected": "没有选中", + "MaidataHashChecking": "正在查询这个谱面是否已存在...", + "MaidataHashNotFound": "未找到已存在的谱面", + "MaidataAlreadyExists": "这个谱面已经存在于网站上", + "MaidataAlreadyExistsWithTitle": "这个谱面已经存在于网站上", + "MaidataWillInheritScores": "这次上传会继承之前的分数", + "MaidataHashLookupFailed": "查询 maidata.txt 哈希失败", + "View": "查看", "ManageYourCharts": "管理您上传的谱面", "ModifyPersonalInfo": "修改个人信息和设置", "ViewYourHomePage": "查看您的个人主页", diff --git a/src/components/user/ChartUploader.tsx b/src/components/user/ChartUploader.tsx index 5238001..1b0ee03 100644 --- a/src/components/user/ChartUploader.tsx +++ b/src/components/user/ChartUploader.tsx @@ -1,16 +1,197 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { toast } from 'react-toastify'; import axios, { AxiosError } from 'axios'; +import { md5 } from 'js-md5'; import { endpoints } from '@/config/api'; import { useLoc } from '@/hooks'; import { getDisplayMessage, sleep } from '@/utils'; import { motion } from 'framer-motion'; import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVideoFile, MdCloudUpload } from 'react-icons/md'; import { LoadingSpinner } from '@/components'; +import type { Song } from '@/types'; + +type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsScores' | 'error'; + +interface HashLookupState { + status: HashLookupStatus; + fileKey?: string; + hash?: string; + chart?: Song; + message?: string; +} + +interface HashStatusResponse { + hash?: string; + Hash?: string; + exists?: boolean; + Exists?: boolean; + hasHistoricalScores?: boolean; + HasHistoricalScores?: boolean; + willInheritScores?: boolean; + WillInheritScores?: boolean; + chart?: Song | null; + Chart?: Song | null; +} + +function getFileKey(file: File) { + return `${file.name}:${file.size}:${file.lastModified}`; +} + +function addUnique(items: T[], item: T) { + if (!items.includes(item)) { + items.push(item); + } +} + +function normalizeLines(text: string) { + return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function encodeUtf8(text: string) { + return new TextEncoder().encode(text); +} + +function hashCandidatesFromBytes(bytes: Uint8Array) { + const candidates: string[] = []; + const add = (value: Uint8Array | ArrayBuffer | number[]) => { + addUnique(candidates, md5.base64(value)); + }; + + add(bytes); + + if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) { + add(bytes.slice(3)); + } + + const decoder = new TextDecoder('utf-8', { fatal: false }); + const rawText = decoder.decode(bytes); + const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText; + const normalized = normalizeLines(text); + + add(encodeUtf8(normalized)); + add(encodeUtf8(normalized.split('\n').filter((line) => line.trim() !== '').join('\n'))); + + const nonBlank = normalized.split('\n').filter((line) => line.trim() !== ''); + const firstLevelIndex = nonBlank.findIndex((line) => line.trimStart().toLowerCase().startsWith('&lv_')); + if (firstLevelIndex !== -1) { + const normalizedLevelGap = [ + ...nonBlank.slice(0, firstLevelIndex), + '', + ...nonBlank.slice(firstLevelIndex), + ].join('\n'); + add(encodeUtf8(normalizedLevelGap)); + } + + add(encodeUtf8(rawText.replace(/\n/g, '\r\n'))); + + return candidates; +} export default function ChartUploader() { const loc = useLoc(); const [isUploading, setIsUploading] = useState(false); + const [hashLookup, setHashLookup] = useState({ status: 'idle' }); + const hashLookupSeq = useRef(0); + const hashLookupAbort = useRef(null); + + async function lookupMaidataHash(file: File) { + const fileKey = getFileKey(file); + const seq = hashLookupSeq.current + 1; + hashLookupSeq.current = seq; + hashLookupAbort.current?.abort(); + const abortController = new AbortController(); + hashLookupAbort.current = abortController; + + setHashLookup({ status: 'checking', fileKey }); + + try { + const bytes = new Uint8Array(await file.arrayBuffer()); + const hashes = hashCandidatesFromBytes(bytes); + let inheritedState: HashLookupState | null = null; + + for (const hash of hashes) { + const response = await axios.get(endpoints.maichart.hashStatus(hash), { + withCredentials: true, + signal: abortController.signal, + }); + const exists = response.data.exists ?? response.data.Exists; + const hasHistoricalScores = response.data.hasHistoricalScores ?? response.data.HasHistoricalScores; + const willInheritScores = response.data.willInheritScores ?? response.data.WillInheritScores; + const chart = response.data.chart ?? response.data.Chart ?? undefined; + const result: Pick | null = exists + ? { status: 'exists', chart } + : willInheritScores || hasHistoricalScores + ? { status: 'inheritsScores' } + : null; + + if (result) { + const nextState: HashLookupState = { + status: result.status, + fileKey, + hash, + chart: result.chart, + }; + if (nextState.status === 'inheritsScores') { + inheritedState ??= nextState; + continue; + } + + if (hashLookupSeq.current === seq) { + setHashLookup(nextState); + } + return nextState; + } + } + + if (inheritedState) { + if (hashLookupSeq.current === seq) { + setHashLookup(inheritedState); + } + return inheritedState; + } + + const nextState: HashLookupState = { status: 'notFound', fileKey }; + if (hashLookupSeq.current === seq) { + setHashLookup(nextState); + } + return nextState; + } catch (e: unknown) { + if (axios.isCancel(e) || (e instanceof AxiosError && e.code === 'ERR_CANCELED')) { + return { status: 'checking', fileKey } satisfies HashLookupState; + } + + const error = e as { response?: { data?: unknown }; message?: string }; + const nextState: HashLookupState = { + status: 'error', + fileKey, + message: getDisplayMessage(error.response?.data ?? error.message, loc('MaidataHashLookupFailed', 'Failed to query maidata hash')), + }; + if (hashLookupSeq.current === seq) { + setHashLookup(nextState); + } + return nextState; + } + } + + function resetHashLookup() { + hashLookupSeq.current += 1; + hashLookupAbort.current?.abort(); + setHashLookup({ status: 'idle' }); + } + + function onFileChange(index: number, event: React.ChangeEvent) { + if (index !== 0) { + return; + } + + const file = event.currentTarget.files?.[0]; + if (!file) { + resetHashLookup(); + return; + } + + void lookupMaidataHash(file); + } async function onSubmit(event: React.FormEvent) { event.preventDefault(); @@ -40,6 +221,11 @@ export default function ChartUploader() { return; } + if (hashLookup.status === 'exists') { + toast.error(loc('MaidataAlreadyExists', 'This chart already exists on the site'), { autoClose: false }); + return; + } + const uploading = toast.loading(loc('Uploading'), { hideProgressBar: false, }); @@ -82,6 +268,25 @@ export default function ChartUploader() { { label: 'track.mp3', icon: }, { label: loc('BGVideoHint'), icon: }, ]; + const hashLookupStatusText: Record = { + idle: '', + checking: loc('MaidataHashChecking', 'Checking whether this chart already exists...'), + notFound: loc('MaidataHashNotFound', 'No existing chart found'), + exists: hashLookup.chart + ? loc('MaidataAlreadyExistsWithTitle', 'This chart already exists on the site') + `: ${hashLookup.chart.title}` + : loc('MaidataAlreadyExists', 'This chart already exists on the site'), + inheritsScores: loc('MaidataWillInheritScores', 'This upload will inherit previous scores'), + error: hashLookup.message || loc('MaidataHashLookupFailed', 'Failed to query maidata hash'), + }; + const hashLookupStatusClass: Record = { + idle: '', + checking: 'text-blue-300', + notFound: 'text-emerald-300', + exists: 'text-red-300', + inheritsScores: 'text-amber-300', + error: 'text-red-300', + }; + const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists'; return ( onFileChange(index, event)} /> ))} + {hashLookup.status !== 'idle' && ( +
+ {hashLookup.status === 'checking' && } + {hashLookupStatusText[hashLookup.status]} + {hashLookup.status === 'exists' && hashLookup.chart && ( + + {loc('View', 'View')} + + )} +
+ )} + {isUploading ? ( <> diff --git a/src/config/api.ts b/src/config/api.ts index c0f9a59..72b05b8 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -42,6 +42,7 @@ export const endpoints = { listSearch: (searchKeyword: string) => `${apiroot3}/maichart/list?sort=&search=${encodeURIComponent(searchKeyword)}`, listRanking: (sortType: string) => `${apiroot3}/maichart/list?&isRanking=true&sort=${encodeURIComponent(sortType)}`, upload: `${apiroot3}/maichart/upload`, + hashStatus: (hash: string) => `${apiroot3}/maichart/hash-status?hash=${encodeURIComponent(hash)}`, delete: (chartId: string) => `${apiroot3}/maichart/delete?chartId=${chartId}`, summary: (id: string | number) => `${apiroot3}/maichart/${id}/summary`, image: (id: string | number) => `${apiroot3}/maichart/${id}/image`,