263 lines
8.5 KiB
JavaScript
263 lines
8.5 KiB
JavaScript
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<Buffer>
|
|
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()
|