@omnizap-system/omnizap 2.6.1 → 2.6.3

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 (172) hide show
  1. package/.env.example +78 -9
  2. package/.github/workflows/ci.yml +3 -3
  3. package/.github/workflows/security-runner-hardening.yml +1 -1
  4. package/.github/workflows/security-zap-full-scan.yml +1 -0
  5. package/app/config/index.js +6 -0
  6. package/app/configParts/adminIdentity.js +36 -7
  7. package/app/configParts/baileysConfig.js +343 -56
  8. package/app/configParts/groupUtils.js +226 -0
  9. package/app/configParts/loggerConfig.js +185 -0
  10. package/app/configParts/messagePersistenceService.js +307 -5
  11. package/app/configParts/sessionConfig.js +242 -0
  12. package/app/connection/baileysCompatibility.test.js +10 -1
  13. package/app/connection/baileysDbAuthState.js +205 -9
  14. package/app/connection/baileysLibsignalPatch.js +210 -0
  15. package/app/connection/groupOwnerWriteStateResolver.js +141 -0
  16. package/app/connection/socketController.js +694 -123
  17. package/app/connection/socketController.multiSession.test.js +128 -0
  18. package/app/controllers/messageController.js +1 -1
  19. package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
  20. package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
  21. package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
  22. package/app/controllers/messagePipeline/preProcessingMiddlewares.js +96 -4
  23. package/app/controllers/messageProcessingPipeline.js +90 -9
  24. package/app/controllers/messageProcessingPipeline.test.js +202 -0
  25. package/app/modules/adminModule/AGENT.md +1 -1
  26. package/app/modules/adminModule/commandConfig.json +3318 -1347
  27. package/app/modules/adminModule/groupCommandHandlers.js +856 -14
  28. package/app/modules/adminModule/groupCommandHandlers.test.js +375 -9
  29. package/app/modules/adminModule/groupWarningRepository.js +152 -0
  30. package/app/modules/aiModule/AGENT.md +47 -30
  31. package/app/modules/aiModule/aiConfigRuntime.js +1 -0
  32. package/app/modules/aiModule/catCommand.js +132 -25
  33. package/app/modules/aiModule/commandConfig.json +114 -28
  34. package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
  35. package/app/modules/gameModule/AGENT.md +1 -1
  36. package/app/modules/gameModule/commandConfig.json +29 -0
  37. package/app/modules/menuModule/AGENT.md +1 -1
  38. package/app/modules/menuModule/commandConfig.json +45 -10
  39. package/app/modules/menuModule/menuCatalogService.js +190 -0
  40. package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
  41. package/app/modules/menuModule/menuDynamicService.js +511 -0
  42. package/app/modules/menuModule/menuDynamicService.test.js +141 -0
  43. package/app/modules/menuModule/menus.js +36 -5
  44. package/app/modules/playModule/AGENT.md +10 -5
  45. package/app/modules/playModule/commandConfig.json +74 -16
  46. package/app/modules/playModule/playCommandConstants.js +13 -7
  47. package/app/modules/playModule/playCommandCore.js +4 -6
  48. package/app/modules/playModule/{playCommandYtDlpClient.js → playCommandMediaClient.js} +684 -332
  49. package/app/modules/playModule/playConfigRuntime.js +5 -6
  50. package/app/modules/playModule/playModuleCriticalFlows.test.js +44 -59
  51. package/app/modules/quoteModule/AGENT.md +1 -1
  52. package/app/modules/quoteModule/commandConfig.json +29 -0
  53. package/app/modules/rpgPokemonModule/AGENT.md +1 -1
  54. package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
  55. package/app/modules/statsModule/AGENT.md +1 -1
  56. package/app/modules/statsModule/commandConfig.json +58 -0
  57. package/app/modules/stickerModule/AGENT.md +1 -1
  58. package/app/modules/stickerModule/commandConfig.json +145 -0
  59. package/app/modules/stickerPackModule/AGENT.md +1 -1
  60. package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
  61. package/app/modules/stickerPackModule/commandConfig.json +29 -0
  62. package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +1 -1
  63. package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +78 -57
  64. package/app/modules/stickerPackModule/stickerPackService.js +13 -6
  65. package/app/modules/systemMetricsModule/AGENT.md +1 -1
  66. package/app/modules/systemMetricsModule/commandConfig.json +29 -0
  67. package/app/modules/tiktokModule/AGENT.md +1 -1
  68. package/app/modules/tiktokModule/commandConfig.json +29 -0
  69. package/app/modules/userModule/AGENT.md +1 -1
  70. package/app/modules/userModule/commandConfig.json +29 -0
  71. package/app/modules/waifuPicsModule/AGENT.md +57 -27
  72. package/app/modules/waifuPicsModule/commandConfig.json +87 -0
  73. package/app/observability/metrics.js +136 -0
  74. package/app/services/ai/commandConfigEnrichmentService.js +229 -47
  75. package/app/services/ai/geminiService.js +131 -7
  76. package/app/services/ai/geminiService.test.js +59 -2
  77. package/app/services/ai/moduleAiHelpCoreService.js +33 -4
  78. package/app/services/group/groupMetadataService.js +24 -1
  79. package/app/services/infra/dbWriteQueue.js +51 -21
  80. package/app/services/messaging/newsBroadcastService.js +843 -27
  81. package/app/services/multiSession/assignmentBalancerService.js +452 -0
  82. package/app/services/multiSession/groupOwnershipRepository.js +346 -0
  83. package/app/services/multiSession/groupOwnershipService.js +809 -0
  84. package/app/services/multiSession/groupOwnershipService.test.js +317 -0
  85. package/app/services/multiSession/sessionRegistryService.js +239 -0
  86. package/app/store/aiPromptStore.js +36 -19
  87. package/app/store/groupConfigStore.js +41 -5
  88. package/app/store/premiumUserStore.js +21 -7
  89. package/app/utils/antiLink/antiLinkModule.js +391 -25
  90. package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
  91. package/database/index.js +6 -0
  92. package/database/migrations/20260307_d0_hardening_down.sql +1 -1
  93. package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
  94. package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
  95. package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
  96. package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
  97. package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
  98. package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
  99. package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
  100. package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
  101. package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
  102. package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
  103. package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
  104. package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
  105. package/database/schema.sql +102 -1
  106. package/docker-compose.yml +4 -1
  107. package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
  108. package/docs/compliance/privacy-policy-2026-03-07.md +2 -2
  109. package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
  110. package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
  111. package/docs/security/omnizap-static-security-headers.conf +25 -0
  112. package/ecosystem.prod.config.cjs +31 -11
  113. package/index.js +52 -18
  114. package/observability/alert-rules.yml +20 -0
  115. package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
  116. package/observability/mysql-setup.sql +4 -4
  117. package/observability/system-admin-observability.md +26 -0
  118. package/package.json +14 -6
  119. package/public/comandos/commands-catalog.json +2253 -78
  120. package/public/css/payments-react.css +478 -0
  121. package/public/js/apps/commandsReactApp.js +267 -87
  122. package/public/js/apps/createPackApp.js +3 -3
  123. package/public/js/apps/homeReactApp.js +2 -2
  124. package/public/js/apps/paymentsCancelReactApp.js +45 -0
  125. package/public/js/apps/paymentsReactApp.js +399 -0
  126. package/public/js/apps/paymentsSuccessReactApp.js +148 -0
  127. package/public/js/apps/stickersApp.js +255 -103
  128. package/public/js/apps/termsReactApp.js +57 -8
  129. package/public/js/apps/userPasswordResetReactApp.js +406 -0
  130. package/public/js/apps/userReactApp.js +96 -47
  131. package/public/js/apps/userSystemAdmReactApp.js +1506 -0
  132. package/public/pages/pagamentos-cancelado.html +21 -0
  133. package/public/pages/pagamentos-sucesso.html +21 -0
  134. package/public/pages/pagamentos.html +30 -0
  135. package/public/pages/politica-de-privacidade.html +1 -1
  136. package/public/pages/stickers.html +5 -5
  137. package/public/pages/termos-de-uso-texto-integral.html +1 -1
  138. package/public/pages/termos-de-uso.html +1 -1
  139. package/public/pages/user-password-reset.html +3 -4
  140. package/public/pages/user-systemadm.html +8 -462
  141. package/public/pages/user.html +1 -1
  142. package/scripts/clear-whatsapp-session.sh +123 -0
  143. package/scripts/core-ai-mode.mjs +163 -0
  144. package/scripts/deploy.sh +13 -0
  145. package/scripts/enrich-command-config-ux-openai.mjs +492 -0
  146. package/scripts/generate-commands-catalog.mjs +155 -0
  147. package/scripts/new-whatsapp-session.sh +564 -0
  148. package/scripts/security-web-surface-check.mjs +218 -0
  149. package/server/controllers/admin/adminPanelHandlers.js +253 -3
  150. package/server/controllers/admin/systemAdminController.js +254 -0
  151. package/server/controllers/payments/paymentsController.js +731 -0
  152. package/server/controllers/sticker/stickerCatalogController.js +9 -23
  153. package/server/controllers/system/contactController.js +9 -17
  154. package/server/controllers/system/stickerCatalogSystemContext.js +27 -6
  155. package/server/controllers/system/systemController.js +228 -1
  156. package/server/controllers/userController.js +6 -0
  157. package/server/email/emailAutomationRuntime.js +36 -1
  158. package/server/email/emailAutomationService.js +42 -1
  159. package/server/email/emailTemplateService.js +140 -33
  160. package/server/http/httpRequestUtils.js +18 -14
  161. package/server/http/httpServer.js +8 -4
  162. package/server/middleware/securityHeaders.js +35 -3
  163. package/server/routes/admin/systemAdminRouter.js +6 -0
  164. package/server/routes/indexRouter.js +50 -6
  165. package/server/routes/observability/grafanaProxyRouter.js +254 -0
  166. package/server/routes/payments/paymentsRouter.js +47 -0
  167. package/server/routes/static/staticPageRouter.js +30 -1
  168. package/server/utils/publicContact.js +31 -0
  169. package/utils/whatsapp/contactEnv.js +39 -0
  170. package/vite.config.mjs +5 -1
  171. package/app/modules/playModule/local/installYtDlp.js +0 -25
  172. package/app/modules/playModule/local/ytDlpInstaller.js +0 -28
