loukai-app 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (290) hide show
  1. package/README.md +558 -0
  2. package/bin/loukai.js +32 -0
  3. package/package.json +243 -0
  4. package/src/main/appState.js +250 -0
  5. package/src/main/audioEngine.js +478 -0
  6. package/src/main/creator/conversionService.js +503 -0
  7. package/src/main/creator/downloadManager.js +1128 -0
  8. package/src/main/creator/ffmpegService.js +487 -0
  9. package/src/main/creator/installLogger.js +51 -0
  10. package/src/main/creator/keyDetection.js +212 -0
  11. package/src/main/creator/llmService.js +370 -0
  12. package/src/main/creator/lrclibService.js +340 -0
  13. package/src/main/creator/python/crepe_runner.py +189 -0
  14. package/src/main/creator/python/demucs_runner.py +158 -0
  15. package/src/main/creator/python/whisper_runner.py +172 -0
  16. package/src/main/creator/pythonRunner.js +268 -0
  17. package/src/main/creator/stemBuilder.js +491 -0
  18. package/src/main/creator/systemChecker.js +474 -0
  19. package/src/main/handlers/appHandlers.js +45 -0
  20. package/src/main/handlers/audioHandlers.js +33 -0
  21. package/src/main/handlers/autotuneHandlers.js +28 -0
  22. package/src/main/handlers/canvasHandlers.js +84 -0
  23. package/src/main/handlers/creatorHandlers.js +159 -0
  24. package/src/main/handlers/editorHandlers.js +98 -0
  25. package/src/main/handlers/effectsHandlers.js +100 -0
  26. package/src/main/handlers/fileHandlers.js +45 -0
  27. package/src/main/handlers/index.js +78 -0
  28. package/src/main/handlers/libraryHandlers.js +96 -0
  29. package/src/main/handlers/mixerHandlers.js +64 -0
  30. package/src/main/handlers/playerHandlers.js +39 -0
  31. package/src/main/handlers/preferencesHandlers.js +46 -0
  32. package/src/main/handlers/queueHandlers.js +81 -0
  33. package/src/main/handlers/rendererHandlers.js +63 -0
  34. package/src/main/handlers/settingsHandlers.js +42 -0
  35. package/src/main/handlers/webServerHandlers.js +105 -0
  36. package/src/main/main.js +2351 -0
  37. package/src/main/preload.js +252 -0
  38. package/src/main/settingsManager.js +139 -0
  39. package/src/main/statePersistence.js +193 -0
  40. package/src/main/utils/pathValidator.js +112 -0
  41. package/src/main/webServer.js +2535 -0
  42. package/src/native/autotune.js +417 -0
  43. package/src/renderer/adapters/ElectronBridge.js +677 -0
  44. package/src/renderer/canvas.html +80 -0
  45. package/src/renderer/components/App.jsx +303 -0
  46. package/src/renderer/components/AppRoot.jsx +37 -0
  47. package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
  48. package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
  49. package/src/renderer/components/MixerTab.jsx +233 -0
  50. package/src/renderer/components/MixerTabWrapper.jsx +31 -0
  51. package/src/renderer/components/PortalSelect.jsx +239 -0
  52. package/src/renderer/components/QueueTab.jsx +116 -0
  53. package/src/renderer/components/RequestsListWrapper.jsx +78 -0
  54. package/src/renderer/components/ServerTab.jsx +472 -0
  55. package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
  56. package/src/renderer/components/StatusBar.jsx +92 -0
  57. package/src/renderer/components/TabNavigation.jsx +77 -0
  58. package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
  59. package/src/renderer/components/creator/CreateTab.jsx +1236 -0
  60. package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
  61. package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
  62. package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
  63. package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
  64. package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
  65. package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
  66. package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
  67. package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
  68. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
  69. package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
  70. package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
  71. package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
  72. package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
  73. package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
  74. package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
  75. package/src/renderer/dist/renderer.css +1 -0
  76. package/src/renderer/dist/renderer.js +62 -0
  77. package/src/renderer/dist/renderer.js.map +1 -0
  78. package/src/renderer/dist/renderer.woff2 +0 -0
  79. package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
  80. package/src/renderer/index.html +24 -0
  81. package/src/renderer/index.html.backup +372 -0
  82. package/src/renderer/js/PlayerInterface.js +267 -0
  83. package/src/renderer/js/autoTuneWorklet.js +224 -0
  84. package/src/renderer/js/butterchurnVerify.js +46 -0
  85. package/src/renderer/js/canvas-app.js +114 -0
  86. package/src/renderer/js/cdgPlayer.js +685 -0
  87. package/src/renderer/js/kaiPlayer.js +1200 -0
  88. package/src/renderer/js/karaokeRenderer.js +3392 -0
  89. package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
  90. package/src/renderer/js/microphoneEngine.js +656 -0
  91. package/src/renderer/js/musicAnalysisWorklet.js +216 -0
  92. package/src/renderer/js/phaseVocoderWorklet.js +341 -0
  93. package/src/renderer/js/player.js +232 -0
  94. package/src/renderer/js/referencePitchTracker.js +130 -0
  95. package/src/renderer/js/songLoaders.js +334 -0
  96. package/src/renderer/js/soundtouch-worklet.js +1395 -0
  97. package/src/renderer/js/webrtcManager.js +511 -0
  98. package/src/renderer/lib/butterchurn.min.js +6739 -0
  99. package/src/renderer/lib/butterchurnPresets.min.js +1 -0
  100. package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
  101. package/src/renderer/lib/cdgraphics.js +299 -0
  102. package/src/renderer/public/js/autoTuneWorklet.js +224 -0
  103. package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
  104. package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
  105. package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
  106. package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
  107. package/src/renderer/react-entry.jsx +44 -0
  108. package/src/renderer/styles/tailwind.css +106 -0
  109. package/src/renderer/utils/qrCodeGenerator.js +98 -0
  110. package/src/renderer/vite.config.js +31 -0
  111. package/src/shared/adapters/BridgeInterface.js +195 -0
  112. package/src/shared/components/EffectsPanel.jsx +177 -0
  113. package/src/shared/components/LibraryPanel.jsx +701 -0
  114. package/src/shared/components/LineDetailCanvas.jsx +167 -0
  115. package/src/shared/components/LyricLine.jsx +505 -0
  116. package/src/shared/components/LyricRejection.jsx +84 -0
  117. package/src/shared/components/LyricSuggestion.jsx +80 -0
  118. package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
  119. package/src/shared/components/MixerPanel.jsx +94 -0
  120. package/src/shared/components/PlayerControls.jsx +206 -0
  121. package/src/shared/components/PortalSelect.jsx +239 -0
  122. package/src/shared/components/QueueList.jsx +365 -0
  123. package/src/shared/components/QuickSearch.jsx +126 -0
  124. package/src/shared/components/RequestsList.jsx +121 -0
  125. package/src/shared/components/SongEditor.jsx +1362 -0
  126. package/src/shared/components/SongInfoBar.jsx +81 -0
  127. package/src/shared/components/ThemeToggle.jsx +106 -0
  128. package/src/shared/components/Toast.jsx +30 -0
  129. package/src/shared/components/VisualizationSettings.jsx +243 -0
  130. package/src/shared/constants.js +95 -0
  131. package/src/shared/context/BridgeContext.jsx +32 -0
  132. package/src/shared/contexts/AudioContext.jsx +37 -0
  133. package/src/shared/contexts/PlayerContext.jsx +66 -0
  134. package/src/shared/contexts/SettingsContext.jsx +50 -0
  135. package/src/shared/defaults.js +158 -0
  136. package/src/shared/formatUtils.js +59 -0
  137. package/src/shared/formatUtils.test.js +207 -0
  138. package/src/shared/hooks/useAppState.js +97 -0
  139. package/src/shared/hooks/useAudioEngine.js +264 -0
  140. package/src/shared/hooks/usePlayer.js +89 -0
  141. package/src/shared/hooks/useSettingsPersistence.js +74 -0
  142. package/src/shared/hooks/useWebRTC.js +118 -0
  143. package/src/shared/ipcContracts.js +299 -0
  144. package/src/shared/package.json +3 -0
  145. package/src/shared/services/creatorService.js +373 -0
  146. package/src/shared/services/creatorService.test.js +413 -0
  147. package/src/shared/services/editorService.js +213 -0
  148. package/src/shared/services/editorService.test.js +219 -0
  149. package/src/shared/services/effectsService.js +271 -0
  150. package/src/shared/services/effectsService.test.js +418 -0
  151. package/src/shared/services/libraryService.js +438 -0
  152. package/src/shared/services/libraryService.test.js +474 -0
  153. package/src/shared/services/mixerService.js +172 -0
  154. package/src/shared/services/mixerService.test.js +399 -0
  155. package/src/shared/services/playerService.js +221 -0
  156. package/src/shared/services/playerService.test.js +357 -0
  157. package/src/shared/services/preferencesService.js +219 -0
  158. package/src/shared/services/queueService.js +226 -0
  159. package/src/shared/services/queueService.test.js +430 -0
  160. package/src/shared/services/requestsService.js +155 -0
  161. package/src/shared/services/requestsService.test.js +362 -0
  162. package/src/shared/services/serverSettingsService.js +151 -0
  163. package/src/shared/services/settingsService.js +257 -0
  164. package/src/shared/services/settingsService.test.js +295 -0
  165. package/src/shared/state/StateManager.js +263 -0
  166. package/src/shared/utils/audio.js +42 -0
  167. package/src/shared/utils/format.js +32 -0
  168. package/src/shared/utils/lyricsUtils.js +162 -0
  169. package/src/test/setup.js +40 -0
  170. package/src/utils/cdgLoader.js +180 -0
  171. package/src/utils/m4aLoader.js +333 -0
  172. package/src/web/App.jsx +578 -0
  173. package/src/web/adapters/WebBridge.js +428 -0
  174. package/src/web/components/PlayerSettingsPanel.jsx +231 -0
  175. package/src/web/components/SongSearch.jsx +180 -0
  176. package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
  177. package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
  178. package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
  179. package/src/web/dist/index.html +15 -0
  180. package/src/web/index.html +14 -0
  181. package/src/web/main.jsx +10 -0
  182. package/src/web/package-lock.json +1765 -0
  183. package/src/web/pages/SongRequestPage.jsx +619 -0
  184. package/src/web/styles/tailwind.css +68 -0
  185. package/src/web/vite.config.js +27 -0
  186. package/static/fonts/material-icons.woff2 +0 -0
  187. package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
  188. package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
  189. package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
  190. package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
  191. package/static/images/butterchurn-screenshots/An AdamFX n Martin Infusion 2 flexi - Why The Sky Looks Diffrent Today - AdamFx n Martin Infusion - Tack Tile Disfunction B.png +0 -0
  192. package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
  193. proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
  194. package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
  195. package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
  196. package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
  197. package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
  198. package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
  199. package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
  200. package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
  201. package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
  202. package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
  203. package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
  204. package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
  205. package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
  206. package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
  207. package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
  208. package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
  209. package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
  210. package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
  211. package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
  212. package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
  213. package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
  214. package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
  215. package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
  216. package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
  217. package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
  218. package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
  219. package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
  220. package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
  221. package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
  222. package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
  223. package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
  224. package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
  225. package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
  226. package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
  227. package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
  228. package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
  229. package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
  230. package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
  231. package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
  232. package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
  233. package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
  234. package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
  235. package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
  236. package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
  237. package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
  238. package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
  239. package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
  240. package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
  241. package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
  242. package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
  243. package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
  244. package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
  245. package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
  246. package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
  247. package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
  248. package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
  249. package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
  250. package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
  251. package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
  252. package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
  253. package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
  254. package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
  255. package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
  256. package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
  257. package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
  258. package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
  259. package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
  260. package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
  261. package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
  262. package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
  263. package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
  264. package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
  265. package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
  266. package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
  267. package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
  268. package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
  269. package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
  270. package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
  271. package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
  272. package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
  273. package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
  274. package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
  275. package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
  276. package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
  277. package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
  278. package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
  279. package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
  280. package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
  281. package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
  282. package/static/images/butterchurn-screenshots/suksma - Rovastar - Sunflower Passion _Enlightment Mix__Phat_edit _ flexi und martin shaders - circumflex in character classes in regular expression.png +0 -0
  283. package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
  284. package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
  285. package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
  286. package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
  287. package/static/images/logo-512.png +0 -0
  288. package/static/images/logo.png +0 -0
  289. package/static/loukai-logo.png +0 -0
  290. package/static/screenshot-generator.html +610 -0
