identification mostly working
This commit is contained in:
270
index.js
270
index.js
@@ -1,66 +1,80 @@
|
||||
const spawn = require('child_process').spawn
|
||||
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) => {
|
||||
const tesseract = charWhitelist ? spawn('tesseract', [tmpfile.name, '-', '-c', `tessedit_char_whitelist="${charWhitelist}`]) : spawn('tesseract', [tmpfile.name, '-'])
|
||||
let recognizedString = ''
|
||||
|
||||
tesseract.stdout.on('data', (data) => {
|
||||
recognizedString += data;
|
||||
})
|
||||
|
||||
tesseract.on('exit', (code) => {
|
||||
tmpfile.removeCallback()
|
||||
if (code !== 0) {
|
||||
reject()
|
||||
} else {
|
||||
resolve(recognizedString)
|
||||
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>
|
||||
console.log("retrieving region")
|
||||
const threshold = "15%"
|
||||
return new Promise(function (resolve, reject) {
|
||||
const tmpFile = tmp.fileSync()
|
||||
const convert = spawn('convert', [`png:${frame.name}`, '-negate', '-crop', `${width}x${height}+${x}+${y}`, '-white-threshold', threshold, `png:${tmpFile.name}`])
|
||||
|
||||
convert.stderr.on('data', data => console.error(data.toString()))
|
||||
|
||||
convert.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject()
|
||||
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 {
|
||||
console.log("got region", tmpFile.name)
|
||||
resolve(tmpFile);
|
||||
resolve(tmpFile)
|
||||
}
|
||||
});
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
function getFrame(url) {
|
||||
return new Promise((resolve, reject) => { // Returns Promise<Buffer>
|
||||
return new Promise((resolve, reject) => {
|
||||
const frameFile = tmp.fileSync()
|
||||
const ffmpeg = spawn('ffmpeg', ['-i', url, '-y', '-f', 'image2', '-c:v', 'png', '-frames:v', '1', frameFile.name])
|
||||
|
||||
ffmpeg.stderr.on('data', data => console.error(data.toString()))
|
||||
|
||||
ffmpeg.on('exit', (code) => {
|
||||
if (code !== 0) {
|
||||
reject()
|
||||
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 {
|
||||
console.log("got frame", frameFile.name)
|
||||
resolve(frameFile)
|
||||
}
|
||||
})
|
||||
@@ -77,24 +91,172 @@ function getYoutubeStream() {
|
||||
})
|
||||
}
|
||||
|
||||
getYoutubeStream()
|
||||
.then(url => getFrame(url))
|
||||
.then(frame => {
|
||||
let title = '',
|
||||
album = '',
|
||||
duration = ''
|
||||
function getYoutubeAudioUrl() {
|
||||
|
||||
const titlePromise = getRegion(frame, 432, 906, 1487, 54).then(titleRegion => readText(titleRegion))
|
||||
const albumPromise = getRegion(frame, 440, 957, 1487, 32).then(albumRegion => readText(albumRegion))
|
||||
const durationPromise = getRegion(frame, 0, 1028, 235, 34).then(durationRegion => readText(durationRegion, "1234567890:/"))
|
||||
}
|
||||
|
||||
console.log("waiting for processors")
|
||||
Promise.all([titlePromise, albumPromise, durationPromise]).then(values => {
|
||||
frame.removeCallback()
|
||||
title = values[0].trim()
|
||||
album = values[1].trim()
|
||||
duration = values[2].trim()
|
||||
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]
|
||||
}
|
||||
|
||||
console.log(`${album}: ${title} (${duration})`)
|
||||
}).catch(err => console.error(err))
|
||||
}).catch(err => console.error(err))
|
||||
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()
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
const got = require('got')
|
||||
const cheerio = require('cheerio')
|
||||
const fs = require('fs')
|
||||
const PLAYLIST_URL = 'https://nintendoradioplaylist2.000webhostapp.com/Livestream%20Playlist.html'
|
||||
|
||||
got('https://nintendoradioplaylist2.000webhostapp.com/Livestream%20Playlist.html').then(res => {
|
||||
function scrapePlaylist(url) {
|
||||
got(url).then(res => {
|
||||
const $ = cheerio.load(res.body)
|
||||
const playlist = []
|
||||
$('#playlistTableBody tr').each((i, row) => {
|
||||
cells = $(row).find('td')
|
||||
album = $(cells[1]).text()
|
||||
track = $(cells[2]).text()
|
||||
duration = $(cells[3]).text()
|
||||
const cells = $(row).find('td')
|
||||
const album = $(cells[1]).text()
|
||||
const track = $(cells[2]).text()
|
||||
const duration = $(cells[3]).text()
|
||||
playlist.push({album, track, duration})
|
||||
})
|
||||
fs.writeFileSync('playlist.json', JSON.stringify(playlist))
|
||||
}).catch(err => {
|
||||
}).catch(err => {
|
||||
console.log(err)
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
scrapePlaylist
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
scrapePlaylist(PLAYLIST_URL)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
const Fuse = require('fuse.js')
|
||||
const playlist = require('../radioScraper/playlist.json')
|
||||
const fs = require('fs')
|
||||
const scraper = require('./playlist-scrape')
|
||||
const PLAYLIST_URL = 'https://nintendoradioplaylist2.000webhostapp.com/Livestream%20Playlist.html'
|
||||
const playlist = require('./playlist.json')
|
||||
|
||||
const albumValidCharacters = new Set()
|
||||
const trackValidCharacters = new Set()
|
||||
|
||||
function buildAlbumValidCharacters() {
|
||||
albumValidCharacters.clear()
|
||||
new Set(playlist.map(entry => entry.album)) // Get set of albums
|
||||
.forEach(album => album.split('').forEach(letter => albumValidCharacters.add(letter)))
|
||||
}
|
||||
|
||||
function buildTrackValidCharacters() {
|
||||
trackValidCharacters.clear()
|
||||
new Set(playlist.map(entry => entry.track))
|
||||
.forEach(track => track.split('').forEach(letter => trackValidCharacters.add(letter)))
|
||||
}
|
||||
|
||||
function getValidAlbumCharacters() {
|
||||
if (albumValidCharacters.size === 0) {
|
||||
buildAlbumValidCharacters()
|
||||
}
|
||||
return albumValidCharacters
|
||||
}
|
||||
|
||||
function getValidTrackCharacters() {
|
||||
if (trackValidCharacters.size === 0) {
|
||||
console.log("Building index of valid track characters, this may take some time...")
|
||||
buildTrackValidCharacters()
|
||||
}
|
||||
return trackValidCharacters
|
||||
}
|
||||
|
||||
const fuseByAlbum = new Fuse(playlist, {
|
||||
keys: ['album']
|
||||
@@ -9,33 +42,70 @@ const fuseByTrack = new Fuse(playlist, {
|
||||
})
|
||||
|
||||
function searchAlbums(query) {
|
||||
return fuseByAlbum.search(query).map(result => result.item)
|
||||
return fuseByAlbum.search(query)
|
||||
}
|
||||
|
||||
function searchTracks(query) {
|
||||
return fuseByTrack.search(query).map(result => result.item)
|
||||
return fuseByTrack.search(query)
|
||||
}
|
||||
|
||||
function fullSearch(album, track, duration) {
|
||||
const inAlbumSearch = new Fuse(searchAlbums(album), {keys: ['item.track']})
|
||||
const trackResults = inAlbumSearch.search(track).map(result => result.item)
|
||||
if (duration) {
|
||||
return filterResultsByDuration(trackResults, duration)
|
||||
} else {
|
||||
// No duration present, treat as album+track lookup
|
||||
return trackResults[0]
|
||||
}
|
||||
}
|
||||
|
||||
function albumDurationSearch(album, duration) {
|
||||
const albumResults = searchAlbums(album)
|
||||
return filterResultsByDuration(albumResults, duration, true)
|
||||
}
|
||||
|
||||
function trackDurationSearch(track, duration) {
|
||||
const trackResults = searchTracks(track)
|
||||
return filterResultsByDuration(trackResults, duration)
|
||||
}
|
||||
|
||||
function filterResultsByDuration(results, duration, failSearch = false) {
|
||||
const durationResults = results.filter(result => result.item.duration === duration)
|
||||
if (durationResults.length > 0) {
|
||||
return durationResults[0]
|
||||
} else {
|
||||
return failSearch ? null : results[0]
|
||||
}
|
||||
}
|
||||
|
||||
function search(albumQuery, trackQuery, duration) {
|
||||
if (albumQuery) {
|
||||
const inAlbumSearch = new Fuse(searchAlbums(albumQuery), {keys: ['track']})
|
||||
const trackResults = inAlbumSearch.search(trackQuery)
|
||||
console.log(`initial results: ${trackResults.length}`)
|
||||
trackResults.filter(result => result.item.duration === duration)
|
||||
if (trackResults.length > 0) {
|
||||
return trackResults[0].item
|
||||
}
|
||||
if (albumQuery && trackQuery && duration) {
|
||||
return fullSearch(albumQuery, trackQuery, duration)
|
||||
} else if (albumQuery && duration) {
|
||||
return albumDurationSearch(albumQuery, duration)
|
||||
} else if (trackQuery && duration) {
|
||||
return trackDurationSearch(trackQuery, duration)
|
||||
} else {
|
||||
const trackResults = searchTracks(trackQuery)
|
||||
console.log(`initial results: ${trackResults.length}`)
|
||||
const durationResults = trackResults.filter(result => result.duration === duration)
|
||||
console.log(`secondary results: ${durationResults.length}`)
|
||||
if (durationResults.length > 0) {
|
||||
return durationResults[0]
|
||||
}
|
||||
// Not enough info to perform a decent search
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function trySearch() {
|
||||
console.log(JSON.stringify(search("The Lede", "Lanayru Mining Facility", "3:10")))
|
||||
console.log(JSON.stringify(search("Final Fantasy VI", "Dancing Mad", "\f")))
|
||||
}
|
||||
|
||||
// These shouldn't be called asynchronously, manually call it ourselves
|
||||
buildTrackValidCharacters()
|
||||
buildAlbumValidCharacters()
|
||||
|
||||
module.exports = {
|
||||
search,
|
||||
getValidAlbumCharacters,
|
||||
getValidTrackCharacters
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
trySearch()
|
||||
}
|
||||
|
||||
1
playlist.json
Normal file
1
playlist.json
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user