@@ -7,9 +7,8 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { spawn } from 'node:child_process';
9
9
  import logger from '#logger';
10
- import { installYtDlpBinary } from './local/ytDlpInstaller.js';
11
10
  import { getPlayExecutionOptions, getPlayOperationalLimits, getPlayReadyTitle, getPlayText } from './playConfigRuntime.js';
12
- import { DEFAULT_TIMEOUT_MS, DOWNLOAD_TIMEOUT_MS, YTDLP_INFO_TIMEOUT_MS, YTDLP_BINARY_PATH, YTDLP_COOKIES_FROM_BROWSER, PROJECT_ROOT_DIR, DEFAULT_COOKIES_PATH, 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';
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';
13
12
 
14
13
  const createError = (code, message, meta) => {
15
14
  const error = new Error(message);
@@ -157,6 +156,30 @@ const ensureHttpUrl = (value) => {
157
156
  }
158
157
  };
159
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
+
160
183
  const formatNumber = (value) => {
161
184
  const number = toNumberOrNull(value);
162
185
  if (number === null) return null;
@@ -499,11 +522,12 @@ const readResponseBuffer = async (stream, { maxBytes = Infinity, tooBigMessage }
499
522
  return Buffer.concat(chunks, total);
500
523
  };
501
524
 
502
- const httpRequest = ({ url, 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 }) =>
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 }) =>
503
526
  new Promise((resolve, reject) => {
504
527
  const urlObj = new URL(url);
505
528
  const httpModule = resolveHttpModule(urlObj);
506
529
  const { signal, cleanup } = createAbortSignal(timeoutMs);
530
+ const normalizedMethod = String(method || 'GET').toUpperCase();
507
531
 
508
532
  let settled = false;
509
533
  const settle = (fn) => {
@@ -519,8 +543,11 @@ const httpRequest = ({ url, timeoutMs = DEFAULT_TIMEOUT_MS, maxRedirects = 0, re
519
543
  const req = httpModule.request(
520
544
  urlObj,
521
545
  {
522
- method: 'GET',
523
- headers: { Accept: '*/*' },
546
+ method: normalizedMethod,
547
+ headers: {
548
+ Accept: '*/*',
549
+ ...(headers && typeof headers === 'object' ? headers : {}),
550
+ },
524
551
  signal,
525
552
  },
526
553
  (res) => {
@@ -546,6 +573,9 @@ const httpRequest = ({ url, timeoutMs = DEFAULT_TIMEOUT_MS, maxRedirects = 0, re
546
573
  settleResolve(
547
574
  httpRequest({
548
575
  url: nextUrl,
576
+ method: normalizedMethod,
577
+ headers,
578
+ body,
549
579
  timeoutMs,
550
580
  maxRedirects,
551
581
  redirectCount: redirectCount + 1,
@@ -586,12 +616,185 @@ const httpRequest = ({ url, timeoutMs = DEFAULT_TIMEOUT_MS, maxRedirects = 0, re
586
616
  settleReject(withErrorMeta(normalized, { endpoint }));
587
617
  });
588
618
 
619
+ if (body !== null && body !== undefined) {
620
+ req.write(body);
621
+ }
622
+
589
623
  req.end();
590
624
  });
591
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
+
592
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 }) =>
593
795
  httpRequest({
594
796
  url,
797
+ method: 'GET',
595
798
  timeoutMs,
596
799
  endpoint,
597
800
  maxRedirects: getLimits().max_redirects ?? MAX_REDIRECTS,
@@ -627,6 +830,8 @@ const requestBuffer = async ({ url, timeoutMs = getLimits().thumbnail_timeout_ms
627
830
 
628
831
  const httpClient = {
629
832
  requestBuffer,
833
+ requestJson,
834
+ requestFile,
630
835
  };
631
836
 
632
837
  const isTransientError = (error) => {
@@ -704,171 +909,8 @@ const setSearchCache = (queryKey, value) => {
704
909
  pruneSearchCache();
705
910
  };
706
911
 
707
- let ytDlpInstallPromise = null;
708
-
709
912
  const ensurePlayLocalDirs = async () => {
710
913
  await fs.promises.mkdir(PLAY_DOWNLOADS_DIR, { recursive: true });
711
- await fs.promises.mkdir(path.dirname(YTDLP_BINARY_PATH), { recursive: true });
712
- };
713
-
714
- const hasLocalBinary = async () => {
715
- const mode = os.platform() === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;
716
- try {
717
- await fs.promises.access(YTDLP_BINARY_PATH, mode);
718
- return true;
719
- } catch {
720
- return false;
721
- }
722
- };
723
-
724
- const ensureYtDlpReady = async () => {
725
- await ensurePlayLocalDirs();
726
-
727
- if (await hasLocalBinary()) {
728
- return YTDLP_BINARY_PATH;
729
- }
730
-
731
- if (!ytDlpInstallPromise) {
732
- ytDlpInstallPromise = installYtDlpBinary({ binaryPath: YTDLP_BINARY_PATH })
733
- .then(() => {
734
- logger.info('yt-dlp instalado para play local.', {
735
- endpoint: YTDLS_ENDPOINTS.install,
736
- binaryPath: YTDLP_BINARY_PATH,
737
- });
738
- })
739
- .finally(() => {
740
- ytDlpInstallPromise = null;
741
- });
742
- }
743
-
744
- await ytDlpInstallPromise;
745
- return YTDLP_BINARY_PATH;
746
- };
747
-
748
- const YOUTUBE_AUTH_COOKIE_NAMES = new Set(['SID', 'SSID', 'HSID', 'SAPISID', 'APISID', '__Secure-1PSID', '__Secure-3PSID', '__Secure-1PAPISID', '__Secure-3PAPISID']);
749
- let warnedInvalidCookiesPath = false;
750
- let warnedMissingCookiesPath = false;
751
- let warnedWeakCookiesPath = false;
752
-
753
- const inspectYtDlpCookiesFile = (cookiePath) => {
754
- try {
755
- const raw = fs.readFileSync(cookiePath, 'utf8');
756
- const lines = raw.split(/\r?\n/);
757
- let totalEntries = 0;
758
- let authCookieCount = 0;
759
- let hasYoutubeDomain = false;
760
- let hasGoogleDomain = false;
761
-
762
- for (const line of lines) {
763
- if (!line || line.startsWith('#')) continue;
764
- const parts = line.split('\t');
765
- if (parts.length < 7) continue;
766
- totalEntries += 1;
767
-
768
- const domain = String(parts[0] || '').toLowerCase();
769
- const cookieName = String(parts[5] || '').trim();
770
- if (domain.includes('youtube.com')) hasYoutubeDomain = true;
771
- if (domain.includes('google.com')) hasGoogleDomain = true;
772
- if (YOUTUBE_AUTH_COOKIE_NAMES.has(cookieName)) authCookieCount += 1;
773
- }
774
-
775
- return {
776
- ok: true,
777
- totalEntries,
778
- authCookieCount,
779
- hasYoutubeDomain,
780
- hasGoogleDomain,
781
- isLikelyAuthenticated: totalEntries > 0 && authCookieCount > 0 && hasYoutubeDomain,
782
- };
783
- } catch (error) {
784
- return {
785
- ok: false,
786
- error: error?.message || 'unknown',
787
- totalEntries: 0,
788
- authCookieCount: 0,
789
- hasYoutubeDomain: false,
790
- hasGoogleDomain: false,
791
- isLikelyAuthenticated: false,
792
- };
793
- }
794
- };
795
-
796
- const resolveYtDlpCookiesPath = () => {
797
- const configuredPath = (process.env.PLAY_YTDLP_COOKIES_PATH || '').trim();
798
- const rawCookiePath = configuredPath || DEFAULT_COOKIES_PATH;
799
-
800
- if (!rawCookiePath) return null;
801
- const cookiePath = path.isAbsolute(rawCookiePath) ? rawCookiePath : path.resolve(PROJECT_ROOT_DIR, rawCookiePath);
802
- if (!fs.existsSync(cookiePath)) {
803
- if (!warnedMissingCookiesPath) {
804
- warnedMissingCookiesPath = true;
805
- logger.warn('Play local: arquivo de cookies configurado não encontrado.', {
806
- endpoint: YTDLS_ENDPOINTS.download,
807
- cookiePath,
808
- configuredPath: Boolean(configuredPath),
809
- });
810
- }
811
- return null;
812
- }
813
-
814
- const cookiesDiagnostics = inspectYtDlpCookiesFile(cookiePath);
815
- if (!cookiesDiagnostics.ok && !warnedInvalidCookiesPath) {
816
- warnedInvalidCookiesPath = true;
817
- logger.warn('Play local: falha ao ler arquivo de cookies do yt-dlp.', {
818
- endpoint: YTDLS_ENDPOINTS.download,
819
- cookiePath,
820
- cause: cookiesDiagnostics.error,
821
- });
822
- } else if (cookiesDiagnostics.ok && !cookiesDiagnostics.isLikelyAuthenticated && !warnedWeakCookiesPath) {
823
- warnedWeakCookiesPath = true;
824
- logger.warn('Play local: cookies carregados, mas parecem incompletos para autenticação no YouTube.', {
825
- endpoint: YTDLS_ENDPOINTS.download,
826
- cookiePath,
827
- totalEntries: cookiesDiagnostics.totalEntries,
828
- authCookieCount: cookiesDiagnostics.authCookieCount,
829
- hasYoutubeDomain: cookiesDiagnostics.hasYoutubeDomain,
830
- hasGoogleDomain: cookiesDiagnostics.hasGoogleDomain,
831
- });
832
- }
833
-
834
- return cookiePath;
835
- };
836
-
837
- const buildYtDlpArgsBase = () => {
838
- const executionOptions = getExecutionOptions();
839
- const args = [...(Array.isArray(executionOptions?.ytdlp_base_args) ? executionOptions.ytdlp_base_args : [])];
840
- const cookiesPath = resolveYtDlpCookiesPath();
841
- if (cookiesPath) {
842
- args.push('--cookies', cookiesPath);
843
- } else if (YTDLP_COOKIES_FROM_BROWSER) {
844
- args.push('--cookies-from-browser', YTDLP_COOKIES_FROM_BROWSER);
845
- }
846
- return args;
847
- };
848
-
849
- const parseJsonOutput = (stdout) => {
850
- const text = String(stdout || '').trim();
851
- if (!text) return null;
852
-
853
- try {
854
- return JSON.parse(text);
855
- } catch {
856
- const lines = text
857
- .split(/\r?\n/)
858
- .map((line) => line.trim())
859
- .filter(Boolean);
860
- for (let index = lines.length - 1; index >= 0; index -= 1) {
861
- const line = lines[index];
862
- if (!line.startsWith('{') && !line.startsWith('[')) continue;
863
- try {
864
- return JSON.parse(line);
865
- } catch {
866
- continue;
867
- }
868
- }
869
- }
870
-
871
- return null;
872
914
  };
873
915
 
874
916
  const normalizeYoutubeWatchUrl = (value) => {
@@ -886,37 +928,7 @@ const normalizeYoutubeWatchUrl = (value) => {
886
928
  return null;
887
929
  };
888
930
 
889
- const extractYtDlpEntry = (payload) => {
890
- if (!payload || typeof payload !== 'object') return null;
891
-
892
- if (Array.isArray(payload.entries)) {
893
- const first = payload.entries.find((entry) => entry && typeof entry === 'object');
894
- if (first) return first;
895
- }
896
-
897
- return payload;
898
- };
899
-
900
- const normalizeResolvedVideoInfo = (entry, fallbackUrl = null) => {
901
- if (!entry || typeof entry !== 'object') return null;
902
-
903
- const resolvedUrl = normalizeYoutubeWatchUrl(entry.webpage_url) || normalizeYoutubeWatchUrl(entry.original_url) || normalizeYoutubeWatchUrl(entry.url) || normalizeYoutubeWatchUrl(entry.id) || normalizeYoutubeWatchUrl(fallbackUrl);
904
-
905
- return {
906
- ...entry,
907
- id: pickFirstString(entry, ['id', 'video_id', 'videoId']),
908
- title: pickFirstString(entry, ['title', 'fulltitle', 'name']) || 'Sem título',
909
- channel: pickFirstString(entry, ['channel', 'uploader', 'uploader_id', 'uploader_name']),
910
- uploader: pickFirstString(entry, ['uploader', 'channel', 'uploader_name']),
911
- duration: toNumberOrNull(entry.duration) ?? entry.duration ?? null,
912
- thumbnail: pickFirstString(entry, ['thumbnail']) || null,
913
- thumbnails: Array.isArray(entry.thumbnails) ? entry.thumbnails : [],
914
- url: resolvedUrl,
915
- webpage_url: resolvedUrl || entry.webpage_url || null,
916
- };
917
- };
918
-
919
- const normalizeYtDlpError = (error, { endpoint, requestId, input, timeoutMessage, fallbackMessage }) => {
931
+ const normalizeProviderError = (error, { endpoint, requestId, input, timeoutMessage, fallbackMessage }) => {
920
932
  if (KNOWN_ERROR_CODES.has(error?.code) && error?.message) return error;
921
933
 
922
934
  const stderr = String(error?.stderr || '').trim();
@@ -979,31 +991,390 @@ const normalizeYtDlpError = (error, { endpoint, requestId, input, timeoutMessage
979
991
  });
980
992
  };
981
993
 
982
- const isRequestedFormatUnavailableError = (error) => {
983
- const stderr = String(error?.stderr || '').trim();
984
- const stdout = String(error?.stdout || '').trim();
985
- const message = String(error?.message || '').trim();
986
- const combined = `${stderr}\n${stdout}\n${message}`.toLowerCase();
987
- return combined.includes('requested format is not available');
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
+ };
988
1020
  };
989
1021
 
990
- const runYtDlp = async ({ args, endpoint, requestId, input, timeoutMs = DEFAULT_TIMEOUT_MS, timeoutMessage, fallbackMessage }) => {
991
- const binaryPath = await ensureYtDlpReady();
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
+ };
992
1030
 
993
- try {
994
- return await runWithPlayProcessSlot(() => runBinaryCommand(binaryPath, args, { timeoutMs }), {
995
- endpoint,
996
- command: path.basename(binaryPath),
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,
997
1068
  });
998
- } catch (error) {
999
- throw normalizeYtDlpError(error, {
1000
- endpoint,
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,
1001
1128
  requestId,
1002
- input,
1003
- timeoutMessage: timeoutMessage || playText('ytdlp_timeout_generic', 'Timeout ao processar mídia com yt-dlp.'),
1004
- fallbackMessage: fallbackMessage || playText('ytdlp_error_generic', 'Falha ao processar mídia com yt-dlp.'),
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,
1005
1295
  });
1006
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
+ };
1007
1378
  };
1008
1379
 
1009
1380
  const fetchSearchResult = async (query) => {
@@ -1024,25 +1395,58 @@ const fetchSearchResult = async (query) => {
1024
1395
  const endpoint = YTDLS_ENDPOINTS.search;
1025
1396
  const isUrlLookup = /^https?:\/\//i.test(normalized);
1026
1397
  const maxSearchResults = getLimits().max_search_results ?? MAX_SEARCH_RESULTS;
1027
- const lookup = isUrlLookup ? normalized : `ytsearch${maxSearchResults}:${normalized}`;
1398
+ const normalizedUrlLookup = isUrlLookup ? normalizeYoutubeWatchUrl(normalized) : null;
1028
1399
 
1029
1400
  const payload = await retryAsync(
1030
1401
  async () => {
1031
- const args = isUrlLookup ? [...buildYtDlpArgsBase(), '--dump-single-json', lookup] : [...buildYtDlpArgsBase(), '--flat-playlist', '--ignore-errors', '--dump-single-json', lookup];
1402
+ let normalizedEntries = [];
1032
1403
 
1033
- const { stdout } = await runYtDlp({
1034
- args,
1035
- endpoint,
1036
- input: normalized,
1037
- timeoutMs: YTDLP_INFO_TIMEOUT_MS,
1038
- timeoutMessage: playText('search_timeout', 'Timeout ao buscar metadados do vídeo.'),
1039
- fallbackMessage: playText('search_failed', 'Não foi possível buscar o vídeo agora.'),
1040
- });
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
+ }
1041
1449
 
1042
- const parsed = parseJsonOutput(stdout);
1043
- const rawEntries = Array.isArray(parsed?.entries) ? parsed.entries.filter((entry) => entry && typeof entry === 'object') : [];
1044
- const candidateEntries = isUrlLookup ? [extractYtDlpEntry(parsed)].filter(Boolean) : rawEntries;
1045
- const normalizedEntries = candidateEntries.map((entry) => normalizeResolvedVideoInfo(entry, isUrlLookup ? normalized : null)).filter((entry) => entry?.url);
1046
1450
  const info = normalizedEntries[0] || null;
1047
1451
 
1048
1452
  if (!info?.url) {
@@ -1087,7 +1491,14 @@ const resolveYoutubeLink = async (query) => {
1087
1491
  }
1088
1492
 
1089
1493
  if (/^https?:\/\//i.test(normalized)) {
1090
- return 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;
1091
1502
  }
1092
1503
 
1093
1504
  const searchResult = await fetchSearchResult(normalized);
@@ -1137,7 +1548,14 @@ const resolveYoutubeCandidates = async (query) => {
1137
1548
  }
1138
1549
 
1139
1550
  if (/^https?:\/\//i.test(normalized)) {
1140
- return [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];
1141
1559
  }
1142
1560
 
1143
1561
  const searchResult = await fetchSearchResult(normalized);
@@ -1160,14 +1578,7 @@ const isYouTubeBotCheckCause = (error) => {
1160
1578
  };
1161
1579
 
1162
1580
  const buildYouTubeBotCheckUserMessage = () => {
1163
- const cookiesPath = resolveYtDlpCookiesPath();
1164
- if (cookiesPath) {
1165
- return getPlayText('anti_bot_with_cookies', 'YouTube solicitou verificação anti-bot. Atualize o arquivo .secrets/cookies.txt e tente novamente.');
1166
- }
1167
- if (YTDLP_COOKIES_FROM_BROWSER) {
1168
- return getPlayText('anti_bot_with_browser_profile', 'YouTube solicitou verificação anti-bot. Verifique o perfil informado em PLAY_YTDLP_COOKIES_FROM_BROWSER e tente novamente.');
1169
- }
1170
- return getPlayText('anti_bot_without_cookies', 'YouTube solicitou verificação anti-bot. Configure PLAY_YTDLP_COOKIES_PATH com um cookies.txt válido e tente novamente.');
1581
+ return getPlayText('anti_bot_without_cookies', 'YouTube solicitou verificação anti-bot no provedor de mídia. Tente novamente em alguns minutos.');
1171
1582
  };
1172
1583
 
1173
1584
  const fetchVideoInfo = async (query, fallback) => {
@@ -1271,100 +1682,42 @@ const cleanupDownloadedArtifacts = async (basePath) => {
1271
1682
  await Promise.allSettled(targets.map((name) => safeUnlink(path.join(dir, name))));
1272
1683
  };
1273
1684
 
1274
- const buildDownloadAttemptArgsList = ({ type, outputTemplate, link }) => {
1275
- const executionOptions = getExecutionOptions();
1276
- const formatOptions = executionOptions?.estrategias_formato || {};
1277
- const audioFormats = Array.isArray(formatOptions.audio) ? formatOptions.audio.filter(Boolean) : [];
1278
- const videoFormats = Array.isArray(formatOptions.video) ? formatOptions.video.filter(Boolean) : [];
1279
- const audioExtract = formatOptions.audio_extract && typeof formatOptions.audio_extract === 'object' ? formatOptions.audio_extract : {};
1280
- const mergeOutputFormat = String(formatOptions.video_merge_output_format || '').trim();
1281
-
1282
- if (type === 'audio') {
1283
- return audioFormats.map((format) => {
1284
- const args = ['--no-progress', '-o', outputTemplate, '-f', format];
1285
- if (audioExtract.enabled !== false) {
1286
- args.push('-x');
1287
- if (audioExtract.format) {
1288
- args.push('--audio-format', String(audioExtract.format));
1289
- }
1290
- if (audioExtract.quality) {
1291
- args.push('--audio-quality', String(audioExtract.quality));
1292
- }
1293
- }
1294
- args.push(link);
1295
- return args;
1296
- });
1297
- }
1298
-
1299
- return videoFormats.map((format) => {
1300
- const args = ['--no-progress', '-o', outputTemplate, '-f', format];
1301
- if (mergeOutputFormat) {
1302
- args.push('--merge-output-format', mergeOutputFormat);
1303
- }
1304
- args.push(link);
1305
- return args;
1306
- });
1307
- };
1308
-
1309
1685
  const requestDownloadToFile = async (link, type, requestId) => {
1310
- const endpoint = YTDLS_ENDPOINTS.download;
1686
+ const endpoint = YTDLS_ENDPOINTS.ytmp3Download;
1311
1687
  const safeId = String(requestId || 'req')
1312
1688
  .replace(/[^a-z0-9-_]+/gi, '')
1313
1689
  .slice(0, 48);
1314
1690
  const basePath = path.join(PLAY_DOWNLOADS_DIR, `play-${safeId}-${__timeNowMs()}`);
1315
- const outputTemplate = `${basePath}.%(ext)s`;
1316
- const preferredExt = type === 'audio' ? 'mp3' : 'mp4';
1317
1691
  let filePath = null;
1318
- const attemptArgsList = buildDownloadAttemptArgsList({ type, outputTemplate, link });
1692
+ let providerResult = null;
1319
1693
 
1320
1694
  try {
1321
- let downloadCompleted = false;
1322
- let lastError = null;
1695
+ await ensurePlayLocalDirs();
1323
1696
 
1324
- for (let index = 0; index < attemptArgsList.length; index += 1) {
1325
- const attemptArgs = attemptArgsList[index];
1326
- try {
1327
- if (index > 0) {
1328
- await cleanupDownloadedArtifacts(basePath);
1329
- }
1330
-
1331
- await runYtDlp({
1332
- args: [...buildYtDlpArgsBase(), ...attemptArgs],
1333
- endpoint,
1334
- requestId,
1335
- input: link,
1336
- timeoutMs: DOWNLOAD_TIMEOUT_MS,
1337
- timeoutMessage: playText('download_timeout', 'Timeout ao baixar o arquivo.'),
1338
- fallbackMessage: playText('download_failed', 'Falha ao baixar o arquivo localmente.'),
1339
- });
1340
- downloadCompleted = true;
1341
- lastError = null;
1342
- break;
1343
- } catch (error) {
1344
- lastError = error;
1345
- const shouldRetryWithFallback = isRequestedFormatUnavailableError(error) && index < attemptArgsList.length - 1;
1346
-
1347
- if (!shouldRetryWithFallback) {
1348
- throw error;
1349
- }
1350
-
1351
- logger.warn('Play download: formato indisponível, tentando fallback.', {
1352
- requestId,
1353
- endpoint,
1354
- type,
1355
- attempt: index + 1,
1356
- nextAttempt: index + 2,
1357
- code: error?.code || null,
1358
- cause: truncateText(error?.meta?.cause || error?.message || ''),
1359
- });
1360
- }
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
+ });
1361
1704
  }
1362
1705
 
1363
- if (!downloadCompleted && lastError) {
1364
- throw lastError;
1365
- }
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
+ });
1366
1719
 
1367
- filePath = await findDownloadedFileByBase(basePath, preferredExt);
1720
+ filePath = providerResult?.filePath || (await findDownloadedFileByBase(basePath, type === 'audio' ? 'mp3' : 'mp4'));
1368
1721
  if (!filePath) {
1369
1722
  throw createError(ERROR_CODES.API, playText('download_file_not_found', 'Não foi possível localizar o arquivo baixado.'), {
1370
1723
  endpoint,
@@ -1375,8 +1728,8 @@ const requestDownloadToFile = async (link, type, requestId) => {
1375
1728
 
1376
1729
  let stat = await fs.promises.stat(filePath);
1377
1730
  let finalBytes = Number(stat?.size || 0);
1378
- let finalMimeType = inferMimeFromFilePath(filePath, type);
1379
- let finalMediaType = type;
1731
+ let finalMimeType = providerResult?.contentType || inferMimeFromFilePath(filePath, type);
1732
+ let finalMediaType = providerResult?.mediaType || type;
1380
1733
 
1381
1734
  if (finalBytes <= 0) {
1382
1735
  throw createError(ERROR_CODES.API, playText('download_invalid_media', 'Falha ao baixar mídia válida.'), {
@@ -1451,14 +1804,14 @@ const requestDownloadToFile = async (link, type, requestId) => {
1451
1804
  const normalized =
1452
1805
  KNOWN_ERROR_CODES.has(error?.code) && error?.message
1453
1806
  ? error
1454
- : normalizeYtDlpError(error, {
1807
+ : normalizeProviderError(error, {
1455
1808
  endpoint,
1456
1809
  requestId,
1457
1810
  input: link,
1458
1811
  timeoutMessage: playText('download_timeout', 'Timeout ao baixar o arquivo.'),
1459
1812
  fallbackMessage: playText('download_failed', 'Falha ao baixar o arquivo localmente.'),
1460
1813
  });
1461
- throw withErrorMeta(normalized, { endpoint, filePath });
1814
+ throw withErrorMeta(normalized, { endpoint: normalized?.meta?.endpoint || endpoint, filePath, provider: 'ytmp3' });
1462
1815
  }
1463
1816
  };
1464
1817
 
@@ -1485,10 +1838,9 @@ const fetchThumbnailBuffer = async (url) =>
1485
1838
  },
1486
1839
  );
1487
1840
 
1488
- const ytdlsClient = {
1841
+ const playMediaClient = {
1489
1842
  resolveYoutubeLink,
1490
1843
  resolveYoutubeCandidates,
1491
- resolveYtDlpCookiesPath,
1492
1844
  fetchVideoInfo,
1493
1845
  fetchQueueStatus,
1494
1846
  requestDownloadToFile,
@@ -1509,12 +1861,12 @@ const fileUtils = {
1509
1861
  safeUnlink,
1510
1862
  };
1511
1863
 
1512
- export const __playYtDlpClientTestUtils = {
1864
+ export const __playMediaClientTestUtils = {
1513
1865
  extractCandidateUrlsFromSearchResult,
1514
- buildDownloadAttemptArgsList,
1866
+ isYtmp3PrimaryEligible,
1515
1867
  isYouTubeBotCheckCause,
1516
1868
  buildYouTubeBotCheckUserMessage,
1517
1869
  getProcessLimiterStats: () => playProcessLimiter.stats(),
1518
1870
  };
1519
1871
 
1520
- export { createError, withErrorMeta, normalizePlayError, truncateText, ytdlsClient, formatters, fileUtils, isYouTubeBotCheckCause, buildYouTubeBotCheckUserMessage };
1872
+ export { createError, withErrorMeta, normalizePlayError, truncateText, playMediaClient, formatters, fileUtils, isYouTubeBotCheckCause, buildYouTubeBotCheckUserMessage };