const { spawn, exec } = require('child_process') const fs = require('fs') const youtubedl = require('youtube-dl-exec') const tmp = require('tmp') const playlist = require('./playlist-search') const STREAM_URL = "https://www.youtube.com/henrikomagnifico/live" const DURATION_REGEX = /(\d{1,2}:\d{2})\/(\d{1,2}:\d{2})/ const EXPIRATION_REGEX = /.+\/expire\/([0-9]{10})\/.+/ let currentTrack = {} let nextTrackTimestamp = 0 let resolvedUrl = { url: '', expires: 0 } function readText(tmpfile, charWhitelist) { return new Promise((resolve, reject) => { let command = `tesseract ${tmpfile.name} -` if (charWhitelist) { command += ` -c tessedit_char_whitelist="${charWhitelist}"` } exec(command, ((error, stdout, stderr) => { tmpfile.removeCallback() if (error) { console.error(stderr) reject(error) } else { resolve(stdout) } })) }) } function thresholdImage(image, threshold) { return new Promise(((resolve, reject) => { const tmpFile = tmp.fileSync() exec(`convert png:${image.name} -white-threshold ${threshold}% -colorspace HSB -channel B -separate ${tmpFile.name}`, ((error, stdout, stderr) => { if (error) { console.error(stderr) tmpFile.removeCallback() reject(error) } else { resolve(tmpFile) } })) })) } function getRegion(frame, x, y, width, height) { // Returns Promise return new Promise(function (resolve, reject) { const tmpFile = tmp.fileSync() exec(`convert png:${frame.name} -negate -crop ${width}x${height}+${x}+${y} png:${tmpFile.name}`, ((error, stdout, stderr) => { if (error) { console.error(stderr) tmpFile.removeCallback() reject(error) } else { resolve(tmpFile) } })) }); } function getFrame(url) { return new Promise((resolve, reject) => { const frameFile = tmp.fileSync() exec(`ffmpeg -i ${url} -y -f image2 -c:v png -frames:v 1 ${frameFile.name}`, (err, stdout, stderr) => { if (err) { console.log(stderr) frameFile.removeCallback() reject(err) } else { resolve(frameFile) } }) }); } function getYoutubeStream() { return new Promise((resolve, reject) => { youtubedl(STREAM_URL, { dumpJson: true, format: "best" }).then(output => resolve(output.url)) .catch(err => reject(err)) }) } function getYoutubeAudioUrl() { } function getVotes(ocrResults) { return ocrResults.map((text, index, arr) => { // Actually perform the search. Map returns array of search results, result.refIndex is of interest const title = text[0].trim() const album = text[1].trim() const position_duration = text[2].trim() let capturedDuration = DURATION_REGEX.test(position_duration) ? position_duration.match(DURATION_REGEX) : ['', '', ''] const retval = { result: playlist.search(album, title, capturedDuration[2]), position: capturedDuration[1] } return retval }) } function tallyVotes(votes) { const trackResults = {} const positionResults = {} votes.forEach(vote => { if (vote.result != null) { if (trackResults.hasOwnProperty(vote.result.refIndex)) { trackResults[`${vote.result.refIndex}`] = trackResults[`${vote.result.refIndex}`] + 1 } else { trackResults[`${vote.result.refIndex}`] = 1 } } if (vote.position) { if (positionResults.hasOwnProperty(vote.position)) { positionResults[`${vote.position}`] = positionResults[`${vote.position}`] + 1 } else { positionResults[`${vote.position}`] = 1 } } }) if (trackResults.length === 0) { console.log("votes resulted in no results!") return { position: null, positionConfidence: 0, result: null, resultConfidence: 0 } } // Retrieve votes let trackIndex = Object.keys(trackResults)[0] let trackMaxVotes = trackResults[trackIndex] let position = Object.keys(positionResults)[0] let positionMaxVotes = positionResults[position] for (const index in trackResults) { if (trackResults[index] > trackMaxVotes) { trackIndex = index trackMaxVotes = trackResults[index] } } for (const positionKey in positionResults) { if (positionResults[positionKey] > positionMaxVotes) { position = positionKey positionMaxVotes = positionResults[positionKey] } } // console.log(JSON.stringify(votes)) console.debug(`Vote was index ${trackIndex}`) const actualResult = votes.find(v => v.result != null && `${v.result.refIndex}` === trackIndex).result.item return { position: position, positionConfidence: positionMaxVotes / votes.length, result: actualResult, resultConfidence: trackMaxVotes / votes.length } } function timeUntilNextTrack(position, duration) { const timeRegex = /(\d{1,2}):(\d{2})/ const durationValues = duration.match(timeRegex) const positionValues = position.match(timeRegex) const durationInSeconds = (parseInt(durationValues[1]) * 60) + parseInt(durationValues[2]) const positionInSeconds = (parseInt(positionValues[1]) * 60) + parseInt(positionValues[2]) return durationInSeconds - positionInSeconds } function performQuorumProcessing(titleCroppedPromise, albumCroppedPromise, durationCroppedPromise) { return new Promise(((resolve, reject) => { Promise.all([titleCroppedPromise, albumCroppedPromise, durationCroppedPromise]) // Wait for all promises to be resolved, returns paths to cropped segments of frame .then(images => { const thresholds = [15, 12.5, 10, 7.5, 5] Promise.all(thresholds.map(t => { // Threshold all segments at determined levels, returns text determined by each threshold level as [title, album, position/duration] return Promise.all([ thresholdImage(images[0], t).then(file => readText(file)), thresholdImage(images[1], t).then(file => readText(file)), thresholdImage(images[2], t).then(file => readText(file, "1234567890:/")) ]) })).then(allOcrResults => { // Perform search based on resolved text. Perform voting here between thresholds // console.debug(allOcrResults) // Cast votes const votes = getVotes(allOcrResults) // Actually perform the search. Map returns array of search results, result.refIndex is of interest // console.debug(JSON.stringify(votes)) // Tally votes const winner = tallyVotes(votes) console.debug(`Position confidence: ${winner.positionConfidence}`) console.debug(`Result confidence: ${winner.resultConfidence}`) resolve(winner) }) }).catch(error => reject(error)) })) } async function updateStreamUrl() { const ytStreamUrl = await getYoutubeStream() resolvedUrl.url = ytStreamUrl resolvedUrl.expires = parseInt(ytStreamUrl.match(EXPIRATION_REGEX)[1]) * 1000 } async function updateTrackInfo() { const urlExpiresSoon = resolvedUrl.expires < (Date.now() + (30 * 60 * 1000)) if (resolvedUrl.expires < Date.now()) { await updateStreamUrl() console.log("Got stream info") } else if (urlExpiresSoon) { updateStreamUrl().then(() => console.log("Updated stream info in the background")) } console.log(`Now: ${Date.now()}\tExpiration: ${resolvedUrl.expires}`) const frame = await getFrame(resolvedUrl.url) const frameTime = Date.now() const titlePromise = getRegion(frame, 432, 906, 1487, 54) const albumPromise = getRegion(frame, 440, 957, 1487, 32) const durationPromise = getRegion(frame, 0, 1028, 235, 34) const result = await performQuorumProcessing(titlePromise, albumPromise, durationPromise) if (result.resultConfidence < 0.5) { setTimeout(() => updateTrackInfo(), 250) return; } currentTrack = result.result console.log(`${currentTrack.album}: ${currentTrack.track} (${result.position}/${currentTrack.duration})`) const secondsUntilNext = timeUntilNextTrack(result.position, currentTrack.duration) const processingTime = Date.now() - frameTime nextTrackTimestamp = frameTime + (secondsUntilNext * 1000) setTimeout(() => updateTrackInfo(), (secondsUntilNext * 1000) - processingTime + 500) console.log(`Frame processing took ${processingTime}ms`) console.log(`Next track should be at: ${new Date(nextTrackTimestamp).toLocaleString()}`) } // getYoutubeStream() // .then(url => getFrame(url)) // .then(frame => { // const titlePromise = getRegion(frame, 432, 906, 1487, 54) // const albumPromise = getRegion(frame, 440, 957, 1487, 32) // const durationPromise = getRegion(frame, 0, 1028, 235, 34) // // return performQuorumProcessing(titlePromise, albumPromise, durationPromise) // }) // .then(value => { // console.log('Processing complete, voted result was:') // console.log(`${value.result.album}: ${value.result.track} (${value.position}/${value.result.duration})`) // console.log(`Time until next track: ${timeUntilNextTrack(value.position, value.result.duration)}`) // }) // .catch(err => console.error(err)) updateTrackInfo()