87 Commits

Author SHA1 Message Date
1f59f6a98b installer: move yt-dlp/ffmpeg under .mc_custom/installer/, clean platform-cache
- yt-dlp.exe, ffmpeg.exe now live in %appdata%/.mc_custom/installer/ so
  the .mc_custom root stays a clean Minecraft game folder. Existing
  binaries at the old location are migrated on first run.
- After a successful install, the platform-cache (downloaded fabric /
  forge / neoforge installer jars) is deleted — it's regenerable and
  was just wasting disk space.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 19:02:01 +09:00
794ad9b778 installer-rp: rename fallback pack name musicquiz → resourcepack
Per user request: when outputPackName is empty, fall back to
`<packKey>_resourcepack` instead of `<packKey>_musicquiz`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:54:31 +09:00
f810719d92 installer-rp: site-configured outputPackName for built zip
Adds a new "생성되는 리소스팩 이름" admin field saved to the pack
manifest and consumed by the rp installer when naming the final zip.
Empty value falls back to <packKey>_musicquiz; Windows-invalid chars
are sanitized to '_'. Bumps version 0.1.1 → 0.2.0 (new feature).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:34:46 +09:00
ae771668de installer-rp: ship installer/styles.css so packaged UI renders
The rp installer's `index.html` references `../installer/styles.css`,
which works in dev because both source directories sit side by side.
The packaged exe's `files` list only included `installer-rp/**`, so
inside the asar the stylesheet path resolved to nothing and the UI
rendered completely unstyled (per user screenshot).

Add the single shared file `installer/styles.css` to the rp build's
file list. The cross-directory `<link>` reference now resolves inside
the asar, and we avoid duplicating the stylesheet.

Bump to 0.1.1 — small patch-level fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:14:42 +09:00
40c47fbeb3 build: bundle sharp's win32-x64 prebuilt for Windows packaging
The packaged installer-rp crashed on launch with
"Could not load the 'sharp' module using the win32-x64 runtime" because
electron-builder ran on Linux and only the Linux sharp variants were
present in node_modules.

- Add `preinstall:sharp-win32` script that force-installs
  `@img/sharp-win32-x64@0.34.5` into the local node_modules (npm refuses
  it on Linux without --force due to its os/cpu restrictions).
- Chain that script before both `dist:win` and `dist:win:rp` so future
  Windows builds always have the native prebuilt available.
- Exclude `@img/sharp-{,libvips-}linux*` from the packaged files list in
  both electron-builder configs so the unused Linux variants don't bloat
  the portable exe.

Verified `release/win-unpacked/resources/app.asar.unpacked/node_modules/
@img/sharp-win32-x64/lib/sharp-win32-x64.node` is present and that no
linux sharp variants ship inside the asar.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 02:14:55 +09:00
6e170646a7 env: commit .env.build with production SITE_BASE_URL
Track `.env.build` in version control so the production site domain
(`https://mc.tkrmagid.kr`) is baked into every portable exe build by
default. `.env` (server/dev secrets) stays gitignored.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:15:22 +09:00
3017e77479 env: merge .env + .env.build instead of stopping at first match
Reviewer noted that returning on the first found file meant a project-root
`.env.build` could shadow the dev `.env`, leaving the server without
`PORT`/`HOST`/`SESSION_SECRET`. Switch `loadEnv()` to iterate every
candidate with `override:false` so multiple files merge — first-loaded
value wins per key.

Order also reshuffled so dev's `.env` takes precedence over `.env.build`
at the project root (server settings stay alive), while in packaged
mode `resources/.env.build` still wins. Verified manually with a
temp-dir reproduction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:11:15 +09:00
c8da4207fc build: separate .env.build for packaging, keep .env dev-only
The previous setup packaged the development `.env` into the installer
resources, mixing local server settings (PORT/HOST/SESSION_SECRET) with
the build-time site domain. Introduce a dedicated `.env.build`:

- electron-builder configs now copy `.env.build` (gitignored) into
  `resources/`, no longer touching the dev `.env`.
- `loadEnv()` prefers `resources/.env.build` first, falling back to
  `resources/.env` (for operators who hand-edit the packaged file),
  then `<root>/.env.build`, then `<root>/.env`.
- `.env.build.example` documents the build-only keys (SITE_BASE_URL,
  MANIFEST_URL, MUSIC_CONCURRENCY); server-side keys stay in `.env`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:03:22 +09:00
tkrmagid-desktop
dfb60046c8 Merge branch 'main' of https://git.tkrmagid.kr/tkrmagid/minecraft_launcher 2026-05-18 01:02:40 +09:00
tkrmagid-desktop
6472b12d58 한국어 변경 2026-05-18 01:02:23 +09:00
bc974ecd24 installer: switch Windows target from NSIS to portable single-exe
Both electron-builder configs now produce a single-file portable .exe
instead of an NSIS installer. Removes installer/uninstaller icons and
the install-directory wizard — users can run the .exe directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:38:22 +09:00
132700720d installer-rp: override packaged main entry to dist/installer-rp/main.js
The root package.json's `main` field points at dist/installer/main.js
because that's the default `npm run installer` entry. Without an
override, `electron-builder --config electron-builder-rp.yml` would
package the resourcepack installer with the wrong main, so the exe
would start the regular installer (or fail outright).

Add `extraMetadata.main: dist/installer-rp/main.js` so the packaged
package.json is rewritten at build time to point at the right entry.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:17:48 +09:00
c527efc42f installer: address vanilla mods preservation, robust map rename, new app icon
Reviewer follow-ups:

1) Preserve mods/ for vanilla packs. `downloadModsFolder` now checks
   `!pack.modsFolder` BEFORE wiping — vanilla packs (no modsFolder) no
   longer clobber a user's hand-curated mods directory. Wipe still runs
   for modded packs to keep different MC versions from colliding.

2) Always rename the extracted map to `saves/<퀴즈이름>/`, regardless of
   the zip's top-level layout. The zip is now extracted into a temp
   directory under saves/, and:
   - if the temp has a single subdirectory, that subdirectory's content
     becomes the world;
   - otherwise the temp dir itself (e.g. level.dat + region/ at root) is
     the world.
   In either case, it is renamed atomically to `saves/<sanitized name>`
   (or `<name>_2` etc. if a user world collides). Marker tracks the
   final folder name for participant cleanup.

User request: replace both .exe icons.

- Added build/icon.ico (multi-size 16/32/48/64/128/256) and build/icon.png
  generated from the new music-note artwork.
- electron-builder.yml: set win.icon, nsis installer/uninstaller icons,
  buildResources=build, include build/icon.* in files for runtime use.
- New electron-builder-rp.yml + dist:win:rp script so the resourcepack
  installer also packages with the same icon.
- BrowserWindow({ icon }) wired in both installer and installer-rp main
  processes so the running window's titlebar/taskbar icon matches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:14:44 +09:00
4ee0a59f2b installer: wipe mods/ before install and rename extracted map to pack name
Two follow-ups requested by the user (and the first flagged by the
reviewer for omission):

1) Different Minecraft versions or different packs leave behind mod jars
   that crash Fabric on load. `downloadModsFolder` now removes the entire
   `.mc_custom/mods/` directory before every install — including when the
   pack is vanilla (no modsFolder) so leftovers from a previous modded
   pack get cleared too.

2) `downloadMapZip` renames the single extracted top-level folder to the
   pack name (sanitized for Windows: forbidden chars `<>:"/\|?*` and
   control chars → `_`, trailing space/dot trimmed, reserved names like
   CON/NUL prefixed, empty fallback to `map`). Collisions with user
   worlds get `_2`, `_3` … suffixes so we never overwrite the user's
   own worlds. The marker file tracks the post-rename folder so future
   participant cleanup still removes only what the installer created.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:06:11 +09:00
06b35abcb1 installer: split host/participant choice into its own tab
Previously the multi-mode role pick appeared as a sub-section that
expanded inline under the single/multi cards. Per request, separate it
into a dedicated page reached by pressing Next on the mode tab.

- renderStep2 now only handles mode selection (single/multi). Next
  routes single → step4 directly, multi → renderStep2Role.
- New renderStep2Role shows the host/participant cards on its own page
  with back to renderStep2. Next routes host → step3, participant → step4.
- backToPrevStep in step4 and the back from sub31 in step3 updated so
  the role tab is the correct intermediate landing for multi flows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:00:29 +09:00
ca1c5f237f installer: clean up installer-extracted map when switching to participant
When the user installs as single (skipMap=false) and then navigates back
to choose participant (skipMap=true), the previously-extracted map files
in .mc_custom/saves/ would remain because skipMap=true only skipped the
download. The final participant install state was therefore inconsistent
with the chosen role.

Track the top-level entries that downloadMapZip extracts via a marker
file (.musicquiz-installer-map.json) inside saves/. On participant
install (skipMap=true) or before a re-download, only the entries listed
in the marker are removed, so user-created worlds are preserved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:16:43 +09:00
5ea9b49630 installer: re-install client when navigation changes install payload
기존 state.client.clientInstalled boolean 은 packKey/installPlatform/skipMap
차이를 보지 않아, 참가자→싱글 로 뒤로가서 변경했을 때 skipMap=false
경로의 맵 설치가 영영 안 일어났다 (반대로 싱글→참가자 면 안 풀어도 될
맵이 남음). state.client.lastInstall 에 마지막 성공 payload 전체를
저장하고, 진입 시 새 payload 와 필드별 비교해서 다르면 재설치한다.
실패는 lastInstall 을 비워 다음 진입에서 자동 재시도.

리뷰어 지적사항(installer/renderer.js:657) 대응.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:11:44 +09:00
49f320fa71 installer: multi-role host/participant split + auto-platform + EULA + port UX
step2:
- 멀티 선택 시 호스트 / 참가자 sub-choice 추가. 호스트 는 기존 멀티 흐름 그대로,
  참가자 는 step3 (서버 설치) 를 건너뛰고 step4 client install 만 진행.

step4 client install:
- 플랫폼 설치/생략 선택 화면(sub41) 제거. 음악퀴즈 platform.type 이 vanilla 가
  아니면 무조건 자동 설치, vanilla 면 자동 건너뜀. 사용자 결정 없음.
- 참가자 모드에서는 ClientInstallPayload.skipMap=true 로 보내 client 측
  saves/ 에 맵을 풀지 않는다 (서버에 이미 있음).
- types.ts 에 skipMap 필드 추가. main.ts client:install 핸들러에서 분기.

step3 EULA modal:
- eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 서버 EULA 페이지를
  받아 iframe 에 표시. readEula() 분기 제거.

step3 포트포워딩 결과:
- 성공(preForwarded/upnpOk) 시 "친구는 <address> 주소로 서버에 접속할 수
  있습니다" 처럼 외부 주소를 강조해 표시.
- 포트가 25565 면 :포트 를 생략하고 ip 만 보여줌 (마인크래프트 자바판
  기본 포트라 클라이언트에서도 생략 가능).

step5:
- 서버 마무리 액션 (바로가기/서버 실행 토글) 은 호스트 만 노출. 참가자는
  서버를 띄우지 않으므로 런처 토글만 보인다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 22:06:48 +09:00
848fac500e op/datapack: add painting_variant JSON zip export button
데이터팩 수정 페이지에 "이미지.zip 출력" 버튼과 크기 입력(기본 4, 1~16)
을 추가. 누르면 GET /op/datapack/:key/images-zip?size=N 으로 음악 개수만큼
cover_NN.json (asset_id, width=size, height=size, title, author) 을 zip 으로
스트리밍해서 내려준다. 사용자가 맵 데이터팩의 data/musicquiz/painting_variant/
에 그대로 풀어 넣을 수 있다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 23:22:23 +09:00
212e70cd56 installer-rp: adjust music download stagger 1500ms → 2000ms
사용자 요청에 따라 1.5s 는 너무 짧다고 판단해 2s 로 재조정.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:40:20 +09:00
3ca93abae9 installer-rp: reduce music download stagger 2500ms → 1500ms
체감 속도 향상을 위해 음악 다운로드 시작 간격을 1.5초로 단축한다.
동시성 시각 효과는 유지하면서 전체 대기시간을 줄인다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 22:40:05 +09:00
a8b9b689c2 resourcepack: gate shader strip on declared maxFmt, not build target
베이스팩의 vanilla 셰이더는 manifest 의 mcVersion(resolved.format) 이 64 이하
라도, 우리가 supported_formats/max_format 으로 1.21.9+ 까지 호환을 선언하면
새 GLSL API 환경에서 로드돼 "리소스 새로고침 실패" 가 다시 난다. 셰이더 제거
판정 기준을 resolved.format > 64 에서 maxFmt > 64 로 옮기고, 그 계산을
mcmeta 작성보다 먼저 수행한다. 로그의 format 값도 maxFmt 를 표시해 어떤
호환 상한 때문에 제거됐는지 추적 가능하게 했다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 21:49:11 +09:00
1665f05c55 resourcepack: declare compatibility range from 1.21.6 to latest known
Pack.mcmeta now spans pack_format MIN_SUPPORTED_FORMAT (=63, 1.21.6) up to
max(LATEST_KNOWN_FORMAT, resolved.format) so a single build loads on every
MC from 1.21.6 through 26.1.2+ (currently extending to 86 = 26.2). Both
schemas are written: supported_formats for clients on pack_format <= 64,
and min_format/max_format for 1.21.9+ clients. pack_format itself stays
at the build target so newer clients see the pack as current rather than
legacy.
2026-05-14 21:43:31 +09:00
40b2ff81f5 resourcepack: strip vanilla shader overrides when pack_format > 64
Reviewer was right that warn-only let broken zips through. On 1.21.9+
(pack_format > 64) the vanilla shader GLSL API changed (ProjMat, FogColor
etc.) so any base pack carrying old assets/minecraft/shaders/* fails to
compile and causes the same "리소스 새로고침 실패" the pack.mcmeta fix
addressed. Strip that directory at build time when the target pack_format
exceeds 64; keep textures/models/sounds intact and log what was removed.
On <= 64 the old shaders still work, so leave the base pack untouched.
2026-05-14 21:34:30 +09:00
9cb7c05b43 resourcepack: warn when base zip overrides vanilla shaders
The actual "리소스 새로고침 실패" was caused by pack.mcmeta JsonParseException
(fixed in 6718315). The shader compile errors seen on the same log come from
the user-supplied base resourcepack overriding vanilla shaders for an older
MC. Auto-stripping would silently destroy the user's intended look, so emit
a clear warning during build instead and let the user decide whether to
update the base pack.
2026-05-14 21:28:41 +09:00
671831535b fix(resourcepack): emit min_format/max_format when pack_format > 64
MC 1.21.9+ (pack_format >= 65) deprecated supported_formats and now
requires min_format/max_format at pack root. On 26.1.x (format 84) the
old supported_formats made pack.mcmeta unparseable, so MC rejected the
music-quiz resourcepack with "리소스 새로고침 실패" on reload.
2026-05-14 21:23:45 +09:00
506e506cfa resourcepack: add music quiz pack.png icon to base zip 2026-05-14 02:29:01 +09:00
9db70d0bea installer: options.txt 류는 .mc_custom 으로 매번 덮어쓰기 동기화
기존 copyMinecraftUserSettings 는 .mc_custom 에 같은 이름의 파일이
있으면 무조건 보존했기 때문에, 사용자가 .minecraft 에서 새로 바꾼
키설정·옵션이 .mc_custom 으로 이어지지 못했다. options.txt /
optionsof.txt / optionsshaders.txt 는 사용자가 원래 쓰던 설정을
그대로 가져오기 위한 파일이므로 매번 .minecraft 쪽으로 덮어써서
동기화하고, servers.dat 같은 그 외 파일은 종전대로 보존한다.
2026-05-14 00:43:55 +09:00
c8911a9a62 fix(editor): fabric에서도 platformDownloadUrl 저장되도록 수정
normalizePackDefinition 이 fabric 일 때 downloadUrl 을 의도적으로
스트립하고 있어 저장 후 입력값이 사라지는 문제. vanilla 외에는
모두 보관하도록 조건을 변경하고, 에디터 UI 도 fabric 에서 URL
입력 칸을 다시 보여주도록 되돌렸다.
2026-05-14 00:19:19 +09:00
2a500a381f op editor: 플랫폼 설치파일 URL 필드 초기 렌더에서 플랫폼 타입에 맞게 숨김
증상: 플랫폼 타입이 fabric 인 음악퀴즈를 편집할 때 "플랫폼 설치파일
URL" 필드가 잠깐 보이고, 그 자리에 값을 입력해 저장해도 disk 에는
저장되지 않아 다시 비어 보였다 (normalizePackDefinition 이 fabric 의
downloadUrl 을 의도적으로 제거하기 때문).

원인: editor.ejs 가 platformDownloadField 를 항상 visible 로,
platformLoaderField 를 항상 hidden 으로 렌더한 뒤 JS 가 뒤늦게 보정.
이 짧은 깜빡임 동안 사용자가 URL 필드를 보고 입력하게 됨.

수정: 서버 렌더 시점에 pack.platform.type 에 따라 hidden 속성을 미리
붙여 둔다 (fabric/vanilla → URL 숨김, fabric → loader 표시).
2026-05-14 00:06:15 +09:00
ea72051e43 installer: Fabric 이미 설치돼 있으면 fabric-installer 재실행 건너뛰기
증상: 두 번째 설치 시도에서 fabric-installer 가
  FileSystemException: ...fabric-loader-X-Y.jar: 다른 프로세스가 파일을
  사용 중이기 때문에 프로세스가 액세스 할 수 없습니다
로 실패. 마인크래프트(또는 OS 인덱서)가 jar 핸들을 잡고 있을 때 발생.

원인: fabric-installer 는 매 실행마다 versions/<id>/<id>.jar 를
deleteIfExists 한 뒤 다시 쓰려고 한다. 이미 설치돼 있으면 굳이 다시
쓸 필요가 없다.

수정: installFabricLoader 에서 customRoot/versions/<versionId>/<versionId>.jar
와 .json 이 둘 다 존재하면 곧바로 return 하고 안내 로그만 남긴다.
2026-05-13 23:51:13 +09:00
c0472bb57b site: 사이트 팝업창 ESC 로 닫기 지원
- listEditor: keydown(Escape) 시 열린 .modalOverlay 닫기. 별칭 모달은
  "돌아가기" 와 동일하게 입력값을 저장한 뒤 닫는다.
- datapack: pickModal 도 ESC 로 닫히게 추가.

(팝업 바깥 영역 클릭으로 닫기는 두 페이지 모두 기존부터 동작 중.)
2026-05-13 16:44:58 +09:00
de08f9a810 op: 데이터팩 출력을 zip 대신 songs.mcfunction 코드 텍스트로 변경
운영자가 mc_datapack 의 init/songs.mcfunction 파일에 직접 복사해 붙여넣
는 워크플로로 단순화. 전체 데이터팩을 패키징할 필요가 없다.

- /op/datapack/:packName/generate 가 buildSongsMcfunction(list) 결과를
  text/plain 으로 반환 (zip 스트리밍 제거).
- file/datapacks/music_quiz_template/ 정적 사본 제거.
- datapack.ejs 에 코드블록·복사 버튼 복원, 안내 문구 추가
  ("data/mq/function/init/songs.mcfunction 에 그대로 덮어쓰세요").
- datapack 로케일 라벨을 "코드 출력 / 복사 / 출력 완료" 로 정리.
2026-05-13 16:41:25 +09:00
af884706d4 op: 데이터팩 출력을 실제 music_quiz zip 으로 교체
가이드 (mc_datapack/launcher_datapack_연동_가이드.txt) 에 따라:
- file/datapacks/music_quiz_template/ 에 mc_datapack 의 music_quiz/ 정적
  파일을 미리 동봉 (data/mq/function/init/songs.mcfunction 제외).
- src/server/datapack.ts: list.music → SNBT (`{title, author, alias}`)
  songs.mcfunction 빌더와 archiver 기반 zip 스트리머 추가.
- /op/datapack/:packName/generate 가 텍스트 placeholder 대신
  music_quiz_<key>.zip 을 Content-Disposition attachment 로 내려준다.
- datapack.ejs 의 코드블록·복사 UI 제거, 곡 수는 서버 렌더 시점에 표시.
- 더 이상 쓰이지 않는 locales 의 datapackOutput.* 키 제거, datapack
  버튼 라벨/상태 문구를 zip 다운로드용으로 정리.
2026-05-13 16:34:34 +09:00
2344c4b8d2 site: 음악목록 항목별 별칭 편집 기능 추가
- MusicListEntry 에 aliases: string[] 필드 추가, 저장 시 trim·중복 제거.
- 목록 행에 "별칭" 버튼 표시(개수 있으면 강조), 클릭 시 모달 오픈.
- 모달에서 "별칭 추가" → 입력행 생성, "−" 버튼 → 해당 행 삭제,
  좌상단 "← 돌아가기" 또는 오버레이 클릭으로 저장 후 닫기.
2026-05-13 15:57:35 +09:00
f9cf373550 수정 2026-05-13 10:31:08 +09:00
f92dc02879 installer: 4단계 sub43(완료 확인) 화면 제거
sub42 에서 클라이언트 설치가 끝나면 그 자리의 "다음" 버튼이 바로 5단계로
넘어가도록 변경. 중복적이던 sub43 의 i18n 키와 renderSubStep43 함수도 함께
삭제.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:18:11 +09:00
5e418a5c21 ko수정 2026-05-13 10:15:51 +09:00
6cd402121b i18n: 리소스팩 설치기 UI 문구를 locales/installer-rp/ko-kr.json 으로 분리
- main/preload/ytdlp/ffmpeg/music/images/pack/renderer 전반에서 로그·에러·진행
  메시지 문자열을 locales/installer-rp/ko-kr.json 사전 키로 교체
- preload 에 loadLocale 추가, main 에 rp:i18n:dict IPC 핸들러 추가
- 패키징된 .exe 에서도 한국어 사전이 적용되도록 electron-builder.yml 의
  extraResources 에 locales/ 폴더 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 04:00:31 +09:00
135bc98840 i18n: 음악퀴즈 설치기 UI 문구를 locales/installer/ko-kr.json 으로 분리
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 03:53:55 +09:00
c2fcc2fbbf i18n: 서버 측 모든 UI 문구를 locales/server/ko-kr.json 으로 분리
- src/shared/i18n.ts: 공용 i18n 로더 (dotted-key + {{placeholder}} 보간)
- locales/server/ko-kr.json: 사이트 + 라우터 + 데이터팩 출력 사전
- EJS 뷰는 res.locals.t 미들웨어로 일괄 적용
- listEditor.js 등 클라이언트 JS 는 사전을 inline <script> 로 주입받아 tt() 헬퍼 사용
2026-05-13 03:43:04 +09:00
401d72622e config: 빌드된 .exe 에도 .env 가 적용되도록 보강
- electron-builder.yml
  - dist/shared/** 추가 (env.js 등이 패키지에 들어가도록)
  - extraResources 로 빌드 시점 .env 를 resources/.env 에 배포
- loadEnv: 패키징된 Electron 앱이면 process.resourcesPath/.env 를 먼저 시도하고
  없으면 프로젝트 루트 .env 로 폴백
- docs/admin-site.md: .exe 빌드에도 .env 가 따라가는 동작 설명 추가
2026-05-13 03:29:30 +09:00
69ed4ad744 config: 사이트 도메인·서버 설정을 .env 로 중앙화 + 설치기 자동 종료 복구
- dotenv 도입, src/shared/env.ts 추가
  - loadEnv() 가 프로젝트 루트 .env 를 로드 (override=false: 쉘 env 우선)
  - getSiteBaseUrl() / getManifestUrl() 헬퍼
- 서버/설치기/리소스팩설치기 진입점에서 loadEnv() 호출
- 설치기 두 종의 기본 MANIFEST_URL 을 SITE_BASE_URL 기반으로 변경
  (운영 도메인을 한 곳에서만 바꾸면 됨)
- .env.example 템플릿 + .gitignore 에 .env 추가
- README / docs/admin-site.md 에 환경변수 표·사용법 추가
- installer/renderer.js: 4단계 완료 후 자동 종료 다시 활성화
2026-05-13 02:55:58 +09:00
894a86a117 docs: README/docs 재정리 — 프로젝트 개요·사용방법 정리
- README.md 를 개발 명세에서 프로젝트 개요/구성/빠른 시작 형식으로 재작성
- docs/installer.md (음악퀴즈 간편설치기 단계별 사용 가이드) 신설
- docs/admin-site.md (관리 사이트 운영자 가이드) 신설
- docs/resourcepack-installer.md (리소스팩 간편설치기 동작 명세) 신설
- 중복된 docs/add.md 제거 (resourcepack-installer.md 로 이전)
2026-05-13 02:44:39 +09:00
475bf924a0 installer: JVM 튜닝 플래그를 마인크래프트 런처 프로필 javaArgs 에 적용
이전 커밋이 server run.bat 에 잘못 적용됐던 것을 되돌리고, 본래 의도대로
launcher_profiles.json 의 javaArgs 에 Aikar 권장 G1 GC 플래그 6종을 병합한다.
  - mergeRamArgs(-Xmx) 후 mergeJvmTuningFlags 로 누락 플래그만 추가
  - 사용자가 같은 키를 이미 지정했으면 그 값을 존중(덮어쓰지 않음)
  - run.bat 의 injectJvmFlags 호출 및 함수 제거
2026-05-13 02:07:47 +09:00
d194e28cf2 installer: run.bat 후처리에 JVM 튜닝 플래그 주입 추가
메모리(-Xms/-Xmx)는 기존 설정 그대로 두고 Aikar 권장 G1 GC 플래그 6종
(-XX:+UnlockExperimentalVMOptions, -XX:+UseG1GC, -XX:G1NewSizePercent=20,
-XX:G1ReservePercent=20, -XX:MaxGCPauseMillis=50, -XX:G1HeapRegionSize=32M)
을 -jar 앞에 끼워 넣는다. -XX:+UseG1GC 가 이미 있으면 멱등 처리.
2026-05-13 02:05:25 +09:00
a9b766d14d installer: 마인크래프트 런처 실행 전략 재정렬 — Win32/MSIX 직접 실행 우선
minecraft:// URL 스킴이 핸들러가 깨졌거나 비어 있을 때 MS Store 로 폴백되어
실제 런처가 안 떠는 케이스 대응. 실행 순서를 아래로 변경:
  1) Win32 설치판 직접 spawn (Program Files / Xbox / portable)
  2) App Execution Alias(Minecraft.exe / MinecraftLauncher.exe, reparse point
     이므로 cmd /c start 경유)
  3) explorer.exe shell:AppsFolder\\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft
     로 MSIX(Microsoft Store) 런처 직접 호출
  4) 마지막 수단: minecraft:// URL 스킴
2026-05-13 01:59:30 +09:00
99ed5076c1 installer: 3-2 JDK 자동 설치(Temurin 21) 버튼 추가, 취소 가능
JDK 가 없을 때 사용자가 "자동 설치" 를 눌러 Adoptium Temurin 21 LTS
(Windows x64 zip) 를 받아 %APPDATA% 의 jdk/temurin-21 으로 풀어 사용하도록
한다. 다운로드는 streaming + AbortController 로 묶어, 설치 진행 중 같은
버튼이 "설치 취소" 로 바뀌며 누르면 다운로드를 즉시 중단하고 부분 파일을
정리한다. jdk:detect 후보에 자동 설치 경로도 추가해 다음 실행 시 자동 탐색됨.
2026-05-13 01:57:24 +09:00
c621185abc installer: run.bat 에 서버 기동/종료시 UPnP 자동 등록·해제 주입
서버가 실행 중일 때만 25565(또는 server-port) 가 열려 있도록 run.bat 을
후처리해 java 호출 전 UPnP Add, 종료 후 UPnP Remove 를 PowerShell 한 줄
(HNetCfg.NATUPnP.1)로 끼워 넣는다. Add 전에 같은 포트 매핑을 Remove 하므로
재실행에도 idempotent. 포트체크 단계에서 만든 테스트용 UPnP 매핑은 테스트
직후 제거해 실제 개방은 run.bat 이 단독으로 책임지게 한다.

제한: 콘솔창 X 강제 종료 시 teardown 미실행. 라우터 TTL 만료 또는 다음
실행 시 재등록 직전 Remove 로 자연 정리.
2026-05-13 01:52:51 +09:00
d0e7aa4f41 installer: .mc_custom 에 .minecraft 기존 설정 파일 복사
options.txt, optionsof.txt, servers.dat, usercache.json 등 .minecraft 최상위
파일을 .mc_custom 으로 복사해 사용자가 기존에 만들어둔 키바인딩/볼륨/렌더거리/
서버목록을 음악퀴즈 인스턴스에서도 그대로 사용할 수 있도록 한다.
이미 .mc_custom 에 같은 이름의 파일이 있으면 보존(덮어쓰지 않음). 디렉터리는
복사 대상에서 제외(mods/saves/versions/assets 등은 별도 처리).
2026-05-13 01:46:59 +09:00
b407a2ca6a installer: fabric 실행 실패 / 자동 종료 / UPnP 우선순위 정리
세 가지 문제를 정리한다.

1) fabric 으로 마인크래프트 실행 시 "Unable to prepare assets for download" 로 실패하던 문제.
   - launcher_profiles.lastVersionId 를 "<mc>-fabric" 으로 만들고 있었는데 fabric-installer 가 실제로 만드는 폴더 이름은 `fabric-loader-<loaderVer>-<mcVer>` 라서 런처가 존재하지 않는 버전을 받으려다 실패.
   - resolveLastVersionId 헬퍼 추가: fabric 일 때 platform.loaderVersion 으로 정확한 이름을 만든다. loaderVersion 미지정이면 .minecraft/versions 에서 `fabric-loader-*-<mcVer>` 패턴 자동 탐색. forge/neoforge 도 동일 패턴으로 후보 탐색.
   - 결정된 lastVersionId 의 versions 폴더 존재 여부도 점검해 경고 로그를 남긴다.

2) 5단계 완료 후 자동 종료를 임시 비활성화. 마인크래프트 런처 실행 실패 메시지를 사용자가 확인할 수 있도록 quitApp 호출만 주석 처리. X 버튼으로 직접 닫는다.

3) 포트포워딩 점검에서 사용자 라우터 규칙의 활성/비활성을 우리 UPnP 매핑과 구분.
   - 점검 시작 즉시 removeUpnpMapping 으로 이전 실행에서 남았을 수 있는 우리 UPnP 매핑을 제거.
   - 그 뒤 1차 점검을 돌리면 "사용자 규칙이 활성화돼 외부 접근 가능" 인 경우만 reachable=true 가 되어 preForwarded.
   - 사용자 규칙이 비활성/없으면 reachable=false → UPnP 등록 단계로 자연스럽게 진행.
   - 기존 preForwarded 분기의 사후 unmap 은 제거(시작 단계에서 이미 했으므로 중복).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:37:47 +09:00
7d0f1719f3 fabric: 로더 버전 선택 + fabric-installer CLI 자동 설치
관리 사이트에서 모드 플랫폼으로 fabric 을 선택하면 jar 파일 업로드 대신, 선택한 마인크래프트 버전을 기준으로 Fabric Meta v2 API 에서 호환 로더 목록을 가져와 드롭다운으로 선택하도록 했다. 설치기는 platform.loaderVersion 만 보고 최신 fabric-installer.jar 를 받아 CLI 로 자동 설치(GUI 미표시)한다.

스키마:
- PackPlatform 에 loaderVersion?: string 추가. fabric 일 때만 사용.
- normalizePackDefinition: fabric 이면 downloadUrl 무시하고 loaderVersion 만 저장, 그 외에는 기존 downloadUrl 유지.

웹 UI(views/op/editor.ejs):
- platformType 이 fabric 일 때 platformLoaderVersion select 노출. mcVersion 셀렉트 값을 가지고 https://meta.fabricmc.net/v2/versions/loader/<mcVersion> 호출.
- mcVersion 또는 platformType 변경 시 자동 재조회. 동시 요청 경쟁은 sequence 비교로 무시.
- 이전 저장값을 우선 선택하되 목록에 없으면 최신 stable 자동 선택.
- 폼 제출 시 fabric 인데 로더 미선택이면 경고.
- 라우트(op.ts): platformLoaderVersion 폼 필드 수신.

설치기(installer/main.ts):
- client:install 분기 추가. fabric 이면 installFabricLoader 호출.
- installFabricLoader: Fabric Meta installer 메타 조회 → 최신 stable installer jar 캐시 다운로드 → java -jar fabric-installer.jar client -mcversion <ver> -loader <ver> -dir <.mc_custom> -noprofile 실행. launcher_profiles 갱신은 우리 코드(updateLauncherProfile)가 담당하므로 -noprofile.
- findJavaExecutable: JAVA_HOME → .minecraft\runtime 의 번들 자바(델타/감마/베타 등 우선순위) → PATH 폴백.
- runJavaProcess: stdout/stderr 를 로그 뷰어에 prefix 와 함께 스트리밍. 실패 시 stderr 끝부분을 메시지에 포함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:28:45 +09:00
536e94474f installer: 런처 실행을 URL 스킴으로 보강, 완료 후 자동 종료
Minecraft Launcher 실행 핸들러가 옛 Program Files 경로 두 곳만 보고 있어서 Microsoft Store/UWP/Xbox 앱 설치 등 최근 설치 형태에서 거의 못 찾았다.

- 1순위로 shell.openExternal('minecraft://') 사용. OS에 등록된 프로토콜 핸들러가 설치 형태(UWP/Win32/Xbox)에 무관하게 처리.
- 폴백 경로 후보 확장: Program Files / Program Files (x86) 양쪽의 Minecraft, Minecraft Launcher, XboxGames 경로, LOCALAPPDATA\Programs\minecraft-launcher까지 검사.
- 못 찾았을 때 메시지에 설치처(Microsoft Store/minecraft.net) 안내 추가.

5단계 완료 버튼: 모든 단계가 끝난 뒤이므로 마무리 액션(바로가기/서버 실행/런처 실행)을 처리한 다음 app.quit으로 설치기를 자동 종료한다. 'app:quit' IPC 핸들러와 preload 노출(quitApp) 추가.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:13:30 +09:00
d440514fdc installer: 외부 포트체크 서비스 기반 점검으로 교체
기존 방식은 자기 PC에서 자기 외부 IP로 TCP 연결을 시도해 도달성을 판정했는데, 가정용 라우터의 헤어핀(hairpin) NAT 미지원으로 실제 외부 접근은 가능해도 내부 검증은 실패하던 문제가 있었다.

- probePortFromOutside: 임시 TCP 리스너를 대상 포트에 띄우고 ifconfig.co/port/PORT를 호출해 외부에서 해당 포트로 TCP 연결을 시도하게 한 뒤, 리스너에 연결이 도달했는지 + ifconfig.co의 JSON reachable 값으로 종합 판정.
- 포트가 이미 사용 중(서버 동작 중)이면 임시 리스너를 띄우지 않고 외부 서비스 응답만으로 판정.
- ifconfig.co 응답에서 IP도 같이 얻어 외부 IP 폴백 경로 추가.

또한 1차 점검에서 이미 외부 접근이 가능한 상태(사용자가 라우터에 수동 포워딩 규칙을 등록한 경우)에는 UPnP로 추가 매핑을 만들지 않고, 우리가 이전 실행에서 만들어 둔 UPnP 매핑이 남아 있으면 portUnmapping으로 제거하여 중복/충돌 가능성을 줄인다.

UPnP 매핑 후 재점검도 외부 서비스 기반 probePortFromOutside로 1.5초 간격 3회 재시도해 NAT 상태 전파 지연을 흡수.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:08:50 +09:00
e31c6ed55b installer: 3-3 자동 다운로드 및 UPnP 안정화
3-3 서버 다운로드도 진입 즉시 자동 시작하도록 변경. "다운로드 시작" 버튼을 제거하고 4-2와 같은 자동 실행 패턴을 적용했다(EULA 모달은 그대로 유지). 실패 시 이전→다음으로 재시도.

UPnP 점검 안정화:
- openPortViaUpnp에 15초 타임아웃 추가. SSDP 응답 없을 때 영구 hang을 방지한다.
- detectExternalIp: ipify 단일 실패 시 ifconfig.me/icanhazip 폴백 후, 최종 폴백으로 UPnP 게이트웨이의 externalIp를 사용. 기존에는 IP를 못 얻으면 UPnP 시도조차 안 했음.
- portMapping 성공 후 NAT 상태 전파 지연을 고려해 testPortReachable을 1.5초 간격 3회 재시도.
- 각 단계마다 로그를 남겨 라우터 UPnP 비활성/SSDP 차단/이중 NAT 등의 원인을 구분할 수 있게 함.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 01:01:00 +09:00
c2fb7d03a6 installer: auto-run port check on 3-5 and auto-install on 4-2
3-5 포트포워딩 점검에 진입하면 즉시 점검을 시작하고, 버튼은 "재점검"으로 다시 돌릴 수 있게 한다. 4-2 클라이언트 설치도 진입과 동시에 자동 실행되어 사용자가 따로 버튼을 누를 필요가 없다.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:58:24 +09:00
d630c90862 fix(installer-rp): selected card highlight + log viewer no longer covers buttons
- renderer.js: 카드 선택 시 .active 가 아니라 .selected 클래스를 붙여
  CSS 의 .cardChoice button.selected 스타일이 실제로 적용되게 함.
- styles.css: 로그 뷰어가 position: fixed 라 본문 하단 버튼을 덮던 문제.
  body 를 3-row grid (header/main/logViewer) 로 바꿔 로그 뷰어가
  자연스럽게 본문 아래에 배치되도록 수정. hidden 일 때 row 자동 collapse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:51:56 +09:00
5a018bcb8d fix(installer-rp): URL-encode base pack path, output to .mc_custom + installer: no auto -Xms
리소스팩 간편설치기:
- 베이스 리소스팩 다운로드 URL 에 encodeURIComponent 적용. "Puzzle Resource
  Pack (basic).zip" 같이 공백·괄호가 들어간 파일명 정상 처리.
- 출력 경로를 %appdata%/.minecraft/resourcepacks/ → %appdata%/.mc_custom/
  resourcepacks/ 로 변경 (renderer 안내문, openFolder, 빌드 출력 일괄).
- 로드 직후 각 음악퀴즈의 베이스 등록 여부를 로그에 노출 (디버그용).
- 베이스 다운로드 시 실제 URL 도 로그에 출력.

음악퀴즈 간편설치기:
- mergeRamArgs: -Xms 가 기존에 없으면 추가하지 않도록 수정. clientMinRam
  은 "유저 PC 사양 최소 요구치" 의미이지 JVM 초기 힙이 아님. -Xmx 는
  계속 추천 RAM 으로 강제 갱신.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:48:46 +09:00
82307d9d16 fix(installer): preserve JVM args + link runtime dirs from .minecraft
1) launcher_profiles.json 의 javaArgs 를 통째로 덮어쓰던 코드를 수정.
   mergeRamArgs() 로 -Xmx/-Xms 토큰만 새 값으로 교체하고 그 외
   사용자 추가 JVM 인수(-Xss, -XX:..., -Dfoo=bar 등)는 보존.

2) .mc_custom 을 gameDir 로 쓰면 마인크래프트 런처가 assets/libraries/
   versions 를 못 찾아 "Unable to prepare assets for download" 로 실패.
   linkMinecraftRuntimeDirs() 가 .minecraft 의 해당 세 폴더를
   .mc_custom 으로 junction(Windows) / symlink(POSIX) 연결. 이미 같은
   자리에 무언가 있으면 손대지 않음.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:41:45 +09:00
45540f3db7 feat(installer-rp): use registered base resourcepack as overlay base
/manifest/<key>.json 의 resourcepackPath 가 비어있지 않으면
/file/resourcepacks/<path> 의 zip 을 받아 임시 폴더에 풀고, 그 위에
음악·사진·sounds.json·pack.mcmeta 를 얹어 최종 zip 을 만든다.

- types.ts: RpFetchedPack 에 resourcepackPath 필드 추가
- main.ts: 로드 시 normalizePackDefinition 으로 resourcepackPath 캡처,
  설치 시 베이스 zip 다운로드 → tempRoot/base.zip → buildResourcepackZip 에 전달
- pack.ts: baseZipPath 옵션 추가. extract-zip 으로 베이스 압축 해제 후 위에 얹기.
  sounds.json 은 기존 항목과 병합, pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어씀.

베이스가 없으면(빈 문자열) 기존처럼 새 리소스팩을 처음부터 생성.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:37:00 +09:00
df3d0a5cda feat(installer-rp): stagger music download starts (2.5s gap)
동시 N개를 모두 t=0 에 띄우면 카드들이 0% 에서 같이 멈춰있다가
한꺼번에 100% 가 되는 "정지된 듯한" 구간이 보였음.

이제 새 다운로드 시작 사이에 최소 2.5초 간격을 두어, 어떤 카드는 70%,
어떤 카드는 30% 식으로 항상 진행 흐름이 이어지게 만든다.

- musicStartChain 으로 acquire 직렬화 → race-free
- nextMusicStartAt 으로 마지막 시작 시점 추적
- 동시성(코어 수 기반) 자체는 그대로 유지, 시작 시점만 분산

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:24:33 +09:00
bb43e8b125 feat(installer-rp): auto-tune music concurrency to CPU core count
os.cpus().length 기준 동시 다운로드 수를 자동 결정:
- 2 코어 이하 → 2 동시
- 3~4 코어 → 3 동시
- 5~8 코어 → 4 동시
- 9 코어 이상 → 5 동시 (YouTube throttle 때문에 상한)

환경변수 MUSIC_CONCURRENCY 로 강제 오버라이드 가능(상한 8).
설치 로그에 감지된 코어 수와 선택된 동시성 노출.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:21:39 +09:00
861e5678fc perf(installer-rp): parallel music download (3 concurrent) + fragmented chunks
- yt-dlp 인자에 --concurrent-fragments 5 추가 (HLS/DASH 청크 병렬 다운로드)
- yt-dlp 인자에 --newline 추가 (진행률 라인 안정화)
- 음악 다운로드 루프를 단일 순차 → worker pool 3개 동시 처리로 전환
- state.currentChild (단일) → state.activeChildren (Set) 으로 확장,
  취소 시 실행 중인 모든 자식 프로세스 kill
- UI 는 카드 그리드라 병렬 진행 상태가 그대로 표시됨

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 00:19:29 +09:00
9f9cffffeb feat(installer-rp): auto-start install with progress card grid
2단계 페이지 진입 즉시 설치를 시작하고, 음악·사진을 1번부터 카드 그리드로
한눈에 볼 수 있게 만든다. 다운로드는 % 게이지로, 완료/실패는 색상으로 표시.

- main: prep/item/package phase 의 ProgressEvent 를 renderer 로 송신
- music.ts: yt-dlp stdout 의 [download] X% 라인을 파싱해 onProgress 호출
- preload: onProgress 채널 구독 함수 노출
- renderer: 다음 버튼 제거, prep chip + music/image 카드 그리드 + 빌드 상태
- styles: progressCard / prepChip / progressGrid 스타일 추가

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 20:01:15 +09:00
d5079125cb fix(installer-rp): type archiver warning handler explicitly
archive.on('warning', ...) 콜백의 err 파라미터에 implicit any 가 떠서
strict tsc 빌드가 깨졌다. Error & { code?: string } 로 명시.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 19:51:33 +09:00
4b83d95cbf Resolve pack_format from the pack's mcVersion
The previous hardcoded pack_format 34 + supported_formats 34..75
covered 1.21 through 1.21.11 only, so a pack generated for the
current latest (26.1.2 → format 84) was rejected as outdated.

Add src/installer-rp/packFormat.ts with a 1.21 → 26.2 lookup table
from the Minecraft wiki and resolveResourcePackFormat() that returns
{matched, format}. Unknown mcVersion falls back to the table's most
recent entry, with a log line warning the user.

Plumb mcVersion through the load → install flow:
- rp:packs:load now also fetches /manifest/<key>.json alongside
  /file/list/<key>.json and runs it through the existing
  normalizePackDefinition so the editor and the installer agree on
  the mcVersion shape. Pack manifest load failures fall back to an
  empty mcVersion (which then triggers the latest-format fallback).
- RpFetchedPack carries mcVersion; the install handler hands it to
  buildResourcepackZip.
- buildResourcepackZip drops the constant pack_format / supported_
  formats and uses the resolved format both as pack_format and as
  the {min,max} of supported_formats. Each pack is thus pinned to
  exactly the MC version it was authored for.
- The renderer's pack card now shows "마인크래프트 <version>" in
  the small line so the user can confirm before installing.

Verified locally: pack.mcmeta generated for mcVersion "1.21",
"1.21.6", "26.1.2", and the bogus "99.9.9" produce pack_format
34 / 63 / 84 / 86 (last falls back to the table tail) respectively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:44:46 +09:00
8525517a87 Build resource pack zip and drop it into .minecraft
Add archiver dep (v7 — v8 dropped the function-style default export
that the @types still describe) and a new src/installer-rp/pack.ts
that assembles the resource pack tree under tempDir/resourcepack/
and zips it to %appdata%/.minecraft/resourcepacks/<key>_musicquiz.zip.

The tree matches Minecraft 1.21+ painting variant + custom sound
conventions:

  pack.mcmeta                                           pack_format 34,
                                                        supported 34..75
  assets/musicquiz/sounds.json                          stream:true per track
  assets/musicquiz/sounds/track_NN.ogg                  from tempDir/music
  assets/musicquiz/textures/painting/cover_NN.png       from tempDir/painting

Music NN.ogg is renamed to track_NN.ogg at copy time so the sound
event ids stay readable. The painting_variant JSON definitions are
intentionally NOT generated here — those live in the data pack and
are owned by /op/datapack on the website.

Wire step 2-4 of the install IPC to call buildResourcepackZip with
the now-populated music/painting temp dirs. Step 2-5 is now just a
log line since buildResourcepackZip writes directly to the final
path.

Verified by a node smoke test: tempDir of two stub ogg files plus
two 256x256 PNGs produces a valid zip with the expected entries
and UTF-8 Korean strings in pack.mcmeta description.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:36:58 +09:00
9e96366956 Download and normalize painting images via sharp
Add sharp dep (libvips bindings) — fastest option for the per-image
center-crop + Lanczos resize step. Pure-JS alternatives (jimp) and
spawning ffmpeg per image were both ~5-10x slower in this hot loop.

Add src/installer-rp/images.ts:
- ytIdFromUrl: extracts the video ID from watch?v=, youtu.be/, and
  /shorts|embed/ URL forms
- downloadImage: for YouTube URLs tries i.ytimg.com/vi/<id>/
  maxresdefault.jpg first, falls back to hqdefault.jpg; plain image
  URLs go through a generic HTTP/HTTPS GET that follows 302s
- normalizeToCover: center-crop to min(w,h), Lanczos resize down to
  1024x1024 when larger, never upscales, writes PNG
- coverFileName: returns cover_NN.png with zero-padded NN

Wire step 2-3 of the install handler to download + normalize each
image into <tempDir>/painting/cover_NN.png. Zip build (step 2-4)
will pick those up next.

Verified with synthetic 1200x800 and 2000x1500 buffers: small
input stays 800x800 (no upscale), large input becomes 1024x1024.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:30:11 +09:00
5e3a42ff4f Add ffmpeg prep and music ogg download to rp installer
Add src/installer-rp/ffmpeg.ts that downloads BtbN/FFmpeg-Builds
win64-gpl zip into %appdata%/.mc_custom/, extracts ffmpeg.exe out
of bin/, drops it at %appdata%/.mc_custom/ffmpeg.exe and verifies
with `ffmpeg -version`. Reuses existing extract-zip dep.

Add src/installer-rp/music.ts that spawns yt-dlp with
--extract-audio --audio-format vorbis --ffmpeg-location <ffmpeg.exe>
to produce <tempDir>/NN.ogg per track. Streams yt-dlp stdout to
the log channel and reports stderr on non-zero exit.

Wire both into the install IPC handler: step 2-1 now preps both
binaries, step 2-2 iterates the music list and downloads each
track. Track the currently running child process in state so the
cancel button can kill it instead of waiting for it to finish.

Image / zip / place steps remain stubbed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:23:01 +09:00
860c30fdfe Wire yt-dlp.exe prep into resource pack installer
Add src/installer-rp/ytdlp.ts that downloads
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
into %appdata%/.mc_custom/yt-dlp.exe (fixed path, Windows-only since
the installer is shipped as .exe). If the file is already there and
--version works it is reused; otherwise it is re-downloaded and
verified. The server's existing OS-aware ensureYtDlp stays intact.

The install IPC handler now calls ensureYtDlpExe() in step 2-1 and
logs the resolved path. Music / image / zip / place steps are still
stubbed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:18:01 +09:00
db5a1e0eac Scaffold resource pack installer entry
Add a second Electron entry under src/installer-rp/ + installer-rp/
launched by `npm run installer:rp`. It is structurally separate from
the music quiz installer (own tsconfig, own preload, own renderer),
shares the existing styles via a relative link, and exposes a
three-step UI: pick a 음악퀴즈, run the install, then a 완료 page that
can open the resourcepacks folder or quit.

The install IPC handler currently scaffolds the flow per docs/add.md
(yt-dlp prep → music download → image normalize → zip build → place
at %appdata%/.minecraft/resourcepacks/) but the actual work is still
TODO. Cancel/cleanup of %appdata%/.mc_custom/.temp/ is wired up so
that future iterations can plug each step in without rewiring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 15:11:41 +09:00
da3a398684 Show * mark when list editor has unsaved changes
Add a red asterisk in the top-right of the dashboard header (and
prefix the document title with *) whenever the dirty flag is set,
so the user can tell at a glance that the music/image list hasn't
been saved yet. The indicator is driven by markDirty/markClean so
it follows the existing tracking.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:58:56 +09:00
4d18c93369 Warn on unsaved navigation in list editor
Track a dirty flag set by every state mutation (inline edit, drag,
delete, modal save, fetch playlist, clear, image-from-music, playlist
URL input) and cleared by a successful save. Intercept back-link
clicks with the existing ask() confirm modal. Use beforeunload for
tab close / refresh. Also refactor ask() so cancel paths properly
discard the pending callback instead of leaking handlers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:55:47 +09:00
633a895617 Handle 2D grid orientation for image list drag
The image grid wraps onto multiple rows, so picking the insertion
target by Y-axis alone meant horizontal moves within a row didn't
register. Add a 'grid' orientation that checks Y for row, then X for
column position within the row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:44:12 +09:00
f27c3690e3 Use in-place source move for drag instead of placeholder + display:none
The clone-placeholder approach hid the source element with
display:none, which some browsers treat as drag cancellation. The drag
appeared to "not respond at all" to mouse press.

Switch to a simpler approach: keep the source element in the DOM and
move it directly during dragover. Apply a .dragGhost class after the
drag image is captured (via setTimeout 0) so the source becomes a
translucent dashed placeholder at the prospective drop position. drop
just compares the source's current DOM index to its original
data-index.

Also add cursor:grabbing on :active for visible press feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:39:46 +09:00
e617c71b0a Fix drag regression by gating inline edit behind double-click
The previous version had contenteditable always on for title/artist
spans, which intercepted mousedown and prevented dragstart from firing
on the row. Now the spans render as plain text, and double-click
activates contenteditable + focus + select-all. blur or Enter/Escape
exits edit mode, saves to state, and re-enables row dragging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 14:18:01 +09:00
7ac07a58ef Stable drop-preview drag + image captions
Drag-and-drop UX rewrite:
- The old "highlight target row + margin-grow animation" approach was
  driven by per-row dragenter/dragleave events. Those fire in a noisy
  enter/leave/enter cascade as the pointer crosses sub-elements and as
  the row itself grows under the pointer, which is why the gap was
  pulsing open/closed.
- New approach: a single container-level dragover handler. On dragstart
  the source row is briefly cloned into a translucent "placeholder"
  element (dashed outline, 45% opacity, pointer-events:none) inserted in
  the source's slot; the original is then hidden (display:none) right
  after dragstart so the browser can still capture it as the drag image.
  As the cursor moves over the container we compute which sibling's
  midpoint the pointer just crossed and insertBefore the placeholder
  accordingly. The list length stays constant the whole time, so there
  is no growing/shrinking gap to fight with — what the user sees is the
  dragged item itself shown semi-transparently at the exact drop slot.
  On drop, splice the array using the placeholder's index among the
  non-source children, then re-render.
- The bindContainerDnd helper handles both lists; image grid uses
  vertical Y math (same midpoint rule as the track list since cards
  flow row-by-row in the auto-fill grid). attachDraggable now only sets
  up dragstart/dragend/contextmenu per row; no more dragenter/dragleave.

Image grid:
- Image cards now have a caption below the thumbnail. When the same
  URL appears in the music list, the music entry's title/artist are
  borrowed via captionForImage(url); otherwise "(제목 없음)" muted text.
  Layout changed from a square aspect-ratio card to a flex column:
  .imgWrap holds the square thumbnail, .cardCaption sits underneath
  with single-line title + smaller muted artist line.

CSS cleanup:
- Drop the old .dropAbove margin-grow rules and .dragOver border rule
  on .trackRow/.imageCard. Replaced with .dragPlaceholder +
  .hiddenWhileDragging.
- .imageCard no longer uses aspect-ratio on itself; aspect lives on
  .imgWrap so caption can extend the card vertically.
2026-05-12 13:51:33 +09:00
635c22c7ad Migrate tsconfig to Node16 module/moduleResolution
VS Code surfaced the TS deprecation:
  'moduleResolution=node10' is deprecated and won't work in TS 7.0.

Fix: switch the root tsconfig.json from
  module: CommonJS, moduleResolution: node
to
  module: Node16, moduleResolution: Node16

TypeScript 7 only supports node16/nodenext/bundler. node16 matches the
runtime semantics we already use (Node ≥ 16, CommonJS output via the
absence of "type": "module" in package.json), so the emit is unchanged.

Side effect of Node16 resolution: relative imports must carry the .js
extension. Added .js to every relative import across src/* (17 sites,
8 files). Bare module specifiers (express, electron, node:fs, ...) are
unaffected.

Verified:
- tsc -p tsconfig.server.json — 0 errors
- tsc -p tsconfig.installer.json — 0 errors
- node dist/server/app.js boots; /op login → 302, /op/list → 200
2026-05-12 13:41:58 +09:00
7316477e23 Inline edit, URL-driven meta refresh, drag gap animation, copy-from-music
Music list tab:
- Title + artist are now contenteditable in-place. Typing updates state on
  every input event; Enter blurs (no embedded newlines), and on Save the
  current DOM text is re-synced into state.music[i].title/.artist before
  the JSON POST so a quick-save right after typing doesn't drop the last
  keystroke. While focused, the field is highlighted with the accent
  outline and unclamped so long titles wrap. Empty cells show a muted
  placeholder via :empty::before { content: attr(data-placeholder) }.
- Edit modal "save" now POSTs the new URL to /op/list/<pack>/video-meta
  (new endpoint backed by yt-dlp --dump-json --no-playlist) and patches
  state.music[i] with the returned title/channel/durationSec. If yt-dlp
  is unavailable or the lookup fails, the modal asks the user whether to
  apply just the URL change without metadata.
- Drag-and-drop UX: replaced the simple "highlight target" feedback with a
  "gap opens above the target" animation. The hovered row gets a
  .dropAbove class that animates margin-top to 64px via CSS transition,
  visually carving out the slot the dragged item will land in. Insertion
  math is now strictly "drop before the hover target" (with the
  srcIdx<dstIdx → dstIdx-1 adjustment after the splice removal), matching
  what the gap implies. Contenteditable spans no longer hijack drag start
  — on focus the parent <li>.draggable is flipped off so the user can
  freely select text, and back on at blur.

Image list tab:
- New "음악목록에서 가져오기" button. Copies state.music[*].url into
  state.images, which (via the existing thumbUrl() helper) renders each
  song's YouTube thumbnail as the image. Behind a confirm prompt because
  it replaces the entire image list.
- Same drag gap animation (.dropAbove → margin-left 80px) applied to the
  grid cards for consistency.

Server:
- youtube.ts: add fetchVideoMeta(url) using the same ensureYtDlp() path
  (auto-installed binary under %appdata%/.mc_custom). Returns one
  YtPlaylistEntry or null.
- routes/op.ts: POST /op/list/:packName/video-meta. 400 on missing URL,
  503 NO_YTDLP if the auto-install failed, 500 on other yt-dlp errors,
  200 { ok: true, entry } otherwise.

Smoke test (Rick Astley URL) returns
title=Rick Astley - Never Gonna..., channel=Rick Astley, durationSec=213.
2026-05-12 13:38:29 +09:00
7349d4e71e Auto-install yt-dlp into %appdata%/.mc_custom on first use
The previous flow required the operator to manually install yt-dlp on the
server. Now the server downloads the correct binary for the current OS/arch
from GitHub Releases (latest) the first time a playlist fetch is requested,
caches it under %appdata%/.mc_custom (resolved per-OS), chmod +x's it on
POSIX, and skips the download on every subsequent call.

- shared/paths.ts: add getAppDataDir() + getMcCustomDir() that map %appdata%
  to ~/.config (Linux), ~/Library/Application Support (macOS), or
  process.env.APPDATA (Windows) so the path is OS-agnostic.
- server/youtube.ts: replace the old "probe PATH and bail" probeYtDlp with
  ensureYtDlp(): picks the right GitHub asset name per platform.arch
  (yt-dlp.exe / yt-dlp_macos / yt-dlp_linux / yt-dlp_linux_aarch64 /
  yt-dlp_linux_armv7l), downloads it with redirect-following https.get to
  the install path, chmods +x, then verifies with `--version` before
  reporting success. Uses an installPromise singleton so concurrent
  requests don't race the download. On any failure it cleans up the
  partial file and throws YtDlpUnavailableError with a real reason.
- docs/yt-dlp-setup.md: note that auto-install is now the default; manual
  install is only for environments where the auto-download fails.

Smoke-tested on this Linux x64 box: first call downloads to
~/.config/.mc_custom/yt-dlp_linux and reports `2026.03.17`; second call
takes ~700ms (just the version probe) and reuses the cached file.
2026-05-12 13:15:25 +09:00
a532ce5507 Fix op page UI: button underline, tab panel duplication, header spacing
- /op/dashboard: remove underline from <a class="secondaryButton"> by adding
  text-decoration:none + inline-flex centering on .primaryButton/.secondaryButton/
  .dangerButton, plus margin-left:auto on .dashboardActions so the buttons stick
  to the right side of the row even when the header is laid out as flex+wrap.
- /op/list/<pack>: fix the duplicate "save/clear + playlist URL" UI showing in
  the active tab. .tabPanel had a baseline `display: block;` that overrode the
  browser default `[hidden] { display: none }`. Add an explicit
  `.tabPanel[hidden] { display: none !important }` rule so the inactive panel
  actually hides.
- /op/list, /op/list/<pack>, /op/datapack: bump the gap between the "돌아가기"
  ghost link and the page title from 8px to 20px.
- docs/yt-dlp-setup.md: install guide (single-binary curl + pipx + apt) since
  the server doesn't have pip/pipx/yt-dlp installed. Server keeps the graceful
  "수동 입력으로 진행" fallback when yt-dlp is missing.
2026-05-12 13:11:44 +09:00
a2817c921d Add /op/list, /op/list/:pack, /op/datapack web admin + spec lock
docs/add.md
 - 사진 PNG 규격을 1024×1024 (4×4 블록 슬롯 × ×16 배율) 로 못박음
 - 짧은 변 기준 가운데 정사각 크롭 + 1024 초과 시만 축소, 미만은 native 유지

신규 라우트 (모두 requireAuth):
 - GET  /op/list                          → manifest 카드 목록
 - GET  /op/list/:pack                    → 음악목록·사진목록 탭 편집기
 - POST /op/list/:pack                    → file/list/<pack>.json 저장 (JSON)
 - POST /op/list/:pack/playlist           → yt-dlp 로 플레이리스트 펼치기
 - GET  /op/datapack                      → 음악퀴즈 선택 + 출력
 - GET  /op/datapack/:pack/generate       → 임시 포맷 mcfunction 텍스트

shared/types.ts: PackList / MusicListEntry / ImageListEntry
shared/store.ts: loadPackList, savePackList, normalizePackList
shared/paths.ts: fileListDirPath = file/list/
server/youtube.ts: yt-dlp 플레이리스트 펼치기 (--flat-playlist --dump-json),
  설치 안 됐을 때 NO_YTDLP 코드로 503.

UI:
 - views/op/list.ejs: 가로 카드 목록 + 돌아가기 버튼
 - views/op/listEditor.ejs + public/listEditor.js: 탭 전환, 드래그 정렬,
   우클릭 컨텍스트 메뉴(수정/삭제), 사진 수정 모달의 [유튜브 / 이미지] 토글,
   목록 저장·초기화·플레이리스트 불러오기 확인 팝업
 - views/op/datapack.ejs: 음악퀴즈 카드 선택 팝업 → 데이터팩 출력 + 복사
 - views/op/dashboard.ejs: 상단에 [음악목록 수정] [데이터팩 수정] 버튼
 - public/styles.css: 탭, 트랙 로우, 이미지 그리드, 컨텍스트 메뉴, 모달, 코드블록

.gitignore: conversations/ 추가.

스모크: login → /op/list 렌더, POST 저장 라운드트립 OK,
/op/datapack/.../generate 텍스트 출력 OK, 플레이리스트 fetch는 yt-dlp 미설치
환경에서 503 NO_YTDLP 메시지 정상.

Section 1 (리소스팩 설치기 EXE: yt-dlp 음악 다운로드, painting variant
텍스처 패키징, 리소스팩 zip 배치) 은 후속 커밋에서 작업.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:38:30 +09:00
26cc625de6 docs: 리소스팩 설치기·웹사이트 추가사항 정리
add.txt를 md로 옮기면서 비어 있던 칸을 채움:
- 이미지 다운로드 방식 (yt 썸네일 + 일반 URL)
- 리소스팩 패키징 흐름 (painting-variant.md 슬롯 규격에 맞춰 변환)
- 사진목록 UI: 반응형 그리드 카드 + 번호 배지 + 토글 입력
- /op/datapack mcfunction 출력 placeholder

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 11:22:49 +09:00
d4ef76bc7f docs: painting variant 이미지 추가 가이드
리소스팩 PNG 교체만으로 그림 갈이가 가능한
1.21+ painting variant 시스템 정리.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:50:35 +09:00
8574aeffa2 Track file/ test artifacts in repo
Remove file/* from .gitignore so the 음악퀴즈_test pack is reproducible
across clones. The fabric-installer.jar is the real upstream 1.1.1
(205KB); the rest are minimal stub zips/jar built locally.

  file/platforms/fabric-installer.jar          (fabric-installer 1.1.1)
  file/mods/music-quiz/music-quiz-test-mod.jar (stub fabric mod jar)
  file/resourcepacks/music-quiz.zip            (stub resourcepack)
  file/maps/music-quiz-map.zip                 (stub world)
  file/servers/music-quiz-server.zip           (server stub with eula=false)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 10:07:16 +09:00
678e886a52 Wire up 음악퀴즈_test manifest to local test artifacts
Fill empty platform.downloadUrl / modsFolder / resourcepackPath / serverPath
so the test pack drives a complete installer flow end-to-end.

Test artifacts (gitignored, must be present locally under file/):
  file/platforms/fabric-installer.jar  (real fabric installer 1.0.1)
  file/mods/music-quiz/music-quiz-test-mod.jar  (stub fabric mod)
  file/resourcepacks/music-quiz.zip  (stub resourcepack)
  file/maps/music-quiz-map.zip  (stub world)
  file/servers/music-quiz-server.zip  (stub server with eula.txt=false to exercise EULA popup)

mcVersion "26.1.2" left as user authored; not a real MC version,
so 게임 실행 단계는 실패할 수 있음 — installer flow itself runs through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 03:36:30 +09:00
tkrmagid-desktop
4e7a7023cd 기본 내용 수정 2026-05-11 03:31:07 +09:00
66 changed files with 7909 additions and 880 deletions

4
.env.build Normal file
View File

@@ -0,0 +1,4 @@
# 빌드용 환경변수 — `npm run dist:win` / `npm run dist:win:rp` 로 패키징될 때
# 설치기 exe 의 `resources/.env.build` 로 함께 배포되어 런타임에 로드됨.
# 서버 운영용(PORT/HOST/SESSION_SECRET) 값은 여기 두지 말고 `.env` 에.
SITE_BASE_URL=https://mc.tkrmagid.kr

37
.env.build.example Normal file
View File

@@ -0,0 +1,37 @@
# =============================================================================
# 음악퀴즈 통합 패키지 — 빌드용 환경변수 템플릿
#
# 이 파일은 `npm run dist:win` / `npm run dist:win:rp` 로 exe 를 패키징할 때
# 설치기(installer / installer-rp) 안에 함께 묶이는 값들입니다.
# 개발 실행에서 쓰는 `.env` 와는 분리되어 있어, 운영 도메인 같은 값을 빌드용
# 으로만 관리할 수 있습니다.
#
# 사용법:
# 1) 이 파일을 복사해 `.env.build` 로 만든다.
# 2) 운영 도메인 등 배포에 들어갈 값으로 채운다.
# 3) `npm run dist:win` 또는 `npm run dist:win:rp` 로 빌드한다.
# → electron-builder 가 `.env.build` 를 패키지된 exe 의
# `resources/.env.build` 로 함께 배포.
# → 런타임에서 `env.ts` 가 우선 로드.
#
# `.env.build` 는 .gitignore 로 제외되어 있습니다.
# 서버(express) 운영용 PORT / HOST / SESSION_SECRET 같은 변수는 여기 두지 말고
# 서버 측 `.env` 에 두세요. 이 파일은 설치기 exe 에 묶이는 값 전용입니다.
# =============================================================================
# ----- 사이트 도메인(설치기가 manifest 를 받아갈 주소) -----
# 설치기 두 종(installer / installer-rp) 이 첫 화면에서 자동으로 채워 넣는
# manifest 의 호스트. 프로토콜 + 호스트(+포트) 까지만 적고 슬래시는 끝에 붙이지 않음.
# 예) 운영 도메인 : https://mq.example.com
# 로컬 개발 : http://127.0.0.1:3000
SITE_BASE_URL=https://mq.example.com
# 위 SITE_BASE_URL 로부터 자동으로 `${SITE_BASE_URL}/manifest.json` 이 생성됩니다.
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
# MANIFEST_URL=https://mq.example.com/manifest.json
# ----- 리소스팩 설치기 -----
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.
# MUSIC_CONCURRENCY=

33
.env.example Normal file
View File

@@ -0,0 +1,33 @@
# =============================================================================
# 음악퀴즈 통합 패키지 — 환경변수 템플릿
# 이 파일을 복사해 `.env` 로 만든 뒤 값만 수정해 사용하세요.
# `.env` 는 .gitignore 로 제외되어 있습니다.
# =============================================================================
# ----- 관리 사이트(서버) -----
# 서버가 listen 할 포트
PORT=3000
# 서버 바인드 주소. 127.0.0.1 이면 로컬 전용, 0.0.0.0 이면 외부 노출.
HOST=127.0.0.1
# Express 세션 시크릿. 운영 환경에서는 반드시 추측 어려운 무작위 값으로.
SESSION_SECRET=music-quiz-installer-dev-secret
# ----- 사이트 도메인(설치기가 manifest 를 받아갈 주소) -----
# 설치기 두 종(installer / installer-rp) 이 첫 화면에서 자동으로 채워 넣는
# manifest 의 호스트. 프로토콜 + 호스트(+포트) 까지만 적고 슬래시는 끝에 붙이지 않음.
# 예) 운영 도메인 : https://mq.example.com
# 로컬 개발 : http://127.0.0.1:3000
SITE_BASE_URL=http://127.0.0.1:3000
# 위 SITE_BASE_URL 로부터 자동으로 `${SITE_BASE_URL}/manifest.json` 이 생성됩니다.
# 특별히 다른 경로를 쓰고 싶을 때만 아래를 풀어서 우선 적용시키세요.
# MANIFEST_URL=http://127.0.0.1:3000/manifest.json
# ----- 리소스팩 설치기 -----
# yt-dlp 동시 다운로드 수(1~8). 비워두면 CPU 코어 수로 자동 결정.
# MUSIC_CONCURRENCY=

6
.gitignore vendored
View File

@@ -3,5 +3,7 @@ dist/
release/
logs/
*.log
file/*
!file/.gitkeep
conversations/
.env
.env.local
.env.*.local

462
README.md
View File

@@ -1,356 +1,192 @@
# 마인크래프트 음악퀴즈 간편설치기 + 관리 사이트 개발 명세서
# 마인크래프트 음악퀴즈 통합 패키지
## 프로젝트 개요
마인크래프트 음악퀴즈를 한 번에 배포·관리할 수 있도록 만든 통합 프로젝트입니다.
마인크래프트 음악퀴즈를 `.exe` 하나로 간편하게 설치할 수 있는 설치기와, 음악퀴즈 정보를 관리하는 웹사이트를 개발한다.
- **관리 사이트** — 음악퀴즈 정보(JSON)와 음악·사진 목록, 데이터팩 출력을 한 곳에서 운영.
- **음악퀴즈 간편설치기 (`.exe`)** — `manifest.json` 기반으로 사용자가 마인크래프트 본체·서버·모드를 자동 설치.
- **리소스팩 간편설치기 (`.exe`)** — 음악퀴즈 음악·표지를 yt-dlp 로 받아 painting variant 텍스처 리소스팩으로 패키징.
### 핵심 컨셉
- 이 프로젝트는 `%appdata%\.mc_custom` 폴더를 생성하여 모드 적용 및 서버 실행을 독립적으로 관리한다.
- 음악퀴즈 정보는 **`manifest.json`** 으로 중앙 관리한다.
---
### .mc_custom 폴더 구조
## 무엇이 들어 있나
| 디렉터리 | 역할 | 진입점 |
| --- | --- | --- |
| `src/server/` | 음악퀴즈 관리 웹사이트 (Express + EJS) | `bun start` 또는 `npm start` |
| `src/installer/` | 음악퀴즈 간편설치기 (Electron) | `npm run installer` |
| `src/installer-rp/` | 리소스팩 간편설치기 (Electron) | `npm run installer:rp` |
| `src/shared/` | 두 설치기와 서버가 공유하는 타입·스토어 | — |
| `views/` | EJS 템플릿 (관리 사이트) | — |
| `manifest/` | 음악퀴즈별 정의 JSON | — |
| `file/list/` | 음악퀴즈별 음악·사진 목록 JSON | — |
| `file/` | 정적 파일(서버 zip / 맵 zip / 모드 jar 등) 호스팅 | — |
---
## 핵심 컨셉
설치기는 사용자의 `%APPDATA%\.minecraft` 를 더럽히지 않기 위해 **`.mc_custom`** 을 별도 게임 디렉터리로 사용합니다.
```
%appdata%\.mc_custom\
├── mods/ ← 모드 (.jar) 저장 및 자동 적용
├── resourcepacks/ ← 리소스팩 (.zip) 저장 및 자동 적용
├── saves/ ← 월드 저장
├── config/ ← 모드 설정 파일
├─ screenshots/ ← 스크린샷
└── options.txt ← 게임 설정
%APPDATA%\
├─ .minecraft\ ← 원래 마인크래프트 폴더(공용 자원: assets, libraries, versions, runtime)
└─ .mc_custom\ ← 음악퀴즈 전용 게임 폴더(설치기가 자동 생성)
├─ mods\ ← 음악퀴즈가 지정한 모드(.jar)
├─ resourcepacks\ ← 리소스팩(.zip)
├─ saves\ ← 단일 맵 .zip 압축 해제 결과
├─ assets\ (junction → .minecraft\assets)
├─ libraries\ (junction → .minecraft\libraries)
├─ versions\ (junction → .minecraft\versions)
├─ options.txt 등 ← `.minecraft` 의 최상위 설정 파일을 복사해 사용
└─ launcher_profiles ← 실제 파일은 `.minecraft\launcher_profiles.json` 을 수정해 gameDir=.mc_custom 으로 지정
```
- 마인크래프트 런처 프로필의 `gameDir``%appdata%\.mc_custom`으로 설정하면, 마인크래프트가 이 폴더를 기준으로 모든 파일을 읽고 저장한다.
- 버전 파일과 assets는 기존 `%appdata%\.minecraft`를 그대로 사용한다.
이렇게 분리해 두면 사용자가 평소 쓰던 마인크래프트와 음악퀴즈 설정이 섞이지 않고, 음악퀴즈만 삭제해도 본체에는 영향이 없습니다.
---
## 파트 1. 간편설치기 (`.exe`)
## 빠른 시작
> 설치기는 아래 단계를 순서대로 페이지 단위로 진행한다. 각 번호 = 1페이지.
전제: Node.js 18+, npm. 윈도우 빌드를 만들 때만 추가로 Electron 의 PE 서명·아이콘 도구가 필요합니다.
### 1단계: 음악퀴즈 선택
```bash
# 의존성 설치
npm install
- 음악퀴즈사이트(아래 파트 2 참고)에서 `manifest.json`을 가져와 등록된 음악퀴즈 목록을 표시한다.
- 사용자가 설치할 음악퀴즈를 선택한다.
# 환경변수 템플릿 복사 (처음 한 번만)
cp .env.example .env
---
# 1) 관리 사이트 개발 실행 (http://localhost:3000)
npm start
### 2단계: 싱글 / 멀티 선택
# 2) 음악퀴즈 간편설치기를 Electron 으로 실행해 보기
npm run installer
- 싱글 또는 멀티 중 하나를 선택하는 화면을 표시한다.
- **멀티 선택 시**: 3단계(서버 설치)를 거친 후 4단계로 진행한다.
- **싱글 선택 시**: 3단계를 건너뛰고 4단계로 바로 진행한다.
# 3) 리소스팩 간편설치기를 Electron 으로 실행해 보기
npm run installer:rp
---
### 3단계: 서버 관련 설정
- 2단계에서 **멀티**를 선택한 경우에만 진입한다. 싱글 선택 시 이 단계 전체를 건너뛴다.
- 3단계의 각 소항목(3-1 ~ 3-5)은 완료되어도 자동으로 다음으로 넘어가지 않으며, 사용자가 **확인 버튼**을 눌러야 다음 소항목으로 진행된다.
#### 3-1. 서버 설치 경로 설정
- 서버를 생성할 폴더 경로를 사용자가 직접 지정한다.
- **경로에 한글이 포함되면 안 된다.** 한글 포함 시 경고 메시지를 표시하고 다음 단계로 진행 불가.
#### 3-2. JDK 확인 / 설치
- 폴더 선택 UI로 JDK 경로를 지정할 수 있다.
- JDK 자동 탐색 우선순위:
1. 시스템 환경변수 (`JAVA_HOME` 등)
2. 로컬 환경변수
3. `C:\Program Files\Java` (JDK 기본 설치 경로)
- 위 경로에서 JDK가 발견되면 해당 경로를 기본값으로 자동 설정한다.
- JDK가 없으면 설치를 안내한다.
#### 3-3. 서버 다운로드 및 설치
- 처음 파일 다운로드는 자동으로 시작
##### 3-3-1. 파일 다운로드
- JSON의 `packPath` 필드 값을 `도메인/file/` 뒤에 붙여 서버 파일 다운로드 URL을 구성한다.
- 예: `packPath``music-quiz/files`이면 → `도메인/file/music-quiz/files`
- 해당 경로의 모든 파일을 순서대로 다운로드한다.
##### 3-3-2. 설치 로그
- 다른 프로그램 설치 화면처럼 실시간 로그를 표시하는 로그 뷰어를 제공한다.
##### 3-3-3. EULA 동의
- 설치 중간에 Minecraft EULA 동의 화면을 표시하고, 사용자가 직접 동의해야 다음 단계로 진행된다.
- 음악퀴즈 내에 `eula.txt`가 포함되어 있으면 **삭제하고** 새로 동의를 받는다.
##### 3-3-4. 램 검사 로직
> 아래 기준은 모두 JSON의 서버 램 필드(`serverMinRam`, `serverMaxRam`)를 사용한다.
```
유저 시스템 램 >= serverMaxRam → serverMaxRam으로 설정
유저 시스템 램 >= serverMinRam → serverMinRam으로 설정 (경고 메시지 표시)
유저 시스템 램 < serverMinRam → "플레이 불가" 메시지 출력 후 설치 중단
# 4) 음악퀴즈 간편설치기 윈도우 .exe 빌드
npm run dist:win
```
- 서버 실행 시 `-Xmx``serverMaxRam`, `-Xms``serverMinRam` 값을 JVM 인자로 사용한다.
#### 3-4. 서버 설정
- **로컬 웹서버**를 띄워 브라우저에서 서버 설정 파일을 GUI로 편집할 수 있게 한다.
- 메모장으로 수정해야 했던 파일들을 설명과 함께 편리하게 수정 가능:
- `bukkit.yml`
- `server.properties`
- 기타 설정 파일
- 수정 후 "적용" 버튼으로 실제 파일에 반영한다.
#### 3-5. 서버 포트포워딩 설정
1. **이미 포트포워딩 되어 있는 경우** (UPnP 포함): 외부 접속 주소를 화면에 표시하고 다음 단계로 진행.
2. **포트포워딩 안 된 경우 → UPnP 시도**:
- UPnP로 포트 개방 가능 여부 확인
- 가능하면 자동으로 개방 후 외부 접속 테스트
- 접속 확인되면 다음 단계로 진행 가능
3. **UPnP 불가 시**: "직접 포트포워딩을 해주세요." 메시지를 안내와 함께 표시.
리소스팩 설치기는 `yt-dlp` 가 필요합니다. 자동 다운로드되지만, 막혀 있는 환경이라면 [`docs/yt-dlp-setup.md`](docs/yt-dlp-setup.md) 참고.
---
### 4단계: 유저 클라이언트 설정
- 음악퀴즈 JSON 파일에 명시된 클라이언트 설정을 기반으로 설치한다.
## 자주 쓰는 문서
#### 4-1. 모드 플랫폼 설치
- JSON 파일의 `platform.type`에 명시된 플랫폼 이름을 화면에 표시하고, **설치** / **건너뛰기** 버튼으로 사용자가 직접 선택한다.
- `vanilla`가 아닌 경우: `platform.downloadUrl`을 기반으로 다운로드 후 설치한다.
- **건너뛰기** 선택 시: 플랫폼 설치 없이 바닐라로 진행한다.
#### 4-2. 설치설정 설정
- `%appdata%\.minecraft\launcher_profiles.json`에 프로필을 추가한다.
- 이미 동일한 이름의 프로필이 있으면 새로 만들지 않고 기존 프로필을 수정한다.
- **`gameDir``%appdata%\.mc_custom`으로 설정한다.**
- 이 설정으로 인해 모드, 리소스팩, 세이브 등 모든 파일이 `.mc_custom` 기준으로 읽히고 저장된다.
- 램 설정(`javaArgs`)을 JSON의 서버 램 필드 기준으로 적용한다.
- `-Xmx``serverMaxRam`, `-Xms``serverMinRam` 값을 사용한다.
- 플랫폼(Forge 등) 설치 버전을 `lastVersionId`로 설정한다.
```json
// launcher_profiles.json 프로필 예시
{
"음악퀴즈": {
"name": "음악퀴즈",
"type": "custom",
"gameDir": "%appdata%\\.mc_custom",
"lastVersionId": "1.20.1-forge-47.2.0",
"javaArgs": "-Xmx4G -Xms2G"
}
}
```
#### 4-3. 모드 및 리소스팩 설치
- JSON 파일에 명시된 모드와 리소스팩을 다운로드하여 `.mc_custom` 하위 폴더에 저장한다.
- 다운로드 진행 상황을 실시간 로그 뷰어로 표시한다. (다른 프로그램 설치 화면과 동일한 형태)
| 파일 종류 | 저장 경로 |
|-----------|-----------|
| 모드 (`.jar`) | `%appdata%\.mc_custom\mods\` |
| 리소스팩 (`.zip`) | `%appdata%\.mc_custom\resourcepacks\` |
- 이미 동일한 파일명이 존재하면 덮어쓴다.
- 다운로드 완료 후 마인크래프트 실행 시 자동으로 적용된다. (별도 설정 불필요)
| 문서 | 내용 |
| --- | --- |
| [`docs/installer.md`](docs/installer.md) | 음악퀴즈 간편설치기 사용자 흐름(단계별 화면·동작). |
| [`docs/admin-site.md`](docs/admin-site.md) | 관리 사이트 운영자 가이드(음악퀴즈 추가·편집, 음악 목록, 데이터팩 출력). |
| [`docs/resourcepack-installer.md`](docs/resourcepack-installer.md) | 리소스팩 간편설치기 동작 명세(yt-dlp 흐름, 이미지 정규화 규칙). |
| [`docs/painting-variant.md`](docs/painting-variant.md) | 1.21+ painting variant 슬롯/리소스팩 텍스처 규격. |
| [`docs/yt-dlp-setup.md`](docs/yt-dlp-setup.md) | yt-dlp 자동/수동 설치, 트러블슈팅. |
---
### 5단계: 완료 페이지
## 데이터 포맷 (요약)
- 멀티로 진행해서 서버도 설치했다면:
- **서버 폴더 열기** 버튼
- **바탕화면에 서버 실행 바로가기 만들기** 토글 (기본값: ON)
- **서버 바로 실행** 토글 (기본값: ON)
- 마인크래프트 런처 실행 토글 (기본값: ON)
자세한 필드 설명은 `docs/admin-site.md` 의 "음악퀴즈 JSON" 절을 참고하세요.
---
## 파트 2. 음악퀴즈 관리 웹사이트
> **기술 스택: Node.js + TypeScript + Express + EJS**
### 라우팅 구조
| 경로 | 설명 |
|------|------|
| `/` | 메인 페이지 (음악퀴즈 목록) |
| `/manifest.json` | manifest.json 파일 직접 접근 |
| `/file/` | 음악퀴즈 파일 제공 경로 |
| `/op` | 관리자 로그인 페이지 |
| `/op/dashboard` | 관리자 대시보드 |
| `/op/dashboard/:packName` | 음악퀴즈 JSON 편집 페이지 |
---
### 메인 페이지 (`/`)
- `manifest.json`에 등록된 음악퀴즈를 **가로 한 줄 목록(카드) 형식**으로 표시한다.
---
### 관리자 인증 (`/op`)
- `/op` 하위 모든 경로는 로그인 없이 접근 불가 (미들웨어로 처리).
- 로그인 화면: 아이디 + 비밀번호 입력.
- 계정 정보는 **`account.json`** 파일에 저장.
- `account.json`**서버 내부에서만 접근 가능**, 외부 HTTP 요청으로 절대 노출되지 않아야 함.
- 로그인 성공 시 `/op/dashboard`로 리다이렉트.
#### account.json 예시 구조
```json
[
{
"id": "admin",
"password": "admin"
}
]
```
---
### 관리자 대시보드 (`/op/dashboard`)
#### 공통 레이아웃 (메뉴바)
- **왼쪽**: 로고 + "관리자 페이지" 텍스트 → 클릭 시 `/op/dashboard`로 이동
- **오른쪽**: 로그인한 아이디 표시 → 클릭 시 드롭다운 메뉴 표시
- 드롭다운 항목: **로그아웃** 버튼
#### 음악퀴즈 목록
- `/manifest` 폴더 안의 JSON 파일들을 가져와 **가로 한 줄 카드 형식**으로 표시.
- 카드 클릭 → `/op/dashboard/:packName` 으로 이동하여 JSON 편집 시작.
#### 음악퀴즈 추가 버튼
- `/manifest/` 폴더 안에 새 JSON 파일 생성.
- 기본 이름: `new.json`
- 이미 존재하면: `new2.json`, `new3.json` ... 순으로 증가.
- 생성과 동시에 `manifest.json`에도 자동 등록.
#### 음악퀴즈 삭제 버튼
- 버튼 클릭 시:
- 목록 카드에 **체크박스** 표시
- 버튼 아래 **취소** / **확인** 버튼 표시
- 확인 클릭 시 체크된 JSON 파일 삭제 + `manifest.json`에서도 자동 제거.
---
### 음악퀴즈 편집 페이지 (`/op/dashboard/:packName`)
- 해당 JSON 파일의 내용을 GUI 폼으로 편집한다.
- **JSON 파일 이름 변경** 기능 제공.
- 이름 변경 후 적용 클릭 시 현재 브라우저 URL의 `:packName` 부분도 자동 변경 (리다이렉트).
---
## 파트 3. 음악퀴즈 JSON 구조 및 편집 항목
> `/manifest/*.json` 파일의 구조. 관리자 편집 페이지에서 아래 항목들을 GUI로 수정 가능.
### JSON 필드 정의
| 필드명 | 타입 | 설명 |
|--------|------|------|
| `name` | `string` | 음악퀴즈 이름 |
| `mcVersion` | `string` | 마인크래프트 버전 (스냅샷 제외한 정식 릴리즈만) |
| `platform` | `object` | 모드 플랫폼 정보 (아래 참고) |
| `platform.type` | `string` | 플랫폼 종류 (`vanilla` / `forge` / `fabric` / `neoforge` 등) |
| `platform.downloadUrl` | `string` | 플랫폼 설치파일 다운로드 URL (바닐라면 생략) |
| `mods` | `array` | 설치할 모드 목록 (아래 참고) |
| `mods[].name` | `string` | 모드 이름 |
| `mods[].downloadUrl` | `string` | 모드 다운로드 URL |
| `resourcepacks` | `array` | 설치할 리소스팩 목록 |
| `resourcepacks[].name` | `string` | 리소스팩 이름 |
| `resourcepacks[].downloadUrl` | `string` | 리소스팩 다운로드 URL |
| `serverMinRam` | `number` | 서버 최소 램 (MB 단위) |
| `serverMaxRam` | `number` | 서버 최대 램 (MB 단위) |
| `clientMinRam` | `number` | 유저(클라이언트) 최소 램 (MB 단위) |
| `clientRecommendedRam` | `number` | 유저(클라이언트) 권장 램 (MB 단위) |
| `packPath` | `string` | 서버 파일 경로 (`/file/` 이후의 경로, 멀티 전용) |
### JSON 예시
```json
{
"name": "음악퀴즈 v1",
"mcVersion": "1.20.1",
"platform": {
"type": "forge",
"downloadUrl": "https://example.com/forge-installer.jar"
},
"mods": [
{
"name": "ExampleMod",
"downloadUrl": "https://example.com/examplemod.jar"
}
],
"resourcepacks": [
{
"name": "ExampleResourcePack",
"downloadUrl": "https://example.com/resourcepack.zip"
}
],
"serverMinRam": 2048,
"serverMaxRam": 8192,
"clientMinRam": 4096,
"clientRecommendedRam": 8192,
"packPath": "music-quiz/files"
}
```
### 편집 UI 항목별 비고
| 항목 | UI 형태 | 비고 |
|------|---------|------|
| `mcVersion` | 드롭다운 | Mojang API에서 정식 릴리즈만 가져와 표시, 스냅샷 제외 |
| `platform.type` | 드롭다운 | `vanilla` 선택 시 `downloadUrl` 입력란 숨김 |
| `mods` | 동적 목록 | 항목 추가 / 삭제 가능 |
| `resourcepacks` | 동적 목록 | 항목 추가 / 삭제 가능 |
| 램 관련 필드 | 숫자 입력 | MB 단위, `clientMinRam``clientRecommendedRam` 유효성 검사 |
| `packPath` | 텍스트 입력 | `/file/` 이후 경로만 입력 |
---
## 파트 4. manifest.json 구조
> 사이트 루트의 `manifest.json`. 설치기와 메인 페이지가 이 파일을 읽는다.
### `manifest.json` — 사이트 루트, 음악퀴즈 목록
```json
{
"packs": [
{
"name": "음악퀴즈 이름",
"file": "new"
}
{ "name": "음악퀴즈 v1", "file": "mq-v1" }
]
}
```
- `file`: `/manifest/` 폴더 안의 JSON 파일 이름 (확장자 제외).
- 음악퀴즈 추가/삭제 자동으로 이 파일도 업데이트된다.
- `file` `/manifest/<file>.json` 의 파일명(확장자 제외).
- 관리 사이트에서 음악퀴즈 추가/삭제하면 자동으로 갱신됩니다.
### `/manifest/<key>.json` — 음악퀴즈 정의
```json
{
"name": "음악퀴즈 v1",
"mcVersion": "1.21.4",
"platform": {
"type": "fabric",
"loaderVersion": "0.16.10"
},
"modsFolder": "mq-v1",
"resourcepackPath": "mq-v1.zip",
"mapPath": "mq-v1-map.zip",
"serverPath": "mq-v1-server.zip",
"serverMinRam": 4096,
"serverMaxRam": 8192,
"clientMinRam": 4096,
"clientRecommendedRam": 8192
}
```
- `platform.type` = `vanilla` / `forge` / `fabric` / `neoforge`.
- `fabric``loaderVersion` 만 지정하면 설치기가 최신 fabric-installer 로 자동 CLI 설치합니다.
- 나머지(forge/neoforge) 는 `platform.downloadUrl` 에 설치 jar URL.
- `modsFolder``/file/mods/<폴더>/` 의 모든 `.jar` 를 자동으로 받습니다.
- `serverPath` / `mapPath` / `resourcepackPath``/file/servers/`, `/file/maps/`, `/file/resourcepacks/` 아래 zip 파일명.
### `/file/list/<key>.json` — 음악·사진 목록 (리소스팩 설치기용)
```json
{
"musicPlaylistUrl": "https://www.youtube.com/playlist?list=...",
"imagePlaylistUrl": "https://www.youtube.com/playlist?list=...",
"music": [
{ "url": "https://www.youtube.com/watch?v=...", "title": "...", "artist": "...", "durationSec": 213 }
],
"images": [
{ "url": "https://www.youtube.com/watch?v=..." },
{ "url": "https://example.com/cover.png" }
]
}
```
---
## 디렉리 구조 (웹사이트)
## 디렉리 구조 (전체)
```
project-root/
├── manifest.json # 음악퀴즈 목록 (외부 접근 가능)
├── account.json # 관리자 계정 정보 (외부 접근 절대 불가)
├── /manifest/ # 음악퀴즈 JSON 파일 저장 폴더
├── new.json
└── music-quiz.json
├── /file/ # 서버 파일 제공 경로 (멀티 전용)
├── /src/ # TypeScript 소스
│ ├── app.ts
│ ├── routes/
├── index.ts # 메인 페이지
│ │ └── op.ts # 관리자 라우트
└── middleware/
└── auth.ts # /op 인증 미들웨어
└── /views/ # EJS 템플릿
├── index.ejs
├── op/
│ ├── login.ejs
├── dashboard.ejs
└── editor.ejs
└── partials/
└── navbar.ejs
minecraft_launcher/
├─ src/
│ ├─ server/ Express + EJS 관리 사이트
│ ├─ installer/ 음악퀴즈 간편설치기 (Electron 메인 + preload)
├─ installer-rp/ 리소스팩 간편설치기 (Electron 메인 + 음악/이미지 파이프라인)
└─ shared/ 공용 타입, 매니페스트 스토어, mojang/upnp 유틸
├─ installer/ 음악퀴즈 설치기 렌더러(HTML/CSS/JS)
├─ installer-rp/ 리소스팩 설치기 렌더러(HTML/CSS/JS)
├─ views/ 관리 사이트 EJS 템플릿
├─ public/ 관리 사이트 정적 파일(styles.css 등)
├─ manifest/ 음악퀴즈 JSON 정의 (운영자가 편집)
├─ file/
├─ servers/ 서버 zip
├─ maps/ 맵 zip
│ ├─ mods/<폴더>/ 모드 jar 묶음 (index.json 자동 생성)
├─ resourcepacks/ 리소스팩 zip
├─ platforms/ Forge / NeoForge 설치 jar
│ └─ list/<key>.json 음악·사진 목록
├─ docs/ 사용·운영 문서
├─ manifest.json 사이트 루트 매니페스트 (자동 관리)
├─ account.json 관리자 계정 (절대 외부 노출 금지)
├─ package.json
└─ tsconfig.{,server,installer,installer-rp}.json
```
---
## 빌드 산출물 / 배포
| 산출물 | 빌드 명령 | 비고 |
| --- | --- | --- |
| 관리 사이트 (Node 실행) | `npm start` | systemd 등으로 띄우기. 외부 도메인이 manifest 의 base URL 이 됩니다. |
| 음악퀴즈 간편설치기 `.exe` | `npm run dist:win` | `electron-builder.yml` 설정 사용. |
| 리소스팩 간편설치기 `.exe` | `tsconfig.installer-rp.json` 빌드 후 `electron-builder` 수동 패키징 | |
---
## 라이선스
내부 프로젝트. 외부 공개 시 별도 명시.

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

141
docs/admin-site.md Normal file
View File

@@ -0,0 +1,141 @@
# 관리 사이트 운영 가이드
음악퀴즈 정의(`/manifest/*.json`), 음악·사진 목록(`/file/list/*.json`), 데이터팩 출력을 한 곳에서 관리하는 Express + EJS 사이트입니다.
## 실행
```bash
npm install
cp .env.example .env # 처음 한 번만. 운영 도메인이면 SITE_BASE_URL 만 바꾸면 됩니다.
npm start # 기본 포트 3000.
```
배포 시에는 시스템 서비스(systemd 등) 로 등록해 두면 됩니다.
### 환경변수 (`.env`)
| 키 | 기본값 | 설명 |
| --- | --- | --- |
| `PORT` | `3000` | Express 서버 listen 포트. |
| `HOST` | `127.0.0.1` | 바인드 주소. 외부 노출하려면 `0.0.0.0`. |
| `SESSION_SECRET` | dev secret | `/op` 세션 쿠키 서명 키. 운영에서는 반드시 임의값으로 교체. |
| `SITE_BASE_URL` | `http://127.0.0.1:3000` | 설치기 두 종이 첫 화면에서 자동으로 채우는 manifest 호스트. 운영 도메인으로 바꿔두면 manifest URL 도 자동으로 따라갑니다. |
| `MANIFEST_URL` | — | 특별히 다른 경로를 쓰고 싶을 때만 지정. 비우면 `${SITE_BASE_URL}/manifest.json`. |
| `MUSIC_CONCURRENCY` | (자동) | 리소스팩 설치기 yt-dlp 동시 다운로드 수(1~8). |
`.env``.gitignore` 로 제외되어 있습니다. 새 환경을 셋업할 때 `.env.example` 을 복사해서 시작하세요. 쉘에서 직접 환경변수를 지정한 경우에는 `.env` 값을 덮어쓰지 않습니다.
#### 설치기 `.exe` 빌드에도 적용됨
`npm run dist:win` 으로 만든 설치기 `.exe` 도 빌드 시점의 `.env` 를 함께 가져갑니다 (`electron-builder``extraResources``resources/.env` 에 배포). 런타임에서는 `process.resourcesPath/.env``<프로젝트 루트>/.env` 순으로 찾기 때문에:
- 운영자가 `SITE_BASE_URL=https://mq.example.com` 으로 `.env` 를 설정해 두고 빌드 → 최종 사용자가 받은 `.exe` 가 그 도메인의 `manifest.json` 을 자동으로 받아옴.
- 빌드 이후라도 설치된 폴더(`resources/.env`) 를 텍스트 편집기로 고치면 도메인을 바꿀 수 있음.
- 빌드 시점에 `.env` 가 없으면 단순히 빠진 채로 패키징되고, 코드 기본값(`http://127.0.0.1:3000`) 이 그대로 사용됩니다.
## 도메인 / 경로 구성
| 경로 | 내용 |
| --- | --- |
| `/` | 음악퀴즈 카드 한 줄 목록(공개). 카드 클릭 시 해당 음악퀴즈 페이지. |
| `/manifest.json` | 사이트 루트 매니페스트(공개). 설치기가 첫 화면에서 로드. |
| `/file/...` | 정적 파일 호스팅. `servers/`, `maps/`, `mods/<폴더>/`, `resourcepacks/`, `platforms/`, `list/`. |
| `/op` | 관리자 로그인. 미들웨어로 `/op/*` 전체를 보호. |
| `/op/dashboard` | 음악퀴즈 카드 목록 + 추가/삭제 + 편집 진입. |
| `/op/dashboard/:packKey` | 음악퀴즈 정의(JSON) 편집기. |
| `/op/list` | 카드 목록(음악·사진 편집 진입). |
| `/op/list/:packKey` | 음악퀴즈의 음악·사진 목록 편집(드래그/우클릭). |
| `/op/datapack` | 데이터팩 mcfunction 출력. |
| `/op/datapack/:packKey/generate` | 텍스트 한 덩어리로 mcfunction 반환. |
## 계정
`account.json` 에 정의합니다(루트 디렉터리). **외부 HTTP 로 절대 노출되지 않도록 라우팅에서 제외돼 있습니다.**
```json
[
{ "id": "admin", "password": "비번" }
]
```
> 운영 환경에서는 평문 비밀번호 대신 해시를 쓰도록 추후 보강할 여지가 있습니다.
## 대시보드 (`/op/dashboard`)
- 상단 메뉴: 좌측 로고 = 대시보드로 이동, 우측 아이디 드롭다운에 **로그아웃**.
- 상단 버튼: `[음악목록 수정]``/op/list`, `[데이터팩 수정]``/op/datapack`.
- 카드 목록: `manifest.json` 의 음악퀴즈를 가로 한 줄 카드로 표시.
- **음악퀴즈 추가** — `/manifest/new.json` 생성. 이미 있으면 `new2.json`, `new3.json` … 으로 증가. 동시에 `manifest.json` 갱신.
- **음악퀴즈 삭제** — 카드에 체크박스가 뜨고, 확인 시 선택한 JSON 과 매니페스트 항목을 삭제.
## 음악퀴즈 정의 편집 (`/op/dashboard/:packKey`)
폼 필드:
| 필드 | 설명 |
| --- | --- |
| 음악퀴즈 이름 | 사용자에게 보이는 표시명. |
| JSON 파일 이름 | URL 키. 영문/숫자/`_`/`-` 만 허용. 변경 시 파일명 + 매니페스트가 동시에 갱신되며 URL 도 새 키로 리다이렉트. |
| 마인크래프트 버전 | Mojang 공식 API 에서 정식 릴리즈만 드롭다운으로 표시. |
| 모드 플랫폼 | `vanilla` / `forge` / `fabric` / `neoforge`. |
| 플랫폼 설치파일 URL | `forge` / `neoforge` 용. 도메인 없이 입력하면 `/file/platforms/<파일명>` 으로 해석. |
| Fabric Loader 버전 | `fabric` 선택 시 자동 표시. Fabric Meta API 에서 선택한 mcVersion 의 로더 목록을 가져옴. 설치기는 최신 fabric-installer 로 CLI 자동 설치. |
| 서버 최소/최대 램 (MB) | 설치기 RAM 검사 및 `run.bat``-Xms/-Xmx`. |
| 클라이언트 최소/권장 램 (MB) | 사용자 PC 요구치 + 마인크래프트 런처 JVM 인수에 사용. |
| 맵 파일 (.zip) | `/file/maps/` 아래 zip 파일명. `.mc_custom/saves/` 로 압축 해제. |
| 서버 파일 (.zip) | `/file/servers/` 아래 zip. 멀티 전용. |
| 모드 폴더 이름 | `/file/mods/<폴더>/` 아래 모든 `.jar` 자동 다운로드. 빈 값이면 받지 않음. |
| 리소스팩 (.zip) | `/file/resourcepacks/` 아래 zip 파일명. |
저장 시 `clientMinRam ≤ clientRecommendedRam` 검증이 들어갑니다.
## 음악·사진 목록 편집 (`/op/list/:packKey`)
상단 탭: **음악목록** / **사진목록**.
### 음악목록 탭
- `[목록 저장]`, `[목록 초기화]`.
- 플레이리스트 주소 + `[플레이리스트 불러오기]`. 확인 팝업에서 동의해야 기존 순서를 덮어씀.
- 항목 표시: 좌측 번호 배지, 썸네일, 제목 / 가수 / 길이.
- 드래그로 순서 변경. 우클릭: **수정** / **삭제**.
- **수정** → 새 유튜브 주소를 입력하면 yt-dlp 가 메타데이터를 가져와 자동 채움.
### 사진목록 탭
- `[목록 저장]`, `[목록 초기화]`, 플레이리스트 불러오기는 동일.
- 카드 그리드(반응형) 로 표시. 우클릭에서 수정/삭제. 수정 팝업은 [유튜브 주소] / [이미지 주소] 토글.
저장 포맷은 `/file/list/<packKey>.json` (README 데이터 포맷 참고).
> yt-dlp 메타데이터/플레이리스트 조회를 위해 서버에 yt-dlp 가 설치되어 있어야 합니다. ([`yt-dlp-setup.md`](yt-dlp-setup.md))
## 데이터팩 (`/op/datapack`)
- `[음악퀴즈 선택]` → 팝업에서 음악퀴즈 선택.
- "총 N개의 음악을 찾았습니다." 와 `[데이터팩 출력]` 버튼.
- 출력 클릭 시 코드 영역에 mcfunction(임시 포맷) + 복사 버튼. 실제 포맷은 추후 확정.
```
# === musicquiz: <pack name> ===
# 총 N곡 / 사진 M장
say [musicquiz] 데이터팩 초기화
# 곡별 placeholder. 실제 포맷 확정되면 교체 예정.
# 1. <title> - <artist> (<duration>s)
```
## 파일 업로드 / 정리 운영
| 카테고리 | 위치 | 비고 |
| --- | --- | --- |
| 서버 zip | `file/servers/<이름>.zip` | EULA / `run.bat` 포함 권장. 설치기가 `run.bat` 에 UPnP/JVM 후처리 자동 주입. |
| 맵 zip | `file/maps/<이름>.zip` | 압축 해제 시 `.mc_custom/saves/` 아래에 풀림. |
| 모드 jar | `file/mods/<폴더>/...jar` | 해당 폴더의 `index.json` 이 자동 생성됩니다. 신규 jar 를 넣은 뒤에는 사이트가 인덱스를 다시 만들도록 한 번 호출. |
| 리소스팩 zip | `file/resourcepacks/<이름>.zip` | — |
| 플랫폼 설치 jar | `file/platforms/<이름>.jar` | forge/neoforge 용. fabric 은 사이트에 둘 필요 없음(자동 다운로드). |
## 보안 주의
- `account.json` 은 라우팅에서 차단되어 있으나, 디스크 권한도 운영자만 접근 가능하게 두는 것이 안전합니다.
- 관리자 비밀번호는 충분히 강하게 설정.
- 모든 `/op/*` 라우트는 세션 기반 인증 미들웨어를 거칩니다. 세션 만료 시 자동으로 로그인 페이지로 리다이렉트.

105
docs/installer.md Normal file
View File

@@ -0,0 +1,105 @@
# 음악퀴즈 간편설치기 사용 가이드
`%APPDATA%\.mc_custom\` 을 게임 디렉터리로 쓰는 별도 인스턴스를 자동으로 구축합니다. 평소 쓰던 `.minecraft` 는 그대로 유지되며, 음악퀴즈 모드/리소스팩/맵/서버만 분리된 폴더에 자동 설치됩니다.
설치기는 단계별 화면으로 진행됩니다. 단계 사이에는 "이전 / 다음" 버튼이 있고, 일부 단계는 페이지 진입 시 자동으로 작업을 시작합니다.
---
## 1단계 — 음악퀴즈 선택
`manifest.json` 을 읽어 등록된 음악퀴즈를 카드 한 줄로 표시합니다. 하나를 선택하면 다음 단계로 진행됩니다.
> manifest URL 은 기본값이 박혀 있지만, 다른 호스트의 음악퀴즈를 받고 싶다면 첫 화면 상단의 URL 입력란에 붙여 넣고 새로고침 버튼을 누르면 됩니다.
## 2단계 — 싱글 / 멀티 선택
| 선택 | 흐름 |
| --- | --- |
| **싱글** | 3단계(서버 설치)를 건너뛰고 곧장 4단계로 진행. |
| **멀티** | 3단계의 5개 소항목을 거친 뒤 4단계로 진행. |
## 3단계 — 서버 관련 설정 (멀티 전용)
### 3-1. 서버 설치 경로
- 폴더 선택 또는 직접 입력.
- **경로에 한글이 있으면 안 됩니다.** (마인크래프트 서버가 비정상 동작)
### 3-2. JDK 확인
- 환경변수(`JAVA_HOME`, `JDK_HOME`) → 자동 설치 위치(`%APPDATA%\jdk\temurin-21`) → `C:\Program Files\Java` 순으로 자동 탐색.
- **자동 설치** 버튼을 누르면 Adoptium Temurin 21 LTS Windows x64 zip 을 받아 `%APPDATA%\jdk\temurin-21\` 에 풀어 사용합니다.
- 설치 중 같은 버튼이 "설치 취소" 로 바뀌고, 누르면 다운로드를 즉시 중단하고 부분 파일을 정리합니다.
### 3-3. 서버 다운로드 및 설치
페이지 진입 즉시 음악퀴즈의 `serverPath` 에 지정된 서버 zip 을 다운로드해 압축을 풉니다. 압축 해제 직후 `run.bat` 에 다음 처리를 자동 주입합니다:
- **UPnP 자동 등록/해제**: 서버 시작 직전 `server-port` 를 읽어 PowerShell COM (`HNetCfg.NATUPnP.1`) 으로 TCP 매핑 추가, 종료 후 해제.
- 설치 끝나면 EULA 동의 화면이 표시되고, 동의해야 다음으로 넘어갑니다. 이어서 시스템 RAM 검사가 자동 실행됩니다.
램 검사 규칙:
```
시스템 RAM ≥ serverMaxRam → 서버 RAM = serverMaxRam
시스템 RAM ≥ serverMinRam → 서버 RAM = serverMinRam (경고 표시)
시스템 RAM < serverMinRam → "플레이 불가" 후 설치 중단
```
### 3-4. 서버 설정 (편집기)
내장 로컬 웹서버를 띄워 브라우저에서 `server.properties`, `bukkit.yml`, `paper-global.yml` 등 주요 설정 파일을 GUI 로 편집합니다. 저장 누르면 실제 파일에 반영됩니다.
### 3-5. 서버 포트포워딩 점검
페이지 진입 시 자동으로 검사합니다. 흐름:
1. 이전 실행에서 만든 UPnP 매핑이 남아 있으면 먼저 제거.
2. 외부 포트체크 서비스(`ifconfig.co`) 로 1차 점검. 임시 TCP 리스너를 띄워 외부에서 닿는지 확인.
3. 이미 사용자가 라우터 규칙으로 포워딩 해 두었으면 → "포워딩 됨" 으로 통과.
4. 아니면 UPnP 로 자동 개방 시도 후 재점검. 성공 시 테스트용 매핑은 즉시 제거(실제 개방은 `run.bat` 이 서버 기동 때마다 처리).
5. UPnP 도 실패하면 안내 메시지 (사용자가 라우터에서 수동 포워딩) 표시.
> 동일 페이지에 **재점검** 버튼이 있어, 라우터 설정을 바꾼 뒤 다시 누르면 1차부터 다시 검사합니다.
## 4단계 — 유저 클라이언트 설정 (자동 진행)
페이지 진입 즉시 시작합니다.
1. `.mc_custom` 폴더 생성 + `mods/`, `resourcepacks/` 생성.
2. `.minecraft` 최상위 설정 파일(`options.txt`, `optionsof.txt`, `servers.dat`, `usercache.json`, …) 을 `.mc_custom` 으로 복사. 이미 같은 이름이 있으면 보존.
3. 플랫폼 설치:
- `vanilla` → 건너뜀.
- `fabric` → Adoptium 자동 설치 → 최신 fabric-installer.jar 다운로드 → `java -jar fabric-installer.jar client -mcversion X -loader Y -dir .mc_custom -noprofile` 자동 실행.
- `forge` / `neoforge``platform.downloadUrl` 의 설치 jar 다운로드(사용자가 직접 실행하거나 마인크래프트 런처가 인식).
4. `modsFolder` 의 모든 `.jar``resourcepackPath` zip, `mapPath` zip 을 자동 다운로드.
5. `.minecraft\{assets,libraries,versions}``.mc_custom\{assets,libraries,versions}` 로 junction 링크. (없으면 "Unable to prepare assets for download" 오류로 마인크래프트가 실패하기 때문)
6. `.minecraft\launcher_profiles.json` 에 해당 음악퀴즈 이름의 프로필을 추가/갱신:
- `gameDir` = `%APPDATA%\.mc_custom`
- `lastVersionId` = `vanilla``mcVersion`, `fabric``fabric-loader-<loaderVer>-<mcVer>` (forge/neoforge 는 `versions/` 폴더에서 휴리스틱 매칭)
- `javaArgs` = `-Xmx<serverMaxRam>M` + Aikar 권장 G1 GC 플래그 6종 (`-XX:+UnlockExperimentalVMOptions -XX:+UseG1GC -XX:G1NewSizePercent=20 -XX:G1ReservePercent=20 -XX:MaxGCPauseMillis=50 -XX:G1HeapRegionSize=32M`). 기존에 사용자가 지정한 키는 덮어쓰지 않음.
## 5단계 — 완료
- 멀티 설치까지 거친 경우:
- **서버 폴더 열기**
- **바탕화면에 서버 실행 바로가기 만들기**
- **서버 바로 실행**
- **마인크래프트 런처 실행** — 실행 우선순위:
1. Win32 설치판 (`Program Files\Minecraft Launcher\MinecraftLauncher.exe` 등)
2. App Execution Alias (`%LOCALAPPDATA%\Microsoft\WindowsApps\Minecraft.exe` 등, `cmd /c start` 경유)
3. `explorer.exe shell:AppsFolder\Microsoft.4297127D64EC6_8wekyb3d8bbwe!Minecraft` (MS Store MSIX)
4. 마지막 수단: `minecraft://` URL 스킴
런처가 떴다면 음악퀴즈 이름의 프로필을 선택해 플레이하면 됩니다.
---
## 로그
설치기 화면 하단에 실시간 로그가 표시됩니다. 모든 다운로드/링크/JVM 인수 갱신/UPnP 시도 내역이 기록됩니다. 문제가 생기면 이 로그 내용을 캡처해 함께 전달해 주세요.
## 음악퀴즈 제거
`%APPDATA%\.mc_custom\` 폴더를 통째로 삭제하면 인스턴스가 사라집니다. `.minecraft\launcher_profiles.json` 에 남은 프로필은 마인크래프트 런처에서 직접 지우거나, 다른 음악퀴즈를 재설치하면 같은 이름이 갱신됩니다.

57
docs/painting-variant.md Normal file
View File

@@ -0,0 +1,57 @@
# 리소스팩 이미지 추가 (painting variant)
마인크래프트 1.21+ 의 painting variant 시스템.
**데이터팩에 슬롯을 미리 등록 → 리소스팩 PNG만 갈아끼우면 그림 교체.**
## 폴더 구조
```
my-datapack/
pack.mcmeta
data/musicquiz/painting_variant/cover_01.json
data/minecraft/tags/painting_variant/placeable.json
my-resourcepack/
pack.mcmeta
assets/musicquiz/textures/painting/cover_01.png
```
## 슬롯 정의 (데이터팩, 고정)
`data/musicquiz/painting_variant/cover_01.json`
```json
{
"asset_id": "musicquiz:cover_01",
"title": "표지 01",
"author": "음악퀴즈",
"width": 3,
"height": 2
}
```
`data/minecraft/tags/painting_variant/placeable.json` (인벤토리·랜덤 배치에 포함)
```json
{ "replace": false, "values": ["musicquiz:cover_01"] }
```
## 이미지 (리소스팩, 교체 대상)
`assets/musicquiz/textures/painting/cover_01.png`
- 크기: `width*16 × height*16` (예: 3×2 → `48×32` px, 또는 그 정수배)
- 슬롯 이름과 동일해야 함 (`cover_01.png`)
## 게임에서 호출
```
/give @s minecraft:painting[minecraft:painting/variant="musicquiz:cover_01"]
/summon minecraft:painting ~ ~ ~ {variant:"musicquiz:cover_01",facing:0b}
```
## 운영 팁
- 슬롯은 크기별로 미리 다 만들어 둔다 (`cover_3x2_01..N`, `cover_2x2_01..N` …). 크기는 데이터팩에 박혀 있으므로 나중에 못 바꿈.
- 그림 갈이는 리소스팩 PNG만 교체 → 클라이언트가 리소스팩을 새로 적용하면 즉시 반영 (월드 재진입 불필요).
- 데이터팩 수정(슬롯 추가/삭제/크기 변경)은 `/reload` 불가 → 월드 재진입 필요.

View File

@@ -0,0 +1,69 @@
# 리소스팩 간편설치기
음악퀴즈에 등록된 음악·사진 목록을 yt-dlp 로 받아 마인크래프트 1.21+ painting variant 텍스처 리소스팩으로 패키징하는 별도 설치기입니다.
기본 골격은 음악퀴즈 간편설치기(`src/installer/`)와 동일하므로 UI/스토어 코드를 공유합니다. 진입점은 `src/installer-rp/main.ts` + `installer-rp/` 렌더러.
## 실행
```bash
npm run installer:rp # 개발 실행
```
윈도우 `.exe` 패키징은 `tsconfig.installer-rp.json` 으로 컴파일한 뒤 `electron-builder` 로 수동 패키징합니다.
## 단계
번호가 붙은 단계는 "이전 / 다음" 으로 이동 가능합니다.
### 1) 음악퀴즈 선택
- 도메인의 `manifest.json` 에서 음악퀴즈 카드 목록을 표시.
- 선택한 음악퀴즈의 `file/list/<key>.json` 에서 다음을 로드:
- 음악 목록(유튜브 영상 주소).
- 사진 목록(유튜브 주소 또는 일반 이미지 주소).
### 2) 리소스팩 설치 (자동 시작)
- "다음" 누르면 즉시 시작.
- 설치 로그를 실시간으로 표시(곡 제목 노출 없이 `n번 노래 다운로드 중…` 형식).
- 취소 버튼 / 창 닫기 / 강제 종료 시: 진행 중 다운로드를 안전하게 중단하고 임시 파일을 정리한 뒤 정상 종료.
#### 2-1. yt-dlp 준비
- 자동 설치 위치: `%APPDATA%/.mc_custom/` (Linux `~/.config/.mc_custom/`, macOS `~/Library/Application Support/.mc_custom/`).
- 이미 있으면 업데이트 확인 후 그대로 사용.
- 시스템 `PATH``yt-dlp` 가 있으면 그것을 우선 사용 후 업데이트 확인.
- 자동 설치가 실패하는 환경은 [`yt-dlp-setup.md`](yt-dlp-setup.md) 참고.
#### 2-2. 음악 다운로드 (순차)
- 임시 경로: `%APPDATA%/.mc_custom/.temp/`.
- 각 곡을 `ogg` 로 변환(Minecraft 사운드 호환 포맷). ffmpeg 필요.
- 중단되거나 모두 끝나면 `.temp` 내용 삭제.
#### 2-3. 사진 다운로드 → painting variant 텍스처
이미지는 두 형태로 들어옵니다.
- **유튜브 주소** — yt-dlp 가 알려준 영상 ID 로 `https://i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 시도, 실패하면 `hqdefault.jpg` 폴백.
- **일반 이미지 주소** — HTTP GET 으로 그대로.
정규화 규칙:
- **슬롯 규격(고정, 데이터팩 측)**: `4 × 4` 블록 정사각, `cover_01 … cover_N`.
- **최종 PNG 규격(리소스팩 측)**: 정사각 1:1, 최대 `1024 × 1024` px.
- `4 × 4` 블록 × 블록당 `256` px (×16 배율) → 1024×1024 가 픽셀 그리드와 정확히 일치.
- **알고리즘**:
1. 가운데 정사각 크롭: `s = min(원본 가로, 원본 세로)``s × s`.
2. `s > 1024` 이면 `1024 × 1024` 로 축소 (Lanczos 권장).
3. `s ≤ 1024` 이면 그대로 `s × s` 유지(업스케일 없음).
- 파일명: `cover_<NN>.png` (`NN` 은 2자리 0패딩).
- 저장 경로: `resourcepack/assets/musicquiz/textures/painting/`.
- 패키지 완성된 리소스팩을 `%APPDATA%/.minecraft/resourcepacks/` 에 zip 으로 배치.
painting variant 의 슬롯·태그 구조와 게임 내 호출 예시는 [`painting-variant.md`](painting-variant.md) 참고.
### 3) 설치 완료
- "확인" 누르면 프로그램 종료.

95
docs/yt-dlp-setup.md Normal file
View File

@@ -0,0 +1,95 @@
# yt-dlp 설치 가이드
> ✅ **기본 동작: 자동 설치.** 서버가 처음 플레이리스트를 불러올 때 `%appdata%/.mc_custom/`
> (Linux 는 `~/.config/.mc_custom/`, macOS 는 `~/Library/Application Support/.mc_custom/`)
> 에 현재 OS/아키텍처에 맞는 `yt-dlp` 바이너리를 GitHub Releases 에서 받아 권한까지 부여합니다.
> 이미 받아둔 게 있으면 그대로 재사용합니다. 따라서 **일반적으로는 아래 수동 설치가 필요 없습니다.**
>
> 자동 설치가 실패하는 환경(외부 인터넷 차단, 권한 부족 등)에서만 아래 절차로 수동 설치하세요.
---
## 1. 가장 간단한 방법 — 단일 바이너리 내려받기 (권장)
Python/pip 없이도 동작하며, 권한도 깔끔합니다. 서버에 SSH로 접속한 뒤:
```bash
sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp
sudo chmod a+rx /usr/local/bin/yt-dlp
yt-dlp --version
```
마지막 줄에서 버전(예: `2025.12.13`)이 출력되면 끝입니다.
업데이트는 같은 한 줄을 다시 실행하거나 `yt-dlp -U` 로 수행합니다.
> 만약 `/usr/local/bin` 에 쓰기 권한이 없는 환경이면 `~/.local/bin/yt-dlp` 로 받고
> `~/.local/bin` 이 `$PATH` 에 포함돼 있는지 확인하세요.
---
## 2. pipx 사용 (이미 pipx 가 깔려 있다면)
```bash
sudo apt-get update
sudo apt-get install -y pipx
pipx ensurepath
pipx install yt-dlp
```
업데이트: `pipx upgrade yt-dlp`
> Ubuntu 24.04 이상은 시스템 파이썬에 `pip install` 이 막혀 있어 (`PEP 668`)
> `pipx` 가 사실상 표준입니다. `pip install yt-dlp` 는 권장하지 않습니다.
---
## 3. apt 패키지 (구버전일 가능성 있음)
```bash
sudo apt-get update
sudo apt-get install -y yt-dlp
```
`apt` 의 yt-dlp 는 유튜브 정책 변경을 따라가지 못해 자주 깨집니다. **1번 방법을 추천합니다.**
---
## 부가: ffmpeg
설치기 EXE 에서 음악을 ogg 로 변환할 때 `ffmpeg` 도 필요합니다. 음악퀴즈 관리 사이트 자체는 필요 없지만,
설치기를 서버에서 디버깅한다면 함께 깔아두면 편합니다.
```bash
sudo apt-get install -y ffmpeg
```
---
## 동작 확인
설치 후 관리 사이트 서비스를 재시작할 필요는 **없습니다**. 매 요청마다 `spawn('yt-dlp', ['--version'])` 으로 직접 호출하므로,
`PATH` 상에 `yt-dlp` 가 있기만 하면 바로 인식됩니다.
확인:
```bash
sudo -u <서버를_실행하는_사용자> yt-dlp --version
```
예) systemd 로 `minecraft-launcher.service` 가 실행 중이고 사용자가 `claude` 라면:
```bash
sudo -u claude yt-dlp --version
```
여기서 버전이 출력되면 관리 사이트의 "플레이리스트 불러오기" 도 정상 동작합니다.
---
## 트러블슈팅
- **`yt-dlp: command not found`** — `$PATH` 에 설치 디렉터리가 없습니다. `which yt-dlp` 로 위치 확인.
- **`ERROR: ... HTTP Error 403`** — yt-dlp 가 너무 오래된 버전입니다. `yt-dlp -U` 로 업데이트.
- **`Sign in to confirm you're not a bot`** — 일시적인 IP 제한. 몇 분 후 재시도하거나, 같은 서버에서 다른 영상 재생을 시도해본 적이 있다면 IP 가 풀릴 때까지 기다려야 합니다.
- **systemd 서비스에서만 안 됨** — `PATH` 환경변수가 다를 수 있음. 서비스 유닛에 `Environment=PATH=/usr/local/bin:/usr/bin:/bin` 추가.

41
electron-builder-rp.yml Normal file
View File

@@ -0,0 +1,41 @@
appId: kr.tkrmagid.musicquiz.installer-rp
productName: MusicQuizResourcepackInstaller
# 루트 package.json 의 "main" 은 메인 설치기를 가리키므로, 패키지된 앱이
# 리소스팩 설치기를 진입점으로 쓰도록 빌드 시 main 을 덮어쓴다.
extraMetadata:
main: dist/installer-rp/main.js
directories:
output: release
buildResources: build
files:
- dist/installer-rp/**
- dist/shared/**
- installer-rp/**
# rp 의 index.html 은 메인 설치기와 동일한 styles.css 를 공유함
# (`<link href="../installer/styles.css">`). asar 안에 해당 파일이 없으면
# UI 가 무스타일로 렌더링되므로 그 한 파일만 명시적으로 포함.
- installer/styles.css
- build/icon.*
- package.json
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
- "!node_modules/@img/sharp-linux-*"
- "!node_modules/@img/sharp-linuxmusl-*"
- "!node_modules/@img/sharp-libvips-linux-*"
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
# 메인 설치기와 동일하게 빌드 전용 `.env.build` 와 locales 를 함께 배포.
extraResources:
- from: .
to: .
filter:
- .env.build
- from: locales
to: locales
filter:
- "**/*"
win:
target: portable
artifactName: ${productName}-${version}-Portable.${ext}
icon: build/icon.ico
portable:
artifactName: ${productName}-${version}-Portable.${ext}

View File

@@ -2,14 +2,37 @@ appId: kr.tkrmagid.musicquiz.installer
productName: MusicQuizInstaller
directories:
output: release
buildResources: build
files:
- dist/installer/**
- dist/shared/**
- installer/**
- build/icon.*
- package.json
# sharp 는 플랫폼별 prebuilt 가 분리 패키지로 배포됨. Windows 빌드에서는
# win32-x64 만 포함하고 linux/* 변종은 묶지 않아 exe 크기를 줄임.
- "!node_modules/@img/sharp-linux-*"
- "!node_modules/@img/sharp-linuxmusl-*"
- "!node_modules/@img/sharp-libvips-linux-*"
- "!node_modules/@img/sharp-libvips-linuxmusl-*"
# 빌드 전용 `.env.build` 를 설치기 옆에 함께 배포(없으면 조용히 패스).
# `.env` 는 서버/개발 실행용이라 빌드 산출물에는 포함되지 않으며, 패키지된 exe
# 는 `resources/.env.build` 를 우선 로드함(없으면 `resources/.env` 로 폴백).
# 패키징 후 운영자가 `resources/.env.build` 만 교체해서 도메인을 바꿀 수 있음.
# locales/ 폴더는 i18n.ts 가 process.resourcesPath/locales/<component>/ko-kr.json
# 을 찾아 로드하므로, 빌드된 .exe 에서도 한국어 사전이 적용되도록 함께 배포.
extraResources:
- from: .
to: .
filter:
- .env.build
- from: locales
to: locales
filter:
- "**/*"
win:
target: nsis
artifactName: ${productName}-${version}-Setup.${ext}
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
perMachine: false
target: portable
artifactName: ${productName}-${version}-Portable.${ext}
icon: build/icon.ico
portable:
artifactName: ${productName}-${version}-Portable.${ext}

View File

@@ -0,0 +1,6 @@
{
"musicPlaylistUrl": "",
"imagePlaylistUrl": "",
"music": [],
"images": []
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

27
installer-rp/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>마인크래프트 음악퀴즈 리소스팩 간편설치기</title>
<link rel="stylesheet" href="../installer/styles.css" />
</head>
<body>
<header class="appHeader">
<h1>마인크래프트 음악퀴즈 리소스팩 간편설치기</h1>
<ol class="stepIndicator" id="stepIndicator">
<li data-step="1">1. 음악퀴즈</li>
<li data-step="2">2. 설치</li>
<li data-step="3">3. 완료</li>
</ol>
</header>
<main id="pageHost"></main>
<aside class="logViewer" id="logViewer" hidden>
<header><h2>설치 로그</h2><button type="button" id="logToggle">접기</button></header>
<pre id="logBody"></pre>
</aside>
<script src="./renderer.js"></script>
</body>
</html>

316
installer-rp/renderer.js Normal file
View File

@@ -0,0 +1,316 @@
'use strict'
const api = window.rpInstaller
const state = {
packs: [],
selectedKey: null,
installing: false,
installed: false,
resourcepackPath: ''
}
let I18N = {}
function tt(key, params) {
var parts = String(key).split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) {
cur = cur[parts[i]]
} else {
return key
}
}
if (typeof cur !== 'string') return key
if (!params) return cur
return cur.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return name in params ? String(params[name]) : '{{' + name + '}}'
})
}
const pageHost = document.getElementById('pageHost')
const stepIndicator = document.getElementById('stepIndicator')
const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = tt('logViewer.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = tt('logViewer.collapse')
}
})
api.onLog(function (line) {
logViewer.hidden = false
logBody.textContent += line + '\n'
logBody.scrollTop = logBody.scrollHeight
})
function applyStaticI18n() {
document.title = tt('app.title')
var h1 = document.querySelector('.appHeader h1')
if (h1) h1.textContent = tt('app.title')
var stepLis = stepIndicator.querySelectorAll('li')
stepLis.forEach(function (item) {
var idx = item.getAttribute('data-step')
if (idx === '1') item.textContent = tt('stepIndicator.step1')
else if (idx === '2') item.textContent = tt('stepIndicator.step2')
else if (idx === '3') item.textContent = tt('stepIndicator.step3')
})
var logH2 = logViewer.querySelector('header h2')
if (logH2) logH2.textContent = tt('logViewer.heading')
logToggle.textContent = tt('logViewer.collapse')
}
function setActiveStep(step) {
stepIndicator.querySelectorAll('li').forEach(function (item) {
var index = Number(item.getAttribute('data-step'))
item.classList.remove('active', 'done')
if (index < step) item.classList.add('done')
if (index === step) item.classList.add('active')
})
}
function clearPage() { pageHost.innerHTML = '' }
// ── 1단계: 음악퀴즈 선택 ────────────────────────────
function renderStep1() {
setActiveStep(1)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('step1.heading')) + '</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">' + escapeHtml(tt('common.loading')) + '</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + escapeHtml(tt('common.next')) + '</button></div>'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">' + escapeHtml(tt('common.noPacks')) + '</p>'
return
}
state.packs.forEach(function (pack) {
var card = document.createElement('button')
card.type = 'button'
card.className = 'choiceCard'
if (state.selectedKey === pack.key) card.classList.add('selected')
var verLabel = pack.mcVersion
? escapeHtml(tt('common.mcVersionLabel', { version: pack.mcVersion }))
: ''
card.innerHTML =
'<strong>' + escapeHtml(pack.name) + '</strong>' +
'<small>' + verLabel +
escapeHtml(tt('common.trackImageCount', { music: pack.list.music.length, image: pack.list.images.length })) +
'</small>'
card.addEventListener('click', function () {
state.selectedKey = pack.key
nextBtn.disabled = false
renderList()
})
listEl.appendChild(card)
})
}
nextBtn.addEventListener('click', function () {
if (!state.selectedKey) return
api.selectPack(state.selectedKey).then(function () {
renderStep2()
}).catch(function (err) {
alert(err.message || tt('common.selectFailed'))
})
})
api.loadPacks().then(function (packs) {
state.packs = packs || []
renderList()
}).catch(function (err) {
listEl.innerHTML = '<p class="formMessage error">' +
escapeHtml(tt('common.listLoadFailed', { message: err.message || '' })) +
'</p>'
})
}
// ── 2단계: 설치 진행 ────────────────────────────────
function renderStep2() {
setActiveStep(2)
clearPage()
var pack = null
for (var i = 0; i < state.packs.length; i++) {
if (state.packs[i].key === state.selectedKey) { pack = state.packs[i]; break }
}
var musicTotal = pack ? pack.list.music.length : 0
var imageTotal = pack ? pack.list.images.length : 0
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('step2.heading')) + '</h2>' +
'<p class="formMessage">' + tt('step2.description') + '</p>' +
'<div class="prepRow">' +
' <span class="prepChip" id="chip-ytdlp">' + escapeHtml(tt('step2.chipYtdlp')) + '</span>' +
' <span class="prepChip" id="chip-ffmpeg">' + escapeHtml(tt('step2.chipFfmpeg')) + '</span>' +
'</div>' +
'<div class="progressSection">' +
' <h3>' + escapeHtml(tt('step2.musicHeading')) + '</h3>' +
' <div class="sectionSub" id="music-sub">' + escapeHtml(tt('step2.musicSub', { count: musicTotal })) + '</div>' +
' <div class="progressGrid" id="musicGrid"></div>' +
'</div>' +
'<div class="progressSection">' +
' <h3>' + escapeHtml(tt('step2.imageHeading')) + '</h3>' +
' <div class="sectionSub" id="image-sub">' + escapeHtml(tt('step2.imageSub', { count: imageTotal })) + '</div>' +
' <div class="progressGrid" id="imageGrid"></div>' +
'</div>' +
'<div class="progressSection">' +
' <h3>' + escapeHtml(tt('step2.packageHeading')) + '</h3>' +
' <div class="sectionSub" id="pkg-sub">' + escapeHtml(tt('step2.packageWaiting')) + '</div>' +
'</div>' +
'<div class="actionRow">' +
' <span></span>' +
' <button class="dangerBtn" id="cancel">' + escapeHtml(tt('common.cancel')) + '</button>' +
'</div>'
pageHost.appendChild(section)
var musicGrid = section.querySelector('#musicGrid')
var imageGrid = section.querySelector('#imageGrid')
var chipYtdlp = section.querySelector('#chip-ytdlp')
var chipFfmpeg = section.querySelector('#chip-ffmpeg')
var pkgSub = section.querySelector('#pkg-sub')
var cancelBtn = section.querySelector('#cancel')
function buildCard(idx) {
var card = document.createElement('div')
card.className = 'progressCard pending'
card.setAttribute('data-idx', String(idx))
card.innerHTML =
'<div class="cardTop"><span class="label">' + idx + '</span><span class="icon">○</span></div>' +
'<div class="bar"><span></span></div>' +
'<div class="pct">' + escapeHtml(tt('step2.cardWaiting')) + '</div>'
return card
}
for (var m = 1; m <= musicTotal; m++) musicGrid.appendChild(buildCard(m))
for (var k = 1; k <= imageTotal; k++) imageGrid.appendChild(buildCard(k))
function updateCard(grid, index, percent, status) {
var card = grid.querySelector('[data-idx="' + index + '"]')
if (!card) return
card.classList.remove('pending', 'running', 'done', 'error')
card.classList.add(status)
var bar = card.querySelector('.bar > span')
if (bar) bar.style.width = Math.max(0, Math.min(100, percent)) + '%'
var pct = card.querySelector('.pct')
var icon = card.querySelector('.icon')
if (status === 'done') {
if (pct) pct.textContent = tt('step2.cardDone')
if (icon) icon.textContent = '✓'
if (bar) bar.style.width = '100%'
} else if (status === 'error') {
if (pct) pct.textContent = tt('step2.cardError')
if (icon) icon.textContent = '✕'
} else if (status === 'running') {
if (pct) pct.textContent = Math.round(percent) + '%'
if (icon) icon.textContent = '⏳'
} else {
if (pct) pct.textContent = tt('step2.cardWaiting')
if (icon) icon.textContent = '○'
}
}
var stopProgress = api.onProgress(function (payload) {
if (!payload || typeof payload !== 'object') return
if (payload.phase === 'prep') {
if (payload.done) {
chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done')
chipFfmpeg.classList.remove('active'); chipFfmpeg.classList.add('done')
return
}
if (payload.message && payload.message.indexOf('yt-dlp') >= 0) {
chipYtdlp.classList.add('active')
} else if (payload.message && payload.message.indexOf('ffmpeg') >= 0) {
chipYtdlp.classList.remove('active'); chipYtdlp.classList.add('done')
chipFfmpeg.classList.add('active')
}
return
}
if (payload.phase === 'item') {
var grid = payload.kind === 'music' ? musicGrid : imageGrid
updateCard(grid, payload.index, payload.percent || 0, payload.status)
return
}
if (payload.phase === 'package') {
pkgSub.textContent = payload.done
? tt('step2.packageDone')
: (payload.message || tt('step2.packageBuilding'))
return
}
})
cancelBtn.addEventListener('click', function () {
if (!state.installing) return
cancelBtn.disabled = true
api.cancelInstall()
})
// 페이지 진입 즉시 설치 시작
state.installing = true
logViewer.hidden = false
api.startInstall().then(function (result) {
state.installing = false
state.installed = true
state.resourcepackPath = (result && result.resourcepackPath) || ''
if (stopProgress) stopProgress()
renderStep3()
}).catch(function (err) {
state.installing = false
if (stopProgress) stopProgress()
alert(tt('common.installFailed', { message: (err && err.message) || err }))
renderStep1()
})
}
// ── 3단계: 완료 ────────────────────────────────────
function renderStep3() {
setActiveStep(3)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + escapeHtml(tt('step3.heading')) + '</h2>' +
'<p class="formMessage">' + escapeHtml(tt('step3.message')) + '</p>' +
(state.resourcepackPath
? '<p class="formMessage"><code>' + escapeHtml(state.resourcepackPath) + '</code></p>'
: '') +
'<div class="actionRow">' +
' <button class="secondaryBtn" id="openFolder">' + escapeHtml(tt('common.openFolder')) + '</button>' +
' <button class="primaryBtn" id="finish">' + escapeHtml(tt('common.confirm')) + '</button>' +
'</div>'
pageHost.appendChild(section)
section.querySelector('#openFolder').addEventListener('click', function () {
api.openResourcepackFolder()
})
section.querySelector('#finish').addEventListener('click', function () {
api.quit()
})
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return c === '&' ? '&amp;' : c === '<' ? '&lt;' : c === '>' ? '&gt;' : c === '"' ? '&quot;' : '&#39;'
})
}
;(async function () {
try { I18N = (await api.loadLocale()) || {} } catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()

View File

@@ -2,10 +2,33 @@
const installerApi = window.installer
// I18N 사전: locales/installer/ko-kr.json. 처음 한 번 메인 프로세스에서 받아오고
// 그 뒤로는 동기적으로 lookup. tt() 가 호출될 때 사전이 비어 있어도 키를 그대로 반환해
// 화면이 깨지지는 않는다.
var I18N = {}
function tt(key, params) {
var parts = key.split('.')
var cur = I18N
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
else { cur = null; break }
}
var tpl = (typeof cur === 'string') ? cur : key
if (!params) return tpl
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
})
}
const state = {
packs: [],
selectedPackKey: null,
mode: null, // 'single' | 'multi'
// mode==='multi' 일 때만 의미가 있다.
// 'host' → 서버를 직접 연다. 기존 멀티 흐름 (step3 + step4) 그대로.
// 'participant' → 친구 서버에 접속만 한다. step3 (서버 설치) 를 건너뛰고
// client 측에서도 맵은 받지 않는다 (참가자라 서버에 이미 있음).
role: null, // 'host' | 'participant' | null
serverInstall: {
path: '',
jdk: '',
@@ -30,14 +53,27 @@ const logViewer = document.getElementById('logViewer')
const logBody = document.getElementById('logBody')
const logToggle = document.getElementById('logToggle')
function applyStaticI18n() {
document.title = tt('app.browserTitle')
var headerH1 = document.querySelector('.appHeader h1')
if (headerH1) headerH1.textContent = tt('app.headerTitle')
stepIndicator.querySelectorAll('li').forEach(function (item) {
var step = Number(item.getAttribute('data-step'))
item.textContent = tt('stepIndicator.step' + step)
})
var logHeader = logViewer.querySelector('h2')
if (logHeader) logHeader.textContent = tt('logViewer.title')
logToggle.textContent = tt('common.collapse')
}
logToggle.addEventListener('click', function () {
logViewer.classList.toggle('collapsed')
if (logViewer.classList.contains('collapsed')) {
logViewer.style.height = '36px'
logToggle.textContent = '펼치기'
logToggle.textContent = tt('common.expand')
} else {
logViewer.style.height = ''
logToggle.textContent = '접기'
logToggle.textContent = tt('common.collapse')
}
})
@@ -66,9 +102,9 @@ function renderStep1() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>1단계. 설치할 음악퀴즈 선택</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">목록을 불러오는 중...</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<h2>' + tt('step1.heading') + '</h2>' +
'<div id="packList" class="cardChoice"><p class="formMessage">' + tt('step1.loading') + '</p></div>' +
'<div class="actionRow"><span></span><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var listEl = section.querySelector('#packList')
var nextBtn = section.querySelector('#next')
@@ -76,13 +112,14 @@ function renderStep1() {
function renderList() {
listEl.innerHTML = ''
if (state.packs.length === 0) {
listEl.innerHTML = '<p class="formMessage error">등록된 음악퀴즈가 없습니다.</p>'
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.empty') + '</p>'
return
}
state.packs.forEach(function (pack) {
var btn = document.createElement('button')
btn.type = 'button'
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>마인크래프트 ' + pack.pack.mcVersion + ' / ' + pack.pack.platform.type + '</small>'
btn.innerHTML = '<strong>' + pack.name + '</strong><br><small>' +
tt('step1.subtitle', { mc: pack.pack.mcVersion, platform: pack.pack.platform.type }) + '</small>'
if (state.selectedPackKey === pack.key) btn.classList.add('selected')
btn.addEventListener('click', function () {
state.selectedPackKey = pack.key
@@ -106,7 +143,7 @@ function renderStep1() {
state.packs = packs
renderList()
} catch (err) {
listEl.innerHTML = '<p class="formMessage error">목록을 가져오지 못했습니다: ' + err.message + '</p>'
listEl.innerHTML = '<p class="formMessage error">' + tt('step1.fetchFailed', { message: err.message }) + '</p>'
}
})()
}
@@ -117,54 +154,105 @@ function renderStep2() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>2단계. 싱글 / 멀티 선택</h2>' +
'<h2>' + tt('step2.heading') + '</h2>' +
'<div class="cardChoice">' +
'<button id="single" type="button" data-mode="single"><strong>싱글</strong><br><small>혼자 즐기는 모드. 4단계만 진행합니다.</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>멀티</strong><br><small>친구들과 함께. 3단계 서버 설치 후 4단계를 진행합니다.</small></button>' +
'<button id="single" type="button" data-mode="single"><strong>' + tt('step2.singleTitle') + '</strong><br><small>' + tt('step2.singleHint') + '</small></button>' +
'<button id="multi" type="button" data-mode="multi"><strong>' + tt('step2.multiTitle') + '</strong><br><small>' + tt('step2.multiHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var modeButtons = section.querySelectorAll('[data-mode]')
function applySelection(mode) {
function applyMode(mode) {
state.mode = mode
modeButtons.forEach(function (btn) {
if (btn.getAttribute('data-mode') === mode) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
// 모드가 바뀌면 이전에 골랐던 역할은 의미가 없어진다. 멀티→싱글 전환 시 잔존하던
// role 이 다음 단계 분기에 영향 주지 않도록 명시적으로 초기화.
if (mode !== 'multi') state.role = null
}
modeButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applySelection(btn.getAttribute('data-mode'))
applyMode(btn.getAttribute('data-mode'))
})
})
if (state.mode === 'single' || state.mode === 'multi') applySelection(state.mode)
if (state.mode === 'single' || state.mode === 'multi') {
applyMode(state.mode)
}
nextBtn.addEventListener('click', function () {
if (!state.mode) return
state.stepDone[2] = true
if (state.mode === 'single') renderStep4()
else renderStep3()
// 멀티는 호스트/참가자 선택 탭을 거친다. 싱글은 곧장 클라이언트(step4) 로.
if (state.mode === 'multi') renderStep2Role()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderStep1)
}
function renderStep2Role() {
// 스텝 인디케이터는 여전히 2 단계 안쪽이다 — 호스트/참가자 선택은 모드 선택의
// 하위 결정이기 때문. 별도 탭으로 분리해서 한 화면에 한 결정만 보이도록 한다.
setActiveStep(2)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>' + tt('step2.roleHeading') + '</h2>' +
'<div class="cardChoice">' +
'<button type="button" data-role="host"><strong>' + tt('step2.hostTitle') + '</strong><br><small>' + tt('step2.hostHint') + '</small></button>' +
'<button type="button" data-role="participant"><strong>' + tt('step2.participantTitle') + '</strong><br><small>' + tt('step2.participantHint') + '</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
pageHost.appendChild(section)
var nextBtn = section.querySelector('#next')
var roleButtons = section.querySelectorAll('[data-role]')
function applyRole(role) {
state.role = role
roleButtons.forEach(function (btn) {
if (btn.getAttribute('data-role') === role) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
roleButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyRole(btn.getAttribute('data-role'))
})
})
if (state.role === 'host' || state.role === 'participant') applyRole(state.role)
nextBtn.addEventListener('click', function () {
if (!state.role) return
// 호스트는 서버 설치(step3) 부터, 참가자는 클라이언트(step4) 로 바로.
if (state.role === 'host') renderStep3()
else renderStep4()
})
section.querySelector('#back').addEventListener('click', renderStep2)
}
function renderStep3() {
setActiveStep(3)
clearPage()
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>3단계. 서버 관련 설정</h2>' +
'<h2>' + tt('step3.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2, show32) }
// step3 는 멀티+호스트 만 진입하므로 sub31 의 back 은 역할 선택 탭으로.
function show31() { subHost.innerHTML = ''; renderSubStep31(subHost, renderStep2Role, show32) }
function show32() { subHost.innerHTML = ''; renderSubStep32(subHost, show31, show33) }
function show33() { subHost.innerHTML = ''; renderSubStep33(subHost, show32, show34) }
function show34() { subHost.innerHTML = ''; renderSubStep34(subHost, show33, show35) }
@@ -180,12 +268,12 @@ function renderStep3() {
function renderSubStep31(host, back, done) {
host.innerHTML =
'<h3>3-1. 서버 설치 경로</h3>' +
'<p class="formMessage">서버를 생성할 폴더를 선택하세요. 경로에 한글이 포함되면 안 됩니다.</p>' +
'<h3>' + tt('step3.sub31.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub31.description') + '</p>' +
'<div class="fieldset"><label><input id="installPath" type="text" placeholder="C:\\MusicQuizServer" value="' + (state.serverInstall.path || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickFolder">폴더 선택</button></div>' +
'<button class="secondaryBtn" id="pickFolder">' + tt('step3.sub31.pickFolder') + '</button></div>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
var input = host.querySelector('#installPath')
var msg = host.querySelector('#msg')
host.querySelector('#pickFolder').addEventListener('click', async function () {
@@ -196,11 +284,11 @@ function renderSubStep31(host, back, done) {
host.querySelector('#next').addEventListener('click', async function () {
var result = await installerApi.validateInstallPath(input.value.trim())
if (!result.ok) {
msg.textContent = result.message || '경로가 유효하지 않습니다.'
msg.textContent = result.message || tt('step3.sub31.invalidPath')
msg.classList.add('error')
return
}
msg.textContent = '경로 확정: ' + result.message
msg.textContent = tt('step3.sub31.confirmed', { message: result.message })
msg.classList.remove('error')
msg.classList.add('success')
state.serverInstall.path = input.value.trim()
@@ -210,35 +298,102 @@ function renderSubStep31(host, back, done) {
function renderSubStep32(host, back, done) {
host.innerHTML =
'<h3>3-2. JDK 확인</h3>' +
'<p class="formMessage">JAVA_HOME 또는 C:\\Program Files\\Java 에서 자동 탐색합니다. 직접 폴더를 선택해도 됩니다.</p>' +
'<h3>' + tt('step3.sub32.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub32.description') + '</p>' +
'<div class="fieldset"><label><input id="jdkPath" type="text" placeholder="C:\\Program Files\\Java\\jdk-17" value="' + (state.serverInstall.jdk || '') + '" /></label>' +
'<button class="secondaryBtn" id="pickJdk">폴더 선택</button>' +
'<button class="secondaryBtn" id="auto">자동 탐색</button></div>' +
'<button class="secondaryBtn" id="pickJdk">' + tt('step3.sub32.pickFolder') + '</button>' +
'<button class="secondaryBtn" id="auto">' + tt('step3.sub32.auto') + '</button>' +
'<button class="secondaryBtn" id="install">' + tt('step3.sub32.install') + '</button></div>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
var input = host.querySelector('#jdkPath')
var msg = host.querySelector('#msg')
host.querySelector('#auto').addEventListener('click', async function () {
var installBtn = host.querySelector('#install')
var autoBtn = host.querySelector('#auto')
var pickBtn = host.querySelector('#pickJdk')
var nextBtn = host.querySelector('#next')
var installing = false
function setInstallingUi(on) {
installing = on
if (on) {
installBtn.textContent = tt('step3.sub32.installCancel')
installBtn.classList.remove('secondaryBtn')
installBtn.classList.add('dangerBtn')
autoBtn.disabled = true
pickBtn.disabled = true
nextBtn.disabled = true
input.disabled = true
} else {
installBtn.textContent = tt('step3.sub32.install')
installBtn.classList.remove('dangerBtn')
installBtn.classList.add('secondaryBtn')
autoBtn.disabled = false
pickBtn.disabled = false
nextBtn.disabled = false
input.disabled = false
}
}
autoBtn.addEventListener('click', async function () {
if (installing) return
var detect = await installerApi.detectJdk()
if (detect.found) {
input.value = detect.path
msg.textContent = 'JDK 발견: ' + detect.path
msg.textContent = tt('step3.sub32.found', { path: detect.path })
msg.classList.remove('error')
msg.classList.add('success')
} else {
msg.textContent = 'JDK를 자동으로 찾지 못했습니다. 직접 선택해 주세요.'
msg.textContent = tt('step3.sub32.notFound')
msg.classList.remove('success')
msg.classList.add('error')
}
})
host.querySelector('#pickJdk').addEventListener('click', async function () {
pickBtn.addEventListener('click', async function () {
if (installing) return
var picked = await installerApi.pickFolder()
if (picked) input.value = picked
})
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', function () {
installBtn.addEventListener('click', async function () {
if (installing) {
// 진행 중이면 취소.
msg.textContent = tt('step3.sub32.cancelRequested')
msg.classList.remove('success', 'error')
await installerApi.cancelJdkInstall()
return
}
setInstallingUi(true)
msg.classList.remove('success', 'error')
msg.textContent = tt('step3.sub32.downloading')
try {
var result = await installerApi.installJdk()
if (result.ok && result.path) {
input.value = result.path
state.serverInstall.jdk = result.path
msg.textContent = tt('step3.sub32.installComplete', { path: result.path })
msg.classList.add('success')
} else {
var raw = result.message || tt('common.unknownError')
msg.textContent = raw === '취소됨'
? tt('step3.sub32.installCanceled')
: tt('step3.sub32.installFailed', { message: raw })
msg.classList.add('error')
}
} catch (err) {
msg.textContent = tt('step3.sub32.installError', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
} finally {
setInstallingUi(false)
}
})
host.querySelector('#back').addEventListener('click', function () {
if (installing) return
back()
})
nextBtn.addEventListener('click', function () {
if (installing) return
if (!input.value.trim()) {
msg.textContent = 'JDK 경로를 입력해 주세요.'
msg.textContent = tt('step3.sub32.pathRequired')
msg.classList.add('error')
return
}
@@ -249,70 +404,73 @@ function renderSubStep32(host, back, done) {
var detect = await installerApi.detectJdk()
if (detect.found && !input.value) {
input.value = detect.path
msg.textContent = 'JDK 자동 탐색됨: ' + detect.path
msg.textContent = tt('step3.sub32.autoDetected', { path: detect.path })
msg.classList.add('success')
} else if (!detect.found) {
msg.textContent = tt('step3.sub32.notFoundHint')
}
})()
}
function renderSubStep33(host, back, done) {
host.innerHTML =
'<h3>3-3. 서버 다운로드 및 설치</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 서버 파일을 다운로드합니다. 진행 상황은 하단 로그 뷰어에 표시됩니다.</p>' +
'<div class="formMessage" id="downloadStatus">대기 중</div>' +
'<button class="primaryBtn" id="startDownload">다운로드 시작</button>' +
'<h3>' + tt('step3.sub33.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub33.description') + '</p>' +
'<div class="formMessage" id="downloadStatus">' + tt('step3.sub33.waiting') + '</div>' +
'<div id="ramSection" hidden style="margin-top:14px;">' +
'<h4>램 검사</h4>' +
'<div class="formMessage" id="ramMsg">검사 중...</div>' +
'<h4>' + tt('step3.sub33.ramHeading') + '</h4>' +
'<div class="formMessage" id="ramMsg">' + tt('step3.sub33.ramChecking') + '</div>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var startBtn = host.querySelector('#startDownload')
var statusEl = host.querySelector('#downloadStatus')
var ramSection = host.querySelector('#ramSection')
var ramMsg = host.querySelector('#ramMsg')
var nextBtn = host.querySelector('#next')
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', function () {
if (!state.serverInstall.eulaAccepted) return
done()
})
// 이미 통과했던 상태 복원: 사용자가 다음→이전으로 돌아왔을 때 재다운로드 강요하지 않는다.
if (state.serverInstall.eulaAccepted && state.serverInstall.ram) {
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
showRamResult(state.serverInstall.ram)
nextBtn.disabled = false
return
}
startBtn.addEventListener('click', async function () {
startBtn.disabled = true
// 페이지 진입 즉시 자동 다운로드
;(async function () {
state.serverInstall.eulaAccepted = false
nextBtn.disabled = true
statusEl.classList.remove('success', 'error')
statusEl.textContent = '다운로드 중...'
statusEl.textContent = tt('step3.sub33.downloading')
try {
await installerApi.startServerInstall({
packKey: state.selectedPackKey,
installPath: state.serverInstall.path,
jdkPath: state.serverInstall.jdk
})
statusEl.textContent = 'EULA 동의가 필요합니다. 팝업을 확인해 주세요.'
statusEl.textContent = tt('step3.sub33.eulaPrompt')
var accepted = await openEulaPopup(state.serverInstall.path)
if (!accepted) {
statusEl.textContent = 'EULA 동의 실패. 다운로드를 취소합니다. "다운로드 시작"으로 다시 시도하세요.'
statusEl.textContent = tt('step3.sub33.eulaRejected')
statusEl.classList.add('error')
startBtn.disabled = false
return
}
try {
await installerApi.acceptEula(state.serverInstall.path)
} catch (err) {
statusEl.textContent = 'EULA 저장 실패: ' + err.message
statusEl.textContent = tt('step3.sub33.eulaSaveFailed', { message: err.message })
statusEl.classList.add('error')
startBtn.disabled = false
return
}
state.serverInstall.eulaAccepted = true
statusEl.textContent = '다운로드 및 EULA 동의 완료.'
statusEl.textContent = tt('step3.sub33.doneSummary')
statusEl.classList.add('success')
var ram = await installerApi.checkRam(state.selectedPackKey)
state.serverInstall.ram = ram
@@ -320,16 +478,10 @@ function renderSubStep33(host, back, done) {
if (ram.decision === 'tooLow') return
nextBtn.disabled = false
} catch (err) {
statusEl.textContent = '다운로드 실패: ' + err.message
statusEl.textContent = tt('step3.sub33.downloadFailed', { message: (err && err.message) ? err.message : String(err) })
statusEl.classList.add('error')
startBtn.disabled = false
}
})
nextBtn.addEventListener('click', function () {
if (!state.serverInstall.eulaAccepted) return
done()
})
})()
function showRamResult(result) {
ramSection.hidden = false
@@ -337,44 +489,40 @@ function renderSubStep33(host, back, done) {
if (result.decision === 'tooLow') {
var pack = state.packs.find(function (p) { return p.key === state.selectedPackKey })
var minRam = pack ? pack.pack.serverMinRam : 0
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 음악퀴즈 최소 요구치(' + minRam + 'MB)에 미치지 못합니다. 설치를 중단합니다.'
ramMsg.innerHTML = tt('step3.sub33.ramTooLow', { system: result.systemRamMb, min: minRam })
ramMsg.classList.add('error')
} else if (result.decision === 'minOk') {
ramMsg.innerHTML = '시스템 램(' + result.systemRamMb + 'MB)이 권장치보다 부족합니다. 최소치(' + result.appliedRamMb + 'MB)로 진행합니다.'
ramMsg.innerHTML = tt('step3.sub33.ramMinOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('warn')
} else {
ramMsg.textContent = '시스템 램(' + result.systemRamMb + 'MB) 충분. ' + result.appliedRamMb + 'MB로 설정.'
ramMsg.textContent = tt('step3.sub33.ramMaxOk', { system: result.systemRamMb, applied: result.appliedRamMb })
ramMsg.classList.add('success')
}
}
}
// EULA 동의 팝업. resolve(true) = 동의, resolve(false) = 비동의/창 닫힘.
async function openEulaPopup(installPath) {
var read = await installerApi.readEula(installPath)
// eula.txt 의 내용과 무관하게 항상 minecraft.net 의 공식 EULA 페이지를 받아서
// 표시한다 — 사용자가 실제 서버 약관을 보고 동의하도록.
async function openEulaPopup(_installPath) {
var bodyHtml = ''
if (read.exists) {
bodyHtml = '<p class="formMessage">서버 파일에 포함된 eula.txt 내용입니다.</p>' +
'<pre class="eulaPre">' + escapeHtml(read.content) + '</pre>'
} else {
var fetched = await installerApi.fetchMinecraftEula()
if (fetched.html) {
bodyHtml = '<p class="formMessage">서버 파일에 eula.txt가 없어 minecraft.net의 EULA를 표시합니다 (<a href="' + fetched.url + '" target="_blank">' + fetched.url + '</a>).</p>' +
bodyHtml = '<p class="formMessage">' + tt('step3.eulaModal.fromMojang', { url: fetched.url }) + '</p>' +
'<iframe class="eulaFrame" sandbox srcdoc="' + escapeAttr(fetched.html) + '"></iframe>'
} else {
bodyHtml = '<p class="formMessage error">EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href="https://www.minecraft.net/en-us/eula" target="_blank">https://www.minecraft.net/en-us/eula</a></p>'
}
bodyHtml = '<p class="formMessage error">' + tt('step3.eulaModal.loadFailed') + '</p>'
}
return new Promise(function (resolve) {
var overlay = document.createElement('div')
overlay.className = 'modalOverlay'
overlay.innerHTML =
'<div class="modalCard" role="dialog" aria-modal="true">' +
'<header><h3>Minecraft EULA 동의</h3><button type="button" class="modalClose" aria-label="닫기">×</button></header>' +
'<header><h3>' + tt('step3.eulaModal.title') + '</h3><button type="button" class="modalClose" aria-label="' + tt('common.close') + '">×</button></header>' +
'<div class="modalBody">' + bodyHtml + '</div>' +
'<footer class="actionRow">' +
'<button type="button" class="secondaryBtn" data-action="reject">비동의</button>' +
'<button type="button" class="primaryBtn" data-action="accept">동의</button>' +
'<button type="button" class="secondaryBtn" data-action="reject">' + tt('common.reject') + '</button>' +
'<button type="button" class="primaryBtn" data-action="accept">' + tt('common.agree') + '</button>' +
'</footer>' +
'</div>'
document.body.appendChild(overlay)
@@ -395,30 +543,24 @@ async function openEulaPopup(installPath) {
})
}
function escapeHtml(text) {
return String(text).replace(/[&<>"']/g, function (ch) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]
})
}
function escapeAttr(text) {
return String(text).replace(/&/g, '&amp;').replace(/"/g, '&quot;')
}
function renderSubStep34(host, back, done) {
host.innerHTML =
'<h3>3-4. 서버 설정 편집</h3>' +
'<p class="formMessage">로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.</p>' +
'<button class="secondaryBtn" id="open">편집기 열기</button>' +
'<h3>' + tt('step3.sub34.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub34.description') + '</p>' +
'<button class="secondaryBtn" id="open">' + tt('step3.sub34.open') + '</button>' +
'<div class="formMessage" id="editorMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next">' + tt('common.next') + '</button></div>'
host.querySelector('#open').addEventListener('click', async function () {
var msg = host.querySelector('#editorMsg')
try {
var result = await installerApi.startServerConfigEditor(state.serverInstall.path)
msg.innerHTML = '편집기 주소: <a href="' + result.url + '" target="_blank">' + result.url + '</a>'
msg.innerHTML = tt('step3.sub34.openedAt', { url: result.url })
} catch (err) {
msg.textContent = '편집기 실행 실패: ' + err.message
msg.textContent = tt('step3.sub34.openFailed', { message: err.message })
msg.classList.add('error')
}
})
@@ -428,35 +570,58 @@ function renderSubStep34(host, back, done) {
function renderSubStep35(host, back, done) {
host.innerHTML =
'<h3>3-5. 포트포워딩 점검</h3>' +
'<p class="formMessage">서버의 외부 접근 가능 여부를 확인합니다. UPnP를 시도해도 안 되면 직접 포트포워딩을 안내합니다.</p>' +
'<div class="fieldset"><label>포트 <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">검사 시작</button>' +
'<h3>' + tt('step3.sub35.heading') + '</h3>' +
'<p class="formMessage">' + tt('step3.sub35.description') + '</p>' +
'<div class="fieldset"><label>' + tt('step3.sub35.portLabel') + ' <input id="port" type="text" value="25565" /></label></div>' +
'<button class="secondaryBtn" id="run">' + tt('step3.sub35.recheck') + '</button>' +
'<div class="formMessage" id="resultMsg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var resultMsg = host.querySelector('#resultMsg')
var nextBtn = host.querySelector('#next')
if (state.serverInstall.portStatus) nextBtn.disabled = false
var runBtn = host.querySelector('#run')
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#run').addEventListener('click', async function () {
// 25565 는 마인크래프트 자바판 기본 포트라 클라이언트에서 생략 가능 →
// 사용자에게도 ip 만 보여주는 게 깔끔하다.
function formatServerAddress(ip, port) {
var safeIp = ip || tt('step3.sub35.ipUnknown')
if (Number(port) === 25565) return safeIp
return safeIp + ':' + port
}
async function runCheck() {
runBtn.disabled = true
resultMsg.classList.remove('success', 'warn', 'error')
resultMsg.textContent = tt('step3.sub35.checking')
var port = Number(host.querySelector('#port').value) || 25565
resultMsg.textContent = '확인 중...'
try {
var result = await installerApi.checkPortForward(port)
state.serverInstall.portStatus = result
var address = formatServerAddress(result.externalIp, result.port)
if (result.status === 'preForwarded') {
resultMsg.innerHTML = '이미 외부 접속 가능: ' + result.externalIp + ':' + result.port
resultMsg.innerHTML = tt('step3.sub35.preForwarded', { address: address })
resultMsg.classList.add('success')
} else if (result.status === 'upnpOk') {
resultMsg.innerHTML = 'UPnP로 자동 개방 완료: ' + result.externalIp + ':' + result.port
resultMsg.innerHTML = tt('step3.sub35.upnpOk', { address: address })
resultMsg.classList.add('success')
} else {
resultMsg.innerHTML = (result.message || '직접 포트포워딩을 해주세요.') +
'<br><small>외부 IP: ' + (result.externalIp || '확인 불가') + ', 포트: ' + result.port + '</small>'
resultMsg.innerHTML = (result.message || tt('step3.sub35.manualHint')) +
tt('step3.sub35.manualDetail', { address: address })
resultMsg.classList.add('warn')
}
nextBtn.disabled = false
})
} catch (err) {
resultMsg.textContent = tt('step3.sub35.checkFailed', { message: (err && err.message) ? err.message : String(err) })
resultMsg.classList.add('error')
} finally {
runBtn.disabled = false
}
}
runBtn.addEventListener('click', runCheck)
nextBtn.addEventListener('click', done)
// 페이지 진입 즉시 자동 점검
runCheck()
}
function renderStep4() {
@@ -466,114 +631,78 @@ function renderStep4() {
var section = document.createElement('section')
section.className = 'page'
section.innerHTML =
'<h2>4단계. 유저 클라이언트 설정</h2>' +
'<h2>' + tt('step4.heading') + '</h2>' +
'<div class="subStep" id="subHost"></div>'
pageHost.appendChild(section)
var subHost = section.querySelector('#subHost')
function backToPrevStep() { if (state.mode === 'multi') renderStep3(); else renderStep2() }
function show41() { subHost.innerHTML = ''; renderSubStep41(subHost, pack, backToPrevStep, show42) }
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, show41, show43) }
function show43() {
subHost.innerHTML = ''
renderSubStep43(subHost, show42, function () {
// 플랫폼 선택 UI 는 더 이상 보여주지 않는다. 음악퀴즈에 지정된 플랫폼이
// 바닐라가 아니면 자동으로 설치하고, 바닐라면 건너뛴다 — 사용자가 고를 일이 없다.
var platformType = pack ? pack.pack.platform.type : 'vanilla'
state.client.installPlatform = platformType !== 'vanilla'
// 멀티+호스트 만 step3 (서버 설치) 를 거쳤으므로 거기로 돌아간다.
// 멀티+참가자 는 직전 화면이 역할 선택 탭이므로 거기로, 싱글은 모드 탭으로.
function backToPrevStep() {
if (state.mode === 'multi' && state.role === 'host') renderStep3()
else if (state.mode === 'multi') renderStep2Role()
else renderStep2()
}
function show42() { subHost.innerHTML = ''; renderSubStep42(subHost, backToPrevStep, goStep5) }
function goStep5() {
state.stepDone[4] = true
renderStep5()
})
}
show41()
}
function renderSubStep41(host, pack, back, done) {
var platformType = pack ? pack.pack.platform.type : 'vanilla'
if (platformType === 'vanilla') {
state.client.installPlatform = false
host.innerHTML =
'<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>vanilla</strong></p>' +
'<p class="formMessage">바닐라이므로 별도 설치는 필요 없습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">다음</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
return
}
host.innerHTML =
'<h3>4-1. 모드 플랫폼</h3>' +
'<p class="formMessage">선택한 음악퀴즈의 플랫폼: <strong>' + platformType + '</strong></p>' +
'<div class="cardChoice">' +
'<button type="button" data-choice="install"><strong>권장 플랫폼 설치</strong><br><small>' + platformType + ' 설치파일을 함께 다운로드해 4-2 설치 시작 시 함께 설치됩니다.</small></button>' +
'<button type="button" data-choice="skip"><strong>기본 마인크래프트로 설치</strong><br><small>플랫폼은 설치하지 않고 바닐라로 진행합니다.</small></button>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var nextBtn = host.querySelector('#next')
var choiceButtons = host.querySelectorAll('[data-choice]')
function applyChoice(choice) {
state.client.installPlatform = choice === 'install'
choiceButtons.forEach(function (btn) {
if (btn.getAttribute('data-choice') === choice) btn.classList.add('selected')
else btn.classList.remove('selected')
})
nextBtn.disabled = false
}
choiceButtons.forEach(function (btn) {
btn.addEventListener('click', function () {
applyChoice(btn.getAttribute('data-choice'))
})
})
if (typeof state.client.installPlatform === 'boolean') {
applyChoice(state.client.installPlatform ? 'install' : 'skip')
}
host.querySelector('#back').addEventListener('click', back)
nextBtn.addEventListener('click', done)
show42()
}
function renderSubStep42(host, back, done) {
host.innerHTML =
'<h3>4-2. 모드/리소스팩 다운로드 및 launcher_profiles 갱신</h3>' +
'<p class="formMessage">%appdata%\\.mc_custom 에 모드와 리소스팩을 설치하고, launcher_profiles.json에 프로필을 등록합니다.</p>' +
'<button class="primaryBtn" id="run">설치 시작</button>' +
'<div class="formMessage" id="msg"></div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next" disabled>다음</button></div>'
var runBtn = host.querySelector('#run')
'<h3>' + tt('step4.sub42.heading') + '</h3>' +
'<p class="formMessage">' + tt('step4.sub42.description') + '</p>' +
'<div class="formMessage" id="msg">' + tt('step4.sub42.installing') + '</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="next" disabled>' + tt('common.next') + '</button></div>'
var msg = host.querySelector('#msg')
var nextBtn = host.querySelector('#next')
if (state.client.clientInstalled) nextBtn.disabled = false
host.querySelector('#back').addEventListener('click', back)
runBtn.addEventListener('click', async function () {
runBtn.disabled = true
msg.textContent = '설치 중...'
msg.classList.remove('error', 'success')
try {
await installerApi.installClient({
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform
})
msg.textContent = '클라이언트 설치 완료.'
msg.classList.add('success')
state.client.clientInstalled = true
nextBtn.disabled = false
} catch (err) {
msg.textContent = '설치 실패: ' + err.message
msg.classList.add('error')
runBtn.disabled = false
}
})
nextBtn.addEventListener('click', done)
// 이번에 실제로 보내야 할 payload. 이전 진입에서 같은 payload 로 이미 끝났으면
// 다시 돌리지 않지만, packKey / installPlatform / skipMap 중 하나라도 다르면
// (예: 참가자 → 싱글 로 뒤로가서 변경) 재설치한다.
var payload = {
packKey: state.selectedPackKey,
installPlatform: !!state.client.installPlatform,
// 참가자는 친구 서버에 접속만 하므로 클라이언트에 맵을 풀지 않는다.
skipMap: state.mode === 'multi' && state.role === 'participant'
}
var last = state.client.lastInstall
if (last
&& last.packKey === payload.packKey
&& last.installPlatform === payload.installPlatform
&& last.skipMap === payload.skipMap) {
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
nextBtn.disabled = false
return
}
function renderSubStep43(host, back, done) {
host.innerHTML =
'<h3>4-3. 완료 확인</h3>' +
'<p class="formMessage">모드와 리소스팩이 .mc_custom에 설치되어 있고, launcher_profiles.json도 갱신되었습니다.</p>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="next">5단계로</button></div>'
host.querySelector('#back').addEventListener('click', back)
host.querySelector('#next').addEventListener('click', done)
// 페이지 진입 즉시 자동 설치
;(async function () {
try {
await installerApi.installClient(payload)
msg.textContent = tt('step4.sub42.done')
msg.classList.add('success')
state.client.lastInstall = payload
nextBtn.disabled = false
} catch (err) {
// 실패한 호출은 "마지막 성공" 기록에 남기지 않는다. 다음 진입 시 재시도.
state.client.lastInstall = null
msg.textContent = tt('step4.sub42.failed', { message: (err && err.message) ? err.message : String(err) })
msg.classList.add('error')
}
})()
}
function renderStep5() {
@@ -581,37 +710,53 @@ function renderStep5() {
clearPage()
var section = document.createElement('section')
section.className = 'page'
var multi = state.mode === 'multi'
// 서버 마무리 액션 (바로가기/서버 실행) 은 step3 를 거친 호스트 만 노출한다.
// 싱글, 멀티+참가자 는 서버를 직접 띄우지 않으므로 런처만 보여준다.
var showServerActions = state.mode === 'multi' && state.role === 'host'
section.innerHTML =
'<h2>5단계. 설치 완료</h2>' +
'<p>모든 단계가 끝났습니다. 아래 옵션을 선택해 주세요.</p>' +
(multi ? '<div class="subStep">' +
'<h3>서버</h3>' +
'<button class="secondaryBtn" id="openFolder">서버 폴더 열기</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> 바탕화면에 서버 실행 바로가기 만들기</label>' +
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> 서버 바로 실행</label>' +
'<h2>' + tt('step5.heading') + '</h2>' +
'<p>' + tt('step5.summary') + '</p>' +
(showServerActions ? '<div class="subStep">' +
'<h3>' + tt('step5.serverHeading') + '</h3>' +
'<button class="secondaryBtn" id="openFolder">' + tt('step5.openServerFolder') + '</button>' +
'<label class="toggleRow"><input type="checkbox" id="shortcut" checked /> ' + tt('step5.shortcut') + '</label>' +
'<label class="toggleRow"><input type="checkbox" id="startServer" checked /> ' + tt('step5.startServer') + '</label>' +
'</div>' : '') +
'<div class="subStep">' +
'<h3>마인크래프트 런처</h3>' +
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> 마인크래프트 런처 실행</label>' +
'<h3>' + tt('step5.launcherHeading') + '</h3>' +
'<label class="toggleRow"><input type="checkbox" id="startLauncher" checked /> ' + tt('step5.startLauncher') + '</label>' +
'</div>' +
'<div class="actionRow"><button class="secondaryBtn" id="back">이전</button><button class="primaryBtn" id="finish">완료</button></div>'
'<div class="actionRow"><button class="secondaryBtn" id="back">' + tt('common.back') + '</button><button class="primaryBtn" id="finish">' + tt('step5.finish') + '</button></div>'
pageHost.appendChild(section)
section.querySelector('#back').addEventListener('click', renderStep4)
if (multi) {
if (showServerActions) {
section.querySelector('#openFolder').addEventListener('click', function () {
installerApi.openServerFolder()
})
}
section.querySelector('#finish').addEventListener('click', async function () {
if (multi) {
var finishBtn = section.querySelector('#finish')
finishBtn.disabled = true
finishBtn.textContent = tt('step5.finishing')
try {
if (showServerActions) {
if (section.querySelector('#shortcut').checked) await installerApi.createDesktopShortcut()
if (section.querySelector('#startServer').checked) await installerApi.startServer()
}
if (section.querySelector('#startLauncher').checked) await installerApi.startMinecraftLauncher()
section.querySelector('#finish').disabled = true
section.querySelector('#finish').textContent = '완료됨'
} catch (err) {
// 마무리 액션 실패는 무시하고 종료 진행
}
finishBtn.textContent = tt('step5.finished')
if (installerApi.quitApp) installerApi.quitApp()
})
}
// 시작 진입점: 사전을 먼저 받아서 정적 텍스트 갱신 후 첫 페이지 렌더.
;(async function () {
try {
I18N = (await installerApi.loadLocale()) || {}
} catch (_) { I18N = {} }
applyStaticI18n()
renderStep1()
})()

View File

@@ -24,7 +24,8 @@ html, body {
body {
display: grid;
grid-template-rows: auto 1fr;
/* header / main(스크롤) / logViewer(hidden 이면 0). */
grid-template-rows: auto 1fr auto;
grid-template-columns: 1fr;
}
@@ -67,8 +68,9 @@ body {
}
main {
padding: 28px 32px 100px;
padding: 28px 32px;
overflow-y: auto;
min-height: 0;
}
.page { max-width: 720px; margin: 0 auto; }
@@ -135,16 +137,14 @@ main {
.subStep h3 { margin: 0 0 8px; font-size: 16px; }
.logViewer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
/* fixed 였으면 본문 하단 버튼이 가려져서 grid 행으로 자연 배치하도록 변경. */
height: 200px;
background: #0a0d11;
border-top: 1px solid var(--border);
display: grid;
grid-template-rows: auto 1fr;
}
.logViewer.collapsed { height: 36px; }
.logViewer header { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--bg-alt); }
.logViewer header h2 { margin: 0; font-size: 13px; }
@@ -221,3 +221,85 @@ main {
.statusBadge.ok { background: rgba(63, 185, 80, 0.2); color: var(--success); }
.statusBadge.warn { background: rgba(248, 197, 49, 0.2); color: #f0c244; }
.statusBadge.fail { background: rgba(248, 81, 73, 0.2); color: var(--danger); }
/* 설치 진행 카드 그리드 */
.progressSection { margin: 18px 0 8px; }
.progressSection h3 { margin: 0 0 10px; font-size: 15px; }
.progressSection .sectionSub { font-size: 12px; color: var(--text-muted); margin-bottom: 10px; }
.progressGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
}
.progressCard {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 10px 8px;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 72px;
transition: border-color 0.15s, background 0.15s;
}
.progressCard.running { border-color: var(--accent); background: rgba(47, 129, 247, 0.10); }
.progressCard.done { border-color: var(--success); background: rgba(63, 185, 80, 0.10); }
.progressCard.error { border-color: var(--danger); background: rgba(248, 81, 73, 0.10); }
.progressCard .cardTop {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
font-weight: 600;
}
.progressCard .cardTop .label { color: var(--text); }
.progressCard .cardTop .icon { font-size: 14px; }
.progressCard.pending .cardTop .icon { color: var(--text-muted); }
.progressCard.running .cardTop .icon { color: var(--accent); }
.progressCard.done .cardTop .icon { color: var(--success); }
.progressCard.error .cardTop .icon { color: var(--danger); }
.progressCard .bar {
height: 6px;
background: #2a2f37;
border-radius: 4px;
overflow: hidden;
}
.progressCard .bar > span {
display: block;
height: 100%;
width: 0%;
background: var(--accent);
transition: width 0.18s linear;
}
.progressCard.done .bar > span { background: var(--success); }
.progressCard.error .bar > span { background: var(--danger); }
.progressCard .pct {
font-size: 11px;
color: var(--text-muted);
text-align: right;
}
.prepRow {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.prepChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 999px;
background: var(--bg-card);
border: 1px solid var(--border);
font-size: 12px;
color: var(--text-muted);
}
.prepChip.active { border-color: var(--accent); color: var(--text); }
.prepChip.done { border-color: var(--success); color: var(--success); }

View File

@@ -0,0 +1,129 @@
{
"app": {
"title": "마인크래프트 음악퀴즈 리소스팩 간편설치기"
},
"stepIndicator": {
"step1": "1. 음악퀴즈",
"step2": "2. 설치",
"step3": "3. 완료"
},
"logViewer": {
"heading": "설치 로그",
"collapse": "접기",
"expand": "펼치기"
},
"common": {
"next": "다음",
"cancel": "취소",
"confirm": "확인",
"openFolder": "리소스팩 폴더 열기",
"loading": "목록을 불러오는 중...",
"selectFailed": "선택 실패",
"listLoadFailed": "목록 로드 실패: {{message}}",
"installFailed": "설치 실패: {{message}}",
"noPacks": "등록된 음악퀴즈가 없습니다.",
"mcVersionLabel": "마인크래프트 {{version}} · ",
"trackImageCount": "음악 {{music}}곡 · 사진 {{image}}장",
"requestTimeout": "요청 시간 초과",
"tooManyRedirects": "너무 많은 요청."
},
"step1": {
"heading": "음악퀴즈 선택"
},
"step2": {
"heading": "리소스팩 설치",
"description": "음악·사진을 받아 리소스팩을 만들고 <code>%appdata%/.mc_custom/resourcepacks/</code> 에 자동 설치합니다.",
"chipYtdlp": "yt-dlp 준비",
"chipFfmpeg": "ffmpeg 준비",
"musicHeading": "음악 다운로드",
"musicSub": "{{count}}곡",
"imageHeading": "사진 다운로드",
"imageSub": "{{count}}장",
"packageHeading": "리소스팩 빌드",
"packageWaiting": "대기 중…",
"packageBuilding": "빌드 중…",
"packageDone": "설치 완료",
"cardWaiting": "대기",
"cardDone": "완료",
"cardError": "실패"
},
"step3": {
"heading": "완료",
"message": "리소스팩 설치를 완료했습니다."
},
"log": {
"manifestDownload": "manifest 다운로드: {{url}}",
"packDefFailed": "팩 정의 로드 실패 ({{file}}): {{message}} — mcVersion 폴백",
"listLoadFailed": "목록 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
"packEntry": " - {{key}}: mc={{mc}} 베이스={{base}}",
"packEntryUnknownVersion": "?",
"packEntryNoBase": "(없음)",
"selectedPack": "선택: {{key}}",
"ytdlpPreparing": "yt-dlp 준비 중…",
"ytdlpPath": "yt-dlp 경로: {{path}}",
"ffmpegPreparing": "ffmpeg 준비 중…",
"ffmpegPath": "ffmpeg 경로: {{path}}",
"cpuDetected": "CPU 코어 {{cores}}개 감지 → 동시 다운로드 {{concurrency}}개",
"musicStart": "음악 다운로드 시작 ({{total}}곡, 동시 {{concurrency}}개, 시차 {{stagger}}ms)",
"musicTrackStart": "{{idx}}번 노래 다운로드 시작",
"musicTrackDone": "{{idx}}번 노래 완료: {{name}}",
"imageStart": "사진 다운로드 시작 ({{total}}장)",
"imageDownloading": "{{idx}}번 사진 다운로드 중…",
"imageDone": "{{idx}}번 사진 완료: {{name}}",
"baseDownload": "베이스 리소스팩 다운로드: {{path}}",
"baseUrl": " URL: {{url}}",
"baseReceived": "베이스 리소스팩 받음 ({{kb}} KB)",
"baseAbsent": "베이스 리소스팩 없음(resourcepackPath 빈 값) — 새 리소스팩으로 생성",
"buildingZip": "리소스팩 zip 빌드 중… ({{name}})",
"installComplete": "설치 완료: {{path}}",
"cancelRequested": "취소 요청됨. 실행 중 프로세스 {{count}}개 중단…",
"ytdlpExists": "yt-dlp.exe 이미 있음: {{path}}",
"ytdlpDownloading": "yt-dlp.exe 다운로드 중: {{url}}",
"ytdlpReady": "yt-dlp.exe 준비 완료: {{path}}",
"ffmpegExists": "ffmpeg.exe 이미 있음: {{path}}",
"ffmpegDownloading": "ffmpeg.exe 다운로드 중: {{url}}",
"ffmpegExtracting": "ffmpeg zip 압축 해제 중…",
"ffmpegReady": "ffmpeg.exe 준비 완료: {{path}}",
"baseExtract": "베이스 리소스팩 압축 해제: {{name}}",
"baseShaderOverrideStripped": "베이스 리소스팩의 vanilla 셰이더 오버라이드 제거: assets/minecraft/shaders/{{path}} — mcVersion {{mc}} (pack_format {{format}}) 의 새 GLSL API 와 호환되지 않아 결과 zip 에서 제외했습니다.",
"packFormatMatched": "pack_format = {{format}} (mcVersion {{matched}})",
"packFormatFallback": "pack_format = {{format}} (mcVersion \"{{version}}\" 매칭 실패, 최신 폴백)",
"packFormatRange": "호환 범위 선언: pack_format {{min}} ~ {{max}} (supported_formats / min_format / max_format 모두 기록)",
"soundsMerged": "기존 sounds.json 병합 ({{count}}개 항목)",
"ytdlpLine": "yt-dlp> {{line}}"
},
"progress": {
"ytdlpPreparing": "yt-dlp 준비 중",
"ffmpegPreparing": "ffmpeg 준비 중",
"ready": "준비 완료",
"cancelled": "취소됨",
"baseDownloading": "베이스 리소스팩 다운로드 중",
"buildingWithBase": "베이스에 음악·사진 추가 중",
"buildingZip": "zip 빌드 중",
"installComplete": "설치 완료"
},
"pack": {
"description": "음악퀴즈 리소스팩 - {{name}}"
},
"errors": {
"selectedPackNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
"selectPackFirst": "음악퀴즈를 먼저 선택해주세요.",
"currentPackNotFound": "선택된 음악퀴즈를 찾을 수 없습니다.",
"cancelledByUser": "사용자가 설치를 취소했습니다.",
"musicDownloadFailed": "{{idx}}번 노래 다운로드 실패: {{message}}",
"imageDownloadFailed": "{{idx}}번 사진 다운로드 실패: {{message}}",
"imageNormalizeFailed": "{{idx}}번 사진 정규화 실패: {{message}}",
"baseDownloadFailed": "베이스 리소스팩 다운로드 실패: {{message}}",
"ytdlpSignal": "yt-dlp 가 신호 {{signal}} 로 종료됨",
"ytdlpExit": "yt-dlp 종료 코드 {{code}}: {{stderr}}",
"ytdlpNoStderr": "(stderr 없음)",
"ytdlpMissingOutput": "예상 출력파일이 없음: {{path}}",
"imageMetaUnknown": "이미지 크기를 읽지 못함",
"ytdlpVerifyFailed": "yt-dlp.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp.exe 자동 설치 실패: {{message}}",
"ffmpegNotInZip": "zip 내부에서 ffmpeg.exe 를 찾을 수 없습니다.",
"ffmpegVerifyFailed": "ffmpeg.exe 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ffmpegInstallFailed": "ffmpeg.exe 자동 설치 실패: {{message}}"
}
}

View File

@@ -0,0 +1,295 @@
{
"common": {
"back": "이전",
"next": "다음",
"ok": "확인",
"cancel": "취소",
"close": "닫기",
"agree": "동의",
"reject": "비동의",
"apply": "적용",
"save": "저장",
"load": "불러오기",
"expand": "펼치기",
"collapse": "접기",
"saved": "저장 완료",
"saveFailed": "저장 실패",
"unknownError": "알 수 없는 오류"
},
"app": {
"browserTitle": "마인크래프트 음악퀴즈 간편설치기",
"headerTitle": "마인크래프트 음악퀴즈 간편설치기"
},
"stepIndicator": {
"step1": "1. 음악퀴즈",
"step2": "2. 모드",
"step3": "3. 서버",
"step4": "4. 클라이언트",
"step5": "5. 완료"
},
"logViewer": {
"title": "설치 로그"
},
"step1": {
"heading": "설치할 음악퀴즈 선택",
"loading": "목록을 불러오는 중...",
"empty": "등록된 음악퀴즈가 없습니다.",
"fetchFailed": "목록을 가져오지 못했습니다: {{message}}",
"subtitle": "마인크래프트 {{mc}} / {{platform}}"
},
"step2": {
"heading": "싱글 / 멀티 선택",
"singleTitle": "싱글",
"singleHint": "싱글 맵으로 혼자 플레이할때",
"multiTitle": "멀티",
"multiHint": "버킷 서버로 친구들과 같이 플레이할때",
"roleHeading": "호스트 / 참가자",
"hostTitle": "호스트",
"hostHint": "내가 서버를 직접 열고 친구들을 초대할 때",
"participantTitle": "참가자",
"participantHint": "친구가 연 서버에 접속만 할 때"
},
"step3": {
"heading": "서버 관련 설정",
"sub31": {
"heading": "서버 설치 경로",
"description": "서버를 생성할 폴더를 선택하세요.",
"pickFolder": "폴더 선택",
"invalidPath": "경로가 유효하지 않습니다.",
"confirmed": "경로 확정: {{message}}"
},
"sub32": {
"heading": "JDK 확인",
"description": "JDK 자동탐색 or 설치",
"pickFolder": "폴더 선택",
"auto": "자동 탐색",
"install": "자동 설치",
"installCancel": "설치 취소",
"found": "JDK 발견: {{path}}",
"autoDetected": "JDK 자동 탐색됨: {{path}}",
"notFound": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 눌러 JDK를 설치하거나 직접 선택해 주세요.",
"notFoundHint": "JDK를 자동으로 찾지 못했습니다. \"자동 설치\" 를 누르면 JDK를 받아 설치합니다.",
"cancelRequested": "JDK 설치 취소 요청 중...",
"downloading": "JDK 다운로드 중...",
"installComplete": "JDK 자동 설치 완료: {{path}}",
"installCanceled": "JDK 설치 취소됨",
"installFailed": "JDK 설치 실패: {{message}}",
"installError": "JDK 설치 오류: {{message}}",
"pathRequired": "JDK 경로를 입력해 주세요."
},
"sub33": {
"heading": "서버 다운로드 및 설치",
"description": "서버 파일 다운로드",
"waiting": "대기 중",
"downloading": "다운로드 중...",
"ramHeading": "램 검사",
"ramChecking": "검사 중...",
"eulaPrompt": "EULA 동의가 필요합니다. 팝업을 확인해 주세요.",
"eulaRejected": "EULA 동의 실패. 다운로드를 취소했습니다.",
"eulaSaveFailed": "EULA 저장 실패: {{message}}",
"doneSummary": "다운로드 및 EULA 동의 완료.",
"downloadFailed": "다운로드 실패: {{message}}",
"ramTooLow": "시스템 램({{system}}MB)이 음악퀴즈 최소 요구치({{min}}MB)에 미치지 못합니다. 설치를 중단합니다.",
"ramMinOk": "시스템 램({{system}}MB)이 권장치보다 부족합니다. 최소치({{applied}}MB)로 진행합니다.",
"ramMaxOk": "시스템 램({{system}}MB) 확인. {{applied}}MB로 설정."
},
"eulaModal": {
"title": "Minecraft EULA 동의",
"fromMojang": "마인크래프트 서버를 실행하려면 아래 EULA에 동의해야 합니다 (<a href=\"{{url}}\" target=\"_blank\">{{url}}</a>).",
"loadFailed": "EULA 페이지를 불러올 수 없습니다. 직접 확인해 주세요: <a href=\"https://www.minecraft.net/en-us/eula\" target=\"_blank\">https://www.minecraft.net/en-us/eula</a>"
},
"sub34": {
"heading": "서버 설정 편집",
"description": "로컬 웹서버를 띄워 server.properties / bukkit.yml 등을 GUI로 편집합니다.",
"open": "편집기 열기",
"openedAt": "편집기 주소: <a href=\"{{url}}\" target=\"_blank\">{{url}}</a>",
"openFailed": "편집기 실행 실패: {{message}}"
},
"sub35": {
"heading": "포트포워딩",
"description": "UPNP를 개방해 외부 접속을 허용합니다.",
"portLabel": "포트",
"recheck": "재점검",
"checking": "확인 중...",
"preForwarded": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (이미 외부 개방되어 있음)",
"upnpOk": "포트포워딩 성공! 친구는 <strong>{{address}}</strong> 주소로 서버에 접속할 수 있습니다. (UPnP로 자동 개방 완료)",
"manualHint": "직접 포트포워딩을 해주세요.",
"manualDetail": "<br><small>외부 주소: {{address}}</small>",
"checkFailed": "점검 실패: {{message}}",
"ipUnknown": "확인 불가"
}
},
"step4": {
"heading": "클라이언트 설정",
"sub42": {
"heading": "다운로드 및 적용",
"description": "클라이언트 설정",
"installing": "설치 중...",
"done": "클라이언트 설치 완료.",
"failed": "설치 실패: {{message}}"
}
},
"step5": {
"heading": "설치 완료",
"summary": "",
"serverHeading": "서버",
"openServerFolder": "서버 폴더 열기",
"shortcut": "바탕화면에 서버 실행 바로가기 만들기",
"startServer": "서버 바로 실행",
"launcherHeading": "마인크래프트 런처",
"startLauncher": "마인크래프트 런처 실행",
"finish": "완료",
"finishing": "마무리 중…",
"finished": "완료됨"
},
"configEditor": {
"pageTitle": "서버 설정 편집기",
"heading": "서버 설정 편집기",
"intro": "아래 파일을 직접 편집한 후 \"적용\" 버튼으로 저장합니다. 설치기 화면에서 다음 단계로 진행하기 전 마음껏 편집할 수 있습니다.",
"targetLabel": "대상 파일",
"applyButton": "적용",
"saved": "저장 완료",
"saveFailed": "저장 실패",
"unknownFile": "알 수 없는 파일",
"serverError": "서버 오류: {{message}}"
},
"errors": {
"requestTimeout": "요청 시간 초과",
"requestTimeout15s": "요청 시간 초과(15s)",
"canceled": "취소되었습니다.",
"canceledShort": "취소됨",
"packNotFound": "선택한 음악퀴즈를 찾을 수 없습니다.",
"packNotFound2": "음악퀴즈를 찾을 수 없습니다.",
"installPathRequired": "서버 설치 경로를 입력해 주세요.",
"installPathHangul": "경로에 한글이 포함되면 마인크래프트 서버가 정상 동작하지 않습니다.",
"installPathHangulShort": "경로에 한글이 포함되면 안 됩니다.",
"jdkBusy": "이미 JDK 설치가 진행 중입니다.",
"javaExeMissing": "설치 후 java 실행 파일을 찾지 못했습니다: {{path}}",
"javaSpawnFailed": "Java 실행 실패: {{message}}",
"fabricInstallerExit": "fabric-installer 종료 코드 {{code}}{{detail}}",
"fabricLoaderRequired": "Fabric 로더 버전이 음악퀴즈에 지정되지 않았습니다. 관리 사이트에서 platform.loaderVersion 을 설정해 주세요.",
"fabricInstallerListEmpty": "Fabric installer 목록을 받지 못했습니다.",
"portAllocFail": "포트를 할당할 수 없습니다.",
"upnpTimeout": "UPnP 응답 없음(타임아웃 15s). 라우터의 UPnP가 꺼져 있거나 SSDP 패킷이 차단됐을 수 있습니다.",
"parseResponseFailed": "응답 파싱 실패: {{snippet}}"
},
"log": {
"manifestDownload": "manifest 다운로드: {{url}}",
"packLoadFail": "pack 로드 실패 ({{file}}): {{message}}",
"packsLoaded": "로드된 음악퀴즈: {{count}}개",
"selectedPack": "선택: {{key}}",
"jdkInstallStart": "JDK(Temurin 21) 자동 설치 시작 — 다운로드 중...",
"jdkDownloadProgress": "JDK 다운로드: {{percent}}% ({{loaded}}MB / {{total}}MB)",
"jdkExtracting": "JDK 압축 해제 중...",
"jdkDoneRoot": "JDK 자동 설치 완료: {{path}}",
"jdkCanceled": "JDK 설치가 취소되었습니다.",
"jdkInstallFailedLog": "JDK 설치 실패: {{message}}",
"jdkCancelRequested": "JDK 설치 취소 요청을 보냈습니다.",
"labelDownload": "{{label}} 다운로드: {{url}}",
"labelExtract": "{{label}} 압축 해제: {{dir}}",
"labelServerFile": "서버 파일",
"labelMap": "맵",
"skipServerZip": "서버 파일(serverPath)이 비어 있어 서버 zip 다운로드를 건너뜁니다.",
"skipMapZip": "맵 다운로드를 건너뜁니다 (mapPath 비어 있음 또는 참가자 모드).",
"cleanupInstallerMap": "이전 설치에서 풀어둔 맵 {{count}}개를 정리합니다.",
"mapInstalledAs": "맵을 saves/{{name}} 으로 설치했습니다.",
"clearMods": "기존 mods 폴더({{dir}})를 비우고 새로 받습니다.",
"skipModsFolder": "modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.",
"modsIndexFetch": "모드 목록 조회: {{url}}",
"modsFolderEmpty": "/file/mods/{{folder}}/ 안에 .jar 파일이 없습니다.",
"modDownload": "모드 다운로드: {{file}}",
"skipResourcepack": "resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.",
"resourcepackDownload": "리소스팩 다운로드: {{url}}",
"serverInstallPath": "서버 설치 경로: {{path}}",
"runBatMissing": "run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.",
"runBatAlreadyInjected": "run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.",
"runBatNoJava": "run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.",
"runBatInjected": "run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.",
"mojangEulaFetchFail": "Minecraft EULA 페이지 조회 실패: {{message}}",
"eulaAccepted": "EULA 동의 저장 완료.",
"configEditorOpen": "서버 설정 편집기 실행: {{url}}",
"portCheckStart": "포트포워딩 점검 시작: 포트 {{port}}",
"upnpCleanup": "이전 실행의 UPnP 매핑이 남아 있으면 제거합니다(중복 방지)...",
"externalIpHttp": "외부 IP 확인(HTTP): {{ip}}",
"externalIpHttpFail": "외부 IP 확인 실패(HTTP). UPnP 게이트웨이를 통한 조회 시도...",
"externalIpUpnp": "외부 IP 확인(UPnP): {{ip}}",
"externalIpUpnpFail": "UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.",
"probeStart": "외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...",
"probeResult": "1차 점검 결과: {{verdict}} ({{detail}})",
"probeVerdictSuccess": "성공",
"probeVerdictFail": "실패",
"probeVerdictUnknown": "확인 불가",
"probePreForwarded": "외부에서 {{addr}}:{{port}} 접근 확인됨. 사용자 규칙으로 포워딩 됨.",
"ipUnknown": "(IP 미상)",
"upnpTryOpen": "UPnP로 포트 {{port}} 자동 개방 시도(TCP)...",
"upnpReqOk": "UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.",
"upnpTryFail": "UPnP 시도 실패: {{message}}",
"upnpFailDetail": "UPnP 실패: {{message}}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.",
"upnpRecheck": "UPnP 적용 후 재점검 {{attempt}}/3...",
"upnpDone": "UPnP로 포트 {{port}} 자동 개방 완료. 테스트 매핑을 제거합니다(실제 개방은 run.bat 이 서버 기동 시 자동으로 처리).",
"upnpCleanupTest": "테스트용 UPnP 매핑을 정리합니다.",
"upnpFailReason1": "UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.",
"upnpFailReason2": "외부 포트체크 결과를 받지 못했습니다({{detail}}). UPnP 매핑은 등록됐을 수 있습니다.",
"upnpClientFail": "UPnP 클라이언트 생성 실패: {{message}}",
"upnpExternalTimeout": "UPnP externalIp 조회 타임아웃(8s).",
"upnpExternalErr": "UPnP externalIp 오류: {{message}}",
"portInUse": "포트 {{port}}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.",
"listenerBindFail": "임시 리스너 바인딩 실패: {{message}}",
"detailListenerHit": "임시 리스너 도달={{value}}",
"detailListenerSkip": "임시 리스너=skip(포트 사용중)",
"detailIfconfig": "ifconfig.co reachable={{reachable}} ip={{ip}}",
"detailIfconfigFail": "ifconfig.co 실패={{error}}",
"detailNone": "결과 없음",
"upnpClientFailRemove": "UPnP 클라이언트 생성 실패(매핑 제거 단계): {{message}}",
"upnpRemoveTimeout": "UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.",
"upnpRemoveAttempt": "UPnP 매핑 제거 시도 결과: {{message}} (없으면 정상)",
"upnpRemoveDone": "UPnP 매핑 제거 완료(포트 {{port}}).",
"platformDownload": "플랫폼({{type}}) 다운로드: {{url}}",
"platformSaved": "플랫폼 설치파일 저장: {{path}} (사용자가 직접 실행하거나 마인크래프트 런처에서 인식할 수 있습니다.)",
"platformSkipped": "플랫폼 설치 건너뜀. 바닐라로 진행합니다.",
"fabricFetchInstallerList": "Fabric installer 최신 버전 조회 중...",
"fabricInstallerDownload": "Fabric installer {{version}} 다운로드: {{url}}",
"javaUsed": "Java 사용: {{path}}",
"fabricInstallStart": "Fabric 자동 설치 시작: {{mc}} / loader {{loader}} → {{dir}}",
"fabricInstallDone": "Fabric 자동 설치 완료.",
"fabricAlreadyInstalled": "Fabric 이미 설치돼 있어 건너뜁니다: {{id}} ({{dir}})",
"launcherProfilesMissing": "launcher_profiles.json을 찾을 수 없습니다: {{path}}",
"javaArgsUpdated": "JVM 인수 갱신(메모리 + G1 GC 튜닝 추가): \"{{before}}\" → \"{{after}}\"",
"lastVersionId": "launcher_profiles 의 lastVersionId = {{id}}",
"versionMissingWarn": "경고: .minecraft/versions/{{id}} 가 없습니다. 마인크래프트 런처에서 해당 버전을 한 번 받아주거나, 플랫폼 설치를 먼저 마쳐주세요.",
"launcherProfilesUpdated": "launcher_profiles.json 갱신: 프로필 \"{{profile}}\", gameDir={{dir}}",
"minecraftRootMissing": ".minecraft 폴더가 없어 기존 설정 복사를 건너뜁니다.",
"settingCopyFail": "설정 복사 실패 ({{name}}): {{message}}",
"settingCopySummary": "기존 마인크래프트 설정 복사: 새로 복사 {{copied}}개 / 동기화(options 류 덮어쓰기) {{synced}}개 / 보존(이미 존재) {{skipped}}개.",
"settingCopyError": "기존 설정 복사 중 오류: {{message}}",
"runtimeDirMissing": ".minecraft/{{dir}} 가 없습니다. 마인크래프트 런처를 한 번 실행한 뒤 다시 시도해주세요.",
"runtimeDirExists": ".mc_custom/{{dir}} 가 실제 폴더로 이미 존재 — 건너뜀.",
"runtimeLinkCreated": "링크 생성: .mc_custom/{{dir}} → .minecraft/{{dir}}",
"runtimeLinkFail": "링크 생성 실패 ({{dir}}): {{message}}",
"shortcutCreated": "바로가기 생성: {{path}}",
"shortcutFailed": "바로가기 생성 실패",
"shortcutDescription": "음악퀴즈 서버 실행",
"runBatMissingPath": "run.bat을 찾을 수 없습니다: {{path}}",
"serverStartRequested": "서버 실행 요청 완료.",
"launcherUrlSchemeNonWin": "마인크래프트 런처 실행 요청 완료(URL 스킴, 비-Windows).",
"launcherFail": "런처 실행 실패: {{message}}",
"launcherExecShell": "마인크래프트 런처 실행({{label}}, 셸 경유): {{path}}",
"launcherExec": "마인크래프트 런처 실행({{label}}): {{path}}",
"launcherCandFail": "{{path}} 실행 실패: {{message}}",
"launcherAppsFolderTry": "AppsFolder 로 MS Store 런처 실행 시도: {{aumid}}",
"launcherAppsFolderFail": "AppsFolder 실행 실패: {{message}}",
"launcherUrlSchemeFallback": "마지막 시도: minecraft:// URL 스킴 (런처가 없으면 MS Store 가 열릴 수 있음).",
"launcherUrlSchemeFail": "URL 스킴 실행 실패: {{message}}.",
"launcherAllFail": "Minecraft Launcher 실행 시도가 모두 실패했습니다. minecraft.net 또는 Microsoft Store 에서 \"Minecraft Launcher\" 를 설치한 뒤 다시 시도해 주세요."
},
"candidates": {
"winProgramFiles86": "Win32 설치(Program Files (x86))",
"winProgramFiles": "Win32 설치(Program Files)",
"winLegacy86": "Win32 설치(legacy Minecraft 폴더)",
"winLegacy": "Win32 설치(legacy Minecraft 폴더)",
"xboxGamePass": "Xbox / Game Pass",
"npmPortable": "npm/portable",
"appAliasMinecraft": "App Execution Alias(Minecraft.exe)",
"appAliasLauncher": "App Execution Alias(MinecraftLauncher.exe)"
}
}

173
locales/server/ko-kr.json Normal file
View File

@@ -0,0 +1,173 @@
{
"common": {
"back": "← 돌아가기",
"backToList": "목록으로",
"save": "저장",
"cancel": "취소",
"ok": "확인",
"delete": "삭제",
"edit": "수정",
"close": "x",
"loading": "불러오는 중..."
},
"site": {
"indexTitle": "음악퀴즈 목록",
"heroTitle": "마인크래프트 음악퀴즈",
"heroSubtitle": "설치기에서 사용 가능한 음악퀴즈 목록입니다.",
"empty": "등록된 음악퀴즈가 없습니다.",
"fileLabel": "파일: {{file}}.json",
"mcVersion": "마인크래프트",
"platform": "플랫폼",
"modsFolder": "모드 폴더",
"resourcepack": "리소스팩",
"noneFallback": "없음"
},
"nav": {
"brand": "관리자 페이지",
"logout": "로그아웃"
},
"login": {
"title": "관리자 로그인",
"password": "비밀번호",
"submit": "로그인",
"wrongPassword": "비밀번호가 올바르지 않습니다."
},
"dashboard": {
"title": "음악퀴즈 목록",
"browserTitle": "관리자 대시보드",
"editList": "음악목록 수정",
"editDatapack": "데이터팩 수정",
"addPack": "음악퀴즈 추가",
"deletePack": "음악퀴즈 삭제",
"emptyHint": "등록된 음악퀴즈가 없습니다. \"음악퀴즈 추가\" 버튼으로 새로 만들어 보세요.",
"select": "선택",
"confirmDelete": "삭제 확인",
"mcShort": "MC"
},
"list": {
"browserTitle": "음악목록 수정",
"title": "음악목록 수정"
},
"listEditor": {
"browserTitle": "{{name}} — 음악/사진 목록",
"dirtyTooltip": "저장되지 않은 변경사항이 있습니다",
"tabMusic": "음악목록",
"tabImage": "사진목록",
"saveList": "목록 저장",
"clearList": "목록 초기화",
"playlistPlaceholder": "유튜브 플레이리스트 URL",
"fetchPlaylist": "플레이리스트 불러오기",
"imageFromMusic": "음악목록에서 가져오기",
"modalConfirmTitle": "확인",
"musicEditTitle": "음악 항목 수정",
"musicEditUrl": "유튜브 영상 주소",
"musicEditHint": "저장하면 yt-dlp 로 제목·가수·재생시간을 자동으로 갱신합니다.",
"imageEditTitle": "사진 항목 수정",
"imageSegYt": "유튜브 주소",
"imageSegImg": "이미지 주소",
"imageEditUrl": "주소",
"titleFallback": "(제목 없음)",
"artistFallback": "(가수 미상)",
"rowEditTooltip": "더블클릭해서 수정",
"aliasBtn": "별칭",
"aliasBtnWithCount": "별칭 ({{count}})",
"aliasModalTitle": "별칭 - {{title}}",
"aliasBack": "← 돌아가기",
"aliasAdd": "별칭 추가",
"aliasPlaceholder": "별칭 입력",
"aliasRemove": "삭제",
"aliasHint": "정답으로 인정할 다른 표기·번역·약칭을 추가할 수 있습니다.",
"metaLoading": "메타데이터 가져오는 중…",
"metaFailedShort": "메타 조회 실패",
"metaFailedTitle": "메타데이터 조회 실패",
"metaFailedAsk": "{{message}}\n주소만 변경하고 제목/가수/시간은 그대로 둘까요?",
"saving": "저장 중…",
"saved": "저장 완료",
"saveFailed": "저장 실패: {{message}}",
"fetchEnterUrl": "플레이리스트 주소를 입력해 주세요.",
"fetchTitle": "플레이리스트 불러오기",
"fetchConfirm": "현재 {{type}}목록 순서가 모두 사라집니다. 진행할까요?",
"fetchTypeMusic": "음악",
"fetchTypeImage": "사진",
"fetchLoading": "불러오는 중…",
"fetchedCount": "{{count}}개 항목을 불러왔습니다.",
"failed": "실패: {{message}}",
"clearTitle": "목록 초기화",
"clearConfirm": "\"{{type}}목록\"을 비웁니다. 진행할까요?",
"imageFromMusicEmpty": "음악목록이 비어 있어 가져올 수 없습니다.",
"imageFromMusicTitle": "사진목록 가져오기",
"imageFromMusicConfirm": "저장된 음악목록의 영상 {{count}}개를 그대로 사진목록으로 가져옵니다.\n현재 사진목록은 모두 사라집니다. 진행할까요?",
"leaveTitle": "저장되지 않은 변경사항",
"leaveConfirm": "저장하지 않은 변경사항이 있습니다.\n저장 없이 이 페이지를 떠나시겠습니까?"
},
"editor": {
"browserTitle": "{{name}} 편집",
"eyebrow": "PACK EDITOR",
"displayName": "음악퀴즈 이름",
"fileName": "JSON 파일 이름 (확장자 제외)",
"mcVersion": "마인크래프트 버전",
"platformType": "모드 플랫폼",
"platformDownloadUrl": "플랫폼 설치파일 URL",
"platformDownloadHint": "도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.",
"platformLoaderVersion": "Fabric Loader 버전",
"platformLoaderHint": "선택한 마인크래프트 버전 기준 Fabric Loader 목록입니다. 설치기는 최신 fabric-installer 를 받아 자동으로 CLI 설치합니다.",
"platformLoaderEmpty": "호환 로더 없음",
"platformLoaderPickMc": "마인크래프트 버전을 먼저 선택하세요",
"platformLoaderLoadFailed": "로더 목록 로드 실패: {{message}}",
"serverMinRam": "서버 최소 램 (MB)",
"serverMaxRam": "서버 최대 램 (MB)",
"clientMinRam": "클라이언트 최소 램 (MB)",
"clientRecommendedRam": "클라이언트 권장 램 (MB)",
"mapPath": "맵 파일 (.zip)",
"mapPathHint": "/file/maps/ 아래 zip 파일 이름.",
"serverPath": "서버 파일 (.zip)",
"serverPathHint": "/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.",
"modsFolder": "모드 폴더 이름",
"modsFolderHint": "/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.",
"resourcepackPath": "베이스 리소스팩 (.zip)",
"resourcepackHint": "/file/resourcepacks/ 아래 .zip 파일 이름. 리소스팩 설치기가 이 zip 위에 음악·사진을 얹어 최종 리소스팩을 만듭니다. 비워두면 처음부터 새로 만듭니다.",
"outputPackName": "생성되는 리소스팩 이름",
"outputPackNamePlaceholder": "예: 음악퀴즈 테스트팩",
"outputPackNameHint": "리소스팩 설치기가 만들어 내는 zip 파일 이름이자, 마인크래프트 리소스팩 목록의 제목이 됩니다. 비워두면 파일이름_resourcepack 형태로 자동 지정됩니다. Windows 파일명 금지 문자(\\ / : * ? \" &lt; &gt; |)는 자동으로 _ 로 바뀝니다.",
"ramOrderInvalid": "클라이언트 최소 램은 권장 램보다 클 수 없습니다.",
"fabricLoaderRequired": "Fabric 로더 버전을 선택해 주세요."
},
"datapack": {
"browserTitle": "데이터팩 수정",
"title": "데이터팩 수정",
"pickPack": "음악퀴즈 선택",
"pickedNone": "선택된 음악퀴즈 없음",
"pickedLabel": "선택: {{name}}",
"totalCount": "총 {{count}}개의 음악을 찾았습니다.",
"hint": "music_quiz 데이터팩의 data/mq/function/init/songs.mcfunction 파일에 아래 코드를 그대로 덮어쓰세요.",
"export": "코드 출력",
"copy": "복사",
"copied": "복사됨",
"exporting": "출력 중…",
"exported": "출력 완료",
"failed": "실패: {{message}}",
"modalPickTitle": "음악퀴즈 선택",
"imagesZip": "이미지.zip 출력",
"imagesZipSizeLabel": "크기",
"imagesZipDownloading": "이미지.zip 생성 중…",
"imagesZipDone": "이미지.zip 다운로드 완료"
},
"errors": {
"packNotFound": "해당 음악퀴즈를 찾을 수 없습니다.",
"packNotFoundJson": "음악퀴즈를 찾을 수 없습니다.",
"videoUrlRequired": "영상 주소를 입력해 주세요.",
"playlistUrlRequired": "플레이리스트 주소를 입력해 주세요.",
"metaNotFound": "메타데이터를 찾을 수 없습니다.",
"ramOrderInvalid": "clientMinRam은 clientRecommendedRam보다 클 수 없습니다.",
"unknown": "알 수 없는 오류",
"serverError": "서버 오류: {{message}}"
},
"youtube": {
"ytdlpUnavailable": "yt-dlp 를 준비하지 못했습니다. (수동 입력으로 진행)",
"ytdlpVerifyFailed": "yt-dlp 다운로드는 됐지만 실행 검증에 실패했습니다.",
"ytdlpInstallFailed": "yt-dlp 자동 설치에 실패했습니다: {{message}}",
"ytdlpVideoFailed": "yt-dlp 영상 조회 실패 (code={{code}}): {{detail}}",
"ytdlpPlaylistFailed": "yt-dlp 플레이리스트 조회 실패 (code={{code}}): {{detail}}",
"tooManyRedirects": "redirect 가 너무 많습니다."
}
}

View File

@@ -1,7 +1,7 @@
{
"packs": [
{
"name": "음악퀴즈 v1",
"name": "음악퀴즈_test",
"file": "music-quiz"
}
]

View File

@@ -1,9 +1,9 @@
{
"name": "음악퀴즈 v1",
"mcVersion": "1.20.1",
"name": "음악퀴즈_test",
"mcVersion": "26.1.2",
"platform": {
"type": "forge",
"downloadUrl": "/forge-installer.jar"
"type": "fabric",
"downloadUrl": "/fabric-installer.jar"
},
"modsFolder": "music-quiz",
"resourcepackPath": "music-quiz.zip",

1206
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "minecraft-music-quiz-installer",
"version": "0.1.0",
"version": "0.2.2",
"description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트",
"main": "dist/installer/main.js",
"scripts": {
@@ -8,24 +8,31 @@
"start": "tsc -p tsconfig.server.json && node dist/server/app.js",
"dev:server": "tsc -p tsconfig.server.json && node dist/server/app.js",
"installer": "tsc -p tsconfig.installer.json && electron .",
"dist:win": "tsc -p tsconfig.installer.json && electron-builder --win"
"installer:rp": "tsc -p tsconfig.installer-rp.json && electron dist/installer-rp/main.js",
"preinstall:sharp-win32": "npm install --no-save --force @img/sharp-win32-x64@0.34.5",
"dist:win": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer.json && electron-builder --win --config electron-builder.yml",
"dist:win:rp": "npm run preinstall:sharp-win32 && tsc -p tsconfig.installer-rp.json && electron-builder --win --config electron-builder-rp.yml"
},
"dependencies": {
"@types/archiver": "^7.0.0",
"archiver": "^7.0.1",
"dotenv": "^17.4.2",
"ejs": "^3.1.10",
"express": "^4.19.2",
"express-session": "^1.18.0",
"ejs": "^3.1.10",
"extract-zip": "^2.0.1",
"multer": "^1.4.5-lts.1",
"nat-upnp": "^1.1.1",
"extract-zip": "^2.0.1"
"sharp": "^0.34.5"
},
"devDependencies": {
"typescript": "^5.5.4",
"@types/node": "^22.5.0",
"@types/ejs": "^3.1.5",
"@types/express": "^4.17.21",
"@types/express-session": "^1.18.0",
"@types/ejs": "^3.1.5",
"@types/multer": "^1.4.11",
"@types/node": "^22.5.0",
"electron": "^31.4.0",
"electron-builder": "^24.13.3"
"electron-builder": "^24.13.3",
"typescript": "^5.5.4"
}
}

688
public/listEditor.js Normal file
View File

@@ -0,0 +1,688 @@
(function () {
'use strict'
// listEditor.ejs 에서 주입되는 사전 (locales/server/ko-kr.json 의 listEditor + common 섹션).
// 키가 비어 있어도 lookup 함수가 키를 그대로 반환해 UI 가 깨지지는 않는다.
function tt(key, params) {
var parts = key.split('.')
var cur = (typeof I18N !== 'undefined') ? I18N : {}
for (var i = 0; i < parts.length; i++) {
if (cur && typeof cur === 'object' && parts[i] in cur) cur = cur[parts[i]]
else { cur = null; break }
}
var tpl = (typeof cur === 'string') ? cur : key
if (!params) return tpl
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, function (_m, name) {
return (name in params) ? String(params[name]) : ('{{' + name + '}}')
})
}
var state = {
musicPlaylistUrl: (INITIAL.musicPlaylistUrl) || '',
imagePlaylistUrl: (INITIAL.imagePlaylistUrl) || '',
music: Array.isArray(INITIAL.music) ? INITIAL.music.slice() : [],
images: Array.isArray(INITIAL.images) ? INITIAL.images.slice() : []
}
// 저장되지 않은 변경 추적
var dirty = false
var dirtyMarkEl = document.getElementById('dirty-mark')
var baseTitle = document.title
function updateDirtyIndicator() {
if (dirtyMarkEl) dirtyMarkEl.hidden = !dirty
document.title = dirty ? ('*' + baseTitle) : baseTitle
}
function markDirty() { dirty = true; updateDirtyIndicator() }
function markClean() { dirty = false; updateDirtyIndicator() }
// ── 탭 ────────────────────────────────────────────
var tabBtns = document.querySelectorAll('.tabBtn')
tabBtns.forEach(function (btn) {
btn.addEventListener('click', function () {
tabBtns.forEach(function (b) { b.classList.remove('active') })
btn.classList.add('active')
var key = btn.getAttribute('data-tab')
document.getElementById('tab-music').hidden = (key !== 'music')
document.getElementById('tab-image').hidden = (key !== 'image')
})
})
// ── 유틸 ──────────────────────────────────────────
function ytIdFromUrl(url) {
if (!url) return ''
var m = url.match(/[?&]v=([\w-]{11})/) || url.match(/youtu\.be\/([\w-]{11})/) ||
url.match(/\/embed\/([\w-]{11})/) || url.match(/\/shorts\/([\w-]{11})/)
return m ? m[1] : ''
}
function isYtUrl(url) { return ytIdFromUrl(url).length > 0 }
function thumbUrl(url) {
var id = ytIdFromUrl(url)
if (id) return 'https://i.ytimg.com/vi/' + id + '/hqdefault.jpg'
return url
}
function fmtTime(sec) {
var s = Math.max(0, Math.floor(Number(sec) || 0))
var m = Math.floor(s / 60)
var rem = s % 60
return m + ':' + (rem < 10 ? '0' : '') + rem
}
function setStatus(id, text, isError) {
var el = document.getElementById(id)
el.textContent = text || ''
el.classList.toggle('error', !!isError)
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' })[c]
})
}
// 사진 항목에 어울리는 캡션. 동일한 URL 의 음악 항목이 있으면 그 제목/가수 를 빌려옴.
function captionForImage(url) {
var match = null
for (var i = 0; i < state.music.length; i++) {
if (state.music[i].url === url) { match = state.music[i]; break }
}
if (match && (match.title || match.artist)) {
return { title: match.title || '', sub: match.artist || '' }
}
return { title: '', sub: '' }
}
// ── 렌더 ──────────────────────────────────────────
function renderMusic() {
var ol = document.getElementById('music-list')
ol.innerHTML = ''
state.music.forEach(function (entry, idx) {
var li = document.createElement('li')
li.className = 'trackRow'
li.draggable = true
li.dataset.index = String(idx)
// 기본 상태에서는 contenteditable 을 켜지 않는다. 더블클릭 시에만 편집 모드 ON.
// 이렇게 해야 어디를 눌러도 드래그가 시작될 수 있다.
var aliasCount = Array.isArray(entry.aliases) ? entry.aliases.length : 0
var aliasLabel = aliasCount > 0
? tt('aliasBtnWithCount', { count: aliasCount })
: tt('aliasBtn')
li.innerHTML =
'<span class="rowNum">' + (idx + 1) + '</span>' +
'<img class="rowThumb" src="' + thumbUrl(entry.url) + '" alt="" loading="lazy" draggable="false"/>' +
'<div class="rowMeta">' +
'<div class="rowTitle" spellcheck="false" data-field="title" data-placeholder="' + escapeHtml(tt('titleFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
escapeHtml(entry.title || '') +
'</div>' +
'<div class="rowSub" spellcheck="false" data-field="artist" data-placeholder="' + escapeHtml(tt('artistFallback')) + '" title="' + escapeHtml(tt('rowEditTooltip')) + '">' +
escapeHtml(entry.artist || '') +
'</div>' +
'</div>' +
'<button type="button" class="aliasBtn' + (aliasCount > 0 ? ' hasAliases' : '') + '" data-alias-open="' + idx + '" draggable="false">' +
escapeHtml(aliasLabel) +
'</button>' +
'<span class="rowDur">' + fmtTime(entry.durationSec) + '</span>'
attachDraggable(li, 'music', idx)
attachInlineEdit(li, idx)
attachAliasBtn(li, idx)
ol.appendChild(li)
})
}
function renderImage() {
var grid = document.getElementById('image-list')
grid.innerHTML = ''
state.images.forEach(function (entry, idx) {
var cap = captionForImage(entry.url)
var card = document.createElement('div')
card.className = 'imageCard'
card.draggable = true
card.dataset.index = String(idx)
card.innerHTML =
'<div class="imgWrap">' +
'<span class="cardNum">' + (idx + 1) + '</span>' +
'<img src="' + thumbUrl(entry.url) + '" alt="" loading="lazy"/>' +
'</div>' +
'<div class="cardCaption">' +
'<div class="cardTitle" title="' + escapeHtml(cap.title) + '">' + (escapeHtml(cap.title) || ('<span class="muted">' + escapeHtml(tt('titleFallback')) + '</span>')) + '</div>' +
'<div class="cardSub">' + escapeHtml(cap.sub) + '</div>' +
'</div>'
attachDraggable(card, 'image', idx)
grid.appendChild(card)
})
}
// ── 인라인 편집 (제목/가수) ─────────────────────────
// 기본 상태에서는 contenteditable 이 꺼져 있어서 row 어디를 클릭해도 드래그가 시작된다.
// 더블클릭 시점에 해당 칸만 contenteditable 로 켜고, 드래그는 일시 비활성화.
// blur 또는 Enter 키로 편집 종료 + 상태 저장 + 드래그 복원.
function attachInlineEdit(li, idx) {
li.querySelectorAll('[data-field]').forEach(function (el) {
el.addEventListener('mousedown', function (e) {
// 편집 모드일 때만 드래그를 막아서 텍스트 선택을 허용.
if (el.getAttribute('contenteditable') === 'true') e.stopPropagation()
})
el.addEventListener('dblclick', function (e) {
e.stopPropagation()
if (el.getAttribute('contenteditable') === 'true') return
el.setAttribute('contenteditable', 'true')
li.draggable = false
el.focus()
try {
var range = document.createRange()
range.selectNodeContents(el)
var sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
} catch (_) {}
})
el.addEventListener('blur', function () {
if (el.getAttribute('contenteditable') !== 'true') return
el.removeAttribute('contenteditable')
li.draggable = true
var field = el.getAttribute('data-field')
var value = (el.textContent || '').replace(/\r?\n/g, ' ').trim()
if (!state.music[idx]) return
var prev = field === 'title' ? state.music[idx].title : state.music[idx].artist
if (value === prev) return
if (field === 'title') state.music[idx].title = value
else if (field === 'artist') state.music[idx].artist = value
markDirty()
})
el.addEventListener('keydown', function (e) {
if (el.getAttribute('contenteditable') !== 'true') return
if (e.key === 'Enter') { e.preventDefault(); el.blur() }
else if (e.key === 'Escape') { e.preventDefault(); el.blur() }
})
})
}
// ── 드래그 시스템 ───────────────────────────────────
// 원본 요소 자체를 dragover 동안 이동시켜서 "착지 자리에 반투명 고스트" 효과를 만든다.
// placeholder/clone 방식은 source 를 display:none 으로 숨기는 순간 일부 브라우저에서
// 드래그가 즉시 취소되는 문제가 있어 채택하지 않는다.
var drag = null // { type, srcEl } | null
function attachDraggable(el, type, idx) {
el.addEventListener('dragstart', function (e) {
// 편집 중인 칸을 잡고 드래그하려는 경우는 드래그를 막음.
var t = e.target
if (t && t.getAttribute && t.getAttribute('contenteditable') === 'true') {
e.preventDefault()
return
}
drag = { type: type, srcEl: el }
try {
e.dataTransfer.setData('text/plain', String(idx))
e.dataTransfer.effectAllowed = 'move'
} catch (_) {}
// 드래그 이미지 캡처 이후에 ghost 스타일을 적용 (이미지에 ghost 가 묻지 않도록).
setTimeout(function () {
if (drag && drag.srcEl) drag.srcEl.classList.add('dragGhost')
}, 0)
})
el.addEventListener('dragend', cleanupDrag)
el.addEventListener('contextmenu', function (e) {
e.preventDefault()
openCtxMenu(e.pageX, e.pageY, type, idx)
})
}
function bindContainerDnd(containerId, type, orientation) {
var container = document.getElementById(containerId)
container.addEventListener('dragover', function (e) {
if (!drag || drag.type !== type) return
e.preventDefault()
try { e.dataTransfer.dropEffect = 'move' } catch (_) {}
// 컨테이너 자식 중 source 를 제외한 나머지에서 삽입 지점을 찾는다.
var target = null
for (var i = 0; i < container.children.length; i++) {
var c = container.children[i]
if (c === drag.srcEl) continue
var rect = c.getBoundingClientRect()
if (orientation === 'grid') {
// 2D 그리드: 위쪽 행에 있으면 그 행으로, 같은 행에서는 중앙 X 보다 왼쪽이면 이 카드 앞.
if (e.clientY < rect.top) { target = c; break }
if (e.clientY <= rect.bottom && e.clientX < rect.left + rect.width / 2) {
target = c; break
}
} else if (orientation === 'horizontal') {
if (e.clientX < rect.left + rect.width / 2) { target = c; break }
} else {
if (e.clientY < rect.top + rect.height / 2) { target = c; break }
}
}
if (target) {
if (drag.srcEl.nextSibling !== target) container.insertBefore(drag.srcEl, target)
} else {
if (drag.srcEl !== container.lastChild) container.appendChild(drag.srcEl)
}
})
container.addEventListener('drop', function (e) {
if (!drag || drag.type !== type) return
e.preventDefault()
// 새 인덱스 = source 의 현재 컨테이너 내 위치.
var newIdx = 0
for (var i = 0; i < container.children.length; i++) {
if (container.children[i] === drag.srcEl) { newIdx = i; break }
}
var arr = (type === 'music') ? state.music : state.images
// 원래 인덱스: state 에서 동일 url 을 찾는 대신 data-index 가 렌더 시점의 위치이므로 사용.
var srcIdx = Number(drag.srcEl.dataset.index)
if (srcIdx !== newIdx) {
var moved = arr.splice(srcIdx, 1)[0]
arr.splice(newIdx, 0, moved)
markDirty()
}
cleanupDrag()
if (type === 'music') renderMusic(); else renderImage()
})
}
bindContainerDnd('music-list', 'music', 'vertical')
bindContainerDnd('image-list', 'image', 'grid')
function cleanupDrag() {
if (!drag) return
if (drag.srcEl) drag.srcEl.classList.remove('dragGhost')
drag = null
}
// ── 컨텍스트 메뉴 ─────────────────────────────────
var ctxMenu = document.getElementById('ctxMenu')
var ctxTarget = null
function openCtxMenu(x, y, type, idx) {
ctxTarget = { type: type, index: idx }
ctxMenu.style.left = x + 'px'
ctxMenu.style.top = y + 'px'
ctxMenu.hidden = false
}
function closeCtxMenu() {
ctxMenu.hidden = true
ctxTarget = null
}
document.addEventListener('click', function (e) {
if (ctxMenu.hidden) return
if (!ctxMenu.contains(e.target)) closeCtxMenu()
})
ctxMenu.querySelectorAll('button').forEach(function (b) {
b.addEventListener('click', function () {
if (!ctxTarget) return
var action = b.getAttribute('data-ctx')
var t = ctxTarget
closeCtxMenu()
if (action === 'delete') {
if (t.type === 'music') state.music.splice(t.index, 1)
else state.images.splice(t.index, 1)
markDirty()
if (t.type === 'music') renderMusic(); else renderImage()
} else if (action === 'edit') {
openEditModal(t.type, t.index)
}
})
})
// ── 수정 팝업 ─────────────────────────────────────
var editMusic = document.getElementById('editMusicModal')
var editImage = document.getElementById('editImageModal')
var editingIdx = -1
var editingImageMode = 'yt'
function openEditModal(type, idx) {
editingIdx = idx
if (type === 'music') {
document.getElementById('edit-music-url').value = state.music[idx].url || ''
setStatus('edit-music-status', '')
editMusic.hidden = false
} else {
var url = state.images[idx].url || ''
editingImageMode = isYtUrl(url) ? 'yt' : 'img'
updateSegButtons()
document.getElementById('edit-image-url').value = url
editImage.hidden = false
}
}
function closeAllModals() {
document.querySelectorAll('.modalOverlay').forEach(function (m) { m.hidden = true })
}
document.querySelectorAll('[data-modal-close]').forEach(function (b) {
b.addEventListener('click', closeAllModals)
})
document.querySelectorAll('.modalOverlay').forEach(function (m) {
m.addEventListener('click', function (e) {
if (e.target === m) closeAllModals()
})
})
// ESC 로 열린 모달 닫기. 별칭 모달은 "돌아가기" 와 같은 저장 후 닫기 의미.
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return
var aliasOpen = aliasModal && !aliasModal.hidden
var anyOpen = document.querySelector('.modalOverlay:not([hidden])')
if (!anyOpen) return
e.preventDefault()
if (aliasOpen) {
closeAliasModalSaving()
return
}
closeAllModals()
})
document.getElementById('edit-music-save').addEventListener('click', function () {
var url = document.getElementById('edit-music-url').value.trim()
if (!url) return
var prev = state.music[editingIdx] || { url: '', title: '', artist: '', durationSec: 0 }
if (url === prev.url) { closeAllModals(); return }
setStatus('edit-music-status', tt('metaLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/video-meta', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body } })
}).then(function (result) {
if (!result.ok || !result.body || !result.body.ok) {
var msg = (result.body && result.body.message) ? result.body.message : tt('metaFailedShort')
ask(tt('metaFailedTitle'), tt('metaFailedAsk', { message: msg }), function () {
state.music[editingIdx].url = url
markDirty()
closeAllModals()
renderMusic()
})
setStatus('edit-music-status', msg, true)
return
}
var meta = result.body.entry
state.music[editingIdx] = {
url: meta.url || url,
title: meta.title || prev.title || '',
artist: meta.channel || prev.artist || '',
durationSec: typeof meta.durationSec === 'number' ? meta.durationSec : (prev.durationSec || 0)
}
markDirty()
closeAllModals()
renderMusic()
}).catch(function (err) {
setStatus('edit-music-status', tt('failed', { message: err.message }), true)
})
})
function updateSegButtons() {
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-seg') === editingImageMode)
})
}
document.querySelectorAll('#editImageModal .segBtn').forEach(function (b) {
b.addEventListener('click', function () {
editingImageMode = b.getAttribute('data-seg')
updateSegButtons()
})
})
document.getElementById('edit-image-save').addEventListener('click', function () {
var url = document.getElementById('edit-image-url').value.trim()
if (!url) return
if (state.images[editingIdx].url !== url) {
state.images[editingIdx].url = url
markDirty()
}
closeAllModals()
renderImage()
})
// ── 별칭 모달 ─────────────────────────────────────
// 음악 행의 "별칭" 버튼을 누르면 열린다. 헤더의 "← 돌아가기" 버튼 (또는 닫기 동작)이
// 호출되면 현재 인풋박스들에 입력된 값을 정규화해 state.music[idx].aliases 에 저장.
var aliasModal = document.getElementById('aliasModal')
var aliasRowsHost = document.getElementById('alias-rows')
var aliasModalTitleEl = document.getElementById('alias-modal-title')
var aliasBackBtn = document.getElementById('alias-back')
var aliasAddBtn = document.getElementById('alias-add')
var aliasEditingIdx = -1
function attachAliasBtn(li, idx) {
var btn = li.querySelector('[data-alias-open]')
if (!btn) return
// 버튼에서 시작하는 mousedown 은 행 드래그로 전파되지 않도록 차단.
btn.addEventListener('mousedown', function (e) { e.stopPropagation() })
btn.addEventListener('click', function (e) {
e.stopPropagation()
openAliasModal(idx)
})
}
function openAliasModal(idx) {
if (!state.music[idx]) return
aliasEditingIdx = idx
var entry = state.music[idx]
aliasModalTitleEl.textContent = tt('aliasModalTitle', { title: entry.title || tt('titleFallback') })
aliasRowsHost.innerHTML = ''
var existing = Array.isArray(entry.aliases) ? entry.aliases : []
if (existing.length === 0) {
// 빈 상태에서도 입력 시작을 쉽게 하려고 첫 줄 하나는 미리 만들어 둔다.
appendAliasRow('')
} else {
existing.forEach(function (a) { appendAliasRow(a) })
}
aliasModal.hidden = false
}
function appendAliasRow(value) {
var row = document.createElement('div')
row.className = 'aliasRow'
var input = document.createElement('input')
input.type = 'text'
input.className = 'textInput aliasInput'
input.placeholder = tt('aliasPlaceholder')
input.value = value || ''
var removeBtn = document.createElement('button')
removeBtn.type = 'button'
removeBtn.className = 'aliasRowRemove'
removeBtn.title = tt('aliasRemove')
removeBtn.textContent = ''
removeBtn.addEventListener('click', function () { row.remove() })
row.appendChild(input)
row.appendChild(removeBtn)
aliasRowsHost.appendChild(row)
return input
}
function readAliasInputs() {
var seen = Object.create(null)
var out = []
var inputs = aliasRowsHost.querySelectorAll('.aliasInput')
for (var i = 0; i < inputs.length; i++) {
var v = (inputs[i].value || '').trim()
if (!v) continue
if (seen[v]) continue
seen[v] = true
out.push(v)
}
return out
}
function closeAliasModalSaving() {
if (aliasEditingIdx < 0 || !state.music[aliasEditingIdx]) {
aliasModal.hidden = true
aliasEditingIdx = -1
return
}
var nextAliases = readAliasInputs()
var prev = state.music[aliasEditingIdx].aliases || []
var changed = prev.length !== nextAliases.length
if (!changed) {
for (var i = 0; i < prev.length; i++) {
if (prev[i] !== nextAliases[i]) { changed = true; break }
}
}
if (changed) {
state.music[aliasEditingIdx].aliases = nextAliases
markDirty()
renderMusic()
}
aliasModal.hidden = true
aliasEditingIdx = -1
}
aliasAddBtn.addEventListener('click', function () {
var input = appendAliasRow('')
input.focus()
})
aliasBackBtn.addEventListener('click', closeAliasModalSaving)
// 모달 바깥 클릭으로 닫혀도 입력값은 보존(저장)되도록 처리.
aliasModal.addEventListener('click', function (e) {
if (e.target === aliasModal) closeAliasModalSaving()
})
// ── 사진목록: 음악목록 그대로 복사 ─────────────────
document.getElementById('image-from-music').addEventListener('click', function () {
if (state.music.length === 0) {
setStatus('status-image', tt('imageFromMusicEmpty'), true)
return
}
ask(tt('imageFromMusicTitle'),
tt('imageFromMusicConfirm', { count: state.music.length }),
function () {
state.images = state.music.map(function (m) { return { url: m.url } })
markDirty()
renderImage()
setStatus('status-image', tt('fetchedCount', { count: state.images.length }))
})
})
// ── 액션 (save/clear/fetch) ───────────────────────
var confirmModal = document.getElementById('confirmModal')
var pendingOk = null
function ask(title, message, onOk) {
document.getElementById('confirm-title').textContent = title
document.getElementById('confirm-message').textContent = message
confirmModal.hidden = false
pendingOk = onOk
}
document.getElementById('confirm-ok').addEventListener('click', function () {
confirmModal.hidden = true
var fn = pendingOk
pendingOk = null
if (fn) fn()
})
// 취소(×, 취소 버튼, 배경 클릭)로 닫히면 pending 콜백 폐기.
confirmModal.querySelectorAll('[data-modal-close]').forEach(function (b) {
b.addEventListener('click', function () { pendingOk = null })
})
confirmModal.addEventListener('click', function (e) {
if (e.target === confirmModal) pendingOk = null
})
document.querySelectorAll('[data-action]').forEach(function (btn) {
btn.addEventListener('click', function () {
var action = btn.getAttribute('data-action')
var target = btn.getAttribute('data-target')
if (action === 'clear') {
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
ask(tt('clearTitle'), tt('clearConfirm', { type: typeLabel }), function () {
if (target === 'music') { state.music = []; renderMusic() }
else { state.images = []; renderImage() }
markDirty()
})
} else if (action === 'save') {
doSave(target)
} else if (action === 'fetch') {
doFetch(target)
}
})
})
function doSave(target) {
state.musicPlaylistUrl = document.getElementById('music-playlist-url').value.trim()
state.imagePlaylistUrl = document.getElementById('image-playlist-url').value.trim()
document.querySelectorAll('#music-list .trackRow').forEach(function (li) {
var idx = Number(li.dataset.index)
var t = li.querySelector('[data-field="title"]')
var a = li.querySelector('[data-field="artist"]')
if (state.music[idx]) {
if (t) state.music[idx].title = (t.textContent || '').replace(/\r?\n/g, ' ').trim()
if (a) state.music[idx].artist = (a.textContent || '').replace(/\r?\n/g, ' ').trim()
}
})
var statusId = 'status-' + target
setStatus(statusId, tt('saving'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state)
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (result.ok && result.body.ok) { setStatus(statusId, tt('saved')); markClean() }
else setStatus(statusId, tt('saveFailed', { message: result.body.message || '' }), true)
}).catch(function (err) {
setStatus(statusId, tt('saveFailed', { message: err.message }), true)
})
}
function doFetch(target) {
var input = document.getElementById(target + '-playlist-url')
var url = input.value.trim()
if (!url) {
setStatus('status-' + target, tt('fetchEnterUrl'), true)
return
}
var typeLabel = target === 'music' ? tt('fetchTypeMusic') : tt('fetchTypeImage')
ask(tt('fetchTitle'), tt('fetchConfirm', { type: typeLabel }), function () {
setStatus('status-' + target, tt('fetchLoading'))
fetch('/op/list/' + encodeURIComponent(PACK_KEY) + '/playlist', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: url })
}).then(function (r) {
return r.json().then(function (body) { return { ok: r.ok, body: body } })
}).then(function (result) {
if (!result.ok || !result.body.ok) {
setStatus('status-' + target, tt('failed', { message: result.body.message || '' }), true)
return
}
var entries = result.body.entries || []
if (target === 'music') {
state.music = entries.map(function (e) {
return { url: e.url, title: e.title || '', artist: e.channel || '', durationSec: e.durationSec || 0 }
})
renderMusic()
} else {
state.images = entries.map(function (e) { return { url: e.url } })
renderImage()
}
markDirty()
setStatus('status-' + target, tt('fetchedCount', { count: entries.length }))
}).catch(function (err) {
setStatus('status-' + target, tt('failed', { message: err.message }), true)
})
})
}
// 플레이리스트 URL 입력 변경 추적
;['music-playlist-url', 'image-playlist-url'].forEach(function (id) {
var el = document.getElementById(id)
if (!el) return
var initialValue = el.value
el.addEventListener('input', function () {
if (el.value !== initialValue) markDirty()
})
})
// ── 페이지 이탈 가드 ───────────────────────────────
// 1) 돌아가기 링크 : 커스텀 확인 팝업
document.querySelectorAll('a.ghostLink').forEach(function (a) {
a.addEventListener('click', function (e) {
if (!dirty) return
e.preventDefault()
var href = a.getAttribute('href')
ask(tt('leaveTitle'), tt('leaveConfirm'), function () {
markClean()
window.location.href = href
})
})
})
// 2) 탭 닫기 / 새로고침 : 브라우저 네이티브 확인 다이얼로그
window.addEventListener('beforeunload', function (e) {
if (!dirty) return
e.preventDefault()
e.returnValue = ''
})
// 초기 렌더
renderMusic()
renderImage()
})()

View File

@@ -171,9 +171,16 @@ body.siteBody.centerLayout {
gap: 12px;
}
.dashboardHeader > div { min-width: 0; }
.dashboardHeader h1 { margin: 0; font-size: 24px; }
.dashboardActions { display: flex; gap: 8px; }
.dashboardActions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-left: auto;
align-items: center;
}
.inlineForm { margin: 0; }
@@ -193,6 +200,10 @@ body.siteBody.centerLayout {
cursor: pointer;
font-size: 14px;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.primaryButton:hover { background: var(--accent-hover); }
@@ -205,6 +216,10 @@ body.siteBody.centerLayout {
border-radius: 8px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.secondaryButton:hover { border-color: var(--accent); }
@@ -218,6 +233,10 @@ body.siteBody.centerLayout {
cursor: pointer;
font-size: 14px;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.dangerButton:hover { background: #d73a48; }
@@ -357,3 +376,214 @@ body.siteBody.centerLayout {
font-size: 13px;
margin: 0 0 14px;
}
/* ── /op/list, /op/list/:pack, /op/datapack ────────────── */
.tabBar { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
.tabBtn {
background: transparent; border: none; color: var(--text-muted);
padding: 10px 18px; cursor: pointer; font-size: 14px;
border-bottom: 2px solid transparent;
}
.tabBtn:hover { color: var(--text); }
.tabBtn.active { color: var(--text); border-bottom-color: var(--accent); }
.tabPanel { display: block; }
.tabPanel[hidden] { display: none !important; }
.listActionsRow { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.statusText { font-size: 13px; color: var(--text-muted); margin-left: 8px; }
.statusText.error { color: var(--danger); }
.playlistRow { display: flex; gap: 8px; margin-bottom: 16px; }
.textInput {
flex: 1; background: var(--bg); color: var(--text);
border: 1px solid var(--border); padding: 10px 12px; border-radius: 8px;
font-size: 14px;
}
.textInput:focus { outline: none; border-color: var(--accent); }
/* 음악 행 */
.trackList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
.trackRow {
display: grid;
grid-template-columns: 36px 80px 1fr auto auto;
gap: 12px; align-items: center;
padding: 8px 12px; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px;
cursor: grab; user-select: none;
}
.aliasBtn {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
white-space: nowrap;
}
.aliasBtn:hover { border-color: var(--accent); }
.aliasBtn.hasAliases { border-color: var(--accent); color: var(--accent); }
/* 별칭 모달 */
.aliasModalHeader {
display: grid !important;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
}
.aliasModalHeader h3 { text-align: center; }
.aliasModalHeader .ghostLink {
background: transparent; border: none; color: var(--accent); cursor: pointer;
font-size: 13px; padding: 4px 8px;
}
.aliasModalHeader .ghostLink:hover { text-decoration: underline; }
.aliasRowList { display: flex; flex-direction: column; gap: 8px; }
.aliasRow { display: flex; gap: 8px; align-items: center; }
.aliasRow .aliasInput { flex: 1; }
.aliasRowRemove {
background: var(--bg-card); border: 1px solid var(--border); color: var(--danger);
width: 32px; height: 32px; border-radius: 6px; cursor: pointer;
font-size: 16px; line-height: 1; flex-shrink: 0;
}
.aliasRowRemove:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
.rowNum { color: var(--text-muted); font-size: 14px; text-align: center; }
.rowThumb { width: 80px; height: 45px; object-fit: cover; border-radius: 4px; background: #000; }
.rowMeta { min-width: 0; }
.rowTitle {
font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
outline: none; border-radius: 4px; padding: 2px 4px; margin: -2px -4px;
}
.rowSub {
font-size: 12px; color: var(--text-muted); margin-top: 2px;
outline: none; border-radius: 4px; padding: 2px 4px;
}
.rowTitle[contenteditable="true"]:hover,
.rowSub[contenteditable="true"]:hover { background: rgba(255,255,255,0.04); }
.rowTitle[contenteditable="true"]:focus,
.rowSub[contenteditable="true"]:focus {
background: var(--bg);
box-shadow: 0 0 0 1px var(--accent);
white-space: normal; cursor: text;
}
.rowTitle[contenteditable="true"]:empty::before,
.rowSub[contenteditable="true"]:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
opacity: 0.6;
}
.rowDur { color: var(--text-muted); font-size: 13px; }
/* 드래그 시스템: 원본 요소가 그대로 새 위치로 이동하면서 반투명 ghost 로 보임 */
.dragGhost {
opacity: 0.45;
outline: 2px dashed var(--accent);
outline-offset: -2px;
background: rgba(47, 129, 247, 0.08);
}
.dragGhost * { pointer-events: none !important; }
.trackRow:active { cursor: grabbing; }
.imageCard:active { cursor: grabbing; }
/* 사진 그리드 */
.imageGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.imageCard {
background: var(--bg-card);
border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; cursor: grab; user-select: none;
display: flex; flex-direction: column;
}
.imageCard .imgWrap {
position: relative; aspect-ratio: 1 / 1; overflow: hidden;
}
.imageCard img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cardNum {
position: absolute; top: 6px; left: 6px;
background: rgba(0,0,0,0.7); color: #fff;
padding: 2px 8px; border-radius: 999px;
font-size: 12px; font-weight: 600;
}
.cardCaption {
padding: 8px 10px;
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.cardTitle {
font-size: 13px; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cardSub {
font-size: 11px; color: var(--text-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-top: 2px;
}
.cardTitle .muted { color: var(--text-muted); }
/* 컨텍스트 메뉴 */
.ctxMenu {
position: absolute; z-index: 200;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 8px; padding: 4px; min-width: 120px;
box-shadow: 0 12px 24px rgba(0,0,0,0.5);
}
.ctxMenu button {
display: block; width: 100%; text-align: left;
background: transparent; border: none; color: var(--text);
padding: 8px 12px; cursor: pointer; font-size: 13px; border-radius: 4px;
}
.ctxMenu button:hover { background: var(--bg); }
/* 모달 (음악퀴즈 인스톨러의 modalOverlay 와 호환) */
.modalOverlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modalOverlay[hidden] { display: none; }
.modalCard {
background: var(--bg-alt); border: 1px solid var(--border);
border-radius: 12px; width: min(560px, 92vw); max-height: 86vh;
display: grid; grid-template-rows: auto 1fr auto; overflow: hidden;
}
.modalCard > header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; border-bottom: 1px solid var(--border);
}
.modalCard > header h3 { margin: 0; font-size: 16px; }
.modalCard > footer { padding: 12px 16px; border-top: 1px solid var(--border); }
.modalClose { background: transparent; border: none; color: var(--text-muted); font-size: 22px; cursor: pointer; }
.modalClose:hover { color: var(--text); }
.modalBody { padding: 16px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; }
.modalBody label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--text-muted); }
/* 토글 버튼 (segmented) */
.segmentedRow { display: flex; gap: 4px; }
.segBtn {
background: var(--bg-card); border: 1px solid var(--border); color: var(--text-muted);
padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
.segBtn.active { border-color: var(--accent); color: var(--text); background: rgba(47,129,247,0.15); }
/* 데이터팩 페이지 */
.dpControls { display: flex; gap: 12px; align-items: center; margin-bottom: 12px; }
.dpActions { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
.codeBlock {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 14px 16px; overflow-x: auto;
font-family: 'Consolas','SFMono-Regular',monospace; font-size: 13px;
white-space: pre-wrap; word-break: break-word;
max-height: 60vh; overflow-y: auto;
}
.packCard.pickable { cursor: pointer; }
.packCard.pickable:hover { border-color: var(--accent); }
/* 저장 안 됨 표시 (목록 편집기) */
.dirtyMark {
font-size: 36px;
font-weight: 700;
color: var(--danger, #f85149);
line-height: 1;
margin-left: auto;
align-self: flex-start;
padding: 4px 8px;
user-select: none;
}

168
src/installer-rp/ffmpeg.ts Normal file
View File

@@ -0,0 +1,168 @@
import { spawn } from 'node:child_process'
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
// extract-zip 은 CommonJS 기본 export 라 require 로 받음.
const extractZip: (source: string, options: { dir: string }) => Promise<void> = require('extract-zip')
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 ffmpeg.exe 한 종류만 사용.
* 경로: %appdata%/.mc_custom/installer/ffmpeg.exe
*/
export function getFfmpegExePath(): string {
return path.join(getMcCustomInstallerDir(), 'ffmpeg.exe')
}
/**
* 0.2.1 이전 버전이 `.mc_custom/ffmpeg.exe` 에 받아둔 파일이 있으면 새 위치로
* 옮긴다.
*/
async function migrateLegacyExe(target: string): Promise<void> {
const legacy = path.join(getMcCustomDir(), 'ffmpeg.exe')
if (legacy === target) return
try {
await fs.access(legacy, fsConst.F_OK)
} catch {
return
}
try {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(legacy, target)
} catch {
try { await fs.unlink(legacy) } catch { /* noop */ }
}
}
/** BtbN/FFmpeg-Builds 의 win64-gpl 빌드. zip 내부에 bin/ffmpeg.exe 가 들어 있음. */
const FFMPEG_ZIP_URL =
'https://github.com/BtbN/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip'
let installPromise: Promise<string> | null = null
/**
* %appdata%/.mc_custom/ffmpeg.exe 가 없거나 실행 불가하면 BtbN 빌드 zip 에서
* ffmpeg.exe 만 추출해 설치하고 절대경로를 돌려준다.
*/
export async function ensureFfmpegExe(
log?: (line: string) => void
): Promise<string> {
const target = getFfmpegExePath()
await migrateLegacyExe(target)
if (await canExecute(target)) {
log?.(t('log.ffmpegExists', { path: target }))
return target
}
if (installPromise) return installPromise
installPromise = (async () => {
const dir = getMcCustomInstallerDir()
const zipPath = path.join(dir, '.tmp_ffmpeg.zip')
const extractDir = path.join(dir, '.tmp_ffmpeg')
try {
await fs.mkdir(dir, { recursive: true })
// 이전 시도의 임시 파일/폴더 정리
await fs.rm(zipPath, { force: true })
await fs.rm(extractDir, { recursive: true, force: true })
log?.(t('log.ffmpegDownloading', { url: FFMPEG_ZIP_URL }))
await downloadToFile(FFMPEG_ZIP_URL, zipPath)
log?.(t('log.ffmpegExtracting'))
await extractZip(zipPath, { dir: extractDir })
const found = await findFile(extractDir, 'ffmpeg.exe')
if (!found) {
throw new Error(t('errors.ffmpegNotInZip'))
}
// 같은 파일시스템(=같은 드라이브) 일 가능성이 높아 rename 시도, 실패 시 copyFile fallback.
try {
await fs.rename(found, target)
} catch {
await fs.copyFile(found, target)
}
const ok = await probeVersion(target)
if (!ok) throw new Error(t('errors.ffmpegVerifyFailed'))
log?.(t('log.ffmpegReady', { path: target }))
return target
} catch (err) {
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
t('errors.ffmpegInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
// 임시 파일/폴더 정리
await fs.rm(zipPath, { force: true }).catch(() => {})
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {})
installPromise = null
}
})()
return installPromise
}
async function canExecute(filePath: string): Promise<boolean> {
try { await fs.access(filePath, fsConst.F_OK) } catch { return false }
return probeVersion(filePath)
}
function probeVersion(bin: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, ['-version'], { stdio: ['ignore', 'pipe', 'pipe'] })
let ok = false
child.stdout.on('data', () => { ok = true })
child.on('error', () => resolve(false))
child.on('close', (code) => resolve(ok && code === 0))
})
}
async function findFile(root: string, name: string): Promise<string | null> {
const entries = await fs.readdir(root, { withFileTypes: true })
for (const e of entries) {
const full = path.join(root, e.name)
if (e.isFile() && e.name.toLowerCase() === name.toLowerCase()) return full
if (e.isDirectory()) {
const inner = await findFile(full, name)
if (inner) return inner
}
}
return null
}
/** GitHub Releases latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http
const req = lib.get(url, {
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
}, (res) => {
const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) {
res.resume()
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
return
}
if (code !== 200) {
res.resume()
reject(new Error(`HTTP ${code} (${url})`))
return
}
const out = createWriteStream(dest)
res.pipe(out)
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
out.on('error', reject)
res.on('error', reject)
})
req.on('error', reject)
})
}

112
src/installer-rp/images.ts Normal file
View File

@@ -0,0 +1,112 @@
import { promises as fs } from 'node:fs'
import path from 'node:path'
import http from 'node:http'
import https from 'node:https'
import { URL } from 'node:url'
import sharp from 'sharp'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/** painting variant 텍스처의 최대 변 길이(px). 슬롯 4x4 × 256px. */
const MAX_SIDE = 1024
/** 유튜브 URL 에서 영상 ID 만 뽑아낸다. 못 찾으면 빈 문자열. */
export function ytIdFromUrl(url: string): string {
try {
const u = new URL(url)
if (u.hostname === 'youtu.be') return u.pathname.replace(/^\//, '')
if (/youtube\.com$/i.test(u.hostname) || /^(www\.|m\.)?youtube\.com$/i.test(u.hostname)) {
const v = u.searchParams.get('v')
if (v) return v
// shorts/<id>, embed/<id> 형태도 대응
const m = u.pathname.match(/\/(?:shorts|embed)\/([^/]+)/)
if (m) return m[1]
}
return ''
} catch {
return ''
}
}
/** 단순 HTTP/HTTPS GET (302 따라감, 4xx/5xx 는 reject). */
function fetchBuffer(url: string, redirects = 0): Promise<Buffer> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error(t('common.tooManyRedirects')))
return
}
const target = new URL(url)
const lib = target.protocol === 'https:' ? https : http
const req = lib.get(target, {
timeout: 30000,
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
}, (res) => {
const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) {
res.resume()
fetchBuffer(new URL(res.headers.location, target).toString(), redirects + 1)
.then(resolve, reject)
return
}
if (code !== 200) {
res.resume()
reject(new Error(`HTTP ${code}`))
return
}
const chunks: Buffer[] = []
res.on('data', (c: Buffer) => chunks.push(c))
res.on('end', () => resolve(Buffer.concat(chunks)))
})
req.on('error', reject)
req.on('timeout', () => req.destroy(new Error(t('common.requestTimeout'))))
})
}
/**
* 이미지 URL 을 다운로드해 Buffer 로 돌려준다.
* - 유튜브 영상 URL 이면 `i.ytimg.com/vi/<id>/maxresdefault.jpg` 1차 →
* 실패하면 `hqdefault.jpg` 로 폴백.
* - 그 외 URL 은 HTTP GET 으로 그대로 받음.
*/
export async function downloadImage(rawUrl: string): Promise<Buffer> {
const ytId = ytIdFromUrl(rawUrl)
if (ytId) {
try {
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/maxresdefault.jpg`)
} catch {
return await fetchBuffer(`https://i.ytimg.com/vi/${ytId}/hqdefault.jpg`)
}
}
return fetchBuffer(rawUrl)
}
/**
* painting variant 슬롯 규격(정사각 1:1, ≤1024×1024)에 맞춰 정규화.
* 알고리즘 (docs/add.md):
* 1) s = min(가로, 세로) → 가운데 정사각 크롭 (s×s)
* 2) s > 1024 이면 1024×1024 로 축소 (Lanczos)
* 3) s ≤ 1024 이면 그대로 (업스케일 없음)
* 결과를 PNG 로 outPath 에 저장.
*/
export async function normalizeToCover(buffer: Buffer, outPath: string): Promise<void> {
const img = sharp(buffer)
const meta = await img.metadata()
const w = meta.width ?? 0
const h = meta.height ?? 0
if (w <= 0 || h <= 0) throw new Error(t('errors.imageMetaUnknown'))
const s = Math.min(w, h)
const left = Math.floor((w - s) / 2)
const top = Math.floor((h - s) / 2)
let pipeline = img.extract({ left, top, width: s, height: s })
if (s > MAX_SIDE) {
pipeline = pipeline.resize(MAX_SIDE, MAX_SIDE, { kernel: 'lanczos3' })
}
await fs.mkdir(path.dirname(outPath), { recursive: true })
await pipeline.png().toFile(outPath)
}
/** cover_NN.png 파일명을 만든다 (NN 2자리 0패딩). */
export function coverFileName(index: number): string {
return `cover_${String(index).padStart(2, '0')}.png`
}

471
src/installer-rp/main.ts Normal file
View File

@@ -0,0 +1,471 @@
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import http from 'node:http'
import https from 'node:https'
import path from 'node:path'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import { URL } from 'node:url'
import type { ChildProcess } from 'node:child_process'
import type { Manifest, PackDefinition, PackList } from '../shared/types.js'
import { normalizePackDefinition } from '../shared/store.js'
import { getAppDataDir, getMcCustomDir } from '../shared/paths.js'
import { loadEnv, getManifestUrl } from '../shared/env.js'
import { loadComponentI18n } from '../shared/i18n.js'
import type { RpFetchedPack } from './types.js'
import { ensureYtDlpExe } from './ytdlp.js'
import { ensureFfmpegExe } from './ffmpeg.js'
import { downloadMusicTrack } from './music.js'
import { downloadImage, normalizeToCover, coverFileName } from './images.js'
import { buildResourcepackZip } from './pack.js'
loadEnv()
const i18n = loadComponentI18n('installer-rp')
const t = i18n.t
export const localeDict = i18n.dict
interface RpInstallerState {
manifestUrl: string
baseUrl: string
packs: Map<string, RpFetchedPack>
selectedKey: string | null
/** 현재 설치 진행 중인지 여부. 취소 신호로 사용. */
cancelRequested: boolean
/** 현재 실행 중인 외부 프로세스들(yt-dlp/ffmpeg). 취소 시 모두 kill. */
activeChildren: Set<ChildProcess>
}
/**
* 사용자가 사이트에서 지정한 "생성되는 리소스팩 이름" 을 Windows 파일명으로 쓸 수
* 있게 정리한다. 금지 문자(\<\>:"/\\|?*\x00-\x1f) 는 `_` 로, 끝의 공백/마침표는
* 제거, 예약어(CON/PRN/...)는 앞에 `_` 를 붙인다. 빈 입력은 빈 문자열 반환 →
* 호출 측에서 폴백을 결정한다.
*/
function sanitizeOutputPackName(name: string): string {
let cleaned = (name || '').replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
cleaned = cleaned.replace(/[ .]+$/, '')
if (!cleaned) return ''
if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(cleaned)) cleaned = '_' + cleaned
return cleaned
}
/**
* 동시 yt-dlp 프로세스 수를 CPU 코어 수로 자동 결정.
* - yt-dlp + ffmpeg 변환이 CPU 바운드라 코어 수가 가장 좋은 프록시.
* - 유튜브가 IP 단위로 throttle 걸기 때문에 5 이상은 효과 없음 → 상한 5.
* - 환경변수 MUSIC_CONCURRENCY 로 강제 오버라이드 가능.
*/
function pickMusicConcurrency(): number {
const override = Number(process.env.MUSIC_CONCURRENCY)
if (Number.isFinite(override) && override >= 1) {
return Math.min(8, Math.floor(override))
}
const cores = os.cpus()?.length ?? 4
if (cores <= 2) return 2
if (cores <= 4) return 3
if (cores <= 8) return 4
return 5
}
/**
* 새 다운로드 시작 사이의 최소 간격(ms).
* - 동시 N개를 모두 t=0 에 시작하면 카드들이 0% 에서 같이 정지된 듯 보임.
* - 시차를 두고 시작하면 "1번 끝남 → 4번 시작 → 2번 끝남 → 5번 시작" 식으로
* 유저 입장에서 항상 뭔가 새로 시작/완료되는 흐름이 보임.
* - 너무 길면 동시성 이득을 깎아먹음. 2s 가 체감/속도 균형점.
*/
const MUSIC_START_STAGGER_MS = 2000
/** start-gate. 여러 worker 가 동시에 acquire 해도 직렬화되어 순차 통과. */
let musicStartChain: Promise<void> = Promise.resolve()
let nextMusicStartAt = 0
function acquireMusicStartSlot(): Promise<void> {
const slot = musicStartChain.then(async () => {
const wait = Math.max(0, nextMusicStartAt - Date.now())
if (wait > 0) await new Promise<void>((r) => setTimeout(r, wait))
nextMusicStartAt = Date.now() + MUSIC_START_STAGGER_MS
})
musicStartChain = slot.catch(() => {})
return slot
}
const DEFAULT_MANIFEST_URL = getManifestUrl()
const state: RpInstallerState = {
manifestUrl: DEFAULT_MANIFEST_URL,
baseUrl: deriveBaseUrl(DEFAULT_MANIFEST_URL),
packs: new Map(),
selectedKey: null,
cancelRequested: false,
activeChildren: new Set()
}
let mainWindow: BrowserWindow | null = null
function deriveBaseUrl(manifestUrl: string): string {
try {
const parsed = new URL(manifestUrl)
return `${parsed.protocol}//${parsed.host}`
} catch {
return ''
}
}
function createMainWindow(): void {
// 메인 설치기와 동일한 아이콘 사용. dev/prod, Windows/기타 분기까지 같은 규칙.
const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png')
mainWindow = new BrowserWindow({
width: 900,
height: 680,
icon: iconPath,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.removeMenu()
void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-rp', 'index.html'))
}
function sendLog(line: string): void {
if (!mainWindow || mainWindow.isDestroyed()) return
const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}`
mainWindow.webContents.send('log', stamped)
}
type ProgressEvent =
| { phase: 'prep'; message: string; done?: boolean }
| {
phase: 'item'
kind: 'music' | 'image'
index: number
total: number
percent: number
status: 'running' | 'done' | 'error'
message?: string
}
| { phase: 'package'; message: string; done?: boolean }
function sendProgress(payload: ProgressEvent): void {
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send('progress', payload)
}
function fetchBuffer(url: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
const target = new URL(url)
const transport = target.protocol === 'https:' ? https : http
const request = transport.get(target, { timeout: 30000 }, (response) => {
if (response.statusCode === 301 || response.statusCode === 302) {
const redirect = response.headers.location
if (redirect) {
response.resume()
fetchBuffer(new URL(redirect, target).toString()).then(resolve, reject)
return
}
}
if ((response.statusCode ?? 0) >= 400) {
response.resume()
reject(new Error(`HTTP ${response.statusCode}`))
return
}
const chunks: Buffer[] = []
response.on('data', (chunk: Buffer) => chunks.push(chunk))
response.on('end', () => resolve(Buffer.concat(chunks)))
})
request.on('error', reject)
request.on('timeout', () => request.destroy(new Error(t('common.requestTimeout'))))
})
}
async function fetchJson<T>(url: string): Promise<T> {
const buffer = await fetchBuffer(url)
return JSON.parse(buffer.toString('utf8')) as T
}
// ── IPC: 1단계 manifest 로드 ─────────────────────────
ipcMain.handle('rp:packs:load', async (_event, manifestUrlInput?: string): Promise<RpFetchedPack[]> => {
if (typeof manifestUrlInput === 'string' && manifestUrlInput.length > 0) {
state.manifestUrl = manifestUrlInput
state.baseUrl = deriveBaseUrl(manifestUrlInput)
}
sendLog(t('log.manifestDownload', { url: state.manifestUrl }))
const manifest = await fetchJson<Manifest>(state.manifestUrl)
const results: RpFetchedPack[] = []
for (const entry of manifest.packs ?? []) {
if (typeof entry?.file !== 'string') continue
const listUrl = `${state.baseUrl}/file/list/${encodeURIComponent(entry.file)}.json`
const packUrl = `${state.baseUrl}/manifest/${encodeURIComponent(entry.file)}.json`
try {
// 목록(필수) + 팩 정의(mcVersion 용, 실패해도 폴백) 동시 로드.
const [listRaw, packRaw] = await Promise.all([
fetchJson<Partial<PackList>>(listUrl),
fetchJson<Partial<PackDefinition>>(packUrl).catch((err) => {
sendLog(t('log.packDefFailed', { file: entry.file, message: (err as Error).message }))
return null
})
])
const list: PackList = {
musicPlaylistUrl: typeof listRaw.musicPlaylistUrl === 'string' ? listRaw.musicPlaylistUrl : '',
imagePlaylistUrl: typeof listRaw.imagePlaylistUrl === 'string' ? listRaw.imagePlaylistUrl : '',
music: Array.isArray(listRaw.music) ? listRaw.music : [],
images: Array.isArray(listRaw.images) ? listRaw.images : []
}
const normalized = packRaw ? normalizePackDefinition(packRaw as Partial<PackDefinition>) : null
const mcVersion = normalized?.mcVersion ?? ''
const resourcepackPath = normalized?.resourcepackPath ?? ''
const outputPackName = normalized?.outputPackName ?? ''
results.push({
key: entry.file,
name: entry.name || entry.file,
mcVersion,
resourcepackPath,
outputPackName,
list
})
} catch (error) {
sendLog(t('log.listLoadFailed', { file: entry.file, message: (error as Error).message }))
}
}
state.packs.clear()
for (const item of results) state.packs.set(item.key, item)
sendLog(t('log.packsLoaded', { count: results.length }))
for (const item of results) {
sendLog(t('log.packEntry', {
key: item.key,
mc: item.mcVersion || t('log.packEntryUnknownVersion'),
base: item.resourcepackPath || t('log.packEntryNoBase')
}))
}
return results
})
ipcMain.handle('rp:packs:select', async (_event, packKey: string) => {
if (!state.packs.has(packKey)) {
throw new Error(t('errors.selectedPackNotFound'))
}
state.selectedKey = packKey
sendLog(t('log.selectedPack', { key: packKey }))
})
ipcMain.handle('rp:i18n:dict', () => localeDict)
// ── IPC: 2단계 설치 ──────────────────────────────────
ipcMain.handle('rp:install:start', async (): Promise<{ resourcepackPath: string }> => {
if (!state.selectedKey) throw new Error(t('errors.selectPackFirst'))
const pack = state.packs.get(state.selectedKey)
if (!pack) throw new Error(t('errors.currentPackNotFound'))
state.cancelRequested = false
const tempRoot = path.join(getMcCustomDir(), '.temp')
await fsp.mkdir(tempRoot, { recursive: true })
const musicTotal = pack.list.music.length
const imageTotal = pack.list.images.length
try {
// 2-1. yt-dlp / ffmpeg 준비 (%appdata%/.mc_custom/{yt-dlp,ffmpeg}.exe)
sendLog(t('log.ytdlpPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ytdlpPreparing') })
const ytDlpBin = await ensureYtDlpExe(sendLog)
sendLog(t('log.ytdlpPath', { path: ytDlpBin }))
throwIfCancelled()
sendLog(t('log.ffmpegPreparing'))
sendProgress({ phase: 'prep', message: t('progress.ffmpegPreparing') })
const ffmpegBin = await ensureFfmpegExe(sendLog)
sendLog(t('log.ffmpegPath', { path: ffmpegBin }))
sendProgress({ phase: 'prep', message: t('progress.ready'), done: true })
throwIfCancelled()
// 2-2. 음악 다운로드 (CPU 코어 수 기반 자동 동시 다운로드, 시차 출발, ogg 변환)
const musicDir = path.join(tempRoot, 'music')
await fsp.mkdir(musicDir, { recursive: true })
const concurrency = pickMusicConcurrency()
const cpuCount = os.cpus()?.length ?? 0
// 첫 음악은 즉시 시작 가능하도록 base 를 현재 시각으로.
nextMusicStartAt = Date.now()
sendLog(t('log.cpuDetected', { cores: cpuCount, concurrency }))
sendLog(t('log.musicStart', { total: musicTotal, concurrency, stagger: MUSIC_START_STAGGER_MS }))
// 클로저 안에서 narrowing 이 풀리지 않도록 로컬 alias.
const musicList = pack.list.music
let nextIndex = 0
async function musicWorker(): Promise<void> {
while (true) {
if (state.cancelRequested) return
const i = nextIndex++
if (i >= musicTotal) return
// 시차 게이트: 새 다운로드 시작은 직전 시작과 최소 MUSIC_START_STAGGER_MS 간격을 둠.
await acquireMusicStartSlot()
if (state.cancelRequested) return
const entry = musicList[i]
const idx = i + 1
sendLog(t('log.musicTrackStart', { idx }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'running' })
let child: ChildProcess | null = null
try {
const outPath = await downloadMusicTrack({
ytdlpExe: ytDlpBin,
ffmpegExe: ffmpegBin,
tempDir: musicDir,
index: idx,
url: entry.url,
log: sendLog,
onChild: (c) => {
child = c
state.activeChildren.add(c)
},
onProgress: (pct) => {
// 다운로드(0~90%) + 변환(90~100%) 으로 매핑.
sendProgress({
phase: 'item', kind: 'music', index: idx, total: musicTotal,
percent: Math.min(90, pct * 0.9), status: 'running'
})
}
})
if (child) state.activeChildren.delete(child)
sendLog(t('log.musicTrackDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 100, status: 'done' })
} catch (err) {
if (child) state.activeChildren.delete(child)
if (state.cancelRequested) {
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: t('progress.cancelled') })
return
}
sendProgress({ phase: 'item', kind: 'music', index: idx, total: musicTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.musicDownloadFailed', { idx, message: (err as Error).message }))
}
}
}
const workerCount = Math.min(concurrency, musicTotal)
const workers: Promise<void>[] = []
for (let w = 0; w < workerCount; w++) workers.push(musicWorker())
await Promise.all(workers)
throwIfCancelled()
// 2-3. 사진 다운로드 + painting variant 정규화
const paintingDir = path.join(tempRoot, 'painting')
await fsp.mkdir(paintingDir, { recursive: true })
sendLog(t('log.imageStart', { total: imageTotal }))
for (let i = 0; i < imageTotal; i++) {
throwIfCancelled()
const entry = pack.list.images[i]
const idx = i + 1
sendLog(t('log.imageDownloading', { idx }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 10, status: 'running' })
let buf: Buffer
try {
buf = await downloadImage(entry.url)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageDownloadFailed', { idx, message: (err as Error).message }))
}
throwIfCancelled()
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 60, status: 'running' })
const outPath = path.join(paintingDir, coverFileName(idx))
try {
await normalizeToCover(buf, outPath)
} catch (err) {
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 0, status: 'error', message: (err as Error).message })
throw new Error(t('errors.imageNormalizeFailed', { idx, message: (err as Error).message }))
}
sendLog(t('log.imageDone', { idx, name: path.basename(outPath) }))
sendProgress({ phase: 'item', kind: 'image', index: idx, total: imageTotal, percent: 100, status: 'done' })
}
// 2-4. 베이스 리소스팩 다운로드 (있을 때만)
throwIfCancelled()
let baseZipPath: string | undefined
if (pack.resourcepackPath) {
// 파일명에 공백·괄호가 있을 수 있어 encodeURIComponent 로 인코딩.
const cleaned = pack.resourcepackPath.replace(/^\/+/, '')
const baseUrl = `${state.baseUrl}/file/resourcepacks/${encodeURIComponent(cleaned)}`
baseZipPath = path.join(tempRoot, 'base.zip')
sendLog(t('log.baseDownload', { path: cleaned }))
sendLog(t('log.baseUrl', { url: baseUrl }))
sendProgress({ phase: 'package', message: t('progress.baseDownloading') })
try {
const buf = await fetchBuffer(baseUrl)
await fsp.writeFile(baseZipPath, buf)
sendLog(t('log.baseReceived', { kb: (buf.length / 1024).toFixed(1) }))
} catch (err) {
throw new Error(t('errors.baseDownloadFailed', { message: (err as Error).message }))
}
} else {
sendLog(t('log.baseAbsent'))
}
// 2-5. 리소스팩 zip 빌드 (pack.mcmeta + sounds.json + 음악·이미지, 베이스 위에 얹기)
throwIfCancelled()
// 사이트에서 지정한 "생성되는 리소스팩 이름" 을 우선 사용. 비어있거나 sanitize
// 결과가 빈 문자열이면 `<packKey>_resourcepack` 로 폴백.
const sanitizedOutputName = sanitizeOutputPackName(pack.outputPackName)
const resourcepackBaseName = sanitizedOutputName || `${state.selectedKey}_resourcepack`
const resourcepackName = `${resourcepackBaseName}.zip`
const resourcepackDir = path.join(getMcCustomDir(), 'resourcepacks')
const resourcepackPath = path.join(resourcepackDir, resourcepackName)
sendLog(t('log.buildingZip', { name: resourcepackName }))
sendProgress({ phase: 'package', message: baseZipPath ? t('progress.buildingWithBase') : t('progress.buildingZip') })
await buildResourcepackZip({
musicDir,
paintingDir,
packName: pack.name,
mcVersion: pack.mcVersion,
workDir: tempRoot,
outZipPath: resourcepackPath,
baseZipPath,
log: sendLog
})
// 2-6. %appdata%/.mc_custom/resourcepacks/ 에 배치 (위 빌드가 직접 outZipPath 에 저장)
sendLog(t('log.installComplete', { path: resourcepackPath }))
sendProgress({ phase: 'package', message: t('progress.installComplete'), done: true })
return { resourcepackPath }
} finally {
// 임시 파일 정리
await fsp.rm(tempRoot, { recursive: true, force: true }).catch(() => {})
}
})
ipcMain.handle('rp:install:cancel', async () => {
state.cancelRequested = true
sendLog(t('log.cancelRequested', { count: state.activeChildren.size }))
for (const child of state.activeChildren) {
if (!child.killed) child.kill()
}
})
function throwIfCancelled(): void {
if (state.cancelRequested) {
throw new Error(t('errors.cancelledByUser'))
}
}
// ── IPC: 3단계 완료 ──────────────────────────────────
ipcMain.handle('rp:finish:openFolder', async () => {
const dir = path.join(getMcCustomDir(), 'resourcepacks')
if (!fs.existsSync(dir)) {
await fsp.mkdir(dir, { recursive: true })
}
await shell.openPath(dir)
})
ipcMain.handle('rp:quit', async () => {
app.quit()
})
// ── 앱 라이프사이클 ───────────────────────────────
app.whenReady().then(() => {
createMainWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow()
})
})
app.on('window-all-closed', () => {
// 강제 종료 시에도 임시 파일은 정리.
fsp.rm(path.join(getMcCustomDir(), '.temp'), { recursive: true, force: true }).catch(() => {})
if (process.platform !== 'darwin') app.quit()
})

103
src/installer-rp/music.ts Normal file
View File

@@ -0,0 +1,103 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
export interface DownloadMusicOptions {
ytdlpExe: string
ffmpegExe: string
/** %appdata%/.mc_custom/.temp/ 같은 작업 폴더. */
tempDir: string
/** 1부터 시작하는 곡 번호 (파일명 zero-pad 에 사용). */
index: number
/** 유튜브 영상 주소. */
url: string
log?: (line: string) => void
/** 현재 실행 중인 자식 프로세스를 외부에 알림 (취소용). */
onChild?: (child: ChildProcess) => void
/** yt-dlp 의 다운로드 퍼센트 (0~100). 변환 단계는 별도. */
onProgress?: (percent: number) => void
}
/**
* yt-dlp 로 유튜브 영상에서 오디오만 추출해 vorbis(.ogg) 로 변환한다.
* 결과 파일 경로를 돌려준다. 실패하면 reject.
*
* 호출자는 onChild 콜백으로 받은 ChildProcess 에 .kill() 을 호출해 취소할 수 있다.
*/
export function downloadMusicTrack(opts: DownloadMusicOptions): Promise<string> {
const padded = String(opts.index).padStart(2, '0')
const outBase = path.join(opts.tempDir, padded)
const outPath = outBase + '.ogg'
return new Promise((resolve, reject) => {
const args = [
'--no-warnings',
'--no-playlist',
// 단일 파일이 아니라 HLS/DASH fragmented 스트림일 때 청크를 병렬로.
// 일반 progressive 다운로드에는 영향 없음.
'--concurrent-fragments', '5',
// 진행률 표시 안정화 (yt-dlp 가 \r 대신 새 줄로 출력).
'--newline',
'--extract-audio',
'--audio-format', 'vorbis',
'--audio-quality', '0',
'--ffmpeg-location', opts.ffmpegExe,
'-o', outBase + '.%(ext)s',
opts.url
]
const child = spawn(opts.ytdlpExe, args, { stdio: ['ignore', 'pipe', 'pipe'] })
opts.onChild?.(child)
let stderr = ''
let stdoutBuf = ''
let lastReportedPct = -1
child.stdout?.on('data', (chunk: Buffer) => {
stdoutBuf += chunk.toString('utf8')
// yt-dlp 는 `[download] 3.3% of 3.72MiB at ...` 형식으로
// \r 로 같은 줄을 갱신한다. \r 과 \n 을 모두 split 해서 마지막 진행률을 뽑는다.
const lines = stdoutBuf.split(/[\r\n]/)
stdoutBuf = lines.pop() ?? ''
for (const raw of lines) {
const line = raw.trimEnd()
if (!line) continue
opts.log?.(t('log.ytdlpLine', { line }))
const m = line.match(/\[download\]\s+([\d.]+)%/)
if (m) {
const pct = Math.min(100, Math.max(0, parseFloat(m[1])))
// 너무 잦은 이벤트를 피하기 위해 1% 단위로만 전달.
if (Math.floor(pct) !== lastReportedPct) {
lastReportedPct = Math.floor(pct)
opts.onProgress?.(pct)
}
}
}
})
child.stderr?.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
child.on('error', (err) => reject(err))
child.on('close', async (code, signal) => {
if (signal) {
reject(new Error(t('errors.ytdlpSignal', { signal: String(signal) })))
return
}
if (code !== 0) {
reject(new Error(
t('errors.ytdlpExit', {
code: code ?? '',
stderr: stderr.trim() || t('errors.ytdlpNoStderr')
})
))
return
}
// .ogg 가 실제로 생성됐는지 확인
try {
await fs.access(outPath)
resolve(outPath)
} catch {
reject(new Error(t('errors.ytdlpMissingOutput', { path: outPath })))
}
})
})
}

171
src/installer-rp/pack.ts Normal file
View File

@@ -0,0 +1,171 @@
import { promises as fs, createWriteStream } from 'node:fs'
import path from 'node:path'
import archiver from 'archiver'
import extract from 'extract-zip'
import { resolveResourcePackFormat, MIN_SUPPORTED_FORMAT, LATEST_KNOWN_FORMAT } from './packFormat.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
const NAMESPACE = 'musicquiz'
export interface BuildResourcepackOptions {
/** ogg 음악 파일들이 들어 있는 폴더 (01.ogg, 02.ogg, …). */
musicDir: string
/** cover_NN.png 파일들이 들어 있는 폴더. */
paintingDir: string
/** pack.mcmeta 의 description 에 들어갈 표시 이름. */
packName: string
/** /manifest/<key>.json 의 mcVersion. pack_format 결정용. */
mcVersion: string
/** 작업 폴더(임시). 이 안에 트리를 펼친 뒤 zip 생성. */
workDir: string
/** 최종 zip 출력 경로. */
outZipPath: string
/**
* 베이스 리소스팩 zip 경로 (선택). 지정하면 이 zip 의 내용을 먼저 풀고
* 그 위에 음악·사진·sounds.json·pack.mcmeta 를 덮어/병합한다.
*/
baseZipPath?: string
/** 진단용 로그 콜백 (선택). */
log?: (line: string) => void
}
/**
* 임시 폴더에 리소스팩 트리를 펼치고, archiver 로 zip 으로 묶어 outZipPath 에 저장.
*
* 트리 구조:
* pack.mcmeta
* assets/musicquiz/sounds.json
* assets/musicquiz/sounds/track_NN.ogg ← musicDir/NN.ogg 에서 옮김
* assets/musicquiz/textures/painting/cover_NN.png ← paintingDir/cover_NN.png 에서 옮김
*/
export async function buildResourcepackZip(opts: BuildResourcepackOptions): Promise<void> {
const root = path.join(opts.workDir, 'resourcepack')
// 베이스가 있건 없건 작업 트리는 항상 처음부터 다시 만든다.
await fs.rm(root, { recursive: true, force: true })
await fs.mkdir(root, { recursive: true })
// 0) 베이스 리소스팩이 지정되면 먼저 풀어둔다. 그 위에 우리 파일을 얹는다.
if (opts.baseZipPath) {
opts.log?.(t('log.baseExtract', { name: path.basename(opts.baseZipPath) }))
await extract(opts.baseZipPath, { dir: root })
}
const soundsDir = path.join(root, 'assets', NAMESPACE, 'sounds')
const paintingOutDir = path.join(root, 'assets', NAMESPACE, 'textures', 'painting')
await fs.mkdir(soundsDir, { recursive: true })
await fs.mkdir(paintingOutDir, { recursive: true })
// 1) pack.mcmeta 는 mcVersion 에 맞춰 항상 덮어쓴다 (베이스가 다른 버전일 수 있으니).
const resolved = resolveResourcePackFormat(opts.mcVersion)
if (resolved.matched) {
opts.log?.(t('log.packFormatMatched', { format: resolved.format, matched: resolved.matched }))
} else {
opts.log?.(t('log.packFormatFallback', { format: resolved.format, version: opts.mcVersion }))
}
// 호환 범위는 1.21.6 (=MIN_SUPPORTED_FORMAT) 부터 알려진 최신까지 선언한다.
// 빌드 타깃이 LATEST_KNOWN_FORMAT 보다 높으면(테이블 갱신 전 신버전) 그 값까지 확장.
// (셰이더 제거 판정에도 maxFmt 를 쓰므로 mcmeta 작성보다 먼저 계산해 둔다.)
const minFmt = Math.min(MIN_SUPPORTED_FORMAT, resolved.format)
const maxFmt = Math.max(LATEST_KNOWN_FORMAT, resolved.format)
// 1-a) 선언 호환 범위의 max 가 64 를 넘으면(=1.21.9+ 클라이언트에서도 로드 가능)
// 구버전 베이스팩의 assets/minecraft/shaders/* 가 새 GLSL API 와 충돌해 컴파일에
// 실패한다. 결과적으로 "리소스 새로고침 실패" 가 다시 뜨므로, 이 경우엔 해당
// 디렉터리를 결과 zip 에서 제거한다. 텍스처/모델 등 나머지 자산은 그대로 유지.
if (opts.baseZipPath && maxFmt > 64) {
const vanillaShaderDir = path.join(root, 'assets', 'minecraft', 'shaders')
try {
const stat = await fs.stat(vanillaShaderDir)
if (stat.isDirectory()) {
const entries = await fs.readdir(vanillaShaderDir)
if (entries.length > 0) {
await fs.rm(vanillaShaderDir, { recursive: true, force: true })
opts.log?.(t('log.baseShaderOverrideStripped', {
path: entries.join(', '),
mc: opts.mcVersion,
format: maxFmt
}))
}
}
} catch {
// 없으면 정상. 무시.
}
}
// pack_format <= 64 인 MC 는 supported_formats 를, > 64 인 MC 는 min_format/max_format 을
// 읽는다. 어느 한쪽만 두면 반대편 클라이언트에서 거부되므로 양쪽 모두 기록한다.
const packMeta: Record<string, unknown> = {
description: t('pack.description', { name: opts.packName }),
pack_format: resolved.format,
supported_formats: { min_inclusive: minFmt, max_inclusive: maxFmt },
min_format: minFmt,
max_format: maxFmt
}
const mcmeta = { pack: packMeta }
await fs.writeFile(path.join(root, 'pack.mcmeta'), JSON.stringify(mcmeta, null, 2) + '\n')
opts.log?.(t('log.packFormatRange', { min: minFmt, max: maxFmt }))
// 2) 음악 파일 복사 + sounds.json 생성/병합
const musicFiles = (await fs.readdir(opts.musicDir))
.filter((n) => n.toLowerCase().endsWith('.ogg'))
.sort()
// 베이스의 sounds.json 이 있으면 읽어서 우리 트랙을 덧붙인다.
const soundsJsonPath = path.join(root, 'assets', NAMESPACE, 'sounds.json')
let soundsJson: Record<string, unknown> = {}
try {
const existing = await fs.readFile(soundsJsonPath, 'utf8')
const parsed = JSON.parse(existing)
if (parsed && typeof parsed === 'object') {
soundsJson = parsed as Record<string, unknown>
opts.log?.(t('log.soundsMerged', { count: Object.keys(soundsJson).length }))
}
} catch {
// 없으면 새로 생성.
}
for (const fname of musicFiles) {
// NN.ogg → track_NN.ogg 로 리네임해 패키지.
const stem = path.basename(fname, path.extname(fname)) // "01"
const trackId = `track_${stem}`
await fs.copyFile(path.join(opts.musicDir, fname), path.join(soundsDir, `${trackId}.ogg`))
soundsJson[trackId] = {
sounds: [
{ name: `${NAMESPACE}:${trackId}`, stream: true }
]
}
}
await fs.writeFile(soundsJsonPath, JSON.stringify(soundsJson, null, 2) + '\n')
// 3) painting 텍스처 복사 (이미 cover_NN.png 형태). 같은 파일명은 덮어씀.
const paintingFiles = (await fs.readdir(opts.paintingDir))
.filter((n) => n.toLowerCase().endsWith('.png'))
.sort()
for (const fname of paintingFiles) {
await fs.copyFile(path.join(opts.paintingDir, fname), path.join(paintingOutDir, fname))
}
// 4) zip 으로 묶기
await fs.mkdir(path.dirname(opts.outZipPath), { recursive: true })
await zipDirectory(root, opts.outZipPath)
// 임시 트리는 호출자가 tempRoot 통째 정리하므로 여기서 별도 삭제 불필요.
}
function zipDirectory(srcDir: string, outZipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(outZipPath)
const archive = archiver('zip', { zlib: { level: 9 } })
output.on('close', () => resolve())
output.on('error', reject)
archive.on('warning', (err: Error & { code?: string }) => {
// ENOENT 정도면 무시, 그 외는 reject.
if (err.code === 'ENOENT') return
reject(err)
})
archive.on('error', reject)
archive.pipe(output)
archive.directory(srcDir, false)
archive.finalize().catch(reject)
})
}

View File

@@ -0,0 +1,50 @@
// Minecraft Java Edition 버전 → resource pack format 번호.
// 출처: https://minecraft.wiki/w/Pack_format (수동 동기화).
// 1.21.9 부터는 minor 버전(예: 69.0)이 도입됐지만 JSON Number 로 0 차이는
// 표현되지 않으므로 정수만 사용한다.
const TABLE: Array<readonly [string, number]> = [
['1.21', 34],
['1.21.1', 34],
['1.21.2', 42],
['1.21.3', 42],
['1.21.4', 46],
['1.21.5', 55],
['1.21.6', 63],
['1.21.7', 64],
['1.21.8', 64],
['1.21.9', 69],
['1.21.10', 69],
['1.21.11', 75],
['26.1', 84],
['26.1.1', 84],
['26.1.2', 84],
['26.2', 86]
]
/** 테이블에서 마지막(=최신) 항목의 포맷. 알 수 없는 mcVersion 에 대한 폴백. */
export const LATEST_KNOWN_FORMAT: number = TABLE[TABLE.length - 1][1]
/**
* 리소스팩이 호환된다고 선언할 최소 pack_format.
* 1.21.6 (=63) 부터를 지원 범위 하한으로 둔다.
*/
export const MIN_SUPPORTED_FORMAT = 63
export interface ResolvedFormat {
/** 매칭된 mcVersion 키 (없으면 null). */
matched: string | null
/** pack.mcmeta 에 들어갈 pack_format 값. */
format: number
}
/**
* mcVersion 문자열 ("1.21.6", "26.1.2", …) 에서 pack_format 을 찾는다.
* 정확히 일치하는 게 있으면 그 값, 없으면 가장 최근 알려진 포맷을 폴백.
*/
export function resolveResourcePackFormat(mcVersion: string): ResolvedFormat {
const key = (mcVersion || '').trim()
for (const [v, f] of TABLE) {
if (v === key) return { matched: v, format: f }
}
return { matched: null, format: LATEST_KNOWN_FORMAT }
}

View File

@@ -0,0 +1,49 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { RpFetchedPack } from './types.js'
const api = {
/** i18n 사전을 렌더러에 전달. */
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('rp:i18n:dict'),
/** manifest 와 각 음악퀴즈의 file/list/<key>.json 까지 한 번에 로드. */
loadPacks: (manifestUrl?: string): Promise<RpFetchedPack[]> =>
ipcRenderer.invoke('rp:packs:load', manifestUrl),
/** 음악퀴즈 키를 선택. */
selectPack: (packKey: string): Promise<void> =>
ipcRenderer.invoke('rp:packs:select', packKey),
/** 리소스팩 빌드/설치 시작. 완료 또는 취소될 때까지 resolve 되지 않을 수 있음. */
startInstall: (): Promise<{ resourcepackPath: string }> =>
ipcRenderer.invoke('rp:install:start'),
/** 진행 중인 설치 취소. 임시 파일 정리 후 종료. */
cancelInstall: (): Promise<void> =>
ipcRenderer.invoke('rp:install:cancel'),
/** %appdata%/.mc_custom/resourcepacks/ 폴더를 OS 파일 탐색기로 연다. */
openResourcepackFolder: (): Promise<void> =>
ipcRenderer.invoke('rp:finish:openFolder'),
/** 프로그램 종료. */
quit: (): Promise<void> => ipcRenderer.invoke('rp:quit'),
/** 로그 스트림 구독. */
onLog: (handler: (line: string) => void): (() => void) => {
const listener = (_event: unknown, line: string) => handler(line)
ipcRenderer.on('log', listener)
return () => ipcRenderer.removeListener('log', listener)
},
/** 설치 진행 이벤트 구독. payload 구조는 renderer 가 알아서 분기. */
onProgress: (handler: (payload: unknown) => void): (() => void) => {
const listener = (_event: unknown, payload: unknown) => handler(payload)
ipcRenderer.on('progress', listener)
return () => ipcRenderer.removeListener('progress', listener)
}
}
contextBridge.exposeInMainWorld('rpInstaller', api)
declare global {
interface Window {
rpInstaller: typeof api
}
}

28
src/installer-rp/types.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { PackList } from '../shared/types.js'
export interface RpFetchedPack {
key: string
name: string
/** /manifest/<key>.json 의 mcVersion (예: "1.21.6", "26.1.2"). */
mcVersion: string
/**
* /manifest/<key>.json 의 resourcepackPath. 비어있지 않으면 베이스 zip 으로 사용.
* 빈 문자열이면 새 리소스팩을 처음부터 생성.
*/
resourcepackPath: string
/**
* /manifest/<key>.json 의 outputPackName. 관리 사이트에서 설정한 "생성되는
* 리소스팩 이름". 비어 있으면 설치기가 `<key>_resourcepack` 형식으로 폴백.
* 파일명으로 쓰기 전에 Windows 금지 문자(\<\>:"/\\|?*) 는 `_` 로 치환.
*/
outputPackName: string
/** /file/list/<key>.json 의 음악·사진 목록. */
list: PackList
}
export interface RpInstallProgress {
step: 'yt-dlp' | 'music' | 'image' | 'package' | 'place'
index?: number
total?: number
message?: string
}

135
src/installer-rp/ytdlp.ts Normal file
View File

@@ -0,0 +1,135 @@
import { spawn } from 'node:child_process'
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir, getMcCustomInstallerDir } from '../shared/paths.js'
import { loadComponentI18n } from '../shared/i18n.js'
const { t } = loadComponentI18n('installer-rp')
/**
* 리소스팩 간편설치기는 Windows .exe 로 배포되므로 yt-dlp.exe 한 종류만 사용.
* 경로: %appdata%/.mc_custom/installer/yt-dlp.exe
*/
export function getYtDlpExePath(): string {
return path.join(getMcCustomInstallerDir(), 'yt-dlp.exe')
}
/**
* 0.2.1 이전 버전이 `.mc_custom/yt-dlp.exe` 에 받아둔 파일이 있으면 새 위치로
* 옮긴다. 마인크래프트 게임 폴더 루트가 외부 도구 파일로 더럽혀지지 않도록.
*/
async function migrateLegacyExe(target: string): Promise<void> {
const legacy = path.join(getMcCustomDir(), 'yt-dlp.exe')
if (legacy === target) return
try {
await fs.access(legacy, fsConst.F_OK)
} catch {
return
}
try {
await fs.mkdir(path.dirname(target), { recursive: true })
await fs.rename(legacy, target)
} catch {
// 권한·드라이브 문제 등으로 실패하면 그냥 새로 받으면 되므로 무시.
try { await fs.unlink(legacy) } catch { /* noop */ }
}
}
const YT_DLP_DOWNLOAD_URL =
'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe'
let installPromise: Promise<string> | null = null
/**
* %appdata%/.mc_custom/yt-dlp.exe 가 없거나 실행 불가능하면 GitHub Releases
* 의 최신 yt-dlp.exe 를 받아 설치하고, 그 절대경로를 돌려준다.
*/
export async function ensureYtDlpExe(
log?: (line: string) => void
): Promise<string> {
const target = getYtDlpExePath()
await migrateLegacyExe(target)
if (await canExecute(target)) {
log?.(t('log.ytdlpExists', { path: target }))
return target
}
if (installPromise) return installPromise
installPromise = (async () => {
try {
await fs.mkdir(path.dirname(target), { recursive: true })
log?.(t('log.ytdlpDownloading', { url: YT_DLP_DOWNLOAD_URL }))
await downloadToFile(YT_DLP_DOWNLOAD_URL, target)
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new Error(t('errors.ytdlpVerifyFailed'))
}
log?.(t('log.ytdlpReady', { path: target }))
return target
} catch (err) {
// 부분 다운로드 흔적 정리
try { await fs.unlink(target) } catch { /* noop */ }
throw new Error(
t('errors.ytdlpInstallFailed', {
message: err instanceof Error ? err.message : String(err)
})
)
} finally {
installPromise = null
}
})()
return installPromise
}
async function canExecute(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fsConst.F_OK)
} catch {
return false
}
return probeVersion(filePath)
}
function probeVersion(bin: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
let ok = false
child.stdout.on('data', () => { ok = true })
child.on('error', () => resolve(false))
child.on('close', (code) => resolve(ok && code === 0))
})
}
/** GitHub Releases 의 latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error(t('common.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http
const req = lib.get(url, {
headers: { 'user-agent': 'mc-music-quiz-rp-installer' }
}, (res) => {
const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) {
res.resume()
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
return
}
if (code !== 200) {
res.resume()
reject(new Error(`HTTP ${code} (${url})`))
return
}
const out = createWriteStream(dest)
res.pipe(out)
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
out.on('error', reject)
res.on('error', reject)
})
req.on('error', reject)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,10 @@
import { contextBridge, ipcRenderer } from 'electron'
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types'
import type { ClientInstallPayload, FetchedPack, RamCheckResult, ServerInstallPayload, PortForwardResult } from './types.js'
const api = {
// i18n
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('i18n:dict'),
// 1단계
loadPacks: (manifestUrl?: string): Promise<FetchedPack[]> =>
ipcRenderer.invoke('packs:load', manifestUrl),
@@ -15,6 +18,8 @@ const api = {
// 3-2
detectJdk: (): Promise<{ found: boolean; path: string }> => ipcRenderer.invoke('jdk:detect'),
installJdk: (): Promise<{ ok: boolean; path?: string; message?: string }> => ipcRenderer.invoke('jdk:install'),
cancelJdkInstall: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('jdk:cancelInstall'),
// 3-3
startServerInstall: (payload: ServerInstallPayload): Promise<void> =>
@@ -45,6 +50,7 @@ const api = {
createDesktopShortcut: (): Promise<void> => ipcRenderer.invoke('finish:desktopShortcut'),
startServer: (): Promise<void> => ipcRenderer.invoke('finish:startServer'),
startMinecraftLauncher: (): Promise<void> => ipcRenderer.invoke('finish:startLauncher'),
quitApp: (): Promise<void> => ipcRenderer.invoke('app:quit'),
// log stream
onLog: (handler: (line: string) => void): (() => void) => {

View File

@@ -1,4 +1,4 @@
import type { Manifest, PackDefinition } from '../shared/types'
import type { Manifest, PackDefinition } from '../shared/types.js'
export interface InstallerConfig {
manifestUrl: string
@@ -25,6 +25,8 @@ export interface ServerInstallPayload {
export interface ClientInstallPayload {
packKey: string
installPlatform: boolean
/** true 면 client 측 saves/ 에 맵을 풀지 않는다 (참가자 모드). */
skipMap?: boolean
}
export interface RamCheckResult {

View File

@@ -2,9 +2,13 @@ import express from 'express'
import session from 'express-session'
import path from 'node:path'
import fsp from 'node:fs/promises'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
import { indexRouter } from './routes/index'
import { opRouter } from './routes/op'
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths.js'
import { loadEnv } from '../shared/env.js'
import { t, localeDict } from './i18n.js'
import { indexRouter } from './routes/index.js'
import { opRouter } from './routes/op.js'
loadEnv()
const PORT = Number(process.env.PORT ?? 3000)
// 터미널에서 Ctrl+클릭으로 바로 열 수 있도록 기본값은 127.0.0.1.
@@ -20,6 +24,14 @@ app.set('trust proxy', 1)
app.use(express.urlencoded({ extended: true }))
app.use(express.json())
// 모든 EJS 뷰에서 t('key') 로 ko-kr.json 의 문구를 가져올 수 있도록 노출.
// localeDict 는 클라이언트 측 JS 로 사전을 통째로 전달할 때 사용(listEditor 등).
app.use((_req, res, next) => {
res.locals.t = t
res.locals.localeDict = localeDict
next()
})
app.use(session({
secret: process.env.SESSION_SECRET ?? 'music-quiz-installer-dev-secret',
resave: false,
@@ -101,8 +113,8 @@ app.use('/', opRouter)
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error(err)
const message = err instanceof Error ? err.message : '알 수 없는 오류'
res.status(500).send(`서버 오류: ${message}`)
const message = err instanceof Error ? err.message : t('errors.unknown')
res.status(500).send(t('errors.serverError', { message }))
})
app.listen(PORT, HOST, () => {

45
src/server/datapack.ts Normal file
View File

@@ -0,0 +1,45 @@
import type { MusicListEntry, PackList } from '../shared/types.js'
/** SNBT 문자열 리터럴 안에 들어갈 문자열을 escape. */
function escapeSnbtString(input: string): string {
return input.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
/** alias 배열을 SNBT 리스트 리터럴로 변환. 빈 배열도 `[]` 로 출력. */
function aliasListSnbt(aliases: string[]): string {
if (!Array.isArray(aliases) || aliases.length === 0) return '[]'
const parts = aliases.map((a) => `"${escapeSnbtString(a)}"`)
return `[${parts.join(',')}]`
}
/** 한 곡(MusicListEntry) → `{title:"...", author:"...", alias:[...]}` SNBT. */
function entrySnbt(entry: MusicListEntry): string {
const title = escapeSnbtString(entry.title ?? '')
// launcher 의 artist → 데이터팩 SNBT 의 author. 빈 값은 빈 문자열로 그대로 둔다.
const author = escapeSnbtString(entry.artist ?? '')
const alias = aliasListSnbt(entry.aliases ?? [])
return `{title:"${title}", author:"${author}", alias:${alias}}`
}
/**
* list.music 으로부터 `data/mq/function/init/songs.mcfunction` 본문을 생성.
* 운영자는 mc_datapack 의 music_quiz 데이터팩에서 이 파일만 이 내용으로
* 덮어쓰면 된다 — 나머지 파일은 launcher 가 관여하지 않는다.
*/
export function buildSongsMcfunction(list: PackList): string {
const lines: string[] = []
lines.push('# 곡 한 개 = 한 줄.')
lines.push('# 필수 — title, author, alias')
lines.push('# 선택 — volume (이 곡만의 /playsound 음량. 미지정시 init/config.mcfunction')
lines.push('# 의 audio.volume 사용)')
lines.push('# 곡 순서가 리소스팩의 track_NN / cover_NN 인덱스와 1:1 매칭된다.')
lines.push('# 예) {title:"Quiet Song", author:"...", alias:[...], volume:2.0}')
lines.push('data modify storage mq:main songs set value []')
for (const entry of list.music) {
lines.push(`data modify storage mq:main songs append value ${entrySnbt(entry)}`)
}
lines.push('')
lines.push('# 곡 개수는 songs 배열 길이에서 자동 계산됨')
lines.push('execute store result storage mq:main max_index int 1 run data get storage mq:main songs')
return lines.join('\n') + '\n'
}

6
src/server/i18n.ts Normal file
View File

@@ -0,0 +1,6 @@
import { loadComponentI18n } from '../shared/i18n.js'
// 서버 진입 시 한 번 로드. routes/views 어디서든 동일한 사전을 공유.
const i18n = loadComponentI18n('server')
export const t = i18n.t
export const localeDict = i18n.dict

View File

@@ -1,5 +1,5 @@
import { Router } from 'express'
import { listPackKeys, loadPackDefinition, readManifest } from '../../shared/store'
import { listPackKeys, loadPackDefinition, readManifest } from '../../shared/store.js'
export const indexRouter = Router()

View File

@@ -1,17 +1,24 @@
import { Router } from 'express'
import archiver from 'archiver'
import {
createPack,
deletePackKeys,
listPackKeys,
loadPackDefinition,
loadPackList,
normalizePackDefinition,
normalizePackList,
readAccounts,
renamePack,
sanitizePackKey
} from '../../shared/store'
import { fetchReleaseVersions } from '../../shared/mojang'
import { requireAuth } from '../middleware/auth'
import type { PackDefinition } from '../../shared/types'
sanitizePackKey,
savePackList
} from '../../shared/store.js'
import { fetchReleaseVersions } from '../../shared/mojang.js'
import { fetchPlaylistEntries, fetchVideoMeta, YtDlpUnavailableError } from '../youtube.js'
import { requireAuth } from '../middleware/auth.js'
import type { PackDefinition, PackList } from '../../shared/types.js'
import { t } from '../i18n.js'
import { buildSongsMcfunction } from '../datapack.js'
export const opRouter = Router()
@@ -42,7 +49,7 @@ opRouter.post('/op', async (req, res, next) => {
const accounts = await readAccounts()
const matched = accounts.find((entry) => entry.password === password)
if (!matched) {
res.status(401).render('op/login', { error: '비밀번호가 올바르지 않습니다.' })
res.status(401).render('op/login', { error: t('login.wrongPassword') })
return
}
req.session.userId = matched.id
@@ -102,7 +109,7 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send('해당 음악퀴즈를 찾을 수 없습니다.')
res.status(404).send(t('errors.packNotFound'))
return
}
const releases = await fetchReleaseVersions()
@@ -117,6 +124,177 @@ opRouter.get('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
}
})
// ─── /op/list ──────────────────────────────────────────────────────────
// 음악퀴즈를 카드 한 줄로 표시. 카드 클릭 → /op/list/:packName
opRouter.get('/op/list', requireAuth, async (req, res, next) => {
try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => ({
key,
definition: await loadPackDefinition(key)
})))
res.render('op/list', { userId: req.session.userId, items })
} catch (error) {
next(error)
}
})
// 음악퀴즈 음악/사진 목록 편집 페이지.
opRouter.get('/op/list/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).send(t('errors.packNotFound'))
return
}
const list = await loadPackList(packKey)
res.render('op/listEditor', {
userId: req.session.userId,
packKey,
pack: definition,
list
})
} catch (error) {
next(error)
}
})
// 음악/사진 목록 저장. JSON body.
opRouter.post('/op/list/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).json({ ok: false, message: t('errors.packNotFoundJson') })
return
}
const normalized = normalizePackList(req.body)
await savePackList(packKey, normalized)
res.json({ ok: true })
} catch (error) {
next(error)
}
})
// 단일 영상 메타데이터 조회 (음악 항목 수정에서 URL 변경 시 자동 갱신용).
// body: { url: string }
opRouter.post('/op/list/:packName/video-meta', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
res.status(400).json({ ok: false, message: t('errors.videoUrlRequired') })
return
}
try {
const entry = await fetchVideoMeta(url)
if (!entry) {
res.status(404).json({ ok: false, message: t('errors.metaNotFound') })
return
}
res.json({ ok: true, entry })
} catch (error) {
if (error instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' })
return
}
res.status(500).json({ ok: false, message: (error as Error).message })
}
})
// 플레이리스트 주소를 yt-dlp 로 풀어 목록 후보를 반환.
// body: { url: string }
opRouter.post('/op/list/:packName/playlist', requireAuth, async (req, res) => {
const url = pickFirstValue(req.body?.url).trim()
if (!url) {
res.status(400).json({ ok: false, message: t('errors.playlistUrlRequired') })
return
}
try {
const entries = await fetchPlaylistEntries(url)
res.json({ ok: true, entries })
} catch (error) {
if (error instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: error.message, code: 'NO_YTDLP' })
return
}
res.status(500).json({ ok: false, message: (error as Error).message })
}
})
// ─── /op/datapack ──────────────────────────────────────────────────────
opRouter.get('/op/datapack', requireAuth, async (req, res, next) => {
try {
const keys = await listPackKeys()
const items = await Promise.all(keys.map(async (key) => {
const definition = await loadPackDefinition(key)
const list = await loadPackList(key)
return { key, definition, musicCount: list.music.length }
}))
res.render('op/datapack', { userId: req.session.userId, items })
} catch (error) {
next(error)
}
})
// 데이터팩 출력: list.music 으로부터 init/songs.mcfunction 본문만 만들어
// text/plain 으로 반환한다. 운영자가 mc_datapack 의 해당 파일에 붙여넣는다.
opRouter.get('/op/datapack/:packName/generate', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
return
}
const list = await loadPackList(packKey)
res.type('text/plain; charset=utf-8').send(buildSongsMcfunction(list))
} catch (error) {
next(error)
}
})
// painting_variant JSON 들을 zip 으로 묶어 내려준다.
// query.size 로 width/height (블록 단위, 기본 4, 1~16) 지정. 음악 개수만큼 cover_NN.json 생성.
opRouter.get('/op/datapack/:packName/images-zip', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
const definition = await loadPackDefinition(packKey)
if (!definition) {
res.status(404).type('text/plain').send(t('errors.packNotFoundJson'))
return
}
const sizeRaw = Number(pickFirstValue(req.query.size))
const size = Number.isFinite(sizeRaw) && sizeRaw >= 1 && sizeRaw <= 16
? Math.floor(sizeRaw)
: 4
const list = await loadPackList(packKey)
const total = list.music.length
res.setHeader('Content-Type', 'application/zip')
res.setHeader(
'Content-Disposition',
`attachment; filename="${packKey}-painting-variants.zip"`
)
const archive = archiver('zip', { zlib: { level: 9 } })
archive.on('error', (err) => next(err))
archive.pipe(res)
for (let i = 1; i <= total; i++) {
const nn = String(i).padStart(2, '0')
const json = {
asset_id: `musicquiz:cover_${nn}`,
width: size,
height: size,
title: { text: `Cover ${nn}` },
author: { text: 'music quiz' }
}
archive.append(JSON.stringify(json, null, 2) + '\n', { name: `cover_${nn}.json` })
}
await archive.finalize()
} catch (error) {
next(error)
}
})
opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) => {
try {
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
@@ -124,16 +302,19 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const platformType = pickFirstValue(req.body.platformType)
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
const platformLoaderVersion = pickFirstValue(req.body.platformLoaderVersion).trim()
const partial: Partial<PackDefinition> & Record<string, unknown> = {
name: pickFirstValue(req.body.displayName),
mcVersion: pickFirstValue(req.body.mcVersion),
platform: {
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
},
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined,
loaderVersion: platformLoaderVersion.length > 0 ? platformLoaderVersion : undefined
} as PackDefinition['platform'] & { loaderVersion?: string },
modsFolder: pickFirstValue(req.body.modsFolder),
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
outputPackName: pickFirstValue(req.body.outputPackName),
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
@@ -144,7 +325,7 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
const normalized = normalizePackDefinition(partial)
if (normalized.clientMinRam > normalized.clientRecommendedRam) {
res.status(400).send('clientMinRam은 clientRecommendedRam보다 클 수 없습니다.')
res.status(400).send(t('errors.ramOrderInvalid'))
return
}
const finalKey = await renamePack(packKey, requestedKey, normalized)

240
src/server/youtube.ts Normal file
View File

@@ -0,0 +1,240 @@
import { spawn } from 'node:child_process'
import { promises as fs, createWriteStream, constants as fsConst } from 'node:fs'
import path from 'node:path'
import https from 'node:https'
import http from 'node:http'
import { getMcCustomDir } from '../shared/paths.js'
import { t } from './i18n.js'
export interface YtPlaylistEntry {
id: string
title: string
channel: string
durationSec: number
url: string
}
export class YtDlpUnavailableError extends Error {
constructor(message?: string) {
super(message || t('youtube.ytdlpUnavailable'))
}
}
/** 현재 OS/아키텍처에서 GitHub Releases 가 제공하는 yt-dlp 파일 이름. */
function getYtDlpAssetName(): string {
if (process.platform === 'win32') return 'yt-dlp.exe'
if (process.platform === 'darwin') return 'yt-dlp_macos'
if (process.platform === 'linux') {
if (process.arch === 'arm64') return 'yt-dlp_linux_aarch64'
if (process.arch === 'arm') return 'yt-dlp_linux_armv7l'
return 'yt-dlp_linux'
}
return 'yt-dlp' // 그 외 OS: 순수 파이썬 zipapp. python3 가 PATH 에 있어야 동작
}
/** 로컬 설치 경로: %appdata%/.mc_custom/<asset> */
export function getYtDlpInstallPath(): string {
return path.join(getMcCustomDir(), getYtDlpAssetName())
}
/** 한 번에 한 다운로드만 진행하도록 락 (서버 동시 요청 보호). */
let installPromise: Promise<string> | null = null
/**
* %appdata%/.mc_custom/ 에 yt-dlp 가 준비됐는지 확인하고, 없으면 GitHub Releases 에서
* 현재 OS/아키텍처용 바이너리를 자동으로 받아 설치한다. 성공 시 실행 경로 반환.
*/
export async function ensureYtDlp(): Promise<string> {
const target = getYtDlpInstallPath()
// 이미 설치돼 있고 실행 가능하면 그대로 사용
if (await canExecute(target)) return target
if (installPromise) return installPromise
installPromise = (async () => {
try {
const dir = getMcCustomDir()
await fs.mkdir(dir, { recursive: true })
const asset = getYtDlpAssetName()
const url = `https://github.com/yt-dlp/yt-dlp/releases/latest/download/${asset}`
await downloadToFile(url, target)
// POSIX 계열은 실행 권한 부여
if (process.platform !== 'win32') {
await fs.chmod(target, 0o755)
}
// 검증
const okVersion = await probeVersion(target)
if (!okVersion) {
throw new YtDlpUnavailableError(t('youtube.ytdlpVerifyFailed'))
}
return target
} catch (err) {
// 실패 흔적(부분 다운로드) 삭제
try { await fs.unlink(target) } catch { /* noop */ }
throw err instanceof YtDlpUnavailableError
? err
: new YtDlpUnavailableError(
t('youtube.ytdlpInstallFailed', { message: err instanceof Error ? err.message : String(err) })
)
} finally {
installPromise = null
}
})()
return installPromise
}
async function canExecute(filePath: string): Promise<boolean> {
try {
await fs.access(filePath, fsConst.F_OK)
} catch {
return false
}
// POSIX 면 X 비트도 확인
if (process.platform !== 'win32') {
try {
await fs.access(filePath, fsConst.X_OK)
} catch {
return false
}
}
// 실제로 --version 으로 한 번 더 확인
return probeVersion(filePath)
}
function probeVersion(bin: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] })
let ok = false
child.stdout.on('data', () => { ok = true })
child.on('error', () => resolve(false))
child.on('close', (code) => resolve(ok && code === 0))
})
}
/** GitHub Releases 의 latest URL 은 302 리다이렉트를 사용하므로 따라가며 받음. */
function downloadToFile(url: string, dest: string, redirects = 0): Promise<void> {
return new Promise((resolve, reject) => {
if (redirects > 8) {
reject(new Error(t('youtube.tooManyRedirects')))
return
}
const lib = url.startsWith('https://') ? https : http
const req = lib.get(url, {
headers: { 'user-agent': 'mc-music-quiz-launcher' }
}, (res) => {
const code = res.statusCode || 0
if (code >= 300 && code < 400 && res.headers.location) {
res.resume()
downloadToFile(res.headers.location, dest, redirects + 1).then(resolve, reject)
return
}
if (code !== 200) {
res.resume()
reject(new Error(`HTTP ${code} (${url})`))
return
}
const out = createWriteStream(dest)
res.pipe(out)
out.on('finish', () => out.close((err) => err ? reject(err) : resolve()))
out.on('error', reject)
res.on('error', reject)
})
req.on('error', reject)
})
}
/**
* 단일 영상 URL 의 메타데이터를 가져온다.
* `--no-playlist` 로 플레이리스트 URL 이 들어와도 단일 영상 정보만 뽑음.
*/
export async function fetchVideoMeta(url: string): Promise<YtPlaylistEntry | null> {
const bin = await ensureYtDlp()
return new Promise((resolve, reject) => {
const child = spawn(bin, [
'--dump-json',
'--no-warnings',
'--no-playlist',
'--skip-download',
url
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(t('youtube.ytdlpVideoFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const line = stdout.trim().split('\n').find((l) => l.trim().length > 0)
if (!line) { resolve(null); return }
try {
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) { resolve(null); return }
resolve({
id,
title: typeof obj.title === 'string' ? obj.title : '',
channel: typeof obj.channel === 'string'
? obj.channel
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
url: typeof obj.webpage_url === 'string' && obj.webpage_url.length > 0
? obj.webpage_url
: `https://www.youtube.com/watch?v=${id}`
})
} catch (err) {
reject(err)
}
})
})
}
/**
* 플레이리스트 URL 을 펼쳐 각 영상의 메타데이터를 가져온다.
* `--flat-playlist --dump-json` 출력은 한 줄당 한 JSON.
*/
export async function fetchPlaylistEntries(url: string): Promise<YtPlaylistEntry[]> {
const bin = await ensureYtDlp()
return new Promise((resolve, reject) => {
const child = spawn(bin, [
'--flat-playlist',
'--dump-json',
'--no-warnings',
url
], { stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => (stdout += chunk.toString('utf8')))
child.stderr.on('data', (chunk: Buffer) => (stderr += chunk.toString('utf8')))
child.on('error', (err) => reject(err))
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(t('youtube.ytdlpPlaylistFailed', { code: String(code), detail: stderr.trim() || stdout.trim() })))
return
}
const lines = stdout.split('\n').map((l) => l.trim()).filter((l) => l.length > 0)
const parsed: YtPlaylistEntry[] = []
for (const line of lines) {
try {
const obj = JSON.parse(line) as Record<string, unknown>
const id = typeof obj.id === 'string' ? obj.id : ''
if (!id) continue
parsed.push({
id,
title: typeof obj.title === 'string' ? obj.title : '',
channel: typeof obj.channel === 'string'
? obj.channel
: (typeof obj.uploader === 'string' ? obj.uploader : ''),
durationSec: typeof obj.duration === 'number' ? Math.floor(obj.duration) : 0,
url: typeof obj.url === 'string' && obj.url.length > 0
? obj.url
: `https://www.youtube.com/watch?v=${id}`
})
} catch {
// 한 줄이 깨져도 나머지는 살림
}
}
resolve(parsed)
})
})
}

61
src/shared/env.ts Normal file
View File

@@ -0,0 +1,61 @@
import path from 'node:path'
import fs from 'node:fs'
import dotenv from 'dotenv'
import { projectRoot } from './paths.js'
/**
* `.env` / `.env.build` 를 읽어 `process.env` 에 주입.
*
* 여러 파일을 순서대로 읽되 `override:false` 로 병합하므로 **먼저 로드된 값이
* 우선**. 두 도메인(패키지 빌드용 vs 개발/서버용) 이 한 함수에서 자연스럽게
* 분리됨:
*
* 1. 패키징된 Electron 앱: `process.resourcesPath/.env.build`
* — electron-builder 가 빌드 시점 `.env.build` 를 함께 배포. 패키지된 exe
* 에서 가장 먼저 적용되는 값.
* 2. 패키징된 Electron 앱: `process.resourcesPath/.env`
* — 운영자가 패키징 후 직접 `.env` 를 옆에 두고 덮어쓰는 경우 폴백.
* 3. `<프로젝트 루트>/.env`
* — 개발 실행(npm start / npm run installer*) 및 서버 운영용. 서버의
* `PORT/HOST/SESSION_SECRET` 처럼 dev 에서 반드시 살아 있어야 하는 값들이
* 있어, `.env.build` 보다 먼저 로드해 우선권을 줌.
* 4. `<프로젝트 루트>/.env.build`
* — dev 환경에서 빌드용 값(예: 운영 도메인 SITE_BASE_URL)을 테스트하고
* 싶을 때 사용. `.env` 에 없는 키만 채움.
*
* - 이미 설정된 환경변수는 덮어쓰지 않음(쉘/systemd 에서 넘긴 값이 최우선).
* - 존재하지 않는 후보는 조용히 건너뜀.
* - 서버/설치기/리소스팩설치기 진입점에서 한 번씩 호출.
*/
export function loadEnv(): void {
const candidates: string[] = []
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
candidates.push(path.join(resourcesPath, '.env.build'))
candidates.push(path.join(resourcesPath, '.env'))
}
candidates.push(path.join(projectRoot, '.env'))
candidates.push(path.join(projectRoot, '.env.build'))
for (const envPath of candidates) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath, override: false, quiet: true })
}
}
}
/**
* 사이트 베이스 URL. 관리 사이트가 호스팅되는 외부 주소(설치기가 manifest 를
* 받아가는 도메인). 기본값은 로컬 개발용 `http://127.0.0.1:3000`.
*/
export function getSiteBaseUrl(): string {
const raw = (process.env.SITE_BASE_URL ?? 'http://127.0.0.1:3000').trim()
return raw.replace(/\/+$/, '')
}
/** 사이트 베이스 URL + `/manifest.json`. `MANIFEST_URL` 가 따로 지정되면 그 값을 우선. */
export function getManifestUrl(): string {
const explicit = process.env.MANIFEST_URL?.trim()
if (explicit) return explicit
return `${getSiteBaseUrl()}/manifest.json`
}

93
src/shared/i18n.ts Normal file
View File

@@ -0,0 +1,93 @@
import fs from 'node:fs'
import path from 'node:path'
/**
* 단순 키-문자열 사전. 중첩 객체도 허용해서 그룹화 가능.
* { step1: { title: '1단계. 음악퀴즈 선택' } }
* t('step1.title') → '1단계. 음악퀴즈 선택'
*/
export type Locale = Record<string, unknown>
/**
* 자유 형식 ko-kr.json 을 로드하고 `t(key, params)` 헬퍼를 만들어 반환.
*
* 사용 패턴:
* const { t, dict } = createI18n(path.join(__dirname, 'locales', 'ko-kr.json'))
* t('step1.title')
* t('install.downloading', { idx: 3 }) // → '3번 노래 다운로드 중…'
*
* 키가 사전에 없으면 키 자체를 반환(개발 중 누락 빨리 찾도록).
* 사전이 비어 있어도 빌드는 깨지지 않고 키만 노출.
*/
export interface I18n {
/** 키로 문자열 lookup. 누락 시 키 그대로 반환. */
t(key: string, params?: Record<string, string | number>): string
/** 렌더러로 전달하기 위한 원본 사전(JSON 그대로). */
dict: Locale
}
export function createI18n(filePath: string): I18n {
let dict: Locale = {}
try {
const raw = fs.readFileSync(filePath, 'utf-8')
dict = JSON.parse(raw) as Locale
} catch {
// 파일이 없거나 깨진 경우 빈 사전. t() 가 키 자체를 돌려주므로 UI 가 깨지진 않음.
dict = {}
}
function lookup(key: string): string | undefined {
const parts = key.split('.')
let cur: unknown = dict
for (const p of parts) {
if (cur && typeof cur === 'object' && p in (cur as Record<string, unknown>)) {
cur = (cur as Record<string, unknown>)[p]
} else {
return undefined
}
}
return typeof cur === 'string' ? cur : undefined
}
function interpolate(tpl: string, params?: Record<string, string | number>): string {
if (!params) return tpl
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, name: string) => {
return name in params ? String(params[name]) : `{{${name}}}`
})
}
return {
t(key, params) {
const found = lookup(key)
return interpolate(found ?? key, params)
},
dict
}
}
/**
* 진입점에서 호출할 표준 로더. 컴포넌트 이름과 `__dirname`(컴파일 후) 만 주면
* `locales/<component>/ko-kr.json` 을 찾아 로드.
*
* 탐색 순서(처음 발견된 것만 사용):
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
*/
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
const projectRoot = path.resolve(__dirname, '..', '..')
const candidates: string[] = []
const resourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath
if (typeof resourcesPath === 'string' && resourcesPath.length > 0) {
candidates.push(path.join(resourcesPath, 'locales', component, 'ko-kr.json'))
}
candidates.push(path.join(projectRoot, 'locales', component, 'ko-kr.json'))
for (const p of candidates) {
if (fs.existsSync(p)) {
return createI18n(p)
}
}
return createI18n(candidates[candidates.length - 1] ?? '')
}

View File

@@ -1,4 +1,5 @@
import path from 'node:path'
import os from 'node:os'
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
export const projectRoot = path.resolve(__dirname, '..', '..')
@@ -6,5 +7,38 @@ export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const manifestDirPath = path.join(projectRoot, 'manifest')
export const accountFilePath = path.join(projectRoot, 'account.json')
export const fileDirPath = path.join(projectRoot, 'file')
export const fileListDirPath = path.join(fileDirPath, 'list')
export const fileDatapacksDirPath = path.join(fileDirPath, 'datapacks')
export const viewsDirPath = path.join(projectRoot, 'views')
export const publicDirPath = path.join(projectRoot, 'public')
/**
* 사용자 환경의 "%appdata%" 디렉터리(OS별 표준 사용자 데이터 경로)를 반환.
* - Windows : %APPDATA% (보통 C:\Users\<user>\AppData\Roaming)
* - macOS : ~/Library/Application Support
* - Linux 등 : $XDG_CONFIG_HOME 또는 ~/.config
*/
export function getAppDataDir(): string {
if (process.platform === 'win32') {
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
}
if (process.platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support')
}
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
}
/** %appdata%/.mc_custom — 음악퀴즈 관련 외부 도구/캐시 보관 디렉터리. */
export function getMcCustomDir(): string {
return path.join(getAppDataDir(), '.mc_custom')
}
/**
* %appdata%/.mc_custom/installer — 설치기가 자체적으로 다운로드해 사용하는
* 외부 바이너리(yt-dlp.exe, ffmpeg.exe 등) 보관 위치. .mc_custom 루트가
* 마인크래프트 게임 폴더(`mods/`, `resourcepacks/`, `saves/` 등)와 섞이지
* 않도록 별도 하위 폴더에 둔다.
*/
export function getMcCustomInstallerDir(): string {
return path.join(getMcCustomDir(), 'installer')
}

View File

@@ -1,8 +1,11 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
import { manifestRootPath, manifestDirPath, accountFilePath, fileListDirPath } from './paths.js'
import type {
Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType,
PackList, MusicListEntry, ImageListEntry
} from './types.js'
export async function readManifest(): Promise<Manifest> {
try {
@@ -34,6 +37,7 @@ export function defaultPackDefinition(name: string): PackDefinition {
platform: { type: 'vanilla' },
modsFolder: '',
resourcepackPath: '',
outputPackName: '',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 2048,
@@ -78,12 +82,22 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
: fallback.mcVersion,
platform: {
type: platformType,
downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0
// vanilla 외에는 fabric/forge/neoforge 모두 downloadUrl 을 보관한다.
downloadUrl: platformType !== 'vanilla'
&& typeof platform.downloadUrl === 'string'
&& platform.downloadUrl.trim().length > 0
? platform.downloadUrl.trim()
: undefined,
loaderVersion: platformType === 'fabric'
&& typeof (platform as { loaderVersion?: unknown }).loaderVersion === 'string'
&& ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim().length > 0
? ((platform as { loaderVersion?: string }).loaderVersion ?? '').trim()
: undefined
},
modsFolder: sanitizeFolderName(input.modsFolder),
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
// 표시명은 사용자 입력을 보존(공백/마침표 trim 만). 파일명 안전 처리는 설치기 측에서.
outputPackName: typeof input.outputPackName === 'string' ? input.outputPackName.trim() : '',
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
@@ -204,6 +218,79 @@ async function syncManifestWith(key: string, name: string, action: ManifestSyncA
await writeManifest({ packs: filtered })
}
function defaultPackList(): PackList {
return { musicPlaylistUrl: '', imagePlaylistUrl: '', music: [], images: [] }
}
function sanitizeStr(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function sanitizeNumber(value: unknown): number {
const n = typeof value === 'number' ? value : Number(value)
if (!Number.isFinite(n) || n < 0) return 0
return Math.floor(n)
}
/** 별칭 배열을 정규화: 문자열만 받아 trim → 빈 값 제거 → 중복 제거. */
function sanitizeAliases(value: unknown): string[] {
if (!Array.isArray(value)) return []
const out: string[] = []
const seen = new Set<string>()
for (const item of value) {
const s = sanitizeStr(item)
if (!s) continue
if (seen.has(s)) continue
seen.add(s)
out.push(s)
}
return out
}
export function normalizePackList(input: unknown): PackList {
const fallback = defaultPackList()
if (!input || typeof input !== 'object') return fallback
const obj = input as Record<string, unknown>
const music = Array.isArray(obj.music) ? obj.music : []
const images = Array.isArray(obj.images) ? obj.images : []
return {
musicPlaylistUrl: sanitizeStr(obj.musicPlaylistUrl),
imagePlaylistUrl: sanitizeStr(obj.imagePlaylistUrl),
music: music
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): MusicListEntry => ({
url: sanitizeStr(entry.url),
title: sanitizeStr(entry.title),
artist: sanitizeStr(entry.artist),
durationSec: sanitizeNumber(entry.durationSec),
aliases: sanitizeAliases(entry.aliases)
}))
.filter((entry) => entry.url.length > 0),
images: images
.filter((entry): entry is Record<string, unknown> => !!entry && typeof entry === 'object')
.map((entry): ImageListEntry => ({ url: sanitizeStr(entry.url) }))
.filter((entry) => entry.url.length > 0)
}
}
export async function loadPackList(packKey: string): Promise<PackList> {
const filePath = path.join(fileListDirPath, `${packKey}.json`)
try {
const raw = await fsp.readFile(filePath, 'utf8')
return normalizePackList(JSON.parse(raw))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return defaultPackList()
throw error
}
}
export async function savePackList(packKey: string, list: PackList): Promise<void> {
await fsp.mkdir(fileListDirPath, { recursive: true })
const filePath = path.join(fileListDirPath, `${packKey}.json`)
const normalized = normalizePackList(list)
await fsp.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
}
export async function readAccounts(): Promise<AccountEntry[]> {
try {
const raw = await fsp.readFile(accountFilePath, 'utf8')

View File

@@ -2,7 +2,10 @@ export type LoaderType = 'vanilla' | 'forge' | 'fabric' | 'neoforge'
export interface PackPlatform {
type: LoaderType
/** forge / neoforge 처럼 사용자가 직접 업로드한 installer jar 의 URL. */
downloadUrl?: string
/** fabric 의 경우 Fabric Meta 에서 선택한 로더 버전(예: "0.16.0"). 설치 시 최신 fabric-installer 를 받아 CLI 로 자동 설치. */
loaderVersion?: string
}
export interface PackDefinition {
@@ -13,6 +16,14 @@ export interface PackDefinition {
modsFolder: string
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
resourcepackPath: string
/**
* 리소스팩 설치기가 만들어 내는 최종 zip 파일의 이름(확장자 제외).
* 빈 문자열이면 설치기가 `<packKey>_resourcepack` 형식으로 기본 이름을 만든다.
* 마인크래프트 리소스팩 목록에서 사용자에게 제목처럼 보이는 값이므로
* 한글 등 자유 입력을 그대로 보존하고, 파일 시스템에서 사용할 때 금지 문자만
* `_` 로 치환한다(치환 책임은 설치기 측에 있음).
*/
outputPackName: string
serverMinRam: number
serverMaxRam: number
clientMinRam: number
@@ -36,3 +47,28 @@ export interface AccountEntry {
id: string
password: string
}
export interface MusicListEntry {
/** 유튜브 영상 주소. */
url: string
title: string
artist: string
/** 노래 길이 (초). */
durationSec: number
/** 정답으로 인정할 별칭 목록. 빈 배열이면 정답은 title 뿐. */
aliases: string[]
}
export interface ImageListEntry {
/** 유튜브 영상 주소 또는 일반 이미지 URL. */
url: string
}
export interface PackList {
/** 음악 플레이리스트 원본 주소 (저장 시 기억해서 재사용). */
musicPlaylistUrl: string
/** 사진 플레이리스트 원본 주소. */
imagePlaylistUrl: string
music: MusicListEntry[]
images: ImageListEntry[]
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/installer-rp/**/*.ts", "src/shared/**/*.ts"]
}

View File

@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "node",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,

View File

@@ -3,29 +3,29 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>음악퀴즈 목록</title>
<title><%= t('site.indexTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<main class="pageWrap">
<section class="hero">
<h1>마인크래프트 음악퀴즈</h1>
<p>설치기에서 사용 가능한 음악퀴즈 목록입니다.</p>
<h1><%= t('site.heroTitle') %></h1>
<p><%= t('site.heroSubtitle') %></p>
</section>
<section class="cardRow horizontalScroll">
<% if (packs.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다.</p>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% packs.forEach(function (entry) { %>
<article class="packCard">
<h2><%= entry.name %></h2>
<p class="muted">파일: <%= entry.file %>.json</p>
<p class="muted"><%= t('site.fileLabel', { file: entry.file }) %></p>
<% if (entry.definition) { %>
<ul class="metaList">
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
<li><%= t('site.mcVersion') %> <strong><%= entry.definition.mcVersion %></strong></li>
<li><%= t('site.platform') %> <strong><%= entry.definition.platform.type %></strong></li>
<li><%= t('site.modsFolder') %> <%= entry.definition.modsFolder || t('site.noneFallback') %> / <%= t('site.resourcepack') %> <%= entry.definition.resourcepackPath || t('site.noneFallback') %></li>
</ul>
<% } %>
</article>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 대시보드</title>
<title><%= t('dashboard.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -11,34 +11,36 @@
<main class="pageWrap">
<section class="dashboardHeader">
<h1>음악퀴즈 목록</h1>
<h1><%= t('dashboard.title') %></h1>
<div class="dashboardActions">
<a class="secondaryButton" href="/op/list"><%= t('dashboard.editList') %></a>
<a class="secondaryButton" href="/op/datapack"><%= t('dashboard.editDatapack') %></a>
<form method="post" action="/op/dashboard/create" class="inlineForm">
<button type="submit" class="primaryButton">음악퀴즈 추가</button>
<button type="submit" class="primaryButton"><%= t('dashboard.addPack') %></button>
</form>
<button type="button" class="secondaryButton" id="deleteToggle">음악퀴즈 삭제</button>
<button type="button" class="secondaryButton" id="deleteToggle"><%= t('dashboard.deletePack') %></button>
</div>
</section>
<form method="post" action="/op/dashboard/delete" id="deleteForm" class="dashboardListForm">
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted">등록된 음악퀴즈가 없습니다. "음악퀴즈 추가" 버튼으로 새로 만들어 보세요.</p>
<p class="muted"><%= t('dashboard.emptyHint') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard editableCard" data-key="<%= item.key %>">
<label class="cardCheckbox" hidden>
<input type="checkbox" name="targetKey" value="<%= item.key %>" />
<span>선택</span>
<span><%= t('dashboard.select') %></span>
</label>
<a class="cardLink" href="/op/dashboard/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li>MC <%= item.definition.mcVersion %></li>
<li>플랫폼 <%= item.definition.platform.type %></li>
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>
@@ -46,8 +48,8 @@
<% }) %>
</section>
<div class="deleteConfirmRow" id="deleteConfirm" hidden>
<button type="button" class="secondaryButton" id="deleteCancel">취소</button>
<button type="submit" class="dangerButton">삭제 확인</button>
<button type="button" class="secondaryButton" id="deleteCancel"><%= t('common.cancel') %></button>
<button type="submit" class="dangerButton"><%= t('dashboard.confirmDelete') %></button>
</div>
</form>
</main>

152
views/op/datapack.ejs Normal file
View File

@@ -0,0 +1,152 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('datapack.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('datapack.title') %></h1>
</div>
</section>
<p class="muted"><%= t('datapack.hint') %></p>
<section class="dpControls">
<button type="button" class="primaryButton" id="pickPackBtn"><%= t('datapack.pickPack') %></button>
<span class="muted" id="pickedLabel"><%= t('datapack.pickedNone') %></span>
</section>
<p class="muted" id="countLabel"></p>
<section class="dpActions" hidden id="dpActions">
<button type="button" class="secondaryButton" id="imagesZipBtn"><%= t('datapack.imagesZip') %></button>
<label class="muted" for="imagesZipSize" style="margin-left:4px;"><%= t('datapack.imagesZipSizeLabel') %></label>
<input type="number" id="imagesZipSize" value="4" min="1" max="16" style="width:60px;" />
<button type="button" class="secondaryButton" id="exportBtn"><%= t('datapack.export') %></button>
<button type="button" class="secondaryButton" id="copyBtn"><%= t('datapack.copy') %></button>
<span class="statusText" id="dp-status"></span>
</section>
<pre class="codeBlock" id="codeOut" hidden></pre>
</main>
<!-- 음악퀴즈 선택 팝업 -->
<div class="modalOverlay" id="pickModal" hidden>
<div class="modalCard">
<header><h3><%= t('datapack.modalPickTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<div class="cardRow horizontalScroll" id="pickList">
<% items.forEach(function (item) { %>
<article class="packCard pickable"
data-key="<%= item.key %>"
data-name="<%= item.definition ? item.definition.name : item.key %>"
data-music-count="<%= item.musicCount %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
</ul>
<% } %>
</article>
<% }) %>
</div>
</div>
</div>
</div>
<script>
var I18N = <%- JSON.stringify(localeDict.datapack) %>;
</script>
<script>
(function () {
var pickModal = document.getElementById('pickModal')
var pickedKey = ''
document.getElementById('pickPackBtn').addEventListener('click', function () {
pickModal.hidden = false
})
document.querySelectorAll('[data-modal-close]').forEach(function (b) {
b.addEventListener('click', function () { pickModal.hidden = true })
})
pickModal.addEventListener('click', function (e) {
if (e.target === pickModal) pickModal.hidden = true
})
// ESC 로 닫기.
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !pickModal.hidden) {
pickModal.hidden = true
e.preventDefault()
}
})
document.querySelectorAll('#pickList .pickable').forEach(function (card) {
card.addEventListener('click', function () {
pickedKey = card.getAttribute('data-key')
var name = card.getAttribute('data-name')
var count = card.getAttribute('data-music-count') || '0'
document.getElementById('pickedLabel').textContent = I18N.pickedLabel.replace('{{name}}', name)
document.getElementById('countLabel').textContent = I18N.totalCount.replace('{{count}}', count)
pickModal.hidden = true
document.getElementById('dpActions').hidden = false
document.getElementById('dp-status').textContent = ''
document.getElementById('dp-status').classList.remove('error')
document.getElementById('codeOut').hidden = true
document.getElementById('codeOut').textContent = ''
})
})
document.getElementById('exportBtn').addEventListener('click', function () {
if (!pickedKey) return
var s = document.getElementById('dp-status')
s.textContent = I18N.exporting; s.classList.remove('error')
fetch('/op/datapack/' + encodeURIComponent(pickedKey) + '/generate')
.then(function (r) { return r.text().then(function (t) { return { ok: r.ok, text: t } }) })
.then(function (res) {
if (!res.ok) {
s.textContent = I18N.failed.replace('{{message}}', res.text); s.classList.add('error')
return
}
var out = document.getElementById('codeOut')
out.textContent = res.text
out.hidden = false
s.textContent = I18N.exported
})
.catch(function (err) { s.textContent = I18N.failed.replace('{{message}}', err.message); s.classList.add('error') })
})
document.getElementById('imagesZipBtn').addEventListener('click', function () {
if (!pickedKey) return
var sizeInput = document.getElementById('imagesZipSize')
var size = parseInt(sizeInput.value, 10)
if (!isFinite(size) || size < 1) size = 4
if (size > 16) size = 16
sizeInput.value = String(size)
var s = document.getElementById('dp-status')
s.textContent = I18N.imagesZipDownloading; s.classList.remove('error')
// 브라우저 기본 다운로드로 위임. 인증 쿠키는 자동으로 따라간다.
var url = '/op/datapack/' + encodeURIComponent(pickedKey) + '/images-zip?size=' + size
window.location.href = url
// 다운로드 시작은 비동기지만, 사용자에게 즉시 피드백.
setTimeout(function () { s.textContent = I18N.imagesZipDone }, 500)
})
document.getElementById('copyBtn').addEventListener('click', function () {
var out = document.getElementById('codeOut')
if (out.hidden) return
navigator.clipboard.writeText(out.textContent).then(function () {
var s = document.getElementById('dp-status')
s.textContent = I18N.copied
s.classList.remove('error')
})
})
})()
</script>
</body>
</html>

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= pack.name %> 편집</title>
<title><%= t('editor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
@@ -12,27 +12,27 @@
<main class="pageWrap">
<section class="editorHeader">
<div>
<p class="eyebrow">PACK EDITOR</p>
<p class="eyebrow"><%= t('editor.eyebrow') %></p>
<h1><%= pack.name %></h1>
</div>
<a class="ghostLink" href="/op/dashboard">목록으로</a>
<a class="ghostLink" href="/op/dashboard"><%= t('common.backToList') %></a>
</section>
<form method="post" class="editorForm" id="editorForm">
<div class="gridTwo">
<label>
<span>음악퀴즈 이름</span>
<span><%= t('editor.displayName') %></span>
<input name="displayName" value="<%= pack.name %>" required />
</label>
<label>
<span>JSON 파일 이름 (확장자 제외)</span>
<span><%= t('editor.fileName') %></span>
<input name="fileName" value="<%= packKey %>" required pattern="[a-zA-Z0-9_\-]+" />
</label>
</div>
<div class="gridTwo">
<label>
<span>마인크래프트 버전</span>
<span><%= t('editor.mcVersion') %></span>
<select name="mcVersion" required>
<% releases.forEach(function (release) { %>
<option value="<%= release %>" <%= release === pack.mcVersion ? 'selected' : '' %>><%= release %></option>
@@ -40,78 +40,173 @@
</select>
</label>
<label>
<span>모드 플랫폼</span>
<span><%= t('editor.platformType') %></span>
<select name="platformType" id="platformType">
<% ['vanilla','forge','fabric','neoforge'].forEach(function (loader) { %>
<option value="<%= loader %>" <%= pack.platform.type === loader ? 'selected' : '' %>><%= loader %></option>
<% }) %>
</select>
</label>
<label class="fullSpan" id="platformDownloadField">
<span>플랫폼 설치파일 URL</span>
<label class="fullSpan" id="platformDownloadField"<%= pack.platform.type === 'vanilla' ? ' hidden' : '' %>>
<span><%= t('editor.platformDownloadUrl') %></span>
<input name="platformDownloadUrl" value="<%= pack.platform.downloadUrl || '' %>" placeholder="/forge-installer.jar 또는 https://example.com/forge-installer.jar" />
<small class="muted">도메인 없이 입력하면 manifest.json 도메인의 <code>/file/platforms/&lt;파일명&gt;</code>으로 해석됩니다.</small>
<small class="muted"><%- t('editor.platformDownloadHint') %></small>
</label>
<label class="fullSpan" id="platformLoaderField"<%= pack.platform.type === 'fabric' ? '' : ' hidden' %>>
<span><%= t('editor.platformLoaderVersion') %></span>
<select name="platformLoaderVersion" id="platformLoaderVersion" data-current="<%= pack.platform.loaderVersion || '' %>">
<option value=""><%= t('common.loading') %></option>
</select>
<small class="muted"><%= t('editor.platformLoaderHint') %></small>
</label>
<label>
<span>서버 최소 램 (MB)</span>
<span><%= t('editor.serverMinRam') %></span>
<input type="number" name="serverMinRam" value="<%= pack.serverMinRam %>" min="512" required />
</label>
<label>
<span>서버 최대 램 (MB)</span>
<span><%= t('editor.serverMaxRam') %></span>
<input type="number" name="serverMaxRam" value="<%= pack.serverMaxRam %>" min="512" required />
</label>
<label>
<span>클라이언트 최소 램 (MB)</span>
<span><%= t('editor.clientMinRam') %></span>
<input type="number" name="clientMinRam" value="<%= pack.clientMinRam %>" min="512" required />
</label>
<label>
<span>클라이언트 권장 램 (MB)</span>
<span><%= t('editor.clientRecommendedRam') %></span>
<input type="number" name="clientRecommendedRam" value="<%= pack.clientRecommendedRam %>" min="512" required />
</label>
<label>
<span>맵 파일 (.zip)</span>
<span><%= t('editor.mapPath') %></span>
<input name="mapPath" value="<%= pack.mapPath %>" placeholder="my-map.zip" pattern=".+\.zip" />
<small class="muted">/file/maps/ 아래 zip 파일 이름.</small>
<small class="muted"><%= t('editor.mapPathHint') %></small>
</label>
<label>
<span>서버 파일 (.zip)</span>
<span><%= t('editor.serverPath') %></span>
<input name="serverPath" value="<%= pack.serverPath %>" placeholder="my-server.zip" pattern=".+\.zip" />
<small class="muted">/file/servers/ 아래 zip 파일 이름. 멀티 모드 전용.</small>
<small class="muted"><%= t('editor.serverPathHint') %></small>
</label>
</div>
<div class="gridTwo">
<label>
<span>모드 폴더 이름</span>
<span><%= t('editor.modsFolder') %></span>
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
<small class="muted">/file/mods/&lt;폴더이름&gt;/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
<small class="muted"><%- t('editor.modsFolderHint') %></small>
</label>
<label>
<span>리소스팩 (.zip)</span>
<span><%= t('editor.resourcepackPath') %></span>
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
<small class="muted"><%= t('editor.resourcepackHint') %></small>
</label>
<label class="fullSpan">
<span><%= t('editor.outputPackName') %></span>
<input name="outputPackName" value="<%= pack.outputPackName %>" placeholder="<%= t('editor.outputPackNamePlaceholder') %>" />
<small class="muted"><%= t('editor.outputPackNameHint') %></small>
</label>
</div>
<button class="primaryButton" type="submit">저장</button>
<button class="primaryButton" type="submit"><%= t('common.save') %></button>
</form>
</main>
<script>
var I18N = {
ramOrderInvalid: <%- JSON.stringify(t('editor.ramOrderInvalid')) %>,
fabricLoaderRequired: <%- JSON.stringify(t('editor.fabricLoaderRequired')) %>,
loaderEmpty: <%- JSON.stringify(t('editor.platformLoaderEmpty')) %>,
loaderPickMc: <%- JSON.stringify(t('editor.platformLoaderPickMc')) %>,
loaderLoadFailedPrefix: <%- JSON.stringify(t('editor.platformLoaderLoadFailed', { message: '__M__' })) %>,
loading: <%- JSON.stringify(t('common.loading')) %>
}
function formatLoaderLoadFailed(message) {
return I18N.loaderLoadFailedPrefix.replace('__M__', message)
}
</script>
<script>
(function () {
var platformSelect = document.getElementById('platformType')
var mcVersionSelect = document.querySelector('select[name="mcVersion"]')
var downloadField = document.getElementById('platformDownloadField')
var loaderField = document.getElementById('platformLoaderField')
var loaderSelect = document.getElementById('platformLoaderVersion')
var currentLoader = loaderSelect.getAttribute('data-current') || ''
var loaderCache = {} // mcVersion -> [loader versions]
var loaderFetchSeq = 0
function syncPlatformVisibility() {
if (platformSelect.value === 'vanilla') {
var type = platformSelect.value
if (type === 'fabric') {
downloadField.removeAttribute('hidden')
loaderField.removeAttribute('hidden')
loadFabricLoaders()
} else if (type === 'vanilla') {
downloadField.setAttribute('hidden', '')
loaderField.setAttribute('hidden', '')
downloadField.querySelector('input').value = ''
loaderSelect.innerHTML = '<option value=""></option>'
} else {
downloadField.removeAttribute('hidden')
loaderField.setAttribute('hidden', '')
loaderSelect.innerHTML = '<option value=""></option>'
}
}
function populateLoaderOptions(versions, preselect) {
if (!versions || versions.length === 0) {
loaderSelect.innerHTML = '<option value="">' + I18N.loaderEmpty + '</option>'
return
}
var html = ''
for (var i = 0; i < versions.length; i++) {
var v = versions[i]
var sel = v.version === preselect ? ' selected' : ''
var label = v.version + (v.stable ? '' : ' (beta)')
html += '<option value="' + v.version + '"' + sel + '>' + label + '</option>'
}
loaderSelect.innerHTML = html
// 사용자가 저장해둔 값이 목록에 없으면 첫 번째(최신) 자동 선택.
if (preselect && !versions.some(function (v) { return v.version === preselect })) {
loaderSelect.value = versions[0].version
}
}
function loadFabricLoaders() {
var mc = (mcVersionSelect && mcVersionSelect.value) || ''
if (!mc) {
loaderSelect.innerHTML = '<option value="">' + I18N.loaderPickMc + '</option>'
return
}
if (loaderCache[mc]) {
populateLoaderOptions(loaderCache[mc], currentLoader)
return
}
var seq = ++loaderFetchSeq
loaderSelect.innerHTML = '<option value="">' + I18N.loading + '</option>'
fetch('https://meta.fabricmc.net/v2/versions/loader/' + encodeURIComponent(mc))
.then(function (res) {
if (!res.ok) throw new Error('HTTP ' + res.status)
return res.json()
})
.then(function (list) {
if (seq !== loaderFetchSeq) return // 더 새로운 요청이 들어왔으면 무시
// 응답: [{ loader: { version, stable, ... }, intermediary: {...} }, ...]
var versions = (list || []).map(function (item) {
return { version: item.loader.version, stable: !!item.loader.stable }
})
loaderCache[mc] = versions
populateLoaderOptions(versions, currentLoader)
})
.catch(function (err) {
if (seq !== loaderFetchSeq) return
var msg = (err && err.message) ? err.message : String(err)
loaderSelect.innerHTML = '<option value="">' + formatLoaderLoadFailed(msg) + '</option>'
})
}
platformSelect.addEventListener('change', syncPlatformVisibility)
if (mcVersionSelect) mcVersionSelect.addEventListener('change', function () {
if (platformSelect.value === 'fabric') loadFabricLoaders()
})
syncPlatformVisibility()
var form = document.getElementById('editorForm')
@@ -120,7 +215,12 @@
var clientReco = Number(form.clientRecommendedRam.value)
if (clientMin > clientReco) {
event.preventDefault()
alert('클라이언트 최소 램은 권장 램보다 클 수 없습니다.')
alert(I18N.ramOrderInvalid)
return
}
if (platformSelect.value === 'fabric' && !loaderSelect.value) {
event.preventDefault()
alert(I18N.fabricLoaderRequired)
}
})
})()

42
views/op/list.ejs Normal file
View File

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('list.browserTitle') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/dashboard"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= t('list.title') %></h1>
</div>
</section>
<section class="cardRow horizontalScroll">
<% if (items.length === 0) { %>
<p class="muted"><%= t('site.empty') %></p>
<% } %>
<% items.forEach(function (item) { %>
<article class="packCard">
<a class="cardLink" href="/op/list/<%= item.key %>">
<h2><%= item.definition ? item.definition.name : item.key %></h2>
<p class="muted"><%= item.key %>.json</p>
<% if (item.definition) { %>
<ul class="metaList">
<li><%= t('dashboard.mcShort') %> <%= item.definition.mcVersion %></li>
<li><%= t('site.platform') %> <%= item.definition.platform.type %></li>
<li><%= t('site.modsFolder') %> <%= item.definition.modsFolder || t('site.noneFallback') %></li>
</ul>
<% } %>
</a>
</article>
<% }) %>
</section>
</main>
</body>
</html>

157
views/op/listEditor.ejs Normal file
View File

@@ -0,0 +1,157 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= t('listEditor.browserTitle', { name: pack.name }) %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody">
<%- include('../partials/navbar', { userId }) %>
<main class="pageWrap">
<section class="dashboardHeader">
<div>
<a class="ghostLink" href="/op/list"><%= t('common.back') %></a>
<h1 style="margin-top:20px;"><%= pack.name %></h1>
<p class="muted"><%= packKey %>.json</p>
</div>
<div class="dirtyMark" id="dirty-mark" hidden title="<%= t('listEditor.dirtyTooltip') %>">*</div>
</section>
<div class="tabBar">
<button type="button" class="tabBtn active" data-tab="music"><%= t('listEditor.tabMusic') %></button>
<button type="button" class="tabBtn" data-tab="image"><%= t('listEditor.tabImage') %></button>
</div>
<!-- 음악 탭 -->
<section class="tabPanel" id="tab-music">
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="music"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="music"><%= t('listEditor.clearList') %></button>
<span class="statusText" id="status-music"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="music-playlist-url"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.musicPlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="music"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<ol class="trackList" id="music-list"></ol>
</section>
<!-- 사진 탭 -->
<section class="tabPanel" id="tab-image" hidden>
<div class="listActionsRow">
<button type="button" class="primaryButton" data-action="save" data-target="image"><%= t('listEditor.saveList') %></button>
<button type="button" class="dangerButton" data-action="clear" data-target="image"><%= t('listEditor.clearList') %></button>
<button type="button" class="secondaryButton" id="image-from-music"><%= t('listEditor.imageFromMusic') %></button>
<span class="statusText" id="status-image"></span>
</div>
<div class="playlistRow">
<input type="url" class="textInput" id="image-playlist-url"
placeholder="<%= t('listEditor.playlistPlaceholder') %>"
value="<%= list.imagePlaylistUrl %>" />
<button type="button" class="secondaryButton" data-action="fetch" data-target="image"><%= t('listEditor.fetchPlaylist') %></button>
</div>
<div class="imageGrid" id="image-list"></div>
</section>
</main>
<!-- Context menu -->
<div class="ctxMenu" id="ctxMenu" hidden>
<button type="button" data-ctx="edit"><%= t('common.edit') %></button>
<button type="button" data-ctx="delete"><%= t('common.delete') %></button>
</div>
<!-- Confirm modal -->
<div class="modalOverlay" id="confirmModal" hidden>
<div class="modalCard">
<header><h3 id="confirm-title"><%= t('listEditor.modalConfirmTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<p id="confirm-message"></p>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="confirm-ok"><%= t('common.ok') %></button>
</footer>
</div>
</div>
<!-- Edit modal (music) -->
<div class="modalOverlay" id="editMusicModal" hidden>
<div class="modalCard">
<header><h3><%= t('listEditor.musicEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<label><%= t('listEditor.musicEditUrl') %>
<input type="url" id="edit-music-url" class="textInput" />
</label>
<p class="muted" style="margin-top:6px;font-size:12px;">
<%= t('listEditor.musicEditHint') %>
</p>
<p class="statusText" id="edit-music-status" style="margin-top:4px;"></p>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="edit-music-save"><%= t('common.save') %></button>
</footer>
</div>
</div>
<!-- Alias modal (music) -->
<div class="modalOverlay" id="aliasModal" hidden>
<div class="modalCard">
<header class="aliasModalHeader">
<button type="button" class="ghostLink" id="alias-back"><%= t('listEditor.aliasBack') %></button>
<h3 id="alias-modal-title"></h3>
<span></span>
</header>
<div class="modalBody">
<p class="muted" style="margin:0;font-size:12px;"><%= t('listEditor.aliasHint') %></p>
<div id="alias-rows" class="aliasRowList"></div>
<div>
<button type="button" class="secondaryButton" id="alias-add"><%= t('listEditor.aliasAdd') %></button>
</div>
</div>
</div>
</div>
<!-- Edit modal (image) -->
<div class="modalOverlay" id="editImageModal" hidden>
<div class="modalCard">
<header><h3><%= t('listEditor.imageEditTitle') %></h3>
<button class="modalClose" type="button" data-modal-close><%= t('common.close') %></button>
</header>
<div class="modalBody">
<div class="segmentedRow">
<button type="button" class="segBtn active" data-seg="yt"><%= t('listEditor.imageSegYt') %></button>
<button type="button" class="segBtn" data-seg="img"><%= t('listEditor.imageSegImg') %></button>
</div>
<label><%= t('listEditor.imageEditUrl') %>
<input type="url" id="edit-image-url" class="textInput" />
</label>
</div>
<footer style="display:flex;gap:8px;justify-content:flex-end;">
<button type="button" class="secondaryButton" data-modal-close><%= t('common.cancel') %></button>
<button type="button" class="primaryButton" id="edit-image-save"><%= t('common.save') %></button>
</footer>
</div>
</div>
<script>
var PACK_KEY = <%- JSON.stringify(packKey) %>;
var INITIAL = <%- JSON.stringify(list) %>;
var I18N = <%- JSON.stringify(localeDict.listEditor) %>;
I18N.common = <%- JSON.stringify(localeDict.common) %>;
</script>
<script src="/static/listEditor.js"></script>
</body>
</html>

View File

@@ -3,21 +3,21 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>관리자 로그인</title>
<title><%= t('login.title') %></title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body class="siteBody centerLayout">
<main class="loginCard">
<h1>관리자 로그인</h1>
<h1><%= t('login.title') %></h1>
<% if (error) { %>
<p class="errorBanner"><%= error %></p>
<% } %>
<form method="post" action="/op" class="loginForm">
<label>
<span>비밀번호</span>
<span><%= t('login.password') %></span>
<input name="password" type="password" autocomplete="current-password" required autofocus />
</label>
<button class="primaryButton" type="submit">로그인</button>
<button class="primaryButton" type="submit"><%= t('login.submit') %></button>
</form>
</main>
</body>

View File

@@ -1,13 +1,13 @@
<header class="topNav">
<a class="navBrand" href="/op/dashboard">
<span class="navLogo">🎵</span>
<span class="navTitle">관리자 페이지</span>
<span class="navTitle"><%= t('nav.brand') %></span>
</a>
<div class="navUser">
<button type="button" class="navUserButton" id="userMenuToggle"><%= userId %></button>
<div class="navUserMenu" id="userMenu" hidden>
<form method="post" action="/op/logout">
<button type="submit" class="dangerLink">로그아웃</button>
<button type="submit" class="dangerLink"><%= t('nav.logout') %></button>
</form>
</div>
</div>