├── 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 | 
11 | 
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 += `
`;
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 |
114 | `;
115 | }
116 |
117 | likeSegment += (item.likeCount)
118 | ? ``
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 |
152 |
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 |
53 |
54 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
121 |
122 |
123 |
124 |
125 |
126 |
131 |
132 |
133 |
134 |
137 |
138 |
139 |
140 | Aggregate by:
141 |
142 | Hour
143 | Day
144 | Month
145 | Year
146 |
147 |
148 |
149 | Drag to zoom, double-click to reset
150 |
151 |
152 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 | Top-level comments
165 |
166 |
167 |
168 | Total likes
169 | 0
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 | Average comments per day
179 | 0
180 |
181 |
182 | Average likes per comment
183 | 0
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 | Load all comments
202 |
203 |
204 |
205 |
206 |
207 |
0.0%
208 |
209 |
213 |
--
214 |
215 |
216 |
217 |
218 |
219 |
220 |
221 |
222 |
223 | Likes
224 | Date (newest first)
225 | Date (oldest first)
226 |
227 |
228 |
229 |
230 |
251 |
252 |
295 |
296 |
297 |
298 |
309 |
310 |
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;
--------------------------------------------------------------------------------
255 | Comments 256 |
257 |288 | 289 |