곡 보관함과 악보 학습 기능을 보완

업로드 형식을 MIDI와 MusicXML과 MXL 및 PDF로 확장하고 파일별 검증을 추가했다.

AlphaTab 악보 자산과 Nginx MIME 설정을 보완해 운영에서 오선과 음표가 표시되도록 수정했다.

공개 도메인 클래식과 난이도별 연습 편곡 7개를 같은 제목별로 묶고 출처와 운영 검증 결과를 문서화했다.

검사: TypeScript 검사와 Vitest 67개 및 Vite 운영 빌드 통과. 운영 브라우저 악보 표시와 네 가지 업로드 검증 형식 확인.
백업: /home/syskiller/piano/backups/bach-to-basics/20261003-1545-sheet-upload
운영 반영: 완료
This commit is contained in:
이창현
2026-10-03 16:15:00 +09:00
parent c918f02685
commit 0522e48013
35 changed files with 83181 additions and 123 deletions
+96 -32
View File
@@ -109,62 +109,126 @@ def delete_session(token: str | None) -> None:
SAMPLES = (
{
"storage_name": "first-snow-melody.mid",
"title": "첫눈이 내리는 밤",
"artist": "ITOSM 연습곡",
"description": "오른손으로 천천히 익히는 다장조 멜로디 연습곡",
"storage_name": "minuet-bwv-anh-114-beginner.mid",
"title": "미뉴에트 사장조 BWV Anh. 114",
"artist": "크리스티안 페촐트",
"description": "오른손 선율을 느린 속도로 먼저 익히는 입문 편곡입니다. 공개 도메인 원곡에서 연습용으로 구성했습니다.",
"difficulty": "beginner",
"genre": "연습곡",
"genre": "바로크",
"arrangement": "melody",
"tags": ["다장조", "오른손", "겨울", "입문"],
"tags": ["미뉴에트", "사장조", "오른손", "입문", "공개 도메인"],
},
{
"storage_name": "first-snow-accompaniment.mid",
"title": "첫눈이 내리는 밤",
"artist": "ITOSM 연습곡",
"description": "같은 곡을 양손 반주형으로 편곡한 중급 연습 버전",
"difficulty": "intermediate",
"genre": "연습곡",
"arrangement": "accompaniment",
"tags": ["다장조", "양손", "반주", "겨울"],
},
{
"storage_name": "blue-breeze-walk.mid",
"title": "파란 바람 산책",
"artist": "ITOSM 연습곡",
"description": "가벼운 리듬으로 연주하는 쉬운 멜로디",
"storage_name": "minuet-bwv-anh-114.mid",
"title": "미뉴에트 사장조 BWV Anh. 114",
"artist": "크리스티안 페촐트",
"description": "안나 막달레나 바흐 음악수첩에 수록된 양손 원곡입니다. Mutopia Project 공개 도메인 자료를 사용했습니다.",
"difficulty": "easy",
"genre": "가요풍",
"arrangement": "melody",
"tags": ["멜로디", "리듬", "쉬움", "가요풍"],
"genre": "바로크",
"arrangement": "piano_solo",
"tags": ["미뉴에트", "사장조", "양손", "입문", "공개 도메인"],
},
{
"storage_name": "little-morning-waltz.mid",
"title": "아침의 작은 왈츠",
"artist": "ITOSM 연습곡",
"description": "3박자와 왼손 화음을 함께 익히는 피아노 독주곡",
"difficulty": "intermediate",
"genre": "왈츠",
"arrangement": "piano_solo",
"tags": ["3박자", "왈츠", "양손", "화음"],
"storage_name": "menuett-woo-82-easy.mid",
"title": "미뉴에트 내림마장조 WoO 82",
"artist": "루트비히 판 베토벤",
"description": "오른손 선율을 느린 속도로 익히는 쉬운 편곡입니다. 공개 도메인 원곡에서 연습용으로 구성했습니다.",
"difficulty": "easy",
"genre": "고전",
"arrangement": "melody",
"tags": ["미뉴에트", "내림마장조", "오른손", "쉬움", "공개 도메인"],
},
{
"storage_name": "menuett-woo-82.mid",
"title": "미뉴에트 내림마장조 WoO 82",
"artist": "루트비히 판 베토벤",
"description": "고전주의의 프레이징과 양손 균형을 익히는 미뉴에트입니다. Mutopia Project 공개 도메인 자료를 사용했습니다.",
"difficulty": "intermediate",
"genre": "고전",
"arrangement": "piano_solo",
"tags": ["미뉴에트", "내림마장조", "양손", "고전", "공개 도메인"],
},
{
"storage_name": "fur-elise-beginner.mid",
"title": "엘리제를 위하여 WoO 59",
"artist": "루트비히 판 베토벤",
"description": "유명한 첫 주제를 오른손으로 천천히 익히는 입문 편곡입니다. 공개 도메인 원곡에서 연습용으로 구성했습니다.",
"difficulty": "beginner",
"genre": "고전",
"arrangement": "melody",
"tags": ["오른손", "주제 연습", "입문", "베토벤", "공개 도메인"],
},
{
"storage_name": "fur-elise-easy.mid",
"title": "엘리제를 위하여 WoO 59",
"artist": "루트비히 판 베토벤",
"description": "첫 주제의 오른손 선율과 단순화한 왼손 근음을 함께 연습하는 쉬운 양손 편곡입니다.",
"difficulty": "easy",
"genre": "고전",
"arrangement": "accompaniment",
"tags": ["양손", "쉬운 반주", "주제 연습", "베토벤", "공개 도메인"],
},
{
"storage_name": "fur-elise.mid",
"title": "엘리제를 위하여 WoO 59",
"artist": "루트비히 판 베토벤",
"description": "오른손 아르페지오와 양손 전환을 연습하는 대표적인 피아노 소품입니다. Mutopia Project 공개 도메인 자료를 사용했습니다.",
"difficulty": "advanced",
"genre": "고전",
"arrangement": "piano_solo",
"tags": ["아르페지오", "양손", "피아노 소품", "베토벤", "공개 도메인"],
},
)
LEGACY_SAMPLE_KEYS = (
"first-snow-melody.mid",
"first-snow-accompaniment.mid",
"blue-breeze-walk.mid",
"little-morning-waltz.mid",
)
def _seed_samples() -> None:
source_root = Path(__file__).with_name("sample_midi")
with connect() as connection:
# 초기 시험용으로 만든 짧은 음형은 실제 공개 도메인 연습곡으로 교체한다.
# seed_key가 정확히 일치하는 기본 자료만 지우므로 회원 업로드에는 영향을 주지 않는다.
placeholders = ",".join("?" for _ in LEGACY_SAMPLE_KEYS)
connection.execute(
f"DELETE FROM midi_items WHERE owner_id IS NULL AND seed_key IN ({placeholders})",
LEGACY_SAMPLE_KEYS,
)
for legacy_name in LEGACY_SAMPLE_KEYS:
(MIDI_ROOT / legacy_name).unlink(missing_ok=True)
for sample in SAMPLES:
source = source_root / sample["storage_name"]
if not source.exists():
continue
target = MIDI_ROOT / sample["storage_name"]
if not target.exists():
if not target.exists() or hashlib.sha256(target.read_bytes()).digest() != hashlib.sha256(source.read_bytes()).digest():
shutil.copyfile(source, target)
existing = connection.execute(
"SELECT id FROM midi_items WHERE seed_key = ?", (sample["storage_name"],)
).fetchone()
if existing:
content = target.read_bytes()
connection.execute(
"""
UPDATE midi_items
SET title = ?, artist = ?, description = ?, original_name = ?,
storage_name = ?, size_bytes = ?, sha256 = ?, visibility = 'public',
min_tier = 0, difficulty = ?, genre = ?, arrangement = ?,
tags_json = ?, updated_at = ?
WHERE id = ?
""",
(
sample["title"], sample["artist"], sample["description"],
sample["storage_name"], sample["storage_name"], len(content),
hashlib.sha256(content).hexdigest(), sample["difficulty"], sample["genre"],
sample["arrangement"], json.dumps(sample["tags"], ensure_ascii=False),
utc_now(), existing["id"],
),
)
continue
content = target.read_bytes()
connection.execute(
+87 -11
View File
@@ -2,13 +2,16 @@ from __future__ import annotations
import hashlib
import hmac
import io
import json
import os
import re
import secrets
from pathlib import Path
from urllib.parse import quote
from zipfile import BadZipFile, ZipFile
from defusedxml import ElementTree as SafeElementTree
from fastapi import APIRouter, Cookie, File, Form, HTTPException, Query, Request, Response, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
@@ -27,13 +30,23 @@ from library_db import (
router = APIRouter()
COOKIE_NAME = "b2b_session"
MAX_MIDI_BYTES = 10 * 1024 * 1024
MAX_SCORE_BYTES = 25 * 1024 * 1024
MAX_MXL_EXPANDED_BYTES = 50 * 1024 * 1024
MAX_MXL_ENTRIES = 200
MAX_USER_FILES = 100
MAX_USER_BYTES = 500 * 1024 * 1024
USERNAME_RE = re.compile(r"^[A-Za-z0-9_-]{3,30}$")
DIFFICULTIES = {"beginner", "easy", "intermediate", "advanced", "expert"}
ARRANGEMENTS = {"melody", "accompaniment", "piano_solo", "duet", "exercise"}
VISIBILITIES = {"public", "members", "shared", "private"}
SUPPORTED_UPLOADS = {
".mid": "audio/midi",
".midi": "audio/midi",
".xml": "application/vnd.recordare.musicxml+xml",
".musicxml": "application/vnd.recordare.musicxml+xml",
".mxl": "application/vnd.recordare.musicxml",
".pdf": "application/pdf",
}
class AuthPayload(BaseModel):
@@ -142,6 +155,65 @@ def _validate_metadata(visibility: str, min_tier: int, difficulty: str, arrangem
raise HTTPException(status_code=400, detail="회원 전용 곡은 최소 등급이 1 이상이어야 합니다")
def _xml_local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _parse_musicxml(content: bytes) -> None:
try:
root = SafeElementTree.fromstring(content)
except Exception as error:
raise HTTPException(status_code=400, detail="올바른 MusicXML 악보가 아닙니다") from error
if _xml_local_name(root.tag) not in {"score-partwise", "score-timewise"}:
raise HTTPException(status_code=400, detail="MusicXML 악보의 최상위 요소가 올바르지 않습니다")
def _validate_mxl(content: bytes) -> None:
try:
with ZipFile(io.BytesIO(content)) as archive:
entries = archive.infolist()
if not entries or len(entries) > MAX_MXL_ENTRIES:
raise HTTPException(status_code=400, detail="MXL 악보의 내부 파일 구성이 올바르지 않습니다")
if sum(entry.file_size for entry in entries) > MAX_MXL_EXPANDED_BYTES:
raise HTTPException(status_code=413, detail="압축을 푼 MXL 악보는 50MB 이하여야 합니다")
names = {entry.filename for entry in entries}
container_name = "META-INF/container.xml"
if container_name not in names:
raise HTTPException(status_code=400, detail="MXL 악보에 container.xml이 없습니다")
container = SafeElementTree.fromstring(archive.read(container_name))
rootfile = next(
(
element.attrib.get("full-path", "")
for element in container.iter()
if _xml_local_name(element.tag) == "rootfile"
),
"",
)
root_path = Path(rootfile)
if not rootfile or root_path.is_absolute() or ".." in root_path.parts or rootfile not in names:
raise HTTPException(status_code=400, detail="MXL 악보의 주 악보 경로가 올바르지 않습니다")
_parse_musicxml(archive.read(rootfile))
except HTTPException:
raise
except (BadZipFile, KeyError, ValueError) as error:
raise HTTPException(status_code=400, detail="올바른 MXL 악보가 아닙니다") from error
def _validate_uploaded_score(extension: str, content: bytes) -> None:
if extension in {".mid", ".midi"}:
if len(content) < 14 or not content.startswith(b"MThd"):
raise HTTPException(status_code=400, detail="올바른 MIDI 파일이 아닙니다")
return
if extension in {".xml", ".musicxml"}:
_parse_musicxml(content)
return
if extension == ".mxl":
_validate_mxl(content)
return
if extension == ".pdf" and not content.startswith(b"%PDF-"):
raise HTTPException(status_code=400, detail="올바른 PDF 악보가 아닙니다")
def _resolve_share_ids(connection, usernames: list[str]) -> list[int]:
normalized = [name.strip() for name in usernames if name.strip()]
if not normalized:
@@ -183,6 +255,7 @@ def _serialize_item(row: dict, user: dict | None, shared_usernames: list[str]) -
"artist": row["artist"],
"description": row["description"],
"original_name": row["original_name"],
"file_type": Path(row["original_name"]).suffix.lower().lstrip("."),
"size_bytes": row["size_bytes"],
"visibility": row["visibility"],
"min_tier": row["min_tier"],
@@ -407,11 +480,12 @@ def download_midi(item_id: str, session_token: str | None = Cookie(default=None,
raise HTTPException(status_code=403, detail="이 곡을 사용할 권한이 없습니다")
path = MIDI_ROOT / row["storage_name"]
if not path.is_file() or path.parent.resolve() != MIDI_ROOT.resolve():
raise HTTPException(status_code=404, detail="MIDI 파일을 찾을 수 없습니다")
raise HTTPException(status_code=404, detail="곡 또는 악보 파일을 찾을 수 없습니다")
encoded = quote(row["original_name"].replace("\r", "").replace("\n", ""))
extension = Path(row["original_name"]).suffix.lower()
return FileResponse(
path,
media_type="audio/midi",
media_type=SUPPORTED_UPLOADS.get(extension, "application/octet-stream"),
headers={"Content-Disposition": f"inline; filename*=UTF-8''{encoded}"},
)
@@ -439,15 +513,17 @@ async def upload_midi(
raise HTTPException(status_code=400, detail="곡 제목은 1~120자로 입력하세요")
_validate_metadata(visibility, min_tier, difficulty, arrangement)
original_name = Path(file.filename or "upload.mid").name
if Path(original_name).suffix.lower() not in {".mid", ".midi"}:
raise HTTPException(status_code=400, detail="MIDI 파일만 업로드할 수 있습니다")
content = await file.read(MAX_MIDI_BYTES + 1)
if len(content) > MAX_MIDI_BYTES:
raise HTTPException(status_code=413, detail="MIDI 파일은 10MB 이하만 업로드할 수 있습니다")
if len(content) < 14 or not content.startswith(b"MThd"):
raise HTTPException(status_code=400, detail="올바른 MIDI 파일이 아닙니다")
extension = Path(original_name).suffix.lower()
if extension not in SUPPORTED_UPLOADS:
raise HTTPException(status_code=400, detail="MIDI와 MusicXML과 MXL 및 PDF 악보만 업로드할 수 있습니다")
content = await file.read(MAX_SCORE_BYTES + 1)
if len(content) > MAX_SCORE_BYTES:
raise HTTPException(status_code=413, detail="곡 또는 악보 파일은 25MB 이하여야 합니다")
if not content:
raise HTTPException(status_code=400, detail="빈 파일은 업로드할 수 없습니다")
_validate_uploaded_score(extension, content)
item_id = secrets.token_hex(16)
storage_name = f"{item_id}.mid"
storage_name = f"{item_id}{extension}"
target = MIDI_ROOT / storage_name
tag_values = _normalize_tags(tags)
share_names = [name.strip() for name in shared_usernames.split(",") if name.strip()]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.