const { spawn, exec } = require('child_process') const fs = require('fs') const youtubedl = require('youtube-dl-exec') import {FileResult, fileSync} from 'tmp' // const {fileSync} = require('tmp') const playlist = require('./playlist-search') const scraper = require('./playlist-scrape') const {getNewPoll} = require('./poll-master') 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})\/.+/ const thresholdMap = [20, 17.5, 15, 12.5, 10, 7.5, 5].reduce((obj, t) => { obj[t] = {trackVotes: {correct: 0, skipped: 0}, positionVotes: {correct: 0, skipped: 0}} return obj }, {}) const thresholds = [20, 17.5, 15, 12.5, 10, 7.5, 5].map(t => ({threshold: t, trackVotes: {correct: 0, skipped: 0}, positionVotes: {correct: 0, skipped: 0}})) let currentTrack = {} let currentTrackPosition = { position: 0, positionTimestamp: 0 } let trackIdentified = false let nextTrackTimestamp = 0 let readAttempts = 0 let totalVotes = 0 let preemptiveCallback = null let resolvedUrl = { url: '', expires: 0 } const trackStatsFileStream = fs.createWriteStream("track.csv", {flags: 'a'}); const positionStatsFileStream = fs.createWriteStream("position.csv", {flags: 'a'}); thresholds.forEach(t => { trackStatsFileStream.write(t.threshold + ",") positionStatsFileStream.write(t.threshold + ",") }) trackStatsFileStream.write('\n') positionStatsFileStream.write('\n') function readText(tmpFile: FileResult, charWhitelist: string = null) { 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.replace(/[\n\f]/g, '')) } })) }) } function thresholdImage(image, threshold): Promise { return new Promise(((resolve, reject) => { const tmpFile = 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): Promise { // Returns Promise return new Promise(function (resolve, reject) { const tmpFile = 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): Promise { return new Promise((resolve, reject) => { const frameFile = 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 new Promise(resolve => resolve()).then(console.debug(`Worker ${index} input: ${text}`)) 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 = {} let overallVotes = 0 let positionVotes = 0 const voters = [] const durationVotes = [] totalVotes++ votes.forEach((vote, index, arr) => { if (vote.result != null) { overallVotes++ voters.push("y") if (trackResults.hasOwnProperty(vote.result.refIndex)) { trackResults[`${vote.result.refIndex}`] = trackResults[`${vote.result.refIndex}`] + 1 } else { trackResults[`${vote.result.refIndex}`] = 1 } } else { voters.push("n") } if (vote.position) { positionVotes++ durationVotes.push("y") if (positionResults.hasOwnProperty(vote.position)) { positionResults[`${vote.position}`] = positionResults[`${vote.position}`] + 1 } else { positionResults[`${vote.position}`] = 1 } } else { durationVotes.push("n") } }) console.debug("\nDid workers vote?") console.debug("Result vote:", JSON.stringify(voters)) console.debug("Duration vote:", JSON.stringify(durationVotes)) if (overallVotes === 0) { console.warn("no workers voted") return { position: null, positionConfidence: 0, result: null, resultConfidence: 0, voterConfidence: overallVotes / votes.length } } else if (overallVotes / votes.length < 0.5) { console.warn("not enough workers voted") return { position: null, positionConfidence: 0, result: null, resultConfidence: 0, voterConfidence: overallVotes / votes.length } } // Retrieve votes let trackIndex = Object.keys(trackResults)[0] let trackMaxVotes = trackResults[trackIndex] let position = Object.keys(positionResults)[0] let positionMaxVotes = positionResults[position] console.debug("Initial index:", trackIndex, "All vote results:", trackResults) 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(`\nVote was index`, trackIndex) console.debug("Worker results:") const voterTrackAccuracy = voters.map((v, i, a) => { if (v === 'y') { const voteWasCorrect = `${votes[i].result.refIndex}` === trackIndex if (voteWasCorrect) { thresholds[i].trackVotes.correct++ return 'Y' } else { return 'n' } } else { thresholds[i].trackVotes.skipped++ return '_' } }) const voterPositionAccuracy = durationVotes.map(((value, index, array) => { if (value === 'y') { const voteWasCorrect = votes[index].position === position if (voteWasCorrect) { thresholds[index].positionVotes.correct++ return 'Y' } else { return 'n' } } else { thresholds[index].positionVotes.skipped++ return '_' } })) console.debug("Track accuracy:", JSON.stringify(voterTrackAccuracy)) trackStatsFileStream.write(voterTrackAccuracy.join(',')) console.debug("Position accuracy:", JSON.stringify(voterPositionAccuracy)) positionStatsFileStream.write(voterPositionAccuracy.join(',')) 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 / overallVotes, voterConfidence: overallVotes / votes.length } } function millisToNextTrack(position, duration) { const durationInSeconds = timeToSeconds(duration) const positionInSeconds = timeToSeconds(position) return (durationInSeconds - positionInSeconds) * 1000 } function timeToSeconds(time) { const timeRegex = /(\d{1,2}):(\d{2})/ if (timeRegex.test(time)) { const timeValues = time.match(timeRegex) return (parseInt(timeValues[1]) * 60) + parseInt(timeValues[2]) } else { throw `${time} does not match MM:SS format` } } 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 => { 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.threshold).then(file => readText(file)), thresholdImage(images[1], t.threshold).then(file => readText(file)), thresholdImage(images[2], t.threshold).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('\nPosition confidence:', winner.positionConfidence) console.debug('Result confidence:', winner.resultConfidence) console.debug('Voter confidence:', winner.voterConfidence) resolve(winner) }) }).catch(error => reject(error)) })) } function logVotingResults(votes, result, voteType) { Object.keys(votes).forEach(t => { const tracker = thresholdMap[t] if (votes[t] != null) { if (votes[t] === result) { tracker[voteType].correct++ } } else { tracker[voteType].skipped++ } }) } async function positionDurationVoting(croppedDuration, voteOnDuration) { const matchIndex = voteOnDuration ? 2 : 1 return new Promise((async (resolve) => { const positionPollName = `position-${matchIndex}-${Date.now()}` poll.registerNewPoll(positionPollName, thresholds.length, (result, votes) => { // Log votes logVotingResults(votes, result, 'positionVotes') // Return result resolve(result) }, (votes) => { // Log votes logVotingResults(votes, null, 'positionVotes') resolve(null) }) for (const t of Object.keys(thresholdMap)) { const thresholded = await thresholdImage(croppedDuration, t) const ocrText = await readText(thresholded, "1234567890:/") let capturedDuration = DURATION_REGEX.test(ocrText) ? ocrText.match(DURATION_REGEX) : [null, null, null] poll.voteInPoll(positionPollName, t, capturedDuration[matchIndex]) } })) } async function trackVoting(croppedTitle, croppedAlbum, duration) { return new Promise(async (resolve) => { const poll = getNewPoll(Object.keys(thresholdMap).length, (result, votes) => { logVotingResults(votes, result, 'trackVotes') console.log('trackVoteResult', result) resolve(playlist.getAt(result)) }, (votes) => { logVotingResults(votes, null, 'trackVotes') resolve(null) }) for (const t of Object.keys(thresholdMap)) { Promise.all([thresholdImage(croppedTitle, t).then(text => readText(text)), thresholdImage(croppedAlbum, t).then(text => readText(text))]).then(trackOcr => { const title = trackOcr[0].trim() const album = trackOcr[1].trim() const track = playlist.search(album, title, duration) if (track != null) { poll.vote(t, track.refIndex) } else { poll.skipVote(t) } }) } }) } 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}\tAttempt: ${readAttempts}`) const processingStart = Date.now() const frame = await getFrame(resolvedUrl.url) const frameTime = Date.now() const titlePromise = getRegion(frame, 432, 850, 1487, 100) const albumPromise = getRegion(frame, 440, 957, 1487, 32) const durationPromise = getRegion(frame, 0, 1028, 235, 34) const result = await performQuorumProcessing(titlePromise, albumPromise, durationPromise) trackStatsFileStream.write(',' + readAttempts + '\n') const votingTime = Date.now() if (result.resultConfidence < 0.5) { console.debug("Result confidence not high enough, retrying") setTimeout(() => updateTrackInfo(), 0) readAttempts++ return; } readAttempts = 0 currentTrack = result.result console.debug("\nCurrent threshold statistics: ") thresholds.forEach(t => { const skippedTrackRatio = (t.trackVotes.skipped / totalVotes) * 100 const skippedPositionRatio = (t.positionVotes.skipped / totalVotes) * 100 const accurateTrackRatio = (t.trackVotes.correct / (totalVotes - t.trackVotes.skipped)) * 100 const accuratePositionRatio = (t.positionVotes.correct / (totalVotes - t.positionVotes.skipped)) * 100 const trackEffectiveness = accurateTrackRatio - skippedTrackRatio const positionEffectiveness = accuratePositionRatio - skippedPositionRatio console.log(`${t.threshold}%:\tTrack: [Skip: ${skippedTrackRatio.toFixed(2)}% Correct: ${accurateTrackRatio.toFixed(2)}% Eff.: ${trackEffectiveness.toFixed(2)}%]\tPos: [Skip: ${skippedPositionRatio.toFixed(2)}% Correct: ${accuratePositionRatio.toFixed(2)}% Eff.: ${positionEffectiveness.toFixed(2)}%]`) }) if (result.position) { const secondsUntilNext = millisToNextTrack(result.position, currentTrack.duration) const processingTime = Date.now() - processingStart const timeInFrame = Date.now() - frameTime nextTrackTimestamp = frameTime + (secondsUntilNext * 1000) setTimeout(() => updateTrackInfo(), secondsUntilNext - timeInFrame + 750) console.log(`\nFrame processing took ${processingTime}ms (Frame retrieval: ${frameTime - processingStart}ms) (Processing & voting: ${votingTime - frameTime}ms)`) console.log(`${currentTrack.album}: ${currentTrack.track} (${result.position}/${currentTrack.duration})`) console.log(`Next track should be at: ${new Date(nextTrackTimestamp).toLocaleString()}`) } else { console.log(`${currentTrack.album}: ${currentTrack.track}`) console.warn("Workers did not vote on current position, retrying...") setTimeout(() => updateTrackInfo(), 0) } } async function updateTrackInfo_v2() { trackIdentified = false 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(`V2: Now: ${Date.now()}\tExpiration: ${resolvedUrl.expires}\tAttempt: ${readAttempts}`) const processingStart = Date.now() const frame = await getFrame(resolvedUrl.url) const frameTime = Date.now() const croppedDuration = await getRegion(frame, 0, 1028, 235, 34) positionDurationVoting(croppedDuration, true).then(async duration => { // Not checking if duration was null is fine because we can search without it const title = await getRegion(frame, 432, 850, 1487, 100) const album = await getRegion(frame, 440, 957, 1487, 32) const trackResult = await trackVoting(title, album, duration) if (trackResult == null) { // Didn't get a result, try again setTimeout(updateTrackInfo_v2, 0) } else { console.log("identified track") currentTrack = trackResult trackIdentified = true if (positionIsIdentified()) { registerNextTrackCheck(currentTrackPosition.position, currentTrack.duration, frameTime) printTrack(frameTime, processingStart) } else { // We know that a song will only run as long as the duration, // so we can at least register a callback for that far out const timeoutLength = millisToNextTrack("0:00", currentTrack.duration) - (Date.now() - frameTime) + 750 preemptiveCallback = setTimeout(updateTrackInfo_v2, timeoutLength) } } }).catch(err => { // }) if (!positionIsIdentified()) { updatePosition(frame, frameTime).then(position => { if (position != null) { console.log("identified position") if (trackIdentified) { registerNextTrackCheck(position, currentTrack.duration, frameTime) printTrack(frameTime, processingStart) if (preemptiveCallback != null) { clearTimeout(preemptiveCallback) preemptiveCallback = null } } else { currentTrackPosition.position = position currentTrackPosition.positionTimestamp = frameTime } } else { console.log("position missing") setTimeout(updatePosition, 0) } }) } } function printTrack(frameTime, processingStart) { const now = Date.now() const processingTime = now - processingStart const votingTime = now - frameTime const frameRetrievalTime = frameTime - processingStart console.log(`\nFrame processing took ${processingTime}ms (Frame retrieval: ${frameRetrievalTime}ms) (Processing & voting: ${votingTime}ms)`) console.log(`${currentTrack.album}: ${currentTrack.track} (${currentTrackPosition.position}/${currentTrack.duration})`) console.log(`Next track should be at: ${new Date(nextTrackTimestamp).toLocaleString()}`) } function registerNextTrackCheck(position, duration, frameTime) { nextTrackTimestamp = millisToNextTrack(position, duration) - (Date.now() - frameTime) setTimeout(updateTrackInfo_v2, nextTrackTimestamp + 750) } function positionIsIdentified() { return nextTrackTimestamp < currentTrackPosition.positionTimestamp } async function updatePosition(frame, frameTime) { // Stream URL update shouldn't need to be called from here if (frame == null) { frame = await getFrame(resolvedUrl.url) frameTime = Date.now() } const durationFrame = await getRegion(frame, 0, 1028, 235, 34) const position = await positionDurationVoting(durationFrame, false) return position if (position == null) { // Position wasn't found, and we need it. Try again. setTimeout(updatePosition, 0) } else { currentTrackPosition.position = position currentTrackPosition.positionTimestamp = frameTime } } // 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_v2()