@omnizap-system/omnizap 2.6.0 → 2.6.2

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.
Files changed (261) hide show
  1. package/.env.example +58 -13
  2. package/.github/workflows/ci.yml +5 -5
  3. package/.github/workflows/codeql.yml +1 -1
  4. package/.github/workflows/db-migration-check.yml +2 -2
  5. package/.github/workflows/dependency-review.yml +1 -1
  6. package/.github/workflows/deploy.yml +2 -2
  7. package/.github/workflows/release.yml +2 -2
  8. package/.github/workflows/security-attest-provenance.yml +2 -2
  9. package/.github/workflows/security-gitleaks.yml +13 -4
  10. package/.github/workflows/security-runner-hardening.yml +2 -2
  11. package/.github/workflows/security-scorecard.yml +1 -1
  12. package/.github/workflows/security-zap-baseline.yml +1 -1
  13. package/.github/workflows/security-zap-full-scan.yml +2 -1
  14. package/.github/workflows/security-zizmor.yml +1 -1
  15. package/.github/workflows/wiki-sync.yml +1 -1
  16. package/.gitleaksignore +9 -0
  17. package/CODE_OF_CONDUCT.md +2 -2
  18. package/GEMINI.md +64 -0
  19. package/README.md +52 -82
  20. package/SECURITY.md +1 -1
  21. package/app/config/index.js +2 -0
  22. package/app/configParts/adminIdentity.js +5 -5
  23. package/app/configParts/baileysConfig.js +230 -58
  24. package/app/configParts/groupUtils.js +5 -0
  25. package/app/configParts/messagePersistenceService.js +145 -4
  26. package/app/configParts/sessionConfig.js +157 -0
  27. package/app/connection/baileysCompatibility.test.js +1 -1
  28. package/app/connection/groupOwnerWriteStateResolver.js +109 -0
  29. package/app/connection/socketController.js +660 -158
  30. package/app/connection/socketController.multiSession.test.js +108 -0
  31. package/app/controllers/messageController.js +1 -1
  32. package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
  33. package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
  34. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
  35. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +80 -2
  36. package/app/controllers/messageProcessingPipeline.js +93 -13
  37. package/app/controllers/messageProcessingPipeline.test.js +200 -0
  38. package/app/modules/adminModule/AGENT.md +1 -1
  39. package/app/modules/adminModule/commandConfig.json +3318 -1347
  40. package/app/modules/adminModule/groupCommandHandlers.js +858 -15
  41. package/app/modules/adminModule/groupCommandHandlers.test.js +378 -11
  42. package/app/modules/adminModule/groupWarningRepository.js +152 -0
  43. package/app/modules/aiModule/AGENT.md +47 -30
  44. package/app/modules/aiModule/aiConfigRuntime.js +1 -0
  45. package/app/modules/aiModule/catCommand.js +135 -27
  46. package/app/modules/aiModule/commandConfig.json +114 -28
  47. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
  48. package/app/modules/gameModule/AGENT.md +1 -1
  49. package/app/modules/gameModule/commandConfig.json +29 -0
  50. package/app/modules/menuModule/AGENT.md +1 -1
  51. package/app/modules/menuModule/commandConfig.json +45 -10
  52. package/app/modules/menuModule/menuCatalogService.js +190 -0
  53. package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
  54. package/app/modules/menuModule/menuDynamicService.js +511 -0
  55. package/app/modules/menuModule/menuDynamicService.test.js +141 -0
  56. package/app/modules/menuModule/menus.js +36 -5
  57. package/app/modules/playModule/AGENT.md +10 -5
  58. package/app/modules/playModule/commandConfig.json +140 -12
  59. package/app/modules/playModule/playCommand.js +1 -1417
  60. package/app/modules/playModule/playCommandConstants.js +80 -0
  61. package/app/modules/playModule/playCommandCore.js +361 -0
  62. package/app/modules/playModule/playCommandHandlers.js +41 -0
  63. package/app/modules/playModule/playCommandMediaClient.js +1872 -0
  64. package/app/modules/playModule/playConfigRuntime.js +245 -4
  65. package/app/modules/playModule/playModuleCriticalFlows.test.js +152 -0
  66. package/app/modules/quoteModule/AGENT.md +1 -1
  67. package/app/modules/quoteModule/commandConfig.json +29 -0
  68. package/app/modules/quoteModule/quoteCommand.js +3 -2
  69. package/app/modules/rpgPokemonModule/AGENT.md +1 -1
  70. package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
  71. package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +5 -4
  72. package/app/modules/rpgPokemonModule/rpgBattleService.test.js +2 -1
  73. package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +2 -1
  74. package/app/modules/rpgPokemonModule/rpgPokemonService.js +38 -37
  75. package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +4 -3
  76. package/app/modules/statsModule/AGENT.md +1 -1
  77. package/app/modules/statsModule/commandConfig.json +58 -0
  78. package/app/modules/statsModule/rankingCommon.js +5 -4
  79. package/app/modules/stickerModule/AGENT.md +1 -1
  80. package/app/modules/stickerModule/addStickerMetadata.js +4 -3
  81. package/app/modules/stickerModule/commandConfig.json +145 -0
  82. package/app/modules/stickerModule/stickerCommand.js +1 -1
  83. package/app/modules/stickerPackModule/AGENT.md +1 -1
  84. package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
  85. package/app/modules/stickerPackModule/commandConfig.json +29 -0
  86. package/app/modules/stickerPackModule/semanticThemeClusterService.js +7 -6
  87. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +10 -9
  88. package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +9 -8
  89. package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +3 -2
  90. package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +2 -1
  91. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +80 -58
  92. package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +2 -1
  93. package/app/modules/stickerPackModule/stickerPackRepository.js +2 -1
  94. package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +5 -4
  95. package/app/modules/stickerPackModule/stickerPackService.js +13 -6
  96. package/app/modules/stickerPackModule/stickerStorageService.js +3 -2
  97. package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +2 -1
  98. package/app/modules/systemMetricsModule/AGENT.md +1 -1
  99. package/app/modules/systemMetricsModule/commandConfig.json +29 -0
  100. package/app/modules/systemMetricsModule/pingCommand.js +6 -5
  101. package/app/modules/tiktokModule/AGENT.md +1 -1
  102. package/app/modules/tiktokModule/commandConfig.json +29 -0
  103. package/app/modules/tiktokModule/tiktokCommand.js +2 -1
  104. package/app/modules/userModule/AGENT.md +1 -1
  105. package/app/modules/userModule/commandConfig.json +29 -0
  106. package/app/modules/userModule/userCommand.js +72 -23
  107. package/app/modules/waifuPicsModule/AGENT.md +57 -27
  108. package/app/modules/waifuPicsModule/commandConfig.json +87 -0
  109. package/app/modules/waifuPicsModule/waifuPicsCommand.js +3 -2
  110. package/app/observability/metrics.js +136 -0
  111. package/app/services/ai/commandConfigEnrichmentService.js +229 -47
  112. package/app/services/ai/conversationRouterService.js +4 -3
  113. package/app/services/ai/geminiService.js +132 -7
  114. package/app/services/ai/geminiService.test.js +59 -2
  115. package/app/services/ai/globalModuleAiHelpService.js +3 -2
  116. package/app/services/ai/messageCommandExecutionService.js +2 -1
  117. package/app/services/ai/moduleAiHelpCoreService.js +45 -14
  118. package/app/services/ai/moduleToolExecutorService.js +3 -2
  119. package/app/services/ai/moduleToolRegistryService.js +2 -1
  120. package/app/services/ai/toolCandidateSelectorService.js +6 -5
  121. package/app/services/auth/googleWebLinkService.js +3 -2
  122. package/app/services/auth/whatsappLoginLinkService.js +3 -2
  123. package/app/services/external/pokeApiService.js +4 -3
  124. package/app/services/group/groupMetadataService.js +24 -1
  125. package/app/services/infra/dbWriteQueue.js +57 -26
  126. package/app/services/infra/featureFlagService.js +2 -1
  127. package/app/services/messaging/captchaService.js +3 -2
  128. package/app/services/messaging/newsBroadcastService.js +846 -29
  129. package/app/services/multiSession/assignmentBalancerService.js +457 -0
  130. package/app/services/multiSession/groupOwnershipRepository.js +381 -0
  131. package/app/services/multiSession/groupOwnershipService.js +890 -0
  132. package/app/services/multiSession/groupOwnershipService.test.js +309 -0
  133. package/app/services/multiSession/sessionRegistryService.js +293 -0
  134. package/app/services/sticker/stickerFocusService.js +11 -10
  135. package/app/store/aiPromptStore.js +36 -19
  136. package/app/store/conversationSessionStore.js +7 -6
  137. package/app/store/groupConfigStore.js +41 -5
  138. package/app/store/premiumUserStore.js +21 -7
  139. package/app/utils/antiLink/antiLinkModule.js +352 -16
  140. package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
  141. package/app/workers/aiLearningWorker.js +6 -5
  142. package/app/workers/commandConfigEnrichmentWorker.js +4 -3
  143. package/database/index.js +14 -8
  144. package/database/migrations/20260307_d0_hardening_down.sql +1 -1
  145. package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
  146. package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
  147. package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
  148. package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
  149. package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
  150. package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
  151. package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
  152. package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
  153. package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
  154. package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
  155. package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
  156. package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
  157. package/database/schema.sql +102 -1
  158. package/docker-compose.yml +4 -1
  159. package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
  160. package/docs/compliance/dpa-b2b-standard-2026-03-07.md +1 -1
  161. package/docs/compliance/privacy-policy-2026-03-07.md +4 -4
  162. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
  163. package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +1 -1
  164. package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
  165. package/docs/security/omnizap-static-security-headers.conf +25 -0
  166. package/docs/wiki/Home.md +1 -1
  167. package/ecosystem.prod.config.cjs +32 -12
  168. package/index.js +57 -23
  169. package/observability/alert-rules.yml +20 -0
  170. package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
  171. package/observability/mysql-setup.sql +4 -4
  172. package/observability/system-admin-observability.md +26 -0
  173. package/package.json +20 -6
  174. package/public/apple-touch-icon.png +0 -0
  175. package/public/comandos/commands-catalog.json +2853 -3326
  176. package/public/favicon-16x16.png +0 -0
  177. package/public/favicon-32x32.png +0 -0
  178. package/public/favicon.ico +0 -0
  179. package/public/js/apps/apiDocsApp.js +3 -2
  180. package/public/js/apps/commandsReactApp.js +280 -99
  181. package/public/js/apps/createPackApp.js +11 -10
  182. package/public/js/apps/homeReactApp.js +181 -130
  183. package/public/js/apps/loginReactApp.js +1 -1
  184. package/public/js/apps/stickersApp.js +263 -110
  185. package/public/js/apps/termsReactApp.js +73 -24
  186. package/public/js/apps/userApp.js +4 -3
  187. package/public/js/apps/userPasswordResetReactApp.js +406 -0
  188. package/public/js/apps/userReactApp.js +355 -280
  189. package/public/js/apps/userSystemAdmReactApp.js +1506 -0
  190. package/public/pages/api-docs.html +1 -1
  191. package/public/pages/aup.html +2 -2
  192. package/public/pages/dpa.html +3 -3
  193. package/public/pages/licenca.html +4 -4
  194. package/public/pages/login.html +1 -1
  195. package/public/pages/notice-and-takedown.html +2 -2
  196. package/public/pages/politica-de-privacidade.html +6 -6
  197. package/public/pages/seo-bot-whatsapp-para-grupo.html +3 -3
  198. package/public/pages/seo-bot-whatsapp-sem-programar.html +3 -3
  199. package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +3 -3
  200. package/public/pages/seo-como-criar-comandos-whatsapp.html +3 -3
  201. package/public/pages/seo-como-evitar-spam-no-whatsapp.html +3 -3
  202. package/public/pages/seo-como-moderar-grupo-whatsapp.html +3 -3
  203. package/public/pages/seo-como-organizar-comunidade-whatsapp.html +3 -3
  204. package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +3 -3
  205. package/public/pages/stickers-admin.html +1 -1
  206. package/public/pages/stickers-create.html +1 -1
  207. package/public/pages/stickers.html +6 -6
  208. package/public/pages/suboperadores.html +2 -2
  209. package/public/pages/termos-de-uso-texto-integral.html +6 -6
  210. package/public/pages/termos-de-uso.html +3 -3
  211. package/public/pages/user-password-reset.html +4 -5
  212. package/public/pages/user-systemadm.html +9 -463
  213. package/public/pages/user.html +2 -2
  214. package/scripts/clear-whatsapp-session.sh +123 -0
  215. package/scripts/core-ai-mode.mjs +163 -0
  216. package/scripts/deploy.sh +11 -1
  217. package/scripts/email-broadcast-terms-update.mjs +2 -1
  218. package/scripts/enrich-command-config-ux-openai.mjs +492 -0
  219. package/scripts/generate-commands-catalog.mjs +166 -2
  220. package/scripts/generate-module-agents.mjs +2 -1
  221. package/scripts/generate-seo-satellite-pages.mjs +5 -4
  222. package/scripts/github-deploy-notify.mjs +2 -1
  223. package/scripts/github-release-notify.mjs +25 -10
  224. package/scripts/new-whatsapp-session.sh +317 -0
  225. package/scripts/release.sh +2 -19
  226. package/scripts/security-smoketest.mjs +6 -5
  227. package/scripts/security-web-surface-check.mjs +218 -0
  228. package/scripts/sticker-catalog-loadtest.mjs +5 -4
  229. package/server/auth/googleWebAuth/googleWebAuthService.js +8 -7
  230. package/server/auth/jwt/webJwtService.js +1 -1
  231. package/server/auth/stickerCatalogAuthContext.js +2 -1
  232. package/server/auth/termsAcceptance/termsAcceptanceHandler.js +2 -1
  233. package/server/auth/userPassword/userPasswordAuthService.js +2 -1
  234. package/server/auth/userPassword/userPasswordRecoveryService.js +4 -3
  235. package/server/auth/webAccount/webAccountHandlers.js +9 -10
  236. package/server/controllers/admin/adminPanelHandlers.js +267 -16
  237. package/server/controllers/admin/systemAdminController.js +267 -0
  238. package/server/controllers/seo/stickerCatalogSeoContext.js +10 -9
  239. package/server/controllers/sticker/nonCatalogHandlers.js +2 -1
  240. package/server/controllers/sticker/stickerCatalogController.js +23 -36
  241. package/server/controllers/system/contactController.js +9 -17
  242. package/server/controllers/system/githubController.js +3 -2
  243. package/server/controllers/system/stickerCatalogSystemContext.js +41 -19
  244. package/server/controllers/system/systemController.js +254 -1
  245. package/server/controllers/system/systemMetricsController.js +2 -1
  246. package/server/controllers/userController.js +6 -0
  247. package/server/email/emailTemplateService.js +5 -3
  248. package/server/http/httpServer.js +11 -6
  249. package/server/middleware/rateLimit.js +2 -1
  250. package/server/middleware/securityHeaders.js +20 -1
  251. package/server/routes/admin/systemAdminRouter.js +6 -0
  252. package/server/routes/indexRouter.js +30 -6
  253. package/server/routes/observability/grafanaProxyRouter.js +254 -0
  254. package/server/routes/static/staticPageRouter.js +27 -1
  255. package/server/utils/publicContact.js +31 -0
  256. package/utils/time/timeModule.js +135 -0
  257. package/utils/time/timeModule.test.js +65 -0
  258. package/utils/whatsapp/contactEnv.js +39 -0
  259. package/vite.config.mjs +7 -1
  260. package/public/assets/images/brand-icon-192.png +0 -0
  261. package/scripts/sync-readme-snapshot.mjs +0 -133
