@amoshydra/davison 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/bin/davison.js +65 -0
- package/dist/assets/index-BXibJ7mX.js +165 -0
- package/dist/assets/index-DjSu-QAM.css +1 -0
- package/dist/index.html +13 -0
- package/dist-server/config.js +24 -0
- package/dist-server/index.js +88 -0
- package/dist-server/routes/api.js +259 -0
- package/dist-server/services/audio-gen.js +60 -0
- package/dist-server/services/music-discovery.js +73 -0
- package/dist-server/services/music-server.js +90 -0
- package/dist-server/services/playlist-store.js +84 -0
- package/dist-server/services/queue-manager.js +339 -0
- package/dist-server/services/sonos-controller.js +298 -0
- package/dist-server/services/webdav.js +381 -0
- package/package.json +61 -0
- package/patches/nephele.patch +26 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*{margin:0;padding:0;box-sizing:border-box}:root{--bg: #0d0d0d;--surface: #1a1a1a;--surface2: #252525;--surface3: #333;--accent: #06b6d4;--accent2: #0891b2;--text: #fafafa;--text2: #a3a3a3;--text3: #666;--arrow-w: 16px;--checkbox-w: 20px;--hd-px: 8px;--it-px: 6px;--indent: 20px;--cover-bg: none;--sp-2: 2px;--sp-4: 4px;--sp-6: 6px;--sp-8: 8px;--sp-10: 10px;--sp-12: 12px;--sp-14: 14px;--sp-16: 16px;--sp-20: 20px;--sp-24: 24px;--sp-48: 48px;--p-inset: var(--sp-16);--p-inset-sm: var(--sp-12);--p-row: var(--sp-8) var(--sp-16);--p-row-compact: var(--sp-6) var(--sp-16);--p-input: var(--sp-10) var(--sp-14);--p-btn-sm: var(--sp-4) var(--sp-10);--p-btn: var(--sp-8) var(--sp-14);--p-header: var(--sp-12) var(--sp-16) var(--sp-8);--p-scroll: 0 var(--sp-16) var(--sp-16);--p-tab: var(--sp-8) var(--sp-4) var(--sp-6);--p-card: var(--sp-16);--p-side: 0 var(--sp-12);--p-badge: var(--sp-2) var(--sp-8);--p-now: var(--sp-24) var(--sp-16);--g-list: var(--sp-4);--g-section: var(--sp-8);--g-inline: var(--sp-6);--g-stack: var(--sp-2);--g-controls: var(--sp-12);--g-actions: var(--sp-6);--g-hero: var(--sp-24);--g-form: var(--sp-16);--r-sm: var(--sp-8);--r-md: var(--sp-12);--bar-h: 64px}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,sans-serif;background:var(--bg);color:var(--text);overflow-wrap:break-word;word-break:break-word;-webkit-font-smoothing:antialiased}.app{display:flex;height:100dvh;min-width:0;position:relative}.app:before{content:"";position:fixed;top:-40px;right:-40px;bottom:-40px;left:-40px;background-image:var(--cover-bg);background-size:cover;background-position:center;filter:blur(16px);opacity:.12;pointer-events:none;z-index:-1;transition:opacity .6s}.app-main{flex:1;display:flex;flex-direction:column;min-width:0}.sidebar{display:none;flex-direction:column;align-items:center;gap:var(--sp-2);width:64px;padding:var(--sp-16) 0;background:#1a1a1abf;backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-right:1px solid var(--surface3);position:sticky;top:0;height:100dvh;flex-shrink:0}.sidebar-logo{font-size:1.1rem;font-weight:700;color:var(--accent);margin-bottom:var(--sp-16);letter-spacing:-.02em}.sidebar-btn{display:flex;align-items:center;justify-content:center;width:44px;height:44px;border-radius:var(--r-sm);border:none;background:transparent;color:var(--text2);cursor:pointer;transition:all .15s}.sidebar-btn:active{background:var(--surface2)}@media (hover: hover){.sidebar-btn:hover{color:var(--text);background:var(--surface2)}}.sidebar-btn.active{color:var(--accent);background:var(--surface2)}@media (hover: hover) and (pointer: fine){.sidebar{display:flex}.bottom-nav{display:none!important}}.bottom-nav{display:flex;flex-shrink:0;background:#0d0d0dd9;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-top:1px solid var(--surface3);z-index:10}.bottom-tab{flex:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-2);padding:var(--sp-4) var(--sp-2) var(--sp-6);border:none;background:transparent;color:var(--text3);cursor:pointer;font-size:.55rem;transition:color .15s}.bottom-tab svg{width:20px;height:20px}.bottom-tab.active{color:var(--accent)}@media (min-width: 768px){.bottom-nav{display:none!important}}.view-panel{flex:1;display:flex;flex-direction:column;min-height:0;min-width:0;position:relative}.view-stack{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;flex-direction:column;min-height:0;min-width:0}.view-panel-header{display:flex;align-items:center;justify-content:space-between;padding:var(--p-header)}.view-panel-header h2{font-size:1.2rem;font-weight:700}.scroll-area{flex:1;overflow-y:auto;padding:var(--p-scroll);min-width:0;-webkit-overflow-scrolling:touch}.scroll-area::-webkit-scrollbar{width:4px}.scroll-area::-webkit-scrollbar-thumb{background:var(--surface3);border-radius:var(--sp-2)}.player-bar{flex-shrink:0;background:#1a1a1acc;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-top:1px solid var(--surface3);cursor:pointer}.player-bar-progress{height:2px;background:var(--accent);transition:width 1s linear}.player-bar-content{display:flex;align-items:center;height:var(--bar-h);padding:var(--p-side);gap:var(--sp-12);min-width:0}.player-bar-track{flex:1;min-width:0;display:flex;align-items:center;gap:var(--sp-10)}.player-bar-cover{width:40px;height:40px;background:var(--surface3);display:flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden}.player-bar-cover svg{color:var(--text3)}.player-bar-img{width:100%;height:100%;object-fit:cover}.player-bar-text{min-width:0;line-height:1.3}.player-bar-title{display:block;font-size:.85rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.player-bar-artist{display:block;font-size:.75rem;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.player-bar-controls{display:flex;align-items:center;gap:var(--sp-6);flex-shrink:0}.player-bar-controls button{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border:none;background:transparent;color:var(--text);cursor:pointer;border-radius:50%;transition:background .15s}.player-bar-controls button svg{display:block}.player-bar-controls button:active{background:var(--surface2)}@media (hover: hover){.player-bar-controls button:hover{background:var(--surface2)}}.player-bar-volume{display:flex;align-items:center;gap:var(--sp-2);flex-shrink:0;color:var(--text3);margin-left:var(--sp-6)}.pv-btn{width:26px;height:26px;display:flex;align-items:center;justify-content:center;border:1px solid var(--surface3);background:transparent;border-radius:var(--sp-4);color:var(--text3);cursor:pointer;transition:all .12s}.pv-btn:active{background:var(--surface2)}.pv-value{min-width:20px;text-align:center;font-size:.7rem;font-weight:600;color:var(--text2)}@media (min-width: 480px){.player-bar-volume{display:flex}}.now-playing-view{display:flex;flex-direction:column;flex:1;min-width:0;touch-action:pan-y;overflow:hidden}.np-scroll{flex:1;min-height:0;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:var(--sp-16) var(--sp-16) 0;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)}.np-hero{width:min(65vw,260px);aspect-ratio:1;flex-shrink:0}.np-cover{width:100%;height:100%;object-fit:cover;box-shadow:0 8px 40px #00000080}.vinyl-disc{width:100%;height:100%;border-radius:50%;background:conic-gradient(from 0deg,#222,#333,#2a2a2a,#444,#222);animation:spin 4s linear infinite;animation-play-state:paused;position:relative;box-shadow:0 8px 40px #0006}.vinyl-disc.playing{animation-play-state:running}.vinyl-disc:before{content:"";position:absolute;top:18%;right:18%;bottom:18%;left:18%;border-radius:50%;background:conic-gradient(from 0deg,var(--surface3),#444,var(--surface3))}.vinyl-disc:after{content:"";position:absolute;top:38%;right:38%;bottom:38%;left:38%;border-radius:50%;background:var(--bg)}@keyframes spin{to{transform:rotate(360deg)}}.spin{animation:spin 1s linear infinite}.np-info{text-align:center;width:100%;min-width:0}.np-title{font-size:1.1rem;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.np-artist{font-size:.85rem;color:var(--text2);margin-top:var(--sp-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.np-device{display:inline-flex;align-items:center;gap:var(--sp-4);margin-top:var(--sp-4);font-size:.65rem;color:var(--text3)}.np-device:before{content:"";width:5px;height:5px;border-radius:50%;background:var(--accent);flex-shrink:0}.np-progress{width:100%}.np-progress-track{position:relative;height:40px;display:flex;align-items:center;min-width:0}.np-track-bg{position:absolute;left:0;right:0;top:50%;transform:translateY(-50%);height:5px;background:var(--surface2);border-radius:3px;pointer-events:none}.np-track-fill{position:absolute;left:0;top:50%;transform:translateY(-50%);height:5px;background:var(--accent);border-radius:3px;pointer-events:none;max-width:100%;box-shadow:0 0 10px #06b6d440}.np-thumb{position:absolute;top:50%;transform:translate(-50%,-50%);width:18px;height:18px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px #06b6d459;pointer-events:none;z-index:1;transition:transform .12s}.np-seek:active+.np-thumb,.np-seek:focus-visible+.np-thumb{transform:translate(-50%,-50%) scale(1.3)}.np-seek{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:100%;background:transparent;cursor:pointer;outline:none;position:absolute;top:0;left:0;z-index:2;margin:0;opacity:0}.np-seek::-webkit-slider-thumb{-webkit-appearance:none;width:44px;height:44px;cursor:pointer}.np-seek::-moz-range-thumb{width:44px;height:44px;border:none;border-radius:0;cursor:pointer}.np-seek::-moz-range-track{background:transparent;border:none;height:44px}.np-times{display:flex;justify-content:space-between;font-size:.65rem;color:var(--text3);margin-top:var(--sp-2)}.np-upnext{width:100%;padding-bottom:var(--sp-8)}.np-upnext h4{font-size:.7rem;font-weight:600;color:var(--text2);margin-bottom:var(--sp-4);text-transform:uppercase;letter-spacing:.04em}.np-upnext-item{display:flex;align-items:center;gap:var(--sp-6);padding:var(--sp-2) 0}.np-upnext-item+.np-upnext-item{border-top:1px solid var(--surface2)}.np-upnext-num{font-size:.65rem;color:var(--text3);min-width:16px;text-align:center}.np-upnext-info{flex:1;min-width:0}.np-upnext-info span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.np-upnext-info span:first-child{font-size:.8rem}.np-upnext-info span:last-child{font-size:.65rem;color:var(--text3)}.np-bottom-row{display:flex;align-items:center;padding:var(--sp-6) var(--sp-16);gap:var(--sp-8);border-top:1px solid var(--surface3);background:#0d0d0d99;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.np-bottom-controls{display:flex;align-items:center;gap:var(--sp-4)}.np-btn{width:40px;height:40px;display:flex;align-items:center;justify-content:center;border:none;background:transparent;color:var(--text);cursor:pointer;border-radius:50%;transition:all .12s;position:relative}.np-btn:active{background:var(--surface2)}.np-badge{position:absolute;top:5px;right:5px;width:5px;height:5px;border-radius:50%;background:var(--accent)}.np-play{width:52px;height:52px;display:flex;align-items:center;justify-content:center;border:none;background:var(--accent);color:var(--bg);cursor:pointer;border-radius:50%;transition:all .12s}.np-play:active{transform:scale(.95)}.np-bottom-volume{display:flex;align-items:center;gap:var(--sp-2);margin-left:auto}.nv-btn{width:28px;height:28px;display:flex;align-items:center;justify-content:center;border:1px solid var(--surface3);background:transparent;border-radius:var(--sp-4);color:var(--text3);cursor:pointer;transition:all .12s}.nv-btn:active{background:var(--surface2);border-color:var(--text3)}.nv-value{min-width:22px;text-align:center;font-size:.75rem;font-weight:600;color:var(--text2)}.music-browser{flex:1;display:flex;flex-direction:column;min-height:0;min-width:0}.browser-header{padding:var(--sp-12) var(--sp-16) var(--sp-8);display:flex;flex-direction:column;gap:var(--sp-4)}.browser-header input[type=text]{width:100%;padding:var(--p-input);border-radius:var(--r-sm);border:1px solid var(--surface3);background:var(--surface);color:var(--text);font-size:.9rem;outline:none}.browser-header input[type=text]:focus{border-color:var(--accent)}.browser-header input[type=text]::placeholder{color:var(--text3)}.browser-actions{display:flex;align-items:center;gap:var(--g-section);font-size:.8rem;color:var(--text2);min-height:28px}.browser-actions button{padding:var(--p-btn-sm);border-radius:var(--sp-6);border:none;background:var(--accent);color:var(--bg);cursor:pointer;font-size:.75rem;font-weight:600}.select-bar{flex-shrink:0;display:flex;align-items:center;gap:var(--sp-8);padding:var(--sp-8) var(--sp-16);border-top:1px solid var(--surface3);background:var(--surface)}.select-bar-count{font-size:.85rem;font-weight:600;color:var(--text);white-space:nowrap}.select-bar-actions{display:flex;gap:var(--sp-6);margin-left:auto}.select-bar-btn{padding:var(--p-btn-sm);border-radius:var(--sp-6);border:none;background:var(--surface2);color:var(--text2);cursor:pointer;font-size:.8rem;font-weight:500;white-space:nowrap}.select-bar-btn:active{background:var(--surface3)}.select-bar-btn.primary{background:var(--accent);color:var(--bg);font-weight:600}.select-bar-btn.subtle{background:transparent;color:var(--text3)}.track-list{flex:1;overflow-y:auto;min-width:0;padding-bottom:var(--sp-16)}.track-list::-webkit-scrollbar{width:4px}.track-list::-webkit-scrollbar-thumb{background:var(--surface3);border-radius:var(--sp-2)}.track-check{width:var(--checkbox-w);height:var(--checkbox-w);border-radius:var(--sp-6);border:1.5px solid var(--surface3);display:flex;align-items:center;justify-content:center;flex-shrink:0;transition:all .12s;cursor:pointer;color:transparent;opacity:0;margin-left:calc(var(--hd-px) + var(--arrow-w) - var(--it-px) - var(--checkbox-w))}.track-check.checked{opacity:1!important;background:var(--accent);border-color:var(--accent);color:var(--bg)}.track-check.indeterminate{opacity:1!important;background:var(--surface2);border-color:var(--text3);color:var(--text2)}.select-mode .track-check{opacity:1}@media (hover: hover){.track-item:hover .track-check,.dir-header:hover .track-check{opacity:1}}.track-item{display:flex;align-items:center;gap:var(--sp-6);padding:var(--p-row-compact);padding-left:calc(var(--it-px) + var(--depth, 0) * var(--indent));border-radius:var(--r-sm);cursor:pointer;transition:background .12s}.track-item:active{background:var(--surface2)}@media (hover: hover){.track-item:hover{background:var(--surface)}}.track-item.selected{background:var(--surface2)}.track-info{flex:1;min-width:0}.track-info .track-title{display:block;font-size:.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.track-info .track-artist{display:block;font-size:.75rem;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.track-duration{font-size:.75rem;color:var(--text3);flex-shrink:0}.dir-header{display:flex;align-items:center;gap:var(--sp-6);padding:var(--sp-8) var(--sp-16);padding-left:calc(var(--hd-px) + var(--depth, 0) * var(--indent));cursor:pointer;transition:background .12s;font-size:.9rem;font-weight:600;color:var(--text2);-webkit-user-select:none;user-select:none;position:sticky;top:0;z-index:2;background:rgba(13,13,13,calc(.85 - var(--depth, 0) * .04));backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}.dir-header:active{background:var(--surface)}@media (hover: hover){.dir-header:hover{background:var(--surface)}}.dir-arrow{width:var(--arrow-w);flex-shrink:0;display:flex;align-items:center;justify-content:center;color:var(--text3)}.dir-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dir-count{font-size:.7rem;color:var(--text3);background:var(--surface2);padding:var(--sp-2) var(--sp-8);border-radius:var(--sp-10);flex-shrink:0}.dir-btn{padding:var(--sp-4) var(--sp-10);border-radius:var(--sp-6);border:none;background:var(--surface2);color:var(--text2);cursor:pointer;font-size:.7rem}.dir-btn:active{background:var(--surface3)}@media (hover: hover){.dir-btn:hover{background:var(--surface3)}}.dir-children{padding-left:0}.queue-view{flex:1;display:flex;flex-direction:column;min-height:0;min-width:0}.queue-current{background:#1a1a1aa6;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border-radius:var(--r-md);padding:var(--p-card);margin:0 var(--sp-16) var(--sp-12);display:flex;flex-direction:column;gap:var(--sp-4);min-width:0}.queue-badge{font-size:.65rem;font-weight:600;color:var(--accent);text-transform:uppercase;letter-spacing:.06em}.queue-current h3{font-size:1rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-current p{font-size:.85rem;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-pending{margin:calc(-1 * var(--sp-12)) var(--sp-16) var(--sp-12);padding:var(--sp-6) var(--sp-12);background:#06b6d41a;border:1px solid rgba(6,182,212,.2);border-radius:var(--r-sm);font-size:.75rem;color:var(--text2);text-align:center}.queue-section-title{font-size:.75rem;font-weight:600;color:var(--text2);padding:var(--sp-8) var(--sp-16);text-transform:uppercase;letter-spacing:.04em}.queue-list{flex:1;overflow-y:auto;padding:var(--p-scroll);min-width:0}.queue-item{display:flex;align-items:center;gap:var(--sp-6);padding:var(--p-row-compact);border-radius:var(--r-sm);cursor:pointer;transition:background .12s}.queue-item:active{background:var(--surface)}@media (hover: hover){.queue-item:hover{background:var(--surface)}}.queue-item.playing{background:var(--surface2)}.queue-pos{width:20px;font-size:.75rem;color:var(--text3);text-align:center;flex-shrink:0}.queue-info{flex:1;min-width:0}.queue-info span:first-child{display:block;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-info span:last-child{display:block;font-size:.7rem;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.queue-remove{display:flex;align-items:center;justify-content:center;width:24px;height:24px;border:none;background:transparent;color:var(--text3);cursor:pointer;border-radius:50%;flex-shrink:0;transition:color .12s}.queue-remove:active{color:var(--text)}@media (hover: hover){.queue-remove:hover{color:var(--text)}}.queue-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--g-section);color:var(--text3);font-size:.9rem}.playlist-panel{flex:1;display:flex;flex-direction:column;min-height:0;min-width:0}.playlist-create{display:flex;gap:var(--sp-8);padding:0 var(--sp-16) var(--sp-12)}.playlist-create input{flex:1;padding:var(--p-input);border-radius:var(--r-sm);border:1px solid var(--surface3);background:var(--surface);color:var(--text);font-size:.9rem;outline:none}.playlist-create input:focus{border-color:var(--accent)}.playlist-create input::placeholder{color:var(--text3)}.playlist-create button{padding:var(--p-btn);border-radius:var(--r-sm);border:none;background:var(--accent);color:var(--bg);cursor:pointer;font-weight:600;font-size:.85rem}.playlist-list{flex:1;overflow-y:auto;padding:var(--p-scroll);display:flex;flex-direction:column;gap:var(--g-list);min-width:0}.playlist-item{display:flex;align-items:center;justify-content:space-between;padding:var(--p-row-compact);border-radius:var(--r-sm);background:var(--surface);cursor:pointer;transition:background .12s}.playlist-item:active{background:var(--surface2)}@media (hover: hover){.playlist-item:hover{background:var(--surface2)}}.playlist-info{min-width:0}.playlist-name{font-size:.9rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.playlist-count{font-size:.75rem;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.playlist-actions{display:flex;gap:var(--g-actions);flex-shrink:0}.playlist-actions button{padding:var(--p-btn-sm);border-radius:var(--sp-6);border:none;background:var(--surface2);color:var(--text2);cursor:pointer;font-size:.75rem}.playlist-actions button:active{background:var(--surface3)}@media (hover: hover){.playlist-actions button:hover{background:var(--surface3)}}.playlist-actions .btn-play-list{background:var(--accent);color:var(--bg)}.playlist-actions .btn-play-list:active{background:var(--accent2)}@media (hover: hover){.playlist-actions .btn-play-list:hover{background:var(--accent2)}}.playlist-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--g-section);color:var(--text3);font-size:.9rem;padding:var(--sp-48) var(--sp-16)}.settings-view{display:flex;flex-direction:column;gap:var(--g-form);padding:var(--p-card);min-width:0}.settings-row{display:flex;align-items:center;gap:var(--sp-10)}.settings-row label{font-size:.85rem;color:var(--text2);min-width:80px}.settings-row select{flex:1;padding:var(--p-input);border-radius:var(--r-sm);border:1px solid var(--surface3);background:var(--surface);color:var(--text);font-size:.85rem;outline:none}.settings-row select:focus{border-color:var(--accent)}.settings-row select:disabled{opacity:.4}.settings-row button{padding:var(--p-btn);border-radius:var(--r-sm);border:none;background:var(--surface2);color:var(--text);cursor:pointer;font-size:.8rem}.settings-row button:active{background:var(--surface3)}@media (hover: hover){.settings-row button:hover{background:var(--surface3)}}.settings-row button:disabled{opacity:.4;cursor:default}.settings-status{display:flex;align-items:center;gap:var(--sp-8);padding:var(--sp-8) 0;font-size:.85rem;color:var(--text2)}.settings-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.settings-dot.online{background:#22c55e}.settings-dot.offline{background:var(--text3)}.syncing{opacity:.65;transition:opacity .15s}
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Sonos Node</title>
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-BXibJ7mX.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DjSu-QAM.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
function detectLanIp() {
|
|
4
|
+
const ifaces = os.networkInterfaces();
|
|
5
|
+
for (const [name, addrs] of Object.entries(ifaces)) {
|
|
6
|
+
if (!addrs)
|
|
7
|
+
continue;
|
|
8
|
+
if (name.startsWith('wg') || name.startsWith('tun') || name.startsWith('docker') || name.startsWith('br-'))
|
|
9
|
+
continue;
|
|
10
|
+
for (const addr of addrs) {
|
|
11
|
+
if (addr.family === 'IPv4' && !addr.internal) {
|
|
12
|
+
return addr.address;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return '127.0.0.1';
|
|
17
|
+
}
|
|
18
|
+
export const config = {
|
|
19
|
+
port: 3000,
|
|
20
|
+
host: detectLanIp(),
|
|
21
|
+
musicPaths: [],
|
|
22
|
+
dataDir: path.resolve(import.meta.dirname, '..', 'data'),
|
|
23
|
+
playlistsFile: path.resolve(import.meta.dirname, '..', 'data', 'playlists.json'),
|
|
24
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import ViteExpress from 'vite-express';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { discoverMusic } from './services/music-discovery.js';
|
|
6
|
+
import { loadPlaylists } from './services/playlist-store.js';
|
|
7
|
+
import { createApiRouter } from './routes/api.js';
|
|
8
|
+
import { initWebdav } from './services/webdav.js';
|
|
9
|
+
import { startAutoAdvancePolling, stopAutoAdvancePolling } from './services/queue-manager.js';
|
|
10
|
+
const program = new Command();
|
|
11
|
+
program
|
|
12
|
+
.name('davison')
|
|
13
|
+
.description('Sonos music player with web UI')
|
|
14
|
+
.option('-p, --path <paths...>', 'Path(s) to music directories')
|
|
15
|
+
.option('--port <number>', 'Server port', '3000')
|
|
16
|
+
.option('--host <address>', 'Server LAN address (auto-detected if omitted)')
|
|
17
|
+
.parse(process.argv);
|
|
18
|
+
const options = program.opts();
|
|
19
|
+
config.port = parseInt(options.port, 10) || 3000;
|
|
20
|
+
if (options.host)
|
|
21
|
+
config.host = options.host;
|
|
22
|
+
config.musicPaths = options.path || [];
|
|
23
|
+
config.musicPaths.forEach(p => {
|
|
24
|
+
if (p.includes(' ')) {
|
|
25
|
+
console.warn(`Warning: path "${p}" contains spaces — ensure it was quoted in the shell`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
let server = null;
|
|
29
|
+
async function main() {
|
|
30
|
+
console.log('Starting davison...');
|
|
31
|
+
try {
|
|
32
|
+
await loadPlaylists();
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
console.warn('Could not load playlists, starting fresh:', err);
|
|
36
|
+
}
|
|
37
|
+
if (config.musicPaths.length > 0) {
|
|
38
|
+
console.log('Discovering music in:', config.musicPaths.join(', '));
|
|
39
|
+
try {
|
|
40
|
+
const tracks = await discoverMusic(config.musicPaths);
|
|
41
|
+
console.log(`Found ${tracks.length} music files`);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.warn('Music discovery failed:', err);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
console.log('No music paths specified. Use --path to add music folders.');
|
|
49
|
+
}
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json());
|
|
52
|
+
app.use('/api', createApiRouter());
|
|
53
|
+
app.use('/webdav', initWebdav());
|
|
54
|
+
if (config.musicPaths.length > 0) {
|
|
55
|
+
config.musicPaths.forEach((musicDir, i) => {
|
|
56
|
+
app.use(`/music-files/${i}`, express.static(musicDir));
|
|
57
|
+
});
|
|
58
|
+
console.log(`Serving ${config.musicPaths.length} music director${config.musicPaths.length > 1 ? 'ies' : 'y'} at /music-files/* and /webdav/*`);
|
|
59
|
+
}
|
|
60
|
+
server = ViteExpress.listen(app, config.port, () => {
|
|
61
|
+
console.log(`Sonos Node running at http://${config.host}:${config.port}`);
|
|
62
|
+
startAutoAdvancePolling();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function shutdown() {
|
|
66
|
+
console.log('\nShutting down gracefully...');
|
|
67
|
+
stopAutoAdvancePolling();
|
|
68
|
+
if (server) {
|
|
69
|
+
server.close(() => {
|
|
70
|
+
console.log('Server closed');
|
|
71
|
+
process.exit(0);
|
|
72
|
+
});
|
|
73
|
+
// Force exit after 5s if close hangs
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
console.warn('Forced shutdown after timeout');
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}, 5000).unref();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
process.exit(0);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
process.on('SIGINT', shutdown);
|
|
84
|
+
process.on('SIGTERM', shutdown);
|
|
85
|
+
main().catch(err => {
|
|
86
|
+
console.error('Failed to start:', err);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getMusicLibrary } from '../services/music-discovery.js';
|
|
3
|
+
import { sonosController } from '../services/sonos-controller.js';
|
|
4
|
+
import { queueManager } from '../services/queue-manager.js';
|
|
5
|
+
import { getPlaylists, getPlaylist, createPlaylist, deletePlaylist, updatePlaylist } from '../services/playlist-store.js';
|
|
6
|
+
import { audioGenService } from '../services/audio-gen.js';
|
|
7
|
+
import { createMusicServerRouter } from '../services/music-server.js';
|
|
8
|
+
const VALID_LOOP_MODES = new Set(['none', 'one', 'all']);
|
|
9
|
+
function asString(v, fallback = '') {
|
|
10
|
+
return typeof v === 'string' ? v : fallback;
|
|
11
|
+
}
|
|
12
|
+
function asNumber(v, fallback) {
|
|
13
|
+
return typeof v === 'number' && !Number.isNaN(v) ? v : fallback;
|
|
14
|
+
}
|
|
15
|
+
function asStringArray(v) {
|
|
16
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
17
|
+
}
|
|
18
|
+
export function createApiRouter() {
|
|
19
|
+
const router = Router();
|
|
20
|
+
router.get('/devices', async (_req, res) => {
|
|
21
|
+
const devices = sonosController.getDevices();
|
|
22
|
+
res.json(devices);
|
|
23
|
+
});
|
|
24
|
+
router.post('/devices/discover', async (_req, res) => {
|
|
25
|
+
const devices = await sonosController.discoverDevices();
|
|
26
|
+
res.json(devices);
|
|
27
|
+
});
|
|
28
|
+
router.post('/devices/select', async (req, res) => {
|
|
29
|
+
const id = asString(req.body?.id);
|
|
30
|
+
if (!id) {
|
|
31
|
+
res.status(400).json({ error: 'Device id is required' });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const ok = sonosController.selectDevice(id);
|
|
35
|
+
if (!ok) {
|
|
36
|
+
res.status(404).json({ error: 'Device not found' });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// Resume playback if tracks were queued while no device was selected
|
|
40
|
+
await queueManager.resumePlayback().catch(err => {
|
|
41
|
+
console.warn('Failed to resume playback after device select:', err);
|
|
42
|
+
});
|
|
43
|
+
res.json({ success: true });
|
|
44
|
+
});
|
|
45
|
+
router.get('/music', (req, res) => {
|
|
46
|
+
const all = getMusicLibrary();
|
|
47
|
+
const offset = Math.max(0, asNumber(req.query.offset ? Number(req.query.offset) : undefined, 0));
|
|
48
|
+
const limit = Math.max(1, Math.min(1000, asNumber(req.query.limit ? Number(req.query.limit) : undefined, all.length)));
|
|
49
|
+
res.json(all.slice(offset, offset + limit));
|
|
50
|
+
});
|
|
51
|
+
router.use('/music', createMusicServerRouter());
|
|
52
|
+
router.get('/status', async (_req, res) => {
|
|
53
|
+
const sonosStatus = await sonosController.getStatus();
|
|
54
|
+
const queueState = queueManager.getState();
|
|
55
|
+
res.json({ sonos: sonosStatus, queue: queueState });
|
|
56
|
+
});
|
|
57
|
+
router.post('/play', async (_req, res) => {
|
|
58
|
+
await queueManager.play();
|
|
59
|
+
res.json({ success: true });
|
|
60
|
+
});
|
|
61
|
+
router.post('/pause', async (_req, res) => {
|
|
62
|
+
await queueManager.pause();
|
|
63
|
+
res.json({ success: true });
|
|
64
|
+
});
|
|
65
|
+
router.post('/stop', async (_req, res) => {
|
|
66
|
+
await queueManager.stop();
|
|
67
|
+
res.json({ success: true });
|
|
68
|
+
});
|
|
69
|
+
router.post('/next', async (_req, res) => {
|
|
70
|
+
await queueManager.next();
|
|
71
|
+
res.json({ success: true });
|
|
72
|
+
});
|
|
73
|
+
router.post('/previous', async (_req, res) => {
|
|
74
|
+
await queueManager.previous();
|
|
75
|
+
res.json({ success: true });
|
|
76
|
+
});
|
|
77
|
+
router.post('/volume', (req, res) => {
|
|
78
|
+
const volume = asNumber(req.body?.volume, -1);
|
|
79
|
+
if (volume < 0 || volume > 100) {
|
|
80
|
+
res.status(400).json({ error: 'Volume must be between 0 and 100' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
sonosController.setVolume(volume).catch(err => {
|
|
84
|
+
console.warn('Failed to set volume:', err);
|
|
85
|
+
});
|
|
86
|
+
res.json({ success: true });
|
|
87
|
+
});
|
|
88
|
+
router.get('/queue', (_req, res) => {
|
|
89
|
+
res.json(queueManager.getQueue());
|
|
90
|
+
});
|
|
91
|
+
router.post('/queue/add', (req, res) => {
|
|
92
|
+
const trackIds = asStringArray(req.body?.trackIds);
|
|
93
|
+
if (trackIds.length === 0) {
|
|
94
|
+
res.status(400).json({ error: 'trackIds array is required' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
queueManager.addToQueue(trackIds).catch(err => {
|
|
98
|
+
console.warn('Failed to add to queue:', err);
|
|
99
|
+
});
|
|
100
|
+
res.json({ success: true });
|
|
101
|
+
});
|
|
102
|
+
router.post('/queue/remove', (req, res) => {
|
|
103
|
+
const index = asNumber(req.body?.index, -1);
|
|
104
|
+
if (index < 0) {
|
|
105
|
+
res.status(400).json({ error: 'Valid index is required' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
queueManager.removeFromQueue(index);
|
|
109
|
+
res.json({ success: true });
|
|
110
|
+
});
|
|
111
|
+
router.post('/queue/clear', (_req, res) => {
|
|
112
|
+
queueManager.clearQueue();
|
|
113
|
+
res.json({ success: true });
|
|
114
|
+
});
|
|
115
|
+
router.post('/queue/reorder', (req, res) => {
|
|
116
|
+
const from = asNumber(req.body?.from, -1);
|
|
117
|
+
const to = asNumber(req.body?.to, -1);
|
|
118
|
+
if (from < 0 || to < 0) {
|
|
119
|
+
res.status(400).json({ error: 'Valid from and to indices are required' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
queueManager.reorderQueue(from, to);
|
|
123
|
+
res.json({ success: true });
|
|
124
|
+
});
|
|
125
|
+
router.post('/queue/jump', (req, res) => {
|
|
126
|
+
const trackId = asString(req.body?.trackId);
|
|
127
|
+
if (!trackId) {
|
|
128
|
+
res.status(400).json({ error: 'trackId is required' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
queueManager.jumpTo(trackId).catch(err => {
|
|
132
|
+
console.warn('Failed to jump:', err);
|
|
133
|
+
});
|
|
134
|
+
res.json({ success: true });
|
|
135
|
+
});
|
|
136
|
+
router.post('/queue/play-now', (req, res) => {
|
|
137
|
+
const trackIds = asStringArray(req.body?.trackIds);
|
|
138
|
+
if (trackIds.length === 0) {
|
|
139
|
+
res.status(400).json({ error: 'trackIds array is required' });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
queueManager.playNow(trackIds).catch(err => {
|
|
143
|
+
console.warn('Failed to play now:', err);
|
|
144
|
+
});
|
|
145
|
+
res.json({ success: true });
|
|
146
|
+
});
|
|
147
|
+
router.post('/queue/play-next', (req, res) => {
|
|
148
|
+
const trackIds = asStringArray(req.body?.trackIds);
|
|
149
|
+
if (trackIds.length === 0) {
|
|
150
|
+
res.status(400).json({ error: 'trackIds array is required' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
queueManager.playNext(trackIds).catch(err => {
|
|
154
|
+
console.warn('Failed to play next:', err);
|
|
155
|
+
});
|
|
156
|
+
res.json({ success: true });
|
|
157
|
+
});
|
|
158
|
+
router.post('/queue/play-folder-or-now', (req, res) => {
|
|
159
|
+
const trackId = asString(req.body?.trackId);
|
|
160
|
+
if (!trackId) {
|
|
161
|
+
res.status(400).json({ error: 'trackId is required' });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
queueManager.playFolderOrNow(trackId).catch(err => {
|
|
165
|
+
console.warn('Failed to play folder/now:', err);
|
|
166
|
+
});
|
|
167
|
+
res.json({ success: true });
|
|
168
|
+
});
|
|
169
|
+
router.post('/loop', (req, res) => {
|
|
170
|
+
const mode = asString(req.body?.mode);
|
|
171
|
+
if (!VALID_LOOP_MODES.has(mode)) {
|
|
172
|
+
res.status(400).json({ error: 'Mode must be one of: none, one, all' });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
queueManager.setLoopMode(mode);
|
|
176
|
+
res.json({ success: true });
|
|
177
|
+
});
|
|
178
|
+
router.get('/playlists', (_req, res) => {
|
|
179
|
+
res.json(getPlaylists());
|
|
180
|
+
});
|
|
181
|
+
router.get('/playlists/:id', (req, res) => {
|
|
182
|
+
const playlist = getPlaylist(req.params.id);
|
|
183
|
+
if (!playlist) {
|
|
184
|
+
res.status(404).json({ error: 'Playlist not found' });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
res.json(playlist);
|
|
188
|
+
});
|
|
189
|
+
router.post('/playlists', async (req, res) => {
|
|
190
|
+
const name = asString(req.body?.name);
|
|
191
|
+
if (!name) {
|
|
192
|
+
res.status(400).json({ error: 'Name is required' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const trackIds = asStringArray(req.body?.trackIds);
|
|
196
|
+
try {
|
|
197
|
+
const p = await createPlaylist(name, trackIds);
|
|
198
|
+
res.json(p);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
console.warn('Failed to create playlist:', err);
|
|
202
|
+
res.status(500).json({ error: 'Failed to create playlist' });
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
router.put('/playlists/:id', async (req, res) => {
|
|
206
|
+
try {
|
|
207
|
+
const p = await updatePlaylist(req.params.id, {
|
|
208
|
+
name: asString(req.body?.name),
|
|
209
|
+
trackIds: asStringArray(req.body?.trackIds),
|
|
210
|
+
});
|
|
211
|
+
if (!p) {
|
|
212
|
+
res.status(404).json({ error: 'Playlist not found' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
res.json(p);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
console.warn('Failed to update playlist:', err);
|
|
219
|
+
res.status(500).json({ error: 'Failed to update playlist' });
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
router.delete('/playlists/:id', async (req, res) => {
|
|
223
|
+
try {
|
|
224
|
+
const ok = await deletePlaylist(req.params.id);
|
|
225
|
+
res.json({ success: ok });
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
console.warn('Failed to delete playlist:', err);
|
|
229
|
+
res.status(500).json({ error: 'Failed to delete playlist' });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
router.post('/playlists/:id/play', (req, res) => {
|
|
233
|
+
const playlist = getPlaylist(req.params.id);
|
|
234
|
+
if (!playlist) {
|
|
235
|
+
res.status(404).json({ error: 'Playlist not found' });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const allTracks = getMusicLibrary();
|
|
239
|
+
const tracks = playlist.trackIds
|
|
240
|
+
.map(id => allTracks.find(t => t.id === id))
|
|
241
|
+
.filter((t) => !!t);
|
|
242
|
+
queueManager.setQueue(tracks);
|
|
243
|
+
res.json({ success: true });
|
|
244
|
+
});
|
|
245
|
+
router.post('/generate', (req, res) => {
|
|
246
|
+
const prompt = asString(req.body?.prompt);
|
|
247
|
+
if (!prompt) {
|
|
248
|
+
res.status(400).json({ error: 'Prompt is required' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
audioGenService.generate({ prompt }).then(result => {
|
|
252
|
+
res.json(result);
|
|
253
|
+
}).catch(err => {
|
|
254
|
+
console.warn('Audio generation failed:', err);
|
|
255
|
+
res.status(500).json({ error: err.message });
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
return router;
|
|
259
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
class ComfyUIGenerator extends EventEmitter {
|
|
3
|
+
apiUrl;
|
|
4
|
+
workflowTemplate;
|
|
5
|
+
constructor(apiUrl = 'http://localhost:8188', workflowTemplate = {}) {
|
|
6
|
+
super();
|
|
7
|
+
this.apiUrl = apiUrl;
|
|
8
|
+
this.workflowTemplate = workflowTemplate;
|
|
9
|
+
}
|
|
10
|
+
async generate(request) {
|
|
11
|
+
this.emit('generation-start', request);
|
|
12
|
+
const workflow = {
|
|
13
|
+
...this.workflowTemplate,
|
|
14
|
+
prompt: request.prompt,
|
|
15
|
+
...(request.duration ? { duration: request.duration } : {}),
|
|
16
|
+
...(request.params || {}),
|
|
17
|
+
};
|
|
18
|
+
const controller = new AbortController();
|
|
19
|
+
const timeout = setTimeout(() => controller.abort(), 60000);
|
|
20
|
+
try {
|
|
21
|
+
const response = await fetch(`${this.apiUrl}/prompt`, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers: { 'Content-Type': 'application/json' },
|
|
24
|
+
body: JSON.stringify({ prompt: workflow }),
|
|
25
|
+
signal: controller.signal,
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new Error(`ComfyUI API error: ${response.statusText}`);
|
|
29
|
+
}
|
|
30
|
+
const result = await response.json();
|
|
31
|
+
this.emit('generation-complete', result);
|
|
32
|
+
return {
|
|
33
|
+
filePath: result.output?.file || '',
|
|
34
|
+
duration: request.duration || 0,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
clearTimeout(timeout);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export class AudioGenService {
|
|
43
|
+
generator = null;
|
|
44
|
+
configure(generator) {
|
|
45
|
+
this.generator = generator;
|
|
46
|
+
}
|
|
47
|
+
configureComfyUI(apiUrl, workflowTemplate) {
|
|
48
|
+
this.generator = new ComfyUIGenerator(apiUrl, workflowTemplate);
|
|
49
|
+
}
|
|
50
|
+
isAvailable() {
|
|
51
|
+
return this.generator !== null;
|
|
52
|
+
}
|
|
53
|
+
async generate(request) {
|
|
54
|
+
if (!this.generator) {
|
|
55
|
+
throw new Error('No audio generator configured');
|
|
56
|
+
}
|
|
57
|
+
return this.generator.generate(request);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const audioGenService = new AudioGenService();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseFile } from 'music-metadata';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
const supportedExtensions = new Set([
|
|
6
|
+
'.mp3', '.flac', '.wav', '.m4a', '.m4b', '.ogg', '.opus',
|
|
7
|
+
'.wma', '.aac', '.aiff', '.dsf', '.ape',
|
|
8
|
+
]);
|
|
9
|
+
let musicLibrary = [];
|
|
10
|
+
async function walkDir(dirPath, basePath, baseIdx, baseName) {
|
|
11
|
+
const results = [];
|
|
12
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
13
|
+
for (const entry of entries) {
|
|
14
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
15
|
+
if (entry.isDirectory()) {
|
|
16
|
+
results.push(...await walkDir(fullPath, basePath, baseIdx, baseName));
|
|
17
|
+
}
|
|
18
|
+
else if (entry.isFile()) {
|
|
19
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
20
|
+
if (!supportedExtensions.has(ext))
|
|
21
|
+
continue;
|
|
22
|
+
let metadata;
|
|
23
|
+
try {
|
|
24
|
+
metadata = await parseFile(fullPath);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
metadata = { format: { duration: 0 }, common: { title: '', artist: '', album: '' } };
|
|
28
|
+
}
|
|
29
|
+
results.push({
|
|
30
|
+
id: randomUUID(),
|
|
31
|
+
filePath: fullPath,
|
|
32
|
+
fileName: entry.name,
|
|
33
|
+
relativePath: path.relative(basePath, fullPath),
|
|
34
|
+
baseIdx,
|
|
35
|
+
baseName,
|
|
36
|
+
title: metadata.common.title || path.parse(entry.name).name,
|
|
37
|
+
artist: metadata.common.artist || 'Unknown Artist',
|
|
38
|
+
album: metadata.common.album || 'Unknown Album',
|
|
39
|
+
duration: metadata.format.duration || 0,
|
|
40
|
+
format: ext.slice(1),
|
|
41
|
+
size: (await stat(fullPath)).size,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return results;
|
|
46
|
+
}
|
|
47
|
+
export async function discoverMusic(paths) {
|
|
48
|
+
const all = [];
|
|
49
|
+
for (let i = 0; i < paths.length; i++) {
|
|
50
|
+
const resolved = path.resolve(paths[i]);
|
|
51
|
+
try {
|
|
52
|
+
const stats = await stat(resolved);
|
|
53
|
+
if (stats.isDirectory()) {
|
|
54
|
+
const parentDir = path.basename(path.dirname(resolved));
|
|
55
|
+
const baseName = parentDir && parentDir !== path.sep
|
|
56
|
+
? `${parentDir}/${path.basename(resolved)}`
|
|
57
|
+
: path.basename(resolved);
|
|
58
|
+
all.push(...await walkDir(resolved, resolved, i, baseName));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
console.warn(`Music path not accessible: ${resolved}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
musicLibrary = all;
|
|
66
|
+
return musicLibrary;
|
|
67
|
+
}
|
|
68
|
+
export function getMusicLibrary() {
|
|
69
|
+
return [...musicLibrary];
|
|
70
|
+
}
|
|
71
|
+
export function getTrackById(id) {
|
|
72
|
+
return musicLibrary.find(t => t.id === id);
|
|
73
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { parseFile } from 'music-metadata';
|
|
5
|
+
import { getTrackById } from './music-discovery.js';
|
|
6
|
+
export function createMusicServerRouter() {
|
|
7
|
+
const router = Router();
|
|
8
|
+
router.get('/stream/:id', async (req, res) => {
|
|
9
|
+
const track = getTrackById(req.params.id);
|
|
10
|
+
if (!track) {
|
|
11
|
+
res.status(404).json({ error: 'Track not found' });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const fileStat = await stat(track.filePath);
|
|
16
|
+
const fileSize = fileStat.size;
|
|
17
|
+
const range = req.headers.range;
|
|
18
|
+
const sendStream = (start, end) => {
|
|
19
|
+
const stream = createReadStream(track.filePath, { start, end });
|
|
20
|
+
stream.on('error', (err) => {
|
|
21
|
+
console.error('Stream error:', err);
|
|
22
|
+
if (!res.headersSent) {
|
|
23
|
+
res.status(500).json({ error: 'Stream error' });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
res.on('close', () => {
|
|
27
|
+
stream.destroy();
|
|
28
|
+
});
|
|
29
|
+
stream.pipe(res);
|
|
30
|
+
};
|
|
31
|
+
if (range) {
|
|
32
|
+
const match = range.match(/^bytes=(\d+)-(\d*)$/);
|
|
33
|
+
if (!match) {
|
|
34
|
+
res.status(416).json({ error: 'Invalid range' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const start = parseInt(match[1], 10);
|
|
38
|
+
const end = match[2] ? parseInt(match[2], 10) : fileSize - 1;
|
|
39
|
+
if (start >= fileSize || end >= fileSize || start > end) {
|
|
40
|
+
res.status(416).json({ error: 'Range not satisfiable' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const chunkSize = end - start + 1;
|
|
44
|
+
res.writeHead(206, {
|
|
45
|
+
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
|
46
|
+
'Accept-Ranges': 'bytes',
|
|
47
|
+
'Content-Length': chunkSize,
|
|
48
|
+
'Content-Type': 'audio/mpeg',
|
|
49
|
+
});
|
|
50
|
+
sendStream(start, end);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
res.writeHead(200, {
|
|
54
|
+
'Content-Length': fileSize,
|
|
55
|
+
'Content-Type': 'audio/mpeg',
|
|
56
|
+
});
|
|
57
|
+
sendStream(0, fileSize - 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
if (!res.headersSent) {
|
|
62
|
+
res.status(500).json({ error: 'Failed to stream file' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
router.get('/cover/:id', async (req, res) => {
|
|
67
|
+
const track = getTrackById(req.params.id);
|
|
68
|
+
if (!track) {
|
|
69
|
+
res.status(404).json({ error: 'Track not found' });
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const meta = await parseFile(track.filePath);
|
|
74
|
+
const pic = meta.common.picture?.[0];
|
|
75
|
+
if (!pic) {
|
|
76
|
+
res.status(404).json({ error: 'No cover art' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
res.set('Content-Type', pic.format);
|
|
80
|
+
res.set('Cache-Control', 'public, max-age=86400');
|
|
81
|
+
res.end(pic.data);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
if (!res.headersSent) {
|
|
85
|
+
res.status(500).json({ error: 'Failed to extract cover' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return router;
|
|
90
|
+
}
|