You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
244 lines
9.1 KiB
JavaScript
244 lines
9.1 KiB
JavaScript
const config_api = require('./config');
|
|
const logger = require('./logger');
|
|
const utils = require('./utils');
|
|
|
|
const moment = require('moment');
|
|
const fs = require('fs-extra')
|
|
const axios = require('axios');
|
|
const { EmoteFetcher } = require('@tzahi12345/twitch-emoticons');
|
|
const path = require('path');
|
|
const { promisify } = require('util');
|
|
const child_process = require('child_process');
|
|
const commandExistsSync = require('command-exists').sync;
|
|
|
|
let auth_timeout = null;
|
|
let cached_oauth = null;
|
|
|
|
exports.ensureTwitchAuth = async () => {
|
|
const TIMEOUT_MARGIN_MS = 60*1000;
|
|
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
|
|
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
|
|
if (cached_oauth && auth_timeout && (Date.now() - TIMEOUT_MARGIN_MS) < auth_timeout) return cached_oauth;
|
|
|
|
const {token, expires_in} = await exports.getTwitchOAuthToken(twitch_client_id, twitch_client_secret);
|
|
cached_oauth = token;
|
|
auth_timeout = Date.now() + expires_in;
|
|
return token;
|
|
}
|
|
|
|
exports.getCommentsForVOD = async (vodId) => {
|
|
const exec = promisify(child_process.exec);
|
|
|
|
// Reject invalid params to prevent command injection attack
|
|
if (!vodId.match(/^[0-9a-z]+$/)) {
|
|
logger.error('VOD ID must be purely alphanumeric. Twitch chat download failed!');
|
|
return null;
|
|
}
|
|
|
|
const is_windows = process.platform === 'win32';
|
|
const cliExt = is_windows ? '.exe' : ''
|
|
const cliPath = `TwitchDownloaderCLI${cliExt}`
|
|
|
|
if (!commandExistsSync(cliPath)) {
|
|
logger.error(`${cliPath} does not exist. Twitch chat download failed! Get it here: https://github.com/lay295/TwitchDownloader`);
|
|
return null;
|
|
}
|
|
|
|
const result = await exec(`${cliPath} chatdownload -u ${vodId} -o appdata/${vodId}.json`, {stdio:[0,1,2]});
|
|
|
|
if (result['stderr']) {
|
|
logger.error(`Failed to download twitch comments for ${vodId}`);
|
|
logger.error(result['stderr']);
|
|
return null;
|
|
}
|
|
|
|
const temp_chat_path = path.join('appdata', `${vodId}.json`);
|
|
|
|
const raw_json = fs.readJSONSync(temp_chat_path);
|
|
const new_json = raw_json.comments.map(comment_obj => {
|
|
return {
|
|
timestamp: comment_obj.content_offset_seconds,
|
|
timestamp_str: convertTimestamp(comment_obj.content_offset_seconds),
|
|
name: comment_obj.commenter.name,
|
|
message: comment_obj.message.body,
|
|
user_color: comment_obj.message.user_color
|
|
}
|
|
});
|
|
|
|
fs.unlinkSync(temp_chat_path);
|
|
|
|
return new_json;
|
|
}
|
|
|
|
exports.getTwitchChatByFileID = async (id, type, user_uid, uuid, sub) => {
|
|
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
|
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
|
let file_path = null;
|
|
|
|
if (user_uid) {
|
|
if (sub) {
|
|
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
|
|
} else {
|
|
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
|
|
}
|
|
} else {
|
|
if (sub) {
|
|
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
|
|
} else {
|
|
const typeFolder = config_api.getConfigItem(`ytdl_${type}_folder_path`);
|
|
file_path = path.join(typeFolder, `${id}.twitch_chat.json`);
|
|
}
|
|
}
|
|
|
|
var chat_file = null;
|
|
if (fs.existsSync(file_path)) {
|
|
chat_file = fs.readJSONSync(file_path);
|
|
}
|
|
|
|
return chat_file;
|
|
}
|
|
|
|
exports.downloadTwitchChatByVODID = async (vodId, id, type, user_uid, sub, customFileFolderPath = null) => {
|
|
const usersFileFolder = config_api.getConfigItem('ytdl_users_base_path');
|
|
const subscriptionsFileFolder = config_api.getConfigItem('ytdl_subscriptions_base_path');
|
|
const chat = await exports.getCommentsForVOD(vodId);
|
|
|
|
// save file if needed params are included
|
|
let file_path = null;
|
|
if (customFileFolderPath) {
|
|
file_path = path.join(customFileFolderPath, `${id}.twitch_chat.json`)
|
|
} else if (user_uid) {
|
|
if (sub) {
|
|
file_path = path.join(usersFileFolder, user_uid, 'subscriptions', sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
|
|
} else {
|
|
file_path = path.join(usersFileFolder, user_uid, type, `${id}.twitch_chat.json`);
|
|
}
|
|
} else {
|
|
if (sub) {
|
|
file_path = path.join(subscriptionsFileFolder, sub.isPlaylist ? 'playlists' : 'channels', sub.name, `${id}.twitch_chat.json`);
|
|
} else {
|
|
file_path = path.join(type, `${id}.twitch_chat.json`);
|
|
}
|
|
}
|
|
|
|
if (chat) fs.writeJSONSync(file_path, chat);
|
|
|
|
return chat;
|
|
}
|
|
|
|
exports.getTwitchEmotes = async (file_uid) => {
|
|
const emotes_path = path.join('appdata', 'emotes', file_uid, 'emotes.json')
|
|
if (!fs.existsSync(emotes_path)) return null;
|
|
const emote_objs = fs.readJSONSync(emotes_path);
|
|
// inject custom url
|
|
for (const emote_obj of emote_objs) {
|
|
emote_obj.custom_url = `${utils.getBaseURL()}/api/emote/${file_uid}/${emote_obj.id}.${emote_obj.ext}`
|
|
}
|
|
return emote_objs;
|
|
}
|
|
|
|
exports.downloadTwitchEmotes = async (channel_name, file_uid) => {
|
|
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
|
|
const twitch_client_secret = config_api.getConfigItem('ytdl_twitch_client_secret');
|
|
|
|
const channel_id = await exports.getChannelID(channel_name);
|
|
|
|
const fetcher = new EmoteFetcher(twitch_client_id, twitch_client_secret);
|
|
|
|
try {
|
|
await Promise.allSettled([
|
|
fetcher.fetchTwitchEmotes(),
|
|
fetcher.fetchTwitchEmotes(channel_id),
|
|
fetcher.fetchBTTVEmotes(),
|
|
fetcher.fetchBTTVEmotes(channel_id),
|
|
fetcher.fetchSevenTVEmotes(),
|
|
fetcher.fetchSevenTVEmotes(channel_id),
|
|
fetcher.fetchFFZEmotes(),
|
|
fetcher.fetchFFZEmotes(channel_id)
|
|
]);
|
|
|
|
const emotes_dir = path.join('appdata', 'emotes', file_uid);
|
|
const emote_json_path = path.join(emotes_dir, `emotes.json`);
|
|
fs.ensureDirSync(emotes_dir);
|
|
|
|
const emote_objs = [];
|
|
let failed_emote_count = 0;
|
|
for (const [, emote] of fetcher.emotes) {
|
|
const emote_obj = emote.toObject();
|
|
|
|
const ext = emote.imageType;
|
|
const emote_image_path = path.join(emotes_dir, `${emote.id}.${ext}`);
|
|
|
|
try {
|
|
const link = emote.toLink();
|
|
if (!fs.existsSync(emote_image_path)) await utils.fetchFile(link, emote_image_path);
|
|
emote_obj['ext'] = ext;
|
|
emote_objs.push(emote_obj);
|
|
} catch (err) {
|
|
failed_emote_count++;
|
|
}
|
|
}
|
|
if (failed_emote_count) logger.warn(`${failed_emote_count} emotes failed to download for channel ${channel_name}`);
|
|
await fs.writeJSON(emote_json_path, emote_objs);
|
|
return emote_objs;
|
|
} catch (err) {
|
|
logger.error(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
exports.getTwitchOAuthToken = async (client_id, client_secret) => {
|
|
logger.verbose('Generating new Twitch auth token');
|
|
const url = `https://id.twitch.tv/oauth2/token`;
|
|
|
|
try {
|
|
const response = await axios.post(url, {client_id: client_id, client_secret: client_secret, grant_type: 'client_credentials'});
|
|
const token = response['data']['access_token'];
|
|
const expires_in = response['data']['expires_in'];
|
|
if (token) return {token, expires_in};
|
|
|
|
logger.error(`Failed to get token.`);
|
|
return null;
|
|
} catch (err) {
|
|
logger.error(`Failed to get token.`);
|
|
logger.error(err);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
exports.getChannelID = async (channel_name) => {
|
|
const twitch_client_id = config_api.getConfigItem('ytdl_twitch_client_id');
|
|
const token = await exports.ensureTwitchAuth();
|
|
const url = `https://api.twitch.tv/helix/users?login=${channel_name}`;
|
|
const headers = {
|
|
'Client-ID': twitch_client_id,
|
|
'Authorization': 'Bearer ' + token,
|
|
// Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8'
|
|
};
|
|
|
|
try {
|
|
const response = await axios.get(url, {headers: headers});
|
|
const data = response.data.data;
|
|
|
|
if (data && data.length > 0) {
|
|
const channelID = data[0].id;
|
|
return channelID;
|
|
}
|
|
|
|
logger.error(`Failed to get channel ID for user ${channel_name}`);
|
|
if (data.error) logger.error(data.error);
|
|
return null; // User not found
|
|
} catch (err) {
|
|
logger.error(`Failed to get channel ID for user ${channel_name}`);
|
|
logger.error(err);
|
|
}
|
|
}
|
|
|
|
const convertTimestamp = (timestamp) => moment.duration(timestamp, 'seconds')
|
|
.toISOString()
|
|
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
|
|
(_, ...ms) => {
|
|
const seg = v => v ? v.padStart(2, '0') : '00';
|
|
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
|
|
});
|