1
0
Fork 0
mirror of https://git.sr.ht/~cadence/cloudtube synced 2026-05-31 22:46:46 +00:00
cloudtube/background/thumbnail-scan.js
2026-05-27 22:52:13 +12:00

201 lines
5.5 KiB
JavaScript

const crypto = require("crypto")
const sharp = require("sharp")
const Denque = require("denque")
const constants = require("../utils/constants")
const db = require("../utils/db")
const prepared = {
set_short: db.prepare(
"UPDATE Videos SET short = ? WHERE videoId = ?"
),
delete_video: db.prepare(
"DELETE FROM Videos WHERE videoId = ?"
),
}
class ScannerQueue {
constructor() {
this.queue = new Denque()
this.lastLoadTime = 0
}
isEmpty() {
return this.queue.isEmpty()
}
load() {
// get the next set of scheduled channels to refresh
const afterTime = (Date.now() - constants.caching.video_thumbnail_scan_eligible) / 1000
/** @type {string[]} video IDs to check */
const videos = db.prepare("SELECT Videos.videoId FROM Videos WHERE Videos.published > ? AND Videos.short IS NULL ORDER BY Videos.published DESC").pluck().all(afterTime)
// console.log(`loaded ${videos.length} videos for thumbnail scanning`)
this.addLast(videos)
this.lastLoadTime = Date.now()
}
addNext(items) {
for (const i of items) {
this.queue.unshift(i)
}
}
addLast(items) {
for (const i of items) {
this.queue.push(i)
}
}
next() {
if (this.isEmpty()) {
throw new Error("Cannot get next of empty scanner queue")
}
const item = this.queue.shift()
return item
}
}
const DARKNESS_MAX = 0x4C
const DARKNESS_TOLERANCE_G = 0x05
const DARKNESS_TOLERANCE_RB = 0x0A
const PEAKS_TOLERANCE = 0.01
/**
* @param {Buffer<ArrayBufferLike>} data
* @param {sharp.OutputInfo} info
* @param {number} col
*/
function columnIsLight(data, info, col) {
// Pixel ordering is left-to-right, top-to-bottom, without padding. Channel ordering is RGB.
// console.log(`Scanning column ${col}`)
const peaksCountTolerance = Math.ceil(info.height * PEAKS_TOLERANCE)
let peaksSeen = 0
for (let row = 0; row < info.height; row++) {
const x = info.channels * col
const y = row * info.channels * info.width
const xy = x + y
const lightness = Math.max(data.at(xy + 0) - DARKNESS_TOLERANCE_RB, data.at(xy + 1) - DARKNESS_TOLERANCE_G, data.at(xy + 2) - DARKNESS_TOLERANCE_RB)
if (lightness > DARKNESS_MAX) {
peaksSeen++
// console.log(`col ${col} row ${row} lightness = 0x${lightness.toString(16)}`)
if (peaksSeen > peaksCountTolerance) return true
}
}
return false
}
async function scanThumbnail(videoId) {
const files = ["sddefault", "hqdefault", "mqdefault"]
let usableThumbnail
for (const file of files) {
const thumbnail = await fetch(`https://i.ytimg.com/vi/${videoId}/${file}.jpg`).then(res => res.bytes())
// console.log(`scanning ${videoId}`)
// Check if it's the deleted thumbnail
// First check length
if (thumbnail.byteLength === 1097) {
// Then check hash
const h = crypto.createHash("sha256")
h.update(thumbnail)
if (h.digest("hex") === "20e9aab22032d85684d7d916a1013f7c577a132a5b10ea3fd3578e8d0b28a711") {
// It is deleted
continue
}
}
usableThumbnail = thumbnail
}
if (!usableThumbnail) return "deleted"
// Check if it looks like a dimmed 9:16 shorts thumbnail
const {data, info} = await sharp(usableThumbnail).removeAlpha().raw().toBuffer({resolveWithObject: true})
// console.log(data)
// console.log(info)
const SHORTS_ASPECT_RATIO = 9/16
const innerWidth = info.height * SHORTS_ASPECT_RATIO
const innerStartX = info.width / 2 - innerWidth / 2
const innerEndX = info.width / 2 + innerWidth / 2
// console.log(innerStartX, innerEndX)
const pattern = [
columnIsLight(data, info, Math.floor(innerStartX) - 3),
columnIsLight(data, info, Math.ceil(innerStartX) + 3),
columnIsLight(data, info, Math.floor(innerEndX) - 3),
columnIsLight(data, info, Math.ceil(innerEndX) + 3),
].map(Number).join("")
return pattern
}
const IS_NOT_SHORT = 0
const INCONCLUSIVE = 1
const IS_SHORT = 2
class Scanner {
constructor() {
this.sym = constants.symbols.refresher
this.scanQueue = new ScannerQueue()
this.state = this.sym.ACTIVE
this.waitingTimeout = null
this.next()
}
async scanThumbnail(videoId) {
const scanResult = await scanThumbnail(videoId)
if (scanResult === "deleted") {
prepared.delete_video.run(videoId)
} else if (scanResult === "0110") {
prepared.set_short.run(IS_SHORT, videoId)
// console.log("is short")
} else if (scanResult.match(/^1..1$/)) {
prepared.set_short.run(IS_NOT_SHORT, videoId)
// console.log("is not short")
} else {
prepared.set_short.run(INCONCLUSIVE, videoId)
// console.log(`inconclusive: ${scanResult}`)
}
}
next() {
if (this.scanQueue.isEmpty()) {
const timeSinceLastLoop = Date.now() - this.scanQueue.lastLoadTime
if (timeSinceLastLoop < constants.caching.video_thumbnail_scan_loop_min) {
const timeToWait = constants.caching.video_thumbnail_scan_loop_min - timeSinceLastLoop
// console.log(`waiting ${timeToWait} before next loop`)
this.state = this.sym.WAITING
this.waitingTimeout = setTimeout(() => this.next(), timeToWait)
return
} else {
this.scanQueue.load()
}
}
if (!this.scanQueue.isEmpty()) {
this.state = this.sym.ACTIVE
const videoId = this.scanQueue.next()
this.scanThumbnail(videoId).then(() => this.next()).catch(error => {
console.error("Error in background thumbnail scan:\n", error)
setTimeout(() => {
this.next()
}, 10e3)
})
} else {
this.state = this.sym.EMPTY
}
}
skipWaiting() {
if (this.state !== this.sym.ACTIVE) {
clearTimeout(this.waitingTimeout)
this.scanQueue.lastLoadTime = 0
this.next()
}
}
}
const scanner = new Scanner()
module.exports.scanner = scanner