├── Dockerfile ├── LICENSE ├── README.md ├── screen.gif └── src ├── html ├── index.html └── player.js ├── package.json ├── scanner.js ├── server.js └── start.sh /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:latest 2 | 3 | EXPOSE 3000 4 | 5 | copy ./src /src/ 6 | 7 | 8 | WORKDIR /src/ 9 | 10 | RUN npm install 11 | 12 | CMD /src/start.sh 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 4 | Version 2, December 2004 5 | 6 | Copyright (C) 2004 Sam Hocevar 7 | 8 | Everyone is permitted to copy and distribute verbatim or modified 9 | copies of this license document, and changing it is allowed as long 10 | as the name is changed. 11 | 12 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 13 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 14 | 15 | 0. You just DO WHAT THE FUCK YOU WANT TO. 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | dStream.. DusteDs streamer 2 | ==== 3 | ![](https://github.com/DusteDdk/dstream/blob/master/screen.gif) 4 | - Simple access to your music collection from the web browser 5 | - Fast fuzzy search 6 | - For semi-organized music collections 7 | - Low resource usage, sqlite database 8 | - single active process (node) 9 | - totally a halfhearted hack, except for this markup file that I did spend a lot of time making 10 | 11 | Infrastructure 12 | ==== 13 | - Music collection available on the server 14 | - Docker 15 | - Front-end, like nginx for handling HTTPS and authentication, it listens on port 3000 16 | - Don't expose it to the Internet without some authentication in front, unless you want to share your collection with the world 17 | 18 | Running 19 | ==== 20 | I assume you know how to use docker, edit parameters/ports/paths/IDs as needed. 21 | 22 | ```docker run --restart=always -d -v /your/music:/music:ro -v /permanent/database/:/db:rw -e USER_ID=9000 -e GROUP_ID=9000 dusted/dstream:latest``` 23 | 24 | The /db mount is optional, without it, the music database is lost if the container is removed. 25 | The USER_ID and GROUP_ID variables are optional, if not provided, the default of 1000 is used. 26 | 27 | Scanning music 28 | ==== 29 | Adds any music files in the music directory to the searchable database 30 | 1. Log into the website 31 | 2. press h 32 | 3. Click link "/scan to control music scanning" 33 | 4. Click "Scan" and wait.. press refresh if you're impatient, 34 | 5. It's done with "Scan" appears again, go back, you're done. 35 | 36 | Using 37 | ==== 38 | Press J or click text-box 39 | search 40 | press enter to play top result or add from results 41 | 42 | Add track to queue 43 | ---- 44 | Press song-name / file name to add to queue 45 | Click row left of it to autoplay from there if song is last in queue. 46 | Click row right of it to remove currently playing song and play this instead. 47 | 48 | Remove track from queue 49 | ---- 50 | Click on file name to remove a song from queue 51 | Pressing the "insert" key removes the current song from queue 52 | 53 | Misc 54 | ---- 55 | Press the H key to show or hide the instructions 56 | 57 | Why? 58 | ==== 59 | This bespoke interface was hacked to do just what I want, how I want, 60 | maybe it won't fit you, but it fits me perfectly. 61 | 62 | Source? 63 | ==== 64 | https://github.com/DusteDdk/dstream 65 | 66 | License 67 | ==== 68 | WTFPL 69 | -------------------------------------------------------------------------------- /screen.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DusteDdk/dstream/e368575bb2a9e940b421c6717f98c863570ba5da/screen.gif -------------------------------------------------------------------------------- /src/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 24 | 25 | 26 |      🔍     27 |     28 |   🔊  29 |   🕑  30 |
31 |
32 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/html/player.js: -------------------------------------------------------------------------------- 1 | 2 | let lastQuery=[]; 3 | let curPage=0; 4 | function loadList(query, random, offset) { 5 | lastQuery=query.split(' '); 6 | 7 | let request; 8 | if(offset !== 0) { 9 | request = new Request('browse.json?p='+offset); 10 | } else { 11 | curPage=0; 12 | request = new Request((random)?'random.json':'tracks.json?q='+query); 13 | } 14 | 15 | return fetch(request).then( response=>{ 16 | return response.blob(); 17 | }).then( blob=>{ 18 | return blob.text(); 19 | }); 20 | } 21 | 22 | function getClass(idx) { 23 | return ((idx%2===0)?'a':'b'); 24 | } 25 | 26 | let pl; 27 | async function setList(random, offset) { 28 | const list = await loadList(document.getElementById('search').value, random, offset); 29 | pl = JSON.parse(list); 30 | 31 | document.getElementById('numres').innerHTML = pl.length; 32 | 33 | 34 | if(!random && lastQuery.length) { 35 | const art = lastQuery[0]; 36 | const tra = lastQuery[ lastQuery.length-1 ]; 37 | 38 | const rules = [ 39 | { r: new RegExp('^'+art+'$','i'), pa: 40, pt: 35 }, 40 | { r: new RegExp( '^'+tra+'$', 'i'), pa: 35, pt: 40 }, 41 | { r: new RegExp( '^'+art, 'i'), pa: 30, pt: 25 }, 42 | { r: new RegExp( '^'+tra, 'i'), pa: 25, pt: 30 }, 43 | { r: new RegExp( art+'$', 'i'), pa: 20, pt: 15 }, 44 | { r: new RegExp( tra+'$', 'i'), pa: 15, pt: 20 }, 45 | { r: new RegExp( art, 'i'), pa: 10, pt: 5 }, 46 | { r: new RegExp( tra, 'i'), pa: 5, pt: 10 }, 47 | ]; 48 | 49 | if(curPage===0 && offset===0) { 50 | pl.forEach( (e)=>{ 51 | e.score = 0; 52 | rules.forEach( r=>{ 53 | if( e.title && e.title.match(r.r) ) { 54 | e.score += r.pt; 55 | } 56 | if( e.artistName && e.artistName.match(r.r)) { 57 | e.score += r.pa; 58 | } 59 | if( e.codec && e.codec === 'FLAC') { 60 | e.score += 100; 61 | } 62 | }); 63 | 64 | }); 65 | pl.sort( (a,b)=>b.score - a.score ); 66 | } 67 | } 68 | 69 | let html =''; 70 | pl.forEach( (track,idx)=>{ 71 | const duration = `${(''+Math.floor(track.duration/60)).padStart(2,'0')}:${(''+Math.round(track.duration%60)).padStart(2,'0')}`; 72 | const fn = track.file.replaceAll("'", "\\'").replaceAll('#','%23'); 73 | html += ''; 74 | }); 75 | 76 | html+='
'+track.codec.split(' ')[0]+''+track.albumName+''+track.artistName+''+((track.title!=='Untitled')?track.title:track.file )+''+duration+''+track.year+'
'; 77 | 78 | document.getElementById('list').innerHTML=html; 79 | } 80 | 81 | const audio = new Audio(); 82 | 83 | let queue=[]; 84 | 85 | audio.addEventListener('ended', playNext); 86 | let cpl=-1; 87 | 88 | function stopAuto() { 89 | cpl=-1; 90 | document.getElementById('status').innerHTML=''; 91 | audio.src=''; 92 | } 93 | 94 | function playNext() { 95 | queue.shift(); 96 | setQueue(); 97 | if(queue.length) { 98 | play(queue[0]); 99 | } else if(pl.length && cpl !==-1 && cpl < pl.length) { 100 | play(pl[cpl].file); 101 | document.getElementById('status').innerHTML = 'Autoplay:'+pl[cpl].file+'   '; 102 | cpl++; 103 | } else { 104 | cpl=-1; 105 | audio.src=''; 106 | document.getElementById('toggle').value='.'; 107 | } 108 | } 109 | 110 | function add(file) { 111 | cpl=-1; 112 | queue.push(file); 113 | setQueue(); 114 | if(queue.length===1) { 115 | play(file); 116 | } 117 | } 118 | 119 | function setQueue() { 120 | document.getElementById('status').innerHTML = queue.map( (e,i)=>(i===0?'':'')+''+e+''+(i===0?'  ':'')).join('
'); 121 | 122 | } 123 | 124 | function remove(idx) { 125 | if(idx===0) { 126 | playNext(); 127 | } else { 128 | queue.splice(idx,1); 129 | setQueue(); 130 | } 131 | } 132 | 133 | let playing=false; 134 | 135 | function playFrom(file, idx) { 136 | add(file); 137 | if(idx+1 < pl.length); 138 | cpl = idx+1; 139 | } 140 | 141 | function playNow(file) { 142 | queue.splice(1,0,file); 143 | playNext(); 144 | } 145 | 146 | function play(file) { 147 | audio.pause(); 148 | 149 | if(document.getElementById('silly').checked && file.endsWith('.flac')) { 150 | file = '/flac'+file; 151 | } 152 | audio.src = file; 153 | audio.load(); 154 | audio.play(); 155 | document.getElementById('toggle').value='⏸︎'; 156 | playing=true; 157 | pl.forEach( (e,idx)=>{ 158 | if(e.file === file) { 159 | document.getElementById(idx).className='p'; 160 | } else { 161 | document.getElementById(idx).className=getClass(idx); 162 | } 163 | }); 164 | } 165 | 166 | function toggle() { 167 | if(playing) { 168 | playing = false; 169 | audio.pause(); 170 | document.getElementById('toggle').value='⏵︎'; 171 | } else { 172 | playing = true; 173 | audio.play(); 174 | document.getElementById('toggle').value='⏸︎'; 175 | } 176 | } 177 | 178 | let tim = null; 179 | document.addEventListener('DOMContentLoaded', ()=>{ 180 | tim = document.getElementById('tim'); 181 | 182 | tim.addEventListener('change', (event)=>{ 183 | audio.currentTime = audio.duration / 100 * tim.value; 184 | }); 185 | }); 186 | 187 | document.addEventListener("keydown", (event)=>{ 188 | if(document.activeElement !== document.getElementById('search')) { 189 | let hit=false; 190 | if(event.code === 'KeyH') { 191 | hit=true; 192 | const help = document.getElementById('help'); 193 | console.log('help'); 194 | if(help.style.display !== 'none') { 195 | help.style.display = 'none'; 196 | } else { 197 | help.style.display = 'block'; 198 | } 199 | } 200 | if(event.code === 'KeyJ') { 201 | hit=true; 202 | document.getElementById('search').focus(); 203 | } 204 | 205 | if(event.code === 'Comma') { 206 | hit=true; 207 | if(curPage===0) { 208 | curPage=pl[0].id-1; 209 | } else { 210 | curPage-=10; 211 | } 212 | setList(false, curPage); 213 | } 214 | 215 | if(event.code === 'Period') { 216 | hit=true; 217 | if(curPage===0) { 218 | curPage=pl[0].id-1; 219 | } else { 220 | curPage+=10; 221 | } 222 | setList(false, curPage); 223 | } 224 | 225 | if(event.code === 'ArrowLeft') { 226 | hit=true; 227 | if(audio.currentTime < 10) { 228 | audio.currentTime=0; 229 | } else { 230 | audio.currentTime -= 10; 231 | } 232 | } 233 | 234 | if(event.code === 'ArrowRight') { 235 | hit=true; 236 | if(audio.currentTime + 10 < audio.duration) { 237 | audio.currentTime += 10; 238 | } 239 | } 240 | 241 | 242 | if(hit) { 243 | event.stopPropagation(); 244 | event.preventDefault(); 245 | } 246 | } 247 | 248 | if(event.code === 'Insert') { 249 | playNext(); 250 | } 251 | 252 | if(event.code === 'Enter') { 253 | add( pl[0].file); 254 | } 255 | 256 | }, false); 257 | 258 | 259 | let updateTime = setInterval( ()=>{ 260 | const playtime = document.getElementById('playtime'); 261 | if(playtime) { 262 | if(tim) { 263 | tim.value = ( 100 / audio.duration) * audio.currentTime; 264 | } 265 | const a = (''+Math.floor(audio.currentTime/60)).padStart(2, '0'); 266 | const b = (''+Math.floor(audio.currentTime%60)).padStart(2, '0'); 267 | const c = (''+Math.floor(audio.duration/60)).padStart(2, '0'); 268 | const d = (''+Math.floor(audio.duration%60)).padStart(2,'0'); 269 | playtime.innerHTML = `${a}:${b} / ${c}:${d}`; 270 | let file=audio.currentSrc; 271 | file = file.substr( file.lastIndexOf('/')+1 ); 272 | file = file.substr( 0, file.lastIndexOf('.') ); 273 | document.title=file.replaceAll('%20',' ');; 274 | } 275 | },250); 276 | -------------------------------------------------------------------------------- /src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "src", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "main.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "body-parser": "^1.19.0", 13 | "compression": "^1.7.4", 14 | "express": "^4.17.1", 15 | "music-metadata": "^7.11.4", 16 | "sqlite3": "5.1.7" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/scanner.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const mm = require('music-metadata'); 5 | const sqlite3 = require('sqlite3'); 6 | const db = new sqlite3.Database('/db/metadata.sqlite'); 7 | 8 | function getArtistId(artist) { 9 | if(!artist) { 10 | return 1; 11 | } 12 | 13 | artist=artist.trim(); 14 | return new Promise(resolve=>{ 15 | db.get('SELECT id FROM artist WHERE name = ? COLLATE NOCASE', [artist], (err, row)=>{ 16 | if(err) { 17 | console.error(err); 18 | } else { 19 | if(row) { 20 | resolve(row.id); 21 | } else { 22 | db.run('INSERT INTO artist (name) VALUES(?)', [artist], (err)=>{ 23 | console.log('Added artist:',artist); 24 | resolve(getArtistId(artist)); 25 | }); 26 | } 27 | } 28 | }); 29 | }); 30 | } 31 | 32 | function getAlbumId(artist, album, year) { 33 | if(!album || album === '') { 34 | return 1; 35 | } 36 | album=album.trim(); 37 | const uniqname = `${artist}_${album}_${year}`.toUpperCase(); 38 | 39 | return new Promise(resolve=>{ 40 | db.get('SELECT id FROM album WHERE uniqname = ?', [uniqname], (err, row)=>{ 41 | if(err) { 42 | console.error(err); 43 | } else { 44 | if(row) { 45 | resolve(row.id); 46 | } else { 47 | db.run('INSERT INTO album (name, uniqname) VALUES(?, ?)', [album, uniqname], (err)=>{ 48 | console.log('Added album:', uniqname); 49 | resolve( getAlbumId(artist, album, year) ); 50 | }); 51 | } 52 | } 53 | }); 54 | }); 55 | } 56 | 57 | function addFile(file, next) { 58 | (async ()=>{ 59 | let metadata; 60 | try { 61 | metadata = await mm.parseFile(file); 62 | } catch(e) { 63 | console.log('Metadata extract failed, adding anyway..'); 64 | } 65 | 66 | let artistId=1; 67 | let year=0; 68 | let title=file; 69 | let track=0; 70 | let disk=0; 71 | let codec='-'; 72 | let bitrate=0; 73 | let duration=0; 74 | let lossless=0; 75 | let albumId=1; 76 | let genre=0; 77 | 78 | try { 79 | const common = metadata.common; 80 | const format = metadata.format; 81 | artistId = await getArtistId(common.artist); 82 | year = common.year; 83 | title = common.title; 84 | track = common.track.no; 85 | disk = common.disk.no; 86 | codec = format.codec; 87 | bitrate = format.bitrate; 88 | duration = format.duration; 89 | lossless = format.lossless?1:0; 90 | albumId = await getAlbumId(artistId, common.album, year); 91 | genre = common.genre.join(','); 92 | } catch(e) {} 93 | 94 | db.run('INSERT INTO track (file, artist, album, year, title, track, disk, codec, bitrate, duration, lossless, genre) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',[file, artistId, albumId, year, title, track, disk, codec, bitrate, duration, lossless, genre], (err)=>{ 95 | if(err) { 96 | console.error('Error inserting '+file); 97 | console.error(err); 98 | } 99 | next(); 100 | }); 101 | })(); 102 | } 103 | 104 | let files=0, dirs=0; 105 | 106 | const filesToAdd=[]; 107 | 108 | function scan( dir ) { 109 | const ents = fs.readdirSync(dir, {withFileTypes: true}); 110 | ents.forEach( ent=>{ 111 | if(ent.isDirectory()) { 112 | scan(dir + '/' + ent.name); 113 | dirs++; 114 | } 115 | if(ent.isFile()) { 116 | const file = ent.name; 117 | if( file.match(/\.mp3$|\.wav$|\.ogg$|\.flac$|\.mp2$|\.wma$|\.m4a$/i)) { 118 | files++; 119 | filesToAdd.push({fileName: dir +'/'+file, inDb: false}); 120 | } 121 | } 122 | }); 123 | } 124 | 125 | function addFromArr(idx, done) { 126 | 127 | if(idx < filesToAdd.length) { 128 | const { fileName, inDb }= filesToAdd[idx]; 129 | if( !inDb ) { 130 | newFiles++; 131 | console.log(`Adding file ${idx} of ${filesToAdd.length}: ${fileName} ...`); 132 | addFile(fileName, ()=>{ 133 | addFromArr(idx+1, done); 134 | }); 135 | } else { 136 | setImmediate( ()=>addFromArr(idx+1, done)); 137 | } 138 | } else { 139 | done(); 140 | } 141 | 142 | } 143 | 144 | 145 | console.log('Scan music dir...'); 146 | const started = new Date().getTime(); 147 | scan('/music'); 148 | const ended = new Date().getTime(); 149 | const seconds = ((ended-started)/1000).toFixed(3); 150 | console.log(`Scanned ${files} files in ${dirs} dirs in ${seconds} seconds.`); 151 | 152 | // Mark files in db 153 | let filesInDb=0; 154 | let newFiles=0; 155 | let staleFiles=0; 156 | 157 | const filesToRemove=[]; 158 | console.log('Check existing...'); 159 | const namesOnly = filesToAdd.map(e=>e.fileName); 160 | db.each( 'SELECT file FROM track', [],(err,row)=>{ 161 | const idx = namesOnly.indexOf(row.file); 162 | if(idx !== -1) { 163 | filesToAdd[idx].inDb=true; 164 | filesInDb++; 165 | } else { 166 | staleFiles++; 167 | console.log(`File ${row.file} no longer exists.`); 168 | filesToRemove.push(row.file); 169 | } 170 | }, ()=>{ 171 | console.log('Adding new files...'); 172 | addFromArr(0, ()=>{ 173 | 174 | if(filesToRemove.length) { 175 | console.log('Removing old files...'); 176 | db.run('DELETE FROM track WHERE file IN ('+filesToRemove.map(e=>'?').join(',')+')', filesToRemove, (err)=>{ 177 | if(err) { 178 | console.log(err); 179 | } 180 | allDone(); 181 | }); 182 | } else { 183 | allDone(); 184 | } 185 | 186 | }); 187 | }); 188 | 189 | 190 | async function allDone() { 191 | console.log(`New files: ${newFiles} Existing files: ${filesInDb} Delted files: ${staleFiles}`); 192 | console.log('Generating /list ...'); 193 | await genList(); 194 | console.log('List done.'); 195 | db.close(); 196 | console.log('Db closed.'); 197 | console.log('Process exit.'); 198 | } 199 | 200 | async function genList() { 201 | return new Promise(resolve=>{ 202 | const db = new sqlite3.Database('/db/metadata.sqlite'); 203 | 204 | const writeStream = fs.createWriteStream('/db/list.htm', { flags : 'w', flush: true}); 205 | writeStream.write('list') 206 | db.each('SELECT track.rowid, year, duration,codec, file, title, artist.name AS artistName, album.name AS albumName FROM track INNER JOIN artist ON artist.id=track.artist INNER JOIN album ON album.id=track.album ORDER BY track.rowid DESC', async (err,row)=>{ 207 | if(err) { 208 | console.log(err); 209 | } 210 | await new Promise( wr=>{ 211 | const tt = `${row.title}
${row.id} : ${row.file.substr(7)}`; 212 | const r = `\n`; 213 | writeStream.write(r, ()=>{ 214 | wr(); 215 | }); 216 | }); 217 | }, 218 | ()=>{ 219 | writeStream.write('
codecAlbumArtistTitleYear
${row.codec}${row.albumName}${row.artistName}${tt}${row.year}
'); 220 | db.close(); 221 | resolve(); 222 | }); 223 | }); 224 | } 225 | 226 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const compression = require('compression'); 2 | const express = require('express'); 3 | const bodyParser = require('body-parser'); 4 | const app = express(); 5 | app.use(compression({level:7})); 6 | app.use(bodyParser.json()); 7 | const port = 3000; 8 | const spawn = require('child_process').spawn; 9 | 10 | const sqlite3 = require('sqlite3'); 11 | 12 | const db = new sqlite3.Database('/db/metadata.sqlite'); 13 | 14 | const fs = require('fs'); 15 | const path = require('path'); 16 | 17 | 18 | db.serialize( ()=>{ 19 | db.run('CREATE TABLE IF NOT EXISTS track (id INTEGER PRIMARY KEY AUTOINCREMENT, file TEXT UNIQUE, artist INTEGER, album INTEGER, title TEXT, genre TEXT, lossless INTEGER, codec TEXT, bitrate INTEGER, duration REAL, track INTEGER, disk INTEGER, year INTEGER)'); 20 | db.run('CREATE INDEX IF NOT EXISTS trackIdx ON track (file, title, year)'); 21 | db.run('CREATE TABLE IF NOT EXISTS artist (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE)'); 22 | db.run('CREATE INDEX IF NOT EXISTS artIdx ON artist (name)'); 23 | db.run('INSERT OR IGNORE INTO artist (name) VALUES ("-")'); 24 | db.run('CREATE TABLE IF NOT EXISTS album (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, uniqname UNIQUE)'); 25 | db.run('CREATE INDEX IF NOT EXISTS alIdx ON album (name)'); 26 | db.run('INSERT OR IGNORE INTO album (name) VALUES("-")'); 27 | }); 28 | 29 | 30 | // Some flac files in my collection cannot play because.. they have an id3 tag and firefox just can't see past that. 31 | app.get('/flac/music/*.flac', (req, res)=>{ 32 | const first = req.path.replace('/flac', ''); 33 | const filePath = path.resolve( decodeURIComponent(first)); 34 | console.log('handling flac file specially:',decodeURIComponent(req.path)); 35 | 36 | const stream = fs.createReadStream( filePath ); 37 | 38 | let gotHead=false; 39 | stream.on('readable', ()=>{ 40 | let data; 41 | while( (data = stream.read(1024*128) )) { 42 | if(gotHead) { 43 | res.write(data); 44 | } else { 45 | const headerPos = data.indexOf('fLaC'); 46 | if(!headerPos === -1) { 47 | console.error('No fLaC found in',filePath); 48 | } else { 49 | gotHead=true; 50 | res.write( data.slice(headerPos) ); 51 | } 52 | } 53 | } 54 | }); 55 | }); 56 | 57 | app.use('/music/', express.static('/music')); 58 | 59 | app.use('/random.json', (req, res)=>{ 60 | const data = []; 61 | 62 | db.each('SELECT year, duration,codec, file, title, artist.name AS artistName, album.name AS albumName FROM track INNER JOIN artist ON artist.id=track.artist INNER JOIN album ON album.id=track.album ORDER BY RANDOM() LIMIT 25', [], (err,row)=>{ 63 | if(err) { 64 | console.log(err); 65 | } 66 | data.push(row); 67 | }, ()=>{ 68 | return res.send(data); 69 | }); 70 | }); 71 | 72 | app.use('/tracks.json', (req, res)=>{ 73 | const data = []; 74 | const query = req.query.q.split(' '); 75 | const likes = query.map( ()=>{ return ' file LIKE ?'; }).join(' AND '); 76 | const qargs = query.map( e=>'%'+e+'%'); 77 | 78 | db.each('SELECT track.id as id, year, duration,codec, file, title, artist.name AS artistName, album.name AS albumName FROM track INNER JOIN artist ON artist.id=track.artist INNER JOIN album ON album.id=track.album WHERE'+likes+' LIMIT 300', qargs, (err,row)=>{ 79 | if(err) { 80 | console.log(err); 81 | } 82 | data.push(row); 83 | }, ()=>{ 84 | return res.send(data); 85 | }); 86 | }); 87 | 88 | app.use('/browse.json', (req, res)=>{ 89 | const data = []; 90 | 91 | const offset=parseInt(req.query.p); 92 | 93 | db.each('SELECT year, duration,codec, file, title, artist.name AS artistName, album.name AS albumName FROM track INNER JOIN artist ON artist.id=track.artist INNER JOIN album ON album.id=track.album LIMIT ?, 30', [offset], (err,row)=>{ 94 | if(err) { 95 | console.log(err); 96 | } 97 | data.push(row); 98 | }, ()=>{ 99 | return res.send(data); 100 | }); 101 | }); 102 | 103 | app.listen(port, () => { 104 | console.log(`Server listening at on port ${port}`) 105 | }); 106 | 107 | let scanner = null; 108 | let lastScanIo = 'No scans started..'; 109 | 110 | app.use('/scan', (req, res)=>{ 111 | const query = req.query; 112 | 113 | if(query.start) { 114 | if(scanner) { 115 | return res.send('Already scanning!'); 116 | } 117 | 118 | scanner = spawn('node', ['./scanner.js']); 119 | lastScanIo= new Date()+' Started scanning...\n'; 120 | scanner.stdout.on('data', (data)=>{ 121 | lastScanIo += data.toString(); 122 | }); 123 | scanner.stderr.on('data', (data)=>{ 124 | lastScanIo += data.toString(); 125 | }); 126 | scanner.on('close', (code)=>{ 127 | lastScanIo += '\nScan ended: '+new Date()+' with code: '+code; 128 | scanner=null; 129 | }); 130 | } 131 | 132 | db.each('SELECT COUNT(*) as numTracks FROM track', [], (err, row)=>{ 133 | if(!err && row) { 134 | res.send( ( row.numTracks+' tracks in db.\n'+( (!scanner)?'Scan library':'Refresh' )+'\nBack to player\n'+lastScanIo).replace( /\n/g, '
')); 135 | } else { 136 | res.send('Some error:'+ err.message); 137 | } 138 | }); 139 | 140 | }); 141 | 142 | app.use('/list', (req, res)=>{ 143 | res.sendFile('/db/list.htm'); 144 | }); 145 | 146 | app.use('/', express.static('/src/html/')); 147 | -------------------------------------------------------------------------------- /src/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -z "$USER_ID" ] 4 | then 5 | usermod -u $USER_ID node 6 | echo "Changing user id to $USER_ID" 7 | fi 8 | 9 | if [ ! -z "$GROUP_ID" ] 10 | then 11 | groupmod -g $GROUP_ID node 12 | echo "Changing group id to $GROUP_ID" 13 | fi 14 | 15 | if [ ! -d "/db" ] 16 | then 17 | echo "No /db directory, scanned data will be lost with the container." 18 | mkdir /db 19 | chown node:node /db 20 | fi 21 | 22 | runuser -u node -g node node server 23 | 24 | --------------------------------------------------------------------------------