@@ -0,0 +1,1872 @@
1
+ import { now as __timeNow, nowIso as __timeNowIso, toUnixMs as __timeNowMs } from '#time';
2
+ import http from 'node:http';
3
+ import https from 'node:https';
4
+ import { URL } from 'node:url';
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 logger from '#logger';
10
+ import { getPlayExecutionOptions, getPlayOperationalLimits, getPlayReadyTitle, getPlayText } from './playConfigRuntime.js';
11
+ import { DEFAULT_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, MEDIA_INFO_TIMEOUT_MS, PLAY_YTMP3_ENABLED, PLAY_YTMP3_API_BASE_URL, PLAY_YTMP3_API_DOWNLOAD_PATH, PLAY_YTMP3_POLL_INTERVAL_MS, PLAY_YTMP3_SEARCH_BASE_URL, PLAY_YTMP3_SEARCH_PATH, PLAY_YTMP3_VIDEO_DEFAULT_QUALITY, MAX_SEARCH_RESULTS, MAX_MEDIA_BYTES, MAX_MEDIA_MB_LABEL, THUMBNAIL_TIMEOUT_MS, MAX_THUMB_BYTES, VIDEO_PROCESS_TIMEOUT_MS, VIDEO_FORCE_TRANSCODE, FFMPEG_BIN, FFPROBE_BIN, SEARCH_CACHE_TTL_MS, MAX_SEARCH_CACHE_ENTRIES, MAX_REDIRECTS, MAX_ERROR_BODY_BYTES, MAX_META_BODY_CHARS, TRANSIENT_HTTP_STATUSES, TRANSIENT_NETWORK_CODES, YTDLS_ENDPOINTS, ERROR_CODES, KNOWN_ERROR_CODES, TYPE_CONFIG, PLAY_DOWNLOADS_DIR } from './playCommandConstants.js';
12
+
13
+ const createError = (code, message, meta) => {
14
+ const error = new Error(message);
15
+ error.code = code;
16
+ if (meta) error.meta = meta;
17
+ return error;
18
+ };
19
+
20
+ const withErrorMeta = (error, meta) => {
21
+ if (!error || typeof error !== 'object') return error;
22
+ error.meta = {
23
+ ...(error.meta || {}),
24
+ ...(meta || {}),
25
+ };
26
+ return error;
27
+ };
28
+
29
+ const isAbortError = (error) => error?.name === 'AbortError' || error?.code === 'ABORT_ERR' || error?.code === 'ECONNABORTED';
30
+
31
+ const normalizeRequestError = (error, { timeoutMessage, fallbackMessage, fallbackCode }) => {
32
+ if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
33
+ if (isAbortError(error)) {
34
+ return createError(ERROR_CODES.TIMEOUT, timeoutMessage, {
35
+ rawCode: error?.code || error?.name || null,
36
+ technical: true,
37
+ });
38
+ }
39
+ return createError(fallbackCode || ERROR_CODES.API, fallbackMessage, {
40
+ cause: error?.message || 'unknown',
41
+ rawCode: error?.code || error?.name || null,
42
+ technical: true,
43
+ });
44
+ };
45
+
46
+ const normalizePlayError = (error) => {
47
+ if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
48
+ if (isAbortError(error)) {
49
+ return createError(ERROR_CODES.TIMEOUT, getPlayText('user_error_timeout', 'A operação demorou mais que o esperado. Tente novamente.'), {
50
+ rawCode: error?.code || error?.name || null,
51
+ technical: true,
52
+ });
53
+ }
54
+ return createError(ERROR_CODES.API, getPlayText('user_error_technical_generic', 'Não foi possível processar sua solicitação agora. Tente novamente em instantes.'), {
55
+ cause: error?.message || 'unknown',
56
+ rawCode: error?.code || error?.name || null,
57
+ technical: true,
58
+ });
59
+ };
60
+
61
+ const delay = (ms) => new Promise((resolve) => setTimeout(() => resolve(null), ms));
62
+
63
+ const renderTemplate = (value, variables = {}) => {
64
+ let text = String(value || '');
65
+ for (const [key, variableValue] of Object.entries(variables || {})) {
66
+ text = text.replaceAll(`<${key}>`, String(variableValue ?? ''));
67
+ }
68
+ return text;
69
+ };
70
+
71
+ const playText = (key, fallback, variables) => renderTemplate(getPlayText(key, fallback), variables);
72
+
73
+ const getLimits = () => getPlayOperationalLimits();
74
+
75
+ const getExecutionOptions = () => getPlayExecutionOptions();
76
+
77
+ const createPlayProcessLimiter = () => {
78
+ let active = 0;
79
+ const queue = [];
80
+
81
+ const resolveLimit = () => Math.max(1, Number(getLimits().max_concurrent_jobs ?? 2));
82
+
83
+ const pump = () => {
84
+ const limit = resolveLimit();
85
+ while (active < limit && queue.length) {
86
+ const next = queue.shift();
87
+ if (!next) continue;
88
+ active += 1;
89
+ Promise.resolve()
90
+ .then(next.task)
91
+ .then(next.resolve, next.reject)
92
+ .finally(() => {
93
+ active = Math.max(0, active - 1);
94
+ pump();
95
+ });
96
+ }
97
+ };
98
+
99
+ const run = (task) =>
100
+ new Promise((resolve, reject) => {
101
+ queue.push({ task, resolve, reject });
102
+ pump();
103
+ });
104
+
105
+ const stats = () => ({ active, queued: queue.length, limit: resolveLimit() });
106
+
107
+ return { run, stats };
108
+ };
109
+
110
+ const playProcessLimiter = createPlayProcessLimiter();
111
+
112
+ const runWithPlayProcessSlot = async (task, meta = {}) => {
113
+ const before = playProcessLimiter.stats();
114
+ if (before.active >= before.limit) {
115
+ logger.warn('Play process: aguardando slot de execução.', {
116
+ endpoint: meta?.endpoint || YTDLS_ENDPOINTS.download,
117
+ command: meta?.command || null,
118
+ activeJobs: before.active,
119
+ queuedJobs: before.queued,
120
+ maxConcurrentJobs: before.limit,
121
+ });
122
+ }
123
+
124
+ return playProcessLimiter.run(task);
125
+ };
126
+
127
+ const truncateText = (value, maxChars = getLimits().max_meta_body_chars || MAX_META_BODY_CHARS) => {
128
+ if (typeof value !== 'string') return '';
129
+ if (value.length <= maxChars) return value;
130
+ return `${value.slice(0, maxChars)}...[truncated]`;
131
+ };
132
+
133
+ const toNumberOrNull = (value) => {
134
+ if (value === null || value === undefined || value === '') return null;
135
+ const number = Number(value);
136
+ return Number.isFinite(number) ? number : null;
137
+ };
138
+
139
+ const pickFirstString = (source, keys) => {
140
+ if (!source || typeof source !== 'object') return null;
141
+ for (const key of keys) {
142
+ const raw = source[key];
143
+ if (typeof raw === 'string' && raw.trim()) return raw.trim();
144
+ }
145
+ return null;
146
+ };
147
+
148
+ const ensureHttpUrl = (value) => {
149
+ if (!value || typeof value !== 'string') return null;
150
+ try {
151
+ const url = new URL(value.trim());
152
+ if (url.protocol === 'http:' || url.protocol === 'https:') return url.toString();
153
+ return null;
154
+ } catch {
155
+ return null;
156
+ }
157
+ };
158
+
159
+ const resolveHttpUrl = (value, baseUrl = null) => {
160
+ if (!value || typeof value !== 'string') return null;
161
+ try {
162
+ const resolved = baseUrl ? new URL(value.trim(), baseUrl) : new URL(value.trim());
163
+ if (resolved.protocol === 'http:' || resolved.protocol === 'https:') {
164
+ return resolved.toString();
165
+ }
166
+ return null;
167
+ } catch {
168
+ return null;
169
+ }
170
+ };
171
+
172
+ const isYouTubeUrl = (value) => {
173
+ const normalized = ensureHttpUrl(value);
174
+ if (!normalized) return false;
175
+ try {
176
+ const hostname = new URL(normalized).hostname.toLowerCase();
177
+ return hostname === 'youtube.com' || hostname.endsWith('.youtube.com') || hostname === 'youtu.be' || hostname.endsWith('.youtu.be') || hostname === 'youtubekids.com' || hostname.endsWith('.youtubekids.com');
178
+ } catch {
179
+ return false;
180
+ }
181
+ };
182
+
183
+ const formatNumber = (value) => {
184
+ const number = toNumberOrNull(value);
185
+ if (number === null) return null;
186
+ return number.toLocaleString('pt-BR');
187
+ };
188
+
189
+ const formatDuration = (value) => {
190
+ if (value === null || value === undefined) return null;
191
+ const number = toNumberOrNull(value);
192
+ if (number !== null) {
193
+ const totalSeconds = Math.max(0, Math.floor(number));
194
+ const hours = Math.floor(totalSeconds / 3600);
195
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
196
+ const seconds = totalSeconds % 60;
197
+ if (hours > 0) {
198
+ return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
199
+ }
200
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
201
+ }
202
+ if (typeof value === 'string' && value.trim()) return value.trim();
203
+ return null;
204
+ };
205
+
206
+ const formatVideoInfo = (videoInfo) => {
207
+ if (!videoInfo || typeof videoInfo !== 'object') return null;
208
+ const lines = [];
209
+ const title = pickFirstString(videoInfo, ['title', 'titulo', 'name']);
210
+ if (title) lines.push(`🎧 ${title}`);
211
+ const channel = pickFirstString(videoInfo, ['channel', 'uploader', 'uploader_name', 'author']);
212
+ if (channel) lines.push(`📺 ${channel}`);
213
+ const duration = formatDuration(videoInfo.duration);
214
+ if (duration) lines.push(`⏱ ${duration}`);
215
+ const id = pickFirstString(videoInfo, ['id', 'videoId', 'video_id']);
216
+ if (id) lines.push(`🆔 ${id}`);
217
+ return lines.length ? lines.join('\n') : null;
218
+ };
219
+
220
+ const getThumbnailUrl = (videoInfo) => {
221
+ if (!videoInfo || typeof videoInfo !== 'object') return null;
222
+
223
+ const direct = pickFirstString(videoInfo, ['thumbnail', 'thumb', 'thumbnail_url', 'thumbnailUrl', 'thumb_url', 'image', 'cover', 'artwork']);
224
+ const directUrl = ensureHttpUrl(direct);
225
+ if (directUrl) return directUrl;
226
+
227
+ const objectThumb = videoInfo.thumbnail;
228
+ if (objectThumb && typeof objectThumb === 'object') {
229
+ const objectUrl = ensureHttpUrl(objectThumb.url || objectThumb.src);
230
+ if (objectUrl) return objectUrl;
231
+ }
232
+
233
+ if (Array.isArray(videoInfo.thumbnails)) {
234
+ for (const thumb of videoInfo.thumbnails) {
235
+ const thumbUrl = ensureHttpUrl(thumb?.url || thumb?.src);
236
+ if (thumbUrl) return thumbUrl;
237
+ }
238
+ }
239
+
240
+ return null;
241
+ };
242
+
243
+ const buildQueueStatusText = (status) => {
244
+ if (!status?.fila) return null;
245
+
246
+ const fila = status.fila;
247
+ const downloadsAhead = toNumberOrNull(fila.downloads_a_frente);
248
+ const position = toNumberOrNull(fila.posicao_na_fila);
249
+ const totalQueued = toNumberOrNull(fila.enfileirados);
250
+
251
+ if (downloadsAhead === null && position === null && totalQueued === null) {
252
+ return null;
253
+ }
254
+
255
+ const lines = [];
256
+ if (position !== null) lines.push(`📍 Posição na fila: ${position}`);
257
+ if (downloadsAhead !== null) lines.push(`🚀 Downloads à frente: ${downloadsAhead}`);
258
+ if (!lines.length && totalQueued !== null) lines.push(`📦 Itens na fila: ${totalQueued}`);
259
+
260
+ return lines.join('\n');
261
+ };
262
+
263
+ const buildReadyCaption = (type, infoText) => {
264
+ const config = TYPE_CONFIG[type];
265
+ if (!config) return infoText || '';
266
+ const readyTitle = getPlayReadyTitle(type) || config.readyTitle;
267
+ if (!infoText) return readyTitle;
268
+ return `${readyTitle}\n──────────────\n${infoText}`;
269
+ };
270
+
271
+ const buildTempFilePath = (requestId, type) => {
272
+ const safeId = String(requestId || 'req')
273
+ .replace(/[^a-z0-9-_]+/gi, '')
274
+ .slice(0, 48);
275
+ const ext = type === 'audio' ? 'mp3' : 'mp4';
276
+ return path.join(PLAY_DOWNLOADS_DIR, `play-${safeId}-${__timeNowMs()}.${ext}`);
277
+ };
278
+
279
+ const safeUnlink = async (filePath) => {
280
+ if (!filePath) return;
281
+ try {
282
+ await fs.promises.unlink(filePath);
283
+ } catch {
284
+ return;
285
+ }
286
+ };
287
+
288
+ const createAbortSignal = (timeoutMs) => {
289
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
290
+ return { signal: undefined, cleanup: () => {} };
291
+ }
292
+ const controller = new globalThis.AbortController();
293
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
294
+ return {
295
+ signal: controller.signal,
296
+ cleanup: () => clearTimeout(timeoutId),
297
+ };
298
+ };
299
+
300
+ const normalizeHeaderValue = (value) => {
301
+ if (Array.isArray(value)) return value[0];
302
+ return value;
303
+ };
304
+
305
+ const getHeaderValue = (headers, key) => {
306
+ if (!headers || typeof headers !== 'object') return undefined;
307
+ const lowerKey = key.toLowerCase();
308
+ const raw = headers[lowerKey] ?? headers[key] ?? headers[key.toUpperCase()];
309
+ return normalizeHeaderValue(raw);
310
+ };
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/') ? normalized : TYPE_CONFIG.audio.mimeFallback;
323
+ }
324
+
325
+ if (type === 'video') {
326
+ return normalized && normalized.startsWith('video/') ? normalized : TYPE_CONFIG.video.mimeFallback;
327
+ }
328
+
329
+ return normalized || 'application/octet-stream';
330
+ };
331
+
332
+ const runBinaryCommand = (command, args, { timeoutMs = VIDEO_PROCESS_TIMEOUT_MS } = {}) =>
333
+ new Promise((resolve, reject) => {
334
+ const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
335
+ const stdoutChunks = [];
336
+ const stderrChunks = [];
337
+ let stdoutBytes = 0;
338
+ let stderrBytes = 0;
339
+ let timedOut = false;
340
+ const maxCapturedBytes = (getLimits().max_error_body_bytes || MAX_ERROR_BODY_BYTES) * 4;
341
+
342
+ const appendChunk = (chunks, chunk, bytes) => {
343
+ if (!chunk || bytes >= maxCapturedBytes) return bytes;
344
+ const current = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
345
+ const remaining = Math.max(0, maxCapturedBytes - bytes);
346
+ if (remaining <= 0) return bytes;
347
+ const accepted = current.length <= remaining ? current : current.subarray(0, remaining);
348
+ chunks.push(accepted);
349
+ return bytes + accepted.length;
350
+ };
351
+
352
+ child.stdout.on('data', (chunk) => {
353
+ stdoutBytes = appendChunk(stdoutChunks, chunk, stdoutBytes);
354
+ });
355
+
356
+ child.stderr.on('data', (chunk) => {
357
+ stderrBytes = appendChunk(stderrChunks, chunk, stderrBytes);
358
+ });
359
+
360
+ const timeoutId =
361
+ Number.isFinite(timeoutMs) && timeoutMs > 0
362
+ ? setTimeout(() => {
363
+ timedOut = true;
364
+ child.kill('SIGKILL');
365
+ }, timeoutMs)
366
+ : null;
367
+ let settled = false;
368
+
369
+ const finalize = (handler) => {
370
+ if (settled) return;
371
+ settled = true;
372
+ if (timeoutId) clearTimeout(timeoutId);
373
+ handler();
374
+ };
375
+
376
+ child.on('error', (error) => {
377
+ finalize(() => reject(error));
378
+ });
379
+
380
+ child.on('close', (code, signal) => {
381
+ finalize(() => {
382
+ const stdout = Buffer.concat(stdoutChunks, stdoutBytes).toString('utf-8').trim();
383
+ const stderr = Buffer.concat(stderrChunks, stderrBytes).toString('utf-8').trim();
384
+
385
+ if (!timedOut && code === 0) {
386
+ resolve({ stdout, stderr });
387
+ return;
388
+ }
389
+
390
+ const error = new Error(stderr || playText('binary_exec_failed', `Falha ao executar ${path.basename(command)}.`, { command: path.basename(command) }));
391
+ error.code = timedOut ? 'ETIMEDOUT' : 'EPROCESS';
392
+ error.exitCode = code;
393
+ error.signal = signal || null;
394
+ error.stderr = stderr;
395
+ error.stdout = stdout;
396
+ reject(error);
397
+ });
398
+ });
399
+ });
400
+
401
+ const normalizeBinaryError = (error, { timeoutMessage, fallbackMessage, endpoint, requestId, command, outputPath }) => {
402
+ if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
403
+ if (error?.code === 'ETIMEDOUT') {
404
+ return createError(ERROR_CODES.TIMEOUT, timeoutMessage, {
405
+ endpoint,
406
+ requestId,
407
+ command,
408
+ rawCode: error?.code || null,
409
+ technical: true,
410
+ });
411
+ }
412
+ return createError(ERROR_CODES.API, fallbackMessage, {
413
+ endpoint,
414
+ requestId,
415
+ command,
416
+ outputPath: outputPath || null,
417
+ rawCode: error?.code || null,
418
+ exitCode: error?.exitCode ?? null,
419
+ signal: error?.signal || null,
420
+ cause: truncateText(error?.stderr || error?.message || 'unknown'),
421
+ technical: true,
422
+ });
423
+ };
424
+
425
+ const probeVideoStreams = async (filePath, requestId, endpoint) => {
426
+ try {
427
+ const result = await runWithPlayProcessSlot(() => runBinaryCommand(FFPROBE_BIN, ['-v', 'error', '-print_format', 'json', '-show_streams', filePath]), { endpoint, command: FFPROBE_BIN });
428
+ const parsed = JSON.parse(result.stdout || '{}');
429
+ const streams = Array.isArray(parsed?.streams) ? parsed.streams : [];
430
+ const videoStream = streams.find((stream) => stream?.codec_type === 'video') || null;
431
+ const audioStream = streams.find((stream) => stream?.codec_type === 'audio') || null;
432
+
433
+ return {
434
+ hasVideo: Boolean(videoStream),
435
+ hasAudio: Boolean(audioStream),
436
+ videoCodec: videoStream?.codec_name || null,
437
+ audioCodec: audioStream?.codec_name || null,
438
+ };
439
+ } catch (error) {
440
+ const normalized = normalizeBinaryError(error, {
441
+ timeoutMessage: playText('probe_timeout', 'Timeout ao analisar o vídeo recebido.'),
442
+ fallbackMessage: playText('probe_failed', 'Falha ao validar o vídeo recebido.'),
443
+ endpoint,
444
+ requestId,
445
+ command: FFPROBE_BIN,
446
+ });
447
+ throw normalized;
448
+ }
449
+ };
450
+
451
+ const transcodeVideoForWhatsapp = async (filePath, requestId, endpoint) => {
452
+ const outputPath = `${filePath}.wa.mp4`;
453
+
454
+ try {
455
+ await safeUnlink(outputPath);
456
+
457
+ await runWithPlayProcessSlot(
458
+ () =>
459
+ runBinaryCommand(FFMPEG_BIN, ['-y', '-i', filePath, '-map', '0:v:0', '-map', '0:a:0?', '-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', '-ac', '2', outputPath], {
460
+ timeoutMs: VIDEO_PROCESS_TIMEOUT_MS,
461
+ }),
462
+ { endpoint, command: FFMPEG_BIN },
463
+ );
464
+
465
+ const stats = await fs.promises.stat(outputPath);
466
+ const transcodedBytes = Number(stats?.size || 0);
467
+
468
+ if (transcodedBytes <= 0) {
469
+ throw createError(ERROR_CODES.API, playText('transcode_output_invalid', 'Falha ao gerar vídeo compatível para envio.'), {
470
+ endpoint,
471
+ requestId,
472
+ outputPath,
473
+ technical: true,
474
+ });
475
+ }
476
+
477
+ if (transcodedBytes > MAX_MEDIA_BYTES) {
478
+ throw createError(ERROR_CODES.TOO_BIG, playText('media_too_big', `O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`, { max_mb: MAX_MEDIA_MB_LABEL }), {
479
+ endpoint,
480
+ requestId,
481
+ bytes: transcodedBytes,
482
+ technical: false,
483
+ });
484
+ }
485
+
486
+ await fs.promises.rename(outputPath, filePath);
487
+ return transcodedBytes;
488
+ } catch (error) {
489
+ await safeUnlink(outputPath);
490
+ const normalized = normalizeBinaryError(error, {
491
+ timeoutMessage: playText('transcode_timeout', 'Timeout ao normalizar o vídeo para envio.'),
492
+ fallbackMessage: playText('transcode_failed', 'Falha ao converter o vídeo para um formato compatível.'),
493
+ endpoint,
494
+ requestId,
495
+ command: FFMPEG_BIN,
496
+ outputPath,
497
+ });
498
+ throw normalized;
499
+ }
500
+ };
501
+
502
+ const resolveHttpModule = (urlObj) => (urlObj.protocol === 'https:' ? https : http);
503
+
504
+ const shouldFollowRedirect = (status, location, redirectCount, maxRedirects) => status >= 300 && status < 400 && Boolean(location) && redirectCount < maxRedirects;
505
+
506
+ const readResponseBuffer = async (stream, { maxBytes = Infinity, tooBigMessage } = {}) => {
507
+ const chunks = [];
508
+ let total = 0;
509
+
510
+ for await (const chunk of stream) {
511
+ const current = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
512
+ total += current.length;
513
+
514
+ if (Number.isFinite(maxBytes) && total > maxBytes) {
515
+ stream.destroy();
516
+ throw createError(ERROR_CODES.TOO_BIG, tooBigMessage || playText('content_too_big', 'Conteúdo excede o limite permitido.'), { bytes: total, technical: false });
517
+ }
518
+
519
+ chunks.push(current);
520
+ }
521
+
522
+ return Buffer.concat(chunks, total);
523
+ };
524
+
525
+ const httpRequest = ({ url, method = 'GET', headers = {}, body = null, timeoutMs = DEFAULT_TIMEOUT_MS, maxRedirects = 0, redirectCount = 0, endpoint = 'unknown', timeoutMessage = playText('http_timeout', 'Timeout na requisição HTTP.'), fallbackMessage = playText('http_failed', 'Falha na requisição HTTP.'), onResponse }) =>
526
+ new Promise((resolve, reject) => {
527
+ const urlObj = new URL(url);
528
+ const httpModule = resolveHttpModule(urlObj);
529
+ const { signal, cleanup } = createAbortSignal(timeoutMs);
530
+ const normalizedMethod = String(method || 'GET').toUpperCase();
531
+
532
+ let settled = false;
533
+ const settle = (fn) => {
534
+ if (settled) return;
535
+ settled = true;
536
+ cleanup();
537
+ fn();
538
+ };
539
+
540
+ const settleResolve = (value) => settle(() => resolve(value));
541
+ const settleReject = (error) => settle(() => reject(error));
542
+
543
+ const req = httpModule.request(
544
+ urlObj,
545
+ {
546
+ method: normalizedMethod,
547
+ headers: {
548
+ Accept: '*/*',
549
+ ...(headers && typeof headers === 'object' ? headers : {}),
550
+ },
551
+ signal,
552
+ },
553
+ (res) => {
554
+ const status = res.statusCode || 0;
555
+ const location = getHeaderValue(res.headers, 'location');
556
+ res.on('error', (error) => {
557
+ const normalized = normalizeRequestError(error, {
558
+ timeoutMessage,
559
+ fallbackMessage,
560
+ });
561
+ settleReject(withErrorMeta(normalized, { endpoint, status }));
562
+ });
563
+
564
+ if (shouldFollowRedirect(status, location, redirectCount, maxRedirects)) {
565
+ logger.debug('HTTP redirect.', {
566
+ endpoint,
567
+ status,
568
+ location: String(location),
569
+ redirectCount: redirectCount + 1,
570
+ });
571
+ const nextUrl = new URL(String(location), urlObj).toString();
572
+ res.resume();
573
+ settleResolve(
574
+ httpRequest({
575
+ url: nextUrl,
576
+ method: normalizedMethod,
577
+ headers,
578
+ body,
579
+ timeoutMs,
580
+ maxRedirects,
581
+ redirectCount: redirectCount + 1,
582
+ endpoint,
583
+ timeoutMessage,
584
+ fallbackMessage,
585
+ onResponse,
586
+ }),
587
+ );
588
+ return;
589
+ }
590
+
591
+ Promise.resolve(
592
+ onResponse({
593
+ res,
594
+ status,
595
+ headers: res.headers,
596
+ endpoint,
597
+ finalUrl: urlObj.toString(),
598
+ }),
599
+ )
600
+ .then(settleResolve)
601
+ .catch((error) => {
602
+ const normalized = normalizeRequestError(error, {
603
+ timeoutMessage,
604
+ fallbackMessage,
605
+ });
606
+ settleReject(withErrorMeta(normalized, { endpoint, status }));
607
+ });
608
+ },
609
+ );
610
+
611
+ req.on('error', (error) => {
612
+ const normalized = normalizeRequestError(error, {
613
+ timeoutMessage,
614
+ fallbackMessage,
615
+ });
616
+ settleReject(withErrorMeta(normalized, { endpoint }));
617
+ });
618
+
619
+ if (body !== null && body !== undefined) {
620
+ req.write(body);
621
+ }
622
+
623
+ req.end();
624
+ });
625
+
626
+ const requestJson = async ({ url, method = 'GET', data = null, headers = {}, timeoutMs = DEFAULT_TIMEOUT_MS, maxRedirects = getLimits().max_redirects ?? MAX_REDIRECTS, endpoint = 'unknown', timeoutMessage = playText('http_timeout', 'Timeout na requisição HTTP.'), fallbackMessage = playText('http_failed', 'Falha na requisição HTTP.') }) => {
627
+ const normalizedMethod = String(method || 'GET').toUpperCase();
628
+ let finalUrl = url;
629
+ let body = null;
630
+
631
+ const isGetLike = normalizedMethod === 'GET' || normalizedMethod === 'HEAD';
632
+ if (isGetLike && data && typeof data === 'object') {
633
+ const searchParams = new URLSearchParams();
634
+ for (const [key, value] of Object.entries(data)) {
635
+ if (value === null || value === undefined || value === '') continue;
636
+ searchParams.append(key, String(value));
637
+ }
638
+
639
+ const query = searchParams.toString();
640
+ if (query) {
641
+ const delimiter = finalUrl.includes('?') ? '&' : '?';
642
+ finalUrl = `${finalUrl}${delimiter}${query}`;
643
+ }
644
+ } else if (data !== null && data !== undefined) {
645
+ body = JSON.stringify(data);
646
+ }
647
+
648
+ const requestHeaders = {
649
+ Accept: 'application/json',
650
+ ...(headers && typeof headers === 'object' ? headers : {}),
651
+ };
652
+ if (body !== null && body !== undefined) {
653
+ requestHeaders['Content-Type'] = requestHeaders['Content-Type'] || 'application/json';
654
+ }
655
+
656
+ return httpRequest({
657
+ url: finalUrl,
658
+ method: normalizedMethod,
659
+ headers: requestHeaders,
660
+ body,
661
+ timeoutMs,
662
+ endpoint,
663
+ maxRedirects,
664
+ timeoutMessage,
665
+ fallbackMessage,
666
+ onResponse: async ({ res, status, endpoint: currentEndpoint }) => {
667
+ const bodyBuffer = await readResponseBuffer(res, {
668
+ maxBytes: (getLimits().max_error_body_bytes ?? MAX_ERROR_BODY_BYTES) * 4,
669
+ });
670
+ const rawText = bodyBuffer.toString('utf-8').trim();
671
+
672
+ if (status < 200 || status >= 300) {
673
+ throw createError(ERROR_CODES.API, fallbackMessage, {
674
+ endpoint: currentEndpoint,
675
+ status,
676
+ cause: truncateText(rawText || 'http_non_success'),
677
+ technical: true,
678
+ });
679
+ }
680
+
681
+ if (!rawText) return {};
682
+
683
+ try {
684
+ return JSON.parse(rawText);
685
+ } catch {
686
+ throw createError(ERROR_CODES.API, fallbackMessage, {
687
+ endpoint: currentEndpoint,
688
+ status,
689
+ cause: truncateText(rawText || 'invalid_json'),
690
+ technical: true,
691
+ });
692
+ }
693
+ },
694
+ });
695
+ };
696
+
697
+ const requestFile = async ({ url, filePath, timeoutMs = DOWNLOAD_TIMEOUT_MS, maxBytes = MAX_MEDIA_BYTES, endpoint = YTDLS_ENDPOINTS.download, timeoutMessage = playText('download_timeout', 'Timeout ao baixar o arquivo.'), fallbackMessage = playText('download_failed', 'Falha ao baixar o arquivo localmente.') }) => {
698
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
699
+
700
+ try {
701
+ return await httpRequest({
702
+ url,
703
+ timeoutMs,
704
+ endpoint,
705
+ maxRedirects: getLimits().max_redirects ?? MAX_REDIRECTS,
706
+ timeoutMessage,
707
+ fallbackMessage,
708
+ onResponse: async ({ res, status, headers, endpoint: currentEndpoint }) => {
709
+ if (status < 200 || status >= 300) {
710
+ const responseBody = await readResponseBuffer(res, {
711
+ maxBytes: getLimits().max_error_body_bytes ?? MAX_ERROR_BODY_BYTES,
712
+ });
713
+ throw createError(ERROR_CODES.API, fallbackMessage, {
714
+ endpoint: currentEndpoint,
715
+ status,
716
+ cause: truncateText(responseBody.toString('utf-8')),
717
+ technical: true,
718
+ });
719
+ }
720
+
721
+ const contentLength = toNumberOrNull(getHeaderValue(headers, 'content-length'));
722
+ if (contentLength !== null && contentLength > maxBytes) {
723
+ res.resume();
724
+ throw createError(ERROR_CODES.TOO_BIG, playText('media_too_big', `O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`, { max_mb: MAX_MEDIA_MB_LABEL }), {
725
+ endpoint: currentEndpoint,
726
+ status,
727
+ bytes: contentLength,
728
+ technical: false,
729
+ });
730
+ }
731
+
732
+ const writeStream = fs.createWriteStream(filePath, { flags: 'w' });
733
+ let bytes = 0;
734
+
735
+ await new Promise((resolve, reject) => {
736
+ let settled = false;
737
+
738
+ const settleReject = (error) => {
739
+ if (settled) return;
740
+ settled = true;
741
+ writeStream.destroy();
742
+ res.destroy();
743
+ reject(error);
744
+ };
745
+
746
+ const settleResolve = () => {
747
+ if (settled) return;
748
+ settled = true;
749
+ resolve(null);
750
+ };
751
+
752
+ res.on('data', (chunk) => {
753
+ bytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk || ''));
754
+ if (bytes > maxBytes) {
755
+ settleReject(
756
+ createError(ERROR_CODES.TOO_BIG, playText('media_too_big', `O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`, { max_mb: MAX_MEDIA_MB_LABEL }), {
757
+ endpoint: currentEndpoint,
758
+ status,
759
+ bytes,
760
+ technical: false,
761
+ }),
762
+ );
763
+ }
764
+ });
765
+
766
+ res.on('error', settleReject);
767
+ writeStream.on('error', settleReject);
768
+ writeStream.on('finish', settleResolve);
769
+ res.pipe(writeStream);
770
+ });
771
+
772
+ if (bytes <= 0) {
773
+ throw createError(ERROR_CODES.API, playText('download_invalid_media', 'Falha ao baixar mídia válida.'), {
774
+ endpoint: currentEndpoint,
775
+ status,
776
+ bytes,
777
+ filePath,
778
+ technical: true,
779
+ });
780
+ }
781
+
782
+ return {
783
+ bytes,
784
+ contentType: normalizeMimeType(getHeaderValue(headers, 'content-type')),
785
+ };
786
+ },
787
+ });
788
+ } catch (error) {
789
+ await safeUnlink(filePath);
790
+ throw error;
791
+ }
792
+ };
793
+
794
+ const requestBuffer = async ({ url, timeoutMs = getLimits().thumbnail_timeout_ms ?? THUMBNAIL_TIMEOUT_MS, maxBytes = getLimits().max_thumb_bytes ?? MAX_THUMB_BYTES, endpoint = YTDLS_ENDPOINTS.thumbnail }) =>
795
+ httpRequest({
796
+ url,
797
+ method: 'GET',
798
+ timeoutMs,
799
+ endpoint,
800
+ maxRedirects: getLimits().max_redirects ?? MAX_REDIRECTS,
801
+ timeoutMessage: playText('thumbnail_timeout', 'Timeout ao baixar a thumbnail.'),
802
+ fallbackMessage: playText('thumbnail_failed', 'Falha ao baixar a thumbnail.'),
803
+ onResponse: async ({ res, status, headers, endpoint: currentEndpoint }) => {
804
+ if (status < 200 || status >= 300) {
805
+ res.resume();
806
+ throw createError(ERROR_CODES.API, playText('thumbnail_failed', 'Falha ao baixar a thumbnail.'), {
807
+ endpoint: currentEndpoint,
808
+ status,
809
+ technical: true,
810
+ });
811
+ }
812
+
813
+ const contentLength = toNumberOrNull(getHeaderValue(headers, 'content-length'));
814
+ if (contentLength !== null && contentLength > maxBytes) {
815
+ res.resume();
816
+ throw createError(ERROR_CODES.TOO_BIG, playText('thumbnail_too_big', 'Thumbnail excede o limite permitido.'), {
817
+ endpoint: currentEndpoint,
818
+ status,
819
+ bytes: contentLength,
820
+ technical: false,
821
+ });
822
+ }
823
+
824
+ return readResponseBuffer(res, {
825
+ maxBytes,
826
+ tooBigMessage: playText('thumbnail_too_big', 'Thumbnail excede o limite permitido.'),
827
+ });
828
+ },
829
+ });
830
+
831
+ const httpClient = {
832
+ requestBuffer,
833
+ requestJson,
834
+ requestFile,
835
+ };
836
+
837
+ const isTransientError = (error) => {
838
+ if (!error) return false;
839
+ if (error.code === ERROR_CODES.TIMEOUT) return true;
840
+
841
+ const status = toNumberOrNull(error?.meta?.status);
842
+ if (status !== null && TRANSIENT_HTTP_STATUSES.has(status)) return true;
843
+
844
+ const rawCode = String(error?.meta?.rawCode || error?.code || '').toUpperCase();
845
+ return TRANSIENT_NETWORK_CODES.has(rawCode);
846
+ };
847
+
848
+ const retryAsync = async (operation, { retries = 0, shouldRetry = () => false, onRetry } = {}) => {
849
+ let attempt = 0;
850
+
851
+ while (true) {
852
+ try {
853
+ return await operation(attempt);
854
+ } catch (error) {
855
+ if (attempt >= retries || !shouldRetry(error)) {
856
+ throw error;
857
+ }
858
+
859
+ attempt += 1;
860
+ if (typeof onRetry === 'function') {
861
+ onRetry(error, attempt);
862
+ }
863
+ const backoffBase = getLimits().retry_backoff_base_ms ?? 200;
864
+ await delay(backoffBase * attempt);
865
+ }
866
+ }
867
+ };
868
+
869
+ const searchCache = new Map();
870
+
871
+ const pruneSearchCache = () => {
872
+ const maxEntries = getLimits().max_search_cache_entries ?? MAX_SEARCH_CACHE_ENTRIES;
873
+ const now = __timeNowMs();
874
+ for (const [key, entry] of searchCache) {
875
+ if (!entry || entry.expiresAt <= now) {
876
+ searchCache.delete(key);
877
+ }
878
+ }
879
+
880
+ if (searchCache.size <= maxEntries) {
881
+ return;
882
+ }
883
+
884
+ const ordered = [...searchCache.entries()].sort((a, b) => (a[1]?.createdAt || 0) - (b[1]?.createdAt || 0));
885
+ const toRemove = searchCache.size - maxEntries;
886
+ for (let i = 0; i < toRemove; i += 1) {
887
+ searchCache.delete(ordered[i][0]);
888
+ }
889
+ };
890
+
891
+ const getSearchCache = (queryKey) => {
892
+ const entry = searchCache.get(queryKey);
893
+ if (!entry) return null;
894
+ if (entry.expiresAt <= __timeNowMs()) {
895
+ searchCache.delete(queryKey);
896
+ return null;
897
+ }
898
+ return entry.value;
899
+ };
900
+
901
+ const setSearchCache = (queryKey, value) => {
902
+ const ttlMs = getLimits().search_cache_ttl_ms ?? SEARCH_CACHE_TTL_MS;
903
+ const now = __timeNowMs();
904
+ searchCache.set(queryKey, {
905
+ value,
906
+ createdAt: now,
907
+ expiresAt: now + ttlMs,
908
+ });
909
+ pruneSearchCache();
910
+ };
911
+
912
+ const ensurePlayLocalDirs = async () => {
913
+ await fs.promises.mkdir(PLAY_DOWNLOADS_DIR, { recursive: true });
914
+ };
915
+
916
+ const normalizeYoutubeWatchUrl = (value) => {
917
+ if (!value || typeof value !== 'string') return null;
918
+ const trimmed = value.trim();
919
+ if (!trimmed) return null;
920
+
921
+ const direct = ensureHttpUrl(trimmed);
922
+ if (direct) return direct;
923
+
924
+ if (/^[a-zA-Z0-9_-]{6,}$/.test(trimmed)) {
925
+ return `https://www.youtube.com/watch?v=${trimmed}`;
926
+ }
927
+
928
+ return null;
929
+ };
930
+
931
+ const normalizeProviderError = (error, { endpoint, requestId, input, timeoutMessage, fallbackMessage }) => {
932
+ if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
933
+
934
+ const stderr = String(error?.stderr || '').trim();
935
+ const stdout = String(error?.stdout || '').trim();
936
+ const combined = `${stderr}\n${stdout}\n${error?.message || ''}`.trim();
937
+ const low = combined.toLowerCase();
938
+
939
+ if (error?.code === 'ETIMEDOUT') {
940
+ return createError(ERROR_CODES.TIMEOUT, timeoutMessage, {
941
+ endpoint,
942
+ requestId,
943
+ input: truncateText(input || ''),
944
+ rawCode: error?.code || null,
945
+ technical: true,
946
+ });
947
+ }
948
+
949
+ if (low.includes('no matches found') || low.includes('unsupported url')) {
950
+ return createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
951
+ endpoint,
952
+ requestId,
953
+ input: truncateText(input || ''),
954
+ cause: truncateText(combined),
955
+ rawCode: error?.code || null,
956
+ technical: false,
957
+ });
958
+ }
959
+
960
+ if (low.includes('sign in to confirm') || low.includes('private video') || low.includes('video unavailable')) {
961
+ return createError(ERROR_CODES.API, playText('video_unavailable', 'Não foi possível acessar este vídeo agora. Tente outro link.'), {
962
+ endpoint,
963
+ requestId,
964
+ input: truncateText(input || ''),
965
+ cause: truncateText(combined),
966
+ rawCode: error?.code || null,
967
+ technical: false,
968
+ });
969
+ }
970
+
971
+ if (low.includes('ffmpeg') && low.includes('not found')) {
972
+ return createError(ERROR_CODES.API, playText('ffmpeg_not_found', 'ffmpeg não encontrado no servidor para processar esta mídia.'), {
973
+ endpoint,
974
+ requestId,
975
+ input: truncateText(input || ''),
976
+ cause: truncateText(combined),
977
+ rawCode: error?.code || null,
978
+ technical: true,
979
+ });
980
+ }
981
+
982
+ return createError(ERROR_CODES.API, fallbackMessage, {
983
+ endpoint,
984
+ requestId,
985
+ input: truncateText(input || ''),
986
+ rawCode: error?.code || null,
987
+ exitCode: error?.exitCode ?? null,
988
+ signal: error?.signal || null,
989
+ cause: truncateText(combined || 'unknown'),
990
+ technical: true,
991
+ });
992
+ };
993
+
994
+ const YTMP3_AUDIO_FORMATS = new Set(['mp3', 'm4a', 'flac', 'ogg', 'wav', 'opus']);
995
+ const YTMP3_AUDIO_BITRATES = new Set(['best', '320', '192', '128', '64']);
996
+ const YTMP3_DEFAULT_AUDIO_FORMAT = 'mp3';
997
+ const YTMP3_DEFAULT_AUDIO_BITRATE = '128';
998
+
999
+ const resolveYtmp3AudioSettings = () => {
1000
+ const executionOptions = getExecutionOptions();
1001
+ const formatOptions = executionOptions?.estrategias_formato || {};
1002
+ const audioExtract = formatOptions.audio_extract && typeof formatOptions.audio_extract === 'object' ? formatOptions.audio_extract : {};
1003
+
1004
+ const extractedFormat = String(audioExtract.format || YTMP3_DEFAULT_AUDIO_FORMAT)
1005
+ .trim()
1006
+ .toLowerCase();
1007
+ const audioFormat = YTMP3_AUDIO_FORMATS.has(extractedFormat) ? extractedFormat : YTMP3_DEFAULT_AUDIO_FORMAT;
1008
+
1009
+ const rawQuality = String(audioExtract.quality || YTMP3_DEFAULT_AUDIO_BITRATE)
1010
+ .trim()
1011
+ .toLowerCase();
1012
+ const mappedQuality = rawQuality === '0' ? 'best' : rawQuality.replace(/k$/i, '');
1013
+ const audioBitrate = YTMP3_AUDIO_BITRATES.has(mappedQuality) ? mappedQuality : YTMP3_DEFAULT_AUDIO_BITRATE;
1014
+
1015
+ return {
1016
+ audioFormat,
1017
+ audioBitrate,
1018
+ audioTrack: 'origin',
1019
+ };
1020
+ };
1021
+
1022
+ const resolveYtmp3RuntimeOs = () => {
1023
+ const platform = String(os.platform() || '').toLowerCase();
1024
+ if (platform === 'darwin') return 'macos';
1025
+ if (platform === 'win32') return 'windows';
1026
+ if (platform === 'android') return 'android';
1027
+ if (platform === 'linux') return 'linux';
1028
+ return 'windows';
1029
+ };
1030
+
1031
+ const unwrapApiData = (payload) => {
1032
+ if (payload && typeof payload === 'object' && payload.data && typeof payload.data === 'object') {
1033
+ return payload.data;
1034
+ }
1035
+ return payload;
1036
+ };
1037
+
1038
+ const normalizeYtmp3StatusPayload = (payload) => {
1039
+ const raw = unwrapApiData(payload);
1040
+ if (!raw || typeof raw !== 'object') {
1041
+ return {
1042
+ status: '',
1043
+ message: null,
1044
+ progress: null,
1045
+ downloadUrl: null,
1046
+ title: null,
1047
+ };
1048
+ }
1049
+
1050
+ return {
1051
+ status: String(raw.status || '')
1052
+ .trim()
1053
+ .toLowerCase(),
1054
+ message: pickFirstString(raw, ['jobError', 'message', 'error']),
1055
+ progress: toNumberOrNull(raw.progress),
1056
+ downloadUrl: resolveHttpUrl(raw.downloadUrl || raw.download_url),
1057
+ title: pickFirstString(raw, ['title', 'name']),
1058
+ };
1059
+ };
1060
+
1061
+ const buildYtmp3DownloadEndpoint = () => {
1062
+ const baseUrl = ensureHttpUrl(PLAY_YTMP3_API_BASE_URL);
1063
+ if (!baseUrl) {
1064
+ throw createError(ERROR_CODES.API, playText('download_failed', 'Falha ao baixar o arquivo localmente.'), {
1065
+ endpoint: YTDLS_ENDPOINTS.ytmp3Create,
1066
+ cause: `invalid_ytmp3_base_url:${PLAY_YTMP3_API_BASE_URL || 'empty'}`,
1067
+ technical: true,
1068
+ });
1069
+ }
1070
+
1071
+ const endpointUrl = resolveHttpUrl(PLAY_YTMP3_API_DOWNLOAD_PATH, baseUrl);
1072
+ if (!endpointUrl) {
1073
+ throw createError(ERROR_CODES.API, playText('download_failed', 'Falha ao baixar o arquivo localmente.'), {
1074
+ endpoint: YTDLS_ENDPOINTS.ytmp3Create,
1075
+ cause: `invalid_ytmp3_download_path:${PLAY_YTMP3_API_DOWNLOAD_PATH || 'empty'}`,
1076
+ technical: true,
1077
+ });
1078
+ }
1079
+
1080
+ return endpointUrl;
1081
+ };
1082
+
1083
+ const createYtmp3DownloadTask = async ({ link, type, requestId, audioFormat, audioBitrate, audioTrack, youtubeVideoContainer, videoQuality }) => {
1084
+ const endpointUrl = buildYtmp3DownloadEndpoint();
1085
+
1086
+ const downloadType = type === 'video' ? 'video' : 'audio';
1087
+ const payload = {
1088
+ url: link,
1089
+ os: resolveYtmp3RuntimeOs(),
1090
+ output: {
1091
+ type: downloadType,
1092
+ format: downloadType === 'video' ? youtubeVideoContainer || YTMP3_DEFAULT_VIDEO_CONTAINER : audioFormat || YTMP3_DEFAULT_AUDIO_FORMAT,
1093
+ },
1094
+ };
1095
+
1096
+ if (downloadType === 'video' && videoQuality) {
1097
+ payload.output.quality = `${String(videoQuality).replace(/p$/i, '')}p`;
1098
+ }
1099
+
1100
+ if (downloadType === 'audio') {
1101
+ const audioPayload = {};
1102
+ if (audioBitrate && String(audioBitrate).toLowerCase() !== 'best') {
1103
+ audioPayload.bitrate = `${String(audioBitrate).replace(/k$/i, '')}k`;
1104
+ }
1105
+ if (audioTrack && String(audioTrack).trim() && String(audioTrack).trim().toLowerCase() !== 'origin') {
1106
+ audioPayload.trackId = String(audioTrack).trim();
1107
+ }
1108
+ if (Object.keys(audioPayload).length) {
1109
+ payload.audio = audioPayload;
1110
+ }
1111
+ }
1112
+
1113
+ const response = await httpClient.requestJson({
1114
+ url: endpointUrl,
1115
+ method: 'POST',
1116
+ data: payload,
1117
+ endpoint: YTDLS_ENDPOINTS.ytmp3Create,
1118
+ timeoutMs: Math.min(DOWNLOAD_TIMEOUT_MS, 60000),
1119
+ timeoutMessage: playText('download_timeout', 'Timeout ao baixar o arquivo.'),
1120
+ fallbackMessage: playText('download_failed', 'Falha ao baixar o arquivo localmente.'),
1121
+ });
1122
+ const normalized = unwrapApiData(response);
1123
+ const statusUrl = resolveHttpUrl(normalized?.statusUrl || normalized?.status_url, endpointUrl);
1124
+
1125
+ if (!statusUrl) {
1126
+ throw createError(ERROR_CODES.API, playText('download_failed', 'Falha ao baixar o arquivo localmente.'), {
1127
+ endpoint: YTDLS_ENDPOINTS.ytmp3Create,
1128
+ requestId,
1129
+ input: truncateText(link || ''),
1130
+ cause: truncateText(JSON.stringify(normalized || {})),
1131
+ technical: true,
1132
+ });
1133
+ }
1134
+
1135
+ return {
1136
+ statusUrl,
1137
+ title: pickFirstString(normalized, ['title', 'name']),
1138
+ };
1139
+ };
1140
+
1141
+ const pollYtmp3UntilReady = async ({ statusUrl, requestId, input }) => {
1142
+ const timeoutMs = Math.max(15000, DOWNLOAD_TIMEOUT_MS);
1143
+ const pollIntervalMs = Math.max(500, Number(getLimits().ytmp3_poll_interval_ms ?? PLAY_YTMP3_POLL_INTERVAL_MS));
1144
+ const startedAt = __timeNowMs();
1145
+
1146
+ while (true) {
1147
+ const elapsedMs = __timeNowMs() - startedAt;
1148
+ if (elapsedMs >= timeoutMs) {
1149
+ throw createError(ERROR_CODES.TIMEOUT, playText('download_timeout', 'Timeout ao baixar o arquivo.'), {
1150
+ endpoint: YTDLS_ENDPOINTS.ytmp3Poll,
1151
+ requestId,
1152
+ input: truncateText(input || ''),
1153
+ statusUrl: truncateText(statusUrl, 400),
1154
+ technical: true,
1155
+ });
1156
+ }
1157
+
1158
+ const payload = await httpClient.requestJson({
1159
+ url: statusUrl,
1160
+ method: 'GET',
1161
+ endpoint: YTDLS_ENDPOINTS.ytmp3Poll,
1162
+ timeoutMs: Math.max(1000, Math.min(15000, timeoutMs - elapsedMs)),
1163
+ timeoutMessage: playText('download_timeout', 'Timeout ao baixar o arquivo.'),
1164
+ fallbackMessage: playText('download_failed', 'Falha ao baixar o arquivo localmente.'),
1165
+ });
1166
+ const status = normalizeYtmp3StatusPayload(payload);
1167
+
1168
+ if (status.downloadUrl && (!status.status || status.status === 'completed')) {
1169
+ return {
1170
+ downloadUrl: status.downloadUrl,
1171
+ title: status.title,
1172
+ };
1173
+ }
1174
+
1175
+ if (status.status === 'completed') {
1176
+ if (!status.downloadUrl) {
1177
+ throw createError(ERROR_CODES.API, playText('download_failed', 'Falha ao baixar o arquivo localmente.'), {
1178
+ endpoint: YTDLS_ENDPOINTS.ytmp3Poll,
1179
+ requestId,
1180
+ input: truncateText(input || ''),
1181
+ status: status.status,
1182
+ cause: status.message || 'missing_download_url',
1183
+ technical: true,
1184
+ });
1185
+ }
1186
+
1187
+ return {
1188
+ downloadUrl: status.downloadUrl,
1189
+ title: status.title,
1190
+ };
1191
+ }
1192
+
1193
+ if (status.status === 'failed' || status.status === 'error' || status.status === 'not_found') {
1194
+ throw createError(ERROR_CODES.API, playText('download_failed', 'Falha ao baixar o arquivo localmente.'), {
1195
+ endpoint: YTDLS_ENDPOINTS.ytmp3Poll,
1196
+ requestId,
1197
+ input: truncateText(input || ''),
1198
+ status: status.status,
1199
+ cause: truncateText(status.message || 'ytmp3_failed'),
1200
+ technical: true,
1201
+ });
1202
+ }
1203
+
1204
+ await delay(pollIntervalMs);
1205
+ }
1206
+ };
1207
+
1208
+ const YTMP3_VIDEO_CONTAINERS = new Set(['mp4', 'webm', 'mkv']);
1209
+ const YTMP3_DEFAULT_VIDEO_CONTAINER = 'mp4';
1210
+
1211
+ const resolveYtmp3VideoSettings = () => {
1212
+ const executionOptions = getExecutionOptions();
1213
+ const formatOptions = executionOptions?.estrategias_formato || {};
1214
+ const mergeOutputFormat = String(formatOptions.video_merge_output_format || YTMP3_DEFAULT_VIDEO_CONTAINER)
1215
+ .trim()
1216
+ .toLowerCase();
1217
+ const youtubeVideoContainer = YTMP3_VIDEO_CONTAINERS.has(mergeOutputFormat) ? mergeOutputFormat : YTMP3_DEFAULT_VIDEO_CONTAINER;
1218
+ const rawQuality = String(PLAY_YTMP3_VIDEO_DEFAULT_QUALITY || '720')
1219
+ .trim()
1220
+ .replace(/p$/i, '');
1221
+ const videoQuality = /^\d{3,4}$/.test(rawQuality) ? rawQuality : '720';
1222
+
1223
+ return {
1224
+ youtubeVideoContainer,
1225
+ videoQuality,
1226
+ };
1227
+ };
1228
+
1229
+ const isYtmp3PrimaryEligible = ({ type, link }) => PLAY_YTMP3_ENABLED && (type === 'audio' || type === 'video') && isYouTubeUrl(link);
1230
+
1231
+ const requestYtmp3DownloadToFile = async ({ link, type, requestId, basePath }) => {
1232
+ const audioSettings = resolveYtmp3AudioSettings();
1233
+ const videoSettings = resolveYtmp3VideoSettings();
1234
+ const targetExt = type === 'video' ? videoSettings.youtubeVideoContainer : audioSettings.audioFormat || YTMP3_DEFAULT_AUDIO_FORMAT;
1235
+ const targetPath = `${basePath}.${targetExt}`;
1236
+
1237
+ return runWithPlayProcessSlot(
1238
+ async () => {
1239
+ const task = await createYtmp3DownloadTask({
1240
+ link,
1241
+ type,
1242
+ requestId,
1243
+ audioFormat: audioSettings.audioFormat,
1244
+ audioBitrate: audioSettings.audioBitrate,
1245
+ audioTrack: audioSettings.audioTrack,
1246
+ youtubeVideoContainer: videoSettings.youtubeVideoContainer,
1247
+ videoQuality: videoSettings.videoQuality,
1248
+ });
1249
+
1250
+ const ready = await pollYtmp3UntilReady({
1251
+ statusUrl: task.statusUrl,
1252
+ requestId,
1253
+ input: link,
1254
+ });
1255
+
1256
+ const downloaded = await httpClient.requestFile({
1257
+ url: ready.downloadUrl,
1258
+ filePath: targetPath,
1259
+ endpoint: YTDLS_ENDPOINTS.ytmp3Download,
1260
+ timeoutMs: DOWNLOAD_TIMEOUT_MS,
1261
+ timeoutMessage: playText('download_timeout', 'Timeout ao baixar o arquivo.'),
1262
+ fallbackMessage: playText('download_failed', 'Falha ao baixar o arquivo localmente.'),
1263
+ });
1264
+
1265
+ return {
1266
+ filePath: targetPath,
1267
+ bytes: downloaded.bytes,
1268
+ contentType: resolveMediaMimeType(type, downloaded.contentType),
1269
+ mediaType: type,
1270
+ };
1271
+ },
1272
+ {
1273
+ endpoint: YTDLS_ENDPOINTS.ytmp3Create,
1274
+ command: 'ytmp3',
1275
+ },
1276
+ );
1277
+ };
1278
+
1279
+ const buildYtmp3SearchEndpoint = () => {
1280
+ const baseUrl = ensureHttpUrl(PLAY_YTMP3_SEARCH_BASE_URL);
1281
+ if (!baseUrl) {
1282
+ throw createError(ERROR_CODES.API, playText('search_failed', 'Não foi possível buscar o vídeo agora.'), {
1283
+ endpoint: YTDLS_ENDPOINTS.ytmp3Search,
1284
+ cause: `invalid_ytmp3_search_base_url:${PLAY_YTMP3_SEARCH_BASE_URL || 'empty'}`,
1285
+ technical: true,
1286
+ });
1287
+ }
1288
+
1289
+ const endpointUrl = resolveHttpUrl(PLAY_YTMP3_SEARCH_PATH, baseUrl);
1290
+ if (!endpointUrl) {
1291
+ throw createError(ERROR_CODES.API, playText('search_failed', 'Não foi possível buscar o vídeo agora.'), {
1292
+ endpoint: YTDLS_ENDPOINTS.ytmp3Search,
1293
+ cause: `invalid_ytmp3_search_path:${PLAY_YTMP3_SEARCH_PATH || 'empty'}`,
1294
+ technical: true,
1295
+ });
1296
+ }
1297
+
1298
+ return endpointUrl;
1299
+ };
1300
+
1301
+ const extractYoutubeVideoIdFromUrl = (value) => {
1302
+ const normalized = ensureHttpUrl(value);
1303
+ if (!normalized) return null;
1304
+
1305
+ try {
1306
+ const parsed = new URL(normalized);
1307
+ const hostname = parsed.hostname.toLowerCase();
1308
+ if (hostname === 'youtu.be' || hostname.endsWith('.youtu.be')) {
1309
+ const candidate = parsed.pathname.replace(/^\/+/, '').split('/')[0];
1310
+ if (candidate) return candidate;
1311
+ }
1312
+
1313
+ const v = parsed.searchParams.get('v');
1314
+ if (v) return v;
1315
+
1316
+ const parts = parsed.pathname.split('/').filter(Boolean);
1317
+ if (parts.length >= 2 && ['shorts', 'embed', 'live', 'v'].includes(parts[0].toLowerCase())) {
1318
+ return parts[1];
1319
+ }
1320
+ } catch {
1321
+ return null;
1322
+ }
1323
+
1324
+ return null;
1325
+ };
1326
+
1327
+ const normalizeYtmp3SearchItem = (item) => {
1328
+ if (!item || typeof item !== 'object') return null;
1329
+ if (item.type && item.type !== 'stream') return null;
1330
+
1331
+ const url = normalizeYoutubeWatchUrl(item.id || item.url || item.webpage_url);
1332
+ if (!url || !isYouTubeUrl(url)) return null;
1333
+
1334
+ const thumbnailUrl = resolveHttpUrl(item.thumbnailUrl || item.thumbnail);
1335
+
1336
+ return {
1337
+ id: extractYoutubeVideoIdFromUrl(url) || pickFirstString(item, ['id', 'videoId', 'video_id']),
1338
+ title: pickFirstString(item, ['title', 'name']) || 'Sem título',
1339
+ channel: pickFirstString(item, ['uploaderName', 'channel', 'author', 'uploader']),
1340
+ uploader: pickFirstString(item, ['uploaderName', 'uploader', 'channel', 'author']),
1341
+ duration: toNumberOrNull(item.duration) ?? item.duration ?? null,
1342
+ thumbnail: thumbnailUrl || null,
1343
+ thumbnails: thumbnailUrl ? [{ url: thumbnailUrl }] : [],
1344
+ url,
1345
+ webpage_url: url,
1346
+ };
1347
+ };
1348
+
1349
+ const fetchYouTubeOEmbedInfo = async (url) => {
1350
+ const oembedEndpoint = 'https://www.youtube.com/oembed';
1351
+ const response = await httpClient.requestJson({
1352
+ url: oembedEndpoint,
1353
+ method: 'GET',
1354
+ data: {
1355
+ url,
1356
+ format: 'json',
1357
+ },
1358
+ endpoint: YTDLS_ENDPOINTS.ytmp3Metadata,
1359
+ timeoutMs: Math.min(15000, MEDIA_INFO_TIMEOUT_MS),
1360
+ timeoutMessage: playText('search_timeout', 'Timeout ao buscar metadados do vídeo.'),
1361
+ fallbackMessage: playText('search_failed', 'Não foi possível buscar o vídeo agora.'),
1362
+ });
1363
+
1364
+ const normalizedUrl = normalizeYoutubeWatchUrl(url);
1365
+ const thumbnailUrl = resolveHttpUrl(response?.thumbnail_url);
1366
+
1367
+ return {
1368
+ id: extractYoutubeVideoIdFromUrl(normalizedUrl),
1369
+ title: pickFirstString(response, ['title']) || 'Sem título',
1370
+ channel: pickFirstString(response, ['author_name']),
1371
+ uploader: pickFirstString(response, ['author_name']),
1372
+ duration: null,
1373
+ thumbnail: thumbnailUrl || null,
1374
+ thumbnails: thumbnailUrl ? [{ url: thumbnailUrl }] : [],
1375
+ url: normalizedUrl,
1376
+ webpage_url: normalizedUrl,
1377
+ };
1378
+ };
1379
+
1380
+ const fetchSearchResult = async (query) => {
1381
+ const normalized = typeof query === 'string' ? query.trim() : '';
1382
+ if (!normalized) {
1383
+ throw createError(ERROR_CODES.INVALID_INPUT, playText('search_invalid_input', 'Você precisa informar um link do YouTube ou termo de busca.'), {
1384
+ endpoint: YTDLS_ENDPOINTS.search,
1385
+ technical: false,
1386
+ });
1387
+ }
1388
+
1389
+ const cacheKey = normalized.toLowerCase();
1390
+ const cached = getSearchCache(cacheKey);
1391
+ if (cached) {
1392
+ return cached;
1393
+ }
1394
+
1395
+ const endpoint = YTDLS_ENDPOINTS.search;
1396
+ const isUrlLookup = /^https?:\/\//i.test(normalized);
1397
+ const maxSearchResults = getLimits().max_search_results ?? MAX_SEARCH_RESULTS;
1398
+ const normalizedUrlLookup = isUrlLookup ? normalizeYoutubeWatchUrl(normalized) : null;
1399
+
1400
+ const payload = await retryAsync(
1401
+ async () => {
1402
+ let normalizedEntries = [];
1403
+
1404
+ if (isUrlLookup) {
1405
+ if (!normalizedUrlLookup || !isYouTubeUrl(normalizedUrlLookup)) {
1406
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1407
+ endpoint,
1408
+ technical: false,
1409
+ });
1410
+ }
1411
+
1412
+ try {
1413
+ const oembedInfo = await fetchYouTubeOEmbedInfo(normalizedUrlLookup);
1414
+ normalizedEntries = [oembedInfo].filter(Boolean);
1415
+ } catch {
1416
+ normalizedEntries = [
1417
+ {
1418
+ id: extractYoutubeVideoIdFromUrl(normalizedUrlLookup),
1419
+ title: 'Sem título',
1420
+ channel: null,
1421
+ uploader: null,
1422
+ duration: null,
1423
+ thumbnail: null,
1424
+ thumbnails: [],
1425
+ url: normalizedUrlLookup,
1426
+ webpage_url: normalizedUrlLookup,
1427
+ },
1428
+ ];
1429
+ }
1430
+ } else {
1431
+ const searchEndpoint = buildYtmp3SearchEndpoint();
1432
+ const response = await httpClient.requestJson({
1433
+ url: searchEndpoint,
1434
+ method: 'GET',
1435
+ data: {
1436
+ q: normalized,
1437
+ limit: maxSearchResults,
1438
+ },
1439
+ endpoint: YTDLS_ENDPOINTS.ytmp3Search,
1440
+ timeoutMs: Math.min(20000, MEDIA_INFO_TIMEOUT_MS),
1441
+ timeoutMessage: playText('search_timeout', 'Timeout ao buscar metadados do vídeo.'),
1442
+ fallbackMessage: playText('search_failed', 'Não foi possível buscar o vídeo agora.'),
1443
+ });
1444
+
1445
+ const raw = unwrapApiData(response);
1446
+ const items = Array.isArray(raw?.items) ? raw.items : [];
1447
+ normalizedEntries = items.map((item) => normalizeYtmp3SearchItem(item)).filter((entry) => entry?.url);
1448
+ }
1449
+
1450
+ const info = normalizedEntries[0] || null;
1451
+
1452
+ if (!info?.url) {
1453
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1454
+ endpoint,
1455
+ technical: false,
1456
+ });
1457
+ }
1458
+
1459
+ return {
1460
+ sucesso: true,
1461
+ resultado: info,
1462
+ resultados: normalizedEntries,
1463
+ };
1464
+ },
1465
+ {
1466
+ retries: getLimits().search_retry_count ?? 1,
1467
+ shouldRetry: isTransientError,
1468
+ onRetry: (error, attempt) => {
1469
+ logger.warn('Play busca local: retry acionado.', {
1470
+ endpoint,
1471
+ attempt,
1472
+ code: error?.code,
1473
+ status: error?.meta?.status || null,
1474
+ });
1475
+ },
1476
+ },
1477
+ );
1478
+
1479
+ setSearchCache(cacheKey, payload);
1480
+ return payload;
1481
+ };
1482
+
1483
+ const resolveYoutubeLink = async (query) => {
1484
+ const normalized = query ? query.trim() : '';
1485
+
1486
+ if (!normalized) {
1487
+ throw createError(ERROR_CODES.INVALID_INPUT, playText('search_invalid_input', 'Você precisa informar um link do YouTube ou termo de busca.'), {
1488
+ endpoint: YTDLS_ENDPOINTS.search,
1489
+ technical: false,
1490
+ });
1491
+ }
1492
+
1493
+ if (/^https?:\/\//i.test(normalized)) {
1494
+ const direct = normalizeYoutubeWatchUrl(normalized);
1495
+ if (!direct || !isYouTubeUrl(direct)) {
1496
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1497
+ endpoint: YTDLS_ENDPOINTS.search,
1498
+ technical: false,
1499
+ });
1500
+ }
1501
+ return direct;
1502
+ }
1503
+
1504
+ const searchResult = await fetchSearchResult(normalized);
1505
+ if (!searchResult?.resultado?.url) {
1506
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1507
+ endpoint: YTDLS_ENDPOINTS.search,
1508
+ technical: false,
1509
+ });
1510
+ }
1511
+
1512
+ return searchResult.resultado.url;
1513
+ };
1514
+
1515
+ const extractCandidateUrlsFromSearchResult = (searchResult) => {
1516
+ const urls = [];
1517
+ const seen = new Set();
1518
+
1519
+ const pushUrl = (value) => {
1520
+ const url = ensureHttpUrl(value);
1521
+ if (!url) return;
1522
+ if (seen.has(url)) return;
1523
+ seen.add(url);
1524
+ urls.push(url);
1525
+ };
1526
+
1527
+ if (searchResult?.resultado?.url) {
1528
+ pushUrl(searchResult.resultado.url);
1529
+ }
1530
+
1531
+ if (Array.isArray(searchResult?.resultados)) {
1532
+ for (const item of searchResult.resultados) {
1533
+ pushUrl(item?.url);
1534
+ }
1535
+ }
1536
+
1537
+ return urls;
1538
+ };
1539
+
1540
+ const resolveYoutubeCandidates = async (query) => {
1541
+ const normalized = query ? query.trim() : '';
1542
+
1543
+ if (!normalized) {
1544
+ throw createError(ERROR_CODES.INVALID_INPUT, playText('search_invalid_input', 'Você precisa informar um link do YouTube ou termo de busca.'), {
1545
+ endpoint: YTDLS_ENDPOINTS.search,
1546
+ technical: false,
1547
+ });
1548
+ }
1549
+
1550
+ if (/^https?:\/\//i.test(normalized)) {
1551
+ const direct = normalizeYoutubeWatchUrl(normalized);
1552
+ if (!direct || !isYouTubeUrl(direct)) {
1553
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1554
+ endpoint: YTDLS_ENDPOINTS.search,
1555
+ technical: false,
1556
+ });
1557
+ }
1558
+ return [direct];
1559
+ }
1560
+
1561
+ const searchResult = await fetchSearchResult(normalized);
1562
+ const urls = extractCandidateUrlsFromSearchResult(searchResult);
1563
+
1564
+ if (!urls.length) {
1565
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1566
+ endpoint: YTDLS_ENDPOINTS.search,
1567
+ technical: false,
1568
+ });
1569
+ }
1570
+
1571
+ return urls;
1572
+ };
1573
+
1574
+ const isYouTubeBotCheckCause = (error) => {
1575
+ const cause = String(error?.meta?.cause || '').toLowerCase();
1576
+ const message = String(error?.message || '').toLowerCase();
1577
+ return cause.includes('sign in to confirm') || message.includes('sign in to confirm');
1578
+ };
1579
+
1580
+ const buildYouTubeBotCheckUserMessage = () => {
1581
+ return getPlayText('anti_bot_without_cookies', 'YouTube solicitou verificação anti-bot no provedor de mídia. Tente novamente em alguns minutos.');
1582
+ };
1583
+
1584
+ const fetchVideoInfo = async (query, fallback) => {
1585
+ const tryQuery = async (value) => {
1586
+ if (!value) return null;
1587
+ try {
1588
+ const result = await fetchSearchResult(value);
1589
+ if (!result?.sucesso || !result?.resultado) return null;
1590
+ return result.resultado;
1591
+ } catch {
1592
+ return null;
1593
+ }
1594
+ };
1595
+
1596
+ const first = await tryQuery(query);
1597
+ if (first) return first;
1598
+
1599
+ const normalizedQuery = typeof query === 'string' ? query.trim().toLowerCase() : '';
1600
+ const normalizedFallback = typeof fallback === 'string' ? fallback.trim().toLowerCase() : '';
1601
+ if (normalizedFallback && normalizedFallback !== normalizedQuery) {
1602
+ return tryQuery(fallback);
1603
+ }
1604
+
1605
+ return null;
1606
+ };
1607
+
1608
+ const fetchQueueStatus = async (requestId) => {
1609
+ void requestId;
1610
+ return null;
1611
+ };
1612
+
1613
+ const inferMimeFromFilePath = (filePath, type) => {
1614
+ const ext = path.extname(filePath || '').toLowerCase();
1615
+ if (type === 'audio') {
1616
+ if (ext === '.m4a' || ext === '.mp4') return 'audio/mp4';
1617
+ if (ext === '.ogg' || ext === '.opus') return 'audio/ogg';
1618
+ if (ext === '.wav') return 'audio/wav';
1619
+ return TYPE_CONFIG.audio.mimeFallback;
1620
+ }
1621
+
1622
+ if (type === 'video') {
1623
+ if (ext === '.webm') return 'video/webm';
1624
+ return TYPE_CONFIG.video.mimeFallback;
1625
+ }
1626
+
1627
+ return 'application/octet-stream';
1628
+ };
1629
+
1630
+ const findDownloadedFileByBase = async (basePath, preferredExt) => {
1631
+ const dir = path.dirname(basePath);
1632
+ const baseName = path.basename(basePath);
1633
+
1634
+ let entries = [];
1635
+ try {
1636
+ entries = await fs.promises.readdir(dir);
1637
+ } catch {
1638
+ return null;
1639
+ }
1640
+
1641
+ const candidates = entries.filter((name) => name.startsWith(`${baseName}.`));
1642
+ if (!candidates.length) return null;
1643
+
1644
+ if (preferredExt) {
1645
+ const preferred = candidates.find((name) => path.extname(name).toLowerCase() === `.${preferredExt.toLowerCase()}`);
1646
+ if (preferred) {
1647
+ return path.join(dir, preferred);
1648
+ }
1649
+ }
1650
+
1651
+ const stats = await Promise.all(
1652
+ candidates.map(async (name) => {
1653
+ const fullPath = path.join(dir, name);
1654
+ try {
1655
+ const stat = await fs.promises.stat(fullPath);
1656
+ return { fullPath, mtimeMs: stat.mtimeMs };
1657
+ } catch {
1658
+ return null;
1659
+ }
1660
+ }),
1661
+ );
1662
+
1663
+ const existing = stats.filter(Boolean);
1664
+ if (!existing.length) return null;
1665
+
1666
+ existing.sort((a, b) => b.mtimeMs - a.mtimeMs);
1667
+ return existing[0].fullPath;
1668
+ };
1669
+
1670
+ const cleanupDownloadedArtifacts = async (basePath) => {
1671
+ const dir = path.dirname(basePath);
1672
+ const baseName = path.basename(basePath);
1673
+
1674
+ let entries = [];
1675
+ try {
1676
+ entries = await fs.promises.readdir(dir);
1677
+ } catch {
1678
+ return;
1679
+ }
1680
+
1681
+ const targets = entries.filter((name) => name.startsWith(`${baseName}.`));
1682
+ await Promise.allSettled(targets.map((name) => safeUnlink(path.join(dir, name))));
1683
+ };
1684
+
1685
+ const requestDownloadToFile = async (link, type, requestId) => {
1686
+ const endpoint = YTDLS_ENDPOINTS.ytmp3Download;
1687
+ const safeId = String(requestId || 'req')
1688
+ .replace(/[^a-z0-9-_]+/gi, '')
1689
+ .slice(0, 48);
1690
+ const basePath = path.join(PLAY_DOWNLOADS_DIR, `play-${safeId}-${__timeNowMs()}`);
1691
+ let filePath = null;
1692
+ let providerResult = null;
1693
+
1694
+ try {
1695
+ await ensurePlayLocalDirs();
1696
+
1697
+ if (!isYtmp3PrimaryEligible({ type, link })) {
1698
+ throw createError(ERROR_CODES.NOT_FOUND, playText('search_not_found', 'Nenhum resultado encontrado para a busca.'), {
1699
+ endpoint: YTDLS_ENDPOINTS.ytmp3Download,
1700
+ requestId,
1701
+ input: truncateText(link || ''),
1702
+ technical: false,
1703
+ });
1704
+ }
1705
+
1706
+ providerResult = await requestYtmp3DownloadToFile({
1707
+ link,
1708
+ type,
1709
+ requestId,
1710
+ basePath,
1711
+ });
1712
+
1713
+ logger.info('Play download: ytmp3 concluído.', {
1714
+ requestId,
1715
+ endpoint: YTDLS_ENDPOINTS.ytmp3Download,
1716
+ type,
1717
+ bytes: providerResult?.bytes || 0,
1718
+ });
1719
+
1720
+ filePath = providerResult?.filePath || (await findDownloadedFileByBase(basePath, type === 'audio' ? 'mp3' : 'mp4'));
1721
+ if (!filePath) {
1722
+ throw createError(ERROR_CODES.API, playText('download_file_not_found', 'Não foi possível localizar o arquivo baixado.'), {
1723
+ endpoint,
1724
+ requestId,
1725
+ technical: true,
1726
+ });
1727
+ }
1728
+
1729
+ let stat = await fs.promises.stat(filePath);
1730
+ let finalBytes = Number(stat?.size || 0);
1731
+ let finalMimeType = providerResult?.contentType || inferMimeFromFilePath(filePath, type);
1732
+ let finalMediaType = providerResult?.mediaType || type;
1733
+
1734
+ if (finalBytes <= 0) {
1735
+ throw createError(ERROR_CODES.API, playText('download_invalid_media', 'Falha ao baixar mídia válida.'), {
1736
+ endpoint,
1737
+ requestId,
1738
+ filePath,
1739
+ technical: true,
1740
+ });
1741
+ }
1742
+
1743
+ if (finalBytes > MAX_MEDIA_BYTES) {
1744
+ throw createError(ERROR_CODES.TOO_BIG, playText('media_too_big', `O arquivo excede o limite permitido de ${MAX_MEDIA_MB_LABEL} MB.`, { max_mb: MAX_MEDIA_MB_LABEL }), {
1745
+ endpoint,
1746
+ requestId,
1747
+ bytes: finalBytes,
1748
+ technical: false,
1749
+ });
1750
+ }
1751
+
1752
+ if (type === 'video') {
1753
+ const streamInfo = await probeVideoStreams(filePath, requestId, endpoint);
1754
+
1755
+ if (!streamInfo.hasVideo) {
1756
+ if (streamInfo.hasAudio) {
1757
+ finalMediaType = 'audio';
1758
+ finalMimeType = inferMimeFromFilePath(filePath, 'audio');
1759
+
1760
+ logger.warn('Play vídeo: fonte retornou somente áudio, fallback ativado.', {
1761
+ requestId,
1762
+ endpoint,
1763
+ bytes: finalBytes,
1764
+ audioCodec: streamInfo.audioCodec || null,
1765
+ });
1766
+ } else {
1767
+ throw createError(ERROR_CODES.API, playText('video_without_streams', 'Não foi possível enviar como vídeo: a mídia não possui faixa de vídeo nem áudio.'), {
1768
+ endpoint,
1769
+ requestId,
1770
+ hasAudio: streamInfo.hasAudio,
1771
+ videoCodec: streamInfo.videoCodec,
1772
+ audioCodec: streamInfo.audioCodec,
1773
+ technical: true,
1774
+ });
1775
+ }
1776
+ }
1777
+
1778
+ if (finalMediaType === 'video') {
1779
+ if (VIDEO_FORCE_TRANSCODE || streamInfo.videoCodec !== 'h264' || (streamInfo.hasAudio && streamInfo.audioCodec !== 'aac')) {
1780
+ finalBytes = await transcodeVideoForWhatsapp(filePath, requestId, endpoint);
1781
+ finalMimeType = TYPE_CONFIG.video.mimeFallback;
1782
+ logger.info('Play vídeo normalizado para compatibilidade.', {
1783
+ requestId,
1784
+ endpoint,
1785
+ originalVideoCodec: streamInfo.videoCodec || null,
1786
+ originalAudioCodec: streamInfo.audioCodec || null,
1787
+ bytes: finalBytes,
1788
+ });
1789
+ }
1790
+ }
1791
+ }
1792
+
1793
+ stat = await fs.promises.stat(filePath);
1794
+ finalBytes = Number(stat?.size || finalBytes || 0);
1795
+
1796
+ return {
1797
+ filePath,
1798
+ contentType: finalMimeType || resolveMediaMimeType(finalMediaType, null),
1799
+ bytes: finalBytes,
1800
+ mediaType: finalMediaType,
1801
+ };
1802
+ } catch (error) {
1803
+ await cleanupDownloadedArtifacts(basePath);
1804
+ const normalized =
1805
+ KNOWN_ERROR_CODES.has(error?.code) && error?.message
1806
+ ? error
1807
+ : normalizeProviderError(error, {
1808
+ endpoint,
1809
+ requestId,
1810
+ input: link,
1811
+ timeoutMessage: playText('download_timeout', 'Timeout ao baixar o arquivo.'),
1812
+ fallbackMessage: playText('download_failed', 'Falha ao baixar o arquivo localmente.'),
1813
+ });
1814
+ throw withErrorMeta(normalized, { endpoint: normalized?.meta?.endpoint || endpoint, filePath, provider: 'ytmp3' });
1815
+ }
1816
+ };
1817
+
1818
+ const fetchThumbnailBuffer = async (url) =>
1819
+ retryAsync(
1820
+ () =>
1821
+ httpClient.requestBuffer({
1822
+ url,
1823
+ timeoutMs: getLimits().thumbnail_timeout_ms ?? THUMBNAIL_TIMEOUT_MS,
1824
+ maxBytes: getLimits().max_thumb_bytes ?? MAX_THUMB_BYTES,
1825
+ endpoint: YTDLS_ENDPOINTS.thumbnail,
1826
+ }),
1827
+ {
1828
+ retries: getLimits().thumbnail_retry_count ?? 1,
1829
+ shouldRetry: isTransientError,
1830
+ onRetry: (error, attempt) => {
1831
+ logger.warn('Play thumbnail: retry acionado.', {
1832
+ endpoint: YTDLS_ENDPOINTS.thumbnail,
1833
+ attempt,
1834
+ code: error?.code,
1835
+ status: error?.meta?.status || null,
1836
+ });
1837
+ },
1838
+ },
1839
+ );
1840
+
1841
+ const playMediaClient = {
1842
+ resolveYoutubeLink,
1843
+ resolveYoutubeCandidates,
1844
+ fetchVideoInfo,
1845
+ fetchQueueStatus,
1846
+ requestDownloadToFile,
1847
+ fetchThumbnailBuffer,
1848
+ };
1849
+
1850
+ const formatters = {
1851
+ formatNumber,
1852
+ formatDuration,
1853
+ formatVideoInfo,
1854
+ getThumbnailUrl,
1855
+ buildQueueStatusText,
1856
+ buildReadyCaption,
1857
+ };
1858
+
1859
+ const fileUtils = {
1860
+ buildTempFilePath,
1861
+ safeUnlink,
1862
+ };
1863
+
1864
+ export const __playMediaClientTestUtils = {
1865
+ extractCandidateUrlsFromSearchResult,
1866
+ isYtmp3PrimaryEligible,
1867
+ isYouTubeBotCheckCause,
1868
+ buildYouTubeBotCheckUserMessage,
1869
+ getProcessLimiterStats: () => playProcessLimiter.stats(),
1870
+ };
1871
+
1872
+ export { createError, withErrorMeta, normalizePlayError, truncateText, playMediaClient, formatters, fileUtils, isYouTubeBotCheckCause, buildYouTubeBotCheckUserMessage };