├── pics ├── filter.png └── stats.png ├── public ├── favicon.ico ├── fonts │ ├── icomoon.ttf │ ├── icomoon.woff │ └── icomoon.svg ├── opensearch.xml ├── icons.css ├── graphUtils.js ├── video.js ├── style.css ├── graph.js ├── util.js ├── index.html ├── main.js └── bootstrap-purged.min.css ├── .parcelrc ├── .gitignore ├── .eslintrc.json ├── src ├── logger.js ├── gapi.js ├── utils.js ├── database.js └── video.js ├── LICENSE ├── README.md ├── package.json ├── index.js └── CHANGELOG.md /pics/filter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerdash2/comment-viewer/HEAD/pics/filter.png -------------------------------------------------------------------------------- /pics/stats.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerdash2/comment-viewer/HEAD/pics/stats.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerdash2/comment-viewer/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/icomoon.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerdash2/comment-viewer/HEAD/public/fonts/icomoon.ttf -------------------------------------------------------------------------------- /public/fonts/icomoon.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sameerdash2/comment-viewer/HEAD/public/fonts/icomoon.woff -------------------------------------------------------------------------------- /.parcelrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@parcel/config-default"], 3 | "reporters": ["...", "parcel-reporter-static-files-copy"] 4 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | .parcel-cache/ 4 | config.json 5 | *.sqlite 6 | *.sqlite-journal 7 | *.sqlite-shm 8 | *.sqlite-wal 9 | *.log 10 | debug.txt -------------------------------------------------------------------------------- /public/opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | YouTube Comment Viewer 3 | Search a video in YouTube Comment Viewer 4 | UTF-8 5 | https://commentviewer.com/favicon.ico 6 | 7 | 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parser": "@babel/eslint-parser", 9 | "parserOptions": { 10 | "ecmaVersion": 11, 11 | "requireConfigFile": false, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-fallthrough": "off", 16 | "prefer-const": "warn", 17 | "no-unused-vars": "warn" 18 | }, 19 | "globals": { 20 | "gtag": "readonly" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | require('winston-daily-rotate-file'); 3 | const { printTimestamp } = require('./utils'); 4 | 5 | const logger = winston.createLogger({ 6 | transports: [ 7 | new winston.transports.Console(), 8 | new winston.transports.DailyRotateFile({ 9 | filename: 'logs/cv-%DATE%.log', 10 | datePattern: 'YYYY-MM' 11 | }) 12 | ], 13 | format: winston.format.combine( 14 | winston.format.splat(), 15 | winston.format.simple(), 16 | winston.format.timestamp({ 17 | format: () => printTimestamp(new Date()) 18 | }), 19 | winston.format.printf((info) => `[${info.timestamp}] (${info.level}) ${info.message}`) 20 | ) 21 | }); 22 | 23 | module.exports = logger; 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Sameer Dash 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## YouTube Comment Viewer 💬 2 | A web app for sorting and filtering comments on YouTube videos. Enter any video link and the comments will start loading in the background. 3 | 4 | Tech stack: JavaScript, SQLite, and a lot of CSS. 5 | 6 | ### Website 7 | #### https://commentviewer.com 8 | 9 | ### Screenshots 10 | ![Filtering by date](pics/filter.png "Filtering by date") 11 | ![Graphing comments](pics/stats.png "Graphing comments") 12 | 13 | ### Features 14 | - Load large numbers of comments (> 1 million) 15 | - Sort comments by date (oldest to newest) or by likes 16 | - Filter comments by date 17 | - View exact publish timestamps for comments and videos 18 | - See comment trends on an interactive graph 19 | - Input direct links to a comment 20 | - Works well on mobile 21 | 22 | ### Usage / APIs 23 | 24 | You can open the website directly to a video by supplying the video ID as a URL parameter `v`. Example: https://commentviewer.com/?v=4VaqA-5aQTM. The `v=` value can also be a full YouTube URL itself, or the URL to a linked comment/reply. 25 | 26 | ### Changelog 27 | For new changes, check out the [changelog](CHANGELOG.md). 28 | 29 | ### Acknowledgements 30 | 31 | * Thanks to [Ilethas](https://github.com/Ilethas) for contributing a dark mode stylesheet. 32 | -------------------------------------------------------------------------------- /public/icons.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'icomoon'; 3 | src: 4 | url('fonts/icomoon.ttf?41qpll') format('truetype'), 5 | url('fonts/icomoon.woff?41qpll') format('woff'), 6 | url('fonts/icomoon.svg?41qpll#icomoon') format('svg'); 7 | font-weight: normal; 8 | font-style: normal; 9 | font-display: block; 10 | } 11 | 12 | [class^="icon-"], [class*=" icon-"] { 13 | /* use !important to prevent issues with browser extensions that change fonts */ 14 | font-family: 'icomoon' !important; 15 | speak: never; 16 | font-style: normal; 17 | font-weight: normal; 18 | font-variant: normal; 19 | text-transform: none; 20 | line-height: 1; 21 | 22 | /* Better Font Rendering =========== */ 23 | -webkit-font-smoothing: antialiased; 24 | -moz-osx-font-smoothing: grayscale; 25 | } 26 | 27 | .icon-external-link:before { 28 | content: "\e908"; 29 | } 30 | .icon-pencil:before { 31 | content: "\e900"; 32 | } 33 | .icon-thumbs-up:before { 34 | content: "\e905"; 35 | } 36 | .icon-clock:before { 37 | content: "\e901"; 38 | } 39 | .icon-calendar:before { 40 | content: "\e902"; 41 | } 42 | .icon-eye:before { 43 | content: "\e903"; 44 | } 45 | .icon-thumbs-down:before { 46 | content: "\e904"; 47 | } 48 | .icon-comment:before { 49 | content: "\e906"; 50 | } 51 | .icon-search:before { 52 | content: "\e907"; 53 | } 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "commentviewer", 3 | "version": "3.3.4", 4 | "description": "View and analyze YouTube comments easily.", 5 | "author": "Sameer Dash", 6 | "license": "MIT", 7 | "engines": { 8 | "node": ">=14.x" 9 | }, 10 | "dependencies": { 11 | "better-sqlite3": "^11.10.0", 12 | "express": "^4.21.2", 13 | "googleapis": "^148.0.0", 14 | "socket.io": "^4.8.1", 15 | "socket.io-client": "^4.8.1", 16 | "uplot": "^1.6.31", 17 | "winston": "^3.17.0", 18 | "winston-daily-rotate-file": "^5.0.0" 19 | }, 20 | "devDependencies": { 21 | "@babel/eslint-parser": "^7.25.7", 22 | "@parcel/packager-xml": "2.12.0", 23 | "@parcel/transformer-xml": "2.12.0", 24 | "eslint": "^8.38.0", 25 | "parcel": "2.12.0", 26 | "parcel-reporter-static-files-copy": "^1.5.3" 27 | }, 28 | "targets": { 29 | "default": { 30 | "distDir": "dist", 31 | "sourceMap": false, 32 | "engines": {} 33 | } 34 | }, 35 | "staticFiles": { 36 | "staticPath": "public/favicon.ico", 37 | "distDir": "dist" 38 | }, 39 | "scripts": { 40 | "start": "node index.js", 41 | "dev": "parcel watch public/*.html", 42 | "build": "parcel build public/*.html", 43 | "purge-bootstrap": "purgecss --css ../bootstrap.min.css --content public/* --output public/bootstrap-purged.min.css" 44 | }, 45 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" 46 | } 47 | -------------------------------------------------------------------------------- /src/gapi.js: -------------------------------------------------------------------------------- 1 | const {google} = require('googleapis'); 2 | const config = require('../config.json'); 3 | 4 | class YouTubeAPI { 5 | constructor() { 6 | this._youtube = google.youtube({ 7 | version: "v3", 8 | auth: config.gapiKey 9 | }); 10 | } 11 | 12 | executeVideo(videoId) { 13 | return this._youtube.videos.list({ 14 | "part": "snippet,statistics,liveStreamingDetails", 15 | "id": videoId 16 | }); 17 | } 18 | 19 | executeTestComment(videoId) { 20 | return this._youtube.commentThreads.list({ 21 | "part": "id", 22 | "videoId": videoId, 23 | "maxResults": 1 24 | }); 25 | } 26 | 27 | executeCommentChunk(videoId, nextPageToken) { 28 | return this._youtube.commentThreads.list({ 29 | "part": "snippet", 30 | "videoId": videoId, 31 | "order": "time", 32 | "maxResults": 100, 33 | "pageToken": nextPageToken 34 | }); 35 | } 36 | 37 | executeReplies(parentId, nextPageToken) { 38 | return this._youtube.comments.list({ 39 | "part": "snippet", 40 | "maxResults": 100, 41 | "parentId": parentId, 42 | "pageToken": nextPageToken 43 | }); 44 | } 45 | 46 | executeMinReplies(parentId) { 47 | // Usually returns the first 5 replies to a commentThread. 48 | // The normal comments.list returns replies in reverse order, not optimal 49 | // for getting the first few of 500 replies 50 | return this._youtube.commentThreads.list({ 51 | "part": "replies", 52 | "id": parentId, 53 | }); 54 | } 55 | 56 | executeSingleComment(commentId) { 57 | return this._youtube.commentThreads.list({ 58 | "part": "snippet", 59 | "id": commentId 60 | }); 61 | } 62 | executeSingleReply(commentId) { 63 | return this._youtube.comments.list({ 64 | "part": "snippet", 65 | "id": commentId, 66 | }); 67 | } 68 | 69 | quotaExceeded() { 70 | // nothing for now 71 | } 72 | 73 | } 74 | 75 | module.exports = YouTubeAPI; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // Convert comments received from API to objects that contain only necessary metadata 2 | function convertComment(object, isReply = false) { 3 | const comment = isReply ? object : object.snippet.topLevelComment; 4 | const replyCount = isReply ? 0 : object.snippet.totalReplyCount; 5 | // Channel ID is sometimes randomly left out 6 | const channelId = comment.snippet.authorChannelId ? comment.snippet.authorChannelId.value : ""; 7 | 8 | return ({ 9 | id: comment.id, 10 | textDisplay: comment.snippet.textDisplay, 11 | authorDisplayName: comment.snippet.authorDisplayName, 12 | authorProfileImageUrl: comment.snippet.authorProfileImageUrl, 13 | authorChannelId: channelId, 14 | likeCount: comment.snippet.likeCount, 15 | publishedAt: new Date(comment.snippet.publishedAt).getTime(), 16 | updatedAt: new Date(comment.snippet.updatedAt).getTime(), 17 | totalReplyCount: replyCount 18 | }); 19 | } 20 | 21 | // Returns a timestamp in the format `YYYY-MM-DD hh:mm:ss {timeZoneName}` in Pacific time. 22 | function printTimestamp(date) { 23 | const datePart = date.toLocaleDateString("fr-ca", { 24 | timeZone: "America/Los_Angeles", 25 | }); 26 | const timePart = date.toLocaleTimeString("en-us", { 27 | timeZone: "America/Los_Angeles", 28 | hourCycle: "h23", 29 | timeZoneName: "short" 30 | }); 31 | return `${datePart} ${timePart}`; 32 | } 33 | 34 | // Returns a Unix timestamp of the next occurence of a given day of the week (at a given hour) 35 | // 0 = Sunday, 6 = Saturday 36 | function getNextUTCTimestamp(dayOfWeek, hour) { 37 | const nextOccurence = new Date(); 38 | const diff = dayOfWeek - nextOccurence.getUTCDay(); 39 | nextOccurence.setUTCDate(nextOccurence.getUTCDate() + diff); 40 | nextOccurence.setUTCHours(hour, 0, 0, 0); 41 | 42 | // Include 1 second of buffer time to ensure the timestamp is in the future 43 | if (nextOccurence <= (Date.now() + 1000)) { 44 | nextOccurence.setUTCDate(nextOccurence.getUTCDate() + 7); 45 | } 46 | 47 | return nextOccurence.getTime(); 48 | } 49 | 50 | // Returns a Unix timestamp of the most recent Pacific midnight. 51 | function getLastPacificMidnight() { 52 | // Get current hour of the day (0-23) in Pacific time. 53 | const pacificHour = Number(new Date().toLocaleTimeString('en-US', { 54 | timeZone: 'America/Los_Angeles', 55 | hourCycle: 'h23', 56 | hour: '2-digit' 57 | })); 58 | // The hour diff will be off-by-one if used after 2 AM on the day of a DST switch. 59 | // However, I'd rather accept this drawback than import a whole library for this 60 | const midnight = new Date(); 61 | midnight.setUTCHours(midnight.getUTCHours() - pacificHour, 0, 0, 0); 62 | return midnight.getTime(); 63 | } 64 | 65 | module.exports = { 66 | convertComment, 67 | printTimestamp, 68 | getNextUTCTimestamp, 69 | getLastPacificMidnight 70 | } 71 | -------------------------------------------------------------------------------- /public/graphUtils.js: -------------------------------------------------------------------------------- 1 | export function tooltipPlugin(isUtc) { 2 | function init(u) { 3 | const over = u.root.querySelector(".u-over"); 4 | 5 | const tooltip = u.cursortt = document.createElement("div"); 6 | tooltip.id = "tooltip"; 7 | tooltip.style.display = "none"; 8 | over.appendChild(tooltip); 9 | 10 | over.addEventListener("mouseleave", () => { 11 | tooltip.style.display = "none"; 12 | }); 13 | 14 | over.addEventListener("mouseenter", () => { 15 | tooltip.style.display = null; 16 | }); 17 | } 18 | 19 | function setCursor(u) { 20 | const { left, top, idx } = u.cursor; 21 | if (idx === null || !u.data[0][idx]) return; 22 | 23 | // Update text 24 | const xVal = u.data[0][idx]; 25 | const yVal = u.data[1][idx]; 26 | u.cursortt.innerHTML = `${makeLabel(xVal, u.cvInterval, isUtc)}
Comments: ${yVal.toLocaleString()}`; 27 | 28 | // Update positioning 29 | let xPos = left + 10; 30 | const yPos = top + 10; 31 | 32 | const tooltipWidth = u.cursortt.offsetWidth; 33 | const graphOffset = u.root.querySelector(".u-over").getBoundingClientRect().left; 34 | 35 | if ((graphOffset + xPos + tooltipWidth * 1.2) > document.documentElement.clientWidth) { 36 | xPos -= (tooltipWidth + 20); 37 | } 38 | 39 | u.cursortt.style.left = xPos + "px"; 40 | u.cursortt.style.top = yPos + "px"; 41 | } 42 | 43 | return { 44 | hooks: { 45 | init, 46 | setCursor 47 | } 48 | }; 49 | } 50 | 51 | function makeLabel(rawValue, interval, isUtc) { 52 | let output = ""; 53 | const date = new Date(rawValue * 1000); 54 | switch (interval) { 55 | case "year": 56 | output = isUtc ? date.getUTCFullYear() : date.getFullYear(); 57 | break; 58 | case "month": 59 | output = isUtc ? date.toISOString().substring(0, 7) 60 | : date.toLocaleString(undefined, { month: "short", year: "numeric" }) 61 | break; 62 | case "day": 63 | output = isUtc ? date.toISOString().substring(0, 10) : date.toLocaleDateString(); 64 | break; 65 | case "hour": 66 | output = isUtc ? date.toISOString().replace("T", " ").substring(0, 16) 67 | : date.toLocaleDateString(undefined, { hour: "numeric", hour12: true }); 68 | break; 69 | } 70 | return output; 71 | } 72 | 73 | export function calcAxisSpace(interval, scaleMin, scaleMax, plotDim) { 74 | const rangeSecs = scaleMax - scaleMin; 75 | let space = 50; 76 | switch (interval) { 77 | case "year": { 78 | // ensure minimum x-axis gap is 360 days' worth of pixels 79 | const rangeDays = rangeSecs / 86400; 80 | const pxPerDay = plotDim / rangeDays; 81 | space = pxPerDay * 360; 82 | break; 83 | } 84 | case "month": { 85 | // 28 days 86 | const rangeDays = rangeSecs / 86400; 87 | const pxPerDay = plotDim / rangeDays; 88 | space = pxPerDay * 28; 89 | break; 90 | } 91 | case "day": { 92 | // 23 hours 93 | const rangeHours = rangeSecs / 3600; 94 | const pxPerHour = plotDim / rangeHours; 95 | space = pxPerHour * 23; 96 | break; 97 | } 98 | case "hour": { 99 | // 59 minutes 100 | const rangeMins = rangeSecs / 60; 101 | const pxPerMin = plotDim / rangeMins; 102 | space = pxPerMin * 59; 103 | break; 104 | } 105 | } 106 | // Use a minimum gap of 50 pixels 107 | return Math.max(50, space); 108 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const YouTubeAPI = require('./src/gapi'); 2 | const Video = require('./src/video'); 3 | const Database = require('./src/database'); 4 | const logger = require('./src/logger'); 5 | const express = require('express'); 6 | const application = express(); 7 | const http = require('http').createServer(application); 8 | const io = require('socket.io')(http); 9 | 10 | class App { 11 | constructor() { 12 | application.use(express.static('dist', { 13 | extensions: ['html'] 14 | })); 15 | this.ytapi = new YouTubeAPI(); 16 | this.createServer(); 17 | this.database = new Database(); 18 | this.listen(); 19 | } 20 | 21 | createServer() { 22 | io.on('connection', (socket) => { 23 | const videoInstance = new Video(this, io, socket); 24 | let throttled = false; 25 | const throttleMs = 500; 26 | let unThrottleTimestamp = 0; 27 | let queueTimeout = undefined; 28 | 29 | socket.on("idSent", (inputId) => { 30 | if (!checkSendID(inputId.substring(0, 255))) socket.emit("idInvalid"); 31 | }); 32 | socket.on("requestAll", () => { 33 | videoInstance.handleLoad("dateOldest"); 34 | }); 35 | 36 | socket.on("showMore", requestSendComments); 37 | function requestSendComments({ sort, commentNum, pageSize, minDate, maxDate }) { 38 | if (throttled) { 39 | clearTimeout(queueTimeout); 40 | queueTimeout = setTimeout(() => requestSendComments({ sort, commentNum, pageSize, minDate, maxDate }), 41 | unThrottleTimestamp - Date.now()); 42 | } 43 | else { 44 | throttled = true; 45 | 46 | pageSize = Number(pageSize); 47 | // Stop client from doing funny stuff 48 | if (isNaN(pageSize) || pageSize > 500) { 49 | pageSize = 25; 50 | } 51 | 52 | sendComments({ sort, commentNum, pageSize, minDate, maxDate }); 53 | setTimeout(() => throttled = false, throttleMs); 54 | unThrottleTimestamp = Date.now() + throttleMs + 20; 55 | } 56 | } 57 | function sendComments({ sort, commentNum, pageSize, minDate, maxDate }) { 58 | videoInstance.requestLoadedComments(sort, commentNum, pageSize, false, minDate, maxDate); 59 | } 60 | 61 | socket.on("replyRequest", (id) => { 62 | videoInstance.getReplies(id); 63 | }); 64 | socket.on("graphRequest", () => { 65 | videoInstance.requestStatistics(); 66 | }); 67 | 68 | function checkSendID(inp) { 69 | // Assuming video IDs are strings in base64 with 11 chars. 70 | // Match the 11 characters after one of {"v=", "youtu.be/", "shorts/", "live/"}. 71 | // If none of those are found, fall back to last 11 chars. 72 | const videoIdPattern = /(?:v=|youtu\.be\/|shorts\/|live\/)([\w-]{11})/; 73 | const match = videoIdPattern.exec(inp) ?? /([\w-]{11})$/.exec(inp); 74 | if (match === null) { 75 | return false; 76 | } 77 | const videoId = match[1]; 78 | 79 | // Check if user entered a linked comment. These can have periods 80 | const linkedIdPattern = /lc=([\w.-]+)/; 81 | const linkedMatch = linkedIdPattern.exec(inp); 82 | if (linkedMatch !== null) { 83 | const linkedId = linkedMatch[1]; 84 | // Check if this linked ID indicates a reply: look for the dot. 85 | // If so, pull out the parent ID separately 86 | const dotIndex = linkedId.indexOf("."); 87 | if (dotIndex !== -1) { 88 | // Linked a reply 89 | const linkedParentId = linkedId.substring(0, dotIndex); 90 | videoInstance.fetchLinkedComment(videoId, linkedParentId, linkedId); 91 | } else { 92 | // Linked a parent comment 93 | videoInstance.fetchLinkedComment(videoId, linkedId); 94 | } 95 | } else { 96 | // Fetch video info 97 | videoInstance.fetchTitle(videoId); 98 | } 99 | 100 | return true; 101 | } 102 | }); 103 | } 104 | 105 | listen() { 106 | const port = process.env.PORT || 8000; 107 | http.listen(port, () => { 108 | logger.log('info', 'Listening on %s', port); 109 | }); 110 | } 111 | } 112 | 113 | // eslint-disable-next-line no-unused-vars 114 | const app = new App(); 115 | -------------------------------------------------------------------------------- /public/fonts/icomoon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Generated by IcoMoon 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /public/video.js: -------------------------------------------------------------------------------- 1 | import { Graph } from "./graph.js"; 2 | import { formatTitle, formatComment, eta, parseDurationMSS } from './util.js'; 3 | 4 | export class Video { 5 | constructor(socket) { 6 | this._socket = socket; 7 | this._graph = new Graph(this, socket); 8 | this.reset(); 9 | } 10 | reset() { 11 | this._graph.reset(); 12 | this.commentNum = 0; 13 | this.currentSort = "dateOldest"; 14 | 15 | this.options = { 16 | timezone: document.querySelector('input[name="timezone"]:checked').value, 17 | showImg: !document.getElementById("noImg").checked, 18 | }; 19 | this.options.timezone === 'utc' && gtag('event', 'timezone_utc', { 'event_category': 'options' }); 20 | this.options.showImg === false && gtag('event', 'no_images', { 'event_category': 'options' }); 21 | 22 | this._storedReplies = {}; 23 | this._displayedReplies = new Set(); 24 | } 25 | 26 | display(video) { 27 | this._totalExpected = Number(video.statistics.commentCount); // for load percentage 28 | this._videoId = video.id; 29 | this.videoPublished = video.snippet.publishedAt; // for graph bound 30 | this._uploaderId = video.snippet.channelId; // for highlighting OP comments 31 | document.getElementById("message").textContent = "\u00A0"; 32 | formatTitle(video, this.options); 33 | document.getElementById("videoColumn").style.display = "block"; 34 | } 35 | 36 | prepareLoadStatus() { 37 | document.getElementById("linkedHolder").textContent = ""; 38 | document.getElementById("linkedColumn").style.display = "none"; 39 | this._linkedParent = this._currentLinked = null; 40 | 41 | document.getElementById("loadPercentage").textContent = "Initializing..."; 42 | 43 | document.getElementById("loadStatus").style.display = "block"; 44 | } 45 | 46 | updateLoadStatus(count) { 47 | if (this._totalExpected === 0) { 48 | return; 49 | } 50 | // Determine percentage precision based on total comment count 51 | // An "update" includes 100 or more comments 52 | // 1,000+ comments takes at least 10 updates, so use 2 digits (no decimals) 53 | // 10,000+ comments takes at least 100 updates, so use 3 digits (1 decimal place) 54 | const precision = Math.max(0, Math.floor(Math.log10(this._totalExpected)) - 3); 55 | const percentage = (count / this._totalExpected * 100).toFixed(precision) + '%'; 56 | 57 | document.getElementById("progressGreen").style.width = percentage; 58 | document.getElementById("progressGreen").ariaValueNow = percentage; 59 | 60 | document.getElementById("loadPercentage").textContent = percentage; 61 | document.title = percentage + " complete | YouTube Comment Viewer"; 62 | if (this._totalExpected > 1000) { 63 | document.getElementById("loadEta").textContent = `~${parseDurationMSS(eta(this._totalExpected - count))} remaining`; 64 | const countString = Number(count).toLocaleString() + " / " + Number(this._totalExpected).toLocaleString(); 65 | document.getElementById("loadCount").textContent = `(${countString} comments indexed)`; 66 | } 67 | } 68 | 69 | handleGroupComments(reset, items) { 70 | if (reset) { 71 | this.commentNum = 0; 72 | // Clear stored replies so they can be re-fetched on sorting change 73 | // (also cause it makes the logic simpler) 74 | this._storedReplies = {}; 75 | this._displayedReplies = new Set(); 76 | } 77 | let add = ""; 78 | const paddingX = this.options.showImg ? "2" : "3"; 79 | for (let i = 0; i < items.length; i++) { 80 | this.commentNum++; 81 | 82 | add += `
  • ` 83 | + formatComment(items[i], this.commentNum, this.options, this._uploaderId, this._videoId, false) + `
  • `; 84 | } 85 | document.getElementById("commentsSection").insertAdjacentHTML('beforeend', add); 86 | } 87 | 88 | handleNewReplies(id, replies) { 89 | // Intent: store replies in date-ascending order. 90 | // Verify the order, in case the YT API folks decide to flip the order of returned replies 91 | // on a whim like they did in October 2023. 92 | if (replies.length >= 2 && new Date(replies[0].publishedAt) > new Date(replies[1].publishedAt)) { 93 | replies.reverse(); 94 | } 95 | this._storedReplies[id] = replies; 96 | 97 | gtag('event', 'replies', { 98 | 'event_category': 'data_request', 99 | 'value': replies.length 100 | }); 101 | this.populateReplies(id); 102 | } 103 | 104 | handleRepliesButton(button) { 105 | const commentId = button.id.substring(11); 106 | if (this._storedReplies[commentId]) { 107 | if (this._displayedReplies.has(commentId)) { 108 | document.getElementById("repliesEE-" + commentId).style.display = "none"; 109 | button.textContent = `\u25BC Show ${this._storedReplies[commentId].length} replies`; 110 | this._displayedReplies.delete(commentId); 111 | } 112 | else { 113 | document.getElementById("repliesEE-" + commentId).style.display = "block"; 114 | button.textContent = `\u25B2 Hide ${this._storedReplies[commentId].length} replies`; 115 | this._displayedReplies.add(commentId); 116 | } 117 | } 118 | else { 119 | button.disabled = true; 120 | button.textContent = "Loading..."; 121 | this._socket.emit("replyRequest", commentId); 122 | } 123 | } 124 | 125 | populateReplies(commentId) { 126 | const len = this._storedReplies[commentId].length; 127 | let newContent = ""; 128 | let lClass; 129 | for (let i = 0; i < len; i++) { 130 | lClass = this._storedReplies[commentId][i].id === this._currentLinked ? " linked" : ""; 131 | 132 | newContent += `
    ` 133 | + formatComment(this._storedReplies[commentId][i], i + 1, this.options, 134 | this._uploaderId, this._videoId, true) + `
    `; 135 | } 136 | document.getElementById("repliesEE-" + commentId).innerHTML = newContent; 137 | this._displayedReplies.add(commentId); 138 | document.getElementById("getReplies-" + commentId).textContent = `\u25B2 Hide ${len} replies`; 139 | document.getElementById("getReplies-" + commentId).disabled = false; 140 | } 141 | 142 | handleLinkedComment(parent, reply) { 143 | this._linkedParent = parent.id; 144 | this._currentLinked = reply ? reply.id : parent.id; 145 | 146 | document.getElementById("linkedHolder").innerHTML = 147 | formatComment(parent, -1, this.options, this._uploaderId, this._videoId, false); 148 | if (reply) { 149 | document.getElementById("repliesEE-" + parent.id).innerHTML = 150 | `
    ` 151 | + formatComment(reply, -1, this.options, this._uploaderId, this._videoId, true) 152 | + `
    `; 153 | } 154 | 155 | document.getElementById("linkedColumn").style.display = "block"; 156 | } 157 | 158 | handleStatsData(data) { 159 | gtag('event', 'stats', { 'event_category': 'data_request' }); 160 | document.getElementById("s_comments").textContent = data[0].comments.toLocaleString(); 161 | document.getElementById("s_totalLikes").textContent = data[0].totalLikes.toLocaleString(); 162 | document.getElementById("s_avgLikes").textContent = (data[0].totalLikes / data[0].comments) 163 | .toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 164 | 165 | const videoAge = (new Date().getTime() - new Date(this.videoPublished).getTime()) / (24 * 60 * 60 * 1000); 166 | const commentsPerDay = data[1].length / Math.ceil(videoAge); 167 | document.getElementById("s_avgPerDay").textContent = commentsPerDay 168 | .toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); 169 | 170 | this._graph.constructGraph(data[1]); 171 | } 172 | 173 | handleWindowResize() { 174 | this._graph.requestResize(); 175 | } 176 | } -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | @import 'npm:uplot/dist/uPlot.min.css'; 2 | 3 | @import url(./icons.css); 4 | 5 | .uplot { 6 | font-family: inherit; 7 | } 8 | .u-select { 9 | background: var(--graph-selection-color, #00000012); 10 | } 11 | 12 | /* Dark mode colors */ 13 | .dark-mode { 14 | --dark-2: hsl(0, 0%, 3%); 15 | --dark-2: hsl(0, 0%, 6%); 16 | --dark-3: hsl(0, 0%, 9%); 17 | --dark-4: hsl(0, 0%, 12%); 18 | --dark-5: hsl(0, 0%, 15%); 19 | --dark-6: hsl(0, 0%, 18%); 20 | --dark-7: hsl(0, 0%, 21%); 21 | --dark-8: hsl(0, 0%, 24%); 22 | 23 | --light-1: hsl(0, 0%, 80%); 24 | --light-2: hsl(0, 0%, 83%); 25 | --light-3: hsl(0, 0%, 86%); 26 | 27 | --text-color: hsl(0, 0%, 69%); 28 | --link-color: rgb(63, 156, 228); 29 | --author-color: rgb(108, 208, 68); 30 | --backdrop-color: hsla(0, 0%, 0%, .6); 31 | --border-color: hsla(0, 0%, 0%, .25); 32 | --divider-color: hsl(0, 0%, 40%); 33 | --red-color: hsl(0, 70%, 69%); 34 | 35 | --dark-scheme: dark; 36 | 37 | --stroke-color: hsl(188, 65%, 65%); 38 | --graph-selection-color: hsla(0, 0%, 100%, .1); 39 | --grid-color: hsl(200, 10%, 25%); 40 | --graph-selection-color: hsla(0, 0%, 100%, .1); 41 | } 42 | 43 | * { 44 | margin: 0; 45 | } 46 | #reloadAlert { 47 | display: none; 48 | position: fixed; 49 | top: 0; 50 | left: 0; 51 | width: 100%; 52 | padding: 16px 8px; 53 | pointer-events: none; 54 | z-index: 101; 55 | } 56 | #innerMessage { 57 | max-width: 800px; 58 | pointer-events: auto; 59 | margin: 0 auto; 60 | box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.5); 61 | } 62 | #closeAlert { 63 | font-size: 32px; 64 | opacity: 0.75; 65 | line-height: 0.75; 66 | } 67 | #closeAlert:focus { 68 | outline: none; 69 | } 70 | a:link, 71 | a:visited { 72 | color: var(--link-color, #00e); 73 | text-decoration: none; 74 | } 75 | a:hover { 76 | text-decoration: underline; 77 | } 78 | a:focus { 79 | color: var(--red-color, red); 80 | } 81 | b { 82 | font-weight: 600; 83 | } 84 | p { 85 | margin-bottom: 0; 86 | } 87 | body { 88 | background-color: var(--dark-3, #bdf); 89 | font-family: "Open Sans", Arial, sans-serif; 90 | line-height: normal; 91 | font-size: 16px; 92 | color: var(--text-color, #000); 93 | min-height: 100vh; 94 | position: relative; 95 | } 96 | main { 97 | margin: 0 24px; 98 | padding-bottom: 52px; 99 | } 100 | footer { 101 | background-color: var(--dark-5, #def); 102 | padding: 10px 5px; 103 | position: absolute; 104 | bottom: 0; 105 | width: 100%; 106 | border-top: 1px solid var(--border-color, #999); 107 | box-sizing: border-box; 108 | } 109 | 110 | .form-control { 111 | background-color: var(--dark-5, #fff); 112 | border-color: var(--divider-color, #ced4da); 113 | color: var(--text-color, #495057); 114 | } 115 | .form-control:focus { 116 | background-color: var(--dark-4, #fff); 117 | color: var(--text-color, #495057); 118 | } 119 | .card { 120 | background-color: var(--dark-5, #fff); 121 | } 122 | .card-header { 123 | border-bottom: 1px solid var(--border-color, #00000020); 124 | } 125 | .half-dark { 126 | border-top: 1px solid var(--divider-color, rgba(0,0,0,0.5)); 127 | } 128 | .table td { 129 | border-top: 1px solid var(--divider-color, #dee2e6); 130 | } 131 | .table { 132 | color: inherit; 133 | } 134 | input { 135 | color-scheme: var(--dark-scheme, light); /* For color of calendar icon*/ 136 | } 137 | canvas { 138 | margin-top: 5px; 139 | background-color: var(--dark-5,); 140 | } 141 | .list-group-item { 142 | background-color: var(--dark-5,); 143 | } 144 | h1 { 145 | padding: 0.1em 0; 146 | font-size: 2em; 147 | font-weight: 600; 148 | letter-spacing: -2px; 149 | } 150 | button.nostyle { 151 | font: inherit; 152 | text-transform: inherit; 153 | padding: 0; 154 | width: 100%; 155 | text-align: left; 156 | } 157 | #enterVideo { 158 | transform: translateY(-50%); 159 | top: 50%; 160 | position: relative; 161 | } 162 | button#viewGraph { 163 | padding-left: 0.75rem!important; 164 | } 165 | button#viewGraph:hover { 166 | background-color: var(--dark-8, #e4e4e4); 167 | } 168 | .btn:focus { 169 | text-decoration: unset; 170 | box-shadow: none; 171 | } 172 | span#graphDesc { 173 | font-size: 14px; 174 | float: right; 175 | } 176 | #tooltip { 177 | pointer-events: none; 178 | position: absolute; 179 | padding: 4px 8px; 180 | background-color: var(--dark-2, #fff); 181 | box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.1); 182 | border-radius: 4px; 183 | border: 1px solid #0005; 184 | white-space: pre; 185 | z-index: 105; 186 | color: var(--text-color,); 187 | /* transition: all 100ms ease; */ 188 | } 189 | option:disabled { 190 | color: #999; 191 | } 192 | .gray { 193 | color: #555; 194 | } 195 | .light-gray { 196 | color: #999; 197 | } 198 | .red { 199 | color: var(--red-color, #b00); 200 | } 201 | div#info { 202 | display: none; 203 | line-height: 1.75em; 204 | } 205 | .comment { 206 | border: none; 207 | } 208 | .comment:first-child { 209 | border-top: 3px solid rgba(0, 0, 0, 0.25); 210 | border-radius: 0; 211 | } 212 | .comment + .comment { 213 | border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.25)); 214 | } 215 | .dropdown:hover .dropdown-menu { 216 | display: block; 217 | } 218 | .highlight { 219 | background-color: #ff9; 220 | } 221 | .anchor-right { 222 | left: unset; 223 | right: 0; 224 | } 225 | .cursor-default { 226 | cursor: default; 227 | } 228 | .bg-invalid, .bg-invalid:focus { 229 | background-color: rgba(255,0,0,.10); 230 | } 231 | #commentsCol, 232 | #videoColumn, 233 | #statsColumn, 234 | #statsContainer, 235 | #loadStatus, 236 | #linkedColumn, 237 | #noteColumn, 238 | #filter { 239 | display: none; 240 | } 241 | tr:first-child td, 242 | tr:first-child th { 243 | border-top: none; 244 | } 245 | .comments-list { 246 | line-height: 1.5; 247 | } 248 | .vidTitle { 249 | font-size: 1.25em; 250 | } 251 | .author { 252 | font-weight: 600; 253 | } 254 | span#options { 255 | position: relative; 256 | float: right; 257 | margin: 0 0 5px 5px; 258 | } 259 | h6.sub-title { 260 | font-weight: 600; 261 | margin: 0; 262 | } 263 | button.sendSort { 264 | padding: 0.25rem 0.5rem; 265 | } 266 | h5.head { 267 | text-transform: uppercase; 268 | font-size: 16px; 269 | font-weight: 600; 270 | padding: 0.75rem; 271 | } 272 | h5.head, 273 | .form-control::placeholder { 274 | color: var(--text-color, #666); 275 | } 276 | .btn-link, .btn-link:hover { 277 | color: var(--link-color, #00e); 278 | } 279 | .btn-link:hover { 280 | text-decoration: underline; 281 | } 282 | div#spinnerContainer { 283 | display: none; 284 | height: 0; 285 | } 286 | div#spinner { 287 | margin-top: 100px; 288 | z-index: 10; 289 | } 290 | .reloading { 291 | opacity: 0.25; 292 | transition: opacity 0.1s linear; 293 | pointer-events: none; 294 | } 295 | #loadEta { 296 | float: right; 297 | } 298 | #loadCount { 299 | font-size: 14px; 300 | color: var(--text-color,); 301 | } 302 | div#progress { 303 | height: 24px; 304 | } 305 | div.pbar { 306 | background-color: rgba(0, 0, 0, 0.1); 307 | } 308 | .snap { 309 | transition: width 0.25s; 310 | } 311 | div#chooseLoad { 312 | display: none; 313 | } 314 | div#sortLoaded { 315 | display: none; 316 | font-size: 18px; 317 | } 318 | div.linked { 319 | background-color: #def; 320 | } 321 | a.channelPfpLink { 322 | margin-right: 5px; 323 | display: inline-block; 324 | vertical-align: top; 325 | white-space: normal; 326 | height: 48px; 327 | width: 48px; 328 | background-color: #4aa3ff; 329 | } 330 | .commentContent, 331 | .commentContentFull, 332 | .replyContent, 333 | .replyContentFull { 334 | display: inline-block; 335 | white-space: normal; 336 | word-wrap: break-word; 337 | width: calc(100% - 53px); 338 | } 339 | .commentContentFull { 340 | width: 100%; 341 | } 342 | .replyContentFull { 343 | width: calc(100% - 32px); 344 | margin-left: 32px; 345 | } 346 | span.num { 347 | float: right; 348 | } 349 | .commentFooter { 350 | font-weight: 600; 351 | } 352 | img.pfp { 353 | height: 48px; 354 | width: 48px; 355 | } 356 | img.thumbnail { 357 | width: 100%; 358 | margin-bottom: 5px; 359 | } 360 | #submitAll { 361 | width: 100%; 362 | } 363 | #showMoreDiv { 364 | display: none; 365 | } 366 | #showMoreBtn { 367 | font-size: 20px; 368 | width: 100%; 369 | } 370 | a.authorName, 371 | a.authorNameOp { 372 | font-weight: 600; 373 | letter-spacing: -0.5px; 374 | } 375 | a.authorNameOp { 376 | color: var(--author-color, #fff); 377 | } 378 | span.authorNameCreator { 379 | display: inline-block; 380 | background-color: var(--dark-8, #03f); 381 | padding: 0 8px; 382 | vertical-align: middle; 383 | border-radius: 12px; 384 | } 385 | .noColor, .noColor:focus, a.noColor { 386 | color: inherit; 387 | } 388 | .bold-column > tbody > tr > td:first-child { 389 | font-weight: 600; 390 | } 391 | ul.terms-list { 392 | padding-left: 0px; 393 | } 394 | li.terms-item { 395 | margin-left: 20px; 396 | } 397 | div#terms { 398 | display: none; 399 | position: fixed; 400 | z-index: 2; 401 | left: 0; 402 | top: 0; 403 | width: 100%; 404 | height: 100%; 405 | overflow: auto; 406 | background-color: var(--backdrop-color, rgba(0, 0, 0, 0.4)); 407 | animation-name: fadein; 408 | animation-duration: 0.25s; 409 | } 410 | .terms-content { 411 | position: relative; 412 | word-break: break-word; 413 | max-width: 800px; 414 | background-color: var(--dark-5, #e0f0ff); 415 | margin: 15vh auto; 416 | width: 90%; 417 | box-shadow: 8px 8px 12px 0 rgba(0, 0, 0, 0.2); 418 | } 419 | @keyframes fadein { 420 | from { 421 | opacity: 0; 422 | } 423 | to { 424 | opacity: 1; 425 | } 426 | } 427 | .terms-header { 428 | padding: 8px 16px; 429 | font-weight: 600; 430 | font-size: 20px; 431 | background-color: var(--dark-4, #bdf); 432 | } 433 | .terms-body { 434 | margin-top: 20px; 435 | padding: 0 16px; 436 | } 437 | .terms-footer { 438 | font-size: 18px; 439 | overflow: auto; 440 | padding: 0 8px 8px 0; 441 | } 442 | #closeTerms { 443 | float: right; 444 | padding: 8px 16px; 445 | font-weight: 600; 446 | } 447 | #closeTerms:focus, 448 | #closeTerms:hover { 449 | background-color: var(--dark-8, #bdf); 450 | text-decoration: none; 451 | cursor: pointer; 452 | } 453 | -------------------------------------------------------------------------------- /public/graph.js: -------------------------------------------------------------------------------- 1 | import uPlot from 'uplot'; 2 | import { shiftDate, floorDate, getCssProperty } from './util.js'; 3 | import { tooltipPlugin, calcAxisSpace } from './graphUtils.js'; 4 | 5 | const HOUR = 60 * 60 * 1000, DAY = 24 * HOUR, MONTH = 30 * DAY, YEAR = 365 * DAY; 6 | 7 | export class Graph { 8 | constructor(video, socket) { 9 | this._video = video; 10 | this._socket = socket; 11 | this.reset(); 12 | 13 | document.getElementById("viewGraph").addEventListener('click', () => this.handleGraphButton()); 14 | document.getElementById("intervalSelect").onchange = () => this.intervalChange(); 15 | } 16 | reset() { 17 | this._graphDisplayState = 0; //0=none, 1=loaded, 2=shown 18 | this._graphInstance = undefined; 19 | this._loadingDots = 3; 20 | this._loadingInterval = undefined; 21 | this._rawDates = []; 22 | this._leftBound = undefined; 23 | this._datasets = { 24 | "hour": undefined, 25 | "day": undefined, 26 | "month": undefined, 27 | "year": undefined 28 | }; 29 | } 30 | 31 | intervalChange() { 32 | const newInterval = document.getElementById("intervalSelect").value; 33 | if (newInterval !== this._graphInstance.cvInterval) { 34 | // Build the graph data array if needed 35 | if (!this._datasets[newInterval]) { 36 | this.buildDataArray(newInterval); 37 | } 38 | 39 | const currentTimestamps = this._graphInstance.data[0]; 40 | const isZoomed = this._graphInstance.scales.x.min > currentTimestamps[0] 41 | || this._graphInstance.scales.x.max < currentTimestamps[currentTimestamps.length - 1]; 42 | 43 | const newIntervalTimestamps = this._datasets[newInterval][0]; 44 | const newLen = newIntervalTimestamps.length; 45 | let leftBound = newIntervalTimestamps[0]; 46 | let rightBound = newIntervalTimestamps[newLen - 1]; 47 | 48 | if (isZoomed) { 49 | // Save the current scale's min & max (only if they're within the new x range) 50 | leftBound = Math.max(this._graphInstance.scales.x.min, leftBound); 51 | rightBound = Math.min(this._graphInstance.scales.x.max, rightBound); 52 | 53 | // Catch case where old left bound exceeds new right bound. 54 | // Ex: Graph shows 2021-08-10 to 2021-08-20 on "day" interval, then switches to 55 | // "month" interval which only goes until 2021-08-01 56 | if (leftBound > newIntervalTimestamps[newLen - 1]) { 57 | // Walk back a point, to show at least 2 points on graph 58 | const newLeftIndex = Math.max(newLen - 2, 0); 59 | leftBound = newIntervalTimestamps[newLeftIndex]; 60 | } 61 | } 62 | 63 | // Commence interval change 64 | this._graphInstance.cvInterval = newInterval; 65 | this._graphInstance.setData(this._datasets[newInterval], false); 66 | this._graphInstance.setScale("x", { min: leftBound, max: rightBound }); 67 | 68 | gtag('event', 'interval_change', { 69 | 'event_category': 'stats', 70 | 'event_label': newInterval 71 | }); 72 | } 73 | } 74 | 75 | getGraphSize = () => { 76 | // Fill container width 77 | const statsColumn = document.getElementById("statsColumn"); 78 | const computedStyle = window.getComputedStyle(statsColumn); 79 | const containerWidth = statsColumn.clientWidth - parseFloat(computedStyle.paddingLeft) - parseFloat(computedStyle.paddingRight); 80 | // Minimum dimensions: 250 x 150, CSS pixels 81 | return { 82 | // 24px buffer to account for card padding 83 | // and scrollbar, since browsers don't fire resize event on scrollbar appearance. 84 | width: Math.max(250, containerWidth - 24), 85 | height: Math.max(150, Math.min(400, document.documentElement.clientHeight - 75)) 86 | }; 87 | } 88 | 89 | handleGraphButton() { 90 | if (this._graphDisplayState == 2) { 91 | document.getElementById("statsContainer").style.display = "none"; 92 | document.getElementById("viewGraph").textContent = "\u25BC Statistics"; 93 | this._graphDisplayState = 1; 94 | } 95 | else if (this._graphDisplayState == 1) { 96 | document.getElementById("statsContainer").style.display = "block"; 97 | document.getElementById("viewGraph").textContent = "\u25B2 Statistics"; 98 | this._graphDisplayState = 2; 99 | } 100 | else { 101 | document.getElementById("viewGraph").disabled = true; 102 | document.getElementById("viewGraph").textContent = "Loading..."; 103 | this._loadingInterval = setInterval(() => { 104 | this._loadingDots = ++this._loadingDots % 4; 105 | document.getElementById("viewGraph").textContent = "Loading" + '.'.repeat(this._loadingDots); 106 | }, 200); 107 | this._socket.emit("graphRequest"); 108 | } 109 | } 110 | 111 | buildDataArray(interval) { 112 | const dateMap = {}; 113 | const isUtc = this._video.options.timezone === "utc"; 114 | 115 | const startDate = floorDate(new Date(this._leftBound), interval, isUtc); 116 | const endDate = floorDate(new Date(), interval, isUtc); 117 | 118 | const currentDate = startDate; 119 | // One key for each unit 120 | while (currentDate <= endDate) { 121 | dateMap[new Date(currentDate).getTime()] = 0; 122 | shiftDate(currentDate, interval, 1, isUtc); 123 | } 124 | 125 | // Populate date counts from comments 126 | for (const rawDate of this._rawDates) { 127 | dateMap[floorDate(new Date(rawDate), interval, isUtc).getTime()]++; 128 | } 129 | 130 | // Build dataset for graph 131 | const data = [[], []]; 132 | for (const key in dateMap) { 133 | data[0].push(Math.floor(key / 1000)); 134 | data[1].push(dateMap[key]); 135 | } 136 | this._datasets[interval] = data; 137 | } 138 | 139 | constructGraph(dates) { 140 | this._rawDates = dates; 141 | 142 | // Begin from video publish date, or the first comment if its date precedes the video's 143 | this._leftBound = Math.min(new Date(this._video.videoPublished), new Date(this._rawDates[this._rawDates.length - 1])); 144 | const graphDomainLength = new Date().getTime() - new Date(this._leftBound).getTime(); 145 | 146 | // Make available only the intervals that result in the graph having more than 1 point 147 | document.getElementById("optHour").disabled = graphDomainLength < 1 * HOUR; 148 | document.getElementById("optDay").disabled = graphDomainLength < 1 * DAY; 149 | document.getElementById("optMonth").disabled = graphDomainLength < 1 * MONTH; 150 | document.getElementById("optYear").disabled = graphDomainLength < 1 * YEAR; 151 | 152 | // Pick an interval based on the graph domain length 153 | let interval = "hour"; 154 | if (graphDomainLength > 2 * DAY) interval = "day"; 155 | if (graphDomainLength > 3 * YEAR) interval = "month"; 156 | 157 | document.getElementById("intervalSelect").value = interval; 158 | 159 | this.buildDataArray(interval); 160 | 161 | this.drawGraph(interval); 162 | this._graphInstance.cvInterval = interval; 163 | 164 | document.getElementById("statsContainer").style.display = "block"; 165 | this._graphDisplayState = 2; 166 | clearInterval(this._loadingInterval); 167 | document.getElementById("viewGraph").disabled = false; 168 | document.getElementById("viewGraph").textContent = "\u25B2 Statistics"; 169 | } 170 | 171 | drawGraph(interval) { 172 | if (this._graphInstance) this._graphInstance.destroy(); 173 | 174 | const gridColor = () => getCssProperty("--grid-color") || "rgba(0,0,0,0.1)"; 175 | const textColor = () => getCssProperty("--text-color") || "#000"; 176 | const strokeColor = () => getCssProperty("--stroke-color") || "blue"; 177 | 178 | const isUtc = this._video.options.timezone === "utc"; 179 | const axis = { 180 | font: "14px Open Sans", 181 | grid: { stroke: gridColor }, 182 | ticks: { 183 | show: true, 184 | size: 5, 185 | stroke: gridColor 186 | }, 187 | stroke: textColor 188 | } 189 | 190 | const opts = { 191 | ...this.getGraphSize(), 192 | tzDate: (ts) => isUtc ? uPlot.tzDate(new Date(ts * 1000), "Etc/UTC") : new Date(ts * 1000), 193 | plugins: [ 194 | tooltipPlugin(isUtc) 195 | ], 196 | scales: { 197 | 'y': { 198 | // Force min to be 0 & let uPlot compute max normally 199 | range: (_self, _min, max) => uPlot.rangeNum(0, max, 0.1, true) 200 | } 201 | }, 202 | axes: [ 203 | { 204 | ...axis, 205 | space: (self, _axisIdx, scaleMin, scaleMax, plotDim) => calcAxisSpace(self.cvInterval, scaleMin, scaleMax, plotDim), 206 | }, 207 | { 208 | ...axis, 209 | size: (_self, values) => { 210 | const size = 50; 211 | let increment = 0; 212 | if (values) { 213 | // Buff axis space if more than 5 characters 214 | const longestLen = values[values.length - 1].length; 215 | increment = Math.max(longestLen - 5, 0) * 8; 216 | } 217 | return size + increment; 218 | }, 219 | // Only allow whole numbers on y axis 220 | space: (_self, _axisIdx, _scaleMin, scaleMax, plotDim) => Math.max(plotDim / scaleMax, 30) 221 | } 222 | ], 223 | series: [ 224 | {}, 225 | { 226 | // y series 227 | stroke: strokeColor, 228 | width: 2, 229 | points: { show: false } 230 | }, 231 | ], 232 | legend: { show: false }, 233 | cursor: { 234 | y: false, 235 | drag: { dist: 5 } 236 | }, 237 | }; 238 | 239 | this._graphInstance = new uPlot(opts, this._datasets[interval], document.getElementById("graphSpace")); 240 | } 241 | 242 | requestResize() { 243 | if (this._graphInstance) { 244 | this._graphInstance.setSize(this.getGraphSize()); 245 | } 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /public/util.js: -------------------------------------------------------------------------------- 1 | const wholeHourOffset = new Date().getTimezoneOffset() % 60 === 0; 2 | 3 | export function formatTitle(video, options) { 4 | const liveState = video.snippet.liveBroadcastContent; 5 | const viewCount = Number(video.statistics.viewCount); 6 | const likeCount = Number(video.statistics.likeCount); 7 | const dislikeCount = Number(video.statistics.dislikeCount); 8 | 9 | if (options.showImg) { 10 | document.getElementById("thumb").src = video.snippet.thumbnails.medium.url; 11 | } 12 | else { 13 | document.getElementById("thumbCol").style.display = "none"; 14 | } 15 | 16 | document.getElementById("videoTitle").textContent = video.snippet.title; 17 | document.getElementById("videoTitle").href = `https://www.youtube.com/watch?v=${video.id}`; 18 | 19 | document.getElementById("uploader").textContent = video.snippet.channelTitle; 20 | document.getElementById("uploader").href = getChannelUrl(video.snippet.channelId); 21 | 22 | let ratingsSec = ``; 23 | if (typeof video.statistics.likeCount === "undefined") { 24 | ratingsSec += ` Ratings have been hidden.`; 25 | } 26 | else { 27 | ratingsSec += ` ${likeCount.toLocaleString()}`; 28 | 29 | // in case dislikeCount somehow exists... 30 | if (typeof video.statistics.dislikeCount !== "undefined") { 31 | ratingsSec += `     32 | 33 | ${dislikeCount.toLocaleString()} 34 | 35 | `; 36 | } 37 | } 38 | document.getElementById("ratings").innerHTML = ratingsSec; 39 | 40 | let viewcountSec = ` `; 41 | let timestampSec = ` Published: ${parseTimestamp(video.snippet.publishedAt, options.timezone)}`; 42 | if (liveState === "live") { 43 | const concurrentViewers = Number(video.liveStreamingDetails.concurrentViewers); 44 | viewcountSec += `${concurrentViewers.toLocaleString()} watching now / ${viewCount.toLocaleString()} views`; 45 | 46 | const startTime = new Date(video.liveStreamingDetails.actualStartTime); 47 | const duration = new Date().getTime() - startTime.getTime(); 48 | timestampSec += `
    Stream start time: 49 | ${parseTimestamp(startTime.toISOString(), options.timezone)} 50 | (Elapsed: ${parseDurationHMMSS(Math.floor(duration / 1000))})`; 51 | } 52 | else if (liveState === "upcoming") { 53 | viewcountSec += `Upcoming live stream`; 54 | timestampSec += `
    Scheduled start time: 55 | ${parseTimestamp(video.liveStreamingDetails.scheduledStartTime, options.timezone)}`; 56 | } 57 | else { 58 | // Handle missing viewcount (seen in YT premium shows) 59 | viewcountSec += (typeof video.statistics.viewCount === "undefined") 60 | ? ` View count unavailable` 61 | : `${viewCount.toLocaleString()} views`; 62 | 63 | timestampSec += ``; 64 | 65 | // For premieres 66 | if (typeof video.liveStreamingDetails !== "undefined") { 67 | timestampSec += `
    Stream start time: 68 | ${parseTimestamp(video.liveStreamingDetails.actualStartTime, options.timezone)}`; 69 | } 70 | 71 | document.getElementById("commentInfo").innerHTML = ` Loading comment information...`; 72 | } 73 | document.getElementById("viewcount").innerHTML = viewcountSec; 74 | document.getElementById("vidTimestamp").innerHTML = timestampSec; 75 | 76 | document.getElementById("info").style.display = "block"; 77 | } 78 | 79 | export function formatComment(item, number, options, uploaderId, videoId, reply = false) { 80 | let content = ""; 81 | let contentClass; 82 | if (reply) { 83 | contentClass = options.showImg ? "replyContent" : "replyContentFull"; 84 | } 85 | else { 86 | contentClass = options.showImg ? "commentContent" : "commentContentFull"; 87 | } 88 | const channelUrl = getChannelUrl(item.authorChannelId); 89 | let replySegment = ""; 90 | let likeSegment = ""; 91 | let numSegment = ""; 92 | let opSegment = ""; 93 | let pfpSegment = ""; 94 | 95 | const totalReplyCount = Number(item.totalReplyCount); 96 | const likeCount = Number(item.likeCount); 97 | 98 | let timeString = parseTimestamp(item.publishedAt, options.timezone); 99 | if (item.publishedAt != item.updatedAt) { 100 | timeString += ` ( edited ${parseTimestamp(item.updatedAt, options.timezone)})`; 101 | } 102 | 103 | // second condition included for safety 104 | if (item.totalReplyCount > 0 && !reply) { 105 | replySegment = ` 106 |
    107 |
    108 | 111 |
    112 |
    113 |
    114 | `; 115 | } 116 | 117 | likeSegment += (item.likeCount) 118 | ? `
    ${likeCount.toLocaleString()}
    ` 119 | : `
    `; 120 | 121 | if (number > 0) { 122 | numSegment += 123 | ` 124 | #${number} 125 | `; 126 | } 127 | 128 | let authorClass = "authorName"; 129 | if (item.authorChannelId === uploaderId) { 130 | opSegment += `class="authorNameCreator"`; 131 | authorClass = "authorNameOp"; 132 | } 133 | 134 | if (options.showImg) { 135 | pfpSegment += 136 | ` 137 | 138 | `; 139 | } 140 | 141 | content += 142 | `${pfpSegment}` + 143 | `
    144 |
    145 | ${item.authorDisplayName} 146 | | 147 | 148 | ${timeString} 149 | 150 | ${numSegment} 151 |
    152 |
    ${item.snippet || item.textDisplay}
    153 | ${likeSegment}${replySegment} 154 |
    `; 155 | 156 | return content; 157 | } 158 | 159 | export function getChannelUrl(channelId) { 160 | return `https://www.youtube.com/channel/${channelId}`; 161 | } 162 | 163 | export function parseTimestamp(iso, timezone) { 164 | const date = new Date(iso); 165 | if (isNaN(date)) { 166 | return `(No date)`; 167 | } 168 | 169 | let output; 170 | switch (timezone) { 171 | case "utc": 172 | output = date.toISOString().substring(0, 19).replace('T', ' '); 173 | break; 174 | case "local": 175 | default: 176 | output = date.toLocaleString(); 177 | } 178 | return output; 179 | } 180 | 181 | export function shiftDate(date, unit, amt, isUtc) { 182 | switch (unit) { 183 | case "year": 184 | isUtc ? date.setUTCFullYear(date.getUTCFullYear() + amt) : date.setFullYear(date.getFullYear() + amt); 185 | break; 186 | case "month": 187 | isUtc ? date.setUTCMonth(date.getUTCMonth() + amt) : date.setMonth(date.getMonth() + amt); 188 | break; 189 | case "day": 190 | isUtc ? date.setUTCDate(date.getUTCDate() + amt) : date.setDate(date.getDate() + amt); 191 | break; 192 | case "hour": 193 | // Use UTC hour shifting, because otherwise JavaScript skips the 1 AM hour after DST "fall back" 194 | // Only exception is for half-hour time zones (e.g. India: GMT+5:30) 195 | wholeHourOffset ? date.setUTCHours(date.getUTCHours() + amt) : date.setHours(date.getHours() + amt); 196 | break; 197 | } 198 | } 199 | 200 | export function floorDate(date, unit, isUtc) { 201 | switch (unit) { 202 | // No breaks, because each date needs to be floored down to the smallest unit. 203 | case "year": 204 | isUtc ? date.setUTCMonth(0) : date.setMonth(0); 205 | case "month": 206 | isUtc ? date.setUTCDate(1) : date.setDate(1); 207 | case "day": 208 | isUtc ? date.setUTCHours(0) : date.setHours(0); 209 | case "hour": 210 | // Use UTC hour shifting, because otherwise JavaScript incorrectly floors the hour after DST "fall back" 211 | // Example: 1:40 AM PST -> 1:00 AM PDT (should be 1:00 AM PST) 212 | // Only exception is for half-hour time zones 213 | wholeHourOffset ? date.setUTCMinutes(0, 0, 0) : date.setMinutes(0, 0, 0); 214 | } 215 | return date; 216 | } 217 | 218 | export function parseDurationMSS(timeSeconds) { 219 | const minutes = Math.floor(timeSeconds / 60); 220 | const seconds = timeSeconds % 60; 221 | return minutes + ':' + ('0' + seconds).slice(-2); 222 | } 223 | 224 | export function parseDurationHMMSS(timeSeconds) { 225 | const hours = Math.floor(timeSeconds / 60 / 60); 226 | const minutes = Math.floor(timeSeconds / 60) % 60; 227 | const seconds = timeSeconds % 60; 228 | return hours + ':' + ('0' + minutes).slice(-2) + ':' + ('0' + seconds).slice(-2); 229 | } 230 | 231 | export function eta(x) { 232 | // Estimates number of seconds to load x comments 233 | const estimate = Math.floor(x / 450); 234 | return Math.max(estimate, 0); 235 | } 236 | 237 | export function getCssProperty(propertyName) { 238 | return window.getComputedStyle(document.body).getPropertyValue(propertyName); 239 | } 240 | 241 | export function timeToNextPacificMidnight() { 242 | // Get current Pacific time in "HH:MM" 243 | const now = new Date(); 244 | const timeString = now.toLocaleTimeString('en-US', { 245 | timeZone: 'America/Los_Angeles', 246 | hourCycle: 'h23', 247 | hour: '2-digit', 248 | minute: '2-digit' 249 | }); 250 | const [hh, mm] = timeString.split(':').map(Number); 251 | // The hour diff will be off-by-one if used before 2 AM on the day of a DST switch. 252 | // However, I'd rather accept this drawback than import a whole library for this 253 | const hourDiff = 23 - hh; 254 | const minDiff = 59 - mm; 255 | return { hourDiff, minDiff }; 256 | } 257 | -------------------------------------------------------------------------------- /src/database.js: -------------------------------------------------------------------------------- 1 | const sqlite = require('better-sqlite3'); 2 | const logger = require('./logger'); 3 | const { printTimestamp, getNextUTCTimestamp, getLastPacificMidnight } = require('./utils'); 4 | 5 | const DAY = 24 * 60 * 60 * 1000; 6 | const timer = ms => new Promise(res => setTimeout(res, ms)); 7 | const CHUNK_SIZE = 1500; 8 | 9 | class Database { 10 | constructor() { 11 | this._db = new sqlite('db.sqlite'); 12 | this._statsDb = new sqlite('stats.sqlite'); 13 | 14 | this._videosInProgress = new Set(); 15 | this._videosInDeletion = new Set(); 16 | 17 | this._db.pragma('journal_mode=WAL'); 18 | this._db.pragma('secure_delete=0'); 19 | this._db.pragma('synchronous=NORMAL'); 20 | this._db.pragma('cache_size=5000'); 21 | this._db.pragma('journal_size_limit=10000000'); 22 | 23 | this._db.prepare('CREATE TABLE IF NOT EXISTS videos(id TINYTEXT PRIMARY KEY, initialCommentCount INT,' + 24 | ' commentCount INT, retrievedAt BIGINT, lastUpdated BIGINT, inProgress BOOL, nextPageToken TEXT)').run(); 25 | this._db.prepare('CREATE TABLE IF NOT EXISTS comments(id TINYTEXT PRIMARY KEY, textDisplay TEXT, authorDisplayName TEXT,' + 26 | ' authorProfileImageUrl TINYTEXT, authorChannelId TINYTEXT, likeCount INT, publishedAt BIGINT, updatedAt BIGINT,' + 27 | ' totalReplyCount SMALLINT, videoId TINYTEXT, FOREIGN KEY(videoId) REFERENCES videos(id) ON DELETE CASCADE)').run(); 28 | 29 | this._db.prepare('CREATE INDEX IF NOT EXISTS comment_index ON comments(videoId, publishedAt, likeCount)').run(); 30 | 31 | this._statsDb.prepare('CREATE TABLE IF NOT EXISTS stats(id TINYTEXT, title TINYTEXT, duration INT,' + 32 | ' finishedAt BIGINT, commentCount INT, commentThreads INT, isAppending BOOL)').run(); 33 | 34 | this._statsDb.prepare('CREATE INDEX IF NOT EXISTS stats_index ON stats(finishedAt)').run(); 35 | 36 | this.cleanupChunkTimes = []; 37 | this.scheduleCleanup(); 38 | } 39 | 40 | checkVideo(videoId) { 41 | const actuallyInProgress = this._videosInProgress.has(videoId); 42 | const inDeletion = this._videosInDeletion.has(videoId); 43 | const row = this._db.prepare('SELECT * FROM videos WHERE id = ?').get(videoId); 44 | 45 | return { row, actuallyInProgress, inDeletion }; 46 | } 47 | 48 | addVideo(videoId, commentCount) { 49 | const now = Date.now(); 50 | this._db.prepare('INSERT OR REPLACE INTO videos(id, initialCommentCount, commentCount, retrievedAt, lastUpdated, inProgress)' + 51 | ' VALUES(?, ?, 0, ?, ?, true)') 52 | .run(videoId, commentCount, now, now); 53 | this._videosInProgress.add(videoId); 54 | } 55 | 56 | reAddVideo(videoId) { 57 | this._db.prepare('UPDATE videos SET lastUpdated = ?, inProgress = true WHERE id = ?') 58 | .run(Date.now(), videoId); 59 | this._videosInProgress.add(videoId); 60 | } 61 | 62 | deleteVideo(videoId) { 63 | this._db.prepare('DELETE FROM videos WHERE id = ?').run(videoId); 64 | } 65 | 66 | async deleteVideoChunks(videoId, verbose = false) { 67 | verbose && logger.log('info', "Deleting video %s in chunks.", videoId); 68 | 69 | this._videosInDeletion.add(videoId); 70 | this._db.prepare('UPDATE videos SET inProgress = true, nextPageToken = ? WHERE id = ?').run(null, videoId); 71 | 72 | let deleteCount = 0; 73 | let changes = 0; 74 | let start, end; 75 | do { 76 | start = Date.now(); 77 | changes = this._db.prepare(`DELETE FROM comments WHERE videoId = ? LIMIT ${CHUNK_SIZE}`).run(videoId).changes; 78 | end = Date.now(); 79 | 80 | // Track time taken for each full-size chunk 81 | changes === CHUNK_SIZE && this.cleanupChunkTimes.push(end - start); 82 | 83 | deleteCount += changes; 84 | await (timer(50)); 85 | } while (changes > 0); 86 | 87 | this._db.prepare('DELETE FROM videos WHERE id = ?').run(videoId); 88 | this._videosInDeletion.delete(videoId); 89 | 90 | verbose && logger.log('info', "Finished deleting video %s in chunks; %s comments deleted.", 91 | videoId, deleteCount.toLocaleString()); 92 | 93 | return deleteCount; 94 | } 95 | 96 | abortVideo(videoId) { 97 | this._videosInProgress.delete(videoId); 98 | } 99 | 100 | getLastComment(videoId) { 101 | return this._db.prepare('SELECT id, MAX(publishedAt) FROM comments WHERE videoId = ?').get(videoId); 102 | } 103 | 104 | getComments(videoId, limit, offset, sortBy, minDate, maxDate) { 105 | const rows = this._db.prepare(`SELECT * FROM comments WHERE videoId = ? AND publishedAt >= ? AND publishedAt <= ? 106 | ORDER BY ${sortBy} LIMIT ${Number(limit)} OFFSET ${Number(offset)}`).all(videoId, minDate, maxDate); 107 | 108 | const subCount = this._db.prepare(`SELECT COUNT(*) FROM comments WHERE videoId = ? AND publishedAt >= ? AND publishedAt <= ?`) 109 | .get(videoId, minDate, maxDate)['COUNT(*)']; 110 | 111 | const totalCount = this._db.prepare('SELECT COUNT(*) FROM comments WHERE videoId = ?').get(videoId)['COUNT(*)']; 112 | 113 | return { rows, subCount, totalCount }; 114 | } 115 | 116 | getAllDates(videoId) { 117 | return this._db.prepare('SELECT publishedAt FROM comments WHERE videoId = ? ORDER BY publishedAt DESC') 118 | .all(videoId); 119 | } 120 | 121 | getStatistics(videoId) { 122 | const stats = {}; 123 | 124 | const row = this._db.prepare('SELECT COUNT(*), sum(likeCount) FROM comments WHERE videoId = ?').get(videoId); 125 | stats.comments = Number(row['COUNT(*)']); 126 | stats.totalLikes = Number(row['sum(likeCount)']); 127 | 128 | return stats; 129 | } 130 | 131 | /** 132 | * Get the total number of comment threads fetched since midnight, Pacific Time. 133 | * Used to fine-tune API quota usage. 134 | */ 135 | commentThreadsFetchedToday() { 136 | const cutoff = getLastPacificMidnight(); 137 | const row = this._statsDb.prepare(`SELECT COALESCE(SUM(commentThreads), 0) AS commentThreadsSum 138 | FROM stats WHERE finishedAt > ?`) 139 | .get(cutoff); 140 | return Number(row.commentThreadsSum); 141 | } 142 | 143 | writeNewComments(videoId, comments, newCommentCount, nextPageToken) { 144 | const insert = []; 145 | for (let i = 0; i < comments.length; i++) { 146 | insert.push(comments[i].id, comments[i].textDisplay, comments[i].authorDisplayName, 147 | comments[i].authorProfileImageUrl, comments[i].authorChannelId, comments[i].likeCount, 148 | comments[i].publishedAt, comments[i].updatedAt, comments[i].totalReplyCount, videoId); 149 | } 150 | const placeholders = comments.map(() => `(?,?,?,?,?,?,?,?,?,?)`).join(','); 151 | 152 | const statement = this._db.prepare(`INSERT OR REPLACE INTO comments(id, textDisplay, authorDisplayName, authorProfileImageUrl,` + 153 | ` authorChannelId, likeCount, publishedAt, updatedAt, totalReplyCount, videoId) VALUES ${placeholders}`); 154 | statement.run(insert); 155 | 156 | this._db.prepare('UPDATE videos SET commentCount = ?, lastUpdated = ?, nextPageToken = ? WHERE id = ?') 157 | .run(newCommentCount, Date.now(), nextPageToken || null, videoId); 158 | } 159 | 160 | markVideoComplete(videoId, videoTitle, elapsed, newComments, newCommentThreads, appending) { 161 | this._db.prepare('UPDATE videos SET inProgress = false WHERE id = ?').run(videoId); 162 | this._videosInProgress.delete(videoId); 163 | 164 | this._statsDb.prepare('INSERT INTO stats(id, title, duration, finishedAt, commentCount, commentThreads, isAppending)' + 165 | ' VALUES (?,?,?,?,?,?,?)') 166 | .run(videoId, videoTitle, elapsed, Date.now(), newComments, newCommentThreads, appending ? 1 : 0); 167 | } 168 | 169 | scheduleCleanup() { 170 | // Clean up database every Tuesday, Thursday, & Saturday at 09:00 UTC 171 | const nextTuesday = getNextUTCTimestamp(2, 9); 172 | const nextThursday = getNextUTCTimestamp(4, 9); 173 | const nextSaturday = getNextUTCTimestamp(6, 9); 174 | 175 | // Take the earliest date 176 | const nextCleanup = new Date(Math.min(nextTuesday, nextThursday, nextSaturday)); 177 | const timeToNextCleanup = nextCleanup.getTime() - Date.now(); 178 | 179 | setTimeout(() => this.cleanup(), timeToNextCleanup); 180 | logger.log('info', "Next database cleanup scheduled for %s, in %d hours", 181 | printTimestamp(nextCleanup), (timeToNextCleanup / 1000 / 60 / 60).toFixed(3)); 182 | } 183 | 184 | async cleanup() { 185 | // The following are based on commentCount, which *counts replies*, even though replies are not stored in database. 186 | // commentCount is the user-facing number of comments, which is larger than the number of comment threads stored. 187 | 188 | // Remove any videos with: 189 | // - under 10,000 comments & > 2 days untouched 190 | // - under 100K comments & > 2 days untouched 191 | // - under 1M comments & > 5 days untouched 192 | // - under 10M comments & > 7 days untouched 193 | 194 | const cleanupStart = Date.now(); 195 | logger.log('info', "CLEANUP: Starting database cleanup"); 196 | 197 | this.cleanupChunkTimes = []; 198 | 199 | let totalDeleteCount = 0; 200 | totalDeleteCount += await this.cleanUpSet(2 * DAY, 10000, true); 201 | totalDeleteCount += await this.cleanUpSet(2 * DAY, 100000); 202 | totalDeleteCount += await this.cleanUpSet(5 * DAY, 1000000); 203 | totalDeleteCount += await this.cleanUpSet(7 * DAY, 10000000); 204 | 205 | const elapsed = Math.ceil((Date.now() - cleanupStart) / 1000); 206 | const elapsedMins = Math.floor(elapsed / 60), elapsedSecs = elapsed % 60; 207 | 208 | logger.log('info', "CLEANUP: Finished database cleanup in %d min, %d s. Comments deleted: %s", 209 | elapsedMins, elapsedSecs, totalDeleteCount.toLocaleString()); 210 | 211 | const chunkSum = this.cleanupChunkTimes.reduce((prev, current) => prev + current, 0); 212 | const chunkAvg = Math.ceil(chunkSum / this.cleanupChunkTimes.length); 213 | 214 | logger.log('info', "CLEANUP: There were %d chunks of size %d. Average time per chunk: %d ms", 215 | this.cleanupChunkTimes.length, CHUNK_SIZE, chunkAvg); 216 | 217 | this.scheduleCleanup(); 218 | } 219 | 220 | async cleanUpSet(age, commentCount, includeStrays = false) { 221 | const now = Date.now(); 222 | // Clean out "stuck" videos (inProgress == true) or "damaged" videos (commentCount is null) 223 | // Not entirely sure why the latter happens 224 | const strayClause = includeStrays ? `OR inProgress = true OR commentCount IS NULL` : ``; 225 | 226 | const rows = this._db.prepare(`SELECT id FROM videos WHERE (lastUpdated < ?) AND (commentCount < ? ${strayClause})`) 227 | .all(now - age, commentCount); 228 | 229 | let deleteCountSet = 0; 230 | 231 | for (const row of rows) { 232 | deleteCountSet += await this.deleteVideoChunks(row.id); 233 | } 234 | 235 | logger.log('info', "CLEANUP: Deleted rows with < %s comments: %s videos, %s comments", 236 | commentCount.toLocaleString(), rows.length, deleteCountSet.toLocaleString()); 237 | 238 | return deleteCountSet; 239 | } 240 | } 241 | 242 | module.exports = Database; 243 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Note 2 | 3 | This changelog is not exhaustive. It served as a useful record back in 2020–2021 when I used to work on many features and push them all at once, but nowadays most [new commits](https://github.com/sameerdash2/comment-viewer/commits/master/) are small hotfixes that go to production immediately. 4 | 5 | --- 6 | 7 | ### 3.3.4 (27 Dec 2023) 8 | - Added dark mode (https://github.com/sameerdash2/comment-viewer/issues/14). Toggleable via a button in the footer. 9 | 10 | ### 3.3.3 (18 Jul 2023) 11 | - Added page size option: show 25, 50, 100, or 500 comments at a time. (https://github.com/sameerdash2/comment-viewer/issues/3) 12 | - Added support for passing video IDs or links as a URL parameter: `https://commentviewer.com/?v=4VaqA-5aQTM`. (https://github.com/sameerdash2/comment-viewer/issues/5) 13 | - Added OpenSearch support: quickly open the site from the address bar in Chrome/Edge/Firefox/Safari. 14 | - Apparently Chrome disabled this feature in the past couple years -- you need to turn on the search engines manually in settings, which (imo) defeats the purpose. 15 | - In Edge, you can use tab-to-search once you've loaded the page for a first time. 16 | - In Firefox you can [manually add the search engine](https://support.mozilla.org/en-US/kb/add-or-remove-search-engine-firefox) once the page is loaded. 17 | - In Safari, I couldn't figure out how to access the site search (in my 5 minutes of testing) but it did recognize the site's OpenSearch engine. 18 | 19 | ### 3.3.2 (11 Apr 2023) 20 | - Graph and statistics now show automatically for videos with under 100,000 comments 21 | - Fixed "NaN%" showing in tab title 22 | - Updated system to use Node 18 23 | - Updated dependencies 24 | 25 | ### 3.3.1 (03 Feb 2022) 26 | - Removed display of dislikes on videos (dislike counts have been unavailable since 2021-12-13) 27 | - If the API somehow returns a dislike count, it will be displayed in red. 28 | - Better handling for videos with 0 comments 29 | - Updated dependencies / resolved security vulnerabilities 30 | 31 | ### 3.3.0 (12 Nov 2021) 32 | - Reworked database cleanup: Comments are now deleted in chunks of 10,000. This should stop the site becoming unresponsive during cleanup. 33 | - Amendment (21 Nov 2021): Chunk size reduced to 2500, as there was still some lag with 10,000. Cleanup also happens twice a week now (on Wednesdays and Saturdays) 34 | - Removed the search feature entirely. This includes dropping the database FTS table, which should improve performance (especially cleanup). The last commit that included the enabled search feature has been tagged. 35 | - Updated to Parcel 2 to resolve npm security warnings 36 | - Handled missing dislike counts on videos. The YouTube API will stop returning dislike counts [on Dec 13, 2021](https://support.google.com/youtube/thread/134791097/update-to-youtube-dislike-counts). 37 | - Graph y-axis now widens to display large numbers, instead of cutting them off 38 | - Fixed graph combining two points across the DST "fall back" hours (the second 1 AM - 2 AM hour now appears as a separate data point) 39 | - Handled strange case where video is missing metadata. Examples: https://www.youtube.com/watch?v=MOqm0qGJhpw, https://www.youtube.com/watch?v=TlGXDy5xFlw 40 | - Fixed styling issues on page 41 | - Updated to Node 16 42 | - Updated dependencies 43 | 44 | ### 3.2.1 (02 Sep 2021) 45 | - Disabled searching for now. Search queries were taking up to 30 seconds (much longer than normal) and blocking the main thread, making the entire site unresponsive. :( This probably won't be restored unless a feasible solution becomes available. 46 | - Made criteria for fully re-fetching a video less strict. This means new, fast-rising videos will not have to start from 0% as often. 47 | - Added an alert to reload the page after WebSocket connection lost 48 | - Fixed lag on graph tooltip's position update 49 | - Fixed a graph interval-change case where old left bound exceeds new right bound 50 | 51 | ### 3.2.0 (26 Aug 2021) 52 | - Added new hover tooltip on graph 53 | - Removed unused styles from Bootstrap CSS. This shaves off 138 KB (28% of page size) 54 | - Switched SQLite search engine back to FTS4 from FTS5. 55 | - Since switching to FTS5 in January, the database has been throwing `SQLITE_CORRUPT_VTAB` "database disk image is malformed" errors on about 50% of text searches. 3.2.0 reverts to the FTS4 implementation which didn't have this issue, though we'll have to see if it scales well. This will also require the database to be reset once. 56 | - Added button to clear search on search error 57 | - Moved graph tools ("Aggregate by") to above the graph 58 | - Removed "Initializing" progress bar animation 59 | - Turned off auto retrieval for videos with <200 comments 60 | - Refined statistics button 61 | - Added logging for different possible states of video (appending to stored comments, re-fetching video, etc.) 62 | - Fixed missing whitespace before "x hidden comments" 63 | - Updated dependencies 64 | 65 | ### 3.1.4 (13 Mar 2021) 66 | - Separated socket.io client script from bundled JS (improves load time) 67 | - Icons are now served directly instead of loading from CDN 68 | - Removed graph resize throttling 69 | 70 | ### 3.1.3 (10 Jan 2021) 71 | - Better handling for YouTube API quota being exceeded 72 | - Enabled SQLite WAL mode for better performance 73 | - Switched search engine from FTS4 to FTS5 in hopes of better scalability 74 | - Made database cleanup (somewhat) asynchronous as it was causing the entire site to stall 75 | - Database cleanup is also stricter now (stores comments for less time than before) due to increased traffic 76 | - Published date now shows for upcoming streams 77 | 78 | ### 3.1.2 (19 Nov 2020) 79 | - Switched graphs to `distr: 1`, making interval changes smoother and improving x-axis temporal labels 80 | - Prefer smaller intervals for the graph 81 | - Limited graph y-axis to only whole numbers 82 | - Added progress percentage in the tab title 83 | - Fixed missing comma separators in statistics data 84 | - Fixed lone comment icon showing up for live streams 85 | - Updated to Node.js 14 86 | - Updated various dependencies 87 | 88 | ### 3.1.1 (29 Aug 2020) 89 | - Improved graph behavior when changing intervals 90 | 91 | ### 3.1.0 (03 Aug 2020) 92 | - Added a search bar. All comments can be searched by text or author name, and the resulting subsets can be further sorted and/or filtered. 93 | - Added filter by date. You can select any date range, making it easier to analyze hundreds of thousands of comments. 94 | - Switched database library to `better-sqlite3`, reducing graph load times by up to 60% over `node-sqlite3` 95 | - Graph now responds properly to resize events (it now debounces if throttled) 96 | - Fixed linked comment card having shorter line height 97 | - 1 milion comments limit will be lifted soon™, partially thanks to YouTube [relaxing](https://developers.google.com/youtube/v3/revision_history#july-29,-2020) their API quota policies 98 | 99 | ### 3.0.1 (22 Jul 2020) 100 | - Removed "top commenters" for now due to slow performance and replaced it with "average comments per day" 101 | - Switched to throttling instead of debouncing for graph resize 102 | 103 | ### 3.0.0 (21 Jul 2020) 104 | - Revamped page to fresh new card layout 105 | - Added more comment statistics, including total likes & top commenters 106 | - Graph now loads in larger, quicker chunks 107 | - Fixed some faulty counting logic causing progress to exceed 100% 108 | - Slight improvements to input parsing 109 | - Switched to proper bold font 110 | - Fixed linked comment instantly disappearing for videos with under 200 comments 111 | - Full video information is now stored in database (for future possibilities) 112 | - Various performance and visual improvements 113 | 114 | ### 2.4.0 (09 Jul 2020) 115 | - Stores the next pageToken to continue loading comments even after server crash 116 | - Added 30-second timeout on API responses 117 | - Fixed comments with identical timestamps being out of order (now preserves the order from API response) 118 | - Linked comment now clears before loading all comments 119 | - Graph height now shrinks if necessary (e.g. landscape mobile displays) 120 | - Changed video metadata to use CSS float to reduce blank gaps 121 | - Limited input to 255 characters 122 | - Added ESLint and Parcel bundler 123 | - Fixed Discussion tab linked comments breaking the page 124 | - Fixed API errors due to extra whitespaces that seemed to somehow be the issue 125 | 126 | ### 2.3.1 (02 Jul 2020) 127 | - Fixed trying to load videos with 0 comments 128 | - Cached comments will be refreshed slightly more often 129 | - Larger favicon 130 | 131 | ### 2.3.0 (29 Jun 2020) 132 | - All comments now initially show a subset of their replies 133 | - Load percentage now shows as many decimal points as necessary 134 | - More loading indicators & responsive, restyled buttons 135 | - Scaled down size of most elements 136 | 137 | ### 2.2.0 (25 Jun 2020) 138 | - Added options for different intervals on the graph. Aggregate comments by hour, day, month, or year. 139 | - Made optimizations on the comments fetch process. Loading over 1 million comments should (theoretically) be possible. 140 | - Multiple users can now track the same video's load progress 141 | - New fancy progress bar when loading 142 | - Switched database schema to single table for all comments 143 | - Switched to Cloudflare CDN for Font Awesome icons 144 | - Fixed reply buttons not working after changing sort order 145 | - Fixed "Linked Comment" indicator not showing up 146 | 147 | ### 2.1.1 (17 Jun 2020) 148 | - Better dynamic resizing on window resize 149 | - Added comment permalink on the comment numbers 150 | - Updated home page to hide input box after enter 151 | - Organized frontend code into different files 152 | - Server now loads graph data in chunks of 1000 to ease CPU load 153 | - Fixed load progress showing over 100% due to pinned comment & its replies being recounted 154 | - Fixed RTL text not displaying properly 155 | 156 | ### 2.1.0 (16 Jun 2020) 157 | - Switched to columns instead of raw JSON for storing comments. (75% space decrease!) 158 | - Server no longer retains comments in memory; they are only served from the database. 159 | - Scaled down font size on main page 160 | - Added scheduled database pruning (to be improved) 161 | - Added Google Analytics 162 | - Fixed videos being added as fresh entries, resulting in the cached comments never updating 163 | - Fixed crashing due to client socket timeout 164 | 165 | ### 2.0.0 (11 Jun 2020) 166 | - Comments are now cached in a database, greatly reducing load times and quota usage. Any video with over 500 comments will be cached. 167 | - Visual changes to support small/mobile displays 168 | - Added terms of service 169 | - Changed font to Open Sans 170 | - Set graph minimum to always be 0, and maximum to be at least 5 171 | - Reworked linked comment logic to be faster (and highlight the uploader's name) 172 | - Input field now focuses on page load 173 | - More responsive drag-zooming on graph 174 | - Increased graph's y-axis padding to properly show large numbers 175 | - Added handling for missing dates (e.g. stream start time on https://youtu.be/CD4hT4bLwnc) 176 | - Several visual enchancements 177 | - Fixed some faulty API error handling 178 | 179 | ### 1.2.0 (29 May 2020) 180 | - Better no-image mode (no squares) 181 | - Changed UTC date format to YYYY-MM-DD to transcend language 182 | - Refactored backend into modules 183 | - Fixed load button showing up for 0 comments 184 | - Fixed timestamp link on linked comment 185 | 186 | ### 1.1.0 (19 May 2020) 187 | - Hour/minute units on graph are now hidden 188 | - Improved linked comment error handling for replies 189 | - Relocated "view graph" button 190 | - Replies will be retrieved on initial load for comments with over 100 replies 191 | - Added footer with version number 192 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | YouTube Comment Viewer 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 48 | 49 | 50 | 51 |
    52 |

    YouTube Comment Viewer

    53 | 54 |
    55 |
    56 |
    57 |
    Enter video
    58 |
    59 |
    60 |
    61 |
    62 | 64 |
    65 |
    66 |

     

    67 |
    68 |
    69 |
    70 |
    71 |
    72 |
    73 |
    Options
    74 |
    75 |
    Time zone
    76 |
    77 |
    78 | 79 | 80 |
    81 | 82 |
    83 | 84 | 85 |
    86 | 87 |
    88 | 89 |
    90 | 91 | 92 |
    93 |
    94 |
    95 |
    96 |
    97 |
    98 | 99 |
    100 |
    101 |
    102 |
    Video
    103 |
    104 |
    105 |
    106 |
    107 | thumbnail 108 |
    109 |
    110 |
    111 |
    112 |
    113 |
    114 |
    115 |
    116 |
    117 |
    118 |
    119 |
    120 |
    121 |
    122 |
    123 |
    124 |
    125 |
    126 |
    127 |
    128 |
    129 |
    130 |
    131 | 132 |
    133 |
    134 |
    135 | 136 |
    137 |
    138 |
    139 |
    140 | 141 | 147 |
    148 |
    149 | Drag to zoom, double-click to reset 150 |
    151 |
    152 |
    153 |
    154 |
    155 |
    156 |
    157 |
    158 | 159 |
    160 |
    161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 |
    Top-level comments0
    Total likes0
    173 |
    174 |
    175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 |
    Average comments per day0
    Average likes per comment0
    187 |
    188 |
    189 |
    190 |
    191 |
    192 |
    193 |
    194 |
    Linked comment
    195 |
    196 |
    197 |
    198 |
    199 |
    200 |
    201 | 202 |
    203 |
    204 |
    205 |
    Load status
    206 |
    207 | 0.0% 208 | 209 |
    210 |
    212 |
    213 | -- 214 |
    215 |
    216 |
    217 | 218 |
    219 |
    220 |
    Sort by
    221 |
    222 |
    223 | 224 | 225 | 226 |
    227 |
    228 |
    229 |
    230 |
    231 |
    232 |
    Filter
    233 |
    234 |
    235 |
    236 | 237 |
    238 | 239 |
    240 |
    241 |
    242 | 243 |
    244 | 245 |
    246 |
    247 |
    248 |
    249 |
    250 |
    251 | 252 |
    253 |
    254 |
    255 | Comments 256 |
    257 |
    258 |
    259 |
    260 | Loading... 261 |
    262 |
    263 | 264 |
    265 |
    266 | 267 | Showing 0 / 0 results 268 | • 269 | 270 | 271 | 272 |
    273 | 274 |
    275 |
    276 | 277 | 283 |
    284 |
    285 |
    286 | 287 |
      288 | 289 | 292 |
      293 |
      294 |
      295 |
      296 |
      297 | 298 | 309 | 310 |
      311 |
      312 |
      Terms of Service
      313 |
      314 | 328 |
      329 | 332 |
      333 |
      334 |
      335 |
      336 | 337 | The connection has been lost. Please reload the page 338 |
      339 |
      340 | 341 | 342 | -------------------------------------------------------------------------------- /public/main.js: -------------------------------------------------------------------------------- 1 | import { Video } from "./video.js"; 2 | import { shiftDate, timeToNextPacificMidnight } from './util.js'; 3 | 4 | const ERR = "#A00"; 5 | const LOAD = "#666"; 6 | 7 | document.addEventListener("DOMContentLoaded", () => { 8 | // eslint-disable-next-line no-undef 9 | const socket = io(undefined, { 10 | reconnectionDelayMax: 30000, 11 | randomizationFactor: 0 12 | }); 13 | const video = new Video(socket); 14 | 15 | const message = document.getElementById("message"); 16 | const commentsSection = document.getElementById("commentsSection"); 17 | const loadStatus = document.getElementById("loadStatus"); 18 | const showMoreBtn = document.getElementById("showMoreBtn"); 19 | const linkedHolder = document.getElementById("linkedHolder"); 20 | const terms = document.getElementById("terms"); 21 | const reloadAlert = document.getElementById("reloadAlert"); 22 | const dateMin = document.getElementById("dateMin"); 23 | const dateMax = document.getElementById("dateMax"); 24 | 25 | let dateLeftBound = -1; 26 | let dateRightBound = -1; 27 | 28 | let pageSize = document.getElementById("pageSizeSelect").value; 29 | 30 | let firstBatchReceived = false; 31 | let statsAvailable = false; 32 | 33 | // Terms of service button 34 | document.getElementById("viewTerms").addEventListener('click', (event) => { 35 | event.preventDefault(); 36 | terms.style.display = "block"; 37 | gtag('event', 'view_terms'); 38 | }); 39 | document.getElementById("closeTerms").addEventListener('click', () => terms.style.display = "none"); 40 | window.addEventListener('click', (event) => { 41 | if (event.target === terms) { 42 | terms.style.display = "none"; 43 | } 44 | }); 45 | 46 | // Dark mode toggle 47 | document.getElementById("toggleDark").addEventListener('click', (event) => { 48 | event.preventDefault(); 49 | const root = document.documentElement; 50 | const darkIsOn = root.classList.contains("dark-mode"); 51 | if (darkIsOn) { 52 | root.classList.remove("dark-mode"); 53 | } 54 | else { 55 | root.classList.add("dark-mode"); 56 | } 57 | 58 | try { 59 | localStorage.setItem("dark", darkIsOn ? "false" : "true"); 60 | } catch { } 61 | 62 | // Send a resize signal to video instance, to make it redraw the graph (if shown) 63 | video.handleWindowResize(); 64 | // Focus input box if visible 65 | event.target.blur(); 66 | document.getElementById("enterID").focus(); 67 | 68 | gtag('event', 'dark_mode'); 69 | }); 70 | 71 | window.addEventListener('resize', () => video.handleWindowResize()); 72 | 73 | // Listener for entering a video ID via text box. 74 | document.getElementById("videoForm").addEventListener('submit', (event) => { 75 | event.preventDefault(); // prevents page reloading 76 | const enterID = document.getElementById("enterID"); 77 | if (enterID.value.length > 0) { 78 | submitVideo(enterID.value); 79 | enterID.value = ""; 80 | } 81 | return false; 82 | }); 83 | 84 | // Also check if an ID was supplied via URL parameter "v". 85 | const urlParams = new URLSearchParams(window.location.search); 86 | if (urlParams.get('v') !== null) { 87 | submitVideo(urlParams.get('v')); 88 | } 89 | 90 | function submitVideo(input) { 91 | message.textContent = "Working..."; 92 | message.style.color = LOAD; 93 | socket.emit('idSent', input.trim()); 94 | } 95 | 96 | document.getElementById("submitAll").addEventListener('click', () => { 97 | document.getElementById("chooseLoad").style.display = "none"; 98 | video.prepareLoadStatus(); 99 | 100 | socket.emit("requestAll"); 101 | }); 102 | showMoreBtn.addEventListener('click', () => { 103 | showMoreBtn.disabled = true; 104 | showMoreBtn.textContent = "Loading..." 105 | sendCommentRequest(false); 106 | }); 107 | 108 | document.getElementById("sortLoaded").addEventListener('click', (event) => { 109 | const closest = event.target.closest(".sendSort"); 110 | if (closest) { 111 | // Enable all except the clicked button 112 | const items = document.querySelectorAll(".sendSort"); 113 | items.forEach((elem) => { 114 | elem.disabled = (elem.id == closest.id); 115 | }); 116 | 117 | video.currentSort = closest.id.substring(2); 118 | sendCommentRequest(true); 119 | gtag('event', 'sort', { 120 | 'event_category': 'filters', 121 | 'event_label': video.currentSort 122 | }); 123 | } 124 | }); 125 | 126 | document.getElementById("filterDate").addEventListener('change', (event) => { 127 | event.preventDefault(); 128 | const isUtc = video.options.timezone === "utc"; 129 | let minDate, maxDate; 130 | if (isUtc) { 131 | minDate = new Date(dateMin.value); 132 | maxDate = new Date(dateMax.value); 133 | } 134 | else { 135 | minDate = new Date(dateMin.value.split('-', 3)); 136 | maxDate = new Date(dateMax.value.split('-', 3)); 137 | } 138 | 139 | if (isNaN(minDate) || isNaN(maxDate)) { 140 | if (isNaN(minDate)) { 141 | dateMin.classList.add("bg-invalid"); 142 | } 143 | if (isNaN(maxDate)) { 144 | dateMax.classList.add("bg-invalid"); 145 | } 146 | } 147 | else if (minDate > maxDate) { 148 | dateMin.classList.add("bg-invalid"); 149 | dateMax.classList.add("bg-invalid"); 150 | } 151 | else { 152 | dateMin.classList.remove("bg-invalid"); 153 | dateMax.classList.remove("bg-invalid"); 154 | // Shift max date to cover the day 155 | shiftDate(maxDate, "day", 1, true); 156 | maxDate.setTime(maxDate.getTime() - 1); 157 | 158 | dateLeftBound = minDate.getTime(); 159 | dateRightBound = maxDate.getTime(); 160 | 161 | sendCommentRequest(true); 162 | gtag('event', 'date', { 'event_category': 'filters' }); 163 | } 164 | }); 165 | 166 | // On change in page size, request new set of comments with new page size. 167 | document.getElementById("pageSizeSelect").addEventListener("change", () => { 168 | pageSize = document.getElementById("pageSizeSelect").value; 169 | sendCommentRequest(true); 170 | gtag('event', 'page_size', { 'value': pageSize.toString() }); 171 | }); 172 | 173 | document.getElementById("resetFilters").addEventListener('click', () => { 174 | // Reset date filter 175 | dateLeftBound = -1; 176 | dateRightBound = -1; 177 | dateMin.value = dateMin.getAttribute('min'); 178 | dateMax.value = dateMax.getAttribute('max'); 179 | 180 | sendCommentRequest(true); 181 | gtag('event', 'reset', { 'event_category': 'filters' }); 182 | }); 183 | 184 | commentsSection.addEventListener('click', repliesButton); 185 | linkedHolder.addEventListener('click', repliesButton); 186 | function repliesButton(event) { 187 | const closest = event.target.closest(".showHideButton"); 188 | if (closest) { 189 | video.handleRepliesButton(closest); 190 | } 191 | } 192 | 193 | socket.on("idInvalid", () => { 194 | message.textContent = "Invalid video link or ID."; 195 | message.style.color = ERR; 196 | }); 197 | socket.on("videoInfo", ({ videoObject }) => displayVideo(videoObject)); 198 | function displayVideo(videoObject) { 199 | resetPage(); 200 | document.getElementById("intro").style.display = "none"; 201 | if (videoObject !== -1) { 202 | video.display(videoObject); 203 | gtag('event', 'video', { 'event_category': 'data_request' }); 204 | } 205 | } 206 | 207 | socket.on("commentsInfo", ({ num, disabled, max, largeAfterThreshold, graph, error }) => { 208 | const allowLoadingComments = !disabled && max < 0 && largeAfterThreshold < 0 && !error; 209 | document.getElementById("chooseLoad").style.display = allowLoadingComments ? "block" : "none"; 210 | 211 | num = Number(num) || 0; 212 | statsAvailable = graph; 213 | let newCommentInfo = ` `; 214 | if (disabled) { 215 | newCommentInfo += `Comments are disabled.`; 216 | if (num > 0) { 217 | newCommentInfo += ` (${num.toLocaleString()} hidden comments)`; 218 | gtag('event', 'hidden_comments', { 219 | 'event_category': 'data_request', 220 | 'value': num 221 | }); 222 | } 223 | } 224 | else { 225 | newCommentInfo += `${num.toLocaleString()} comments`; 226 | 227 | if (error === true) { 228 | displayNote(`The YouTube API returned an unknown error when trying to access this video's comments.`); 229 | } 230 | 231 | if (max > 0) { 232 | displayNote(`Videos with over ${max.toLocaleString()} comments are not currently supported. 233 | (This may change in the future)`); 234 | gtag('event', 'max_comments', { 235 | 'event_category': 'data_request', 236 | 'value': num 237 | }); 238 | } 239 | else if (largeAfterThreshold > 0) { 240 | displayNote(`Videos with over ${largeAfterThreshold.toLocaleString()} comments are disabled for the rest 241 | of the day. For more details 242 | see here.`); 243 | gtag('event', 'large_after_threshold', { 244 | 'event_category': 'data_request', 245 | 'value': largeAfterThreshold 246 | }); 247 | } 248 | } 249 | 250 | document.getElementById("commentInfo").innerHTML = newCommentInfo; 251 | }); 252 | 253 | socket.on("loadStatus", (totalCount) => { 254 | if (totalCount === -2) { 255 | displayNote(`This video's comments were stored by Comment Viewer, 256 | but they are currently being deleted in order to keep the data up to date. 257 | Please try again in a minute.`); 258 | } else { 259 | video.updateLoadStatus(totalCount); 260 | } 261 | }); 262 | 263 | socket.on("groupComments", ({ reset, items, showMore, subCount, totalCount, fullStatsData }) => { 264 | message.textContent = "\u00A0"; 265 | if (!firstBatchReceived) { 266 | firstBatchReceived = true; 267 | loadStatus.style.display = "none"; 268 | if (items.length < 1) { 269 | displayNote("This video does not have any comments."); 270 | return; 271 | } 272 | 273 | // Apply values to HTML date picker which operates on YYYY-MM-DD format 274 | // **This code assumes the first batch is sorted oldest first** 275 | const minDate = new Date(Math.min(new Date(video.videoPublished), new Date(items[0].publishedAt))); 276 | const maxDate = new Date(); 277 | let min, max; 278 | if (video.options.timezone === "utc") { 279 | min = minDate.toISOString().split('T')[0]; 280 | max = maxDate.toISOString().split('T')[0]; 281 | } 282 | else { 283 | min = new Date(minDate.getTime() - (minDate.getTimezoneOffset() * 60000)).toISOString().split("T")[0]; 284 | max = new Date(maxDate.getTime() - (maxDate.getTimezoneOffset() * 60000)).toISOString().split("T")[0]; 285 | } 286 | dateMin.setAttribute("min", min); 287 | dateMin.setAttribute("max", max); 288 | dateMax.setAttribute("min", min); 289 | dateMax.setAttribute("max", max); 290 | dateMin.value = min; 291 | dateMax.value = max; 292 | 293 | // Display necessary elements 294 | document.getElementById("commentsCol").style.display = "block"; 295 | document.getElementById("sortLoaded").style.display = "block"; 296 | document.getElementById("filter").style.display = "block"; 297 | document.getElementById("statsColumn").style.display = statsAvailable ? "block" : "none"; 298 | document.title = "YouTube Comment Viewer"; 299 | 300 | // If statistics data was sent, display graph and statistics. 301 | if (fullStatsData != null) { 302 | video.handleStatsData(fullStatsData); 303 | } 304 | } 305 | if (reset) { 306 | hideLoading(); 307 | commentsSection.textContent = ""; 308 | document.getElementById("subCount").textContent = Number(subCount).toLocaleString(); 309 | document.getElementById("totalCount").textContent = Number(totalCount).toLocaleString(); 310 | if (subCount === totalCount) { 311 | document.getElementById("resetGroup").style.display = "none"; 312 | } 313 | else { 314 | document.getElementById("resetGroup").style.display = "inline-block"; 315 | } 316 | } 317 | video.handleGroupComments(reset, items); 318 | document.getElementById("showMoreDiv").style.display = showMore ? "block" : "none"; 319 | showMoreBtn.textContent = "Show more comments..."; 320 | showMoreBtn.disabled = false; 321 | }); 322 | 323 | socket.on("newReplies", ({ items, id }) => video.handleNewReplies(id, items)); 324 | 325 | socket.on("statsData", (data) => video.handleStatsData(data)); 326 | 327 | socket.on("linkedComment", ({ parent, hasReply, reply, videoObject }) => { 328 | displayVideo(videoObject); 329 | video.handleLinkedComment(parent, hasReply ? reply : null); 330 | 331 | const action = hasReply ? 'linked_reply' : 'linked_comment'; 332 | gtag('event', action, { 'event_category': 'data_request' }); 333 | }); 334 | 335 | socket.on("resetPage", resetPage); 336 | function resetPage() { 337 | linkedHolder.textContent = ""; 338 | commentsSection.textContent = ""; 339 | document.getElementById("limitMessage").textContent = ""; 340 | document.getElementById("loadPercentage").textContent = "0%"; 341 | document.getElementById("loadEta").textContent = ''; 342 | document.getElementById("loadCount").textContent = '--'; 343 | document.getElementById("progressGreen").style.width = "0%"; 344 | 345 | document.getElementById("chooseLoad").style.display = "none"; 346 | document.getElementById("sortLoaded").style.display = "none"; 347 | document.getElementById("statsColumn").style.display = "none"; 348 | document.getElementById("showMoreDiv").style.display = "none"; 349 | 350 | document.getElementById("b_likesMost").disabled = false; 351 | document.getElementById("b_dateNewest").disabled = false; 352 | document.getElementById("b_dateOldest").disabled = true; 353 | document.getElementById("statsContainer").style.display = "none"; 354 | document.getElementById("graphSpace").textContent = ""; 355 | 356 | video.reset(); 357 | } 358 | 359 | socket.on("quotaExceeded", () => { 360 | const {hourDiff, minDiff} = timeToNextPacificMidnight(); 361 | const concession = `Quota exceeded. Please try again after midnight Pacific Time (in ${hourDiff} hr ${minDiff} min)`; 362 | if (video._videoId) { 363 | displayNote(concession); 364 | } 365 | else { 366 | message.textContent = concession; 367 | message.style.color = ERR; 368 | } 369 | }); 370 | 371 | socket.on("disconnect", () => reloadAlert.style.display = "block"); 372 | 373 | document.getElementById("closeAlert").addEventListener('click', () => reloadAlert.style.display = "none"); 374 | 375 | function sendCommentRequest(getNewSet) { 376 | if (getNewSet) { 377 | showLoading(); 378 | } 379 | // Only reset video.commentNum when the comments are received, to ensure it's always in sync 380 | const index = getNewSet ? 0 : video.commentNum; 381 | socket.emit("showMore", { 382 | sort: video.currentSort, 383 | commentNum: index, 384 | pageSize: pageSize, 385 | minDate: dateLeftBound, 386 | maxDate: dateRightBound 387 | }); 388 | } 389 | 390 | function showLoading() { 391 | commentsSection.classList.add("reloading"); 392 | document.getElementById("spinnerContainer").style.display = "flex"; 393 | } 394 | 395 | function hideLoading() { 396 | commentsSection.classList.remove("reloading"); 397 | document.getElementById("spinnerContainer").style.display = "none"; 398 | } 399 | 400 | function displayNote(note) { 401 | document.getElementById("noteColumn").style.display = "block"; 402 | document.getElementById("limitMessage").innerHTML = note; 403 | } 404 | }); -------------------------------------------------------------------------------- /public/bootstrap-purged.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v4.5.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2020 The Bootstrap Authors 4 | * Copyright 2011-2020 Twitter, Inc. 5 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 6 | */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}footer,main{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}hr{box-sizing:content-box;height:0;overflow:visible}h1,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}ul{margin-top:0;margin-bottom:1rem}ul ul{margin-bottom:0}b{font-weight:bolder}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]){color:inherit;text-decoration:none}a:not([href]):hover{color:inherit;text-decoration:none}code,pre{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,select{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}[hidden]{display:none!important}.h1,.h5,.h6,h1,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container,.container-xl{max-width:1140px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.col,.col-10,.col-12,.col-2,.col-md-4,.col-md-6,.col-md-8,.col-sm,.col-sm-6,.col-sm-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;min-width:0;max-width:100%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}}@media (min-width:768px){.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled{background-color:#e9ecef;opacity:1}input[type=date].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[size]{height:auto}.form-check{position:relative;display:block;padding-left:1.25rem}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#0069d9;border-color:#0062cc;box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.collapse:not(.show){display:none}.dropdown{position:relative}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu.show{display:block}.custom-control{position:relative;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.25rem;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-control-label::before{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before{transition:none}}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-text:last-child{margin-bottom:0}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-header+.list-group .list-group-item:first-child{border-top:0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.align-baseline{vertical-align:baseline!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.d-inline-block{display:inline-block!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.float-right{float:right!important}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.w-25{width:25%!important}.w-auto{width:auto!important}.h-100{height:100%!important}.my-1{margin-top:.25rem!important}.my-1{margin-bottom:.25rem!important}.mt-2,.my-2{margin-top:.5rem!important}.my-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.p-0{padding:0!important}.pt-0{padding-top:0!important}.pr-0{padding-right:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.py-2{padding-top:.5rem!important}.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.font-weight-bold{font-weight:700!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}pre{white-space:pre-wrap!important}pre{border:1px solid #adb5bd;page-break-inside:avoid}img,tr{page-break-inside:avoid}p{orphans:3;widows:3}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}} -------------------------------------------------------------------------------- /src/video.js: -------------------------------------------------------------------------------- 1 | const config = require('../config.json'); 2 | const { convertComment, printTimestamp } = require('./utils'); 3 | const logger = require('./logger'); 4 | const fs = require('fs'); 5 | 6 | const MAX_CONSECUTIVE_ERRORS = 3; 7 | 8 | class Video { 9 | constructor(app, io, socket) { 10 | this._app = app; 11 | this._io = io; 12 | this._socket = socket; 13 | 14 | this.debugStream = fs.createWriteStream('debug.txt', { flags: 'a' }); 15 | 16 | this.reset(); 17 | } 18 | 19 | reset() { 20 | this._indexedComments = 0; // All retrieved comments + their reply counts 21 | this._newComments = 0; 22 | this._newCommentThreads = 0; 23 | this._loadComplete = false; 24 | 25 | this._reportedLowResults = 0; 26 | } 27 | 28 | handleNewVideo(item) { 29 | this.reset(); 30 | if (item !== -1) { 31 | this._video = item; 32 | this._id = this._video.id; 33 | this._commentCount = Number(this._video.statistics.commentCount); 34 | // this._logToDatabase = this._commentCount >= 500; 35 | this._logToDatabase = true; // Currently needed as comments are only sent from database 36 | this.fetchTestComment(); 37 | } 38 | } 39 | 40 | fetchTitle(idString, consecutiveErrors = 0) { 41 | return this._app.ytapi.executeVideo(idString).then((response) => { 42 | if (response.data.pageInfo.totalResults > 0) { 43 | this.handleNewVideo(response.data.items[0]); 44 | // After receiving video info, frontend will wait for the commentsInfo event (except for live streams) 45 | this._socket.emit("videoInfo", { videoObject: this._video }); 46 | } 47 | else { 48 | this._socket.emit("idInvalid"); 49 | } 50 | }, (err) => { 51 | logger.log('error', "Video execute error on %s: %d ('%s') - '%s'", 52 | idString, err.code, err.errors[0].reason, err.errors[0].message); 53 | 54 | if (++consecutiveErrors < MAX_CONSECUTIVE_ERRORS) { 55 | if (err.errors[0].reason === "quotaExceeded") { 56 | this._app.ytapi.quotaExceeded(); 57 | this._socket.emit("quotaExceeded"); 58 | } 59 | else if (err.errors[0].reason === "processingFailure") { 60 | setTimeout(() => this.fetchTitle(idString, consecutiveErrors), 100); 61 | } 62 | } 63 | else { 64 | logger.log('warn', "Giving up video fetch on '%s' due to %d consecutive errors.", idString, consecutiveErrors); 65 | } 66 | }); 67 | } 68 | 69 | fetchTestComment(consecutiveErrors = 0) { 70 | this._app.ytapi.executeTestComment(this._video.id).then(() => { 71 | this._commentsEnabled = true; 72 | // for upcoming/live streams, disregard a 0 count. 73 | if (!(this._video.snippet.liveBroadcastContent !== "none" && this._commentCount === 0)) { 74 | // Make graph available if 1 hour has passed, to ensure at least 2 points on the graph 75 | this._graphAvailable = this._commentCount >= 10 76 | && new Date(this._video.snippet.publishedAt).getTime() <= (Date.now() - 60 * 60 * 1000); 77 | 78 | const videoInDb = typeof this._app.database.checkVideo(this._id).row !== "undefined"; 79 | 80 | // Check if video's comment count exceeds the set maximum. 81 | // If so, make sure the video isn't already retrieved (from when it had fewer comments). 82 | this._commentCountTooLarge = this._commentCount > config.maxLoad && !videoInDb; 83 | 84 | // Check if the daily "warning" threshold has been passed. 85 | // If so, check if the comment count is too large with the strict threshold, 86 | // and that the video isn't already retrieved. 87 | const commentThreadsToday = this._app.database.commentThreadsFetchedToday(); 88 | this._blockedToday = commentThreadsToday >= config.dailyThreshold 89 | && this._commentCount >= config.limitAfterThreshold 90 | && !videoInDb; 91 | if (this._blockedToday) { 92 | logger.log('info', "Blocking video %s with %s comments, since %s comment threads fetched today.", 93 | this._id, this._commentCount.toLocaleString(), commentThreadsToday.toLocaleString()); 94 | } 95 | 96 | // Pass data to frontend 97 | this._socket.emit("commentsInfo", { 98 | num: this._commentCount, 99 | disabled: false, 100 | max: (this._commentCountTooLarge) ? config.maxLoad : -1, 101 | largeAfterThreshold: this._blockedToday ? config.limitAfterThreshold : -1, 102 | graph: this._graphAvailable, 103 | error: false 104 | }); 105 | } 106 | }, (err) => { 107 | const error = err.errors[0]; 108 | if (error.reason !== "commentsDisabled") { 109 | logger.log('error', "TEST comment execute error on %s: %d ('%s') - '%s'", 110 | this._id, err.code, error.reason, error.message); 111 | } 112 | 113 | const errorPayload = { 114 | num: this._commentCount, 115 | disabled: false, 116 | max: -1, 117 | largeAfterThreshold: -1, 118 | graph: false, 119 | error: true 120 | }; 121 | 122 | if (++consecutiveErrors < 3) { 123 | if (error.reason === "quotaExceeded") { 124 | this._app.ytapi.quotaExceeded(); 125 | this._socket.emit("quotaExceeded"); 126 | } 127 | else if (error.reason === "processingFailure") { 128 | setTimeout(() => this.fetchTestComment(consecutiveErrors), 100); 129 | } 130 | else if (this._video.snippet.liveBroadcastContent === "none" && error.reason === "commentsDisabled") { 131 | this._commentsEnabled = false; 132 | this._socket.emit("commentsInfo", { 133 | num: this._commentCount, 134 | disabled: true, 135 | max: -1, 136 | largeAfterThreshold: -1, 137 | graph: false, 138 | error: false 139 | }); 140 | } 141 | else { 142 | // Unknown error. Or members-only content 143 | this._socket.emit("commentsInfo", errorPayload); 144 | } 145 | } else { 146 | logger.log('warn', "Giving up TEST comment fetch on %s due to %d consecutive errors.", this._id, consecutiveErrors); 147 | this._socket.emit("commentsInfo", errorPayload); 148 | } 149 | }); 150 | } 151 | 152 | shouldReFetch = (row) => { 153 | const now = Date.now(); 154 | const initialCommentCount = Number(row.initialCommentCount); 155 | const videoAge = now - new Date(this._video.snippet.publishedAt).getTime(); 156 | const currentCommentsAge = now - row.retrievedAt; 157 | const MONTH = 30 * 24 * 60 * 60 * 1000; 158 | 159 | // Determine whether the comment set should be re-fetched by seeing if it meets at least 1 condition. 160 | // These will probably change over time 161 | const doReFetch = ( 162 | // Comment count has doubled 163 | initialCommentCount * 2 < this._commentCount 164 | // 6 months have passed since initial fetch 165 | || currentCommentsAge > 6 * MONTH 166 | ); 167 | 168 | if (doReFetch && this._commentCount > 5000) { 169 | const commentsAgeHours = currentCommentsAge / 1000 / 60 / 60; 170 | const videoAgeHours = videoAge / 1000 / 60 / 60; 171 | logger.log('info', "Re-fetching video %s. initialCommentCount %s; current commentCount %s; current comments age %sh; video age %sh.", 172 | this._id, (initialCommentCount).toLocaleString(), (this._commentCount).toLocaleString(), 173 | commentsAgeHours.toLocaleString(), videoAgeHours.toLocaleString()); 174 | } 175 | return doReFetch; 176 | } 177 | 178 | handleLoad(type) { 179 | if ( 180 | this._commentsEnabled 181 | && !this._commentCountTooLarge 182 | && !this._blockedToday 183 | && type === "dateOldest" 184 | ) { 185 | this._newComments = 0; 186 | this._newCommentThreads = 0; 187 | const now = Date.now(); 188 | this._startTime = now; 189 | 190 | if (this._logToDatabase) { 191 | const { row, actuallyInProgress, inDeletion } = this._app.database.checkVideo(this._id); 192 | if (row) { 193 | // Comments exist in the database. 194 | 195 | if (inDeletion) { 196 | this._socket.emit("loadStatus", -2); 197 | } 198 | else if (row.inProgress) { 199 | this._socket.join('video-' + this._id); 200 | if (!actuallyInProgress) { 201 | // Comment set is stuck in an unfinished state. 202 | // Use nextPageToken to continue fetch if possible 203 | if (row.nextPageToken) { 204 | logger.log('info', "Attempting to resume unfinished fetch process on %s. Indexed %s comments; total expected: %s", 205 | this._id, row.commentCount.toLocaleString(), this._commentCount.toLocaleString()); 206 | this._indexedComments = row.commentCount ?? 0; 207 | 208 | this._app.database.reAddVideo(this._id); 209 | this.fetchAllComments(row.nextPageToken, false); 210 | } 211 | else { 212 | logger.log('info', "Could not resume unfinished fetch process on %s. Restarting.", this._id); 213 | this.reFetchVideo(row); 214 | } 215 | } 216 | } 217 | // 5-minute cooldown before doing any new fetch 218 | else if ((now - row.lastUpdated) <= 5*60*1000) { 219 | this._loadComplete = true; // To permit statistics retrieval later 220 | this.requestLoadedComments("dateOldest", 0, config.defaultPageSize, false, undefined, undefined, true); 221 | } 222 | // Re-fetch all comments from scratch if needed 223 | else if (this.shouldReFetch(row)) { 224 | this.reFetchVideo(row); 225 | } 226 | // Append to existing set of comments 227 | else { 228 | logger.log('info', "Appending to video %s. %s total; %s new.", 229 | this._id, (this._commentCount).toLocaleString(), (this._commentCount - row.commentCount).toLocaleString()); 230 | this._indexedComments = row.commentCount ?? 0; 231 | this._app.database.reAddVideo(this._id); 232 | const lastCommentRow = this._app.database.getLastComment(this._id); 233 | this._lastComment = { id: lastCommentRow.id, publishedAt: lastCommentRow["MAX(publishedAt)"] }; 234 | this.startFetchProcess(true); 235 | } 236 | } 237 | else { 238 | // New video 239 | this._app.database.addVideo(this._id, this._commentCount); 240 | this.startFetchProcess(false); 241 | } 242 | } 243 | else { 244 | this.startFetchProcess(false); 245 | } 246 | } 247 | } 248 | 249 | reFetchVideo = (row) => { 250 | // If video has over 100,000 comments, delete in chunks 251 | if (row.commentCount > 100000) { 252 | this._app.database.deleteVideoChunks(this._id, true); 253 | this._socket.emit("loadStatus", -2); 254 | } else { 255 | this._app.database.deleteVideo(this._id); 256 | this._app.database.addVideo(this._id, this._commentCount); 257 | this.startFetchProcess(false); 258 | } 259 | } 260 | 261 | startFetchProcess = (appendToDatabase) => { 262 | // Join a room so load status can be broadcast to multiple users 263 | this._socket.join('video-' + this._id); 264 | 265 | this.fetchAllComments("", appendToDatabase); 266 | } 267 | 268 | fetchAllComments(pageToken, appending, consecutiveErrors = 0) { 269 | // Impose 30-second limit on API response. 270 | const timeoutHolder = new Promise((resolve) => setTimeout(resolve, 30*1000, -1)); 271 | Promise.race([timeoutHolder, this._app.ytapi.executeCommentChunk(this._id, pageToken)]).then((response) => { 272 | if (response === -1) { 273 | logger.log('warn', "Fetch process on %s timed out. Indexed %s comments; total expected: %s", 274 | this._id, this._indexedComments.toLocaleString(), this._commentCount.toLocaleString()); 275 | this._app.database.abortVideo(this._id); 276 | return; 277 | } 278 | 279 | // 2023-10-29 API issues: Log youtube API returning low number of results. 280 | if (this._reportedLowResults < 2 && response.data.nextPageToken && response.data.items.length < 100) { 281 | logger.log('warn', "API returned only %d comments for video %s. commentCount: %s", 282 | response.data.items.length, this._id, this._commentCount.toLocaleString()); 283 | this._reportedLowResults++; 284 | } 285 | 286 | let proceed = true; 287 | const convertedComments = []; 288 | // Pinned comments always appear first regardless of their date (thanks google) 289 | // (this also means the pinned comment can be identified as long as it isn't the newest comment; could possibly use that in future) 290 | if (response.data.items.length > 1 && response.data.items[0].snippet.topLevelComment.snippet.publishedAt 291 | < response.data.items[1].snippet.topLevelComment.snippet.publishedAt) { 292 | // If the pinned comment precedes the last date, shift it out of the array in order not to 293 | // distort the date-checking later. Keep it in convertedComments to update its database entry 294 | // since its likeCount is probably increasing rapidly. 295 | if (appending && new Date(response.data.items[0].snippet.topLevelComment.snippet.publishedAt).getTime() < this._lastComment.publishedAt) { 296 | convertedComments.push(convertComment(response.data.items.shift())); 297 | } 298 | 299 | } 300 | let newIndexed; 301 | for (const commentThread of response.data.items) { 302 | // If appending to database-stored comments, check if the records have been reached by 303 | // comparing IDs of the last stored comment. 304 | // In case it's been deleted, also check if the current date has surpassed the last comment's date. 305 | // Equal timestamps can slip through, but they should be taken care of by database. 306 | const currentDate = new Date(commentThread.snippet.topLevelComment.snippet.publishedAt).getTime(); 307 | if (appending && (commentThread.id === this._lastComment.id || currentDate < this._lastComment.publishedAt)) { 308 | proceed = false; 309 | break; 310 | } 311 | 312 | convertedComments.push(convertComment(commentThread)); 313 | newIndexed = 1 + commentThread.snippet.totalReplyCount; 314 | this._indexedComments += newIndexed; 315 | this._newComments += newIndexed; 316 | this._newCommentThreads++; 317 | } 318 | 319 | if (convertedComments.length > 0 && this._logToDatabase) { 320 | // Write new comments to database 321 | this._app.database.writeNewComments(this._id, convertedComments, 322 | this._indexedComments, response.data.nextPageToken || null); 323 | } 324 | 325 | // Broadcast load status to clients to display percentage 326 | this._io.to('video-' + this._id).emit("loadStatus", this._indexedComments); 327 | 328 | // Sometimes, the API returns an empty page of comments and gives no nextPageToken. 329 | // Retry this request 3 times. If nothing changes, assume it's really the end of comments. 330 | if ( 331 | response.data.items.length === 0 332 | && response.data.nextPageToken === undefined 333 | && this._indexedComments < this._commentCount 334 | && ++consecutiveErrors < MAX_CONSECUTIVE_ERRORS 335 | ) { 336 | logger.log('warn', "Empty API response received on %s after %s of %s expected comments indexed. Retrying.", 337 | this._id, this._indexedComments.toLocaleString(), this._commentCount.toLocaleString()); 338 | setTimeout(() => this.fetchAllComments(pageToken, appending, consecutiveErrors), 100); 339 | return; 340 | } 341 | 342 | // If there are more comments, and database-stored comments have not been reached, retrieve the next 100 comments 343 | if (response.data.nextPageToken !== undefined && proceed) { 344 | setTimeout(() => this.fetchAllComments(response.data.nextPageToken, appending), 0); 345 | } 346 | else { 347 | // Finished retrieving all comment threads. 348 | const elapsed = Date.now() - this._startTime; 349 | const cpsString = this._newCommentThreads > 800 ? ('; TRUE CPS = ' + (this._newCommentThreads / elapsed * 1000).toFixed(0)) : ''; 350 | logger.log('info', 'Retrieved video %s; %s comments in %ds' + cpsString, 351 | this._id, (this._newComments).toLocaleString(), (elapsed / 1000).toFixed(1)); 352 | 353 | // Aug 2025: Log indexed counts to debug fetch process ending prematurely. 354 | if (!appending) { 355 | // If over 5% of expected comments are missing, log some data 356 | const diff = this._commentCount - this._indexedComments; 357 | if (diff / this._commentCount > 0.05 && diff > 100) { 358 | this.debugStream.write( 359 | `[${printTimestamp(new Date())}] For video '${this._id}'. ------------------------------------------------\n` + 360 | ` Expected commentCount: ${this._commentCount.toLocaleString()}\n` + 361 | ` Comments indexed: ${this._indexedComments.toLocaleString()}\n` + 362 | ` Comment threads fetched: ${this._newCommentThreads.toLocaleString()}\n` + 363 | ` pageToken used on last request: ${pageToken || 'NONE'}\n` + 364 | ` nextPageToken given on last resp: ${response.data.nextPageToken ?? 'NONE'}\n` + 365 | ` proceed: ${proceed}\n` + 366 | ` last response JSON: ${JSON.stringify(response.data)}\n` 367 | ); 368 | logger.log('warn', "On video %s: only %s of %s comments indexed.", 369 | this._id, this._indexedComments.toLocaleString(), this._commentCount.toLocaleString()); 370 | } 371 | } 372 | 373 | this._app.database.markVideoComplete(this._id, this._video.snippet.title, elapsed, 374 | this._newComments, this._newCommentThreads, appending); 375 | 376 | this._loadComplete = true; // To permit statistics retrieval later 377 | 378 | // Send the first batch of comments 379 | this.requestLoadedComments("dateOldest", 0, config.defaultPageSize, true, undefined, undefined, true); 380 | } 381 | }, (err) => { 382 | const error = err.errors[0]; 383 | logger.log('error', "Comments execute error on %s: %d ('%s') - '%s'", 384 | this._id, err.code, error.reason, error.message); 385 | 386 | // Try the same request up to 10 consecutive times -- we really don't want to abort in a fetch process 387 | if (++consecutiveErrors < 10) { 388 | if (error.reason === "quotaExceeded") { 389 | this._app.ytapi.quotaExceeded(); 390 | this._app.database.abortVideo(this._id); 391 | this._socket.emit("quotaExceeded"); 392 | } 393 | else { 394 | // Retry 395 | setTimeout(() => this.fetchAllComments(pageToken, appending, consecutiveErrors), 100); 396 | } 397 | } 398 | else { 399 | logger.log('warn', "Ending fetch process on %s due to %d consecutive errors.", this._id, consecutiveErrors); 400 | this._app.database.abortVideo(this._id); 401 | } 402 | }); 403 | } 404 | 405 | fetchLinkedComment(idString, parentId, replyId, consecutiveErrors = 0) { 406 | this._app.ytapi.executeSingleComment(parentId).then((response) => { 407 | if (response.data.pageInfo.totalResults) { 408 | // Linked comment found 409 | this._linkedParent = response.data.items[0]; 410 | const videoId = response.data.items[0].snippet.videoId; 411 | let getVideo = (videoId) => this._app.ytapi.executeVideo(videoId); 412 | if (typeof response.data.items[0].snippet.videoId === "undefined") { 413 | // Comment isn't associated with a video; likely on a Discussion page 414 | getVideo = () => Promise.resolve(-1); 415 | } 416 | if (replyId) { 417 | // Fetch the video info & linked reply at the same time 418 | Promise.all([this._app.ytapi.executeSingleReply(replyId), getVideo(videoId)]).then((responses) => { 419 | const videoObject = (responses[1] === -1) ? -1 : responses[1].data.items[0]; 420 | this.handleNewVideo(videoObject); 421 | 422 | if (responses[0].data.items[0]) { 423 | // Send parent comment & linked reply 424 | this.sendLinkedComment(convertComment(this._linkedParent), 425 | convertComment(responses[0].data.items[0], true), videoObject); 426 | } 427 | else { 428 | // Send only parent 429 | this.sendLinkedComment(convertComment(this._linkedParent), null, videoObject); 430 | } 431 | }, (err) => logger.log('error', "Linked reply/video error on replyId %s, video %s: %O", 432 | replyId, videoId, err.response.data.error)); 433 | } 434 | else { 435 | getVideo(videoId).then((res) => { 436 | const videoObject = (res === -1) ? -1 : res.data.items[0]; 437 | this.handleNewVideo(videoObject); 438 | // Send linked comment 439 | this.sendLinkedComment(convertComment(response.data.items[0]), null, videoObject); 440 | }, (err) => logger.log('error', "Linked video error on %s: %O", videoId, err.response.data.error)); 441 | } 442 | } 443 | else { 444 | // Linked comment not found 445 | this.fetchTitle(idString); 446 | } 447 | }, (err) => { 448 | logger.log('error', "Linked comment execute error on %s: %d ('%s') - '%s'", 449 | parentId, err.code, err.errors[0].reason, err.errors[0].message); 450 | 451 | if (++consecutiveErrors < MAX_CONSECUTIVE_ERRORS) { 452 | if (err.errors[0].reason === "quotaExceeded") { 453 | this._app.ytapi.quotaExceeded(); 454 | this._socket.emit("quotaExceeded"); 455 | } 456 | else if (err.errors[0].reason === "processingFailure") { 457 | setTimeout(() => this.fetchLinkedComment(idString, parentId, replyId, consecutiveErrors), 100); 458 | } 459 | } 460 | else { 461 | logger.log('warn', "Giving up fetch of Linked Comment '%s' on video %s due to %d consecutive errors.", 462 | parentId, this._id, consecutiveErrors); 463 | } 464 | }); 465 | } 466 | 467 | sendLinkedComment(parent, reply, video) { 468 | this._socket.emit("linkedComment", {parent: parent, hasReply: (reply !== null), reply: reply, videoObject: video}); 469 | } 470 | 471 | requestLoadedComments(sort, commentIndex, pageSize, broadcast, minDate, maxDate, isFirstBatch = false) { 472 | if (!this._id) return; 473 | 474 | const newSet = commentIndex === 0; 475 | if (!minDate || minDate < 0) { 476 | minDate = 0; 477 | maxDate = 1e13; 478 | } 479 | 480 | // might make this less ugly later 481 | let sortBy = (sort === "likesMost" || sort === "likesLeast") ? "likeCount" : "publishedAt"; 482 | // Including rowid ensures that any comments with identical timestamps will follow their original insertion order. 483 | // This works in 99.99% of cases (as long as said comments were fetched at the same time) 484 | sortBy += (sort === "dateOldest" || sort === "likesLeast") ? " ASC, rowid DESC" : " DESC, rowid ASC"; 485 | 486 | try { 487 | const { rows, subCount, totalCount } = this._app.database.getComments( 488 | this._id, pageSize, commentIndex, sortBy, minDate, maxDate); 489 | 490 | const more = rows.length === pageSize; 491 | const subset = []; 492 | for (const commentThread of rows) { 493 | subset.push({ 494 | id: commentThread.id, 495 | textDisplay: commentThread.textDisplay, 496 | snippet: commentThread.snippet || undefined, 497 | authorDisplayName: commentThread.authorDisplayName, 498 | authorProfileImageUrl: commentThread.authorProfileImageUrl, 499 | authorChannelId: commentThread.authorChannelId, 500 | likeCount: commentThread.likeCount, 501 | publishedAt: commentThread.publishedAt, 502 | updatedAt: commentThread.updatedAt, 503 | totalReplyCount: commentThread.totalReplyCount 504 | }); 505 | } 506 | 507 | const payload = { 508 | reset: newSet, 509 | items: subset, 510 | showMore: more, 511 | subCount: subCount, 512 | totalCount: totalCount, 513 | fullStatsData: null 514 | }; 515 | 516 | // If comment count is small enough, compute statistics and send them with the first batch. 517 | if (isFirstBatch && this._graphAvailable && this._commentCount < 100000) { 518 | this.getStatistics().then((fullStatsData) => { 519 | payload.fullStatsData = fullStatsData; 520 | this.sendLoadedComments(payload, broadcast); 521 | }); 522 | } 523 | else { 524 | // Send first batch without statistics. User can manually request it later 525 | this.sendLoadedComments(payload, broadcast); 526 | } 527 | 528 | } catch (err) { 529 | logger.log('error', "Database getComments error: %O", err); 530 | } 531 | } 532 | 533 | sendLoadedComments(payload, broadcast) { 534 | if (broadcast) { 535 | this._io.to('video-' + this._id).emit("groupComments", payload); 536 | 537 | // Only the first batch is broadcasted. Clear out the socket room 538 | setTimeout(() => { 539 | this._io.of('/').in('video-' + this._id).allSockets().then(clientIds => { 540 | clientIds.forEach((clientId) => this._io.sockets.sockets.get(clientId).leave('video-' + this._id)); 541 | }); 542 | }, 1000); 543 | } 544 | else { 545 | this._socket.emit("groupComments", payload); 546 | } 547 | } 548 | 549 | getReplies(commentId) { 550 | this.fetchReplies(commentId, "", []); 551 | } 552 | 553 | fetchReplies(commentId, pageToken, replies, consecutiveErrors = 0) { 554 | this._app.ytapi.executeReplies(commentId, pageToken).then((response) => { 555 | // After the May 2024 bug, make sure all replies are unique. 556 | const existingReplies = new Set(replies.map((reply) => reply.id)); 557 | let hadDuplicates = false; 558 | response.data.items.forEach((reply) => { 559 | if (existingReplies.has(reply.id)) { 560 | !hadDuplicates && logger.log('warn', "Duplicate reply found on comment %s: %s", commentId, reply.id); 561 | hadDuplicates = true; 562 | } 563 | else { 564 | replies.push(convertComment(reply, true)); 565 | existingReplies.add(reply.id); 566 | } 567 | }); 568 | 569 | const shouldFetchNextPage = ( 570 | response.data.nextPageToken && 571 | response.data.nextPageToken !== pageToken && 572 | !hadDuplicates && 573 | // As a last resort, stop fetching past 1000 replies. 574 | // Comments are technically limited to 500 replies, but there have been some special cases with up to 800. 575 | replies.length < 1000 576 | ); 577 | 578 | if (shouldFetchNextPage) { 579 | // Fetch next batch of replies 580 | setTimeout(() => this.fetchReplies(commentId, response.data.nextPageToken, replies), 0); 581 | } 582 | else { 583 | this.sendReplies(commentId, replies); 584 | } 585 | }, (err) => { 586 | logger.log('error', "Replies execute error on %s: %d ('%s') - '%s'", 587 | this._id, err.code, err.errors[0].reason, err.errors[0].message); 588 | 589 | if (++consecutiveErrors < MAX_CONSECUTIVE_ERRORS) { 590 | if (err.errors[0].reason === "quotaExceeded") { 591 | this._app.ytapi.quotaExceeded(); 592 | this._socket.emit("quotaExceeded"); 593 | } 594 | else if (err.errors[0].reason === "processingFailure") { 595 | setTimeout(() => this.fetchReplies(commentId, pageToken, replies, consecutiveErrors), 100); 596 | } 597 | } 598 | else { 599 | logger.log('warn', "Giving up fetch of REPLIES on comment '%s', on video %s due to %d consecutive errors.", 600 | commentId, this._id, consecutiveErrors); 601 | } 602 | }); 603 | } 604 | 605 | sendReplies(commentId, items) { 606 | this._socket.emit("newReplies", { items: items, id: commentId}); 607 | } 608 | 609 | // Computes statistics for the current video and sends them to the client. 610 | requestStatistics() { 611 | if (this._graphAvailable && this._loadComplete) { 612 | this.getStatistics().then((fullStatsData) => { 613 | this._socket.emit("statsData", fullStatsData); 614 | // Tell compiler to release this memory as soon as possible 615 | fullStatsData = undefined; 616 | }); 617 | } 618 | } 619 | 620 | // Computes statistics for the current video and returns them via Promise. 621 | getStatistics() { 622 | return new Promise((resolve) => { 623 | const stats = this._app.database.getStatistics(this._id); 624 | this.makeGraphArray().then((datesArray) => { 625 | const fullStatsData = [stats, datesArray]; 626 | resolve(fullStatsData); 627 | }); 628 | }); 629 | } 630 | 631 | // Constructs the array of all comments' dates. 632 | makeGraphArray() { 633 | // Form array of all dates 634 | return new Promise((resolve) => { 635 | const rows = this._app.database.getAllDates(this._id); 636 | const dates = new Array(rows.length); 637 | 638 | // Populate dates array in chunks of 10000 to not block the thread 639 | let i = 0; 640 | const processChunk = () => { 641 | let count = 0; 642 | while (count++ < 10000 && i < rows.length) { 643 | dates[i] = rows[i].publishedAt; 644 | i++; 645 | } 646 | if (i < rows.length) { 647 | setTimeout(processChunk, 0); 648 | } 649 | else { 650 | resolve(dates); 651 | } 652 | } 653 | processChunk(); 654 | }); 655 | } 656 | 657 | } 658 | 659 | module.exports = Video; --------------------------------------------------------------------------------