263 lines
7.4 KiB
TypeScript
263 lines
7.4 KiB
TypeScript
|
|
import { getVideo } from '@/utils/metadata'
|
||
|
|
import { db } from '@/utils/database'
|
||
|
|
import * as fs from 'node:fs'
|
||
|
|
|
||
|
|
type FileEntry = {
|
||
|
|
id: string
|
||
|
|
size: number
|
||
|
|
line: string
|
||
|
|
}
|
||
|
|
|
||
|
|
type VideoStatus = {
|
||
|
|
id: string
|
||
|
|
size: number
|
||
|
|
title: string | null
|
||
|
|
stillUp: boolean
|
||
|
|
}
|
||
|
|
|
||
|
|
const INPUT_PATH = 'ratios3.txt'
|
||
|
|
const OUTPUT_PATH = 'colds3.txt'
|
||
|
|
const CONCURRENCY = 2
|
||
|
|
const checkOnlyChannelId = false
|
||
|
|
|
||
|
|
function parseOutput(text: string): FileEntry[] {
|
||
|
|
const entries: FileEntry[] = []
|
||
|
|
|
||
|
|
for (const line of text.split('\n')) {
|
||
|
|
const trimmed = line.trim()
|
||
|
|
if (!trimmed) continue
|
||
|
|
|
||
|
|
const ratioEntry = parseRatioEntry(trimmed, line)
|
||
|
|
if (ratioEntry) {
|
||
|
|
entries.push(ratioEntry)
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
const lsEntry = parseLsEntry(trimmed, line)
|
||
|
|
if (lsEntry) {
|
||
|
|
entries.push(lsEntry)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return entries
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseLsEntry(trimmed: string, line: string): FileEntry | null {
|
||
|
|
const match = trimmed.match(
|
||
|
|
/^[^\s]+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+[A-Za-z]{3}\s+\d{1,2}\s+(?:\d{4}|\d{2}:\d{2})\s+(.+)$/
|
||
|
|
)
|
||
|
|
if (!match) return null
|
||
|
|
|
||
|
|
const [, sizeText, filename] = match
|
||
|
|
const size = Number(sizeText)
|
||
|
|
if (!Number.isFinite(size)) return null
|
||
|
|
|
||
|
|
const idMatch = filename.match(/^([^.]+)\.\w+$/)
|
||
|
|
const id = idMatch?.[1]
|
||
|
|
if (!id) return null
|
||
|
|
|
||
|
|
return { id, size, line }
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseRatioEntry(trimmed: string, line: string): FileEntry | null {
|
||
|
|
const columns = trimmed.split('\t')
|
||
|
|
if (columns.length < 7) return null
|
||
|
|
|
||
|
|
const [rankText, , , sizeText, , , filename] = columns
|
||
|
|
if (!/^\d+$/.test(rankText)) return null
|
||
|
|
|
||
|
|
const size = Number(sizeText)
|
||
|
|
if (!Number.isFinite(size)) return null
|
||
|
|
|
||
|
|
const idMatch = filename.match(/^([^.]+)\.\w+$/)
|
||
|
|
const id = idMatch?.[1]
|
||
|
|
if (!id) return null
|
||
|
|
|
||
|
|
return { id, size, line }
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatBytes(bytes: number): string {
|
||
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||
|
|
let value = bytes
|
||
|
|
let unitIndex = 0
|
||
|
|
|
||
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
||
|
|
value /= 1024
|
||
|
|
unitIndex += 1
|
||
|
|
}
|
||
|
|
|
||
|
|
return `${value.toFixed(value >= 100 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
|
||
|
|
}
|
||
|
|
|
||
|
|
async function mapConcurrent<T, R>(
|
||
|
|
items: T[],
|
||
|
|
concurrency: number,
|
||
|
|
mapper: (item: T, index: number) => Promise<R>,
|
||
|
|
): Promise<R[]> {
|
||
|
|
const results = new Array<R>(items.length)
|
||
|
|
let nextIndex = 0
|
||
|
|
|
||
|
|
async function worker() {
|
||
|
|
while (nextIndex < items.length) {
|
||
|
|
const currentIndex = nextIndex
|
||
|
|
nextIndex += 1
|
||
|
|
results[currentIndex] = await mapper(items[currentIndex], currentIndex)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await Promise.all(
|
||
|
|
Array.from({ length: Math.min(concurrency, items.length) }, () => worker()),
|
||
|
|
)
|
||
|
|
|
||
|
|
return results
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getVideoWithRetry(id: string, retries = 3, delay = 1000): Promise<any> {
|
||
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||
|
|
try {
|
||
|
|
const json = await getVideo(id)
|
||
|
|
if (json && !json.error) {
|
||
|
|
return json
|
||
|
|
}
|
||
|
|
console.warn(`[Retry ${attempt}/${retries}] getVideo(${id}) returned empty or error:`, json)
|
||
|
|
} catch (e: any) {
|
||
|
|
console.error(`[Retry ${attempt}/${retries}] getVideo(${id}) threw:`, e)
|
||
|
|
}
|
||
|
|
if (attempt < retries) {
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, delay * attempt))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null
|
||
|
|
}
|
||
|
|
|
||
|
|
async function getPageViewsWithRetry(id: string, retries = 3, delay = 1000): Promise<number> {
|
||
|
|
for (let attempt = 1; attempt <= retries; attempt++) {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`https://a.gloe.net/api/stats/preservetube.com/top-stats?period=all&date=2026-04-29&filters=%7B%22page%22%3A%22%2Fwatch%2F${id}%22%7D&with_imported=true&comparison=previous_period&compare_from=undefined&compare_to=undefined&match_day_of_week=true`, {
|
||
|
|
headers: {
|
||
|
|
'cookie': 'logged_in=true; _plausible_key=SFMyNTY.g3QAAAAFbQAAAAtfY3NyZl90b2tlbm0AAAAYN0drdE52TGVzejBCSHVBdmlTdWQzTXdTbQAAAA9jdXJyZW50X3VzZXJfaWRhAW0AAAAJbGFzdF9zZWVuYmnxNDttAAAACmxvZ2luX2Rlc3RkAANuaWxtAAAAEnNlc3Npb25fdGltZW91dF9hdGJqA6pE.q1ENNi4aJLNquuSIo_1cca054Tj9lWPmalOAnMNuK8A'
|
||
|
|
}
|
||
|
|
})
|
||
|
|
if (response.ok) {
|
||
|
|
const aJson = await response.json() as any
|
||
|
|
const topStats = aJson?.['top_stats']
|
||
|
|
if (Array.isArray(topStats)) {
|
||
|
|
const pageViewsObj = topStats.find((s: any) => s.name == 'Total pageviews')
|
||
|
|
if (pageViewsObj && typeof pageViewsObj.value === 'number') {
|
||
|
|
return pageViewsObj.value
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
console.warn(`[Retry ${attempt}/${retries}] Failed to fetch page views for ${id}`)
|
||
|
|
} catch (e: any) {
|
||
|
|
console.error(`[Retry ${attempt}/${retries}] Fetch page views for ${id} threw:`, e)
|
||
|
|
}
|
||
|
|
if (attempt < retries) {
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, delay * attempt))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
const text = await Bun.file(INPUT_PATH).text()
|
||
|
|
const files = parseOutput(text)
|
||
|
|
|
||
|
|
if (files.length === 0) {
|
||
|
|
throw new Error(`No video entries could be parsed from ${INPUT_PATH}`)
|
||
|
|
}
|
||
|
|
|
||
|
|
await Bun.write(OUTPUT_PATH, '')
|
||
|
|
|
||
|
|
const statuses = (await mapConcurrent(files, CONCURRENCY, async (file) => {
|
||
|
|
const isAlreadyCold = await db.selectFrom('files')
|
||
|
|
.where('videoId', '=', file.id)
|
||
|
|
.selectAll()
|
||
|
|
.executeTakeFirst()
|
||
|
|
|
||
|
|
if (isAlreadyCold) return {
|
||
|
|
id: file.id,
|
||
|
|
size: file.size,
|
||
|
|
title: 'already cold',
|
||
|
|
stillUp: false,
|
||
|
|
}
|
||
|
|
|
||
|
|
const archivedMetadata = await db.selectFrom('videos')
|
||
|
|
.where('id', '=', file.id)
|
||
|
|
.selectAll()
|
||
|
|
.executeTakeFirst()
|
||
|
|
|
||
|
|
if (!archivedMetadata || archivedMetadata.deletion_stage) {
|
||
|
|
console.log(`${file.id} on disk, but not in database`)
|
||
|
|
fs.appendFileSync('missing.txt', `${file.line}\n`)
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
const channelList = ['UCMnULQ6F6kLDAHxofDWIbrw', 'UCWEoAwbfSQtgB-_OUN-g7gg']
|
||
|
|
if (archivedMetadata?.channelId && channelList.includes(archivedMetadata.channelId)) {
|
||
|
|
console.log(file.id, archivedMetadata?.channelId, 'added based on channel id')
|
||
|
|
fs.appendFileSync(OUTPUT_PATH, `${file.line}\n`)
|
||
|
|
return {
|
||
|
|
id: file.id,
|
||
|
|
size: file.size,
|
||
|
|
title: archivedMetadata.title,
|
||
|
|
stillUp: true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (checkOnlyChannelId) return {
|
||
|
|
id: file.id,
|
||
|
|
size: file.size,
|
||
|
|
title: archivedMetadata.title,
|
||
|
|
stillUp: true
|
||
|
|
}
|
||
|
|
|
||
|
|
const json = await getVideoWithRetry(file.id) as { videoDetails?: { title?: string } } | null
|
||
|
|
console.log(json)
|
||
|
|
const title = json?.videoDetails?.title ?? null
|
||
|
|
const stillUp = Boolean(title)
|
||
|
|
|
||
|
|
const pageViews = await getPageViewsWithRetry(file.id)
|
||
|
|
|
||
|
|
console.log(file.id, archivedMetadata?.channelId, archivedMetadata?.title, stillUp, pageViews)
|
||
|
|
|
||
|
|
if ((stillUp && pageViews < 5) || (!stillUp && pageViews < 2)) {
|
||
|
|
fs.appendFileSync(OUTPUT_PATH, `${file.line}\n`)
|
||
|
|
}
|
||
|
|
|
||
|
|
const status: VideoStatus = {
|
||
|
|
id: file.id,
|
||
|
|
size: file.size,
|
||
|
|
title,
|
||
|
|
stillUp,
|
||
|
|
}
|
||
|
|
|
||
|
|
return status
|
||
|
|
})).filter(s => s != false)
|
||
|
|
|
||
|
|
const stillUpCount = statuses.filter((status) => status.stillUp).length
|
||
|
|
const gone = statuses
|
||
|
|
.filter((status) => !status.stillUp)
|
||
|
|
.sort((a, b) => b.size - a.size)
|
||
|
|
|
||
|
|
console.log(`Parsed ${files.length} files from ${INPUT_PATH}`)
|
||
|
|
console.log(`Checked ${files.length} videos`)
|
||
|
|
console.log(`Still up: ${stillUpCount}`)
|
||
|
|
console.log(`Gone: ${gone.length}`)
|
||
|
|
console.log(`Wrote ${OUTPUT_PATH}`)
|
||
|
|
|
||
|
|
if (gone.length === 0) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('')
|
||
|
|
console.log('Gone videos:')
|
||
|
|
console.log('')
|
||
|
|
|
||
|
|
for (const video of gone) {
|
||
|
|
console.log(`${formatBytes(video.size)}\t${video.id}`)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
await main()
|