More voting stuff, start moving to typescript
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
**/.DS_Store
|
||||
@@ -1,29 +1,39 @@
|
||||
const { spawn, exec } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const youtubedl = require('youtube-dl-exec')
|
||||
const tmp = require('tmp')
|
||||
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 albumWhitelistCharacters = playlist.getValidAlbumCharacters()
|
||||
let trackWhitelistCharacters = playlist.getValidTrackCharacters()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var trackStatsFileStream = fs.createWriteStream("track.csv", {flags: 'a'});
|
||||
var positionStatsFileStream = fs.createWriteStream("position.csv", {flags: 'a'});
|
||||
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 + ",")
|
||||
@@ -31,14 +41,14 @@ thresholds.forEach(t => {
|
||||
trackStatsFileStream.write('\n')
|
||||
positionStatsFileStream.write('\n')
|
||||
|
||||
function readText(tmpfile, charWhitelist) {
|
||||
function readText(tmpFile: FileResult, charWhitelist: string = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let command = `tesseract ${tmpfile.name} -`
|
||||
let command = `tesseract ${tmpFile.name} -`
|
||||
if (charWhitelist) {
|
||||
command += ` -c tessedit_char_whitelist="${charWhitelist}"`
|
||||
}
|
||||
exec(command, ((error, stdout, stderr) => {
|
||||
tmpfile.removeCallback()
|
||||
tmpFile.removeCallback()
|
||||
if (error) {
|
||||
console.error(stderr)
|
||||
reject(error)
|
||||
@@ -49,9 +59,9 @@ function readText(tmpfile, charWhitelist) {
|
||||
})
|
||||
}
|
||||
|
||||
function thresholdImage(image, threshold) {
|
||||
function thresholdImage(image, threshold): Promise<FileResult> {
|
||||
return new Promise(((resolve, reject) => {
|
||||
const tmpFile = tmp.fileSync()
|
||||
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)
|
||||
@@ -65,9 +75,9 @@ function thresholdImage(image, threshold) {
|
||||
|
||||
}
|
||||
|
||||
function getRegion(frame, x, y, width, height) { // Returns Promise<Buffer>
|
||||
function getRegion(frame, x, y, width, height): Promise<FileResult> { // Returns Promise<Buffer>
|
||||
return new Promise(function (resolve, reject) {
|
||||
const tmpFile = tmp.fileSync()
|
||||
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)
|
||||
@@ -80,9 +90,9 @@ function getRegion(frame, x, y, width, height) { // Returns Promise<Buffer>
|
||||
});
|
||||
}
|
||||
|
||||
function getFrame(url) {
|
||||
function getFrame(url): Promise<FileResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const frameFile = tmp.fileSync()
|
||||
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)
|
||||
@@ -245,13 +255,20 @@ function tallyVotes(votes) {
|
||||
}
|
||||
}
|
||||
|
||||
function timeUntilNextTrack(position, duration) {
|
||||
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})/
|
||||
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
|
||||
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) {
|
||||
@@ -283,6 +300,69 @@ function performQuorumProcessing(titleCroppedPromise, albumCroppedPromise, durat
|
||||
}))
|
||||
}
|
||||
|
||||
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
|
||||
@@ -328,11 +408,11 @@ async function updateTrackInfo() {
|
||||
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 = timeUntilNextTrack(result.position, currentTrack.duration)
|
||||
const secondsUntilNext = millisToNextTrack(result.position, currentTrack.duration)
|
||||
const processingTime = Date.now() - processingStart
|
||||
const timeInFrame = Date.now() - frameTime
|
||||
nextTrackTimestamp = frameTime + (secondsUntilNext * 1000)
|
||||
setTimeout(() => updateTrackInfo(), (secondsUntilNext * 1000) - timeInFrame + 750)
|
||||
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()}`)
|
||||
@@ -343,6 +423,108 @@ async function updateTrackInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
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 => {
|
||||
@@ -359,4 +541,4 @@ async function updateTrackInfo() {
|
||||
// })
|
||||
// .catch(err => console.error(err))
|
||||
|
||||
updateTrackInfo()
|
||||
updateTrackInfo_v2()
|
||||
1365
package-lock.json
generated
1365
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"name": "radioscraper",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
@@ -15,5 +15,11 @@
|
||||
"got": "^11.8.1",
|
||||
"tmp": "^0.2.1",
|
||||
"youtube-dl-exec": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.0",
|
||||
"eslint": "^7.21.0",
|
||||
"mocha": "^8.3.1",
|
||||
"typescript": "^4.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import Fuse from 'fuse.js';
|
||||
import FuseResult = Fuse.FuseResult;
|
||||
|
||||
const Fuse = require('fuse.js')
|
||||
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 playlist: Track[] = require('./playlist.json')
|
||||
|
||||
const albumValidCharacters = new Set()
|
||||
const trackValidCharacters = new Set()
|
||||
@@ -41,15 +44,15 @@ const fuseByTrack = new Fuse(playlist, {
|
||||
keys: ['track']
|
||||
})
|
||||
|
||||
function searchAlbums(query) {
|
||||
function searchAlbums(query): FuseResult<Track>[] {
|
||||
return fuseByAlbum.search(query)
|
||||
}
|
||||
|
||||
function searchTracks(query) {
|
||||
function searchTracks(query): FuseResult<Track>[] {
|
||||
return fuseByTrack.search(query)
|
||||
}
|
||||
|
||||
function fullSearch(album, track, duration) {
|
||||
function fullSearch(album, track, duration): FuseResult<Track> {
|
||||
const inAlbumSearch = new Fuse(searchAlbums(album), {keys: ['item.track']})
|
||||
const trackResults = inAlbumSearch.search(track).map(result => result.item)
|
||||
if (duration) {
|
||||
@@ -60,17 +63,17 @@ function fullSearch(album, track, duration) {
|
||||
}
|
||||
}
|
||||
|
||||
function albumDurationSearch(album, duration) {
|
||||
function albumDurationSearch(album, duration): FuseResult<Track> {
|
||||
const albumResults = searchAlbums(album)
|
||||
return filterResultsByDuration(albumResults, duration, true)
|
||||
}
|
||||
|
||||
function trackDurationSearch(track, duration) {
|
||||
function trackDurationSearch(track, duration): FuseResult<Track> {
|
||||
const trackResults = searchTracks(track)
|
||||
return filterResultsByDuration(trackResults, duration)
|
||||
}
|
||||
|
||||
function filterResultsByDuration(results, duration, failSearch = false) {
|
||||
function filterResultsByDuration(results, duration, failSearch = false): FuseResult<Track> {
|
||||
const durationResults = results.filter(result => result.item.duration === duration)
|
||||
if (durationResults.length > 0) {
|
||||
return durationResults[0]
|
||||
@@ -79,7 +82,7 @@ function filterResultsByDuration(results, duration, failSearch = false) {
|
||||
}
|
||||
}
|
||||
|
||||
function search(albumQuery, trackQuery, duration) {
|
||||
export function search(albumQuery, trackQuery, duration): FuseResult<Track> {
|
||||
if (albumQuery && trackQuery) {
|
||||
return fullSearch(albumQuery, trackQuery, duration)
|
||||
} else if (albumQuery && duration) {
|
||||
@@ -93,16 +96,27 @@ function search(albumQuery, trackQuery, duration) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getAt(index): Track {
|
||||
return playlist[index]
|
||||
}
|
||||
|
||||
function trySearch() {
|
||||
console.log(JSON.stringify(search("Final Fantasy VI", "Dancing Mad", "\f")))
|
||||
}
|
||||
|
||||
class Track {
|
||||
public album: string
|
||||
public track: string
|
||||
public duration: string
|
||||
}
|
||||
|
||||
// These shouldn't be called asynchronously, manually call it ourselves
|
||||
buildTrackValidCharacters()
|
||||
buildAlbumValidCharacters()
|
||||
|
||||
module.exports = {
|
||||
search,
|
||||
getAt,
|
||||
getValidAlbumCharacters,
|
||||
getValidTrackCharacters
|
||||
}
|
||||
7
poll-master.spec.ts
Normal file
7
poll-master.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
const {describe} = require("mocha");
|
||||
const poll = require('poll-master')
|
||||
describe('Poll master', () => {
|
||||
it('can create poll', () => {
|
||||
poll.registerNewPoll()
|
||||
})
|
||||
})
|
||||
89
poll-master.ts
Normal file
89
poll-master.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
const polls: Map<string, Poll> = new Map<string, Poll>()
|
||||
|
||||
export function registerNewPoll(key, maxVotes, onComplete, onNoResult): void {
|
||||
polls.set(key, new Poll(maxVotes, onComplete, onNoResult))
|
||||
console.log("created poll", key)
|
||||
}
|
||||
|
||||
export function getNewPoll<T>(maxVotes: number, onComplete: (result: T, votes: Map<string, T>) => void, onNoResult: (votes: Map<string, T>) => void) {
|
||||
return new Poll<T>(maxVotes, onComplete, onNoResult)
|
||||
}
|
||||
|
||||
export function voteInPoll(key, voterId, vote): void {
|
||||
if (polls.has(key)) {
|
||||
const poll = polls.get(key)
|
||||
poll.vote(`${voterId}`, vote)
|
||||
const result = poll.isMajorityReached()
|
||||
if (result) {
|
||||
poll.onComplete(result, poll.votes)
|
||||
delete polls[key]
|
||||
console.log("vote finished", key, result)
|
||||
} else if (poll.currentVotes === poll.maxVotes) {
|
||||
poll.onNoResult(poll.votes)
|
||||
delete polls[key]
|
||||
console.log("vote inconclusive", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isMajorityReached_dep(poll): any {
|
||||
const minimumConsensus = Math.ceil(poll.maxVotes * 0.5)
|
||||
const voteTally = {}
|
||||
for (const vote in poll.votes) {
|
||||
if (vote != null) {
|
||||
if (voteTally.hasOwnProperty(poll.votes[vote])) {
|
||||
voteTally[poll.votes[vote]]++
|
||||
} else {
|
||||
voteTally[poll.votes[vote]] = 1
|
||||
}
|
||||
}
|
||||
if (voteTally[poll.votes[vote]] >= minimumConsensus) {
|
||||
return vote
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
class Poll<T> {
|
||||
public currentVotes = 0;
|
||||
public votes = new Map<string, T>()
|
||||
constructor(public readonly maxVotes: number, public onComplete: (result: any, votes: Map<string, T>) => void, public onNoResult: (votes: Map<string, T>) => void) {
|
||||
|
||||
}
|
||||
|
||||
vote(voterId: string, vote: T): void {
|
||||
this.currentVotes++
|
||||
this.votes.set(voterId, vote)
|
||||
this.checkVotes()
|
||||
}
|
||||
|
||||
skipVote(voterId: string): void {
|
||||
this.currentVotes++
|
||||
this.checkVotes()
|
||||
}
|
||||
|
||||
private checkVotes(): void {
|
||||
const minimumConsensus = Math.ceil(this.maxVotes * 0.5)
|
||||
const voteTally = new Map<T, number>()
|
||||
this.votes.forEach((vote, voter) => {
|
||||
// Allow for null votes, can't have a null key in a map
|
||||
const normalizedVote = vote == null ? new T() : vote
|
||||
if (voteTally.has(normalizedVote)) {
|
||||
voteTally.set(normalizedVote, voteTally.get(normalizedVote) + 1)
|
||||
} else {
|
||||
voteTally.set(normalizedVote, 1)
|
||||
}
|
||||
if (voteTally.get(normalizedVote) >= minimumConsensus) {
|
||||
this.onComplete(vote, this.votes)
|
||||
}
|
||||
})
|
||||
if (this.currentVotes === this.maxVotes) {
|
||||
this.onNoResult(this.votes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerNewPoll,
|
||||
voteInPoll
|
||||
}
|
||||
Reference in New Issue
Block a user