@@ -0,0 +1,2535 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import path, { dirname } from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import fs from 'fs';
6
+ import os from 'os';
7
+ import crypto from 'crypto';
8
+ import bcrypt from 'bcryptjs';
9
+ import cookieSession from 'cookie-session';
10
+ import { Server } from 'socket.io';
11
+ import http from 'http';
12
+ import rateLimit from 'express-rate-limit';
13
+ import Fuse from 'fuse.js';
14
+ import * as queueService from '../shared/services/queueService.js';
15
+ import * as libraryService from '../shared/services/libraryService.js';
16
+ import * as playerService from '../shared/services/playerService.js';
17
+ import * as preferencesService from '../shared/services/preferencesService.js';
18
+ import * as effectsService from '../shared/services/effectsService.js';
19
+ import * as mixerService from '../shared/services/mixerService.js';
20
+ import * as requestsService from '../shared/services/requestsService.js';
21
+ import { SERVER_DEFAULTS, WAVEFORM_DEFAULTS, AUTOTUNE_DEFAULTS } from '../shared/defaults.js';
22
+ import { getSetting } from '../shared/services/settingsService.js';
23
+ import * as serverSettingsService from '../shared/services/serverSettingsService.js';
24
+ import * as creatorService from '../shared/services/creatorService.js';
25
+ import { validateSongPath, validateBase64Path } from './utils/pathValidator.js';
26
+
27
+ // ESM equivalent of __dirname
28
+ const __filename = fileURLToPath(import.meta.url);
29
+ const __dirname = dirname(__filename);
30
+
31
+ class WebServer {
32
+ constructor(mainApp) {
33
+ this.mainApp = mainApp;
34
+ this.app = express();
35
+ this.httpServer = null;
36
+ this.io = null;
37
+ this.port = 3069;
38
+ this.songRequests = [];
39
+ // Use unified defaults from shared/defaults.js
40
+ this.defaultSettings = { ...SERVER_DEFAULTS };
41
+
42
+ // Settings will be loaded after initialization in start() method
43
+ this.settings = { ...SERVER_DEFAULTS };
44
+
45
+ // Fuzzy search instance - will be initialized when songs are loaded
46
+ this.fuse = null;
47
+
48
+ // Songs cache to avoid scanning directory on every request
49
+ this.cachedSongs = null;
50
+ this.songsCacheTime = null;
51
+
52
+ // Maps for opaque song IDs (security: don't expose file paths)
53
+ this.songPathToId = new Map();
54
+ this.songIdToPath = new Map();
55
+
56
+ this.setupMiddleware();
57
+ this.setupRoutes();
58
+ }
59
+
60
+ /**
61
+ * Generate an opaque ID for a song path (security: don't expose file paths)
62
+ * Uses a deterministic hash so the same path always gets the same ID
63
+ */
64
+ generateSongId(songPath) {
65
+ if (!songPath) return null;
66
+
67
+ // Check cache first
68
+ if (this.songPathToId.has(songPath)) {
69
+ return this.songPathToId.get(songPath);
70
+ }
71
+
72
+ // Create a short, URL-safe hash
73
+ const hash = crypto.createHash('sha256').update(songPath).digest('base64url').slice(0, 16);
74
+ const id = `song_${hash}`;
75
+
76
+ // Cache both directions
77
+ this.songPathToId.set(songPath, id);
78
+ this.songIdToPath.set(id, songPath);
79
+
80
+ return id;
81
+ }
82
+
83
+ /**
84
+ * Look up a song path from an opaque ID
85
+ */
86
+ getSongPathFromId(songId) {
87
+ return this.songIdToPath.get(songId) || null;
88
+ }
89
+
90
+ /**
91
+ * Sanitize a song object for public API responses
92
+ * Removes file system paths and other sensitive information
93
+ */
94
+ sanitizeSongForPublic(song) {
95
+ if (!song) return null;
96
+
97
+ const id = this.generateSongId(song.path);
98
+
99
+ return {
100
+ id,
101
+ title: song.title || 'Unknown Title',
102
+ artist: song.artist || 'Unknown Artist',
103
+ duration: song.duration || null,
104
+ format: song.format || 'kai',
105
+ album: song.album || null,
106
+ year: song.year || null,
107
+ genre: song.genre || null,
108
+ // Explicitly exclude: path, originalFilePath, any file system info
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Sanitize the queue for public API responses
114
+ */
115
+ sanitizeQueueForPublic(queue) {
116
+ if (!Array.isArray(queue)) return [];
117
+
118
+ return queue.map(item => ({
119
+ position: item.position,
120
+ singerName: item.singerName,
121
+ song: item.song ? this.sanitizeSongForPublic(item.song) : null,
122
+ status: item.status,
123
+ requestedAt: item.requestedAt,
124
+ // Exclude: path, any file system info
125
+ }));
126
+ }
127
+
128
+ setupMiddleware() {
129
+ // CORS configuration - restrict to localhost and LAN origins
130
+ // Prevents malicious websites from making cross-origin requests
131
+ this.app.use(cors({
132
+ origin: (origin, callback) => {
133
+ // Allow requests with no origin (same-origin, non-browser clients, curl, etc.)
134
+ if (!origin) {
135
+ return callback(null, true);
136
+ }
137
+
138
+ if (this.isAllowedOrigin(origin)) {
139
+ return callback(null, true);
140
+ }
141
+
142
+ // Reject other origins
143
+ callback(new Error('CORS not allowed for this origin'));
144
+ },
145
+ credentials: true,
146
+ methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
147
+ }));
148
+ this.app.use(express.json());
149
+ this.app.use(express.urlencoded({ extended: true }));
150
+
151
+ // Encrypted cookie-based sessions (persists across server restarts)
152
+ this.app.use(
153
+ cookieSession({
154
+ name: 'kai-admin-session',
155
+ keys: [this.getOrCreateSecretKey()], // Encryption key
156
+ maxAge: 24 * 60 * 60 * 1000, // 24 hours
157
+ httpOnly: true,
158
+ secure: false, // Set to true in production with HTTPS
159
+ sameSite: 'strict',
160
+ })
161
+ );
162
+
163
+ // Serve static files (shared between main app and web interface)
164
+ this.app.use('/static', express.static(path.join(__dirname, '../../static')));
165
+
166
+ // Serve Butterchurn libraries for the screenshot generator (both root and admin paths)
167
+ this.app.use('/lib', express.static(path.join(__dirname, '../renderer/lib')));
168
+ this.app.use('/admin/lib', express.static(path.join(__dirname, '../renderer/lib')));
169
+
170
+ // Serve Butterchurn effect screenshots
171
+ this.app.use(
172
+ '/screenshots',
173
+ express.static(path.join(__dirname, '../../static/images/butterchurn-screenshots'))
174
+ );
175
+
176
+ // Serve React web UI build (production)
177
+ const webDistPath = path.join(__dirname, '../web/dist');
178
+ if (fs.existsSync(webDistPath)) {
179
+ this.app.use('/admin', express.static(webDistPath));
180
+ }
181
+
182
+ // Rate limiting middleware - store clientIP for request tracking
183
+ this.app.use((req, res, next) => {
184
+ req.clientIP = req.ip || req.connection.remoteAddress;
185
+ next();
186
+ });
187
+
188
+ // Rate limiters
189
+ this.loginLimiter = rateLimit({
190
+ windowMs: 15 * 60 * 1000, // 15 minutes
191
+ max: 5, // Limit each IP to 5 login requests per windowMs
192
+ message: 'Too many login attempts, please try again after 15 minutes',
193
+ standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
194
+ legacyHeaders: false, // Disable the `X-RateLimit-*` headers
195
+ skipSuccessfulRequests: true, // Don't count successful logins
196
+ });
197
+
198
+ this.apiLimiter = rateLimit({
199
+ windowMs: 1 * 60 * 1000, // 1 minute
200
+ max: 20, // Limit each IP to 20 API requests per minute
201
+ message: 'Too many requests, please slow down',
202
+ standardHeaders: true,
203
+ legacyHeaders: false,
204
+ // Only apply to /api/request (song requests), not all API endpoints
205
+ skip: (req) => !req.path.startsWith('/api/request'),
206
+ });
207
+
208
+ // Rate limiter for admin API endpoints (fixes #27)
209
+ this.adminApiLimiter = rateLimit({
210
+ windowMs: 1 * 60 * 1000, // 1 minute
211
+ max: 60, // Limit each IP to 60 admin API requests per minute
212
+ message: 'Too many admin API requests, please slow down',
213
+ standardHeaders: true,
214
+ legacyHeaders: false,
215
+ // Skip static file requests and login (has its own limiter)
216
+ skip: (req) =>
217
+ req.path === '/login' ||
218
+ (req.method === 'GET' && /\.(js|css|html|png|jpg|svg|ico|woff2?)$/i.test(req.path)),
219
+ });
220
+
221
+ // Apply admin rate limiter to all /admin/* routes
222
+ this.app.use('/admin', this.adminApiLimiter);
223
+ }
224
+
225
+ setupRoutes() {
226
+ // Main song request page (React app - public)
227
+ this.app.get('/', (req, res) => {
228
+ const webDistPath = path.join(__dirname, '../web/dist');
229
+ const indexPath = path.join(webDistPath, 'index.html');
230
+ if (fs.existsSync(indexPath)) {
231
+ res.sendFile(indexPath);
232
+ } else {
233
+ res.status(404).send('Web UI not built. Run: npm run build:web');
234
+ }
235
+ });
236
+
237
+ // Check if admin password is set
238
+ this.app.get('/admin/check-auth', (req, res) => {
239
+ try {
240
+ const passwordHash = this.mainApp.settings?.get('server.adminPasswordHash');
241
+ res.json({
242
+ passwordSet: Boolean(passwordHash),
243
+ authenticated: Boolean(req.session.isAdmin),
244
+ });
245
+ } catch (error) {
246
+ console.error('Error checking auth:', error);
247
+ res.status(500).json({ error: 'Server error' });
248
+ }
249
+ });
250
+
251
+ // Admin login endpoint (with rate limiting)
252
+ this.app.post('/admin/login', this.loginLimiter, async (req, res) => {
253
+ try {
254
+ const { password } = req.body;
255
+
256
+ if (!password) {
257
+ return res.status(400).json({ error: 'Password required' });
258
+ }
259
+
260
+ const passwordHash = this.mainApp.settings?.get('server.adminPasswordHash');
261
+
262
+ if (!passwordHash) {
263
+ return res.status(403).json({ error: 'No admin password set' });
264
+ }
265
+
266
+ const isValid = await bcrypt.compare(password, passwordHash);
267
+
268
+ if (isValid) {
269
+ // Set session data (automatically encrypted by cookie-session)
270
+ req.session.isAdmin = true;
271
+ req.session.loginTime = Date.now();
272
+
273
+ res.json({ success: true, message: 'Login successful' });
274
+ } else {
275
+ res.status(401).json({ error: 'Invalid password' });
276
+ }
277
+ } catch (error) {
278
+ console.error('Error during login:', error);
279
+ res.status(500).json({ error: 'Server error' });
280
+ }
281
+ });
282
+
283
+ // Admin logout endpoint
284
+ this.app.post('/admin/logout', (req, res) => {
285
+ // Clear session data (cookie-session handles the rest)
286
+ req.session = null;
287
+ res.json({ success: true, message: 'Logged out successfully' });
288
+ });
289
+
290
+ // Auth middleware - require login for protected admin endpoints
291
+ const requireAuth = (req, res, next) => {
292
+ if (req.session && req.session.isAdmin) {
293
+ next(); // User is authenticated
294
+ } else {
295
+ res.status(401).json({
296
+ error: 'Unauthorized',
297
+ message: 'Please login to access admin features',
298
+ });
299
+ }
300
+ };
301
+
302
+ // Apply auth middleware to all /admin/* routes except login/logout/check-auth
303
+ // Express 5: Use regex pattern instead of wildcard
304
+ this.app.use(/^\/admin\/.*/, (req, res, next) => {
305
+ const openRoutes = ['/admin/login', '/admin/logout', '/admin/check-auth'];
306
+ if (openRoutes.includes(req.path)) {
307
+ next(); // Allow these routes without auth
308
+ } else {
309
+ requireAuth(req, res, next); // Require auth for everything else
310
+ }
311
+ });
312
+
313
+ // Get available letters for alphabet navigation
314
+ this.app.get('/api/letters', async (req, res) => {
315
+ try {
316
+ console.log('API: Getting available letters...');
317
+
318
+ // Get songs from cache
319
+ const allSongs = await this.getCachedSongs();
320
+ console.log(`API: Found ${allSongs.length} songs`);
321
+
322
+ // Group by first letter of artist
323
+ const letterCounts = {};
324
+ allSongs.forEach((song) => {
325
+ const artist = song.artist || 'Unknown Artist';
326
+ const firstChar = artist.charAt(0).toUpperCase();
327
+ let letter = firstChar;
328
+
329
+ // Group numbers and special characters
330
+ if (!/[A-Z]/.test(firstChar)) {
331
+ letter = '#';
332
+ }
333
+
334
+ letterCounts[letter] = (letterCounts[letter] || 0) + 1;
335
+ });
336
+
337
+ // Return available letters with counts
338
+ res.json({
339
+ letters: Object.keys(letterCounts).sort((a, b) => {
340
+ if (a === '#') return 1; // # goes last
341
+ if (b === '#') return -1; // # goes last
342
+ return a.localeCompare(b);
343
+ }),
344
+ counts: letterCounts,
345
+ });
346
+ } catch (error) {
347
+ console.error('Error fetching letters:', error);
348
+ res.status(500).json({ error: 'Failed to fetch letters' });
349
+ }
350
+ });
351
+
352
+ // Get paginated songs for a specific letter
353
+ this.app.get('/api/songs/letter/:letter', async (req, res) => {
354
+ try {
355
+ const letter = req.params.letter;
356
+ const page = parseInt(req.query.page) || 1;
357
+ const limit = parseInt(req.query.limit) || 100;
358
+
359
+ console.log(`API: Getting songs for letter ${letter}, page ${page}, limit ${limit}`);
360
+
361
+ // Get songs from cache
362
+ const allSongs = await this.getCachedSongs();
363
+
364
+ // Filter songs by first letter of artist
365
+ const letterSongs = allSongs.filter((song) => {
366
+ const artist = song.artist || 'Unknown Artist';
367
+ const firstChar = artist.charAt(0).toUpperCase();
368
+ const songLetter = /[A-Z]/.test(firstChar) ? firstChar : '#';
369
+ return songLetter === letter;
370
+ });
371
+
372
+ // Sort by artist, then title
373
+ letterSongs.sort((a, b) => {
374
+ const artistCompare = a.artist.localeCompare(b.artist);
375
+ if (artistCompare !== 0) return artistCompare;
376
+ return a.title.localeCompare(b.title);
377
+ });
378
+
379
+ // Paginate
380
+ const totalSongs = letterSongs.length;
381
+ const totalPages = Math.ceil(totalSongs / limit);
382
+ const startIndex = (page - 1) * limit;
383
+ const endIndex = startIndex + limit;
384
+ const pageSongs = letterSongs.slice(startIndex, endIndex);
385
+
386
+ const response = {
387
+ songs: pageSongs.map((song) => this.sanitizeSongForPublic(song)),
388
+ pagination: {
389
+ currentPage: page,
390
+ totalPages,
391
+ totalSongs,
392
+ songsPerPage: limit,
393
+ hasNextPage: page < totalPages,
394
+ hasPreviousPage: page > 1,
395
+ },
396
+ };
397
+
398
+ res.json(response);
399
+ } catch (error) {
400
+ console.error('Error fetching songs for letter:', error);
401
+ res.status(500).json({ error: 'Failed to fetch songs' });
402
+ }
403
+ });
404
+
405
+ // Get available songs for the request interface (search functionality)
406
+ this.app.get('/api/songs', async (req, res) => {
407
+ try {
408
+ const search = req.query.search || '';
409
+ const limit = parseInt(req.query.limit) || 50;
410
+
411
+ console.log('API: Getting songs from cache...');
412
+
413
+ // Get songs from cache
414
+ const allSongs = await this.getCachedSongs();
415
+
416
+ console.log(`API: Found ${allSongs.length} songs`);
417
+
418
+ let songs = allSongs;
419
+
420
+ if (search) {
421
+ // Initialize or update Fuse.js if not already done or songs changed
422
+ if (!this.fuse || this.fuse._docs.length !== allSongs.length) {
423
+ this.fuse = new Fuse(allSongs, {
424
+ keys: ['title', 'artist', 'album'],
425
+ threshold: 0.3, // 0 = exact match, 1 = match anything
426
+ includeScore: true,
427
+ ignoreLocation: true,
428
+ findAllMatches: true,
429
+ });
430
+ }
431
+
432
+ // Use fuzzy search
433
+ const fuseResults = this.fuse.search(search);
434
+ songs = fuseResults.map((result) => result.item);
435
+ } else {
436
+ // Sort alphabetically by title when no search
437
+ songs = allSongs.sort((a, b) => a.title.localeCompare(b.title));
438
+ }
439
+
440
+ const limitedSongs = songs.slice(0, limit).map((song) => this.sanitizeSongForPublic(song));
441
+
442
+ res.json({
443
+ songs: limitedSongs,
444
+ total: songs.length,
445
+ hasMore: songs.length > limit,
446
+ });
447
+ } catch (error) {
448
+ console.error('Error fetching songs:', error);
449
+ res.status(500).json({ error: 'Failed to fetch songs' });
450
+ }
451
+ });
452
+
453
+ // Quick search endpoint (public)
454
+ this.app.get('/api/search', async (req, res) => {
455
+ try {
456
+ const query = req.query.q || '';
457
+ const limit = parseInt(req.query.limit) || 20;
458
+
459
+ if (!query.trim()) {
460
+ return res.json({ results: [] });
461
+ }
462
+
463
+ // Get songs from cache
464
+ const allSongs = await this.getCachedSongs();
465
+
466
+ // Initialize or update Fuse.js if needed
467
+ if (!this.fuse || this.fuse._docs.length !== allSongs.length) {
468
+ this.fuse = new Fuse(allSongs, {
469
+ keys: ['title', 'artist', 'album'],
470
+ threshold: 0.3,
471
+ includeScore: true,
472
+ ignoreLocation: true,
473
+ findAllMatches: true,
474
+ });
475
+ }
476
+
477
+ // Use fuzzy search
478
+ const fuseResults = this.fuse.search(query);
479
+ const results = fuseResults.slice(0, limit).map((result) => this.sanitizeSongForPublic(result.item));
480
+
481
+ res.json({ results });
482
+ } catch (error) {
483
+ console.error('Search failed:', error);
484
+ res.status(500).json({ error: 'Search failed', results: [] });
485
+ }
486
+ });
487
+
488
+ // Submit song request (with rate limiting)
489
+ this.app.post('/api/request', this.apiLimiter, async (req, res) => {
490
+ try {
491
+ console.log('🎤 NEW REQUEST received:', req.body);
492
+
493
+ if (!this.settings.allowSongRequests) {
494
+ console.log('❌ REQUEST DENIED: requests disabled');
495
+ return res.status(403).json({ error: 'Song requests are currently disabled' });
496
+ }
497
+
498
+ const { songId, requesterName, message } = req.body;
499
+
500
+ if (!songId || !requesterName) {
501
+ console.log('❌ REQUEST DENIED: missing required fields', {
502
+ songId: Boolean(songId),
503
+ requesterName: Boolean(requesterName),
504
+ });
505
+ return res.status(400).json({ error: 'Song ID and requester name are required' });
506
+ }
507
+
508
+ // Find the song in the library
509
+ // Support both opaque IDs (new) and paths (legacy/admin)
510
+ console.log('🔍 Looking for song with ID:', songId);
511
+ const allSongs = await this.getCachedSongs();
512
+ console.log('📚 Found library with', allSongs.length, 'songs');
513
+
514
+ // Try to find by opaque ID first, then fall back to path (for backwards compatibility)
515
+ const songPath = this.getSongPathFromId(songId);
516
+ const song = songPath
517
+ ? allSongs.find((s) => s.path === songPath)
518
+ : allSongs.find((s) => s.path === songId);
519
+
520
+ if (!song) {
521
+ console.log('❌ SONG NOT FOUND in library:', songId);
522
+ return res.status(404).json({ error: 'Song not found' });
523
+ }
524
+
525
+ console.log('✅ Song found:', song.title, 'by', song.artist);
526
+
527
+ // Generate opaque ID for the song if not already cached
528
+ const opaqueSongId = this.generateSongId(song.path);
529
+
530
+ const request = {
531
+ id: Date.now() + Math.random(),
532
+ songId: opaqueSongId, // Store opaque ID, not path
533
+ song: {
534
+ title: song.title,
535
+ artist: song.artist,
536
+ path: song.path, // Keep path internally for playback
537
+ },
538
+ requesterName: requesterName.trim().substring(0, 50),
539
+ message: message ? message.trim().substring(0, 200) : '',
540
+ timestamp: new Date(),
541
+ status: this.settings.requireKJApproval ? 'pending' : 'approved',
542
+ clientIP: req.clientIP,
543
+ };
544
+
545
+ console.log('📝 Created request object:', request);
546
+ this.songRequests.push(request);
547
+ console.log('📋 Request added to list, total requests:', this.songRequests.length);
548
+
549
+ // If auto-approval is enabled, add to queue immediately
550
+ if (!this.settings.requireKJApproval) {
551
+ console.log('⚡ Auto-approval enabled, adding to queue...');
552
+ try {
553
+ await this.addToQueue(request);
554
+ request.status = 'queued';
555
+ console.log('✅ Successfully added to queue');
556
+ } catch (queueError) {
557
+ console.error('❌ Failed to add to queue:', queueError);
558
+ throw queueError;
559
+ }
560
+ } else {
561
+ console.log('⏳ Manual approval required, request pending');
562
+ }
563
+
564
+ // Notify the main app about the new request
565
+ console.log('📢 Notifying main app about new request...');
566
+ this.mainApp.onSongRequest?.(request);
567
+
568
+ // Broadcast to admin clients and renderer
569
+ this.io.to('admin-clients').emit('song-request', request);
570
+ this.io.to('electron-apps').emit('song-request', request);
571
+ console.log('📡 Broadcasted request to admin and renderer');
572
+
573
+ const responseData = {
574
+ success: true,
575
+ message: this.settings.requireKJApproval
576
+ ? 'Request submitted! Waiting for KJ approval.'
577
+ : 'Song added to queue!',
578
+ requestId: request.id,
579
+ status: request.status,
580
+ };
581
+
582
+ console.log('📤 Sending success response:', responseData);
583
+ res.json(responseData);
584
+ } catch (error) {
585
+ console.error('❌ ERROR processing request:', error);
586
+ res.status(500).json({ error: 'Failed to process request' });
587
+ }
588
+ });
589
+
590
+ // Get queue status for users - using shared queueService
591
+ this.app.get('/api/queue', (req, res) => {
592
+ try {
593
+ const result = queueService.getQueueInfo(this.mainApp.appState);
594
+ const state = this.mainApp.appState.getSnapshot();
595
+
596
+ res.json({
597
+ queue: result.queue,
598
+ currentlyPlaying: result.currentSong,
599
+ playback: state.playback,
600
+ total: result.total,
601
+ });
602
+ } catch (error) {
603
+ console.error('Error fetching queue:', error);
604
+ res.status(500).json({ error: 'Failed to fetch queue' });
605
+ }
606
+ });
607
+
608
+ // Admin endpoints (for the main Electron app) - all require auth via middleware above
609
+ this.app.get('/admin/requests', (req, res) => {
610
+ const result = requestsService.getRequests(this);
611
+ if (result.success) {
612
+ res.json({
613
+ requests: result.requests,
614
+ settings: result.settings,
615
+ });
616
+ } else {
617
+ res.status(500).json(result);
618
+ }
619
+ });
620
+
621
+ this.app.post('/admin/requests/:id/approve', async (req, res) => {
622
+ const requestId = parseFloat(req.params.id);
623
+ const result = await requestsService.approveRequest(this, requestId);
624
+
625
+ if (result.success) {
626
+ res.json(result);
627
+ } else {
628
+ const status = result.error === 'Request not found' ? 404 : 400;
629
+ res.status(status).json(result);
630
+ }
631
+ });
632
+
633
+ this.app.post('/admin/requests/:id/reject', async (req, res) => {
634
+ const requestId = parseFloat(req.params.id);
635
+ const result = await requestsService.rejectRequest(this, requestId);
636
+
637
+ if (result.success) {
638
+ res.json(result);
639
+ } else {
640
+ const status = result.error === 'Request not found' ? 404 : 400;
641
+ res.status(status).json(result);
642
+ }
643
+ });
644
+
645
+ this.app.post('/admin/settings', (req, res) => {
646
+ const result = serverSettingsService.updateServerSettings(this, req.body);
647
+ if (result.success) {
648
+ res.json(result);
649
+ } else {
650
+ res.status(500).json(result);
651
+ }
652
+ });
653
+
654
+ // Screenshot generator utility (admin only - no linking from user interface)
655
+ this.app.get('/admin/screenshot-generator', (req, res) => {
656
+ res.sendFile(path.join(__dirname, '../../static/screenshot-generator.html'));
657
+ });
658
+
659
+ // Butterchurn screenshot API - case insensitive filename matching
660
+ this.app.get('/api/butterchurn-screenshot/:presetName', (req, res) => {
661
+ const presetName = decodeURIComponent(req.params.presetName);
662
+
663
+ console.log(`Screenshot API request for: "${presetName}"`);
664
+
665
+ // Sanitize preset name same way as screenshot generator
666
+ const sanitizedName = presetName.replace(/[^a-zA-Z0-9-_\s]/g, '_') + '.png';
667
+ console.log(`Sanitized filename: "${sanitizedName}"`);
668
+
669
+ const screenshotsDir = path.join(__dirname, '../../static/images/butterchurn-screenshots');
670
+
671
+ try {
672
+ // First try exact match
673
+ const exactPath = path.join(screenshotsDir, sanitizedName);
674
+ if (fs.existsSync(exactPath)) {
675
+ return res.sendFile(exactPath);
676
+ }
677
+
678
+ // If exact match fails, try case-insensitive search
679
+ const files = fs.readdirSync(screenshotsDir);
680
+ const matchingFile = files.find(
681
+ (file) => file.toLowerCase() === sanitizedName.toLowerCase()
682
+ );
683
+
684
+ if (matchingFile) {
685
+ const matchedPath = path.join(screenshotsDir, matchingFile);
686
+ return res.sendFile(matchedPath);
687
+ }
688
+
689
+ // No match found
690
+ res.status(404).send('Screenshot not found');
691
+ } catch (error) {
692
+ console.error('Error serving screenshot:', error);
693
+ res.status(500).send('Server error');
694
+ }
695
+ });
696
+
697
+ // Server info endpoint
698
+ this.app.get('/api/info', (req, res) => {
699
+ res.json({
700
+ serverName: this.settings.serverName,
701
+ allowRequests: this.settings.allowSongRequests,
702
+ requireApproval: this.settings.requireKJApproval,
703
+ });
704
+ });
705
+
706
+ // Unified state endpoint - canonical source of truth for web clients
707
+ // Public state endpoint - returns sanitized state only (no file paths, no sensitive config)
708
+ this.app.get('/api/state', (req, res) => {
709
+ try {
710
+ const state = this.mainApp.appState.getSnapshot();
711
+
712
+ // Sanitize for public consumption - only include safe info
713
+ const sanitizedState = {
714
+ // Current playback status (no paths)
715
+ currentSong: state.currentSong ? {
716
+ title: state.currentSong.title,
717
+ artist: state.currentSong.artist,
718
+ duration: state.currentSong.duration,
719
+ requester: state.currentSong.requester,
720
+ } : null,
721
+ playback: {
722
+ isPlaying: state.playback?.isPlaying || false,
723
+ position: state.playback?.position || 0,
724
+ duration: state.playback?.duration || 0,
725
+ },
726
+ // Queue info (sanitized - no paths)
727
+ queue: (state.queue || []).map(item => ({
728
+ id: item.id,
729
+ title: item.title,
730
+ artist: item.artist,
731
+ duration: item.duration,
732
+ requester: item.requester,
733
+ })),
734
+ // Server info (safe subset)
735
+ serverInfo: {
736
+ serverName: this.settings.serverName,
737
+ allowRequests: this.settings.allowSongRequests,
738
+ },
739
+ // Exclude: mixer, effects, preferences, webServer config, paths, etc.
740
+ };
741
+
742
+ res.json(sanitizedState);
743
+ } catch (error) {
744
+ console.error('Error fetching app state:', error);
745
+ res.status(500).json({ error: 'Failed to fetch state' });
746
+ }
747
+ });
748
+
749
+ // Full state endpoint for admin - includes everything (behind auth via /admin/ prefix)
750
+ this.app.get('/admin/state-full', (req, res) => {
751
+ try {
752
+ const state = this.mainApp.appState.getSnapshot();
753
+ res.json(state);
754
+ } catch (error) {
755
+ console.error('Error fetching full app state:', error);
756
+ res.status(500).json({ error: 'Failed to fetch state' });
757
+ }
758
+ });
759
+
760
+ // Admin queue management endpoints - using shared queueService
761
+ this.app.get('/admin/queue', (req, res) => {
762
+ try {
763
+ const result = queueService.getQueue(this.mainApp.appState);
764
+ const state = this.mainApp.appState.getSnapshot();
765
+ res.json({
766
+ success: result.success,
767
+ queue: result.queue,
768
+ currentSong: state.currentSong,
769
+ playback: state.playback,
770
+ });
771
+ } catch (error) {
772
+ console.error('Error fetching admin queue:', error);
773
+ res.status(500).json({ error: 'Failed to fetch queue' });
774
+ }
775
+ });
776
+
777
+ // Player control endpoints
778
+ this.app.post('/admin/player/play', (req, res) => {
779
+ try {
780
+ const result = playerService.play(this.mainApp);
781
+ res.json(result);
782
+ } catch (error) {
783
+ console.error('Error sending play command:', error);
784
+ res.status(500).json({ error: 'Failed to send play command' });
785
+ }
786
+ });
787
+
788
+ this.app.post('/admin/player/load', async (req, res) => {
789
+ try {
790
+ const { path: songPath } = req.body;
791
+
792
+ if (!songPath) {
793
+ return res.status(400).json({ error: 'Song path required' });
794
+ }
795
+
796
+ // Validate path is within songs directory (prevent path traversal)
797
+ const songsFolder = this.mainApp.settings?.getSongsFolder?.();
798
+ const validation = validateSongPath(songPath, songsFolder);
799
+ if (!validation.valid) {
800
+ console.error('🚫 Path validation failed:', validation.error, songPath);
801
+ return res.status(403).json({ error: validation.error });
802
+ }
803
+
804
+ const result = await playerService.loadSong(this.mainApp, validation.resolvedPath);
805
+ res.json(result);
806
+ } catch (error) {
807
+ console.error('Error loading song:', error);
808
+ res.status(500).json({ error: 'Failed to load song' });
809
+ }
810
+ });
811
+
812
+ this.app.post('/admin/player/pause', (req, res) => {
813
+ try {
814
+ const result = playerService.pause(this.mainApp);
815
+ res.json(result);
816
+ } catch (error) {
817
+ console.error('Error sending pause command:', error);
818
+ res.status(500).json({ error: 'Failed to send pause command' });
819
+ }
820
+ });
821
+
822
+ this.app.post('/admin/player/restart', (req, res) => {
823
+ try {
824
+ const result = playerService.restart(this.mainApp);
825
+ res.json(result);
826
+ } catch (error) {
827
+ console.error('Error sending restart command:', error);
828
+ res.status(500).json({ error: 'Failed to send restart command' });
829
+ }
830
+ });
831
+
832
+ this.app.post('/admin/player/seek', (req, res) => {
833
+ try {
834
+ const { position } = req.body;
835
+ const result = playerService.seek(this.mainApp, position);
836
+ if (result.success) {
837
+ res.json(result);
838
+ } else {
839
+ res.status(400).json(result);
840
+ }
841
+ } catch (error) {
842
+ console.error('Error sending seek command:', error);
843
+ res.status(500).json({ error: 'Failed to send seek command' });
844
+ }
845
+ });
846
+
847
+ this.app.post('/admin/player/next', async (req, res) => {
848
+ try {
849
+ const result = await playerService.playNext(this.mainApp);
850
+ res.json(result);
851
+ } catch (error) {
852
+ console.error('Error sending next command:', error);
853
+ res.status(500).json({ error: 'Failed to send next command' });
854
+ }
855
+ });
856
+
857
+ this.app.post('/admin/queue/add', async (req, res) => {
858
+ try {
859
+ const { song, requester } = req.body;
860
+
861
+ if (!song || !song.path) {
862
+ return res.status(400).json({ error: 'Song path required' });
863
+ }
864
+
865
+ const queueItem = {
866
+ path: song.path,
867
+ title: song.title || 'Unknown',
868
+ artist: song.artist || 'Unknown',
869
+ duration: song.duration,
870
+ requester: requester || 'Admin',
871
+ addedVia: 'web-admin',
872
+ };
873
+
874
+ // Use shared queueService via mainApp method
875
+ // (mainApp.addSongToQueue already uses queueService internally)
876
+ if (this.mainApp.addSongToQueue) {
877
+ const result = await this.mainApp.addSongToQueue(queueItem);
878
+ res.json({
879
+ success: result.success,
880
+ message: 'Song added to queue',
881
+ queueItem: result.queueItem,
882
+ });
883
+ } else {
884
+ res.status(500).json({ error: 'Queue not available' });
885
+ }
886
+ } catch (error) {
887
+ console.error('Error adding to queue:', error);
888
+ res.status(500).json({ error: 'Failed to add to queue' });
889
+ }
890
+ });
891
+
892
+ this.app.post('/admin/queue/reset', async (req, res) => {
893
+ try {
894
+ // Use shared queueService via mainApp method
895
+ const result = await this.mainApp.clearQueue?.();
896
+ res.json(result || { success: true, message: 'Queue reset' });
897
+ } catch (error) {
898
+ console.error('Error resetting queue:', error);
899
+ res.status(500).json({ error: 'Failed to reset queue' });
900
+ }
901
+ });
902
+
903
+ this.app.post('/admin/queue/load', async (req, res) => {
904
+ try {
905
+ const { songId } = req.body;
906
+ const result = await queueService.loadFromQueue(this.mainApp, songId);
907
+ res.json(result);
908
+ } catch (error) {
909
+ console.error('Error loading from queue:', error);
910
+ res.status(500).json({ error: 'Failed to load from queue' });
911
+ }
912
+ });
913
+
914
+ this.app.post('/admin/queue/remove/:songId', (req, res) => {
915
+ try {
916
+ const { songId } = req.params;
917
+ const result = queueService.removeSongFromQueue(this.mainApp.appState, parseFloat(songId));
918
+ res.json(result);
919
+ } catch (error) {
920
+ console.error('Error removing from queue:', error);
921
+ res.status(500).json({ error: 'Failed to remove from queue' });
922
+ }
923
+ });
924
+
925
+ this.app.post('/admin/queue/reorder', (req, res) => {
926
+ try {
927
+ const { songId, newIndex } = req.body;
928
+ const result = queueService.reorderQueue(this.mainApp.appState, songId, newIndex);
929
+ res.json(result);
930
+ } catch (error) {
931
+ console.error('Error reordering queue:', error);
932
+ res.status(500).json({ error: 'Failed to reorder queue' });
933
+ }
934
+ });
935
+
936
+ // Effects management endpoints
937
+ this.app.get('/admin/effects', async (req, res) => {
938
+ try {
939
+ const result = await effectsService.getEffects(this.mainApp);
940
+ if (result.success) {
941
+ res.json({
942
+ effects: result.effects,
943
+ currentEffect: result.currentEffect,
944
+ disabledEffects: result.disabledEffects,
945
+ });
946
+ } else {
947
+ res.status(500).json({ error: result.error });
948
+ }
949
+ } catch (error) {
950
+ console.error('Error fetching effects:', error);
951
+ res.status(500).json({ error: 'Failed to fetch effects' });
952
+ }
953
+ });
954
+
955
+ this.app.post('/admin/effects/select', async (req, res) => {
956
+ try {
957
+ const result = await effectsService.selectEffect(this.mainApp, req.body.effectName);
958
+ if (result.success) {
959
+ res.json(result);
960
+ } else {
961
+ res.status(400).json(result);
962
+ }
963
+ } catch (error) {
964
+ console.error('Error selecting effect:', error);
965
+ res.status(500).json({ error: 'Failed to select effect' });
966
+ }
967
+ });
968
+
969
+ this.app.post('/admin/effects/toggle', async (req, res) => {
970
+ try {
971
+ const result = await effectsService.toggleEffect(
972
+ this.mainApp,
973
+ req.body.effectName,
974
+ req.body.enabled
975
+ );
976
+ if (result.success) {
977
+ res.json(result);
978
+ } else {
979
+ res.status(400).json(result);
980
+ }
981
+ } catch (error) {
982
+ console.error('Error toggling effect:', error);
983
+ res.status(500).json({ error: 'Failed to toggle effect' });
984
+ }
985
+ });
986
+
987
+ // Get songs folder
988
+ this.app.get('/admin/library/folder', (req, res) => {
989
+ try {
990
+ const folder = this.mainApp.settings?.getSongsFolder?.();
991
+ res.json({ folder: folder || null });
992
+ } catch (error) {
993
+ console.error('Error getting songs folder:', error);
994
+ res.status(500).json({ error: 'Failed to get songs folder' });
995
+ }
996
+ });
997
+
998
+ // Get cached library songs
999
+ this.app.get('/admin/library/songs', (req, res) => {
1000
+ try {
1001
+ res.json({
1002
+ success: true,
1003
+ files: this.cachedSongs || [],
1004
+ cached: this.cachedSongs !== null,
1005
+ });
1006
+ } catch (error) {
1007
+ console.error('Error getting cached songs:', error);
1008
+ res.status(500).json({ error: 'Failed to get cached songs' });
1009
+ }
1010
+ });
1011
+
1012
+ // Sync library (quick scan for changes)
1013
+ this.app.post('/admin/library/sync', async (req, res) => {
1014
+ try {
1015
+ const result = await libraryService.syncLibrary(this.mainApp);
1016
+ if (result.success) {
1017
+ await libraryService.updateLibraryCache(this.mainApp, result.files);
1018
+ }
1019
+ res.json(result);
1020
+ } catch (error) {
1021
+ console.error('Error syncing library:', error);
1022
+ res.status(500).json({ error: 'Failed to sync library' });
1023
+ }
1024
+ });
1025
+
1026
+ // Search library
1027
+ this.app.get('/admin/library/search', (req, res) => {
1028
+ try {
1029
+ const query = req.query.q || '';
1030
+ const result = libraryService.searchSongs(this.mainApp, query);
1031
+ res.json(result);
1032
+ } catch (error) {
1033
+ console.error('Library search failed:', error);
1034
+ res.status(500).json({
1035
+ success: false,
1036
+ error: error.message,
1037
+ songs: [],
1038
+ });
1039
+ }
1040
+ });
1041
+
1042
+ // Load song for editing
1043
+ this.app.post('/admin/editor/load', async (req, res) => {
1044
+ try {
1045
+ const { path: songPath } = req.body;
1046
+
1047
+ // Validate path is within songs directory (prevent path traversal)
1048
+ const songsFolder = this.mainApp.settings?.getSongsFolder?.();
1049
+ const validation = validateSongPath(songPath, songsFolder);
1050
+ if (!validation.valid) {
1051
+ console.error('🚫 Path validation failed:', validation.error, songPath);
1052
+ return res.status(403).json({ success: false, error: validation.error });
1053
+ }
1054
+
1055
+ const validatedPath = validation.resolvedPath;
1056
+ const editorService = await import('../shared/services/editorService.js');
1057
+ const result = await editorService.loadSong(validatedPath);
1058
+
1059
+ // For KAI files, add download URLs for audio playback
1060
+ if (result.format === 'kai') {
1061
+ const audioFiles = result.kaiData.audio.sources.map((source) => {
1062
+ const filename = source.filename || source.name;
1063
+ const fileId = Buffer.from(`${validatedPath}:${filename}`).toString('base64url');
1064
+
1065
+ return {
1066
+ name: source.name,
1067
+ filename: filename,
1068
+ downloadUrl: `/admin/editor/kai-audio/${fileId}`,
1069
+ };
1070
+ });
1071
+
1072
+ res.json({
1073
+ success: true,
1074
+ data: {
1075
+ format: 'kai',
1076
+ metadata: result.kaiData.metadata || {},
1077
+ lyrics: result.kaiData.lyrics || [],
1078
+ audioFiles: audioFiles,
1079
+ songJson: result.kaiData.originalSongJson || {},
1080
+ },
1081
+ });
1082
+ } else if (result.format === 'm4a-stems') {
1083
+ // For M4A files, add download URLs for extracted audio tracks
1084
+ const audioFiles = result.kaiData.audio.sources.map((source) => {
1085
+ const trackName = source.name;
1086
+ const fileId = Buffer.from(`${validatedPath}:${trackName}:${source.trackIndex}`).toString(
1087
+ 'base64url'
1088
+ );
1089
+
1090
+ return {
1091
+ name: source.name,
1092
+ filename: `${trackName}.m4a`,
1093
+ downloadUrl: `/admin/editor/m4a-audio/${fileId}`,
1094
+ };
1095
+ });
1096
+
1097
+ res.json({
1098
+ success: true,
1099
+ data: {
1100
+ format: 'm4a-stems',
1101
+ metadata: result.kaiData.metadata || {},
1102
+ lyrics: result.kaiData.lyrics || [],
1103
+ audioFiles: audioFiles,
1104
+ songJson: result.kaiData.originalSongJson || {},
1105
+ },
1106
+ });
1107
+ } else {
1108
+ // For CDG+MP3, read ID3 tags from MP3 file
1109
+ const fs = await import('fs/promises');
1110
+
1111
+ // Find the MP3 file - the path might be .cdg or .mp3
1112
+ let mp3Path;
1113
+ if (path.toLowerCase().endsWith('.cdg')) {
1114
+ mp3Path = path.replace(/\.cdg$/i, '.mp3');
1115
+ } else if (path.toLowerCase().endsWith('.mp3')) {
1116
+ mp3Path = path;
1117
+ } else {
1118
+ return res.json({
1119
+ success: false,
1120
+ error: 'Invalid file format',
1121
+ });
1122
+ }
1123
+
1124
+ // Check if MP3 file exists
1125
+ try {
1126
+ await fs.access(mp3Path);
1127
+ } catch {
1128
+ return res.json({
1129
+ success: false,
1130
+ error: `MP3 file not found: ${mp3Path}`,
1131
+ });
1132
+ }
1133
+
1134
+ // Read ID3 tags using music-metadata
1135
+ const mm = await import('music-metadata');
1136
+ const mmData = await mm.parseFile(mp3Path);
1137
+
1138
+ // Extract key from comment field if present
1139
+ let key = '';
1140
+ if (mmData.common && mmData.common.comment) {
1141
+ const comments = Array.isArray(mmData.common.comment)
1142
+ ? mmData.common.comment
1143
+ : [mmData.common.comment];
1144
+
1145
+ for (const comment of comments) {
1146
+ // Convert to string if it's an object
1147
+ const commentStr = typeof comment === 'string' ? comment : String(comment);
1148
+ const keyMatch = commentStr.match(/Key:\s*(.+)/i);
1149
+ if (keyMatch) {
1150
+ key = keyMatch[1];
1151
+ break;
1152
+ }
1153
+ }
1154
+ }
1155
+
1156
+ res.json({
1157
+ success: true,
1158
+ data: {
1159
+ format: 'cdg-pair',
1160
+ metadata: {
1161
+ title: mmData.common?.title || '',
1162
+ artist: mmData.common?.artist || '',
1163
+ album: mmData.common?.album || '',
1164
+ year: mmData.common?.year ? String(mmData.common.year) : '',
1165
+ genre: mmData.common?.genre ? mmData.common.genre[0] : '',
1166
+ key: key,
1167
+ },
1168
+ lyrics: null,
1169
+ },
1170
+ });
1171
+ }
1172
+ } catch (error) {
1173
+ console.error('Failed to load song for editing:', error);
1174
+ res.status(500).json({
1175
+ success: false,
1176
+ error: error.message,
1177
+ });
1178
+ }
1179
+ });
1180
+
1181
+ // Download M4A audio track (extracted from M4A Stems file)
1182
+ this.app.get('/admin/editor/m4a-audio/:fileId', async (req, res) => {
1183
+ try {
1184
+ const { fileId } = req.params;
1185
+
1186
+ // Validate and decode the fileId (prevent path traversal)
1187
+ const songsFolder = this.mainApp.settings?.getSongsFolder?.();
1188
+ const validation = validateBase64Path(fileId, songsFolder);
1189
+ if (!validation.valid) {
1190
+ console.error('🚫 Path validation failed:', validation.error);
1191
+ return res.status(403).json({ success: false, error: validation.error });
1192
+ }
1193
+
1194
+ // Parse the decoded path: "path:trackName:trackIndex"
1195
+ const parts = validation.decodedPath.split(':');
1196
+ const [, trackName, trackIndexStr] = parts;
1197
+ const trackIndex = parseInt(trackIndexStr, 10);
1198
+ const m4aPath = validation.resolvedPath;
1199
+
1200
+ console.log('📥 M4A audio request:', { m4aPath, trackName, trackIndex });
1201
+
1202
+ // Load the M4A file to extract the audio track
1203
+ const M4ALoader = (await import('../utils/m4aLoader.js')).default;
1204
+ const m4aData = await M4ALoader.load(m4aPath);
1205
+
1206
+ // Find the audio source by track index
1207
+ const audioSource = m4aData.audio.sources.find((s) => s.trackIndex === trackIndex);
1208
+
1209
+ if (!audioSource) {
1210
+ return res.status(404).json({
1211
+ success: false,
1212
+ error: `Audio track not found: ${trackName} (index ${trackIndex})`,
1213
+ });
1214
+ }
1215
+
1216
+ // Extract the audio track if not already extracted
1217
+ let audioData = audioSource.audioData;
1218
+ if (!audioData) {
1219
+ console.log(`🎵 Extracting track ${trackIndex} from M4A file...`);
1220
+ audioData = await M4ALoader.extractTrack(m4aPath, trackIndex);
1221
+ }
1222
+
1223
+ if (!audioData) {
1224
+ return res.status(500).json({
1225
+ success: false,
1226
+ error: 'Failed to extract audio track',
1227
+ });
1228
+ }
1229
+
1230
+ // Send the audio file
1231
+ const filename = `${trackName}.m4a`;
1232
+ res.setHeader('Content-Type', 'audio/mp4');
1233
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
1234
+ res.send(audioData);
1235
+
1236
+ console.log(`✅ Sent M4A track: ${filename} (${audioData.length} bytes)`);
1237
+ } catch (error) {
1238
+ console.error('Failed to download M4A audio:', error);
1239
+ res.status(500).json({
1240
+ success: false,
1241
+ error: error.message,
1242
+ });
1243
+ }
1244
+ });
1245
+
1246
+ // Save song edits
1247
+ this.app.post('/admin/editor/save', async (req, res) => {
1248
+ try {
1249
+ const { path: songPath, format, metadata, lyrics } = req.body;
1250
+ if (!songPath) {
1251
+ return res.status(400).json({
1252
+ success: false,
1253
+ error: 'Path is required',
1254
+ });
1255
+ }
1256
+
1257
+ // Validate path is within songs directory (prevent path traversal)
1258
+ const songsFolder = this.mainApp.settings?.getSongsFolder?.();
1259
+ const validation = validateSongPath(songPath, songsFolder);
1260
+ if (!validation.valid) {
1261
+ console.error('🚫 Path validation failed:', validation.error, songPath);
1262
+ return res.status(403).json({ success: false, error: validation.error });
1263
+ }
1264
+ const validatedPath = validation.resolvedPath;
1265
+
1266
+ // For KAI files, save metadata and lyrics
1267
+ if (format === 'kai') {
1268
+ const editorService = await import('../shared/services/editorService.js');
1269
+ await editorService.saveSong(validatedPath, { format, metadata, lyrics });
1270
+
1271
+ // Update cached library entry if it exists
1272
+ if (this.mainApp.cachedLibrary) {
1273
+ const songIndex = this.mainApp.cachedLibrary.findIndex((s) => s.path === validatedPath);
1274
+ if (songIndex !== -1) {
1275
+ // Update the cached song metadata
1276
+ this.mainApp.cachedLibrary[songIndex] = {
1277
+ ...this.mainApp.cachedLibrary[songIndex],
1278
+ title:
1279
+ metadata.title !== undefined
1280
+ ? metadata.title
1281
+ : this.mainApp.cachedLibrary[songIndex].title,
1282
+ artist:
1283
+ metadata.artist !== undefined
1284
+ ? metadata.artist
1285
+ : this.mainApp.cachedLibrary[songIndex].artist,
1286
+ album:
1287
+ metadata.album !== undefined
1288
+ ? metadata.album
1289
+ : this.mainApp.cachedLibrary[songIndex].album,
1290
+ year:
1291
+ metadata.year !== undefined
1292
+ ? metadata.year
1293
+ : this.mainApp.cachedLibrary[songIndex].year,
1294
+ genre:
1295
+ metadata.genre !== undefined
1296
+ ? metadata.genre
1297
+ : this.mainApp.cachedLibrary[songIndex].genre,
1298
+ key:
1299
+ metadata.key !== undefined
1300
+ ? metadata.key
1301
+ : this.mainApp.cachedLibrary[songIndex].key,
1302
+ };
1303
+
1304
+ // Notify renderer about the update
1305
+ this.mainApp.sendToRenderer('library:songUpdated', {
1306
+ path: validatedPath,
1307
+ metadata: this.mainApp.cachedLibrary[songIndex],
1308
+ });
1309
+
1310
+ // Notify web clients about the update
1311
+ this.io.emit('library:songUpdated', {
1312
+ path: validatedPath,
1313
+ metadata: this.mainApp.cachedLibrary[songIndex],
1314
+ });
1315
+ }
1316
+ }
1317
+
1318
+ res.json({
1319
+ success: true,
1320
+ message: 'Song saved successfully',
1321
+ });
1322
+ } else {
1323
+ // For CDG+MP3, save ID3 tags to the MP3 file
1324
+ const fs = await import('fs/promises');
1325
+
1326
+ // Find the MP3 file - the path might be .cdg or .mp3
1327
+ let mp3Path;
1328
+ if (validatedPath.toLowerCase().endsWith('.cdg')) {
1329
+ mp3Path = validatedPath.replace(/\.cdg$/i, '.mp3');
1330
+ } else if (validatedPath.toLowerCase().endsWith('.mp3')) {
1331
+ mp3Path = validatedPath;
1332
+ } else {
1333
+ return res.json({
1334
+ success: false,
1335
+ error: 'Invalid file format',
1336
+ });
1337
+ }
1338
+
1339
+ // Check if MP3 file exists
1340
+ try {
1341
+ await fs.access(mp3Path);
1342
+ } catch {
1343
+ return res.json({
1344
+ success: false,
1345
+ error: `MP3 file not found: ${mp3Path}`,
1346
+ });
1347
+ }
1348
+
1349
+ // Write ID3 tags
1350
+ const NodeID3Module = await import('node-id3');
1351
+ const NodeID3 = NodeID3Module.default || NodeID3Module;
1352
+
1353
+ const tags = {
1354
+ title: metadata.title !== undefined ? metadata.title : '',
1355
+ artist: metadata.artist !== undefined ? metadata.artist : '',
1356
+ album: metadata.album !== undefined ? metadata.album : '',
1357
+ year: metadata.year !== undefined ? metadata.year : '',
1358
+ genre: metadata.genre !== undefined ? metadata.genre : '',
1359
+ comment: {
1360
+ language: 'eng',
1361
+ text: metadata.key !== undefined && metadata.key ? `Key: ${metadata.key}` : '',
1362
+ },
1363
+ };
1364
+
1365
+ const success = NodeID3.write(tags, mp3Path);
1366
+
1367
+ if (success) {
1368
+ // Update cached library entry if it exists
1369
+ if (this.mainApp.cachedLibrary) {
1370
+ const songIndex = this.mainApp.cachedLibrary.findIndex((s) => s.path === path);
1371
+ if (songIndex !== -1) {
1372
+ // Update the cached song metadata
1373
+ this.mainApp.cachedLibrary[songIndex] = {
1374
+ ...this.mainApp.cachedLibrary[songIndex],
1375
+ title:
1376
+ metadata.title !== undefined
1377
+ ? metadata.title
1378
+ : this.mainApp.cachedLibrary[songIndex].title,
1379
+ artist:
1380
+ metadata.artist !== undefined
1381
+ ? metadata.artist
1382
+ : this.mainApp.cachedLibrary[songIndex].artist,
1383
+ album:
1384
+ metadata.album !== undefined
1385
+ ? metadata.album
1386
+ : this.mainApp.cachedLibrary[songIndex].album,
1387
+ year:
1388
+ metadata.year !== undefined
1389
+ ? metadata.year
1390
+ : this.mainApp.cachedLibrary[songIndex].year,
1391
+ genre:
1392
+ metadata.genre !== undefined
1393
+ ? metadata.genre
1394
+ : this.mainApp.cachedLibrary[songIndex].genre,
1395
+ key:
1396
+ metadata.key !== undefined
1397
+ ? metadata.key
1398
+ : this.mainApp.cachedLibrary[songIndex].key,
1399
+ };
1400
+
1401
+ // Notify renderer about the update
1402
+ this.mainApp.sendToRenderer('library:songUpdated', {
1403
+ path: path,
1404
+ metadata: this.mainApp.cachedLibrary[songIndex],
1405
+ });
1406
+
1407
+ // Notify web clients about the update
1408
+ this.io.emit('library:songUpdated', {
1409
+ path: path,
1410
+ metadata: this.mainApp.cachedLibrary[songIndex],
1411
+ });
1412
+ }
1413
+ }
1414
+
1415
+ res.json({
1416
+ success: true,
1417
+ message: 'Song saved successfully',
1418
+ });
1419
+ } else {
1420
+ res.json({
1421
+ success: false,
1422
+ error: 'Failed to write ID3 tags',
1423
+ });
1424
+ }
1425
+ }
1426
+ } catch (error) {
1427
+ console.error('Failed to save song edits:', error);
1428
+ res.status(500).json({
1429
+ success: false,
1430
+ error: error.message,
1431
+ });
1432
+ }
1433
+ });
1434
+
1435
+ // Refresh library cache
1436
+ this.app.post('/admin/library/refresh', async (req, res) => {
1437
+ try {
1438
+ console.log('🔄 Admin requested library cache refresh');
1439
+
1440
+ // Use libraryService to scan library
1441
+ const result = await libraryService.scanLibrary(this.mainApp);
1442
+
1443
+ if (result.success) {
1444
+ // Update all caches (mainApp, webServer, disk)
1445
+ await libraryService.updateLibraryCache(this.mainApp, result.files);
1446
+
1447
+ res.json({
1448
+ success: true,
1449
+ message: `Library refreshed successfully. Found ${result.files.length} songs.`,
1450
+ songsCount: result.files.length,
1451
+ cacheTime: this.songsCacheTime,
1452
+ });
1453
+ } else {
1454
+ res.status(500).json({ error: result.error || 'Failed to refresh library cache' });
1455
+ }
1456
+ } catch (error) {
1457
+ console.error('Error refreshing library cache:', error);
1458
+ res.status(500).json({ error: 'Failed to refresh library cache' });
1459
+ }
1460
+ });
1461
+
1462
+ // ===== NEW: Master Mixer Control Endpoints =====
1463
+ this.app.post('/admin/mixer/master-gain', (req, res) => {
1464
+ try {
1465
+ const { bus, gainDb } = req.body;
1466
+ const result = mixerService.setMasterGain(this.mainApp, bus, gainDb);
1467
+
1468
+ if (result.success) {
1469
+ res.json(result);
1470
+ } else {
1471
+ res.status(400).json(result);
1472
+ }
1473
+ } catch (error) {
1474
+ console.error('Error setting master gain:', error);
1475
+ res.status(500).json({ error: 'Failed to set master gain' });
1476
+ }
1477
+ });
1478
+
1479
+ this.app.post('/admin/mixer/master-mute', (req, res) => {
1480
+ try {
1481
+ const { bus } = req.body;
1482
+ const result = mixerService.toggleMasterMute(this.mainApp, bus);
1483
+
1484
+ if (result.success) {
1485
+ res.json(result);
1486
+ } else {
1487
+ res.status(400).json(result);
1488
+ }
1489
+ } catch (error) {
1490
+ console.error('Error toggling master mute:', error);
1491
+ res.status(500).json({ error: 'Failed to toggle master mute' });
1492
+ }
1493
+ });
1494
+
1495
+ // ===== NEW: Effects Control Endpoints =====
1496
+ this.app.post('/admin/effects/set', async (req, res) => {
1497
+ try {
1498
+ const result = await effectsService.setEffect(this.mainApp, req.body.effectName);
1499
+ if (result.success) {
1500
+ res.json(result);
1501
+ } else {
1502
+ res.status(400).json(result);
1503
+ }
1504
+ } catch (error) {
1505
+ console.error('Error setting effect:', error);
1506
+ res.status(500).json({ error: 'Failed to set effect' });
1507
+ }
1508
+ });
1509
+
1510
+ this.app.post('/admin/effects/next', (req, res) => {
1511
+ try {
1512
+ const result = effectsService.nextEffect(this.mainApp);
1513
+ res.json(result);
1514
+ } catch (error) {
1515
+ console.error('Error changing to next effect:', error);
1516
+ res.status(500).json({ error: 'Failed to change effect' });
1517
+ }
1518
+ });
1519
+
1520
+ this.app.post('/admin/effects/previous', (req, res) => {
1521
+ try {
1522
+ const result = effectsService.previousEffect(this.mainApp);
1523
+ res.json(result);
1524
+ } catch (error) {
1525
+ console.error('Error changing to previous effect:', error);
1526
+ res.status(500).json({ error: 'Failed to change effect' });
1527
+ }
1528
+ });
1529
+
1530
+ this.app.post('/admin/effects/random', (req, res) => {
1531
+ try {
1532
+ const result = effectsService.randomEffect(this.mainApp);
1533
+ res.json(result);
1534
+ } catch (error) {
1535
+ console.error('Error selecting random effect:', error);
1536
+ res.status(500).json({ error: 'Failed to select random effect' });
1537
+ }
1538
+ });
1539
+
1540
+ this.app.post('/admin/effects/disable', async (req, res) => {
1541
+ try {
1542
+ const result = await effectsService.disableEffect(this.mainApp, req.body.effectName);
1543
+ if (result.success) {
1544
+ // Broadcast to web clients
1545
+ this.io.emit('effects:disabled', {
1546
+ effectName: req.body.effectName,
1547
+ disabled: result.disabled,
1548
+ });
1549
+ res.json(result);
1550
+ } else {
1551
+ res.status(400).json(result);
1552
+ }
1553
+ } catch (error) {
1554
+ console.error('Error disabling effect:', error);
1555
+ res.status(500).json({ error: 'Failed to disable effect' });
1556
+ }
1557
+ });
1558
+
1559
+ this.app.post('/admin/effects/enable', async (req, res) => {
1560
+ try {
1561
+ const result = await effectsService.enableEffect(this.mainApp, req.body.effectName);
1562
+ if (result.success) {
1563
+ // Broadcast to web clients
1564
+ this.io.emit('effects:enabled', {
1565
+ effectName: req.body.effectName,
1566
+ disabled: result.disabled,
1567
+ });
1568
+ res.json(result);
1569
+ } else {
1570
+ res.status(400).json(result);
1571
+ }
1572
+ } catch (error) {
1573
+ console.error('Error enabling effect:', error);
1574
+ res.status(500).json({ error: 'Failed to enable effect' });
1575
+ }
1576
+ });
1577
+
1578
+ // ===== NEW: Preferences Control Endpoints =====
1579
+ this.app.get('/admin/preferences', (req, res) => {
1580
+ try {
1581
+ const result = preferencesService.getPreferences(this.mainApp.appState);
1582
+ if (result.success) {
1583
+ // Also load waveform and autotune preferences from settings (uses defaults from shared/defaults.js)
1584
+ const waveformPreferences = getSetting('waveformPreferences', WAVEFORM_DEFAULTS);
1585
+ const autoTunePreferences = getSetting('autoTunePreferences', AUTOTUNE_DEFAULTS);
1586
+
1587
+ res.json({
1588
+ ...result.preferences,
1589
+ waveformPreferences,
1590
+ autoTunePreferences,
1591
+ });
1592
+ } else {
1593
+ res.status(500).json({ error: result.error });
1594
+ }
1595
+ } catch (error) {
1596
+ console.error('Error fetching preferences:', error);
1597
+ res.status(500).json({ error: 'Failed to fetch preferences' });
1598
+ }
1599
+ });
1600
+
1601
+ this.app.get('/admin/settings/waveform', (req, res) => {
1602
+ try {
1603
+ const result = preferencesService.getWaveformSettings(this.mainApp.settings);
1604
+ res.json(result);
1605
+ } catch (error) {
1606
+ console.error('Error fetching waveform settings:', error);
1607
+ res.status(500).json({ error: 'Failed to fetch waveform settings' });
1608
+ }
1609
+ });
1610
+
1611
+ this.app.post('/admin/settings/waveform', async (req, res) => {
1612
+ try {
1613
+ const result = await preferencesService.updateWaveformSettings(
1614
+ this.mainApp.settings,
1615
+ req.body
1616
+ );
1617
+
1618
+ if (result.success) {
1619
+ // Send to renderer for immediate effect
1620
+ this.mainApp.sendToRenderer('waveform:settingsChanged', result.settings);
1621
+
1622
+ // Broadcast to all admin clients via socket.io
1623
+ this.io.to('admin-clients').emit('settings:waveform', result.settings);
1624
+
1625
+ res.json(result);
1626
+ } else {
1627
+ res.status(500).json(result);
1628
+ }
1629
+ } catch (error) {
1630
+ console.error('Error updating waveform settings:', error);
1631
+ res.status(500).json({ error: 'Failed to update waveform settings' });
1632
+ }
1633
+ });
1634
+
1635
+ this.app.get('/admin/settings/autotune', (req, res) => {
1636
+ try {
1637
+ const result = preferencesService.getAutoTuneSettings(this.mainApp.settings);
1638
+ res.json(result);
1639
+ } catch (error) {
1640
+ console.error('Error fetching autotune settings:', error);
1641
+ res.status(500).json({ error: 'Failed to fetch autotune settings' });
1642
+ }
1643
+ });
1644
+
1645
+ this.app.post('/admin/settings/autotune', async (req, res) => {
1646
+ try {
1647
+ const result = await preferencesService.updateAutoTuneSettings(
1648
+ this.mainApp.settings,
1649
+ req.body
1650
+ );
1651
+
1652
+ if (result.success) {
1653
+ // Send to renderer for immediate effect
1654
+ this.mainApp.sendToRenderer('autotune:settingsChanged', result.settings);
1655
+
1656
+ // Broadcast to all admin clients via socket.io
1657
+ this.io.to('admin-clients').emit('settings:autotune', result.settings);
1658
+
1659
+ res.json(result);
1660
+ } else {
1661
+ res.status(500).json(result);
1662
+ }
1663
+ } catch (error) {
1664
+ console.error('Error updating autotune settings:', error);
1665
+ res.status(500).json({ error: 'Failed to update autotune settings' });
1666
+ }
1667
+ });
1668
+
1669
+ this.app.post('/admin/preferences/autotune', async (req, res) => {
1670
+ try {
1671
+ const result = preferencesService.updateAutoTunePreferences(
1672
+ this.mainApp.appState,
1673
+ req.body
1674
+ );
1675
+
1676
+ if (result.success) {
1677
+ // Save settings and send to renderer (no need to wait for response)
1678
+ await this.mainApp.settings.set('autoTunePreferences', req.body);
1679
+
1680
+ // Send to renderer window to apply in real-time
1681
+ if (this.mainApp.mainWindow && !this.mainApp.mainWindow.isDestroyed()) {
1682
+ this.mainApp.mainWindow.webContents.send('autotune:settingsChanged', req.body);
1683
+ }
1684
+
1685
+ // Broadcast to all web admin clients
1686
+ this.io.to('admin-clients').emit('settings:autotune', req.body);
1687
+
1688
+ res.json(result);
1689
+ } else {
1690
+ res.status(500).json(result);
1691
+ }
1692
+ } catch (error) {
1693
+ console.error('Error updating autotune preferences:', error);
1694
+ res.status(500).json({ error: 'Failed to update autotune preferences' });
1695
+ }
1696
+ });
1697
+
1698
+ this.app.post('/admin/preferences/microphone', async (req, res) => {
1699
+ try {
1700
+ const result = preferencesService.updateMicrophonePreferences(
1701
+ this.mainApp.appState,
1702
+ req.body
1703
+ );
1704
+
1705
+ if (result.success) {
1706
+ // Send to renderer
1707
+ if (req.body.enabled !== undefined) {
1708
+ await this.mainApp.sendToRendererAndWait(
1709
+ 'microphone:setEnabled',
1710
+ { enabled: req.body.enabled },
1711
+ 2000
1712
+ );
1713
+ }
1714
+ if (req.body.gain !== undefined) {
1715
+ await this.mainApp.sendToRendererAndWait(
1716
+ 'microphone:setGain',
1717
+ { gain: req.body.gain },
1718
+ 2000
1719
+ );
1720
+ }
1721
+ res.json(result);
1722
+ } else {
1723
+ res.status(500).json(result);
1724
+ }
1725
+ } catch (error) {
1726
+ console.error('Error updating microphone preferences:', error);
1727
+ res.status(500).json({ error: 'Failed to update microphone preferences' });
1728
+ }
1729
+ });
1730
+
1731
+ this.app.post('/admin/preferences/effects', async (req, res) => {
1732
+ try {
1733
+ const result = preferencesService.updateEffectsPreferences(this.mainApp.appState, req.body);
1734
+
1735
+ if (result.success) {
1736
+ // Save settings and send to renderer (no need to wait for response)
1737
+ await this.mainApp.settings.set('waveformPreferences', req.body);
1738
+
1739
+ // Send to renderer window to apply in real-time
1740
+ if (this.mainApp.mainWindow && !this.mainApp.mainWindow.isDestroyed()) {
1741
+ this.mainApp.mainWindow.webContents.send('waveform:settingsChanged', req.body);
1742
+ }
1743
+
1744
+ // Broadcast to all web admin clients
1745
+ this.io.to('admin-clients').emit('settings:waveform', req.body);
1746
+
1747
+ res.json(result);
1748
+ } else {
1749
+ res.status(500).json(result);
1750
+ }
1751
+ } catch (error) {
1752
+ console.error('Error updating effects preferences:', error);
1753
+ res.status(500).json({ error: 'Failed to update effects preferences' });
1754
+ }
1755
+ });
1756
+
1757
+ // ===== Creator Endpoints =====
1758
+
1759
+ // Check creator components status
1760
+ this.app.get('/admin/creator/status', async (req, res) => {
1761
+ try {
1762
+ const components = await creatorService.checkComponents();
1763
+ const status = creatorService.getStatus();
1764
+ res.json({
1765
+ ...components,
1766
+ ...status,
1767
+ });
1768
+ } catch (error) {
1769
+ console.error('Error checking creator status:', error);
1770
+ res.status(500).json({ error: 'Failed to check creator status' });
1771
+ }
1772
+ });
1773
+
1774
+ // Install creator components
1775
+ this.app.post('/admin/creator/install', async (req, res) => {
1776
+ try {
1777
+ // Start installation with Socket.IO progress updates
1778
+ const result = await creatorService.installComponents((progress) => {
1779
+ this.io.to('admin-clients').emit('creator:install-progress', progress);
1780
+ });
1781
+
1782
+ if (result.success) {
1783
+ res.json(result);
1784
+ } else {
1785
+ this.io.to('admin-clients').emit('creator:install-error', {
1786
+ error: result.error,
1787
+ });
1788
+ res.status(500).json(result);
1789
+ }
1790
+ } catch (error) {
1791
+ console.error('Error installing creator components:', error);
1792
+ this.io.to('admin-clients').emit('creator:install-error', {
1793
+ error: error.message,
1794
+ });
1795
+ res.status(500).json({ error: 'Failed to install components' });
1796
+ }
1797
+ });
1798
+
1799
+ // Cancel creator installation
1800
+ this.app.post('/admin/creator/cancel-install', (req, res) => {
1801
+ try {
1802
+ const result = creatorService.cancelInstall();
1803
+ res.json(result);
1804
+ } catch (error) {
1805
+ console.error('Error cancelling installation:', error);
1806
+ res.status(500).json({ error: 'Failed to cancel installation' });
1807
+ }
1808
+ });
1809
+
1810
+ // Search lyrics
1811
+ this.app.post('/admin/creator/search-lyrics', async (req, res) => {
1812
+ try {
1813
+ const { title, artist } = req.body;
1814
+
1815
+ if (!title) {
1816
+ return res.status(400).json({ error: 'Title is required' });
1817
+ }
1818
+
1819
+ const result = await creatorService.findLyrics(title, artist || '');
1820
+ res.json(result);
1821
+ } catch (error) {
1822
+ console.error('Error searching lyrics:', error);
1823
+ res.status(500).json({ error: 'Failed to search lyrics' });
1824
+ }
1825
+ });
1826
+
1827
+ // Get file info (for library songs)
1828
+ this.app.post('/admin/creator/file-info', async (req, res) => {
1829
+ try {
1830
+ const { path: filePath } = req.body;
1831
+
1832
+ if (!filePath) {
1833
+ return res.status(400).json({ error: 'File path is required' });
1834
+ }
1835
+
1836
+ // Validate path is within songs directory (prevent path traversal)
1837
+ const songsFolder = this.mainApp.settings?.getSongsFolder?.();
1838
+ const validation = validateSongPath(filePath, songsFolder);
1839
+ if (!validation.valid) {
1840
+ console.error('🚫 Path validation failed:', validation.error, filePath);
1841
+ return res.status(403).json({ error: validation.error });
1842
+ }
1843
+
1844
+ const result = await creatorService.getFileInfo(validation.resolvedPath);
1845
+ res.json(result);
1846
+ } catch (error) {
1847
+ console.error('Error getting file info:', error);
1848
+ res.status(500).json({ error: 'Failed to get file info' });
1849
+ }
1850
+ });
1851
+
1852
+ // Start conversion
1853
+ this.app.post('/admin/creator/convert', async (req, res) => {
1854
+ try {
1855
+ const options = req.body;
1856
+
1857
+ if (!options.inputPath) {
1858
+ return res.status(400).json({ error: 'Input path is required' });
1859
+ }
1860
+
1861
+ // Validate input path is within songs directory (prevent path traversal)
1862
+ const songsFolder = this.mainApp.settings?.getSongsFolder?.();
1863
+ const validation = validateSongPath(options.inputPath, songsFolder);
1864
+ if (!validation.valid) {
1865
+ console.error('🚫 Path validation failed:', validation.error, options.inputPath);
1866
+ return res.status(403).json({ error: validation.error });
1867
+ }
1868
+ // Use validated path
1869
+ options.inputPath = validation.resolvedPath;
1870
+
1871
+ // Send immediate response that conversion started
1872
+ res.json({ success: true, message: 'Conversion started' });
1873
+
1874
+ // Run conversion with Socket.IO progress updates
1875
+ const result = await creatorService.startConversion(
1876
+ options,
1877
+ (progress) => {
1878
+ this.io.to('admin-clients').emit('creator:conversion-progress', progress);
1879
+ },
1880
+ (consoleLine) => {
1881
+ this.io.to('admin-clients').emit('creator:conversion-console', { line: consoleLine });
1882
+ },
1883
+ this.mainApp.settings // Pass settings manager for LLM
1884
+ );
1885
+
1886
+ if (result.success) {
1887
+ this.io.to('admin-clients').emit('creator:conversion-complete', {
1888
+ outputPath: result.outputPath,
1889
+ duration: result.duration,
1890
+ stems: result.stems,
1891
+ hasLyrics: result.hasLyrics,
1892
+ hasPitch: result.hasPitch,
1893
+ llmStats: result.llmStats,
1894
+ });
1895
+ } else if (result.cancelled) {
1896
+ // User cancelled - no error event needed
1897
+ } else {
1898
+ this.io.to('admin-clients').emit('creator:conversion-error', {
1899
+ error: result.error,
1900
+ });
1901
+ }
1902
+ } catch (error) {
1903
+ console.error('Error during conversion:', error);
1904
+ this.io.to('admin-clients').emit('creator:conversion-error', {
1905
+ error: error.message,
1906
+ });
1907
+ }
1908
+ });
1909
+
1910
+ // Cancel conversion
1911
+ this.app.post('/admin/creator/cancel-convert', (req, res) => {
1912
+ try {
1913
+ const result = creatorService.stopConversion();
1914
+ res.json(result);
1915
+ } catch (error) {
1916
+ console.error('Error cancelling conversion:', error);
1917
+ res.status(500).json({ error: 'Failed to cancel conversion' });
1918
+ }
1919
+ });
1920
+
1921
+ // Get audio files that can be converted (from library or direct path)
1922
+ this.app.get('/admin/creator/sources', async (req, res) => {
1923
+ try {
1924
+ // Get library songs that are audio files (not already .stem.m4a)
1925
+ const allSongs = await this.getCachedSongs();
1926
+
1927
+ // Filter to songs that could be source files for conversion
1928
+ // (exclude .stem.m4a which are already karaoke files)
1929
+ const sourceCandidates = allSongs.filter((song) => {
1930
+ const ext = song.path.split('.').pop().toLowerCase();
1931
+ return [
1932
+ 'mp3',
1933
+ 'wav',
1934
+ 'flac',
1935
+ 'ogg',
1936
+ 'm4a',
1937
+ 'aac',
1938
+ 'mp4',
1939
+ 'mkv',
1940
+ 'avi',
1941
+ 'mov',
1942
+ 'webm',
1943
+ ].includes(ext);
1944
+ });
1945
+
1946
+ res.json({
1947
+ success: true,
1948
+ sources: sourceCandidates.map((song) => ({
1949
+ path: song.path,
1950
+ title: song.title,
1951
+ artist: song.artist,
1952
+ duration: song.duration,
1953
+ format: song.path.split('.').pop().toLowerCase(),
1954
+ })),
1955
+ });
1956
+ } catch (error) {
1957
+ console.error('Error getting source files:', error);
1958
+ res.status(500).json({ error: 'Failed to get source files' });
1959
+ }
1960
+ });
1961
+
1962
+ // SPA fallback for React Router - serve index.html for all /admin/* routes not handled above
1963
+ // Express 5: Use regex pattern instead of wildcard
1964
+ this.app.get(/^\/admin\/.*/, (req, res) => {
1965
+ const webDistPath = path.join(__dirname, '../web/dist');
1966
+ const indexPath = path.join(webDistPath, 'index.html');
1967
+ if (fs.existsSync(indexPath)) {
1968
+ res.sendFile(indexPath);
1969
+ } else {
1970
+ res.status(404).send('Web UI not built. Run: cd src/web && npm run build');
1971
+ }
1972
+ });
1973
+ }
1974
+
1975
+ setupStateChangeListeners() {
1976
+ // Subscribe to mixer state changes and broadcast to admin clients
1977
+ this.mainApp.appState.on('mixerChanged', (mixerState) => {
1978
+ this.io.to('admin-clients').emit('mixer-update', mixerState);
1979
+ });
1980
+
1981
+ // Subscribe to effects state changes and broadcast to admin clients
1982
+ this.mainApp.appState.on('effectsChanged', (effectsState) => {
1983
+ this.io.to('admin-clients').emit('effects-update', effectsState);
1984
+ });
1985
+
1986
+ // Subscribe to queue changes and broadcast to admin clients
1987
+ this.mainApp.appState.on('queueChanged', (queue) => {
1988
+ const currentSong = this.mainApp.appState.state.currentSong;
1989
+ this.io.to('admin-clients').emit('queue-update', {
1990
+ queue,
1991
+ currentSong,
1992
+ });
1993
+ });
1994
+
1995
+ // Subscribe to current song changes and broadcast to admin clients (includes isLoading state)
1996
+ this.mainApp.appState.on('currentSongChanged', (currentSong) => {
1997
+ this.io.to('admin-clients').emit('current-song-update', currentSong);
1998
+ });
1999
+
2000
+ // Subscribe to playback state changes and broadcast to admin clients
2001
+ this.mainApp.appState.on('playbackChanged', (playbackState) => {
2002
+ this.io.to('admin-clients').emit('playback-state-update', playbackState);
2003
+ });
2004
+
2005
+ console.log('✅ State change listeners configured for WebSocket broadcasting');
2006
+ }
2007
+
2008
+ setupSocketHandlers() {
2009
+ this.io.on('connection', (socket) => {
2010
+ console.log('Client connected:', socket.id);
2011
+
2012
+ // Handle connection type identification
2013
+ socket.on('identify', (data) => {
2014
+ socket.clientType = data.type; // 'electron-app', 'web-ui', or 'admin'
2015
+ console.log(`Client identified as: ${data.type}`);
2016
+
2017
+ if (data.type === 'electron-app') {
2018
+ socket.join('electron-apps');
2019
+ } else if (data.type === 'web-ui') {
2020
+ socket.join('web-clients');
2021
+ } else if (data.type === 'admin') {
2022
+ // SECURITY FIX (#22): Validate admin session before allowing admin room access
2023
+ const session = this.validateSocketSession(socket);
2024
+ if (!session || !session.isAdmin) {
2025
+ console.warn('⚠️ Unauthorized admin connection attempt:', socket.id);
2026
+ socket.emit('auth-error', { message: 'Admin authentication required' });
2027
+ return;
2028
+ }
2029
+ socket.join('admin-clients');
2030
+ console.log('Admin client connected and authenticated');
2031
+
2032
+ // Send current state to newly connected admin client
2033
+ const currentState = this.mainApp.appState.getSnapshot();
2034
+
2035
+ // Get disabled effects from settings (not AppState)
2036
+ const waveformPrefs = this.mainApp.settings.get('waveformPreferences', {});
2037
+ const effectsState = {
2038
+ ...currentState.effects,
2039
+ disabled: waveformPrefs.disabledEffects || [],
2040
+ };
2041
+
2042
+ socket.emit('mixer-update', currentState.mixer);
2043
+ socket.emit('effects-update', effectsState);
2044
+ socket.emit('queue-update', {
2045
+ queue: currentState.queue,
2046
+ currentSong: currentState.currentSong,
2047
+ });
2048
+ socket.emit('playback-state-update', currentState.playback);
2049
+ console.log('📤 Sent initial state to admin client:', {
2050
+ mixer: currentState.mixer,
2051
+ queue: currentState.queue.length,
2052
+ playback: currentState.playback,
2053
+ disabledEffects: effectsState.disabled.length,
2054
+ });
2055
+ }
2056
+ });
2057
+
2058
+ // Handle disconnection
2059
+ socket.on('disconnect', () => {
2060
+ console.log('Client disconnected:', socket.id);
2061
+ });
2062
+
2063
+ // Song request events
2064
+ socket.on('song-request', (request) => {
2065
+ // Broadcast to electron apps
2066
+ socket.to('electron-apps').emit('new-song-request', request);
2067
+ });
2068
+
2069
+ // Queue updates
2070
+ socket.on('queue-updated', (queueData) => {
2071
+ // Broadcast to all web clients and admin clients
2072
+ socket.to('web-clients').emit('queue-update', queueData);
2073
+ socket.to('admin-clients').emit('queue-update', queueData);
2074
+ });
2075
+
2076
+ // Player state events
2077
+ socket.on('player-state', (state) => {
2078
+ socket.to('web-clients').emit('player-state-update', state);
2079
+ socket.to('admin-clients').emit('player-state-update', state);
2080
+ });
2081
+
2082
+ // Settings changes
2083
+ socket.on('settings-changed', (settings) => {
2084
+ socket.to('web-clients').emit('settings-update', settings);
2085
+ socket.to('admin-clients').emit('settings-update', settings);
2086
+ });
2087
+
2088
+ // Playback position sync
2089
+ socket.on('playback-position', (data) => {
2090
+ // Broadcast current playback position to all web clients
2091
+ socket.to('web-clients').emit('position-sync', data);
2092
+ });
2093
+
2094
+ // Song loading events
2095
+ socket.on('song-loaded', (songData) => {
2096
+ // Notify all clients that a new song is loaded
2097
+ socket.to('web-clients').emit('song-changed', songData);
2098
+ socket.to('admin-clients').emit('song-changed', songData);
2099
+ });
2100
+
2101
+ // Effect control events
2102
+ socket.on('effect-control', (data) => {
2103
+ // Forward effect control commands to electron apps
2104
+ socket.to('electron-apps').emit('effect-control', data);
2105
+ console.log(`Effect control: ${data.action}`);
2106
+ });
2107
+ });
2108
+ }
2109
+
2110
+ async addToQueue(request) {
2111
+ console.log('🎵 Adding to queue:', request.song.title);
2112
+
2113
+ // Add the song to the main app's queue
2114
+ if (this.mainApp.addSongToQueue) {
2115
+ const queueItem = {
2116
+ ...request.song,
2117
+ requester: request.requesterName,
2118
+ addedVia: 'web-request',
2119
+ };
2120
+ console.log('🎵 Queue item:', queueItem);
2121
+ console.log('🎵 Calling mainApp.addSongToQueue...');
2122
+
2123
+ try {
2124
+ await this.mainApp.addSongToQueue(queueItem);
2125
+ console.log('✅ Successfully called mainApp.addSongToQueue');
2126
+ } catch (error) {
2127
+ console.error('❌ Error in mainApp.addSongToQueue:', error);
2128
+ throw error;
2129
+ }
2130
+ } else {
2131
+ console.error('❌ mainApp.addSongToQueue method not available');
2132
+ throw new Error('Queue functionality not available');
2133
+ }
2134
+ }
2135
+
2136
+ async approveRequest(requestId) {
2137
+ return await requestsService.approveRequest(this, requestId);
2138
+ }
2139
+
2140
+ async rejectRequest(requestId) {
2141
+ return await requestsService.rejectRequest(this, requestId);
2142
+ }
2143
+
2144
+ start(port) {
2145
+ // Load settings first to get the saved port
2146
+ this.settings = this.loadSettings();
2147
+
2148
+ // Use port from settings if not explicitly provided
2149
+ if (!port) {
2150
+ port = this.settings.port || 3069;
2151
+ }
2152
+
2153
+ this.port = port;
2154
+
2155
+ return new Promise((resolve, reject) => {
2156
+ // Try the requested port first, then try others if it's taken
2157
+ const tryPort = (currentPort) => {
2158
+ // Create HTTP server for Socket.IO
2159
+ this.httpServer = http.createServer(this.app);
2160
+
2161
+ // Initialize Socket.IO with restricted CORS (same as Express)
2162
+ this.io = new Server(this.httpServer, {
2163
+ cors: {
2164
+ origin: (origin, callback) => {
2165
+ // Allow requests with no origin (same-origin, non-browser clients)
2166
+ if (!origin) {
2167
+ return callback(null, true);
2168
+ }
2169
+
2170
+ if (this.isAllowedOrigin(origin)) {
2171
+ return callback(null, true);
2172
+ }
2173
+
2174
+ callback(new Error('CORS not allowed for this origin'));
2175
+ },
2176
+ methods: ['GET', 'POST'],
2177
+ credentials: true,
2178
+ },
2179
+ });
2180
+
2181
+ // Setup Socket.IO connection handling
2182
+ this.setupSocketHandlers();
2183
+
2184
+ // Setup state change listeners for broadcasting
2185
+ this.setupStateChangeListeners();
2186
+
2187
+ // Add global error handling to prevent server crashes
2188
+ this.httpServer.on('error', (error) => {
2189
+ console.error('🚨 HTTP Server error:', error);
2190
+ });
2191
+
2192
+ this.httpServer.on('clientError', (error, socket) => {
2193
+ console.error('🚨 HTTP Client error:', error);
2194
+ if (!socket.destroyed) {
2195
+ socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
2196
+ }
2197
+ });
2198
+
2199
+ // Add Express error handling middleware
2200
+ this.app.use((error, req, res, _next) => {
2201
+ console.error('🚨 Express error:', error);
2202
+ if (!res.headersSent) {
2203
+ res.status(500).json({ error: 'Internal server error' });
2204
+ }
2205
+ });
2206
+
2207
+ this.server = this.httpServer.listen(currentPort, (err) => {
2208
+ if (err) {
2209
+ if (err.code === 'EADDRINUSE' && currentPort < port + 10) {
2210
+ console.log(`Port ${currentPort} in use, trying ${currentPort + 1}...`);
2211
+ tryPort(currentPort + 1);
2212
+ } else {
2213
+ reject(err);
2214
+ }
2215
+ } else {
2216
+ this.port = currentPort;
2217
+
2218
+ // Load settings from persistent storage now that mainApp is available
2219
+ this.settings = this.loadSettings();
2220
+
2221
+ console.log(`Web server started on http://localhost:${this.port}`);
2222
+ console.log(`Socket.IO server ready for connections`);
2223
+ console.log(`🔧 Loaded settings:`, this.settings);
2224
+ resolve(this.port);
2225
+ }
2226
+ });
2227
+
2228
+ this.server.on('error', (err) => {
2229
+ if (err.code === 'EADDRINUSE' && currentPort < port + 10) {
2230
+ tryPort(currentPort + 1);
2231
+ } else {
2232
+ reject(err);
2233
+ }
2234
+ });
2235
+ };
2236
+
2237
+ tryPort(port);
2238
+ });
2239
+ }
2240
+
2241
+ stop() {
2242
+ if (this.io) {
2243
+ this.io.close();
2244
+ this.io = null;
2245
+ }
2246
+
2247
+ if (this.server) {
2248
+ this.server.close();
2249
+ this.server = null;
2250
+ console.log('Web server and Socket.IO server stopped');
2251
+ }
2252
+
2253
+ if (this.httpServer) {
2254
+ this.httpServer = null;
2255
+ }
2256
+ }
2257
+
2258
+ getPort() {
2259
+ return this.port;
2260
+ }
2261
+
2262
+ getLanIp() {
2263
+ try {
2264
+ const interfaces = os.networkInterfaces();
2265
+ for (const name of Object.keys(interfaces)) {
2266
+ for (const iface of interfaces[name]) {
2267
+ // Skip internal (loopback) and non-IPv4 addresses
2268
+ if (iface.family === 'IPv4' && !iface.internal) {
2269
+ return iface.address;
2270
+ }
2271
+ }
2272
+ }
2273
+ } catch (error) {
2274
+ console.error('Failed to get LAN IP:', error);
2275
+ }
2276
+ return 'localhost';
2277
+ }
2278
+
2279
+ /**
2280
+ * Get all local network addresses (for CORS validation)
2281
+ * @returns {string[]} Array of local IP addresses
2282
+ */
2283
+ getAllLocalAddresses() {
2284
+ const addresses = ['localhost', '127.0.0.1', '::1'];
2285
+ try {
2286
+ const interfaces = os.networkInterfaces();
2287
+ for (const name of Object.keys(interfaces)) {
2288
+ for (const iface of interfaces[name]) {
2289
+ if (iface.family === 'IPv4' || iface.family === 4) {
2290
+ addresses.push(iface.address);
2291
+ }
2292
+ }
2293
+ }
2294
+ } catch (error) {
2295
+ console.error('Failed to get network interfaces:', error);
2296
+ }
2297
+ return addresses;
2298
+ }
2299
+
2300
+ /**
2301
+ * Check if an origin is allowed for CORS
2302
+ * Allows: localhost, LAN IPs (private ranges), and this server's addresses
2303
+ * @param {string} origin - The origin to check
2304
+ * @returns {boolean} True if origin is allowed
2305
+ */
2306
+ isAllowedOrigin(origin) {
2307
+ if (!origin) return true;
2308
+
2309
+ try {
2310
+ const url = new URL(origin);
2311
+ const hostname = url.hostname;
2312
+
2313
+ // Allow localhost variants
2314
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') {
2315
+ return true;
2316
+ }
2317
+
2318
+ // Allow this server's own addresses
2319
+ const localAddresses = this.getAllLocalAddresses();
2320
+ if (localAddresses.includes(hostname)) {
2321
+ return true;
2322
+ }
2323
+
2324
+ // Allow private/LAN IP ranges (RFC 1918 + link-local)
2325
+ // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16
2326
+ if (this.isPrivateIP(hostname)) {
2327
+ return true;
2328
+ }
2329
+
2330
+ return false;
2331
+ } catch (error) {
2332
+ // Invalid URL - reject
2333
+ return false;
2334
+ }
2335
+ }
2336
+
2337
+ /**
2338
+ * Check if an IP address is in a private/LAN range
2339
+ * @param {string} ip - The IP address to check
2340
+ * @returns {boolean} True if IP is private
2341
+ */
2342
+ isPrivateIP(ip) {
2343
+ // IPv4 private ranges
2344
+ const parts = ip.split('.').map(Number);
2345
+ if (parts.length === 4 && parts.every(p => p >= 0 && p <= 255)) {
2346
+ // 10.0.0.0/8
2347
+ if (parts[0] === 10) return true;
2348
+ // 172.16.0.0/12 (172.16.x.x - 172.31.x.x)
2349
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
2350
+ // 192.168.0.0/16
2351
+ if (parts[0] === 192 && parts[1] === 168) return true;
2352
+ // 169.254.0.0/16 (link-local)
2353
+ if (parts[0] === 169 && parts[1] === 254) return true;
2354
+ // 127.0.0.0/8 (loopback)
2355
+ if (parts[0] === 127) return true;
2356
+ }
2357
+ return false;
2358
+ }
2359
+
2360
+ getServerUrl() {
2361
+ if (!this.port) return null;
2362
+ const ip = this.getLanIp();
2363
+ return `http://${ip}:${this.port}`;
2364
+ }
2365
+
2366
+ getSettings() {
2367
+ return this.settings;
2368
+ }
2369
+
2370
+ updateSettings(newSettings) {
2371
+ const result = serverSettingsService.updateServerSettings(this, newSettings);
2372
+ return result.success;
2373
+ }
2374
+
2375
+ loadSettings() {
2376
+ return serverSettingsService.loadSettings(this);
2377
+ }
2378
+
2379
+ saveSettings() {
2380
+ return serverSettingsService.saveSettings(this);
2381
+ }
2382
+
2383
+ broadcastSettingsChange(settings) {
2384
+ serverSettingsService.broadcastSettingsChange(this, settings);
2385
+ }
2386
+
2387
+ getSongRequests() {
2388
+ return this.songRequests;
2389
+ }
2390
+
2391
+ clearRequests() {
2392
+ const result = requestsService.clearRequests(this);
2393
+ return result.success;
2394
+ }
2395
+
2396
+ broadcastPlaybackPosition(position, isPlaying, songId) {
2397
+ if (this.io) {
2398
+ this.io.emit('playback-position', {
2399
+ position: position,
2400
+ isPlaying: isPlaying,
2401
+ songId: songId,
2402
+ timestamp: Date.now(),
2403
+ });
2404
+ }
2405
+ }
2406
+
2407
+ broadcastPlaybackState(playbackState) {
2408
+ if (this.io) {
2409
+ this.io.emit('playback-state-update', playbackState);
2410
+ }
2411
+ }
2412
+
2413
+ broadcastSongLoaded(songData) {
2414
+ if (this.io) {
2415
+ // Handle both AppState song format and legacy format
2416
+ const title = songData.title || songData.metadata?.title || 'Unknown';
2417
+ const artist = songData.artist || songData.metadata?.artist || 'Unknown';
2418
+ const duration = songData.duration || songData.metadata?.duration || 0;
2419
+ const path = songData.path || songData.originalFilePath || null;
2420
+ const requester = songData.requester || null;
2421
+ const queueItemId = songData.queueItemId || null;
2422
+ const isLoading = songData.isLoading || false;
2423
+
2424
+ this.io.emit('song-loaded', {
2425
+ songId: `${title} - ${artist}`,
2426
+ title,
2427
+ artist,
2428
+ duration,
2429
+ path,
2430
+ requester,
2431
+ queueItemId,
2432
+ isLoading,
2433
+ });
2434
+ }
2435
+ }
2436
+
2437
+ // Get cached songs or refresh cache if needed
2438
+ async getCachedSongs() {
2439
+ if (!this.cachedSongs) {
2440
+ console.log('📚 Loading songs into cache...');
2441
+ await this.refreshSongsCache();
2442
+ }
2443
+ return this.cachedSongs;
2444
+ }
2445
+
2446
+ // Refresh the songs cache by scanning the directory
2447
+ async refreshSongsCache() {
2448
+ try {
2449
+ console.log('🔄 Refreshing songs cache...');
2450
+ this.cachedSongs = (await this.mainApp.getLibrarySongs?.()) || [];
2451
+ this.songsCacheTime = Date.now();
2452
+
2453
+ // Reset Fuse.js instance since songs changed
2454
+ this.fuse = null;
2455
+
2456
+ console.log(`✅ Cached ${this.cachedSongs.length} songs`);
2457
+ } catch (error) {
2458
+ console.error('❌ Failed to refresh songs cache:', error);
2459
+ this.cachedSongs = [];
2460
+ }
2461
+ }
2462
+
2463
+ // Clear the songs cache (useful for manual refresh)
2464
+ clearSongsCache() {
2465
+ console.log('🗑️ Clearing songs cache...');
2466
+ this.cachedSongs = null;
2467
+ this.songsCacheTime = null;
2468
+ this.fuse = null;
2469
+ }
2470
+
2471
+ // Get or create a persistent secret key for cookie encryption
2472
+ getOrCreateSecretKey() {
2473
+ const keyName = 'server.cookieSecretKey';
2474
+ let secretKey = this.mainApp.settings?.get(keyName);
2475
+
2476
+ if (!secretKey) {
2477
+ // Generate a new 32-byte random key
2478
+ secretKey = crypto.randomBytes(32).toString('base64');
2479
+
2480
+ // Save it persistently
2481
+ if (this.mainApp.settings) {
2482
+ this.mainApp.settings.set(keyName, secretKey);
2483
+ console.log('🔐 Generated new cookie encryption key');
2484
+ }
2485
+ }
2486
+
2487
+ return secretKey;
2488
+ }
2489
+
2490
+ /**
2491
+ * Validate cookie-session from Socket.IO handshake
2492
+ * SECURITY FIX for #22: Validates admin session before allowing admin room access
2493
+ * @param {Object} socket - Socket.IO socket
2494
+ * @returns {Object|null} - Parsed session or null if invalid
2495
+ */
2496
+ validateSocketSession(socket) {
2497
+ try {
2498
+ const cookieHeader = socket.handshake.headers.cookie || '';
2499
+ if (!cookieHeader) return null;
2500
+
2501
+ // Parse cookies manually
2502
+ const cookies = {};
2503
+ cookieHeader.split(';').forEach((cookie) => {
2504
+ const [name, ...rest] = cookie.trim().split('=');
2505
+ cookies[name] = rest.join('=');
2506
+ });
2507
+
2508
+ const sessionCookie = cookies['kai-admin-session'];
2509
+ const sigCookie = cookies['kai-admin-session.sig'];
2510
+
2511
+ if (!sessionCookie || !sigCookie) {
2512
+ return null;
2513
+ }
2514
+
2515
+ // Verify signature using Keygrip (same mechanism as cookie-session)
2516
+ const Keygrip = require('keygrip');
2517
+ const keys = new Keygrip([this.getOrCreateSecretKey()]);
2518
+
2519
+ if (!keys.verify('kai-admin-session=' + sessionCookie, sigCookie)) {
2520
+ console.warn('Socket.IO: Invalid session signature');
2521
+ return null;
2522
+ }
2523
+
2524
+ // Decode base64 JSON session
2525
+ const sessionData = JSON.parse(Buffer.from(sessionCookie, 'base64').toString('utf8'));
2526
+
2527
+ return sessionData;
2528
+ } catch (err) {
2529
+ console.warn('Socket.IO session validation error:', err.message);
2530
+ return null;
2531
+ }
2532
+ }
2533
+ }
2534
+
2535
+ export default WebServer;