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.
- package/README.md +558 -0
- package/bin/loukai.js +32 -0
- package/package.json +243 -0
- package/src/main/appState.js +250 -0
- package/src/main/audioEngine.js +478 -0
- package/src/main/creator/conversionService.js +503 -0
- package/src/main/creator/downloadManager.js +1128 -0
- package/src/main/creator/ffmpegService.js +487 -0
- package/src/main/creator/installLogger.js +51 -0
- package/src/main/creator/keyDetection.js +212 -0
- package/src/main/creator/llmService.js +370 -0
- package/src/main/creator/lrclibService.js +340 -0
- package/src/main/creator/python/crepe_runner.py +189 -0
- package/src/main/creator/python/demucs_runner.py +158 -0
- package/src/main/creator/python/whisper_runner.py +172 -0
- package/src/main/creator/pythonRunner.js +268 -0
- package/src/main/creator/stemBuilder.js +491 -0
- package/src/main/creator/systemChecker.js +474 -0
- package/src/main/handlers/appHandlers.js +45 -0
- package/src/main/handlers/audioHandlers.js +33 -0
- package/src/main/handlers/autotuneHandlers.js +28 -0
- package/src/main/handlers/canvasHandlers.js +84 -0
- package/src/main/handlers/creatorHandlers.js +159 -0
- package/src/main/handlers/editorHandlers.js +98 -0
- package/src/main/handlers/effectsHandlers.js +100 -0
- package/src/main/handlers/fileHandlers.js +45 -0
- package/src/main/handlers/index.js +78 -0
- package/src/main/handlers/libraryHandlers.js +96 -0
- package/src/main/handlers/mixerHandlers.js +64 -0
- package/src/main/handlers/playerHandlers.js +39 -0
- package/src/main/handlers/preferencesHandlers.js +46 -0
- package/src/main/handlers/queueHandlers.js +81 -0
- package/src/main/handlers/rendererHandlers.js +63 -0
- package/src/main/handlers/settingsHandlers.js +42 -0
- package/src/main/handlers/webServerHandlers.js +105 -0
- package/src/main/main.js +2351 -0
- package/src/main/preload.js +252 -0
- package/src/main/settingsManager.js +139 -0
- package/src/main/statePersistence.js +193 -0
- package/src/main/utils/pathValidator.js +112 -0
- package/src/main/webServer.js +2535 -0
- package/src/native/autotune.js +417 -0
- package/src/renderer/adapters/ElectronBridge.js +677 -0
- package/src/renderer/canvas.html +80 -0
- package/src/renderer/components/App.jsx +303 -0
- package/src/renderer/components/AppRoot.jsx +37 -0
- package/src/renderer/components/AudioDeviceSettings.jsx +145 -0
- package/src/renderer/components/EffectsPanelWrapper.jsx +267 -0
- package/src/renderer/components/MixerTab.jsx +233 -0
- package/src/renderer/components/MixerTabWrapper.jsx +31 -0
- package/src/renderer/components/PortalSelect.jsx +239 -0
- package/src/renderer/components/QueueTab.jsx +116 -0
- package/src/renderer/components/RequestsListWrapper.jsx +78 -0
- package/src/renderer/components/ServerTab.jsx +472 -0
- package/src/renderer/components/SongInfoBarWrapper.jsx +77 -0
- package/src/renderer/components/StatusBar.jsx +92 -0
- package/src/renderer/components/TabNavigation.jsx +77 -0
- package/src/renderer/components/TransportControlsWrapper.jsx +69 -0
- package/src/renderer/components/creator/CreateTab.jsx +1236 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js +2 -0
- package/src/renderer/dist/assets/kaiPlayer-CoMx__a_.js.map +1 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js +2 -0
- package/src/renderer/dist/assets/microphoneEngine-BaCUhhQc.js.map +1 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js +3 -0
- package/src/renderer/dist/assets/player-DVrqp7N5.js.map +1 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js +2 -0
- package/src/renderer/dist/assets/songLoaders-BaTgGib4.js.map +1 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js +2 -0
- package/src/renderer/dist/assets/webrtcManager-BhCHWceK.js.map +1 -0
- package/src/renderer/dist/js/autoTuneWorklet.js +224 -0
- package/src/renderer/dist/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/dist/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/dist/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/dist/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/dist/renderer.css +1 -0
- package/src/renderer/dist/renderer.js +62 -0
- package/src/renderer/dist/renderer.js.map +1 -0
- package/src/renderer/dist/renderer.woff2 +0 -0
- package/src/renderer/hooks/useKeyboardShortcuts.js +154 -0
- package/src/renderer/index.html +24 -0
- package/src/renderer/index.html.backup +372 -0
- package/src/renderer/js/PlayerInterface.js +267 -0
- package/src/renderer/js/autoTuneWorklet.js +224 -0
- package/src/renderer/js/butterchurnVerify.js +46 -0
- package/src/renderer/js/canvas-app.js +114 -0
- package/src/renderer/js/cdgPlayer.js +685 -0
- package/src/renderer/js/kaiPlayer.js +1200 -0
- package/src/renderer/js/karaokeRenderer.js +3392 -0
- package/src/renderer/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/js/microphoneEngine.js +656 -0
- package/src/renderer/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/js/player.js +232 -0
- package/src/renderer/js/referencePitchTracker.js +130 -0
- package/src/renderer/js/songLoaders.js +334 -0
- package/src/renderer/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/js/webrtcManager.js +511 -0
- package/src/renderer/lib/butterchurn.min.js +6739 -0
- package/src/renderer/lib/butterchurnPresets.min.js +1 -0
- package/src/renderer/lib/cdgraphics-wrapper.js +16 -0
- package/src/renderer/lib/cdgraphics.js +299 -0
- package/src/renderer/public/js/autoTuneWorklet.js +224 -0
- package/src/renderer/public/js/micPitchDetectorWorklet.js +137 -0
- package/src/renderer/public/js/musicAnalysisWorklet.js +216 -0
- package/src/renderer/public/js/phaseVocoderWorklet.js +341 -0
- package/src/renderer/public/js/soundtouch-worklet.js +1395 -0
- package/src/renderer/react-entry.jsx +44 -0
- package/src/renderer/styles/tailwind.css +106 -0
- package/src/renderer/utils/qrCodeGenerator.js +98 -0
- package/src/renderer/vite.config.js +31 -0
- package/src/shared/adapters/BridgeInterface.js +195 -0
- package/src/shared/components/EffectsPanel.jsx +177 -0
- package/src/shared/components/LibraryPanel.jsx +701 -0
- package/src/shared/components/LineDetailCanvas.jsx +167 -0
- package/src/shared/components/LyricLine.jsx +505 -0
- package/src/shared/components/LyricRejection.jsx +84 -0
- package/src/shared/components/LyricSuggestion.jsx +80 -0
- package/src/shared/components/LyricsEditorCanvas.jsx +271 -0
- package/src/shared/components/MixerPanel.jsx +94 -0
- package/src/shared/components/PlayerControls.jsx +206 -0
- package/src/shared/components/PortalSelect.jsx +239 -0
- package/src/shared/components/QueueList.jsx +365 -0
- package/src/shared/components/QuickSearch.jsx +126 -0
- package/src/shared/components/RequestsList.jsx +121 -0
- package/src/shared/components/SongEditor.jsx +1362 -0
- package/src/shared/components/SongInfoBar.jsx +81 -0
- package/src/shared/components/ThemeToggle.jsx +106 -0
- package/src/shared/components/Toast.jsx +30 -0
- package/src/shared/components/VisualizationSettings.jsx +243 -0
- package/src/shared/constants.js +95 -0
- package/src/shared/context/BridgeContext.jsx +32 -0
- package/src/shared/contexts/AudioContext.jsx +37 -0
- package/src/shared/contexts/PlayerContext.jsx +66 -0
- package/src/shared/contexts/SettingsContext.jsx +50 -0
- package/src/shared/defaults.js +158 -0
- package/src/shared/formatUtils.js +59 -0
- package/src/shared/formatUtils.test.js +207 -0
- package/src/shared/hooks/useAppState.js +97 -0
- package/src/shared/hooks/useAudioEngine.js +264 -0
- package/src/shared/hooks/usePlayer.js +89 -0
- package/src/shared/hooks/useSettingsPersistence.js +74 -0
- package/src/shared/hooks/useWebRTC.js +118 -0
- package/src/shared/ipcContracts.js +299 -0
- package/src/shared/package.json +3 -0
- package/src/shared/services/creatorService.js +373 -0
- package/src/shared/services/creatorService.test.js +413 -0
- package/src/shared/services/editorService.js +213 -0
- package/src/shared/services/editorService.test.js +219 -0
- package/src/shared/services/effectsService.js +271 -0
- package/src/shared/services/effectsService.test.js +418 -0
- package/src/shared/services/libraryService.js +438 -0
- package/src/shared/services/libraryService.test.js +474 -0
- package/src/shared/services/mixerService.js +172 -0
- package/src/shared/services/mixerService.test.js +399 -0
- package/src/shared/services/playerService.js +221 -0
- package/src/shared/services/playerService.test.js +357 -0
- package/src/shared/services/preferencesService.js +219 -0
- package/src/shared/services/queueService.js +226 -0
- package/src/shared/services/queueService.test.js +430 -0
- package/src/shared/services/requestsService.js +155 -0
- package/src/shared/services/requestsService.test.js +362 -0
- package/src/shared/services/serverSettingsService.js +151 -0
- package/src/shared/services/settingsService.js +257 -0
- package/src/shared/services/settingsService.test.js +295 -0
- package/src/shared/state/StateManager.js +263 -0
- package/src/shared/utils/audio.js +42 -0
- package/src/shared/utils/format.js +32 -0
- package/src/shared/utils/lyricsUtils.js +162 -0
- package/src/test/setup.js +40 -0
- package/src/utils/cdgLoader.js +180 -0
- package/src/utils/m4aLoader.js +333 -0
- package/src/web/App.jsx +578 -0
- package/src/web/adapters/WebBridge.js +428 -0
- package/src/web/components/PlayerSettingsPanel.jsx +231 -0
- package/src/web/components/SongSearch.jsx +180 -0
- package/src/web/dist/assets/index-0H-RnRrV.js +51 -0
- package/src/web/dist/assets/index-0H-RnRrV.js.map +1 -0
- package/src/web/dist/assets/index-DYW2zB0u.css +1 -0
- package/src/web/dist/index.html +15 -0
- package/src/web/index.html +14 -0
- package/src/web/main.jsx +10 -0
- package/src/web/package-lock.json +1765 -0
- package/src/web/pages/SongRequestPage.jsx +619 -0
- package/src/web/styles/tailwind.css +68 -0
- package/src/web/vite.config.js +27 -0
- package/static/fonts/material-icons.woff2 +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Potion of Spirits.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Songflower _Moss Posy_.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi - Storm of the Eye _Thunder_ - mash0000 - quasi pseudo meta concentrics.png +0 -0
- package/static/images/butterchurn-screenshots/Aderrasi _ Geiss - Airhandler _Kali Mix_ - Canvas Mix.png +0 -0
- 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
- package/static/images/butterchurn-screenshots/Cope - The Neverending Explosion of Red Liquid Fire.png +0 -0
- proton lights __Krash_s beat code_ _Phat_remix02b.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Phat - cubetrace - v2.png +0 -0
- package/static/images/butterchurn-screenshots/Eo_S_ _ Zylot - skylight _Stained Glass Majesty mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - alien fish pond.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - area 51.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - infused with the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob _shiny mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - mindblob mix.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - predator-prey-spirals.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - smashing fractals _acid etching mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi - truly soft piece of software - this is generic texturing _Jelly_ .png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - astral projection.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ Martin - cascading decay swing.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ amandio c - piercing 05 - Kopie _2_ - Kopie.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi _ stahlregen - jelly showoff parade.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ fishbrain_ Geiss _ Martin - tokamak witchery.png +0 -0
- package/static/images/butterchurn-screenshots/Flexi_ martin _ geiss - dedicated to the sherwin maxawow.png +0 -0
- package/static/images/butterchurn-screenshots/Fumbling_Foo _ Flexi_ Martin_ Orb_ Unchained - Star Nova v7b.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Cauldron - painterly 2 _saturation remix_.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Reaction Diffusion 2.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Spiral Artifact.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss - Thumb Drum.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss _ Flexi _ Martin - disconnected.png +0 -0
- package/static/images/butterchurn-screenshots/Geiss_ Flexi _ Stahlregen - Thumbdrum Tokamak _crossfiring aftermath jelly mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/Goody - The Wild Vort.png +0 -0
- package/static/images/butterchurn-screenshots/Idiot - Star Of Annon.png +0 -0
- package/static/images/butterchurn-screenshots/Krash _ Illusion - Spiral Movement.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - QBikal - Surface Turbulence IIb.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - acid wiring.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - charisma.png +0 -0
- package/static/images/butterchurn-screenshots/Martin - liquid arrows.png +0 -0
- package/static/images/butterchurn-screenshots/Milk Artist At our Best - FED - SlowFast Ft AdamFX n Martin - HD CosmoFX.png +0 -0
- package/static/images/butterchurn-screenshots/ORB - Waaa.png +0 -0
- package/static/images/butterchurn-screenshots/Phat_fiShbRaiN_Eo_S_Mandala_Chasers_remix.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar - Oozing Resistance.png +0 -0
- package/static/images/butterchurn-screenshots/Rovastar _ Loadus _ Geiss - FractalDrop _Triple Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Leonardo Da Vinci_s Balloon _Flexi - merry-go-round _ techstyle_.png +0 -0
- package/static/images/butterchurn-screenshots/TonyMilkdrop - Magellan_s Nebula _Flexi - you enter first _ multiverse_.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Rewop.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained - Unified Drag 2.png +0 -0
- package/static/images/butterchurn-screenshots/Unchained _ Rovastar - Wormhole Pillars _Hall of Shadows mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Paint Spill _Music Reactive Paint Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - Star Ornament.png +0 -0
- package/static/images/butterchurn-screenshots/Zylot - True Visionary _Final Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Aderrasi - Wanderer in Curved Space - mash0000 - faclempt kibitzing meshuggana schmaltz _Geiss color mix_.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Artifact 01.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - Desert Rose 2.png +0 -0
- package/static/images/butterchurn-screenshots/_Geiss - untitled.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_049.png +0 -0
- package/static/images/butterchurn-screenshots/_Mig_085.png +0 -0
- package/static/images/butterchurn-screenshots/_Rovastar _ Geiss - Hurricane Nightmare _Posterize Mix_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _197_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _220_.png +0 -0
- package/static/images/butterchurn-screenshots/___ Royal - Mashup _431_.png +0 -0
- package/static/images/butterchurn-screenshots/cope _ martin - mother-of-pearl.png +0 -0
- package/static/images/butterchurn-screenshots/fiShbRaiN _ Flexi - witchcraft 2_0.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - bouncing balls _double mindblob neon mix_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - mom_ why the sky looks different today.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - patternton_ district of media_ capitol of the united abstractions of fractopia.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - swing out on the spiral.png +0 -0
- package/static/images/butterchurn-screenshots/flexi - what is the matrix.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ amandio c - organic12-3d-2_milk.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ fishbrain - neon mindblob grafitti.png +0 -0
- package/static/images/butterchurn-screenshots/flexi _ geiss - pogo cubes vs_ tokamak vs_ game of life _stahls jelly 4_5 finish_.png +0 -0
- package/static/images/butterchurn-screenshots/high-altitude basket unraveling - singh grooves nitrogen argon nz_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - The Bridge of Khazad-Dum.png +0 -0
- package/static/images/butterchurn-screenshots/martin - angel flight.png +0 -0
- package/static/images/butterchurn-screenshots/martin - another kind of groove.png +0 -0
- package/static/images/butterchurn-screenshots/martin - bombyx mori.png +0 -0
- package/static/images/butterchurn-screenshots/martin - castle in the air.png +0 -0
- package/static/images/butterchurn-screenshots/martin - chain breaker.png +0 -0
- package/static/images/butterchurn-screenshots/martin - disco mix 4.png +0 -0
- package/static/images/butterchurn-screenshots/martin - extreme heat.png +0 -0
- package/static/images/butterchurn-screenshots/martin - frosty caves 2.png +0 -0
- package/static/images/butterchurn-screenshots/martin - fruit machine.png +0 -0
- package/static/images/butterchurn-screenshots/martin - ghost city.png +0 -0
- package/static/images/butterchurn-screenshots/martin - glass corridor.png +0 -0
- package/static/images/butterchurn-screenshots/martin - infinity _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mandelbox explorer - high speed demo version.png +0 -0
- package/static/images/butterchurn-screenshots/martin - mucus cervix.png +0 -0
- package/static/images/butterchurn-screenshots/martin - reflections on black tiles.png +0 -0
- package/static/images/butterchurn-screenshots/martin - stormy sea _2010 update_.png +0 -0
- package/static/images/butterchurn-screenshots/martin - witchcraft reloaded.png +0 -0
- package/static/images/butterchurn-screenshots/martin _ flexi - diamond cutter _prismaticvortex_com_ - camille - i wish i wish i wish i was constrained.png +0 -0
- package/static/images/butterchurn-screenshots/martin _shadow harlequins shape code_ - fata morgana.png +0 -0
- package/static/images/butterchurn-screenshots/martin_ flexi_ fishbrain _ sto - enterstate _random mashup_.png +0 -0
- package/static/images/butterchurn-screenshots/sawtooth grin roam.png +0 -0
- package/static/images/butterchurn-screenshots/shifter - dark tides bdrv mix 2.png +0 -0
- 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
- package/static/images/butterchurn-screenshots/suksma - heretical crosscut playpen.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - uninitialized variabowl _hydroponic chronic_.png +0 -0
- package/static/images/butterchurn-screenshots/suksma - vector exp 1 - couldn_t not.png +0 -0
- package/static/images/butterchurn-screenshots/yin - 191 - Temporal singularities.png +0 -0
- package/static/images/logo-512.png +0 -0
- package/static/images/logo.png +0 -0
- package/static/loukai-logo.png +0 -0
- 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;
|