@kaikybrofc/omnizap-system 2.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +534 -0
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/RELEASE-v2.1.2.md +83 -0
- package/app/config/adminIdentity.js +87 -0
- package/app/config/baileysConfig.js +693 -0
- package/app/config/groupUtils.js +388 -0
- package/app/connection/socketController.js +992 -0
- package/app/controllers/messageController.js +354 -0
- package/app/modules/adminModule/groupCommandHandlers.js +1294 -0
- package/app/modules/adminModule/groupEventHandlers.js +355 -0
- package/app/modules/aiModule/catCommand.js +1006 -0
- package/app/modules/broadcastModule/noticeCommand.js +416 -0
- package/app/modules/gameModule/diceCommand.js +67 -0
- package/app/modules/menuModule/common.js +311 -0
- package/app/modules/menuModule/menus.js +59 -0
- package/app/modules/playModule/playCommand.js +1615 -0
- package/app/modules/quoteModule/quoteCommand.js +851 -0
- package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +786 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.js +2082 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.test.js +760 -0
- package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
- package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +172 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
- package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
- package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
- package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1859 -0
- package/app/modules/rpgPokemonModule/rpgPokemonService.js +6738 -0
- package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
- package/app/modules/statsModule/globalRankingCommand.js +65 -0
- package/app/modules/statsModule/noMessageCommand.js +288 -0
- package/app/modules/statsModule/rankingCommand.js +60 -0
- package/app/modules/statsModule/rankingCommon.js +889 -0
- package/app/modules/stickerModule/addStickerMetadata.js +239 -0
- package/app/modules/stickerModule/convertToWebp.js +390 -0
- package/app/modules/stickerModule/stickerCommand.js +454 -0
- package/app/modules/stickerModule/stickerConvertCommand.js +156 -0
- package/app/modules/stickerModule/stickerTextCommand.js +657 -0
- package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
- package/app/modules/stickerPackModule/autoPackCollectorService.js +284 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.js +466 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +88 -0
- package/app/modules/stickerPackModule/semanticThemeClusterService.js +571 -0
- package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +449 -0
- package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +180 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +4078 -0
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +598 -0
- package/app/modules/stickerPackModule/stickerClassificationService.js +588 -0
- package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +102 -0
- package/app/modules/stickerPackModule/stickerPackCatalogHttp.js +7506 -0
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1095 -0
- package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +108 -0
- package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
- package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +110 -0
- package/app/modules/stickerPackModule/stickerPackItemRepository.js +440 -0
- package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +337 -0
- package/app/modules/stickerPackModule/stickerPackMessageService.js +296 -0
- package/app/modules/stickerPackModule/stickerPackRepository.js +442 -0
- package/app/modules/stickerPackModule/stickerPackService.js +788 -0
- package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +51 -0
- package/app/modules/stickerPackModule/stickerPackUtils.js +97 -0
- package/app/modules/stickerPackModule/stickerStorageService.js +507 -0
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +233 -0
- package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +205 -0
- package/app/modules/systemMetricsModule/pingCommand.js +421 -0
- package/app/modules/tiktokModule/tiktokCommand.js +798 -0
- package/app/modules/userModule/userCommand.js +1217 -0
- package/app/modules/waifuPicsModule/waifuPicsCommand.js +177 -0
- package/app/observability/metrics.js +734 -0
- package/app/services/captchaService.js +492 -0
- package/app/services/dbWriteQueue.js +572 -0
- package/app/services/groupMetadataService.js +279 -0
- package/app/services/lidMapService.js +663 -0
- package/app/services/messagePersistenceService.js +56 -0
- package/app/services/newsBroadcastService.js +351 -0
- package/app/services/pokeApiService.js +398 -0
- package/app/services/queueUtils.js +57 -0
- package/app/services/socketState.js +7 -0
- package/app/store/aiPromptStore.js +38 -0
- package/app/store/groupConfigStore.js +58 -0
- package/app/store/premiumUserStore.js +36 -0
- package/app/utils/antiLink/antiLinkModule.js +804 -0
- package/app/utils/http/getImageBufferModule.js +18 -0
- package/app/utils/json/jsonSanitizer.js +113 -0
- package/app/utils/json/jsonSanitizer.test.js +40 -0
- package/app/utils/logger/loggerModule.js +262 -0
- package/app/utils/systemMetrics/systemMetricsModule.js +91 -0
- package/database/index.js +2052 -0
- package/database/init.js +516 -0
- package/database/migrations/20260203_0001_sticker_packs.sql +54 -0
- package/database/migrations/20260210_0003_rpg_pokemon.sql +58 -0
- package/database/migrations/20260210_0004_rpg_shiny_biome.sql +9 -0
- package/database/migrations/20260210_0005_rpg_missions.sql +14 -0
- package/database/migrations/20260210_0006_rpg_world_pokedex_traits.sql +27 -0
- package/database/migrations/20260210_0007_rpg_raid_pvp.sql +56 -0
- package/database/migrations/20260210_0008_rpg_social_system.sql +195 -0
- package/database/migrations/20260211_0009_rpg_social_xp.sql +36 -0
- package/database/migrations/20260222_0010_remove_message_xp.sql +2 -0
- package/database/migrations/20260226_0011_sticker_asset_classification.sql +17 -0
- package/database/migrations/20260226_0012_sticker_pack_engagement.sql +16 -0
- package/database/migrations/20260226_0013_sticker_marketplace_intelligence.sql +19 -0
- package/database/migrations/20260226_0014_sticker_pack_publish_flow.sql +30 -0
- package/database/migrations/20260226_0014_sticker_worker_queues.sql +42 -0
- package/database/migrations/20260226_0015_sticker_auto_pack_curation_integrity.sql +18 -0
- package/database/migrations/20260226_0016_sticker_web_google_auth_persistence.sql +34 -0
- package/database/migrations/20260226_0017_sticker_web_admin_ban.sql +22 -0
- package/database/migrations/20260226_0018_sticker_web_admin_moderator.sql +18 -0
- package/database/migrations/20260227_0019_sticker_classification_v2_signals.sql +12 -0
- package/database/migrations/20260227_0020_semantic_theme_clusters.sql +35 -0
- package/docker-compose.yml +103 -0
- package/ecosystem.prod.config.cjs +35 -0
- package/eslint.config.js +61 -0
- package/index.js +437 -0
- package/ml/clip_classifier/Dockerfile +16 -0
- package/ml/clip_classifier/README.md +120 -0
- package/ml/clip_classifier/adaptive_scoring.py +40 -0
- package/ml/clip_classifier/classifier.py +654 -0
- package/ml/clip_classifier/embedding_store.py +481 -0
- package/ml/clip_classifier/env_loader.py +15 -0
- package/ml/clip_classifier/llm_label_expander.py +144 -0
- package/ml/clip_classifier/main.py +213 -0
- package/ml/clip_classifier/requirements.txt +10 -0
- package/ml/clip_classifier/similarity_engine.py +74 -0
- package/observability/alert-rules.yml +60 -0
- package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
- package/observability/grafana/dashboards/omnizap-overview.json +170 -0
- package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
- package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
- package/observability/loki-config.yml +38 -0
- package/observability/mysql-exporter.cnf +5 -0
- package/observability/mysql-setup.sql +46 -0
- package/observability/prometheus.yml +32 -0
- package/observability/promtail-config.yml +84 -0
- package/package.json +109 -0
- package/public/api-docs/index.html +144 -0
- package/public/css/github-project-panel.css +297 -0
- package/public/css/stickers-admin.css +1272 -0
- package/public/css/styles.css +671 -0
- package/public/index.html +1311 -0
- package/public/js/apps/apiDocsApp.js +310 -0
- package/public/js/apps/createPackApp.js +2069 -0
- package/public/js/apps/homeApp.js +396 -0
- package/public/js/apps/stickersAdminApp.js +1744 -0
- package/public/js/apps/stickersApp.js +4830 -0
- package/public/js/catalog.js +1019 -0
- package/public/js/github-panel/components/CommitList.js +34 -0
- package/public/js/github-panel/components/ErrorState.js +16 -0
- package/public/js/github-panel/components/GithubProjectPanel.js +106 -0
- package/public/js/github-panel/components/ReleaseList.js +38 -0
- package/public/js/github-panel/components/SkeletonPanel.js +22 -0
- package/public/js/github-panel/components/StatCard.js +15 -0
- package/public/js/github-panel/index.js +15 -0
- package/public/js/github-panel/useGithubRepoData.js +154 -0
- package/public/js/github-panel/vendor/react.js +11 -0
- package/public/js/runtime/react-runtime.js +19 -0
- package/public/licenca/index.html +106 -0
- package/public/stickers/admin/index.html +23 -0
- package/public/stickers/create/index.html +47 -0
- package/public/stickers/index.html +48 -0
- package/public/termos-de-uso/index.html +125 -0
- package/scripts/cache-bust.mjs +107 -0
- package/scripts/deploy.sh +458 -0
- package/scripts/github-deploy-notify.mjs +174 -0
- package/scripts/release.sh +129 -0
|
@@ -0,0 +1,1615 @@
|
|
|
1
|
+
import http from 'node:http';
|
|
2
|
+
import https from 'node:https';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
import { Transform } from 'node:stream';
|
|
10
|
+
import { pipeline } from 'node:stream/promises';
|
|
11
|
+
import logger from '../../utils/logger/loggerModule.js';
|
|
12
|
+
import { sendAndStore } from '../../services/messagePersistenceService.js';
|
|
13
|
+
import { getAdminJid } from '../../config/adminIdentity.js';
|
|
14
|
+
|
|
15
|
+
const adminJid = getAdminJid();
|
|
16
|
+
const DEFAULT_COMMAND_PREFIX = process.env.COMMAND_PREFIX || '/';
|
|
17
|
+
const YTDLS_BASE_URL = (
|
|
18
|
+
process.env.YTDLS_BASE_URL ||
|
|
19
|
+
process.env.YT_DLS_BASE_URL ||
|
|
20
|
+
'http://127.0.0.1:3000'
|
|
21
|
+
).replace(/\/$/, '');
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = Number.parseInt(process.env.PLAY_API_TIMEOUT_MS || '900000', 10);
|
|
24
|
+
const DOWNLOAD_API_TIMEOUT_MS = Number.parseInt(
|
|
25
|
+
process.env.PLAY_API_DOWNLOAD_TIMEOUT_MS || '1800000',
|
|
26
|
+
10,
|
|
27
|
+
);
|
|
28
|
+
const QUEUE_STATUS_TIMEOUT_MS = Number.parseInt(
|
|
29
|
+
process.env.PLAY_QUEUE_STATUS_TIMEOUT_MS || '8000',
|
|
30
|
+
10,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const MAX_MEDIA_MB = Number.parseInt(process.env.PLAY_MAX_MB || '100', 10);
|
|
34
|
+
const MAX_MEDIA_BYTES = Number.isFinite(MAX_MEDIA_MB)
|
|
35
|
+
? MAX_MEDIA_MB * 1024 * 1024
|
|
36
|
+
: 100 * 1024 * 1024;
|
|
37
|
+
const MAX_MEDIA_MB_LABEL = Number.isFinite(MAX_MEDIA_MB) ? MAX_MEDIA_MB : 100;
|
|
38
|
+
|
|
39
|
+
const QUICK_QUEUE_LOOKUP_MS = 1500;
|
|
40
|
+
const THUMBNAIL_TIMEOUT_MS = 15000;
|
|
41
|
+
const MAX_THUMB_BYTES = 5 * 1024 * 1024;
|
|
42
|
+
const VIDEO_PROCESS_TIMEOUT_MS = Number.parseInt(
|
|
43
|
+
process.env.PLAY_VIDEO_PROCESS_TIMEOUT_MS || '420000',
|
|
44
|
+
10,
|
|
45
|
+
);
|
|
46
|
+
const VIDEO_FORCE_TRANSCODE =
|
|
47
|
+
String(process.env.PLAY_VIDEO_FORCE_TRANSCODE || 'true').toLowerCase() !== 'false';
|
|
48
|
+
const FFMPEG_BIN = (process.env.FFMPEG_PATH || 'ffmpeg').trim();
|
|
49
|
+
const FFPROBE_BIN = (process.env.FFPROBE_PATH || 'ffprobe').trim();
|
|
50
|
+
const SEARCH_CACHE_TTL_MS = 60 * 1000;
|
|
51
|
+
const MAX_SEARCH_CACHE_ENTRIES = 500;
|
|
52
|
+
const MAX_REDIRECTS = 2;
|
|
53
|
+
const MAX_ERROR_BODY_BYTES = 64 * 1024;
|
|
54
|
+
const MAX_META_BODY_CHARS = 512;
|
|
55
|
+
|
|
56
|
+
const TRANSIENT_HTTP_STATUSES = new Set([502, 503, 504]);
|
|
57
|
+
const TRANSIENT_NETWORK_CODES = new Set(['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN']);
|
|
58
|
+
|
|
59
|
+
const YTDLS_ENDPOINTS = {
|
|
60
|
+
search: '/search',
|
|
61
|
+
queueStatus: '/download/queue-status',
|
|
62
|
+
download: '/download',
|
|
63
|
+
thumbnail: 'thumbnail',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const ERROR_CODES = {
|
|
67
|
+
INVALID_INPUT: 'EINVALID_INPUT',
|
|
68
|
+
API: 'EAPI',
|
|
69
|
+
TIMEOUT: 'ETIMEOUT',
|
|
70
|
+
TOO_BIG: 'ETOOBIG',
|
|
71
|
+
NOT_FOUND: 'ENOTFOUND',
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const KNOWN_ERROR_CODES = new Set(Object.values(ERROR_CODES));
|
|
75
|
+
|
|
76
|
+
const TYPE_CONFIG = {
|
|
77
|
+
audio: {
|
|
78
|
+
waitText: '⏳ Processando sua mídia...',
|
|
79
|
+
queueWaitText: '⏳ Processando...',
|
|
80
|
+
readyTitle: '🎵 Áudio pronto!',
|
|
81
|
+
mimeFallback: 'audio/mpeg',
|
|
82
|
+
},
|
|
83
|
+
video: {
|
|
84
|
+
waitText: '⏳ Processando sua mídia...',
|
|
85
|
+
queueWaitText: '⏳ Processando...',
|
|
86
|
+
readyTitle: '🎬 Vídeo pronto!',
|
|
87
|
+
mimeFallback: 'video/mp4',
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const createError = (code, message, meta) => {
|
|
92
|
+
const error = new Error(message);
|
|
93
|
+
error.code = code;
|
|
94
|
+
if (meta) error.meta = meta;
|
|
95
|
+
return error;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const withErrorMeta = (error, meta) => {
|
|
99
|
+
if (!error || typeof error !== 'object') return error;
|
|
100
|
+
error.meta = {
|
|
101
|
+
...(error.meta || {}),
|
|
102
|
+
...(meta || {}),
|
|
103
|
+
};
|
|
104
|
+
return error;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const isAbortError = (error) =>
|
|
108
|
+
error?.name === 'AbortError' || error?.code === 'ABORT_ERR' || error?.code === 'ECONNABORTED';
|
|
109
|
+
|
|
110
|
+
const normalizeRequestError = (error, { timeoutMessage, fallbackMessage, fallbackCode }) => {
|
|
111
|
+
if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
|
|
112
|
+
if (isAbortError(error)) {
|
|
113
|
+
return createError(ERROR_CODES.TIMEOUT, timeoutMessage, {
|
|
114
|
+
rawCode: error?.code || error?.name || null,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return createError(fallbackCode || ERROR_CODES.API, fallbackMessage, {
|
|
118
|
+
cause: error?.message || 'unknown',
|
|
119
|
+
rawCode: error?.code || error?.name || null,
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const normalizePlayError = (error) => {
|
|
124
|
+
if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
|
|
125
|
+
if (isAbortError(error)) {
|
|
126
|
+
return createError(ERROR_CODES.TIMEOUT, 'Timeout ao processar sua solicitação.', {
|
|
127
|
+
rawCode: error?.code || error?.name || null,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return createError(ERROR_CODES.API, 'Erro inesperado ao processar sua solicitação.', {
|
|
131
|
+
cause: error?.message || 'unknown',
|
|
132
|
+
rawCode: error?.code || error?.name || null,
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(() => resolve(null), ms));
|
|
137
|
+
|
|
138
|
+
const truncateText = (value, maxChars = MAX_META_BODY_CHARS) => {
|
|
139
|
+
if (typeof value !== 'string') return '';
|
|
140
|
+
if (value.length <= maxChars) return value;
|
|
141
|
+
return `${value.slice(0, maxChars)}...[truncated]`;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const toNumberOrNull = (value) => {
|
|
145
|
+
if (value === null || value === undefined || value === '') return null;
|
|
146
|
+
const number = Number(value);
|
|
147
|
+
return Number.isFinite(number) ? number : null;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const pickFirstString = (source, keys) => {
|
|
151
|
+
if (!source || typeof source !== 'object') return null;
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const raw = source[key];
|
|
154
|
+
if (typeof raw === 'string' && raw.trim()) return raw.trim();
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const ensureHttpUrl = (value) => {
|
|
160
|
+
if (!value || typeof value !== 'string') return null;
|
|
161
|
+
try {
|
|
162
|
+
const url = new URL(value.trim());
|
|
163
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') return url.toString();
|
|
164
|
+
return null;
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const formatNumber = (value) => {
|
|
171
|
+
const number = toNumberOrNull(value);
|
|
172
|
+
if (number === null) return null;
|
|
173
|
+
return number.toLocaleString('pt-BR');
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const formatDuration = (value) => {
|
|
177
|
+
if (value === null || value === undefined) return null;
|
|
178
|
+
const number = toNumberOrNull(value);
|
|
179
|
+
if (number !== null) {
|
|
180
|
+
const totalSeconds = Math.max(0, Math.floor(number));
|
|
181
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
182
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
183
|
+
const seconds = totalSeconds % 60;
|
|
184
|
+
if (hours > 0) {
|
|
185
|
+
return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
186
|
+
}
|
|
187
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
188
|
+
}
|
|
189
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const formatVideoInfo = (videoInfo) => {
|
|
194
|
+
if (!videoInfo || typeof videoInfo !== 'object') return null;
|
|
195
|
+
const lines = [];
|
|
196
|
+
const title = pickFirstString(videoInfo, ['title', 'titulo', 'name']);
|
|
197
|
+
if (title) lines.push(`🎧 ${title}`);
|
|
198
|
+
const channel = pickFirstString(videoInfo, ['channel', 'uploader', 'uploader_name', 'author']);
|
|
199
|
+
if (channel) lines.push(`📺 ${channel}`);
|
|
200
|
+
const duration = formatDuration(videoInfo.duration);
|
|
201
|
+
if (duration) lines.push(`⏱ ${duration}`);
|
|
202
|
+
const id = pickFirstString(videoInfo, ['id', 'videoId', 'video_id']);
|
|
203
|
+
if (id) lines.push(`🆔 ${id}`);
|
|
204
|
+
return lines.length ? lines.join('\n') : null;
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const getThumbnailUrl = (videoInfo) => {
|
|
208
|
+
if (!videoInfo || typeof videoInfo !== 'object') return null;
|
|
209
|
+
|
|
210
|
+
const direct = pickFirstString(videoInfo, [
|
|
211
|
+
'thumbnail',
|
|
212
|
+
'thumb',
|
|
213
|
+
'thumbnail_url',
|
|
214
|
+
'thumbnailUrl',
|
|
215
|
+
'thumb_url',
|
|
216
|
+
'image',
|
|
217
|
+
'cover',
|
|
218
|
+
'artwork',
|
|
219
|
+
]);
|
|
220
|
+
const directUrl = ensureHttpUrl(direct);
|
|
221
|
+
if (directUrl) return directUrl;
|
|
222
|
+
|
|
223
|
+
const objectThumb = videoInfo.thumbnail;
|
|
224
|
+
if (objectThumb && typeof objectThumb === 'object') {
|
|
225
|
+
const objectUrl = ensureHttpUrl(objectThumb.url || objectThumb.src);
|
|
226
|
+
if (objectUrl) return objectUrl;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (Array.isArray(videoInfo.thumbnails)) {
|
|
230
|
+
for (const thumb of videoInfo.thumbnails) {
|
|
231
|
+
const thumbUrl = ensureHttpUrl(thumb?.url || thumb?.src);
|
|
232
|
+
if (thumbUrl) return thumbUrl;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return null;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const buildQueueStatusText = (status) => {
|
|
240
|
+
if (!status?.fila) return null;
|
|
241
|
+
|
|
242
|
+
const fila = status.fila;
|
|
243
|
+
const downloadsAhead = toNumberOrNull(fila.downloads_a_frente);
|
|
244
|
+
const position = toNumberOrNull(fila.posicao_na_fila);
|
|
245
|
+
const totalQueued = toNumberOrNull(fila.enfileirados);
|
|
246
|
+
|
|
247
|
+
if (downloadsAhead === null && position === null && totalQueued === null) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const lines = [];
|
|
252
|
+
if (position !== null) lines.push(`📍 Posição na fila: ${position}`);
|
|
253
|
+
if (downloadsAhead !== null) lines.push(`🚀 Downloads à frente: ${downloadsAhead}`);
|
|
254
|
+
if (!lines.length && totalQueued !== null) lines.push(`📦 Itens na fila: ${totalQueued}`);
|
|
255
|
+
|
|
256
|
+
return lines.join('\n');
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const buildReadyCaption = (type, infoText) => {
|
|
260
|
+
const config = TYPE_CONFIG[type];
|
|
261
|
+
if (!config) return infoText || '';
|
|
262
|
+
if (!infoText) return config.readyTitle;
|
|
263
|
+
return `${config.readyTitle}\n──────────────\n${infoText}`;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const buildTempFilePath = (requestId, type) => {
|
|
267
|
+
const safeId = String(requestId || 'req')
|
|
268
|
+
.replace(/[^a-z0-9-_]+/gi, '')
|
|
269
|
+
.slice(0, 48);
|
|
270
|
+
const ext = type === 'audio' ? 'mp3' : 'mp4';
|
|
271
|
+
return path.join(os.tmpdir(), `play-${safeId}-${Date.now()}.${ext}`);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const safeUnlink = async (filePath) => {
|
|
275
|
+
if (!filePath) return;
|
|
276
|
+
try {
|
|
277
|
+
await fs.promises.unlink(filePath);
|
|
278
|
+
} catch {
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const createAbortSignal = (timeoutMs) => {
|
|
284
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
285
|
+
return { signal: undefined, cleanup: () => {} };
|
|
286
|
+
}
|
|
287
|
+
const controller = new globalThis.AbortController();
|
|
288
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
289
|
+
return {
|
|
290
|
+
signal: controller.signal,
|
|
291
|
+
cleanup: () => clearTimeout(timeoutId),
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const normalizeHeaderValue = (value) => {
|
|
296
|
+
if (Array.isArray(value)) return value[0];
|
|
297
|
+
return value;
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
const getHeaderValue = (headers, key) => {
|
|
301
|
+
if (!headers || typeof headers !== 'object') return undefined;
|
|
302
|
+
const lowerKey = key.toLowerCase();
|
|
303
|
+
const raw = headers[lowerKey] ?? headers[key] ?? headers[key.toUpperCase()];
|
|
304
|
+
return normalizeHeaderValue(raw);
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const hasHeader = (headers, name) =>
|
|
308
|
+
headers && typeof headers === 'object'
|
|
309
|
+
? Object.keys(headers).some((headerName) => headerName.toLowerCase() === name.toLowerCase())
|
|
310
|
+
: false;
|
|
311
|
+
|
|
312
|
+
const normalizeMimeType = (value) => {
|
|
313
|
+
if (typeof value !== 'string' || !value.trim()) return null;
|
|
314
|
+
const mime = value.split(';', 1)[0]?.trim().toLowerCase();
|
|
315
|
+
return mime || null;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
const resolveMediaMimeType = (type, contentType) => {
|
|
319
|
+
const normalized = normalizeMimeType(contentType);
|
|
320
|
+
|
|
321
|
+
if (type === 'audio') {
|
|
322
|
+
return normalized && normalized.startsWith('audio/')
|
|
323
|
+
? normalized
|
|
324
|
+
: TYPE_CONFIG.audio.mimeFallback;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (type === 'video') {
|
|
328
|
+
return normalized && normalized.startsWith('video/')
|
|
329
|
+
? normalized
|
|
330
|
+
: TYPE_CONFIG.video.mimeFallback;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return normalized || 'application/octet-stream';
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const runBinaryCommand = (command, args, { timeoutMs = VIDEO_PROCESS_TIMEOUT_MS } = {}) =>
|
|
337
|
+
new Promise((resolve, reject) => {
|
|
338
|
+
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
339
|
+
const stdoutChunks = [];
|
|
340
|
+
const stderrChunks = [];
|
|
341
|
+
let stdoutBytes = 0;
|
|
342
|
+
let stderrBytes = 0;
|
|
343
|
+
let timedOut = false;
|
|
344
|
+
const maxCapturedBytes = MAX_ERROR_BODY_BYTES * 4;
|
|
345
|
+
|
|
346
|
+
const appendChunk = (chunks, chunk, bytes) => {
|
|
347
|
+
if (!chunk || bytes >= maxCapturedBytes) return bytes;
|
|
348
|
+
const current = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
349
|
+
const remaining = Math.max(0, maxCapturedBytes - bytes);
|
|
350
|
+
if (remaining <= 0) return bytes;
|
|
351
|
+
const accepted = current.length <= remaining ? current : current.subarray(0, remaining);
|
|
352
|
+
chunks.push(accepted);
|
|
353
|
+
return bytes + accepted.length;
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
child.stdout.on('data', (chunk) => {
|
|
357
|
+
stdoutBytes = appendChunk(stdoutChunks, chunk, stdoutBytes);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
child.stderr.on('data', (chunk) => {
|
|
361
|
+
stderrBytes = appendChunk(stderrChunks, chunk, stderrBytes);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const timeoutId =
|
|
365
|
+
Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
366
|
+
? setTimeout(() => {
|
|
367
|
+
timedOut = true;
|
|
368
|
+
child.kill('SIGKILL');
|
|
369
|
+
}, timeoutMs)
|
|
370
|
+
: null;
|
|
371
|
+
let settled = false;
|
|
372
|
+
|
|
373
|
+
const finalize = (handler) => {
|
|
374
|
+
if (settled) return;
|
|
375
|
+
settled = true;
|
|
376
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
377
|
+
handler();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
child.on('error', (error) => {
|
|
381
|
+
finalize(() => reject(error));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
child.on('close', (code, signal) => {
|
|
385
|
+
finalize(() => {
|
|
386
|
+
const stdout = Buffer.concat(stdoutChunks, stdoutBytes).toString('utf-8').trim();
|
|
387
|
+
const stderr = Buffer.concat(stderrChunks, stderrBytes).toString('utf-8').trim();
|
|
388
|
+
|
|
389
|
+
if (!timedOut && code === 0) {
|
|
390
|
+
resolve({ stdout, stderr });
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const error = new Error(stderr || `Falha ao executar ${path.basename(command)}.`);
|
|
395
|
+
error.code = timedOut ? 'ETIMEDOUT' : 'EPROCESS';
|
|
396
|
+
error.exitCode = code;
|
|
397
|
+
error.signal = signal || null;
|
|
398
|
+
error.stderr = stderr;
|
|
399
|
+
error.stdout = stdout;
|
|
400
|
+
reject(error);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const normalizeBinaryError = (
|
|
406
|
+
error,
|
|
407
|
+
{ timeoutMessage, fallbackMessage, endpoint, requestId, command, outputPath },
|
|
408
|
+
) => {
|
|
409
|
+
if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
|
|
410
|
+
if (error?.code === 'ETIMEDOUT') {
|
|
411
|
+
return createError(ERROR_CODES.TIMEOUT, timeoutMessage, {
|
|
412
|
+
endpoint,
|
|
413
|
+
requestId,
|
|
414
|
+
command,
|
|
415
|
+
rawCode: error?.code || null,
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
return createError(ERROR_CODES.API, fallbackMessage, {
|
|
419
|
+
endpoint,
|
|
420
|
+
requestId,
|
|
421
|
+
command,
|
|
422
|
+
outputPath: outputPath || null,
|
|
423
|
+
rawCode: error?.code || null,
|
|
424
|
+
exitCode: error?.exitCode ?? null,
|
|
425
|
+
signal: error?.signal || null,
|
|
426
|
+
cause: truncateText(error?.stderr || error?.message || 'unknown'),
|
|
427
|
+
});
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const probeVideoStreams = async (filePath, requestId, endpoint) => {
|
|
431
|
+
try {
|
|
432
|
+
const result = await runBinaryCommand(FFPROBE_BIN, [
|
|
433
|
+
'-v',
|
|
434
|
+
'error',
|
|
435
|
+
'-print_format',
|
|
436
|
+
'json',
|
|
437
|
+
'-show_streams',
|
|
438
|
+
filePath,
|
|
439
|
+
]);
|
|
440
|
+
const parsed = JSON.parse(result.stdout || '{}');
|
|
441
|
+
const streams = Array.isArray(parsed?.streams) ? parsed.streams : [];
|
|
442
|
+
const videoStream = streams.find((stream) => stream?.codec_type === 'video') || null;
|
|
443
|
+
const audioStream = streams.find((stream) => stream?.codec_type === 'audio') || null;
|
|
444
|
+
|
|
445
|
+
return {
|
|
446
|
+
hasVideo: Boolean(videoStream),
|
|
447
|
+
hasAudio: Boolean(audioStream),
|
|
448
|
+
videoCodec: videoStream?.codec_name || null,
|
|
449
|
+
audioCodec: audioStream?.codec_name || null,
|
|
450
|
+
};
|
|
451
|
+
} catch (error) {
|
|
452
|
+
const normalized = normalizeBinaryError(error, {
|
|
453
|
+
timeoutMessage: 'Timeout ao analisar o vídeo recebido.',
|
|
454
|
+
fallbackMessage: 'Falha ao validar o vídeo recebido.',
|
|
455
|
+
endpoint,
|
|
456
|
+
requestId,
|
|
457
|
+
command: FFPROBE_BIN,
|
|
458
|
+
});
|
|
459
|
+
throw normalized;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const transcodeVideoForWhatsapp = async (filePath, requestId, endpoint) => {
|
|
464
|
+
const outputPath = `${filePath}.wa.mp4`;
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
await safeUnlink(outputPath);
|
|
468
|
+
|
|
469
|
+
await runBinaryCommand(
|
|
470
|
+
FFMPEG_BIN,
|
|
471
|
+
[
|
|
472
|
+
'-y',
|
|
473
|
+
'-i',
|
|
474
|
+
filePath,
|
|
475
|
+
'-map',
|
|
476
|
+
'0:v:0',
|
|
477
|
+
'-map',
|
|
478
|
+
'0:a:0?',
|
|
479
|
+
'-c:v',
|
|
480
|
+
'libx264',
|
|
481
|
+
'-preset',
|
|
482
|
+
'veryfast',
|
|
483
|
+
'-pix_fmt',
|
|
484
|
+
'yuv420p',
|
|
485
|
+
'-movflags',
|
|
486
|
+
'+faststart',
|
|
487
|
+
'-c:a',
|
|
488
|
+
'aac',
|
|
489
|
+
'-b:a',
|
|
490
|
+
'128k',
|
|
491
|
+
'-ar',
|
|
492
|
+
'44100',
|
|
493
|
+
'-ac',
|
|
494
|
+
'2',
|
|
495
|
+
outputPath,
|
|
496
|
+
],
|
|
497
|
+
{ timeoutMs: VIDEO_PROCESS_TIMEOUT_MS },
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const stats = await fs.promises.stat(outputPath);
|
|
501
|
+
const transcodedBytes = Number(stats?.size || 0);
|
|
502
|
+
|
|
503
|
+
if (transcodedBytes <= 0) {
|
|
504
|
+
throw createError(ERROR_CODES.API, 'Falha ao gerar vídeo compatível para envio.', {
|
|
505
|
+
endpoint,
|
|
506
|
+
requestId,
|
|
507
|
+
outputPath,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (transcodedBytes > MAX_MEDIA_BYTES) {
|
|
512
|
+
throw createError(
|
|
513
|
+
ERROR_CODES.TOO_BIG,
|
|
514
|
+
`O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`,
|
|
515
|
+
{
|
|
516
|
+
endpoint,
|
|
517
|
+
requestId,
|
|
518
|
+
bytes: transcodedBytes,
|
|
519
|
+
},
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
await fs.promises.rename(outputPath, filePath);
|
|
524
|
+
return transcodedBytes;
|
|
525
|
+
} catch (error) {
|
|
526
|
+
await safeUnlink(outputPath);
|
|
527
|
+
const normalized = normalizeBinaryError(error, {
|
|
528
|
+
timeoutMessage: 'Timeout ao normalizar o vídeo para envio.',
|
|
529
|
+
fallbackMessage: 'Falha ao converter o vídeo para um formato compatível.',
|
|
530
|
+
endpoint,
|
|
531
|
+
requestId,
|
|
532
|
+
command: FFMPEG_BIN,
|
|
533
|
+
outputPath,
|
|
534
|
+
});
|
|
535
|
+
throw normalized;
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const resolveHttpModule = (urlObj) => (urlObj.protocol === 'https:' ? https : http);
|
|
540
|
+
|
|
541
|
+
const shouldFollowRedirect = (status, location, redirectCount, maxRedirects) =>
|
|
542
|
+
status >= 300 && status < 400 && Boolean(location) && redirectCount < maxRedirects;
|
|
543
|
+
|
|
544
|
+
const preparePayload = (body, headers) => {
|
|
545
|
+
if (body === null || body === undefined) return null;
|
|
546
|
+
|
|
547
|
+
if (Buffer.isBuffer(body) || typeof body === 'string') {
|
|
548
|
+
headers['Content-Length'] = Buffer.byteLength(body);
|
|
549
|
+
return body;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const json = JSON.stringify(body);
|
|
553
|
+
if (!hasHeader(headers, 'Content-Type')) {
|
|
554
|
+
headers['Content-Type'] = 'application/json';
|
|
555
|
+
}
|
|
556
|
+
headers['Content-Length'] = Buffer.byteLength(json);
|
|
557
|
+
return json;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const readResponseBuffer = async (stream, { maxBytes = Infinity, tooBigMessage } = {}) => {
|
|
561
|
+
const chunks = [];
|
|
562
|
+
let total = 0;
|
|
563
|
+
|
|
564
|
+
for await (const chunk of stream) {
|
|
565
|
+
const current = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
566
|
+
total += current.length;
|
|
567
|
+
|
|
568
|
+
if (Number.isFinite(maxBytes) && total > maxBytes) {
|
|
569
|
+
stream.destroy();
|
|
570
|
+
throw createError(
|
|
571
|
+
ERROR_CODES.TOO_BIG,
|
|
572
|
+
tooBigMessage || 'Conteúdo excede o limite permitido.',
|
|
573
|
+
{ bytes: total },
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
chunks.push(current);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return Buffer.concat(chunks, total);
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const readResponseText = async (stream, maxBytes = MAX_ERROR_BODY_BYTES) => {
|
|
584
|
+
const chunks = [];
|
|
585
|
+
let total = 0;
|
|
586
|
+
let truncated = false;
|
|
587
|
+
|
|
588
|
+
for await (const chunk of stream) {
|
|
589
|
+
const current = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
590
|
+
|
|
591
|
+
if (total >= maxBytes) {
|
|
592
|
+
truncated = true;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (total + current.length > maxBytes) {
|
|
597
|
+
const remaining = Math.max(0, maxBytes - total);
|
|
598
|
+
if (remaining > 0) {
|
|
599
|
+
chunks.push(current.subarray(0, remaining));
|
|
600
|
+
total += remaining;
|
|
601
|
+
}
|
|
602
|
+
truncated = true;
|
|
603
|
+
continue;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
chunks.push(current);
|
|
607
|
+
total += current.length;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const text = Buffer.concat(chunks, total).toString('utf-8').trim();
|
|
611
|
+
return truncated ? `${text}...[truncated]` : text;
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const buildApiErrorFromResponse = ({ status, bodyText, defaultMessage, endpoint }) => {
|
|
615
|
+
let message = defaultMessage;
|
|
616
|
+
|
|
617
|
+
if (bodyText) {
|
|
618
|
+
try {
|
|
619
|
+
const parsed = JSON.parse(bodyText);
|
|
620
|
+
if (typeof parsed?.mensagem === 'string' && parsed.mensagem.trim()) {
|
|
621
|
+
message = parsed.mensagem.trim();
|
|
622
|
+
}
|
|
623
|
+
} catch {
|
|
624
|
+
void bodyText;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return createError(ERROR_CODES.API, message, {
|
|
629
|
+
endpoint,
|
|
630
|
+
status,
|
|
631
|
+
body: truncateText(bodyText),
|
|
632
|
+
});
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
const createByteLimitTransform = (maxBytes, tooBigMessage) => {
|
|
636
|
+
let bytes = 0;
|
|
637
|
+
const limiter = new Transform({
|
|
638
|
+
transform(chunk, _encoding, callback) {
|
|
639
|
+
bytes += chunk.length;
|
|
640
|
+
if (bytes > maxBytes) {
|
|
641
|
+
callback(
|
|
642
|
+
createError(ERROR_CODES.TOO_BIG, tooBigMessage, {
|
|
643
|
+
bytes,
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
callback(null, chunk);
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
stream: limiter,
|
|
654
|
+
getBytes: () => bytes,
|
|
655
|
+
};
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const httpRequest = ({
|
|
659
|
+
method,
|
|
660
|
+
url,
|
|
661
|
+
headers = {},
|
|
662
|
+
body = null,
|
|
663
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
664
|
+
maxRedirects = 0,
|
|
665
|
+
redirectCount = 0,
|
|
666
|
+
endpoint = 'unknown',
|
|
667
|
+
timeoutMessage = 'Timeout ao comunicar com a API yt-dls.',
|
|
668
|
+
fallbackMessage = 'Falha ao comunicar com a API yt-dls.',
|
|
669
|
+
onResponse,
|
|
670
|
+
}) =>
|
|
671
|
+
new Promise((resolve, reject) => {
|
|
672
|
+
const urlObj = new URL(url);
|
|
673
|
+
const requestHeaders = { ...headers };
|
|
674
|
+
const payload = preparePayload(body, requestHeaders);
|
|
675
|
+
const httpModule = resolveHttpModule(urlObj);
|
|
676
|
+
const { signal, cleanup } = createAbortSignal(timeoutMs);
|
|
677
|
+
|
|
678
|
+
let settled = false;
|
|
679
|
+
const settle = (fn) => {
|
|
680
|
+
if (settled) return;
|
|
681
|
+
settled = true;
|
|
682
|
+
cleanup();
|
|
683
|
+
fn();
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const settleResolve = (value) => settle(() => resolve(value));
|
|
687
|
+
const settleReject = (error) => settle(() => reject(error));
|
|
688
|
+
|
|
689
|
+
const req = httpModule.request(
|
|
690
|
+
urlObj,
|
|
691
|
+
{
|
|
692
|
+
method,
|
|
693
|
+
headers: requestHeaders,
|
|
694
|
+
signal,
|
|
695
|
+
},
|
|
696
|
+
(res) => {
|
|
697
|
+
const status = res.statusCode || 0;
|
|
698
|
+
const location = getHeaderValue(res.headers, 'location');
|
|
699
|
+
res.on('error', (error) => {
|
|
700
|
+
const normalized = normalizeRequestError(error, {
|
|
701
|
+
timeoutMessage,
|
|
702
|
+
fallbackMessage,
|
|
703
|
+
});
|
|
704
|
+
settleReject(withErrorMeta(normalized, { endpoint, status }));
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
if (shouldFollowRedirect(status, location, redirectCount, maxRedirects)) {
|
|
708
|
+
logger.debug('HTTP redirect.', {
|
|
709
|
+
endpoint,
|
|
710
|
+
status,
|
|
711
|
+
location: String(location),
|
|
712
|
+
redirectCount: redirectCount + 1,
|
|
713
|
+
});
|
|
714
|
+
const nextUrl = new URL(String(location), urlObj).toString();
|
|
715
|
+
res.resume();
|
|
716
|
+
settleResolve(
|
|
717
|
+
httpRequest({
|
|
718
|
+
method,
|
|
719
|
+
url: nextUrl,
|
|
720
|
+
headers,
|
|
721
|
+
body,
|
|
722
|
+
timeoutMs,
|
|
723
|
+
maxRedirects,
|
|
724
|
+
redirectCount: redirectCount + 1,
|
|
725
|
+
endpoint,
|
|
726
|
+
timeoutMessage,
|
|
727
|
+
fallbackMessage,
|
|
728
|
+
onResponse,
|
|
729
|
+
}),
|
|
730
|
+
);
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
Promise.resolve(
|
|
735
|
+
onResponse({
|
|
736
|
+
res,
|
|
737
|
+
status,
|
|
738
|
+
headers: res.headers,
|
|
739
|
+
endpoint,
|
|
740
|
+
finalUrl: urlObj.toString(),
|
|
741
|
+
}),
|
|
742
|
+
)
|
|
743
|
+
.then(settleResolve)
|
|
744
|
+
.catch((error) => {
|
|
745
|
+
const normalized = normalizeRequestError(error, {
|
|
746
|
+
timeoutMessage,
|
|
747
|
+
fallbackMessage,
|
|
748
|
+
});
|
|
749
|
+
settleReject(withErrorMeta(normalized, { endpoint, status }));
|
|
750
|
+
});
|
|
751
|
+
},
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
req.on('error', (error) => {
|
|
755
|
+
const normalized = normalizeRequestError(error, {
|
|
756
|
+
timeoutMessage,
|
|
757
|
+
fallbackMessage,
|
|
758
|
+
});
|
|
759
|
+
settleReject(withErrorMeta(normalized, { endpoint }));
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
if (payload) req.write(payload);
|
|
763
|
+
req.end();
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
const requestJson = async ({ method, url, body = null, timeoutMs = DEFAULT_TIMEOUT_MS, endpoint }) =>
|
|
767
|
+
httpRequest({
|
|
768
|
+
method,
|
|
769
|
+
url,
|
|
770
|
+
body,
|
|
771
|
+
timeoutMs,
|
|
772
|
+
endpoint,
|
|
773
|
+
headers: { Accept: 'application/json' },
|
|
774
|
+
timeoutMessage: 'Timeout ao comunicar com a API yt-dls.',
|
|
775
|
+
fallbackMessage: 'Falha ao comunicar com a API yt-dls.',
|
|
776
|
+
onResponse: async ({ res, status, endpoint: currentEndpoint }) => {
|
|
777
|
+
const raw = await readResponseText(res);
|
|
778
|
+
|
|
779
|
+
if (status < 200 || status >= 300) {
|
|
780
|
+
throw buildApiErrorFromResponse({
|
|
781
|
+
status,
|
|
782
|
+
bodyText: raw,
|
|
783
|
+
defaultMessage: 'Falha na API yt-dls.',
|
|
784
|
+
endpoint: currentEndpoint,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (!raw) return {};
|
|
789
|
+
|
|
790
|
+
try {
|
|
791
|
+
return JSON.parse(raw);
|
|
792
|
+
} catch {
|
|
793
|
+
throw createError(ERROR_CODES.API, 'Resposta inválida da API yt-dls.', {
|
|
794
|
+
endpoint: currentEndpoint,
|
|
795
|
+
status,
|
|
796
|
+
body: truncateText(raw),
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const requestBuffer = async ({
|
|
803
|
+
url,
|
|
804
|
+
timeoutMs = THUMBNAIL_TIMEOUT_MS,
|
|
805
|
+
maxBytes = MAX_THUMB_BYTES,
|
|
806
|
+
endpoint = YTDLS_ENDPOINTS.thumbnail,
|
|
807
|
+
}) =>
|
|
808
|
+
httpRequest({
|
|
809
|
+
method: 'GET',
|
|
810
|
+
url,
|
|
811
|
+
timeoutMs,
|
|
812
|
+
endpoint,
|
|
813
|
+
maxRedirects: MAX_REDIRECTS,
|
|
814
|
+
headers: { Accept: 'image/*' },
|
|
815
|
+
timeoutMessage: 'Timeout ao baixar a thumbnail.',
|
|
816
|
+
fallbackMessage: 'Falha ao baixar a thumbnail.',
|
|
817
|
+
onResponse: async ({ res, status, headers, endpoint: currentEndpoint }) => {
|
|
818
|
+
if (status < 200 || status >= 300) {
|
|
819
|
+
res.resume();
|
|
820
|
+
throw createError(ERROR_CODES.API, 'Falha ao baixar a thumbnail.', {
|
|
821
|
+
endpoint: currentEndpoint,
|
|
822
|
+
status,
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const contentLength = toNumberOrNull(getHeaderValue(headers, 'content-length'));
|
|
827
|
+
if (contentLength !== null && contentLength > maxBytes) {
|
|
828
|
+
res.resume();
|
|
829
|
+
throw createError(ERROR_CODES.TOO_BIG, 'Thumbnail excede o limite permitido.', {
|
|
830
|
+
endpoint: currentEndpoint,
|
|
831
|
+
status,
|
|
832
|
+
bytes: contentLength,
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return readResponseBuffer(res, {
|
|
837
|
+
maxBytes,
|
|
838
|
+
tooBigMessage: 'Thumbnail excede o limite permitido.',
|
|
839
|
+
});
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
const httpClient = {
|
|
844
|
+
request: httpRequest,
|
|
845
|
+
requestJson,
|
|
846
|
+
requestBuffer,
|
|
847
|
+
};
|
|
848
|
+
|
|
849
|
+
const isTransientError = (error) => {
|
|
850
|
+
if (!error) return false;
|
|
851
|
+
if (error.code === ERROR_CODES.TIMEOUT) return true;
|
|
852
|
+
|
|
853
|
+
const status = toNumberOrNull(error?.meta?.status);
|
|
854
|
+
if (status !== null && TRANSIENT_HTTP_STATUSES.has(status)) return true;
|
|
855
|
+
|
|
856
|
+
const rawCode = String(error?.meta?.rawCode || error?.code || '').toUpperCase();
|
|
857
|
+
return TRANSIENT_NETWORK_CODES.has(rawCode);
|
|
858
|
+
};
|
|
859
|
+
|
|
860
|
+
const retryAsync = async (operation, { retries = 0, shouldRetry = () => false, onRetry } = {}) => {
|
|
861
|
+
let attempt = 0;
|
|
862
|
+
|
|
863
|
+
while (true) {
|
|
864
|
+
try {
|
|
865
|
+
return await operation(attempt);
|
|
866
|
+
} catch (error) {
|
|
867
|
+
if (attempt >= retries || !shouldRetry(error)) {
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
attempt += 1;
|
|
872
|
+
if (typeof onRetry === 'function') {
|
|
873
|
+
onRetry(error, attempt);
|
|
874
|
+
}
|
|
875
|
+
await delay(200 * attempt);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const searchCache = new Map();
|
|
881
|
+
|
|
882
|
+
const pruneSearchCache = () => {
|
|
883
|
+
const now = Date.now();
|
|
884
|
+
for (const [key, entry] of searchCache) {
|
|
885
|
+
if (!entry || entry.expiresAt <= now) {
|
|
886
|
+
searchCache.delete(key);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (searchCache.size <= MAX_SEARCH_CACHE_ENTRIES) {
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const ordered = [...searchCache.entries()].sort(
|
|
895
|
+
(a, b) => (a[1]?.createdAt || 0) - (b[1]?.createdAt || 0),
|
|
896
|
+
);
|
|
897
|
+
const toRemove = searchCache.size - MAX_SEARCH_CACHE_ENTRIES;
|
|
898
|
+
for (let i = 0; i < toRemove; i += 1) {
|
|
899
|
+
searchCache.delete(ordered[i][0]);
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
const getSearchCache = (queryKey) => {
|
|
904
|
+
const entry = searchCache.get(queryKey);
|
|
905
|
+
if (!entry) return null;
|
|
906
|
+
if (entry.expiresAt <= Date.now()) {
|
|
907
|
+
searchCache.delete(queryKey);
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
return entry.value;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const setSearchCache = (queryKey, value) => {
|
|
914
|
+
const now = Date.now();
|
|
915
|
+
searchCache.set(queryKey, {
|
|
916
|
+
value,
|
|
917
|
+
createdAt: now,
|
|
918
|
+
expiresAt: now + SEARCH_CACHE_TTL_MS,
|
|
919
|
+
});
|
|
920
|
+
pruneSearchCache();
|
|
921
|
+
};
|
|
922
|
+
|
|
923
|
+
const buildYtdlsUrl = (endpoint, queryParams = null) => {
|
|
924
|
+
const url = new URL(`${YTDLS_BASE_URL}${endpoint}`);
|
|
925
|
+
if (queryParams && typeof queryParams === 'object') {
|
|
926
|
+
for (const [key, value] of Object.entries(queryParams)) {
|
|
927
|
+
if (value !== undefined && value !== null && value !== '') {
|
|
928
|
+
url.searchParams.set(key, String(value));
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return url.toString();
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const fetchSearchResult = async (query) => {
|
|
936
|
+
const normalized = typeof query === 'string' ? query.trim() : '';
|
|
937
|
+
if (!normalized) {
|
|
938
|
+
throw createError(
|
|
939
|
+
ERROR_CODES.INVALID_INPUT,
|
|
940
|
+
'Você precisa informar um link do YouTube ou termo de busca.',
|
|
941
|
+
{ endpoint: YTDLS_ENDPOINTS.search },
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const cacheKey = normalized.toLowerCase();
|
|
946
|
+
const cached = getSearchCache(cacheKey);
|
|
947
|
+
if (cached) {
|
|
948
|
+
return cached;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
const endpoint = YTDLS_ENDPOINTS.search;
|
|
952
|
+
const url = buildYtdlsUrl(endpoint, { q: normalized });
|
|
953
|
+
|
|
954
|
+
const result = await retryAsync(
|
|
955
|
+
async () => {
|
|
956
|
+
const payload = await httpClient.requestJson({
|
|
957
|
+
method: 'GET',
|
|
958
|
+
url,
|
|
959
|
+
endpoint,
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
if (!payload?.sucesso) {
|
|
963
|
+
throw createError(
|
|
964
|
+
ERROR_CODES.API,
|
|
965
|
+
payload?.mensagem || 'Não foi possível buscar o vídeo agora.',
|
|
966
|
+
{ endpoint },
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return payload;
|
|
971
|
+
},
|
|
972
|
+
{
|
|
973
|
+
retries: 1,
|
|
974
|
+
shouldRetry: isTransientError,
|
|
975
|
+
onRetry: (error, attempt) => {
|
|
976
|
+
logger.warn('Play busca: retry acionado.', {
|
|
977
|
+
endpoint,
|
|
978
|
+
attempt,
|
|
979
|
+
code: error?.code,
|
|
980
|
+
status: error?.meta?.status || null,
|
|
981
|
+
});
|
|
982
|
+
},
|
|
983
|
+
},
|
|
984
|
+
);
|
|
985
|
+
|
|
986
|
+
setSearchCache(cacheKey, result);
|
|
987
|
+
return result;
|
|
988
|
+
};
|
|
989
|
+
|
|
990
|
+
const resolveYoutubeLink = async (query) => {
|
|
991
|
+
const normalized = query ? query.trim() : '';
|
|
992
|
+
|
|
993
|
+
if (!normalized) {
|
|
994
|
+
throw createError(
|
|
995
|
+
ERROR_CODES.INVALID_INPUT,
|
|
996
|
+
'Você precisa informar um link do YouTube ou termo de busca.',
|
|
997
|
+
{ endpoint: YTDLS_ENDPOINTS.search },
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (/^https?:\/\//i.test(normalized)) {
|
|
1002
|
+
return normalized;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const searchResult = await fetchSearchResult(normalized);
|
|
1006
|
+
if (!searchResult?.resultado?.url) {
|
|
1007
|
+
throw createError(ERROR_CODES.NOT_FOUND, 'Nenhum resultado encontrado para a busca.', {
|
|
1008
|
+
endpoint: YTDLS_ENDPOINTS.search,
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
return searchResult.resultado.url;
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const fetchVideoInfo = async (query, fallback) => {
|
|
1016
|
+
const tryQuery = async (value) => {
|
|
1017
|
+
if (!value) return null;
|
|
1018
|
+
try {
|
|
1019
|
+
const result = await fetchSearchResult(value);
|
|
1020
|
+
if (!result?.sucesso || !result?.resultado) return null;
|
|
1021
|
+
return result.resultado;
|
|
1022
|
+
} catch {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
const first = await tryQuery(query);
|
|
1028
|
+
if (first) return first;
|
|
1029
|
+
|
|
1030
|
+
const normalizedQuery = typeof query === 'string' ? query.trim().toLowerCase() : '';
|
|
1031
|
+
const normalizedFallback = typeof fallback === 'string' ? fallback.trim().toLowerCase() : '';
|
|
1032
|
+
if (normalizedFallback && normalizedFallback !== normalizedQuery) {
|
|
1033
|
+
return tryQuery(fallback);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
return null;
|
|
1037
|
+
};
|
|
1038
|
+
|
|
1039
|
+
const fetchQueueStatus = async (requestId) => {
|
|
1040
|
+
if (!requestId) return null;
|
|
1041
|
+
|
|
1042
|
+
const endpointBase = YTDLS_ENDPOINTS.queueStatus;
|
|
1043
|
+
const endpointFull = `${endpointBase}/${encodeURIComponent(requestId)}`;
|
|
1044
|
+
const url = buildYtdlsUrl(endpointFull);
|
|
1045
|
+
|
|
1046
|
+
try {
|
|
1047
|
+
const result = await httpClient.requestJson({
|
|
1048
|
+
method: 'GET',
|
|
1049
|
+
url,
|
|
1050
|
+
endpoint: endpointFull,
|
|
1051
|
+
timeoutMs: QUEUE_STATUS_TIMEOUT_MS,
|
|
1052
|
+
});
|
|
1053
|
+
if (!result?.sucesso || !result?.fila) return null;
|
|
1054
|
+
return result;
|
|
1055
|
+
} catch {
|
|
1056
|
+
return null;
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
const requestDownloadToFile = async (link, type, requestId) => {
|
|
1061
|
+
const endpoint = YTDLS_ENDPOINTS.download;
|
|
1062
|
+
const url = buildYtdlsUrl(endpoint);
|
|
1063
|
+
const filePath = buildTempFilePath(requestId, type);
|
|
1064
|
+
const fallbackMime = type === 'audio' ? 'audio/mpeg' : 'video/mp4';
|
|
1065
|
+
let writeStream = null;
|
|
1066
|
+
|
|
1067
|
+
try {
|
|
1068
|
+
return await httpClient.request({
|
|
1069
|
+
method: 'POST',
|
|
1070
|
+
url,
|
|
1071
|
+
endpoint,
|
|
1072
|
+
timeoutMs: DOWNLOAD_API_TIMEOUT_MS,
|
|
1073
|
+
headers: { Accept: '*/*', 'Content-Type': 'application/json' },
|
|
1074
|
+
body: { link, type, request_id: requestId },
|
|
1075
|
+
timeoutMessage: 'Timeout ao baixar o arquivo.',
|
|
1076
|
+
fallbackMessage: 'Falha ao comunicar com a API yt-dls.',
|
|
1077
|
+
onResponse: async ({ res, status, headers, endpoint: currentEndpoint }) => {
|
|
1078
|
+
const contentType = getHeaderValue(headers, 'content-type') || '';
|
|
1079
|
+
const safeMimeType = resolveMediaMimeType(type, contentType);
|
|
1080
|
+
const normalizedContentType = normalizeMimeType(contentType);
|
|
1081
|
+
const contentLength = toNumberOrNull(getHeaderValue(headers, 'content-length'));
|
|
1082
|
+
|
|
1083
|
+
if (normalizedContentType && normalizedContentType !== safeMimeType) {
|
|
1084
|
+
logger.warn('Play download: content-type incompatível com tipo solicitado.', {
|
|
1085
|
+
requestId,
|
|
1086
|
+
type,
|
|
1087
|
+
endpoint: currentEndpoint,
|
|
1088
|
+
originalContentType: normalizedContentType,
|
|
1089
|
+
appliedContentType: safeMimeType,
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
if (contentLength !== null && contentLength > MAX_MEDIA_BYTES) {
|
|
1094
|
+
res.resume();
|
|
1095
|
+
throw createError(
|
|
1096
|
+
ERROR_CODES.TOO_BIG,
|
|
1097
|
+
`O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`,
|
|
1098
|
+
{
|
|
1099
|
+
endpoint: currentEndpoint,
|
|
1100
|
+
status,
|
|
1101
|
+
bytes: contentLength,
|
|
1102
|
+
},
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (status < 200 || status >= 300) {
|
|
1107
|
+
const raw = await readResponseText(res);
|
|
1108
|
+
throw buildApiErrorFromResponse({
|
|
1109
|
+
status,
|
|
1110
|
+
bodyText: raw,
|
|
1111
|
+
defaultMessage: `Falha na API yt-dls (HTTP ${status}).`,
|
|
1112
|
+
endpoint: currentEndpoint,
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
writeStream = fs.createWriteStream(filePath);
|
|
1117
|
+
const limiter = createByteLimitTransform(
|
|
1118
|
+
MAX_MEDIA_BYTES,
|
|
1119
|
+
`O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`,
|
|
1120
|
+
);
|
|
1121
|
+
|
|
1122
|
+
try {
|
|
1123
|
+
await pipeline(res, limiter.stream, writeStream);
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
throw normalizeRequestError(error, {
|
|
1126
|
+
timeoutMessage: 'Timeout ao baixar o arquivo.',
|
|
1127
|
+
fallbackMessage: 'Falha ao receber o arquivo da API yt-dls.',
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
let finalBytes = limiter.getBytes();
|
|
1132
|
+
let finalMimeType = safeMimeType || fallbackMime;
|
|
1133
|
+
let finalMediaType = type;
|
|
1134
|
+
|
|
1135
|
+
if (type === 'video') {
|
|
1136
|
+
const streamInfo = await probeVideoStreams(filePath, requestId, currentEndpoint);
|
|
1137
|
+
|
|
1138
|
+
if (!streamInfo.hasVideo) {
|
|
1139
|
+
if (streamInfo.hasAudio) {
|
|
1140
|
+
finalMediaType = 'audio';
|
|
1141
|
+
finalMimeType =
|
|
1142
|
+
normalizedContentType === 'video/mp4'
|
|
1143
|
+
? 'audio/mp4'
|
|
1144
|
+
: resolveMediaMimeType('audio', contentType);
|
|
1145
|
+
|
|
1146
|
+
logger.warn('Play vídeo: fonte retornou somente áudio, fallback ativado.', {
|
|
1147
|
+
requestId,
|
|
1148
|
+
endpoint: currentEndpoint,
|
|
1149
|
+
status,
|
|
1150
|
+
bytes: finalBytes,
|
|
1151
|
+
audioCodec: streamInfo.audioCodec || null,
|
|
1152
|
+
});
|
|
1153
|
+
} else {
|
|
1154
|
+
throw createError(
|
|
1155
|
+
ERROR_CODES.API,
|
|
1156
|
+
'Não foi possível enviar como vídeo: a mídia não possui faixa de vídeo nem áudio.',
|
|
1157
|
+
{
|
|
1158
|
+
endpoint: currentEndpoint,
|
|
1159
|
+
status,
|
|
1160
|
+
requestId,
|
|
1161
|
+
hasAudio: streamInfo.hasAudio,
|
|
1162
|
+
videoCodec: streamInfo.videoCodec,
|
|
1163
|
+
audioCodec: streamInfo.audioCodec,
|
|
1164
|
+
},
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
if (finalMediaType === 'video') {
|
|
1170
|
+
if (
|
|
1171
|
+
VIDEO_FORCE_TRANSCODE ||
|
|
1172
|
+
streamInfo.videoCodec !== 'h264' ||
|
|
1173
|
+
(streamInfo.hasAudio && streamInfo.audioCodec !== 'aac')
|
|
1174
|
+
) {
|
|
1175
|
+
finalBytes = await transcodeVideoForWhatsapp(filePath, requestId, currentEndpoint);
|
|
1176
|
+
finalMimeType = TYPE_CONFIG.video.mimeFallback;
|
|
1177
|
+
logger.info('Play vídeo normalizado para compatibilidade.', {
|
|
1178
|
+
requestId,
|
|
1179
|
+
endpoint: currentEndpoint,
|
|
1180
|
+
originalVideoCodec: streamInfo.videoCodec || null,
|
|
1181
|
+
originalAudioCodec: streamInfo.audioCodec || null,
|
|
1182
|
+
bytes: finalBytes,
|
|
1183
|
+
});
|
|
1184
|
+
} else {
|
|
1185
|
+
finalMimeType = TYPE_CONFIG.video.mimeFallback;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
return {
|
|
1191
|
+
filePath,
|
|
1192
|
+
contentType: finalMimeType,
|
|
1193
|
+
bytes: finalBytes,
|
|
1194
|
+
mediaType: finalMediaType,
|
|
1195
|
+
};
|
|
1196
|
+
},
|
|
1197
|
+
});
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
if (writeStream) {
|
|
1200
|
+
writeStream.destroy();
|
|
1201
|
+
}
|
|
1202
|
+
const normalized =
|
|
1203
|
+
KNOWN_ERROR_CODES.has(error?.code) && error?.message
|
|
1204
|
+
? error
|
|
1205
|
+
: normalizeRequestError(error, {
|
|
1206
|
+
timeoutMessage: 'Timeout ao baixar o arquivo.',
|
|
1207
|
+
fallbackMessage: 'Falha ao baixar o arquivo.',
|
|
1208
|
+
});
|
|
1209
|
+
throw withErrorMeta(normalized, { endpoint, filePath });
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const fetchThumbnailBuffer = async (url) =>
|
|
1214
|
+
retryAsync(
|
|
1215
|
+
() =>
|
|
1216
|
+
httpClient.requestBuffer({
|
|
1217
|
+
url,
|
|
1218
|
+
timeoutMs: THUMBNAIL_TIMEOUT_MS,
|
|
1219
|
+
maxBytes: MAX_THUMB_BYTES,
|
|
1220
|
+
endpoint: YTDLS_ENDPOINTS.thumbnail,
|
|
1221
|
+
}),
|
|
1222
|
+
{
|
|
1223
|
+
retries: 1,
|
|
1224
|
+
shouldRetry: isTransientError,
|
|
1225
|
+
onRetry: (error, attempt) => {
|
|
1226
|
+
logger.warn('Play thumbnail: retry acionado.', {
|
|
1227
|
+
endpoint: YTDLS_ENDPOINTS.thumbnail,
|
|
1228
|
+
attempt,
|
|
1229
|
+
code: error?.code,
|
|
1230
|
+
status: error?.meta?.status || null,
|
|
1231
|
+
});
|
|
1232
|
+
},
|
|
1233
|
+
},
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
const ytdlsClient = {
|
|
1237
|
+
resolveYoutubeLink,
|
|
1238
|
+
fetchVideoInfo,
|
|
1239
|
+
fetchQueueStatus,
|
|
1240
|
+
requestDownloadToFile,
|
|
1241
|
+
fetchThumbnailBuffer,
|
|
1242
|
+
};
|
|
1243
|
+
|
|
1244
|
+
const formatters = {
|
|
1245
|
+
formatNumber,
|
|
1246
|
+
formatDuration,
|
|
1247
|
+
formatVideoInfo,
|
|
1248
|
+
getThumbnailUrl,
|
|
1249
|
+
buildQueueStatusText,
|
|
1250
|
+
buildReadyCaption,
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1253
|
+
const fileUtils = {
|
|
1254
|
+
buildTempFilePath,
|
|
1255
|
+
safeUnlink,
|
|
1256
|
+
};
|
|
1257
|
+
|
|
1258
|
+
const buildRequestId = () => {
|
|
1259
|
+
if (typeof crypto.randomUUID === 'function') {
|
|
1260
|
+
return crypto.randomUUID();
|
|
1261
|
+
}
|
|
1262
|
+
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const getUserErrorMessage = (error) => {
|
|
1266
|
+
if (!error) return 'Erro inesperado ao processar sua solicitação.';
|
|
1267
|
+
if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error.message;
|
|
1268
|
+
return 'Erro inesperado ao processar sua solicitação.';
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
const notifyFailure = async (sock, remoteJid, messageInfo, expirationMessage, error, context) => {
|
|
1272
|
+
const errorMessage = getUserErrorMessage(error);
|
|
1273
|
+
|
|
1274
|
+
await sendAndStore(
|
|
1275
|
+
sock,
|
|
1276
|
+
remoteJid,
|
|
1277
|
+
{ text: `❌ Erro: ${errorMessage}` },
|
|
1278
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1279
|
+
);
|
|
1280
|
+
|
|
1281
|
+
if (adminJid) {
|
|
1282
|
+
await sendAndStore(sock, adminJid, {
|
|
1283
|
+
text: `Erro no módulo play.\nChat: ${remoteJid}\nRequest: ${
|
|
1284
|
+
context?.requestId || 'n/a'
|
|
1285
|
+
}\nTipo: ${context?.type || 'n/a'}\nEndpoint: ${error?.meta?.endpoint || 'n/a'}\nStatus: ${
|
|
1286
|
+
error?.meta?.status || 'n/a'
|
|
1287
|
+
}\nErro: ${errorMessage}\nCode: ${error?.code || 'n/a'}`,
|
|
1288
|
+
});
|
|
1289
|
+
}
|
|
1290
|
+
};
|
|
1291
|
+
|
|
1292
|
+
const processPlayRequest = async ({
|
|
1293
|
+
sock,
|
|
1294
|
+
remoteJid,
|
|
1295
|
+
messageInfo,
|
|
1296
|
+
expirationMessage,
|
|
1297
|
+
text,
|
|
1298
|
+
type,
|
|
1299
|
+
}) => {
|
|
1300
|
+
const startTime = Date.now();
|
|
1301
|
+
const requestId = buildRequestId();
|
|
1302
|
+
const config = TYPE_CONFIG[type];
|
|
1303
|
+
|
|
1304
|
+
if (!config) {
|
|
1305
|
+
throw createError(ERROR_CODES.INVALID_INPUT, 'Tipo de mídia inválido.');
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
logger.info('Play request iniciado.', {
|
|
1309
|
+
requestId,
|
|
1310
|
+
remoteJid,
|
|
1311
|
+
type,
|
|
1312
|
+
elapsedMs: 0,
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
let filePath = null;
|
|
1316
|
+
|
|
1317
|
+
try {
|
|
1318
|
+
const link = await ytdlsClient.resolveYoutubeLink(text);
|
|
1319
|
+
|
|
1320
|
+
const queueStatusPromise = ytdlsClient.fetchQueueStatus(requestId);
|
|
1321
|
+
const queueStatus = await Promise.race([queueStatusPromise, delay(QUICK_QUEUE_LOOKUP_MS)]);
|
|
1322
|
+
const queueText = formatters.buildQueueStatusText(queueStatus);
|
|
1323
|
+
const waitText = queueText
|
|
1324
|
+
? `${config.queueWaitText || config.waitText}\n${queueText}`
|
|
1325
|
+
: config.waitText;
|
|
1326
|
+
|
|
1327
|
+
await sendAndStore(
|
|
1328
|
+
sock,
|
|
1329
|
+
remoteJid,
|
|
1330
|
+
{ text: waitText },
|
|
1331
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1332
|
+
);
|
|
1333
|
+
|
|
1334
|
+
if (queueStatus?.fila) {
|
|
1335
|
+
logger.info('Play fila consultada.', {
|
|
1336
|
+
requestId,
|
|
1337
|
+
remoteJid,
|
|
1338
|
+
type,
|
|
1339
|
+
endpoint: YTDLS_ENDPOINTS.queueStatus,
|
|
1340
|
+
elapsedMs: Date.now() - startTime,
|
|
1341
|
+
queue: queueStatus.fila,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const [downloadResult, videoInfo] = await Promise.all([
|
|
1346
|
+
ytdlsClient.requestDownloadToFile(link, type, requestId),
|
|
1347
|
+
ytdlsClient.fetchVideoInfo(text, link),
|
|
1348
|
+
]);
|
|
1349
|
+
|
|
1350
|
+
filePath = downloadResult.filePath;
|
|
1351
|
+
const deliveredType = downloadResult.mediaType || type;
|
|
1352
|
+
const deliveredConfig = TYPE_CONFIG[deliveredType] || config;
|
|
1353
|
+
const fallbackToAudio = type === 'video' && deliveredType === 'audio';
|
|
1354
|
+
|
|
1355
|
+
logger.info('Play download concluído.', {
|
|
1356
|
+
requestId,
|
|
1357
|
+
remoteJid,
|
|
1358
|
+
type,
|
|
1359
|
+
deliveredType,
|
|
1360
|
+
fallbackToAudio,
|
|
1361
|
+
endpoint: YTDLS_ENDPOINTS.download,
|
|
1362
|
+
elapsedMs: Date.now() - startTime,
|
|
1363
|
+
bytes: downloadResult.bytes || 0,
|
|
1364
|
+
});
|
|
1365
|
+
|
|
1366
|
+
if (fallbackToAudio) {
|
|
1367
|
+
await sendAndStore(
|
|
1368
|
+
sock,
|
|
1369
|
+
remoteJid,
|
|
1370
|
+
{ text: '⚠️ Este link retornou somente áudio. Enviando no formato de áudio.' },
|
|
1371
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1372
|
+
);
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
if (deliveredType === 'audio') {
|
|
1376
|
+
const infoText = formatters.formatVideoInfo(videoInfo);
|
|
1377
|
+
const caption = formatters.buildReadyCaption(deliveredType, infoText);
|
|
1378
|
+
const thumbUrl = formatters.getThumbnailUrl(videoInfo);
|
|
1379
|
+
let thumbBuffer = null;
|
|
1380
|
+
let previewDelivered = false;
|
|
1381
|
+
|
|
1382
|
+
if (thumbUrl) {
|
|
1383
|
+
try {
|
|
1384
|
+
thumbBuffer = await ytdlsClient.fetchThumbnailBuffer(thumbUrl);
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
logger.warn('Falha ao baixar thumbnail.', {
|
|
1387
|
+
requestId,
|
|
1388
|
+
remoteJid,
|
|
1389
|
+
type: deliveredType,
|
|
1390
|
+
requestedType: type,
|
|
1391
|
+
endpoint: error?.meta?.endpoint || YTDLS_ENDPOINTS.thumbnail,
|
|
1392
|
+
status: error?.meta?.status || null,
|
|
1393
|
+
code: error?.code,
|
|
1394
|
+
error: truncateText(error?.message || ''),
|
|
1395
|
+
elapsedMs: Date.now() - startTime,
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (thumbBuffer) {
|
|
1401
|
+
try {
|
|
1402
|
+
await sendAndStore(
|
|
1403
|
+
sock,
|
|
1404
|
+
remoteJid,
|
|
1405
|
+
{ image: thumbBuffer, caption },
|
|
1406
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1407
|
+
);
|
|
1408
|
+
previewDelivered = true;
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
logger.warn('Falha ao enviar thumbnail de áudio.', {
|
|
1411
|
+
requestId,
|
|
1412
|
+
remoteJid,
|
|
1413
|
+
type: deliveredType,
|
|
1414
|
+
requestedType: type,
|
|
1415
|
+
code: error?.code || null,
|
|
1416
|
+
error: truncateText(error?.message || ''),
|
|
1417
|
+
elapsedMs: Date.now() - startTime,
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
if (!previewDelivered && caption) {
|
|
1423
|
+
try {
|
|
1424
|
+
await sendAndStore(
|
|
1425
|
+
sock,
|
|
1426
|
+
remoteJid,
|
|
1427
|
+
{ text: caption },
|
|
1428
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1429
|
+
);
|
|
1430
|
+
previewDelivered = true;
|
|
1431
|
+
} catch (error) {
|
|
1432
|
+
logger.warn('Falha ao enviar preview textual do áudio.', {
|
|
1433
|
+
requestId,
|
|
1434
|
+
remoteJid,
|
|
1435
|
+
type: deliveredType,
|
|
1436
|
+
requestedType: type,
|
|
1437
|
+
code: error?.code || null,
|
|
1438
|
+
error: truncateText(error?.message || ''),
|
|
1439
|
+
elapsedMs: Date.now() - startTime,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
await sendAndStore(
|
|
1445
|
+
sock,
|
|
1446
|
+
remoteJid,
|
|
1447
|
+
{
|
|
1448
|
+
audio: { url: filePath },
|
|
1449
|
+
mimetype: downloadResult.contentType || deliveredConfig.mimeFallback,
|
|
1450
|
+
ptt: false,
|
|
1451
|
+
},
|
|
1452
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1453
|
+
);
|
|
1454
|
+
|
|
1455
|
+
logger.info('Play áudio enviado.', {
|
|
1456
|
+
requestId,
|
|
1457
|
+
remoteJid,
|
|
1458
|
+
type: deliveredType,
|
|
1459
|
+
requestedType: type,
|
|
1460
|
+
fallbackToAudio,
|
|
1461
|
+
bytes: downloadResult.bytes || 0,
|
|
1462
|
+
elapsedMs: Date.now() - startTime,
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
const infoText = formatters.formatVideoInfo(videoInfo);
|
|
1469
|
+
const caption = formatters.buildReadyCaption(deliveredType, infoText);
|
|
1470
|
+
|
|
1471
|
+
await sendAndStore(
|
|
1472
|
+
sock,
|
|
1473
|
+
remoteJid,
|
|
1474
|
+
{
|
|
1475
|
+
video: { url: filePath },
|
|
1476
|
+
mimetype: downloadResult.contentType || deliveredConfig.mimeFallback,
|
|
1477
|
+
caption,
|
|
1478
|
+
},
|
|
1479
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1480
|
+
);
|
|
1481
|
+
|
|
1482
|
+
logger.info('Play vídeo enviado.', {
|
|
1483
|
+
requestId,
|
|
1484
|
+
remoteJid,
|
|
1485
|
+
type: deliveredType,
|
|
1486
|
+
requestedType: type,
|
|
1487
|
+
bytes: downloadResult.bytes || 0,
|
|
1488
|
+
elapsedMs: Date.now() - startTime,
|
|
1489
|
+
});
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
if (!filePath && error?.meta?.filePath) {
|
|
1492
|
+
filePath = error.meta.filePath;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
const normalizedError = withErrorMeta(normalizePlayError(error), {
|
|
1496
|
+
requestId,
|
|
1497
|
+
remoteJid,
|
|
1498
|
+
type,
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
logger.error('Play falhou.', {
|
|
1502
|
+
requestId,
|
|
1503
|
+
remoteJid,
|
|
1504
|
+
type,
|
|
1505
|
+
endpoint: normalizedError?.meta?.endpoint || null,
|
|
1506
|
+
status: normalizedError?.meta?.status || null,
|
|
1507
|
+
elapsedMs: Date.now() - startTime,
|
|
1508
|
+
error: truncateText(normalizedError.message || ''),
|
|
1509
|
+
code: normalizedError.code,
|
|
1510
|
+
});
|
|
1511
|
+
|
|
1512
|
+
throw normalizedError;
|
|
1513
|
+
} finally {
|
|
1514
|
+
await fileUtils.safeUnlink(filePath);
|
|
1515
|
+
}
|
|
1516
|
+
};
|
|
1517
|
+
|
|
1518
|
+
const playService = {
|
|
1519
|
+
processPlayRequest,
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
const handleTypedPlayCommand = async ({
|
|
1523
|
+
sock,
|
|
1524
|
+
remoteJid,
|
|
1525
|
+
messageInfo,
|
|
1526
|
+
expirationMessage,
|
|
1527
|
+
text,
|
|
1528
|
+
commandPrefix,
|
|
1529
|
+
type,
|
|
1530
|
+
}) => {
|
|
1531
|
+
try {
|
|
1532
|
+
if (!text?.trim()) {
|
|
1533
|
+
const usageText =
|
|
1534
|
+
type === 'audio'
|
|
1535
|
+
? `🎵 Uso: ${commandPrefix}play <link do YouTube ou termo de busca>`
|
|
1536
|
+
: `🎬 Uso: ${commandPrefix}playvid <link do YouTube ou termo de busca>`;
|
|
1537
|
+
|
|
1538
|
+
await sendAndStore(
|
|
1539
|
+
sock,
|
|
1540
|
+
remoteJid,
|
|
1541
|
+
{ text: usageText },
|
|
1542
|
+
{ quoted: messageInfo, ephemeralExpiration: expirationMessage },
|
|
1543
|
+
);
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
await playService.processPlayRequest({
|
|
1548
|
+
sock,
|
|
1549
|
+
remoteJid,
|
|
1550
|
+
messageInfo,
|
|
1551
|
+
expirationMessage,
|
|
1552
|
+
text,
|
|
1553
|
+
type,
|
|
1554
|
+
});
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
await notifyFailure(sock, remoteJid, messageInfo, expirationMessage, error, {
|
|
1557
|
+
type,
|
|
1558
|
+
requestId: error?.meta?.requestId,
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
};
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* Handler do comando play (audio).
|
|
1565
|
+
* @param {object} sock
|
|
1566
|
+
* @param {string} remoteJid
|
|
1567
|
+
* @param {object} messageInfo
|
|
1568
|
+
* @param {number} expirationMessage
|
|
1569
|
+
* @param {string} text
|
|
1570
|
+
* @returns {Promise<void>}
|
|
1571
|
+
*/
|
|
1572
|
+
export const handlePlayCommand = async (
|
|
1573
|
+
sock,
|
|
1574
|
+
remoteJid,
|
|
1575
|
+
messageInfo,
|
|
1576
|
+
expirationMessage,
|
|
1577
|
+
text,
|
|
1578
|
+
commandPrefix = DEFAULT_COMMAND_PREFIX,
|
|
1579
|
+
) =>
|
|
1580
|
+
handleTypedPlayCommand({
|
|
1581
|
+
sock,
|
|
1582
|
+
remoteJid,
|
|
1583
|
+
messageInfo,
|
|
1584
|
+
expirationMessage,
|
|
1585
|
+
text,
|
|
1586
|
+
commandPrefix,
|
|
1587
|
+
type: 'audio',
|
|
1588
|
+
});
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Handler do comando playvid (video).
|
|
1592
|
+
* @param {object} sock
|
|
1593
|
+
* @param {string} remoteJid
|
|
1594
|
+
* @param {object} messageInfo
|
|
1595
|
+
* @param {number} expirationMessage
|
|
1596
|
+
* @param {string} text
|
|
1597
|
+
* @returns {Promise<void>}
|
|
1598
|
+
*/
|
|
1599
|
+
export const handlePlayVidCommand = async (
|
|
1600
|
+
sock,
|
|
1601
|
+
remoteJid,
|
|
1602
|
+
messageInfo,
|
|
1603
|
+
expirationMessage,
|
|
1604
|
+
text,
|
|
1605
|
+
commandPrefix = DEFAULT_COMMAND_PREFIX,
|
|
1606
|
+
) =>
|
|
1607
|
+
handleTypedPlayCommand({
|
|
1608
|
+
sock,
|
|
1609
|
+
remoteJid,
|
|
1610
|
+
messageInfo,
|
|
1611
|
+
expirationMessage,
|
|
1612
|
+
text,
|
|
1613
|
+
commandPrefix,
|
|
1614
|
+
type: 'video',
|
|
1615
|
+
});
|