@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.
- package/.env.example +78 -9
- package/.github/workflows/ci.yml +3 -3
- package/.github/workflows/security-runner-hardening.yml +1 -1
- package/.github/workflows/security-zap-full-scan.yml +1 -0
- package/app/config/index.js +6 -0
- package/app/configParts/adminIdentity.js +36 -7
- package/app/configParts/baileysConfig.js +343 -56
- package/app/configParts/groupUtils.js +226 -0
- package/app/configParts/loggerConfig.js +185 -0
- package/app/configParts/messagePersistenceService.js +307 -5
- package/app/configParts/sessionConfig.js +242 -0
- package/app/connection/baileysCompatibility.test.js +10 -1
- package/app/connection/baileysDbAuthState.js +205 -9
- package/app/connection/baileysLibsignalPatch.js +210 -0
- package/app/connection/groupOwnerWriteStateResolver.js +141 -0
- package/app/connection/socketController.js +694 -123
- package/app/connection/socketController.multiSession.test.js +128 -0
- package/app/controllers/messageController.js +1 -1
- package/app/controllers/messagePipeline/commandMiddleware.js +12 -10
- package/app/controllers/messagePipeline/conversationMiddleware.js +2 -1
- package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +104 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +96 -4
- package/app/controllers/messageProcessingPipeline.js +90 -9
- package/app/controllers/messageProcessingPipeline.test.js +202 -0
- package/app/modules/adminModule/AGENT.md +1 -1
- package/app/modules/adminModule/commandConfig.json +3318 -1347
- package/app/modules/adminModule/groupCommandHandlers.js +856 -14
- package/app/modules/adminModule/groupCommandHandlers.test.js +375 -9
- package/app/modules/adminModule/groupWarningRepository.js +152 -0
- package/app/modules/aiModule/AGENT.md +47 -30
- package/app/modules/aiModule/aiConfigRuntime.js +1 -0
- package/app/modules/aiModule/catCommand.js +132 -25
- package/app/modules/aiModule/commandConfig.json +114 -28
- package/app/modules/analyticsModule/messageAnalysisEventRepository.js +54 -6
- package/app/modules/gameModule/AGENT.md +1 -1
- package/app/modules/gameModule/commandConfig.json +29 -0
- package/app/modules/menuModule/AGENT.md +1 -1
- package/app/modules/menuModule/commandConfig.json +45 -10
- package/app/modules/menuModule/menuCatalogService.js +190 -0
- package/app/modules/menuModule/menuCommandUsageRepository.js +109 -0
- package/app/modules/menuModule/menuDynamicService.js +511 -0
- package/app/modules/menuModule/menuDynamicService.test.js +141 -0
- package/app/modules/menuModule/menus.js +36 -5
- package/app/modules/playModule/AGENT.md +10 -5
- package/app/modules/playModule/commandConfig.json +74 -16
- package/app/modules/playModule/playCommandConstants.js +13 -7
- package/app/modules/playModule/playCommandCore.js +4 -6
- package/app/modules/playModule/{playCommandYtDlpClient.js → playCommandMediaClient.js} +684 -332
- package/app/modules/playModule/playConfigRuntime.js +5 -6
- package/app/modules/playModule/playModuleCriticalFlows.test.js +44 -59
- package/app/modules/quoteModule/AGENT.md +1 -1
- package/app/modules/quoteModule/commandConfig.json +29 -0
- package/app/modules/rpgPokemonModule/AGENT.md +1 -1
- package/app/modules/rpgPokemonModule/commandConfig.json +29 -0
- package/app/modules/statsModule/AGENT.md +1 -1
- package/app/modules/statsModule/commandConfig.json +58 -0
- package/app/modules/stickerModule/AGENT.md +1 -1
- package/app/modules/stickerModule/commandConfig.json +145 -0
- package/app/modules/stickerPackModule/AGENT.md +1 -1
- package/app/modules/stickerPackModule/autoPackCollectorService.js +5 -1
- package/app/modules/stickerPackModule/commandConfig.json +29 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +1 -1
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +78 -57
- package/app/modules/stickerPackModule/stickerPackService.js +13 -6
- package/app/modules/systemMetricsModule/AGENT.md +1 -1
- package/app/modules/systemMetricsModule/commandConfig.json +29 -0
- package/app/modules/tiktokModule/AGENT.md +1 -1
- package/app/modules/tiktokModule/commandConfig.json +29 -0
- package/app/modules/userModule/AGENT.md +1 -1
- package/app/modules/userModule/commandConfig.json +29 -0
- package/app/modules/waifuPicsModule/AGENT.md +57 -27
- package/app/modules/waifuPicsModule/commandConfig.json +87 -0
- package/app/observability/metrics.js +136 -0
- package/app/services/ai/commandConfigEnrichmentService.js +229 -47
- package/app/services/ai/geminiService.js +131 -7
- package/app/services/ai/geminiService.test.js +59 -2
- package/app/services/ai/moduleAiHelpCoreService.js +33 -4
- package/app/services/group/groupMetadataService.js +24 -1
- package/app/services/infra/dbWriteQueue.js +51 -21
- package/app/services/messaging/newsBroadcastService.js +843 -27
- package/app/services/multiSession/assignmentBalancerService.js +452 -0
- package/app/services/multiSession/groupOwnershipRepository.js +346 -0
- package/app/services/multiSession/groupOwnershipService.js +809 -0
- package/app/services/multiSession/groupOwnershipService.test.js +317 -0
- package/app/services/multiSession/sessionRegistryService.js +239 -0
- package/app/store/aiPromptStore.js +36 -19
- package/app/store/groupConfigStore.js +41 -5
- package/app/store/premiumUserStore.js +21 -7
- package/app/utils/antiLink/antiLinkModule.js +391 -25
- package/app/workers/aiHelperContinuousLearningWorker.js +512 -0
- package/database/index.js +6 -0
- package/database/migrations/20260307_d0_hardening_down.sql +1 -1
- package/database/migrations/20260314_d7_canonical_sender_down.sql +1 -1
- package/database/migrations/20260406_d30_security_analytics_down.sql +1 -1
- package/database/migrations/20260411_d35_group_community_metadata_down.sql +59 -0
- package/database/migrations/20260411_d35_group_community_metadata_up.sql +62 -0
- package/database/migrations/20260412_d36_system_config_tables_down.sql +32 -0
- package/database/migrations/20260412_d36_system_config_tables_up.sql +66 -0
- package/database/migrations/20260413_d37_group_user_warnings_down.sql +11 -0
- package/database/migrations/20260413_d37_group_user_warnings_up.sql +24 -0
- package/database/migrations/20260414_d38_multi_session_foundation_down.sql +72 -0
- package/database/migrations/20260414_d38_multi_session_foundation_up.sql +125 -0
- package/database/migrations/20260414_d39_multi_session_cutover_down.sql +103 -0
- package/database/migrations/20260414_d39_multi_session_cutover_up.sql +83 -0
- package/database/schema.sql +102 -1
- package/docker-compose.yml +4 -1
- package/docs/compliance/acceptable-use-policy-2026-03-07.md +1 -1
- package/docs/compliance/privacy-policy-2026-03-07.md +2 -2
- package/docs/security/dsar-lgpd-runbook-2026-03-07.md +1 -1
- package/docs/security/network-hardening-runbook-2026-03-07.md +53 -0
- package/docs/security/omnizap-static-security-headers.conf +25 -0
- package/ecosystem.prod.config.cjs +31 -11
- package/index.js +52 -18
- package/observability/alert-rules.yml +20 -0
- package/observability/grafana/dashboards/omnizap-system-admin.json +229 -0
- package/observability/mysql-setup.sql +4 -4
- package/observability/system-admin-observability.md +26 -0
- package/package.json +14 -6
- package/public/comandos/commands-catalog.json +2253 -78
- package/public/css/payments-react.css +478 -0
- package/public/js/apps/commandsReactApp.js +267 -87
- package/public/js/apps/createPackApp.js +3 -3
- package/public/js/apps/homeReactApp.js +2 -2
- package/public/js/apps/paymentsCancelReactApp.js +45 -0
- package/public/js/apps/paymentsReactApp.js +399 -0
- package/public/js/apps/paymentsSuccessReactApp.js +148 -0
- package/public/js/apps/stickersApp.js +255 -103
- package/public/js/apps/termsReactApp.js +57 -8
- package/public/js/apps/userPasswordResetReactApp.js +406 -0
- package/public/js/apps/userReactApp.js +96 -47
- package/public/js/apps/userSystemAdmReactApp.js +1506 -0
- package/public/pages/pagamentos-cancelado.html +21 -0
- package/public/pages/pagamentos-sucesso.html +21 -0
- package/public/pages/pagamentos.html +30 -0
- package/public/pages/politica-de-privacidade.html +1 -1
- package/public/pages/stickers.html +5 -5
- package/public/pages/termos-de-uso-texto-integral.html +1 -1
- package/public/pages/termos-de-uso.html +1 -1
- package/public/pages/user-password-reset.html +3 -4
- package/public/pages/user-systemadm.html +8 -462
- package/public/pages/user.html +1 -1
- package/scripts/clear-whatsapp-session.sh +123 -0
- package/scripts/core-ai-mode.mjs +163 -0
- package/scripts/deploy.sh +13 -0
- package/scripts/enrich-command-config-ux-openai.mjs +492 -0
- package/scripts/generate-commands-catalog.mjs +155 -0
- package/scripts/new-whatsapp-session.sh +564 -0
- package/scripts/security-web-surface-check.mjs +218 -0
- package/server/controllers/admin/adminPanelHandlers.js +253 -3
- package/server/controllers/admin/systemAdminController.js +254 -0
- package/server/controllers/payments/paymentsController.js +731 -0
- package/server/controllers/sticker/stickerCatalogController.js +9 -23
- package/server/controllers/system/contactController.js +9 -17
- package/server/controllers/system/stickerCatalogSystemContext.js +27 -6
- package/server/controllers/system/systemController.js +228 -1
- package/server/controllers/userController.js +6 -0
- package/server/email/emailAutomationRuntime.js +36 -1
- package/server/email/emailAutomationService.js +42 -1
- package/server/email/emailTemplateService.js +140 -33
- package/server/http/httpRequestUtils.js +18 -14
- package/server/http/httpServer.js +8 -4
- package/server/middleware/securityHeaders.js +35 -3
- package/server/routes/admin/systemAdminRouter.js +6 -0
- package/server/routes/indexRouter.js +50 -6
- package/server/routes/observability/grafanaProxyRouter.js +254 -0
- package/server/routes/payments/paymentsRouter.js +47 -0
- package/server/routes/static/staticPageRouter.js +30 -1
- package/server/utils/publicContact.js +31 -0
- package/utils/whatsapp/contactEnv.js +39 -0
- package/vite.config.mjs +5 -1
- package/app/modules/playModule/local/installYtDlp.js +0 -25
- 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,
|
|
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:
|
|
523
|
-
headers: {
|
|
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
|
|
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
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
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
|
|
991
|
-
const
|
|
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
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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
|
-
}
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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
|
|
1398
|
+
const normalizedUrlLookup = isUrlLookup ? normalizeYoutubeWatchUrl(normalized) : null;
|
|
1028
1399
|
|
|
1029
1400
|
const payload = await retryAsync(
|
|
1030
1401
|
async () => {
|
|
1031
|
-
|
|
1402
|
+
let normalizedEntries = [];
|
|
1032
1403
|
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1692
|
+
let providerResult = null;
|
|
1319
1693
|
|
|
1320
1694
|
try {
|
|
1321
|
-
|
|
1322
|
-
let lastError = null;
|
|
1695
|
+
await ensurePlayLocalDirs();
|
|
1323
1696
|
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|
-
|
|
1364
|
-
|
|
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,
|
|
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
|
-
:
|
|
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
|
|
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
|
|
1864
|
+
export const __playMediaClientTestUtils = {
|
|
1513
1865
|
extractCandidateUrlsFromSearchResult,
|
|
1514
|
-
|
|
1866
|
+
isYtmp3PrimaryEligible,
|
|
1515
1867
|
isYouTubeBotCheckCause,
|
|
1516
1868
|
buildYouTubeBotCheckUserMessage,
|
|
1517
1869
|
getProcessLimiterStats: () => playProcessLimiter.stats(),
|
|
1518
1870
|
};
|
|
1519
1871
|
|
|
1520
|
-
export { createError, withErrorMeta, normalizePlayError, truncateText,
|
|
1872
|
+
export { createError, withErrorMeta, normalizePlayError, truncateText, playMediaClient, formatters, fileUtils, isYouTubeBotCheckCause, buildYouTubeBotCheckUserMessage };
|