From badafc91e35f13ac7512ae72391d28a113ec2c64 Mon Sep 17 00:00:00 2001 From: Collin Kasbergen Date: Wed, 22 Jul 2026 13:23:22 +0200 Subject: [PATCH] Initial commit - Navi.FM v3 --- .DS_Store | Bin 0 -> 18436 bytes .idea/.gitignore | 5 + .idea/coliefm-v3.iml | 12 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/modules.xml | 8 + Dockerfile | 68 + app/.DS_Store | Bin 0 -> 8196 bytes app/__pycache__/analyzer.cpython-311.pyc | Bin 0 -> 2954 bytes app/__pycache__/database.cpython-311.pyc | Bin 0 -> 3112 bytes app/__pycache__/main.cpython-311.pyc | Bin 0 -> 10388 bytes app/__pycache__/navidrome.cpython-311.pyc | Bin 0 -> 6349 bytes app/__pycache__/queue_manager.cpython-311.pyc | Bin 0 -> 5827 bytes app/__pycache__/worker.cpython-311.pyc | Bin 0 -> 4725 bytes app/analyzer.py | 59 + app/crypto.py | 27 + app/database.py | 69 + app/main.py | 520 ++++++ app/navidrome.py | 153 ++ app/queue_manager.py | 111 ++ app/requirements.txt | 13 + app/settings.db | 0 app/worker.py | 160 ++ data/.DS_Store | Bin 0 -> 6148 bytes data/navifm.db | Bin 0 -> 233472 bytes docker-compose.yml | 17 + docker-entrypoint.sh | 37 + frontend/.gitignore | 24 + frontend/.vscode/extensions.json | 3 + frontend/README.md | 5 + frontend/index.html | 12 + frontend/package-lock.json | 1130 +++++++++++++ frontend/package.json | 19 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.vue | 241 +++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/vite.svg | 1 + frontend/src/assets/vue.svg | 1 + frontend/src/components/AdminPanel.vue | 1405 +++++++++++++++++ frontend/src/components/AudioPlayer.vue | 972 ++++++++++++ frontend/src/components/HelloWorld.vue | 95 ++ frontend/src/components/SetupWizard.vue | 347 ++++ frontend/src/components/ThreeOrb.vue | 315 ++++ frontend/src/main.js | 4 + frontend/src/store/player.js | 173 ++ frontend/src/store/settings.js | 146 ++ frontend/vite.config.js | 8 + htpasswd | 1 + icecast.xml | 50 + liquidsoap/radio.liq | 63 + output.txt | 5 + supervisord.conf | 47 + test_liq.liq | 15 + test_liq.py | 9 + test_navidrome_api.py | 3 + 55 files changed, 6384 insertions(+) create mode 100644 .DS_Store create mode 100644 .idea/.gitignore create mode 100644 .idea/coliefm-v3.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/modules.xml create mode 100644 Dockerfile create mode 100644 app/.DS_Store create mode 100644 app/__pycache__/analyzer.cpython-311.pyc create mode 100644 app/__pycache__/database.cpython-311.pyc create mode 100644 app/__pycache__/main.cpython-311.pyc create mode 100644 app/__pycache__/navidrome.cpython-311.pyc create mode 100644 app/__pycache__/queue_manager.cpython-311.pyc create mode 100644 app/__pycache__/worker.cpython-311.pyc create mode 100644 app/analyzer.py create mode 100644 app/crypto.py create mode 100644 app/database.py create mode 100644 app/main.py create mode 100644 app/navidrome.py create mode 100644 app/queue_manager.py create mode 100644 app/requirements.txt create mode 100644 app/settings.db create mode 100644 app/worker.py create mode 100644 data/.DS_Store create mode 100644 data/navifm.db create mode 100644 docker-compose.yml create mode 100644 docker-entrypoint.sh create mode 100644 frontend/.gitignore create mode 100644 frontend/.vscode/extensions.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/assets/vue.svg create mode 100644 frontend/src/components/AdminPanel.vue create mode 100644 frontend/src/components/AudioPlayer.vue create mode 100644 frontend/src/components/HelloWorld.vue create mode 100644 frontend/src/components/SetupWizard.vue create mode 100644 frontend/src/components/ThreeOrb.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/store/player.js create mode 100644 frontend/src/store/settings.js create mode 100644 frontend/vite.config.js create mode 100644 htpasswd create mode 100644 icecast.xml create mode 100644 liquidsoap/radio.liq create mode 100644 output.txt create mode 100644 supervisord.conf create mode 100644 test_liq.liq create mode 100644 test_liq.py create mode 100644 test_navidrome_api.py diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9fba6aa42892fbd3b808d819d402fdd57fbd68fd GIT binary patch literal 18436 zcmeGkZEPGz^}V%|?8Vz=?b>YqHDfuO1irN1gcL7=D-m0GHTMEL{!$e$v$<;~3A z-*?I3~-^|XukC_7iOqGm&fFuB@6dBYtfYyxwae{pmD}g;{u_?4r z4N{CzY$bev0J64UF6L}E*VI~8NDfF2tR)U8)EO4(+ofngw*LE7F6WB2 zx^DdjHL|fyyCrt)_{hh!lNl@TI%(^IIiqj#=iS8-DxUVtvSlt#iNa!E^$D+|K4q zd!j6G&KX2*$TKN5#(e7KG^;q}8rG~uH4Bzwj5*nY`4FoM z?pRD$^q;7rabJH-(_8ec8sWJ2bW;(-!h!oWO>foxhpaKbDAHMJ5JIG-J_TQi886_c@iF`?ehxp6 zPvBSaYxs4162Fb##qZ%$co~0)Kf)=^Zc*B=ac8w1X;5kjhjQm7UsQHBz0-qAnF3W@FwsM_Fg1spxT4o$}|io;kYcm>yv; zZcwD4wh1Ux$pOg$$$=^d*!mEs$Y3&rD+2PRgAyh_u;)yK;DPdNX<>Eo5GF&oA|O4X zfC81EKxO-msORWZ9}mmP5UvO)&;j``6P@svG1O2{paaHvbHJp4GL;;V9BAeM+xy$V zf;`DedC~xUlA3y82?}73*ls@5Z6qz26BokOk@WG#|! zI08v})MR>sgmZT><(3@7=eTL{K#i!88GZA`i~H`mw>v&Cxc^dj{9^y$K!11qzQMjr zmm+FcqW9pLnFYJxxL@F}3>dCHaEmBc)5j^dDnrN|G9;MTlCHd5y*d@0$V^UY(`#Ox zx_s*$u^qei4&3+Ao$e)g8D4`w!{6X7 z_$Ry#?_djR*nwMd2kymQ?8AP%9}nUo((z8<7><*Emm`hNMh9Ke>n`D^a0MU5&)^sF zOQh3%1;2scA${&C{66V(KgFNnGhB=NHU0*Fi?88d@J;+HUd6ZZ9eh`5SGFiSl)IFj zO0UwV3@H1Q2bE*WX=PH$DRaua_`s?<-}&kX$1}?EzxnLKHAPx6q!r@^vtq2ekWFINhuFdvrkM`~!+WDEiWVRl^b)W%X z;}#VrA8Z3uo3)W+UTXqWc1~W|Bpqd^^ z8z|xS21-5~k9>P}tcWu4@0Mpj@{jb`3 z_&Qd;a$Esls_5r`)b)z_fjc~fAF;i&y{n6SiR3EUvUNtkEtkzN%z4bLKn_aROW7s! z+?;0_>0&ld-oF~JW=BKQGp6y-wW_h@BDqjIuT^kz$2`+?IK9!?S>FsYHTlVAAL=ys zzy@Yv#F=$h^@}#)WG1tomv!dM)be@m4IOn-ZVB}rx{%tVpaTKKir|BeULwoN(wZB<5^Cm{f^C&G(rCNKX?#z!&;o; z!xULy?N$WjD3q`!JWz(&6pyn4)$?;61RrK)7AU_WAmu20;1>Zum|F=_|ECKpwU^-K L|0=Al|5f-GRUt^z literal 0 HcmV?d00001 diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..b58b603 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,5 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/coliefm-v3.iml b/.idea/coliefm-v3.iml new file mode 100644 index 0000000..8b8c395 --- /dev/null +++ b/.idea/coliefm-v3.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..48f9b4e --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c8e8219 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# ---- Stage 1: Build Frontend ---- +FROM node:22-alpine AS frontend-builder +WORKDIR /frontend +COPY frontend/package*.json ./ +RUN npm install +COPY frontend . +RUN npm run build + +# ---- Stage 2: Final Image ---- +FROM savonet/liquidsoap:v2.2.5 + +# Switch to root to install packages +USER root + +# Install required packages +# - icecast2: streaming server +# - supervisor: process manager +# - python3-venv, python3-pip: for the backend +# - ffmpeg, libsndfile1: backend audio processing +RUN apt-get update && apt-get install -y \ + icecast2 \ + supervisor \ + python3 \ + python3-venv \ + python3-pip \ + ffmpeg \ + libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +# Set up Icecast directories and permissions +RUN mkdir -p /var/log/icecast2 /etc/icecast2 /var/run/icecast2 \ + && chown -R liquidsoap:liquidsoap /var/log/icecast2 /etc/icecast2 /var/run/icecast2 + +COPY icecast.xml /etc/icecast2/icecast.xml + +# Prepare python environment +WORKDIR /app +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +COPY app/requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend code +COPY app /app/app + +# Copy liquidsoap config +COPY liquidsoap /app/liquidsoap + +# Copy built frontend from Stage 1 +COPY --from=frontend-builder /frontend/dist /app/frontend/dist + +# Copy supervisord config +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +# Give liquidsoap user permissions to /app and /data +RUN mkdir -p /data \ + && chown -R liquidsoap:liquidsoap /app /data /opt/venv + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Expose required ports: 8080 (API/Web UI), 8000 (Icecast) +EXPOSE 8080 8000 + +# Start via entrypoint (runs as root to fix permissions, then starts supervisord) +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..90427844573411408a16942b3dc52b3b7a743bcd GIT binary patch literal 8196 zcmeHMTWl0n7(U;$z>FPWS_>3pvRjv1u-ILwTm`q=a+6k&ZRxfY>+a6bj!b9j&g_;; zYD$c^NTTtU_(Z%uHfl6V42nLPc#EbXkwik&M11jy7fe9^nKMghXE z41^g7GZ1DV%s`ldn;`>qW_wMX=G^Dfa11jLX5fFB0dIeZQuk;wprf4nTL%sP6@aLB z1@Ifq=^o(QL<5=(=qRVYDf#5L2Lz@Ft{C9vB#(OPM3Vs><&>K$p~&J@Sje0 zQD2=Q$!R!-83;3QV+OqPk^DRRARD^={_YIrOgl%?Q@J72aNKzOEQE^688c^zv&1U# zKzhI)PP7-@(eOEj%cG(^`m#YaXYabB!()84*A=AdOi%cDWC zBe2<%qB}8lXr@zY;h|Ne%b}eshbG-tCC^s7GTl9LZ^p>mR@ykK_bGE7if<>edkT7q z;_AxVW^vGR`jqN?*31{ptgAPhrl(A6bDey_HqAY@W4MNGb!S}tuv>OCL6qrn?1J0k zJL^-FqNDHC$(K>mTgfB2s_~w-d&qO+?9&Sl(O5Fh1C_?+EQl;wv1;|Ydy`vtwm&#N zSE;U%YvuV}CBrdtrrtShXzoCF*2(LZW?20_lw`{_4jOtvsz!s!w zBqb|$IFumnqESvriX(L#A|!CrCV8!>Xi}${&FLmz+8}Qdm0eO-iAT;CYvubyDU-Jc z2Pqhbucg#hQTalA$a`>G)^T?a=~nrbQtA#->~RLN1%3B0;jnDcCW`(COY5G=R?Tqj zf}f2C>Q%MdbJeVAx<)}Ss#{p2rqxbS`JQYP!zvkseEKYS0f@0O@?y!Ad>T8WU+`ys zj-}eNLO{LKPG!s3dOF=Y*guegZca0yp%6@LoV!XjabuvAzkGzxKHwXjCmCTth>2;D+X=obcf z1VXv!4or+pI70js`w10Yu%8HHQx{!i`;MLW%WXG((fu&TE3?~c=gnUbUAnTdY5nHr zYhdKqEyMNtGVqZc4 zE~N{WXJL-*nx{9)sc%Aqr{b;q*KwlP{xGC@G>|0&I?5?YsQu?Z1X%yuO~Tr#su zOJtX=QRJ`ze24(HVGtQ$i^Nf3A9T#IM;%FF0TBZbC=le38v!FgkW*)uUXm-=K!?Nk z-pu>VdvD%6^4n-M1jx93^N-s?0D$l9g*(_6#Pfd;;tQYvh01}A9;P;EXU%NTG=MCm zw>ei$p`XwIJSE@W(9R5Vw|(yZw%?_O`RV?yV?VGFbZ}@RtS~U5urRvGD%_{+2Cq=J zK+^Z4ZR8`&-jh`o2RE}hm@dltJ?ukJFCjHUxrY!;0t5{CJ~v2cUw|Ochq&8)+8}`{ z^fTrOk$q~*JL69zpej|Rb$5f{?S7DrzT6NS7?eRjW4-Bc>@ZbU4-O;^Mt82Ew=+nQ zR}+ou!my6QP4t&fi1$zX5mY%<@XDU%d!n%O;*7?SPwpOgX%tnT9v(<+_r3S*_9YCT z;(O-zMsvy|{}xaJ|Dvh^BVhOkaaDt_@H8?weLxx1v*0Paq2WAuyoj}QGSXvBukcIlV;z`w$R=xPmPgC}(x^4?cf(0?_M zF(|Kx23;YyU5yzrg}d)G4ixvu3#a5`w!BIyTvgQH9N68KT-u<$8r8TFZ;ZIyVSL(` zYQo@*pb<8r!+AGEuRRYh;pHjeecvtc2GHjR=ga~$U6u5O!HyC!z*j(t8q!zbG3&KJ zfjgv&RHeU2cAM%^JJb%?W$u6-D#=&oS5-Mzu0U;CR;T5Xk}XWz&5Nv*>0ay7rt?sj z6<5OfVw z>lJ#PQ14dgcL`dp(5nPpO;X5y?!+2jP&KHhq51&k3PtFcPWmu&x0uJweOSgms6w<= z#-dh)86C;FbOx$ADr6NL?dd8|)v|gSv$@iyR*79hsDMZ;c8FPZYq~=Jb~-tZ{kiO2 zRM2E1R*)6U-i5M`LpDqktC{;al-07TrpsyuVp=U?zpBUx$z{xK5)B>uH;HTsK^)55 zLxsGY&U~U{pNzKh@-Aky67d)1HIgPMt7wcwhdIYTH$+WGP~9Rw4-waGodkrL98@t| zEaz0reF$}ka3ZVfa0|&gRMNRZNl~Gu;o!C13@qx|0ud`BB9R8~4MuG#*2$*hZm@q9*yoTMjwmJK2RSsY!3-L= z6*y8E5FxuwVhU=ux#^dUUt$Y#vAEE?2Z$~e%Q*UCk#=ehy-if@R;taCQhErw>_txc zAvRWv)bAYp`cP@cuC!uTtk{*$IFm9{Kly-krnYjDxNw}faB%Z5)l6JyC6=wk@@D~) zGe7BsM?Qy-|FFN&3ZJ*a=evLjp6$fP>Resk59}?}7R)ts?FV~fZ@xBft`U3jiQ3r{ zaqd{0JCF~rHpRJ?c-ay!o4!t5swE#^-Cu7kA5zW9RBJM25g32Vioa$0+rsag&NSIJ zAOHOFqrAxwYr^QG!U;cf%+EAdoBT|RKWFjhOr|4_{vFkJ>e`-BGg{)TCC-{YA|4xi z^w$$%@mN?qT>38F6c$^;iY2U=Y-e)jo7ca7^I-O$7r(yPnq0Id7n^|zqA@aA`|w0s zJeGPI(qc{ zODfyhV~(3~^? zoj|n7FEu6zAoD=(;Z`7J1yapGsx2f;-TnMbMwcV%1Z#Ey{_Q${T?9vB==v4r=*-CV zW#(v^BXm+guMswmXqjxWd`Zi0kq&@4XN9HV%}YC3RVnOf*ndyfWL-zt_d?qLf`jaT zR5(N)rTzR0D39!Bj5)QGFP70+n`X#!#;lShEy=Fqe}m@)0<=QFFM*YOK`G_n@6h{X zX}3tNMnIRLD5?$kht7wjl9HMr(VJ~>rs>YMpMSVxmL3`Pk6Zp(%Rk!%6qRa^Pwhvn s@p)@}>EX3cZ&>Wef5Al4oox_ry0gnJP*VL?7ZB9A?Vwiz^d52Se^yMRhX4Qo literal 0 HcmV?d00001 diff --git a/app/__pycache__/database.cpython-311.pyc b/app/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f60d19e30ee172d5e312d3692f89e58a1e619475 GIT binary patch literal 3112 zcmb7G-EZ5-5noc2L{X9@`8$alw7s4b*^V9D*=c)igT}GlTphdUIXxgO3WCyZ9NHA= zTT)4Eq&PkVg?unt;ea9riULB>`clLX_jr%@Cy<4J6BanYKwsK72hKxqPo1SKJGN?| z-Q~{C&g>V5znS4@>9m5NeD#Z8uWrT=`X?WB3XH>Y_yYl=y>m>R3Z)wsa*5mT-t)I`Wjz$>aEAQ2I?n(S_&y6=N)2 zrbKs%MywUXA}n^xHmenj#g<)aSSu_#Z`!)c;xo2w65V3)_br#K5Ly&i7L&56Q{6Ql z60M{=1Y{|PIF4ak6@86RCePlydsCZz|AWog#3a^TeN%BhG7Xo!H#s@Esg!NgAonX1 zxWsbh2c*2FIeLX??gO|Hp69!jZObBMSJUZ=Q~$(WT))k^qF=YXDB? zXaG#-X!_6941^j5%U!n~JXbS>697hqMekC*yvAZK@0`0uktNruQPJs!wnjEs6uvKc zTomXaSd#-Ij>&z!YP!J6R@F51l1V7{*`?wzlQqraIMSG+X%!n+P0pt@?W3x09%<5= z=JG}DA?0pIIYgLfU>0=^*92G4scSeci|S^nTA?ym%ecw`Z(B@S*8!;UP^eqFxv@zw z<-v(XO6wIC;rEuz>V1dBh(+ki2Fp3?fMu$iTA5fbwc(_+Z8^l%FtHrN-C#*mcU*1V z)Heub3hx2RRm=T5I)shUH`z&jeLZv}?rsz78?+C^A;|*cybE~qj%%xb#9Q1?WNL%; zSY!5c$xloNiRory+M8*mdTaCb>y3Utl@C(+W-9N^wbFgH59{-d@A>IMkS;XS1#kXn z-qzCg5=_Vp)E?GXwm#ne*t^|I=4#jL`K=q*4Z&E5uCvJfN-&1AuwZC_`uG3%#`LAuyX z7vVa?mv>@2)#t81JQWO2HHW8sX`t1Au|B^u{d~&rzZUdgYxZCB7UAf=3-$cY(DU#3 zz2iaec(ZrhTWIBm>VrG+=Q%$&7Uag7xiRnOywa;3E1n7&omhN01L|jxC%Quh1dP+! z;wWI8aq~Eq0OnB^UnZ{0qpA#FoihM+feyg-OGTMp;J3KQ$t6xkI5`F;eH(@>`cOBk zgpPAncO2rbKYEgL%vs+6?m38 z{2h>I$UP2e2(2FTZpT(I2Mc@RX{!jx?PP7xuO82Bbb>NSPl}s5;laLe#z`3Exf?7g zErH#bOdf-KYW5n~ki)+iXQJ&endcsCP>1rg&ZHS|6%Z0v=NX78NBK@SP41aI>7B7B ziyM{!PFbQqf(<62IoE-B=xZtVbm5P315kG*kghbPE5Lj$$xj!*l1Ba^jr=X|OCy0) zY)VDH^MqFIGU!{d-{B`f7LahpsHao@Z&3n0K_8-Lf&hKv*x<1AxSU?mHql%E^Xy0H zapWF)Bou`u2)n7}cK*I&mvxgx%cdQsbrF{we)hL12ONv7@Ss(qKZU`&&>YVE3o6(s zF|c)c`?5E4AV#8nt)6U6=5=3I{vdxMZz6eGnU zIt^pSe;9NV{thY|l*8~1kKmXJ18_Su_ngd}uxW+z?I{0`46jS9han#5cezAbwGE36 zE&uY)%{#Yl&n@25_@Bzm&E+}mS0CJ=@A0KS;pF?Agh4iRG7gCFtmZLW}IfBI>mf`9tiPxjZ|+5TZ&Z43v=(PnbglUo-T z8}zS_K6~<)C!gQ`!VC%v&BB6zagjf0Xg@Jn`&LW3GU|`cA4Eh&e2IX(;>3&b zU_MfN(x`qB{c_&_P07!cgG{-ZDSN5?_>dnT3gQ=<@e7`GAPouQ2dJCWW?ypYs}t2b hl5pt&b(0!?$)&GOR4GvyJwV4qjtYGTsGEdt^&iO8=1Kqn literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-311.pyc b/app/__pycache__/main.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b82839c89ad6982a6b386b594c792b5d7425327 GIT binary patch literal 10388 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 apOi4{%#a33Vz^qG2$v?0Im(YBg8u;Uxo5us literal 0 HcmV?d00001 diff --git a/app/__pycache__/worker.cpython-311.pyc b/app/__pycache__/worker.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e8fc515cec1adf468c152069a2d454433cc2567 GIT binary patch literal 4725 zcmb_gZ)_9E6`!@e8*gGKPS}9KBw6kV;E*^3B^(5fKj9LR90Vbtf>c$scsJn0|8913 zA(lha_N3-ckzZO9Wp8Dlfz>m3^@-G36!yL( zuNe!wOy5~amSd^p$y8K`oAfy>M5n`)8AuAB#zdS-NE6X`OiC)y=NFPfd@dtR2s2_V z6yJK0?1L%)7Hx-eZd@7{&5+vBNoco~cl?*-HH>``uZ$$ZWQCrY(!BX7k$O z3Z}|pPu3&fR_e+M&Uv%$JX@BPIk86cK7e;Vv_18+n(RAk*1of5ok+jq+kK+nsrjh> z0eJbL?df?M_Nih=%4^KhPJdD;>k=C>a6jfZr>*_P9L!MR!SW1FU&DARPtN7K zQmyAWse!EPD(2T`xSjLNH74mTwzJeM zy6V1&W+|nmJl|A9i5E9izdJ`QeH-S+&Sm(sbIE8dd6`$pJ(c-MxS{-5>8eH|k(d93 zS>UeH(bP;*!Q{M{u9#@jN=%7MX3hEJbTT!YG#LRaFeF_PIh zh;a(@vcvLzJ{Eu(!vT{{;}}>-U-?y9LRqLH!YxoYb(9E;0m&PR(*|XQ?KUh(|vuauWyyxu5*1)j;q{JjXSDy zM;8ZI8(Xfo-fUeQFu3N$K_l2wKz@IlMRJ}Op_cpm@9h8M;O!%Kk30|cJPY;wwf!%< zo``?z{ch^n-eD~?qK8JFhfY5Woz_BU^w61{*VqK3Ya82E`F11JmiOH4H(KAjf91}V z{On^^Ywg!t`;E5u?_a%hb;bKQthF7|+YYVQ*w+3{@DIV2j>m$wZNI*4ztOVo{)Iai z@{=o3RGaJ7yg}U?RK3Ah2Vke< zAZ*?526qj1qVGFvhnn2q?`s~UWGd<9IFM^x%h*<(f5TVaV@taYWzGAc{uD11mE7k#aW0H|g7gOKQfXB*-m z%vAw3Ni{&jhA$Yw+;h*nl;+Zm2u;~xyHYdjazY0Fk5Wcj00E~*0O;L#4q&sUtdylD zY|BC7bx26uvI zkPdAb(sQmQcerWn+)$?Rn3XQuXx-0e+nzIW0v2dEygMNW5_h;M<={$^ea`l$2x2oau?rOv)L~dVw61d3h!pg*1M0CLYOj z+P#xPEG~(id_n-E5_v)9g<=9P@=A)Ar(@|zCh!qt1M!p~@_j17NX-FM1RBD5*$FjA0!0K!}JSo-baOqr*?mKifT^amRJ;_;YUL88@PFAM4!5fand}E7az1TMaa; z8;4igA9X?l^-14%2aB8*7|{bGYGB0J+M6HzdRX1sTdX>c)Y-QBudb*sS^wY66Z z_UXYs)v5&sbwu8-cTx+U(u1ee;3-%_aLdiv$d{_&1 z>cLJm*!kuMZnL_jQ{%dHu1mEl3~g+_S$k{m_4=Fjx%wBC$-?4jF(YuXp#mxqDxl^dKj0oa2mc#j;Hr?+nh^!KZpknWx9JL&1d-&&%sGp-a$QXB z3FOqEufRB$(gS~cW%BjG^Tq4sw}cbLtXam#Ky()V?*hl|7=d zM|Aec!a%9F_sKbx9njbTogG*hFg(ofhkrZ#o6*lk7e*Zq3()Zz=0jnBF*5809+Q9u zA2@LDFA1_#3}3`!kl~j;oQhu`r67ijaf#e}YhjkL-y%FfI!LVe$$Kb~5@+JlVSEA} z$yUkBKp?NACOv`c+kHP8;#Sq=1_>Z}D%3$?j`Y;w4Rg2Qf; O3zUayD4_MkGW>rtSn#O; literal 0 HcmV?d00001 diff --git a/app/analyzer.py b/app/analyzer.py new file mode 100644 index 0000000..6b00382 --- /dev/null +++ b/app/analyzer.py @@ -0,0 +1,59 @@ +import librosa + +# Monkeypatch for pyloudnorm which uses the deprecated scipy.signal.hann +import scipy.signal +import scipy.signal.windows +if not hasattr(scipy.signal, 'hann'): + scipy.signal.hann = scipy.signal.windows.hann + +import pyloudnorm as pyln +import numpy as np + +def analyze_audio(file_path: str) -> dict: + """Analyzes an audio file and returns metadata.""" + try: + # Load audio (mono, original sample rate to preserve frequency content) + y, sr = librosa.load(file_path, sr=None, mono=True) + + # BPM + tempo, _ = librosa.beat.beat_track(y=y, sr=sr) + if isinstance(tempo, np.ndarray): + bpm = float(tempo[0]) + else: + bpm = float(tempo) + + # Key extraction using chroma (simplified root note extraction) + chroma = librosa.feature.chroma_cqt(y=y, sr=sr) + key_idx = np.argmax(np.sum(chroma, axis=1)) + keys = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + key = keys[key_idx] + + # Energy (RMS) + rms = librosa.feature.rms(y=y) + energy = float(np.mean(rms)) + + # Spectral Centroid (Brightness) + cent = librosa.feature.spectral_centroid(y=y, sr=sr) + spectral_centroid = float(np.mean(cent)) + + # Onset Density (Danceability/Rhythm) + onset_env = librosa.onset.onset_strength(y=y, sr=sr) + onsets = librosa.onset.onset_detect(onset_envelope=onset_env, sr=sr) + duration_sec = len(y) / sr + onset_density = len(onsets) / duration_sec if duration_sec > 0 else 0 + + # LUFS + meter = pyln.Meter(sr) # create BS.1770 meter + lufs = meter.integrated_loudness(y) + + return { + "bpm": bpm, + "key": key, + "energy": energy, + "spectral_centroid": spectral_centroid, + "onset_density": float(onset_density), + "lufs": float(lufs) + } + except Exception as e: + print(f"Error analyzing {file_path}: {e}") + return None diff --git a/app/crypto.py b/app/crypto.py new file mode 100644 index 0000000..3e537ad --- /dev/null +++ b/app/crypto.py @@ -0,0 +1,27 @@ +import os +from cryptography.fernet import Fernet, InvalidToken + +def get_fernet(): + key = os.environ.get("ENCRYPTION_KEY") + if not key: + # Fallback to an ephemeral key if missing to prevent crashing, + # though data encrypted with this will be lost on restart + key = Fernet.generate_key() + os.environ["ENCRYPTION_KEY"] = key.decode('utf-8') + return Fernet(key) + +def encrypt_password(password: str) -> str: + if not password: + return password + f = get_fernet() + return f.encrypt(password.encode('utf-8')).decode('utf-8') + +def decrypt_password(token: str) -> str: + if not token: + return token + f = get_fernet() + try: + return f.decrypt(token.encode('utf-8')).decode('utf-8') + except InvalidToken: + # If decryption fails, assume it's a legacy plaintext password + return token diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..2ec3638 --- /dev/null +++ b/app/database.py @@ -0,0 +1,69 @@ +import os +from sqlalchemy import create_engine, Column, String, Float, Boolean, Integer +from sqlalchemy.orm import declarative_base +from sqlalchemy.orm import sessionmaker + +DATA_DIR = os.getenv("DATA_DIR", "/data") +SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DATA_DIR, 'navifm.db')}" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, expire_on_commit=False) + +Base = declarative_base() + +class Track(Base): + __tablename__ = "tracks" + + id = Column(String, primary_key=True, index=True) # Navidrome Track ID + title = Column(String, index=True) + artist = Column(String, index=True) + album = Column(String) + duration = Column(Integer) + path = Column(String) # Navidrome file path or relative path + + # Analysis Status + analyzed = Column(Boolean, default=False) + + # Analyzed Metadata + bpm = Column(Float, nullable=True) + key = Column(String, nullable=True) + lufs = Column(Float, nullable=True) + energy = Column(Float, nullable=True) + spectral_centroid = Column(Float, nullable=True) + onset_density = Column(Float, nullable=True) + + # Play stats + last_played = Column(Integer, default=0) # timestamp + play_count = Column(Integer, default=0) + +class Setting(Base): + __tablename__ = "settings" + + key = Column(String, primary_key=True) + value = Column(String) + +def get_setting(db, key: str, default: str = None): + setting = db.query(Setting).filter(Setting.key == key).first() + return setting.value if setting else default + +def set_setting(db, key: str, value: str): + setting = db.query(Setting).filter(Setting.key == key).first() + if setting: + setting.value = value + else: + setting = Setting(key=key, value=value) + db.add(setting) + db.commit() + +def init_db(): + os.makedirs(DATA_DIR, exist_ok=True) + Base.metadata.create_all(bind=engine) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..aea2995 --- /dev/null +++ b/app/main.py @@ -0,0 +1,520 @@ +import os +import time +from fastapi import FastAPI, Request, HTTPException, Depends +from fastapi.responses import PlainTextResponse +from fastapi.security import HTTPBasic, HTTPBasicCredentials +import secrets +import asyncio +from pydantic import BaseModel +import httpx +from fastapi.responses import StreamingResponse + +from database import init_db, SessionLocal, Track, get_setting, set_setting +from worker import start_workers +from queue_manager import get_active_profile, set_active_profile, get_next_track, AVAILABLE_PROFILES +from navidrome import navidrome_client +import bcrypt +import crypto + +# Initialize DB and start workers on startup +init_db() + +db = SessionLocal() +try: + if get_setting(db, "theme_color") is None: + set_setting(db, "theme_color", "purple") + if get_setting(db, "app_title") is None: + set_setting(db, "app_title", "NAVI.FM") + if get_setting(db, "app_tagline") is None: + set_setting(db, "app_tagline", "The least intelligent radio software you’ll ever love.") + if get_setting(db, "info_modal_text") is None: + set_setting(db, "info_modal_text", "Navi.FM is an independent radio project that completely ditches traditional, static playlists. Instead, it relies on a custom, lightweight engine to intelligently shuffle and balance the unique mood, texture, and energy of every single track inside a privately curated, hand-picked music library.\n\nHow it Works\nInstead of just playing songs at random, Navi.FM uses smart audio analysis to understand how different tracks relate to each other and maps out a seamless, organic journey from one song to the next. It constantly schedules the upcoming tracks in real-time, steering the soundscape to keep the stream flowing perfectly.\n\nBehind the Scenes\nThere are no big media companies or human radio hosts pulling the strings. The entire station is a custom-built passion project run entirely out of a home setup.") + if get_setting(db, "artist_separation_enabled") is None: + set_setting(db, "artist_separation_enabled", "false") + if get_setting(db, "artist_separation_count") is None: + set_setting(db, "artist_separation_count", "5") + + # Migration: Encrypt legacy plaintext Navidrome passwords + existing_nav_pwd = get_setting(db, "navidrome_password") + if existing_nav_pwd: + # Fernet tokens start with gAAAAA + if not existing_nav_pwd.startswith("gAAAAA"): + print("Migrating legacy plaintext Navidrome password to Fernet encryption...") + set_setting(db, "navidrome_password", crypto.encrypt_password(existing_nav_pwd)) +finally: + db.close() +start_workers() + +app = FastAPI(title="Navi.FM Backend") +security = HTTPBasic(auto_error=False) + +FAILED_LOGIN_ATTEMPTS = {} +MAX_LOGIN_ATTEMPTS = 5 +LOCKOUT_DURATION = 300 # 5 minutes + +def verify_admin(request: Request, credentials: HTTPBasicCredentials = Depends(security)): + client_ip = request.client.host + now = time.time() + + # Check rate limits + if client_ip in FAILED_LOGIN_ATTEMPTS: + FAILED_LOGIN_ATTEMPTS[client_ip] = [t for t in FAILED_LOGIN_ATTEMPTS[client_ip] if now - t < LOCKOUT_DURATION] + if len(FAILED_LOGIN_ATTEMPTS[client_ip]) >= MAX_LOGIN_ATTEMPTS: + raise HTTPException(status_code=429, detail="Too many failed login attempts. Please try again later.") + + if not credentials: + raise HTTPException(status_code=401, detail="Not authenticated") + + db = SessionLocal() + try: + admin_user = get_setting(db, "admin_user") + admin_pass_hash = get_setting(db, "admin_password_hash") + finally: + db.close() + + if not admin_user or not admin_pass_hash: + raise HTTPException(status_code=401, detail="Admin not setup") + + correct_username = secrets.compare_digest(credentials.username, admin_user) + + # Use bcrypt to verify password + try: + correct_password = bcrypt.checkpw( + credentials.password.encode('utf-8'), + admin_pass_hash.encode('utf-8') + ) + except Exception: + correct_password = False + + if not (correct_username and correct_password): + FAILED_LOGIN_ATTEMPTS.setdefault(client_ip, []).append(now) + raise HTTPException( + status_code=401, + detail="Incorrect username or password", + ) + + # Clear attempts on success + if client_ip in FAILED_LOGIN_ATTEMPTS: + del FAILED_LOGIN_ATTEMPTS[client_ip] + + return credentials.username + +class AdminSetup(BaseModel): + username: str + password: str + +class NavidromeSetup(BaseModel): + url: str + username: str + password: str + +@app.get("/api/setup/status") +async def get_setup_status(): + db = SessionLocal() + try: + admin_setup = get_setting(db, "admin_user") is not None + nav_setup = get_setting(db, "navidrome_url") is not None + analyzed_tracks = db.query(Track).filter(Track.analyzed == True).count() + return { + "is_setup": admin_setup and nav_setup and analyzed_tracks > 10, + "admin_setup": admin_setup, + "navidrome_setup": nav_setup, + "analyzed_tracks": analyzed_tracks + } + finally: + db.close() + +@app.post("/api/setup/admin") +async def setup_admin(data: AdminSetup): + db = SessionLocal() + try: + if get_setting(db, "admin_user"): + raise HTTPException(status_code=400, detail="Admin already setup") + + hashed_password = bcrypt.hashpw(data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + set_setting(db, "admin_user", data.username) + set_setting(db, "admin_password_hash", hashed_password) + return {"status": "success"} + finally: + db.close() + +@app.post("/api/setup/navidrome") +async def setup_navidrome(data: NavidromeSetup): + db = SessionLocal() + try: + if get_setting(db, "navidrome_url"): + raise HTTPException(status_code=400, detail="Navidrome already setup") + + from navidrome import NavidromeClient + is_valid = NavidromeClient.verify_credentials(data.url, data.username, data.password) + if not is_valid: + raise HTTPException(status_code=400, detail="Could not connect to Navidrome server. Please check your URL and credentials.") + + set_setting(db, "navidrome_url", data.url.rstrip("/")) + set_setting(db, "navidrome_user", data.username) + set_setting(db, "navidrome_password", crypto.encrypt_password(data.password)) + return {"status": "success"} + finally: + db.close() + +class ProfileUpdate(BaseModel): + profile: str + +@app.get("/api/profiles") +async def get_profiles(_: str = Depends(verify_admin)): + db = SessionLocal() + try: + active_profile = get_active_profile(db) + track_count = db.query(Track).filter(Track.analyzed == True).count() + total_track_count = navidrome_client.get_total_songs() + finally: + db.close() + + return { + "profiles": AVAILABLE_PROFILES, + "active_profile": active_profile, + "track_count": track_count, + "total_track_count": total_track_count + } + +@app.post("/api/profile") +async def update_profile(data: ProfileUpdate, _: str = Depends(verify_admin)): + valid_ids = [p["id"] for p in AVAILABLE_PROFILES] + if data.profile not in valid_ids: + raise HTTPException(status_code=400, detail="Invalid profile") + + set_active_profile(data.profile) + return {"status": "success", "profile": data.profile} + +class SettingsUpdate(BaseModel): + theme_color: str + app_title: str + app_tagline: str + info_modal_text: str + favicon_svg_overlay: str = "" + favicon_svg_color: str = "white" + artist_separation_enabled: bool = False + artist_separation_count: int = 5 + auto_pilot_schedule: str = "[]" + +@app.get("/api/settings") +async def read_settings(): + db = SessionLocal() + try: + return { + "theme_color": get_setting(db, "theme_color", "purple"), + "app_title": get_setting(db, "app_title", "COLIE.FM"), + "app_tagline": get_setting(db, "app_tagline", ""), + "info_modal_text": get_setting(db, "info_modal_text", ""), + "favicon_svg_overlay": get_setting(db, "favicon_svg_overlay", ""), + "favicon_svg_color": get_setting(db, "favicon_svg_color", "white"), + "artist_separation_enabled": get_setting(db, "artist_separation_enabled", "false") == "true", + "artist_separation_count": int(get_setting(db, "artist_separation_count", "5")), + "auto_pilot_schedule": get_setting(db, "auto_pilot_schedule", "[]") + } + finally: + db.close() + +@app.post("/api/settings") +async def update_settings(data: SettingsUpdate, _: str = Depends(verify_admin)): + db = SessionLocal() + try: + set_setting(db, "theme_color", data.theme_color) + set_setting(db, "app_title", data.app_title) + set_setting(db, "app_tagline", data.app_tagline) + set_setting(db, "info_modal_text", data.info_modal_text) + set_setting(db, "favicon_svg_overlay", data.favicon_svg_overlay) + set_setting(db, "favicon_svg_color", data.favicon_svg_color) + set_setting(db, "artist_separation_enabled", "true" if data.artist_separation_enabled else "false") + set_setting(db, "artist_separation_count", str(data.artist_separation_count)) + set_setting(db, "auto_pilot_schedule", data.auto_pilot_schedule) + finally: + db.close() + return {"status": "success"} + +@app.get("/api/history") +async def get_history(_: str = Depends(verify_admin)): + """ + Returns the last 10 tracks played for the admin/debug panel. + """ + now_data = await now_playing() + return {"history": now_data.get("history", [])} + +import socket + +@app.post("/api/skip") +async def skip_track(_: str = Depends(verify_admin)): + """ + Sends a telnet command to Liquidsoap to skip the current track. + """ + try: + # Connect to liquidsoap telnet server + with socket.create_connection(("127.0.0.1", 1234), timeout=2) as sock: + # Send the skip command for the navifm_queue output node + sock.sendall(b"navifm_queue.skip\r\nquit\r\n") + # Read the 'Done' response to ensure it was processed + sock.recv(4096) + return {"status": "success", "message": "Track skipped"} + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to skip track: {str(e)}") + +@app.get("/next", response_class=PlainTextResponse) +async def next_track(request: Request): + """ + Called by Liquidsoap to get the URL of the next track to play. + Restricted to localhost for security. + """ + if request.client.host != "127.0.0.1": + raise HTTPException(status_code=403, detail="Forbidden: Internal endpoint") + + track_data = get_next_track() + if track_data: + # Generate the stream URL from Navidrome + url = navidrome_client.get_stream_url(track_data["id"]) + + lufs = track_data.get("lufs") + if lufs is not None: + # Calculate ratio to hit target of -14 LUFS (common streaming standard) + target_lufs = -14.0 + delta_db = target_lufs - lufs + # Cap the adjustment between -15dB and +10dB to avoid extreme boosting + delta_db = min(max(delta_db, -15.0), 10.0) + ratio = 10 ** (delta_db / 20.0) + + # Use Liquidsoap's annotate protocol to pass the custom amplify metadata + return f'annotate:liq_amplify="{ratio:.4f}":{url}' + + return url + else: + # Return empty or some fallback if queue is entirely empty + # Sleep for 5 seconds to prevent liquidsoap from looping instantly and spamming logs + await asyncio.sleep(5) + return "" + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + + +@app.get("/stream") +async def proxy_stream(request: Request): + """ + Proxies the Icecast audio stream so the frontend doesn't need to connect to port 8000 directly. + """ + client = httpx.AsyncClient(timeout=None) + + # Pre-flight request to get headers + req = client.build_request("GET", "http://127.0.0.1:8000/stream") + response = await client.send(req, stream=True) + + headers = {} + for k, v in response.headers.items(): + if k.lower().startswith('icy-') or k.lower() == 'content-type': + headers[k] = v + + async def stream_generator(): + try: + async for chunk in response.aiter_bytes(): + if await request.is_disconnected(): + break + yield chunk + except Exception: + pass + finally: + await response.aclose() + await client.aclose() + + return StreamingResponse(stream_generator(), headers=headers) + +import time +current_playing_state = { + "rid": None, + "start_time": int(time.time()), + "track_dict": None, + "history": None +} + + +@app.get("/api/internal/track_changed") +async def track_changed(request: Request, title: str = None, artist: str = None): + """ + Webhook called by Liquidsoap exactly when a new track starts playing. + Restricted to localhost for security. + """ + if request.client.host != "127.0.0.1": + raise HTTPException(status_code=403, detail="Forbidden: Internal endpoint") + + global current_playing_state + + # 1. Pop previous track into history + if current_playing_state["track_dict"] and current_playing_state["track_dict"].get("title") != "Unknown": + old_track = current_playing_state["track_dict"].copy() + old_track["last_played"] = int(time.time()) + if current_playing_state["history"] is None: + current_playing_state["history"] = [] + + current_playing_state["history"].insert(0, old_track) + if len(current_playing_state["history"]) > 10: + current_playing_state["history"].pop() + + # 2. Fetch new track metadata from DB + db = SessionLocal() + try: + if title and artist: + track = db.query(Track).filter(Track.title == title, Track.artist == artist).first() + if track: + current_playing_state["track_dict"] = { + "id": track.id, + "title": track.title, + "artist": track.artist, + "bpm": track.bpm, + "energy": track.energy, + "lufs": track.lufs + } + else: + current_playing_state["track_dict"] = {"id": "", "title": title, "artist": artist, "bpm": 120, "energy": 0.5, "lufs": -14.0} + else: + current_playing_state["track_dict"] = {"id": "", "title": "Unknown", "artist": "Unknown", "bpm": 120, "energy": 0.5, "lufs": -14.0} + + current_playing_state["start_time"] = int(time.time()) + finally: + db.close() + + return {"status": "ok"} + + +@app.get("/api/now-playing") +async def now_playing(): + """ + Returns the metadata of the currently playing track for the frontend UI. + """ + global current_playing_state + + # If UI requests before first track_changed webhook hits, return a fallback + if not current_playing_state["track_dict"]: + db = SessionLocal() + try: + from queue_manager import get_current_track + current = get_current_track() + if current: + current_playing_state["track_dict"] = current + else: + current_playing_state["track_dict"] = {"id": "", "title": "Unknown", "artist": "Unknown", "bpm": 120, "energy": 0.5, "lufs": -14.0} + finally: + db.close() + + # Seed history from DB if empty on first boot + if current_playing_state["history"] is None: + db = SessionLocal() + try: + recent_tracks = db.query(Track).filter(Track.last_played > 0).order_by(Track.last_played.desc()).limit(15).all() + h_list = [] + v_time = int(time.time()) + + # Very rough approximation just so UI isn't empty on boot + for t in recent_tracks[:10]: + if t.title != current_playing_state["track_dict"].get("title"): + h_list.append({ + "id": t.id, + "title": t.title, + "artist": t.artist, + "last_played": v_time + }) + v_time -= (t.duration if t.duration else 180) + current_playing_state["history"] = h_list + finally: + db.close() + + # Return state + res = current_playing_state["track_dict"].copy() + res["history"] = current_playing_state["history"] + res["server_time"] = int(time.time()) + return res + +from fastapi.staticfiles import StaticFiles +import os +import re +import time +from fastapi.responses import HTMLResponse, Response + +frontend_dist = "/app/frontend/dist" + +@app.get("/favicon.svg") +async def serve_favicon(): + db = SessionLocal() + try: + theme_color = get_setting(db, "theme_color", "purple") + overlay = get_setting(db, "favicon_svg_overlay", "") + svg_color = get_setting(db, "favicon_svg_color", "white") + finally: + db.close() + + palette = { + "purple": {"glow": "#8b5cf6"}, + "blue": {"glow": "#3b82f6"}, + "cyan": {"glow": "#06b6d4"}, + "green": {"glow": "#10b981"}, + "yellow": {"glow": "#eab308"}, + "orange": {"glow": "#f97316"}, + "red": {"glow": "#ef4444"}, + "pink": {"glow": "#ec4899"} + } + glow = palette.get(theme_color, palette["purple"])["glow"] + + overlay_content = "" + if overlay: + # Remove hardcoded width/height + processed = re.sub(r'\b(width|height)=("[^"]*"|\'[^\']*\')', '', overlay, flags=re.IGNORECASE) + processed = re.sub(r'{processed}' + + svg_string = f''' + + + {overlay_content} +''' + + return Response(content=svg_string, media_type="image/svg+xml", headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) + +@app.get("/") +async def serve_index(): + index_path = os.path.join(frontend_dist, "index.html") + if not os.path.exists(index_path): + return HTMLResponse("Frontend not built") + + db = SessionLocal() + try: + app_title = get_setting(db, "app_title", "NAVI.FM") + finally: + db.close() + + with open(index_path, "r") as f: + html = f.read() + + # Replace title + html = re.sub(r".*?", f"{app_title}", html) + + # Inject favicon link + cache_bust = int(time.time()) + favicon_link = f'' + html = html.replace("", f" {favicon_link}\n") + + return HTMLResponse(content=html) + +# Serve the Vue frontend built files if they exist +if os.path.exists(frontend_dist): + # Mount frontend dist at root but do not serve index.html directly for / + app.mount("/", StaticFiles(directory=frontend_dist, html=False), name="static") diff --git a/app/navidrome.py b/app/navidrome.py new file mode 100644 index 0000000..03bc861 --- /dev/null +++ b/app/navidrome.py @@ -0,0 +1,153 @@ +import os +import hashlib +import random +import string +import requests +from typing import List, Dict + +class NavidromeClient: + def __init__(self): + self.version = "1.16.1" + self.client_name = "navifm" + + @staticmethod + def verify_credentials(url: str, username: str, password: str) -> bool: + salt = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(6)) + token = hashlib.md5((password + salt).encode('utf-8')).hexdigest() + params = { + "u": username, + "t": token, + "s": salt, + "v": "1.16.1", + "c": "navifm", + "f": "json" + } + try: + url_clean = url.rstrip('/') + response = requests.get(f"{url_clean}/rest/ping", params=params, timeout=10) + response.raise_for_status() + data = response.json() + if "subsonic-response" in data and data["subsonic-response"]["status"] == "ok": + return True + return False + except Exception as e: + print(f"Credentials verification failed: {e}") + return False + + @property + def url(self): + from database import SessionLocal, get_setting + db = SessionLocal() + try: + return get_setting(db, "navidrome_url", "") + finally: + db.close() + + @property + def user(self): + from database import SessionLocal, get_setting + db = SessionLocal() + try: + return get_setting(db, "navidrome_user", "") + finally: + db.close() + + @property + def password(self): + from database import SessionLocal, get_setting + import crypto + db = SessionLocal() + try: + enc_pwd = get_setting(db, "navidrome_password", "") + return crypto.decrypt_password(enc_pwd) + finally: + db.close() + + def _generate_salt(self, length=6): + return ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(length)) + + def _get_auth_params(self): + salt = self._generate_salt() + token = hashlib.md5((self.password + salt).encode('utf-8')).hexdigest() + return { + "u": self.user, + "t": token, + "s": salt, + "v": self.version, + "c": self.client_name, + "f": "json" + } + + def _request(self, endpoint: str, params: Dict = None): + if not self.url or not self.user or not self.password: + print("Navidrome credentials not configured.") + return None + + url = f"{self.url}/rest/{endpoint}" + payload = self._get_auth_params() + if params: + payload.update(params) + + try: + response = requests.get(url, params=payload, timeout=10) + response.raise_for_status() + data = response.json() + if "subsonic-response" in data and data["subsonic-response"]["status"] == "ok": + return data["subsonic-response"] + else: + print(f"Subsonic Error: {data}") + return None + except Exception as e: + print(f"Request failed: {e}") + return None + + def get_random_songs(self, count=100) -> List[Dict]: + """Fetch random songs to discover tracks for the library.""" + res = self._request("getRandomSongs", {"size": count}) + if res and "randomSongs" in res and "song" in res["randomSongs"]: + return res["randomSongs"]["song"] + return [] + + def get_total_songs(self) -> int: + """Attempts to get the total number of songs from Navidrome.""" + if not self.url or not self.user or not self.password: + return 0 + try: + url_clean = self.url.rstrip('/') + # Try native Navidrome API (requires real password, not subsonic token) + login_res = requests.post(f"{url_clean}/auth/login", json={"username": self.user, "password": self.password}, timeout=5) + if login_res.ok: + token = login_res.json().get("token") + if token: + headers = {"x-access-token": token} + song_res = requests.get(f"{url_clean}/api/song?_end=1", headers=headers, timeout=5) + if song_res.ok and "x-total-count" in song_res.headers: + return int(song_res.headers["x-total-count"]) + except Exception as e: + print(f"Failed native API count: {e}") + + return 0 + + def get_stream_url(self, song_id: str) -> str: + """Returns the full authenticated URL to stream a song.""" + params = self._get_auth_params() + params["id"] = song_id + # We also might want to specify format, but usually Navidrome handles it + query_string = "&".join(f"{k}={v}" for k, v in params.items()) + return f"{self.url}/rest/stream?{query_string}" + + def download_song(self, song_id: str, dest_path: str): + """Downloads a song to disk for analysis.""" + stream_url = self.get_stream_url(song_id) + try: + with requests.get(stream_url, stream=True, timeout=30) as r: + r.raise_for_status() + with open(dest_path, 'wb') as f: + for chunk in r.iter_content(chunk_size=8192): + f.write(chunk) + return True + except Exception as e: + print(f"Failed to download song {song_id}: {e}") + return False + +navidrome_client = NavidromeClient() diff --git a/app/queue_manager.py b/app/queue_manager.py new file mode 100644 index 0000000..fcf6212 --- /dev/null +++ b/app/queue_manager.py @@ -0,0 +1,111 @@ +import time +import random +from sqlalchemy.sql.expression import func +from database import SessionLocal, Track, Setting + +def get_active_profile(db): + setting = db.query(Setting).filter(Setting.key == "active_profile").first() + return setting.value if setting else "harmonic" + +def set_active_profile(profile_name: str): + db = SessionLocal() + try: + setting = db.query(Setting).filter(Setting.key == "active_profile").first() + if not setting: + setting = Setting(key="active_profile", value=profile_name) + db.add(setting) + else: + setting.value = profile_name + db.commit() + finally: + db.close() + +def get_next_track(): + db = SessionLocal() + try: + profile = get_active_profile(db) + track = None + + # Only select from analyzed tracks + base_query = db.query(Track).filter(Track.analyzed == True) + + # Avoid playing tracks that were played in the last hour + one_hour_ago = int(time.time()) - 3600 + pool_query = base_query.filter(Track.last_played < one_hour_ago) + + # Artist Separation Logic + artist_sep_enabled = db.query(Setting).filter(Setting.key == "artist_separation_enabled").first() + if artist_sep_enabled and artist_sep_enabled.value == "true": + artist_sep_count_setting = db.query(Setting).filter(Setting.key == "artist_separation_count").first() + sep_count = int(artist_sep_count_setting.value) if artist_sep_count_setting else 5 + + recent_tracks = db.query(Track).filter(Track.last_played > 0).order_by(Track.last_played.desc()).limit(sep_count).all() + recent_artists = [t.artist for t in recent_tracks if t.artist] + + if recent_artists: + filtered_query = pool_query.filter(~Track.artist.in_(recent_artists)) + if filtered_query.count() > 0: + pool_query = filtered_query + + if pool_query.count() == 0: + # Fallback if all tracks were played recently or no tracks analyzed + pool_query = base_query + + if profile == "high_energy": + # Pick from most energetic tracks + tracks = pool_query.order_by(Track.energy.desc()).limit(50).all() + if tracks: + track = random.choice(tracks) + elif profile == "chill": + # Pick from least energetic tracks + tracks = pool_query.order_by(Track.energy.asc()).limit(50).all() + if tracks: + track = random.choice(tracks) + elif profile == "harmonic": + # Get last played track to match key/bpm + last_played = base_query.order_by(Track.last_played.desc()).first() + if last_played and last_played.key and last_played.bpm: + # Find matching key and similar BPM (+- 10) + track = pool_query.filter(Track.key == last_played.key)\ + .filter(Track.bpm.between(last_played.bpm - 10, last_played.bpm + 10))\ + .order_by(func.random()).first() + + # Fallback to random if no track found for profile or profile is 'random' + if not track: + track = pool_query.order_by(func.random()).first() + + if track: + # Update last played + track.last_played = int(time.time()) + track.play_count += 1 + db.commit() + return {"id": track.id, "lufs": track.lufs} + return None + finally: + db.close() + +def get_current_track(): + db = SessionLocal() + try: + # The currently playing track is approximated as the most recently queued track + track = db.query(Track).order_by(Track.last_played.desc()).first() + if track and track.last_played > 0: + return { + "id": track.id, + "title": track.title, + "artist": track.artist, + "bpm": track.bpm, + "energy": track.energy, + "lufs": track.lufs + } + return None + finally: + db.close() + +# Define available profiles for the WebUI +AVAILABLE_PROFILES = [ + {"id": "harmonic", "name": "Harmonic Mix", "description": "Attempts to match the key and BPM of the previously played track."}, + {"id": "high_energy", "name": "High Energy", "description": "Prioritizes tracks with high RMS energy."}, + {"id": "chill", "name": "Chill", "description": "Prioritizes low-energy, ambient tracks."}, + {"id": "random", "name": "Random", "description": "Plays tracks randomly."} +] diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..76aa417 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,13 @@ +fastapi +uvicorn[standard] +requests +httpx +librosa +soundfile +SQLAlchemy +jinja2 +python-dotenv +pydantic +pyloudnorm +bcrypt +cryptography diff --git a/app/settings.db b/app/settings.db new file mode 100644 index 0000000..e69de29 diff --git a/app/worker.py b/app/worker.py new file mode 100644 index 0000000..5122b96 --- /dev/null +++ b/app/worker.py @@ -0,0 +1,160 @@ +import time +import os +import threading +import subprocess +from database import SessionLocal, Track +from navidrome import navidrome_client +from analyzer import analyze_audio +from queue_manager import get_active_profile, set_active_profile +import json +import datetime + +def sync_tracks_loop(): + while True: + try: + if not navidrome_client.url: + time.sleep(10) + continue + + print("Syncing tracks from Navidrome...") + songs = navidrome_client.get_random_songs(count=200) + db = SessionLocal() + added = 0 + for song in songs: + # Check if song exists + existing = db.query(Track).filter(Track.id == song["id"]).first() + if not existing: + new_track = Track( + id=song["id"], + title=song.get("title", "Unknown"), + artist=song.get("artist", "Unknown"), + album=song.get("album", "Unknown"), + duration=song.get("duration", 0), + path=song.get("path", "") + ) + db.add(new_track) + added += 1 + if added > 0: + db.commit() + db.close() + print(f"Sync complete. Added {added} new tracks.") + except Exception as e: + print(f"Error syncing tracks: {e}") + + # Run every hour + time.sleep(3600) + +def analyze_tracks_loop(): + while True: + try: + if not navidrome_client.url: + time.sleep(10) + continue + + db = SessionLocal() + # Find an un-analyzed track + track = db.query(Track).filter(Track.analyzed == False).first() + if track: + print(f"Analyzing track: {track.artist} - {track.title}") + temp_path = f"/tmp/{track.id}.audio" + + # Download + if navidrome_client.download_song(track.id, temp_path): + # Analyze + metadata = analyze_audio(temp_path) + if metadata: + track.bpm = metadata["bpm"] + track.key = metadata["key"] + track.energy = metadata["energy"] + track.spectral_centroid = metadata["spectral_centroid"] + track.onset_density = metadata["onset_density"] + track.lufs = metadata["lufs"] + track.analyzed = True + db.commit() + print("Analysis successful.") + else: + # Mark as analyzed anyway so we don't get stuck in a loop, or maybe add an error flag + track.analyzed = True + db.commit() + print("Analysis failed, marked as analyzed to skip.") + + # Cleanup + if os.path.exists(temp_path): + os.remove(temp_path) + else: + print("Download failed.") + else: + # No tracks to analyze, sleep for a while + time.sleep(60) + + db.close() + except Exception as e: + print(f"Error in analysis loop: {e}") + time.sleep(60) + +def scheduler_loop(): + """ + Checks the auto_pilot_schedule every minute and changes the profile if it matches the current time. + """ + while True: + try: + # Wait until the start of the next minute + now = datetime.datetime.now() + sleep_seconds = 60 - now.second + time.sleep(sleep_seconds) + + # Now it's a new minute + now = datetime.datetime.now() + current_time_str = now.strftime("%H:%M") + + db = SessionLocal() + from database import get_setting + schedule_str = get_setting(db, "auto_pilot_schedule", "[]") + db.close() + + schedule = json.loads(schedule_str) + for rule in schedule: + if rule.get("time") == current_time_str: + target_profile = rule.get("profile") + if target_profile: + print(f"[Scheduler] Time is {current_time_str}. Switching profile to: {target_profile}") + set_active_profile(target_profile) + + except Exception as e: + print(f"Error in scheduler loop: {e}") + time.sleep(60) + +def startup_manager_loop(): + """ + Waits until at least 10 tracks have been analyzed before starting Liquidsoap. + This prevents Liquidsoap from rapidly requesting /next and queuing the exact same track + repeatedly when the database is empty or only has 1 track during initial setup. + """ + while True: + try: + db = SessionLocal() + count = db.query(Track).filter(Track.analyzed == True).count() + db.close() + + if count >= 10: + print(f"[Startup Manager] {count} tracks analyzed. Starting Liquidsoap...") + # Run supervisorctl to start liquidsoap + subprocess.run(["supervisorctl", "start", "liquidsoap"], check=False) + break + else: + print(f"[Startup Manager] Waiting for initial analysis... ({count}/10 tracks analyzed)") + time.sleep(10) + except Exception as e: + print(f"Error in startup manager: {e}") + time.sleep(10) + +def start_workers(): + t1 = threading.Thread(target=sync_tracks_loop, daemon=True) + t2 = threading.Thread(target=analyze_tracks_loop, daemon=True) + t3 = threading.Thread(target=scheduler_loop, daemon=True) + t4 = threading.Thread(target=startup_manager_loop, daemon=True) + t1.start() + t2.start() + t3.start() + t4.start() + diff --git a/data/.DS_Store b/data/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 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 literal 0 HcmV?d00001 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..68b2262 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + navifm: + build: + context: . + dockerfile: Dockerfile + container_name: navifm + ports: + - "8080:8080" # API and Web UI + volumes: + - ./data:/data + environment: + - DATA_DIR=/data + - TZ=Europe/Amsterdam + + # If you change ICECAST_PASSWORD, you must also update icecast.xml! + - ICECAST_PASSWORD=hackme + restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..365a922 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +# Generate or load secure Icecast password +if [ -n "$ICECAST_PASSWORD" ] && [ "$ICECAST_PASSWORD" != "hackme" ]; then + echo "Using user-provided Icecast password." + echo "$ICECAST_PASSWORD" > /data/.icecast_password +elif [ ! -f /data/.icecast_password ]; then + echo "Generating secure Icecast password..." + # Generate a 24-character random alphanumeric password + cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 24 | head -n 1 > /data/.icecast_password +fi + +export ICECAST_PASSWORD=$(cat /data/.icecast_password) + +# Inject password into icecast.xml +sed -i "s/.*<\/source-password>/${ICECAST_PASSWORD}<\/source-password>/g" /etc/icecast2/icecast.xml +sed -i "s/.*<\/relay-password>/${ICECAST_PASSWORD}<\/relay-password>/g" /etc/icecast2/icecast.xml +sed -i "s/.*<\/admin-password>/${ICECAST_PASSWORD}<\/admin-password>/g" /etc/icecast2/icecast.xml + +# Generate or load symmetric encryption key for DB credentials +if [ -n "$ENCRYPTION_KEY" ]; then + echo "Using user-provided Encryption Key." + echo "$ENCRYPTION_KEY" > /data/.encryption_key +elif [ ! -f /data/.encryption_key ]; then + echo "Generating secure Encryption Key..." + # Generate a Fernet-compatible URL-safe base64 encoded 32-byte key + python3 -c "import base64; import os; print(base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8'))" > /data/.encryption_key +fi + +export ENCRYPTION_KEY=$(cat /data/.encryption_key) + +# Fix permissions on /data volume so the liquidsoap user can read/write the SQLite database +chown -R liquidsoap:liquidsoap /data + +# Execute the main process (supervisord) +exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1511959 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,5 @@ +# Vue 3 + Vite + +This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..72c02af --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1130 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "three": "^0.185.1", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.139.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.139.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.5", + "@rolldown/binding-darwin-arm64": "1.1.5", + "@rolldown/binding-darwin-x64": "1.1.5", + "@rolldown/binding-freebsd-x64": "1.1.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", + "@rolldown/binding-linux-arm64-gnu": "1.1.5", + "@rolldown/binding-linux-arm64-musl": "1.1.5", + "@rolldown/binding-linux-ppc64-gnu": "1.1.5", + "@rolldown/binding-linux-s390x-gnu": "1.1.5", + "@rolldown/binding-linux-x64-gnu": "1.1.5", + "@rolldown/binding-linux-x64-musl": "1.1.5", + "@rolldown/binding-openharmony-arm64": "1.1.5", + "@rolldown/binding-wasm32-wasi": "1.1.5", + "@rolldown/binding-win32-arm64-msvc": "1.1.5", + "@rolldown/binding-win32-x64-msvc": "1.1.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/vite": { + "version": "8.1.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.17", + "rolldown": "~1.1.5", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..cba47ba --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "three": "^0.185.1", + "vue": "^3.5.39" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.7", + "vite": "^8.1.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..ac8ef98 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,241 @@ + + + + + diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue new file mode 100644 index 0000000..0beef97 --- /dev/null +++ b/frontend/src/components/AdminPanel.vue @@ -0,0 +1,1405 @@ + + + + + diff --git a/frontend/src/components/AudioPlayer.vue b/frontend/src/components/AudioPlayer.vue new file mode 100644 index 0000000..1dc04b2 --- /dev/null +++ b/frontend/src/components/AudioPlayer.vue @@ -0,0 +1,972 @@ + + + + + diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 0000000..f91553d --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/components/SetupWizard.vue b/frontend/src/components/SetupWizard.vue new file mode 100644 index 0000000..35dee63 --- /dev/null +++ b/frontend/src/components/SetupWizard.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/frontend/src/components/ThreeOrb.vue b/frontend/src/components/ThreeOrb.vue new file mode 100644 index 0000000..01e4b9e --- /dev/null +++ b/frontend/src/components/ThreeOrb.vue @@ -0,0 +1,315 @@ + + + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..01433bc --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/frontend/src/store/player.js b/frontend/src/store/player.js new file mode 100644 index 0000000..74a2663 --- /dev/null +++ b/frontend/src/store/player.js @@ -0,0 +1,173 @@ +import { reactive } from 'vue' +import { settingsState } from './settings' + +const streamUrl = `/stream` + +export const playerState = reactive({ + isPlaying: false, + isLoading: false, + isMuted: false, + volume: 0.8, + error: null, + isStreamOnline: false, + currentTrack: null, + bpm: 120, + intensity: 0.5, + brightness: 0.5 +}) + +let audio = null +let pollInterval = null + +// Fetch currently playing track metadata +export const fetchNowPlaying = async () => { + try { + const response = await fetch('/api/now-playing') + if (response.ok) { + playerState.isStreamOnline = true + const data = await response.json() + playerState.currentTrack = data + + if (data.listeners !== undefined) { + settingsState.listenerCount = data.listeners + } + + if (data.bpm) playerState.bpm = data.bpm + + if (data.energy !== undefined) { + playerState.intensity = Math.max(0.2, Math.min(1.0, data.energy)) + } + + if (data.lufs !== undefined) { + const lufsVal = data.lufs + let b = (lufsVal + 24) / 19 + playerState.brightness = Math.max(0.2, Math.min(1.0, b)) + } + + if (data.history) { + settingsState.history = data.history + } + + if (data.server_time) { + settingsState.serverTimeOffset = Math.floor(Date.now() / 1000) - data.server_time + } + } else { + playerState.isStreamOnline = false + } + } catch (err) { + console.error('Failed to fetch now-playing data:', err) + playerState.isStreamOnline = false + } +} + +// Initialize and bind audio listeners +const initAudio = () => { + if (audio) return + + audio = new Audio() + + audio.addEventListener('playing', () => { + playerState.isLoading = false + playerState.isPlaying = true + playerState.error = null + }) + + audio.addEventListener('waiting', () => { + playerState.isLoading = true + }) + + audio.addEventListener('error', (e) => { + console.error('Audio element error:', e) + const code = audio.error ? audio.error.code : 'unknown' + let msg = `Stream offline (code: ${code})` + if (code === 4) msg = `Stream offline or unsupported format.` + if (code === 2) msg = `Network error connecting to stream.` + playerState.error = msg + playerState.isLoading = false + playerState.isPlaying = false + cleanupAudio() + }) +} + +// Cleanup audio resource +export const cleanupAudio = () => { + if (audio) { + try { + audio.pause() + audio.removeAttribute('src') + audio.load() + } catch (e) { + console.error('Error during cleanup:', e) + } + audio = null + } +} + +// Toggle Play / Pause +export const togglePlay = () => { + if (playerState.isPlaying) { + pauseStream() + } else { + playStream() + } +} + +// Play stream (dynamically loading it to get fresh live feed) +export const playStream = async () => { + playerState.error = null + playerState.isLoading = true + + try { + initAudio() + // Append timestamp to bypass browser caching of dead stream connections + audio.src = `${streamUrl}?cb=${Date.now()}` + audio.volume = playerState.isMuted ? 0 : playerState.volume + audio.load() + + await audio.play() + } catch (err) { + console.error('Playback failed:', err) + playerState.error = `Playback blocked (${err.name || 'unknown error'})` + playerState.isLoading = false + playerState.isPlaying = false + cleanupAudio() + } +} + +// Pause stream (unloads stream to save bandwidth and keep live alignment) +export const pauseStream = () => { + playerState.isPlaying = false + playerState.isLoading = false + cleanupAudio() +} + +// Toggle Mute +export const toggleMute = () => { + playerState.isMuted = !playerState.isMuted + if (audio) { + audio.volume = playerState.isMuted ? 0 : playerState.volume + } +} + +// Set Volume directly +export const setVolume = (newVal) => { + playerState.volume = newVal + if (audio) { + audio.volume = playerState.isMuted ? 0 : newVal + } +} + +// Start Background Polling (Called exactly once in App.vue or AudioPlayer) +export const startPolling = () => { + fetchNowPlaying() + if (!pollInterval) { + pollInterval = setInterval(fetchNowPlaying, 5000) + } +} + +export const stopPolling = () => { + if (pollInterval) { + clearInterval(pollInterval) + pollInterval = null + } +} diff --git a/frontend/src/store/settings.js b/frontend/src/store/settings.js new file mode 100644 index 0000000..e762043 --- /dev/null +++ b/frontend/src/store/settings.js @@ -0,0 +1,146 @@ +import { reactive } from 'vue' + +export const settingsState = reactive({ + themeColor: 'purple', + appTitle: 'NAVI.FM', + appTagline: 'The least intelligent radio software you’ll ever love.', + infoModalText: '', + artistSeparationEnabled: false, + artistSeparationCount: 5, + faviconSvgOverlay: '', + faviconSvgColor: 'white', + autoPilotSchedule: [], + history: [], + listenerCount: 0, + isLoading: false, + error: null, + serverTimeOffset: 0 +}) + +// The 8 primary theme colors available for branding +export const THEME_PALETTE = { + purple: { + glow: '#8b5cf6', + accent: '#c4b5fd', + dark: '#4c1d95' + }, + blue: { + glow: '#3b82f6', + accent: '#93c5fd', + dark: '#1e3a8a' + }, + cyan: { + glow: '#06b6d4', + accent: '#67e8f9', + dark: '#164e63' + }, + green: { + glow: '#10b981', + accent: '#6ee7b7', + dark: '#064e3b' + }, + yellow: { + glow: '#eab308', + accent: '#fde047', + dark: '#713f12' + }, + orange: { + glow: '#f97316', + accent: '#fdba74', + dark: '#7c2d12' + }, + red: { + glow: '#ef4444', + accent: '#fca5a5', + dark: '#7f1d1d' + }, + pink: { + glow: '#ec4899', + accent: '#f9a8d4', + dark: '#831843' + } +} + +export const fetchSettings = async () => { + settingsState.isLoading = true + try { + const res = await fetch('/api/settings') + if (res.ok) { + const data = await res.json() + settingsState.themeColor = data.theme_color || 'purple' + settingsState.appTitle = data.app_title || 'NAVI.FM' + settingsState.appTagline = data.app_tagline || '' + settingsState.infoModalText = data.info_modal_text || '' + settingsState.faviconSvgOverlay = data.favicon_svg_overlay || '' + settingsState.faviconSvgColor = data.favicon_svg_color || 'white' + settingsState.artistSeparationEnabled = !!data.artist_separation_enabled + settingsState.artistSeparationCount = data.artist_separation_count || 5 + try { + settingsState.autoPilotSchedule = JSON.parse(data.auto_pilot_schedule || '[]') + } catch (e) { + settingsState.autoPilotSchedule = [] + } + } + } catch (err) { + console.error('Failed to fetch settings:', err) + } finally { + settingsState.isLoading = false + } +} + +export const updateSettings = async (token, newSettings) => { + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Basic ${token}` + }, + body: JSON.stringify({ + theme_color: newSettings.themeColor, + app_title: newSettings.appTitle, + app_tagline: newSettings.appTagline, + info_modal_text: newSettings.infoModalText, + favicon_svg_overlay: newSettings.faviconSvgOverlay, + favicon_svg_color: newSettings.faviconSvgColor, + artist_separation_enabled: newSettings.artistSeparationEnabled, + artist_separation_count: newSettings.artistSeparationCount, + auto_pilot_schedule: JSON.stringify(newSettings.autoPilotSchedule || []) + }) + }) + + if (res.ok) { + // Immediately reflect changes in frontend store + settingsState.themeColor = newSettings.themeColor + settingsState.appTitle = newSettings.appTitle + settingsState.appTagline = newSettings.appTagline + settingsState.infoModalText = newSettings.infoModalText + settingsState.faviconSvgOverlay = newSettings.faviconSvgOverlay + settingsState.faviconSvgColor = newSettings.faviconSvgColor + settingsState.artistSeparationEnabled = newSettings.artistSeparationEnabled + settingsState.artistSeparationCount = newSettings.artistSeparationCount + settingsState.autoPilotSchedule = newSettings.autoPilotSchedule + return { success: true } + } + return { success: false, status: res.status } + } catch (err) { + console.error('Failed to update settings:', err) + return { success: false, error: err.message } + } +} + +export const fetchHistory = async (token) => { + try { + const res = await fetch('/api/history', { + headers: { + 'Authorization': `Basic ${token}` + } + }) + if (res.ok) { + const data = await res.json() + settingsState.history = data.history || [] + } + } catch (err) { + console.error('Failed to fetch history:', err) + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..01e1a91 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + base: './', +}) diff --git a/htpasswd b/htpasswd new file mode 100644 index 0000000..3070936 --- /dev/null +++ b/htpasswd @@ -0,0 +1 @@ +kasbergen:$2y$05$BIpUTNbICljGVS.UZfWFHe03n/t2YkHoFb6ON06xT5H68sxgltOr6 diff --git a/icecast.xml b/icecast.xml new file mode 100644 index 0000000..2e3ded8 --- /dev/null +++ b/icecast.xml @@ -0,0 +1,50 @@ + + Earth + icemaster@localhost + + + 100 + 2 + 524288 + 30 + 15 + 10 + 1 + 65535 + + + + hackme + hackme + admin + hackme + + + localhost + + + 8000 + + + +

+
+
+ + + + /usr/share/icecast2 + /var/log/icecast2 + /usr/share/icecast2/web + /usr/share/icecast2/admin + /var/run/icecast2/icecast.pid + + + + + access.log + error.log + 3 + 10000 + + diff --git a/liquidsoap/radio.liq b/liquidsoap/radio.liq new file mode 100644 index 0000000..9240225 --- /dev/null +++ b/liquidsoap/radio.liq @@ -0,0 +1,63 @@ +# Logging settings +settings.log.file.path := "/tmp/radio.log" +settings.log.stdout := true +settings.log.level := 2 + +# Enable Telnet server for admin controls (like skipping) +settings.server.telnet := true +settings.server.telnet.bind_addr := "0.0.0.0" +settings.server.telnet.port := 1234 + +# Fetch track dynamically from our Python API +def get_next() = + # Liquidsoap http.get returns the body as a string directly + response = http.get("http://127.0.0.1:8080/next") + url = string.trim(response) + + if url == "" then + print("WARNING: /next returned empty. Adding silence fallback.") + request.create("annotate:duration=5.0:silence") + else + print("Next track queued: #{url}") + request.create(url) + end +end + +# Define the source +radio = request.dynamic(id="navifm_queue", get_next) + +# Register a manual telnet command to skip the track since request.dynamic doesn't expose one by default +server.register("navifm_queue.skip", fun(_) -> begin source.skip(radio); "Done" end) +# Notify backend precisely when a new track starts +def on_new_track(meta) = + let title = meta["title"] + let artist = meta["artist"] + let url = "http://127.0.0.1:8080/api/internal/track_changed?title=#{url.encode(title)}&artist=#{url.encode(artist)}" + ignore(http.get(url)) +end +radio.on_track(on_new_track) + +# Precise static amplification based on LUFS (overridden by liq_amplify metadata) +radio = amplify(1., radio) + +# Smooth crossfade between tracks +radio = crossfade(smart=true, duration=4.0, fade_out=2.0, fade_in=2.0, radio) + +# Ensure the stream doesn't crash if the source is unavailable (apply right before output) +radio = mksafe(radio) + +# Retrieve icecast password from environment variable +icecast_pwd = environment.get(default="hackme", "ICECAST_PASSWORD") + +# Output stream to Icecast +output.icecast( + %mp3(bitrate=192), + host="127.0.0.1", + port=8000, + password=icecast_pwd, + mount="/stream", + name="Navi.FM", + description="Personal Smart Radio", + genre="Various", + radio +) diff --git a/output.txt b/output.txt new file mode 100644 index 0000000..5a1a361 --- /dev/null +++ b/output.txt @@ -0,0 +1,5 @@ +spawn telnet 127.0.0.1 1234 +Trying 127.0.0.1... +Connected to localhost. +Escape character is '^]'. +Connection closed by foreign host. diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..c8db983 --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,47 @@ +[supervisord] +nodaemon=true +logfile=/dev/null +logfile_maxbytes=0 + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0777 + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[program:icecast] +command=icecast2 -c /etc/icecast2/icecast.xml +user=liquidsoap +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:liquidsoap] +command=liquidsoap /app/liquidsoap/radio.liq +user=liquidsoap +autostart=false +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +# Give Icecast a second to start before liquidsoap connects +startsecs=3 + +[program:fastapi] +command=/opt/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8080 +user=liquidsoap +directory=/app/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/test_liq.liq b/test_liq.liq new file mode 100644 index 0000000..b9b0d89 --- /dev/null +++ b/test_liq.liq @@ -0,0 +1,15 @@ +set("server.telnet", true) +set("server.telnet.bind_addr", "0.0.0.0") +set("server.telnet.port", 1234) + +def get_next() = + request.create("annotate:duration=5.0:silence") +end + +radio = request.dynamic(id="navifm_queue", get_next) + +# Try registering a telnet command to skip +server.register("navifm_queue.skip", fun(_) -> begin source.skip(radio); "Done" end) + +radio = mksafe(radio) +output.dummy(radio) diff --git a/test_liq.py b/test_liq.py new file mode 100644 index 0000000..338fbdc --- /dev/null +++ b/test_liq.py @@ -0,0 +1,9 @@ +import socket +with socket.create_connection(("127.0.0.1", 1234), timeout=1) as sock: + sock.sendall(b"help\r\nquit\r\n") + response = b"" + while True: + chunk = sock.recv(4096) + if not chunk: break + response += chunk + print(response.decode()) diff --git a/test_navidrome_api.py b/test_navidrome_api.py new file mode 100644 index 0000000..953ca8a --- /dev/null +++ b/test_navidrome_api.py @@ -0,0 +1,3 @@ +class MockNavidrome: + pass +# Just to check if there is a way I can test Navidrome API locally