This commit is contained in:
Andrey Kondratev
2025-11-10 13:56:19 +05:00
parent 6db48b16a7
commit 82a9596370
13 changed files with 1086 additions and 152 deletions

View File

@@ -58,7 +58,3 @@ docs
Dockerfile* Dockerfile*
docker-compose* docker-compose*
.dockerignore .dockerignore
# Misc
.cache
.parcel-cache

View File

@@ -3,15 +3,20 @@ FROM node:18-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files # Copy package files first (better caching)
COPY package*.json ./ COPY package*.json ./
COPY yarn.lock* ./ COPY yarn.lock* ./
# Install all dependencies (including dev for build) # Install all dependencies (including dev for build)
# This layer will be cached unless package.json changes
RUN yarn install --frozen-lockfile && yarn cache clean RUN yarn install --frozen-lockfile && yarn cache clean
# Copy source code # Copy source code (separate from dependencies)
COPY . . COPY tsconfig*.json ./
COPY eslint.config.mjs ./
COPY scripts ./scripts
COPY src ./src
COPY public ./public
# Build the application with minification # Build the application with minification
RUN yarn build:prod RUN yarn build:prod
@@ -28,6 +33,7 @@ RUN apk update && apk add --no-cache ffmpeg
# Set ffmpeg paths # Set ffmpeg paths
ENV FFMPEG_PATH=/usr/bin/ffmpeg ENV FFMPEG_PATH=/usr/bin/ffmpeg
ENV FFPROBE_PATH=/usr/bin/ffprobe ENV FFPROBE_PATH=/usr/bin/ffprobe
ENV NODE_ENV=production
WORKDIR /app WORKDIR /app

View File

@@ -22,6 +22,9 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
cache_from:
- quixotic-app:latest
image: quixotic-app:latest
container_name: quixotic-app container_name: quixotic-app
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -33,6 +36,9 @@ services:
WEB_APP_URL: http://localhost:3000 WEB_APP_URL: http://localhost:3000
volumes: volumes:
- downloads:/app/downloads - downloads:/app/downloads
# Mount source code for hot reload (uncomment for development)
# - ./src:/app/src
# - ./public:/app/public
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:

View File

@@ -36,13 +36,14 @@
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"node-telegram-bot-api": "^0.64.0", "node-telegram-bot-api": "^0.64.0",
"pg": "^8.11.3", "pg": "^8.11.3",
"soundcloud-downloader": "^1.0.0" "soundcloud-downloader": "^1.0.0",
"winston": "^3.18.3"
}, },
"devDependencies": { "devDependencies": {
"@types/compression": "^1.8.1", "@types/compression": "^1.8.1",
"@types/express": "^5.0.3", "@types/express": "^5.0.3",
"@types/fluent-ffmpeg": "^2.1.27", "@types/fluent-ffmpeg": "^2.1.27",
"@types/node": "^24.3.0", "@types/node": "^24.10.0",
"@types/node-telegram-bot-api": "^0.64.10", "@types/node-telegram-bot-api": "^0.64.10",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@typescript-eslint/eslint-plugin": "^8.41.0", "@typescript-eslint/eslint-plugin": "^8.41.0",

View File

@@ -54,17 +54,13 @@
<link rel="preconnect" href="https://telegram.org" crossorigin> <link rel="preconnect" href="https://telegram.org" crossorigin>
<link rel="dns-prefetch" href="https://telegram.org"> <link rel="dns-prefetch" href="https://telegram.org">
<!-- Preload critical resources -->
<link rel="preload" href="style.css" as="style">
<link rel="preload" href="https://telegram.org/js/telegram-web-app.js" as="script">
<!-- Critical CSS - inline the most important styles --> <!-- Critical CSS - inline the most important styles -->
<style> <style>
:root{--tg-color-bg:var(--tg-theme-bg-color,#fff);--tg-color-secondary-bg:var(--tg-theme-secondary-bg-color,#f1f1f1);--tg-color-section-bg:var(--tg-theme-section-bg-color,#fff);--tg-color-text:var(--tg-theme-text-color,#000);--tg-color-hint:var(--tg-theme-hint-color,#999);--tg-color-button:var(--tg-theme-button-color,#007aff);--tg-color-button-text:var(--tg-theme-button-text-color,#fff);--tg-border-radius:12px;--tg-spacing-lg:16px;--tg-spacing-xl:20px;--tg-spacing-xxl:24px;--tg-font-size-md:16px;--tg-font-size-lg:17px;--tg-font-size-xl:20px;--tg-line-height-normal:1.4;--tg-line-height-relaxed:1.6}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',system-ui,sans-serif;-webkit-font-smoothing:antialiased}body{background:var(--tg-color-bg);color:var(--tg-color-text);font-size:var(--tg-font-size-md);line-height:var(--tg-line-height-normal);overflow-x:hidden}.tg-root{min-height:100vh;display:flex;flex-direction:column}.tg-content{flex:1;padding:var(--tg-spacing-lg);padding-bottom:100px;display:flex;flex-direction:column;gap:var(--tg-spacing-xl)}.tg-placeholder{text-align:center;padding:var(--tg-spacing-xxl) var(--tg-spacing-lg);max-width:300px;margin:0 auto}.tg-placeholder__icon{font-size:48px;margin-bottom:var(--tg-spacing-lg);opacity:.6}.tg-placeholder__title{font-size:var(--tg-font-size-xl);font-weight:600;margin-bottom:8px}.tg-placeholder__description{font-size:14px;color:var(--tg-color-hint);line-height:var(--tg-line-height-relaxed)}.tg-hidden{display:none!important}.tg-form{position:fixed;bottom:0;left:0;right:0;padding:var(--tg-spacing-lg);background:var(--tg-color-bg);border-top:1px solid var(--tg-color-secondary-bg);z-index:100}.tg-input-wrapper{position:relative}.tg-input{width:100%;height:48px;padding:0 var(--tg-spacing-lg);background:var(--tg-color-section-bg);border:2px solid var(--tg-color-secondary-bg);border-radius:var(--tg-border-radius);font-size:var(--tg-font-size-lg);color:var(--tg-color-text);transition:border-color .2s;outline:0}.tg-input::placeholder{color:var(--tg-color-hint)}.tg-input:focus{border-color:var(--tg-color-button)} :root{--tg-color-bg:var(--tg-theme-bg-color,#fff);--tg-color-secondary-bg:var(--tg-theme-secondary-bg-color,#f1f1f1);--tg-color-section-bg:var(--tg-theme-section-bg-color,#fff);--tg-color-text:var(--tg-theme-text-color,#000);--tg-color-hint:var(--tg-theme-hint-color,#999);--tg-color-button:var(--tg-theme-button-color,#007aff);--tg-color-button-text:var(--tg-theme-button-text-color,#fff);--tg-border-radius:12px;--tg-spacing-lg:16px;--tg-spacing-xl:20px;--tg-spacing-xxl:24px;--tg-font-size-md:16px;--tg-font-size-lg:17px;--tg-font-size-xl:20px;--tg-line-height-normal:1.4;--tg-line-height-relaxed:1.6}*{margin:0;padding:0;box-sizing:border-box}html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',system-ui,sans-serif;-webkit-font-smoothing:antialiased}body{background:var(--tg-color-bg);color:var(--tg-color-text);font-size:var(--tg-font-size-md);line-height:var(--tg-line-height-normal);overflow-x:hidden}.tg-root{min-height:100vh;display:flex;flex-direction:column}.tg-content{flex:1;padding:var(--tg-spacing-lg);padding-bottom:100px;display:flex;flex-direction:column;gap:var(--tg-spacing-xl)}.tg-placeholder{text-align:center;padding:var(--tg-spacing-xxl) var(--tg-spacing-lg);max-width:300px;margin:0 auto}.tg-placeholder__icon{font-size:48px;margin-bottom:var(--tg-spacing-lg);opacity:.6}.tg-placeholder__title{font-size:var(--tg-font-size-xl);font-weight:600;margin-bottom:8px}.tg-placeholder__description{font-size:14px;color:var(--tg-color-hint);line-height:var(--tg-line-height-relaxed)}.tg-hidden{display:none!important}.tg-form{position:fixed;bottom:0;left:0;right:0;padding:var(--tg-spacing-lg);background:var(--tg-color-bg);border-top:1px solid var(--tg-color-secondary-bg);z-index:100}.tg-input-wrapper{position:relative}.tg-input{width:100%;height:48px;padding:0 var(--tg-spacing-lg);background:var(--tg-color-section-bg);border:2px solid var(--tg-color-secondary-bg);border-radius:var(--tg-border-radius);font-size:var(--tg-font-size-lg);color:var(--tg-color-text);transition:border-color .2s;outline:0}.tg-input::placeholder{color:var(--tg-color-hint)}.tg-input:focus{border-color:var(--tg-color-button)}
</style> </style>
<!-- Load full CSS asynchronously with fallback --> <!-- Load full CSS asynchronously with fallback -->
<link rel="preload" href="style.css?v=3" as="style" onload="this.onload=null;this.rel='stylesheet'"> <link rel="stylesheet" href="style.css?v=3" media="print" onload="this.media='all';this.onload=null">
<noscript><link rel="stylesheet" href="style.css?v=3"></noscript> <noscript><link rel="stylesheet" href="style.css?v=3"></noscript>
<!-- Load Telegram script asynchronously (defer) --> <!-- Load Telegram script asynchronously (defer) -->
@@ -84,6 +80,79 @@
<div class="tg-spinner__text">Поиск музыки...</div> <div class="tg-spinner__text">Поиск музыки...</div>
</div> </div>
<div class="tg-skeleton-list tg-hidden" id="skeletonList">
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
<div class="tg-skeleton-item">
<div class="tg-skeleton-thumbnail"></div>
<div class="tg-skeleton-text">
<div class="tg-skeleton-line"></div>
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
</div>
</div>
</div>
<div class="tg-list tg-hidden" id="results"> <div class="tg-list tg-hidden" id="results">
<!-- Search results will appear here --> <!-- Search results will appear here -->
</div> </div>
@@ -101,6 +170,12 @@
id="searchInput" id="searchInput"
placeholder="Название песни или исполнитель..." placeholder="Название песни или исполнитель..."
autocomplete="off"> autocomplete="off">
<button class="tg-input-clear tg-hidden" id="clearButton" type="button" aria-label="Очистить поиск">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor"
stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -6,6 +6,11 @@ interface TelegramWebApp {
show(): void; show(): void;
hide(): void; hide(): void;
}; };
HapticFeedback?: {
impactOccurred(style: 'light' | 'medium' | 'heavy' | 'rigid' | 'soft'): void;
notificationOccurred(type: 'error' | 'success' | 'warning'): void;
selectionChanged(): void;
};
initDataUnsafe?: { initDataUnsafe?: {
user?: { user?: {
id: number; id: number;
@@ -48,13 +53,66 @@ class QuixoticApp {
private currentVideos: VideoResult[] = []; private currentVideos: VideoResult[] = [];
private loadingStartTime: number = 0; private loadingStartTime: number = 0;
private minLoadingDuration: number = 400; // Минимальное время показа спиннера (ms) private minLoadingDuration: number = 400; // Минимальное время показа спиннера (ms)
private recentSearches: string[] = [];
private maxRecentSearches: number = 5;
private currentAudio: HTMLAudioElement | null = null;
private currentPlayingItem: HTMLElement | null = null;
constructor() { constructor() {
this.tg = (window as WindowWithTelegram).Telegram?.WebApp; this.tg = (window as WindowWithTelegram).Telegram?.WebApp;
this.loadRecentSearches();
this.init(); this.init();
this.bindEvents(); this.bindEvents();
} }
private triggerHaptic(type: 'light' | 'medium' | 'heavy' | 'success' | 'error' = 'light'): void {
if (this.tg?.HapticFeedback) {
switch(type) {
case 'light':
this.tg.HapticFeedback.impactOccurred('light');
break;
case 'medium':
this.tg.HapticFeedback.impactOccurred('medium');
break;
case 'heavy':
this.tg.HapticFeedback.impactOccurred('heavy');
break;
case 'success':
this.tg.HapticFeedback.notificationOccurred('success');
break;
case 'error':
this.tg.HapticFeedback.notificationOccurred('error');
break;
}
}
}
private loadRecentSearches(): void {
try {
const saved = localStorage.getItem('recentSearches');
if (saved) {
this.recentSearches = JSON.parse(saved);
}
} catch (e) {
console.error('Failed to load recent searches:', e);
}
}
private saveSearch(query: string): void {
// Remove if already exists
this.recentSearches = this.recentSearches.filter(s => s !== query);
// Add to beginning
this.recentSearches.unshift(query);
// Limit count
this.recentSearches = this.recentSearches.slice(0, this.maxRecentSearches);
// Save
try {
localStorage.setItem('recentSearches', JSON.stringify(this.recentSearches));
} catch (e) {
console.error('Failed to save recent searches:', e);
}
}
private init(): void { private init(): void {
if (this.tg) { if (this.tg) {
this.tg.ready(); this.tg.ready();
@@ -87,19 +145,27 @@ class QuixoticApp {
const query = this.searchInput.value.trim(); const query = this.searchInput.value.trim();
// Show/hide clear button
const clearButton = document.getElementById('clearButton');
if (query) {
clearButton?.classList.remove('tg-hidden');
} else {
clearButton?.classList.add('tg-hidden');
}
// If input is empty, reset to welcome state immediately // If input is empty, reset to welcome state immediately
if (query === '') { if (query === '') {
this.resetToWelcomeState(); this.resetToWelcomeState();
return; return;
} }
// Show loading spinner immediately for better UX feedback // Don't show loading immediately - wait for debounce to finish
this.showLoading(); // This prevents flickering when user is typing fast
// Set new timeout for search (300ms delay - best practice for instant search) // Set new timeout for search (600ms delay - prevents missing characters)
this.searchTimeout = setTimeout(() => { this.searchTimeout = setTimeout(() => {
this.search(); this.search();
}, 300); }, 600);
}); });
// Still handle Enter key for immediate search // Still handle Enter key for immediate search
@@ -112,6 +178,67 @@ class QuixoticApp {
this.search(); this.search();
} }
}); });
// Clear button handler
const clearButton = document.getElementById('clearButton');
clearButton?.addEventListener('click', () => {
this.triggerHaptic('light');
this.searchInput.value = '';
this.searchInput.focus();
this.resetToWelcomeState();
clearButton.classList.add('tg-hidden');
});
// Setup pull-to-refresh
this.setupPullToRefresh();
}
private setupPullToRefresh(): void {
let touchStartY = 0;
let pullDistance = 0;
const threshold = 80;
const container = document.querySelector('.tg-content') as HTMLElement;
if (!container) return;
container.addEventListener('touchstart', (e) => {
if (window.scrollY === 0) {
touchStartY = e.touches[0].clientY;
}
}, { passive: true });
container.addEventListener('touchmove', (e) => {
if (touchStartY > 0) {
pullDistance = e.touches[0].clientY - touchStartY;
if (pullDistance > 0 && pullDistance < threshold * 2) {
// Visual indication of stretching
container.style.transform = `translateY(${pullDistance * 0.5}px)`;
// Add pulling class for indicator
if (pullDistance > threshold * 0.5) {
container.classList.add('tg-pulling');
}
}
}
}, { passive: true });
container.addEventListener('touchend', () => {
if (pullDistance > threshold) {
this.triggerHaptic('medium');
this.refreshResults();
}
container.style.transform = '';
container.classList.remove('tg-pulling');
touchStartY = 0;
pullDistance = 0;
});
}
private refreshResults(): void {
const query = this.searchInput.value.trim();
if (query) {
this.search();
}
} }
private resetToWelcomeState(): void { private resetToWelcomeState(): void {
@@ -122,18 +249,25 @@ class QuixoticApp {
// Hide all other states // Hide all other states
this.loading.classList.add('tg-hidden'); this.loading.classList.add('tg-hidden');
this.loading.classList.remove('tg-spinner--visible'); this.loading.classList.remove('tg-spinner--visible');
const skeletonList = document.getElementById('skeletonList');
if (skeletonList) {
skeletonList.classList.add('tg-hidden');
}
this.results.classList.add('tg-hidden'); this.results.classList.add('tg-hidden');
this.results.classList.remove('tg-list--visible'); this.results.classList.remove('tg-list--visible');
this.noResults.classList.add('tg-hidden'); this.noResults.classList.add('tg-hidden');
this.noResults.style.display = 'none'; this.noResults.style.display = 'none';
} }
private async search(): Promise<void> { private async search(): Promise<void> {
const query = this.searchInput.value.trim(); const query = this.searchInput.value.trim();
if (!query) return; if (!query) return;
// Save to recent searches
this.saveSearch(query);
this.showLoading(); this.showLoading();
try { try {
@@ -162,6 +296,7 @@ class QuixoticApp {
await new Promise(resolve => setTimeout(resolve, remainingTime)); await new Promise(resolve => setTimeout(resolve, remainingTime));
} }
this.triggerHaptic('light');
this.displayResults(data.videos); this.displayResults(data.videos);
} catch (error) { } catch (error) {
console.error('Search error:', error); console.error('Search error:', error);
@@ -174,6 +309,7 @@ class QuixoticApp {
await new Promise(resolve => setTimeout(resolve, remainingTime)); await new Promise(resolve => setTimeout(resolve, remainingTime));
} }
this.triggerHaptic('error');
this.showNoResults(); this.showNoResults();
} }
} }
@@ -192,22 +328,31 @@ class QuixoticApp {
this.welcomePlaceholder.classList.add('tg-hidden'); this.welcomePlaceholder.classList.add('tg-hidden');
this.welcomePlaceholder.style.display = 'none'; this.welcomePlaceholder.style.display = 'none';
// Show loading spinner // Show skeleton screens instead of spinner for better UX
this.loading.classList.remove('tg-hidden'); const skeletonList = document.getElementById('skeletonList');
this.loading.classList.add('tg-spinner--visible'); if (skeletonList) {
skeletonList.classList.remove('tg-hidden');
}
// Hide spinner (we're using skeletons now)
this.loading.classList.add('tg-hidden');
this.loading.classList.remove('tg-spinner--visible');
// Hide other elements // Hide other elements
this.results.classList.add('tg-hidden'); this.results.classList.add('tg-hidden');
this.results.classList.remove('tg-list--visible'); this.results.classList.remove('tg-list--visible');
this.noResults.classList.add('tg-hidden'); this.noResults.classList.add('tg-hidden');
this.noResults.style.display = 'none'; this.noResults.style.display = 'none';
} }
private hideLoading(): void { private hideLoading(): void {
this.loading.classList.add('tg-hidden'); this.loading.classList.add('tg-hidden');
this.loading.classList.remove('tg-spinner--visible'); this.loading.classList.remove('tg-spinner--visible');
const skeletonList = document.getElementById('skeletonList');
if (skeletonList) {
skeletonList.classList.add('tg-hidden');
}
} }
private displayResults(videos: VideoResult[]): void { private displayResults(videos: VideoResult[]): void {
@@ -230,7 +375,7 @@ class QuixoticApp {
// Use DocumentFragment for better performance // Use DocumentFragment for better performance
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
videos.forEach(video => { videos.forEach((video, index) => {
const item = document.createElement('div'); const item = document.createElement('div');
item.className = 'tg-list-item'; item.className = 'tg-list-item';
item.dataset.videoId = video.id; item.dataset.videoId = video.id;
@@ -244,16 +389,46 @@ class QuixoticApp {
alt='${this.escapeHtml(video.title)}' alt='${this.escapeHtml(video.title)}'
loading='lazy'> loading='lazy'>
<div class='tg-list-item__duration'>${this.formatDuration(video.duration)}</div> <div class='tg-list-item__duration'>${this.formatDuration(video.duration)}</div>
<button class='tg-list-item__play-btn' data-video-id='${video.id}' aria-label='Play preview'>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<path d="M10 8l6 4-6 4V8z" fill="white"/>
</svg>
</button>
</div> </div>
<div class='tg-list-item__info'> <div class='tg-list-item__info'>
<div class='tg-list-item__title'>${this.escapeHtml(video.title)}</div> <div class='tg-list-item__title'>${this.escapeHtml(video.title)}</div>
<div class='tg-list-item__subtitle'>${this.escapeHtml(video.channel)}</div> <div class='tg-list-item__subtitle'>${this.escapeHtml(video.channel)}</div>
</div> </div>
</div> </div>
<div class='tg-audio-player tg-hidden' data-video-id='${video.id}'>
<div class='tg-audio-player__progress'>
<div class='tg-audio-player__progress-bar'>
<div class='tg-audio-player__progress-fill'></div>
</div>
<div class='tg-audio-player__time'>
<span class='tg-audio-player__current'>0:00</span>
<span class='tg-audio-player__duration'>${this.formatDuration(video.duration)}</span>
</div>
</div>
</div>
`; `;
// Use event listener instead of inline onclick // Handle play button click
item.addEventListener('click', () => { const playBtn = item.querySelector('.tg-list-item__play-btn') as HTMLElement;
playBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.triggerHaptic('light');
this.toggleAudioPreview(video, item);
});
// Use event listener for download
item.addEventListener('click', (e) => {
// Don't trigger download if clicking play button
if ((e.target as HTMLElement).closest('.tg-list-item__play-btn')) {
return;
}
this.triggerHaptic('medium');
this.convertVideo(video.id, video.title, video.url); this.convertVideo(video.id, video.title, video.url);
}); });
@@ -330,6 +505,9 @@ class QuixoticApp {
videoElement.classList.remove('tg-list-item--active'); videoElement.classList.remove('tg-list-item--active');
videoElement.classList.add('tg-list-item--converting'); videoElement.classList.add('tg-list-item--converting');
// Add progress bar
this.showConversionProgress(videoElement);
} }
try { try {
@@ -358,6 +536,7 @@ class QuixoticApp {
if (this.tg) { if (this.tg) {
const userId = this.tg?.initDataUnsafe?.user?.id; const userId = this.tg?.initDataUnsafe?.user?.id;
if (!userId) { if (!userId) {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка: не удается определить пользователя', 'error'); this.showMessage('❌ Ошибка: не удается определить пользователя', 'error');
return; return;
} }
@@ -377,11 +556,14 @@ class QuixoticApp {
}); });
if (directResponse.ok) { if (directResponse.ok) {
this.triggerHaptic('success');
this.showMessage('✅ MP3 отправлен в чат!', 'success'); this.showMessage('✅ MP3 отправлен в чат!', 'success');
} else { } else {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка отправки в Telegram', 'error'); this.showMessage('❌ Ошибка отправки в Telegram', 'error');
} }
} catch { } catch {
this.triggerHaptic('error');
this.showMessage('❌ Ошибка соединения с ботом', 'error'); this.showMessage('❌ Ошибка соединения с ботом', 'error');
} }
} else { } else {
@@ -411,13 +593,48 @@ class QuixoticApp {
errorMsg = 'Видео заблокировано для скачивания.'; errorMsg = 'Видео заблокировано для скачивания.';
} }
this.triggerHaptic('error');
this.showMessage(`${errorMsg}`, 'error'); this.showMessage(`${errorMsg}`, 'error');
} finally { } finally {
if (videoElement) { if (videoElement) {
videoElement.classList.remove('tg-list-item--converting'); videoElement.classList.remove('tg-list-item--converting');
// Remove progress bar
const progressBar = videoElement.querySelector('.tg-conversion-progress');
if (progressBar) {
progressBar.remove();
} }
} }
} }
}
private showConversionProgress(element: HTMLElement): void {
const progressBar = document.createElement('div');
progressBar.className = 'tg-conversion-progress';
progressBar.innerHTML = `
<div class="tg-conversion-progress__bar">
<div class="tg-conversion-progress__fill"></div>
</div>
<div class="tg-conversion-progress__text">Конвертация...</div>
`;
element.appendChild(progressBar);
// Simulate progress (smooth animation)
const fill = progressBar.querySelector('.tg-conversion-progress__fill') as HTMLElement;
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 15;
if (progress > 90) progress = 90; // Stop at 90%
fill.style.width = `${progress}%`;
}, 200);
// Store cleanup function
(element as any).__progressCleanup = () => {
clearInterval(interval);
fill.style.width = '100%';
setTimeout(() => progressBar.remove(), 500);
};
}
private formatDuration(seconds: number): string { private formatDuration(seconds: number): string {
if (!seconds) return ''; if (!seconds) return '';
@@ -454,6 +671,126 @@ class QuixoticApp {
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
private async toggleAudioPreview(video: VideoResult, item: HTMLElement): Promise<void> {
const playerContainer = item.querySelector('.tg-audio-player') as HTMLElement;
const playBtn = item.querySelector('.tg-list-item__play-btn') as HTMLElement;
// If this is already playing, pause it
if (this.currentPlayingItem === item && this.currentAudio) {
this.stopAudioPreview();
return;
}
// Stop any other playing audio
if (this.currentAudio) {
this.stopAudioPreview();
}
try {
// Show converting state
item.classList.add('tg-list-item--loading-preview');
playBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<circle cx="12" cy="12" r="6" stroke="white" stroke-width="2" stroke-dasharray="9.42 9.42" fill="none">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/>
</circle>
</svg>
`;
// Get audio URL (we need to convert first)
const response = await fetch('/api/convert', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
videoId: video.id,
title: video.title,
url: video.url,
performer: video.channel,
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
})
});
if (!response.ok) {
throw new Error('Failed to get audio');
}
const data = await response.json();
if (!data.audioUrl) {
throw new Error('No audio URL');
}
// Create audio element
this.currentAudio = new Audio(data.audioUrl);
this.currentPlayingItem = item;
// Show player
playerContainer.classList.remove('tg-hidden');
item.classList.remove('tg-list-item--loading-preview');
item.classList.add('tg-list-item--playing');
// Update play button to pause
playBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<rect x="9" y="8" width="2" height="8" fill="white"/>
<rect x="13" y="8" width="2" height="8" fill="white"/>
</svg>
`;
// Setup progress tracking
const progressFill = playerContainer.querySelector('.tg-audio-player__progress-fill') as HTMLElement;
const currentTimeEl = playerContainer.querySelector('.tg-audio-player__current') as HTMLElement;
this.currentAudio.addEventListener('timeupdate', () => {
if (!this.currentAudio) return;
const progress = (this.currentAudio.currentTime / this.currentAudio.duration) * 100;
progressFill.style.width = `${progress}%`;
currentTimeEl.textContent = this.formatDuration(Math.floor(this.currentAudio.currentTime));
});
this.currentAudio.addEventListener('ended', () => {
this.stopAudioPreview();
});
// Play audio
await this.currentAudio.play();
this.triggerHaptic('light');
} catch (error) {
console.error('Preview error:', error);
item.classList.remove('tg-list-item--loading-preview');
this.showMessage('❌ Не удалось воспроизвести превью', 'error');
}
}
private stopAudioPreview(): void {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
}
if (this.currentPlayingItem) {
const playerContainer = this.currentPlayingItem.querySelector('.tg-audio-player') as HTMLElement;
const playBtn = this.currentPlayingItem.querySelector('.tg-list-item__play-btn') as HTMLElement;
playerContainer?.classList.add('tg-hidden');
this.currentPlayingItem.classList.remove('tg-list-item--playing');
// Reset play button
if (playBtn) {
playBtn.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
<path d="M10 8l6 4-6 4V8z" fill="white"/>
</svg>
`;
}
this.currentPlayingItem = null;
}
}
} }
const app = new QuixoticApp(); const app = new QuixoticApp();

View File

@@ -71,6 +71,11 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--tg-spacing-xl); gap: var(--tg-spacing-xl);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tg-content.tg-pulling {
transition: none;
} }
/* Form components */ /* Form components */
@@ -93,6 +98,7 @@ body {
width: 100%; width: 100%;
height: 48px; height: 48px;
padding: 0 var(--tg-spacing-lg); padding: 0 var(--tg-spacing-lg);
padding-right: 48px; /* Make room for clear button */
background: var(--tg-color-section-bg); background: var(--tg-color-section-bg);
border: 2px solid var(--tg-color-secondary-bg); border: 2px solid var(--tg-color-secondary-bg);
border-radius: var(--tg-border-radius); border-radius: var(--tg-border-radius);
@@ -111,6 +117,33 @@ body {
background: var(--tg-color-bg); background: var(--tg-color-bg);
} }
.tg-input-clear {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--tg-color-hint);
padding: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.tg-input-clear:hover {
background: var(--tg-color-secondary-bg);
color: var(--tg-color-text);
}
.tg-input-clear:active {
transform: translateY(-50%) scale(0.9);
}
/* Button components */ /* Button components */
.tg-button { .tg-button {
position: relative; position: relative;
@@ -244,6 +277,66 @@ body {
} }
} }
/* Skeleton loading screens */
.tg-skeleton-list {
display: flex;
flex-direction: column;
gap: var(--tg-spacing-xs);
}
.tg-skeleton-list.tg-hidden {
display: none;
}
.tg-skeleton-item {
display: flex;
gap: var(--tg-spacing-md);
padding: var(--tg-spacing-md);
background: var(--tg-color-section-bg);
border-radius: var(--tg-border-radius);
}
.tg-skeleton-thumbnail {
width: 80px;
height: 60px;
background: linear-gradient(90deg,
var(--tg-color-secondary-bg) 25%,
var(--tg-color-hint) 50%,
var(--tg-color-secondary-bg) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--tg-border-radius-small);
flex-shrink: 0;
}
.tg-skeleton-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--tg-spacing-sm);
}
.tg-skeleton-line {
height: 16px;
background: linear-gradient(90deg,
var(--tg-color-secondary-bg) 25%,
var(--tg-color-hint) 50%,
var(--tg-color-secondary-bg) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.tg-skeleton-line--short {
width: 60%;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* List component */ /* List component */
.tg-list { .tg-list {
display: none; display: none;
@@ -264,6 +357,7 @@ body {
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
position: relative; position: relative;
opacity: 1;
} }
/* Hover effects for desktop */ /* Hover effects for desktop */
@@ -313,6 +407,34 @@ body {
image-rendering: optimizeQuality; image-rendering: optimizeQuality;
} }
.tg-list-item__play-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 2;
}
.tg-list-item__media:hover .tg-list-item__play-btn,
.tg-list-item--playing .tg-list-item__play-btn {
opacity: 1;
}
@media (hover: none) {
.tg-list-item__play-btn {
opacity: 1;
}
}
.tg-list-item__duration { .tg-list-item__duration {
position: absolute; position: absolute;
bottom: 2px; bottom: 2px;
@@ -371,6 +493,81 @@ body {
animation: tg-spin 1s linear infinite; animation: tg-spin 1s linear infinite;
} }
/* Conversion progress bar */
.tg-conversion-progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--tg-spacing-xs);
background: rgba(0, 0, 0, 0.05);
z-index: 10;
}
.tg-conversion-progress__bar {
height: 4px;
background: var(--tg-color-secondary-bg);
border-radius: 2px;
overflow: hidden;
}
.tg-conversion-progress__fill {
height: 100%;
background: var(--tg-color-button);
transition: width 0.3s ease;
border-radius: 2px;
width: 0;
}
.tg-conversion-progress__text {
font-size: var(--tg-font-size-xs);
color: var(--tg-color-hint);
text-align: center;
margin-top: 4px;
}
/* Audio player */
.tg-audio-player {
padding: var(--tg-spacing-sm) var(--tg-spacing-md);
background: var(--tg-color-secondary-bg);
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.tg-audio-player__progress {
display: flex;
flex-direction: column;
gap: var(--tg-spacing-xs);
}
.tg-audio-player__progress-bar {
height: 4px;
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
overflow: hidden;
cursor: pointer;
}
.tg-audio-player__progress-fill {
height: 100%;
background: var(--tg-color-button);
width: 0%;
transition: width 0.1s linear;
border-radius: 2px;
}
.tg-audio-player__time {
display: flex;
justify-content: space-between;
font-size: var(--tg-font-size-xs);
color: var(--tg-color-hint);
font-variant-numeric: tabular-nums;
}
.tg-list-item--loading-preview {
opacity: 0.7;
pointer-events: none;
}
/* Status message */ /* Status message */
.tg-status-message { .tg-status-message {
position: fixed; position: fixed;

View File

@@ -1,5 +1,6 @@
import TelegramBot from 'node-telegram-bot-api'; import TelegramBot from 'node-telegram-bot-api';
import { Database } from './database'; import { Database } from './database';
import { logger } from './logger';
interface TelegramUser { interface TelegramUser {
id: number; id: number;
@@ -49,7 +50,7 @@ export class QuixoticBot {
const useWebhook = process.env.NODE_ENV === 'production' && process.env.WEBHOOK_URL; const useWebhook = process.env.NODE_ENV === 'production' && process.env.WEBHOOK_URL;
if (useWebhook) { if (useWebhook) {
console.log('🌐 Using webhook mode for production'); logger.telegram('Using webhook mode for production');
this.bot = new TelegramBot(token, { this.bot = new TelegramBot(token, {
webHook: { webHook: {
port: 8443, port: 8443,
@@ -57,7 +58,7 @@ export class QuixoticBot {
} }
}); });
} else { } else {
console.log('🔄 Using polling mode for development'); logger.telegram('Using polling mode for development');
this.bot = new TelegramBot(token, { polling: true }); this.bot = new TelegramBot(token, { polling: true });
} }
@@ -67,7 +68,7 @@ export class QuixoticBot {
} }
private init(): void { private init(): void {
console.log('🤖 Telegram bot initialized'); logger.telegram('Bot initialized');
this.setupCommands(); this.setupCommands();
this.setupHandlers(); this.setupHandlers();
} }
@@ -82,7 +83,7 @@ export class QuixoticBot {
} }
private setupHandlers(): void { private setupHandlers(): void {
console.log('🔧 Setting up bot handlers...'); logger.telegram('Setting up bot handlers...');
// Handle messages // Handle messages
this.bot.on('message', (msg: any) => { this.bot.on('message', (msg: any) => {
@@ -108,21 +109,21 @@ export class QuixoticBot {
const keyboard = { const keyboard = {
inline_keyboard: [[ inline_keyboard: [[
{ {
text: '🎵 Открыть Quixotic', text: 'Открыть Quixotic',
web_app: { url: this.webAppUrl } web_app: { url: this.webAppUrl }
} }
]] ]]
}; };
await this.bot.sendMessage(chatId, await this.bot.sendMessage(chatId,
'🎵 Добро пожаловать в Quixotic!\n\n' + 'Добро пожаловать в Quixotic!\n\n' +
'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' + 'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' +
'Нажми кнопку ниже, чтобы начать поиск:', 'Нажми кнопку ниже, чтобы начать поиск:',
{ reply_markup: keyboard } { reply_markup: keyboard }
); );
} catch (error) { } catch (error) {
console.error('Start command error:', error); logger.error('Start command error:', error);
await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.'); await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.');
} }
}); });
@@ -130,13 +131,13 @@ export class QuixoticBot {
this.bot.onText(/\/help/, async (msg: Message) => { this.bot.onText(/\/help/, async (msg: Message) => {
const chatId = msg.chat.id; const chatId = msg.chat.id;
const helpText = `🎵 *Quixotic - SoundCloud to MP3* const helpText = `*Quixotic - SoundCloud to MP3*
*Как пользоваться:* *Как пользоваться:*
1️⃣ Нажми кнопку "Открыть Quixotic" 1. Нажми кнопку "Открыть Quixotic"
2️⃣ Введи название песни в поисковую строку 2. Введи название песни в поисковую строку
3️⃣ Выбери нужный трек из списка 3. Выбери нужный трек из списка
4️⃣ Получи MP3 файл в чат! 4. Получи MP3 файл в чат!
*Команды:* *Команды:*
/start - Запустить приложение /start - Запустить приложение
@@ -144,10 +145,10 @@ export class QuixoticBot {
/history - История поиска /history - История поиска
*Возможности:* *Возможности:*
Поиск по SoundCloud - Поиск по SoundCloud
Высокое качество MP3 (192kbps) - Высокое качество MP3 (192kbps)
Быстрая конвертация - Быстрая конвертация
История поиска`; - История поиска`;
await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' }); await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' });
}); });
@@ -174,7 +175,7 @@ export class QuixoticBot {
return; return;
} }
let historyText = '📋 *Последние поисковые запросы:*\n\n'; let historyText = '*Последние поисковые запросы:*\n\n';
history.forEach((item, index) => { history.forEach((item, index) => {
const date = new Date(item.created_at).toLocaleDateString('ru-RU'); const date = new Date(item.created_at).toLocaleDateString('ru-RU');
historyText += `${index + 1}. ${item.query} _(${date})_\n`; historyText += `${index + 1}. ${item.query} _(${date})_\n`;
@@ -182,8 +183,8 @@ export class QuixoticBot {
await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' }); await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' });
} catch (error) { } catch (error) {
console.error('History command error:', error); logger.error('History command error:', error);
await this.bot.sendMessage(chatId, 'Ошибка получения истории.'); await this.bot.sendMessage(chatId, 'Ошибка получения истории.');
} }
}); });
@@ -226,10 +227,10 @@ export class QuixoticBot {
type: 'article', type: 'article',
id: `${index}`, id: `${index}`,
title: video.title, title: video.title,
description: `${video.channel} ${this.formatDuration(video.duration)}`, description: `${video.channel} - ${this.formatDuration(video.duration)}`,
thumb_url: video.thumbnail, thumb_url: video.thumbnail,
input_message_content: { input_message_content: {
message_text: `🎵 ${video.title}\n🔗 ${video.url}` message_text: `${video.title}\n${video.url}`
} }
})); }));
@@ -238,31 +239,31 @@ export class QuixoticBot {
is_personal: true is_personal: true
}); });
} catch (error) { } catch (error) {
console.error('Inline query error:', error); logger.error('Inline query error:', error);
await this.bot.answerInlineQuery(queryId, []); await this.bot.answerInlineQuery(queryId, []);
} }
}); });
// Error handler with detailed logging // Error handler with detailed logging
this.bot.on('error', (error: any) => { this.bot.on('error', (error: any) => {
console.error('🚨 Telegram bot error:', error.message || error); logger.error('Telegram bot error:', error.message || error);
console.error('Error code:', error.code); logger.error('Error code:', error.code);
console.error('Full error:', error); logger.error('Full error:', error);
}); });
// Handle polling errors specifically // Handle polling errors specifically
this.bot.on('polling_error', (error: any) => { this.bot.on('polling_error', (error: any) => {
console.error('🚨 Telegram polling error:', error.message || error); logger.error('Telegram polling error:', error.message || error);
console.error('Error code:', error.code); logger.error('Error code:', error.code);
// Don't crash on polling errors, just log them // Don't crash on polling errors, just log them
if (error.code === 'ETELEGRAM') { if (error.code === 'ETELEGRAM') {
console.warn('⚠️ Telegram API error - continuing operation'); logger.warn('Telegram API error - continuing operation');
} }
}); });
console.log('Bot handlers setup complete'); logger.telegram('Bot handlers setup complete');
} }
private async getSearchHistory(userId: number): Promise<SearchResult[]> { private async getSearchHistory(userId: number): Promise<SearchResult[]> {
@@ -276,8 +277,9 @@ export class QuixoticBot {
private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise<void> { private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
try { try {
console.log(`📤 Sending: ${title} to chat ${chatId}`); logger.telegram('Sending audio', `${title} to chat ${chatId}`);
console.log(`📂 File source: ${audioUrlOrPath}`); logger.debug(`File source: ${audioUrlOrPath}`);
logger.debug(`Thumbnail: ${thumbnail}`);
// Check if it's a URL or local file path // Check if it's a URL or local file path
const isUrl = audioUrlOrPath.startsWith('http'); const isUrl = audioUrlOrPath.startsWith('http');
@@ -288,82 +290,132 @@ export class QuixoticBot {
const urlParts = audioUrlOrPath.split('/'); const urlParts = audioUrlOrPath.split('/');
const filename = urlParts[urlParts.length - 1]; const filename = urlParts[urlParts.length - 1];
filePath = require('path').join(process.cwd(), 'downloads', filename); filePath = require('path').join(process.cwd(), 'downloads', filename);
console.log(`📂 Converted URL to local path: ${filePath}`); logger.debug(`Converted URL to local path: ${filePath}`);
} }
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const https = require('https');
// Check if file exists // Check if file exists
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
console.error(`File not found: ${filePath}`); logger.error(`File not found: ${filePath}`);
throw new Error('File not found: ' + filePath); throw new Error('File not found: ' + filePath);
} }
// Get file stats for debugging // Get file stats for debugging
const stats = fs.statSync(filePath); const stats = fs.statSync(filePath);
console.log(`📊 File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); logger.debug(`File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
// Generate custom filename for display // Generate custom filename for display
const safeTitle = (title || 'audio').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 30); const safeTitle = (title || 'audio').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 30);
const safePerformer = (performer || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 20); const safePerformer = (performer || '').replace(/[^\w\s-]/g, '').replace(/\s+/g, '_').substring(0, 20);
const customFilename = safePerformer ? `${safePerformer} - ${safeTitle}.mp3` : `${safeTitle}.mp3`; const customFilename = safePerformer ? `${safePerformer} - ${safeTitle}.mp3` : `${safeTitle}.mp3`;
console.log(`📝 Sending as: ${customFilename}`); logger.debug(`Sending as: ${customFilename}`);
// Download thumbnail if provided
let thumbnailPath: string | undefined;
if (thumbnail && thumbnail.startsWith('http')) {
try {
logger.debug('Downloading thumbnail...');
const thumbnailFilename = `thumb_${Date.now()}.jpg`;
thumbnailPath = path.join(process.cwd(), 'downloads', thumbnailFilename);
await new Promise<void>((resolve, reject) => {
const file = fs.createWriteStream(thumbnailPath);
https.get(thumbnail, (response: any) => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve();
});
}).on('error', (err: any) => {
fs.unlink(thumbnailPath, () => {});
reject(err);
});
});
logger.success(`Thumbnail downloaded: ${thumbnailPath}`);
} catch (thumbError: any) {
logger.warn('Failed to download thumbnail:', thumbError.message);
thumbnailPath = undefined;
}
}
// Send file using stream (better for large files) // Send file using stream (better for large files)
const fileStream = fs.createReadStream(filePath); const fileStream = fs.createReadStream(filePath);
// Try sending as audio with metadata // Try sending as audio with metadata
try { try {
await this.bot.sendAudio(chatId, fileStream, { const options: any = {
title: title, title: title,
performer: performer || 'Unknown Artist', performer: performer || 'Unknown Artist',
caption: undefined, caption: undefined,
thumbnail: undefined, // Thumbnail requires special handling
parse_mode: undefined parse_mode: undefined
}, { };
// Add thumbnail if downloaded
if (thumbnailPath) {
options.thumbnail = fs.createReadStream(thumbnailPath);
}
await this.bot.sendAudio(chatId, fileStream, options, {
filename: customFilename, filename: customFilename,
contentType: 'audio/mpeg' contentType: 'audio/mpeg'
}); });
console.log(`✅ Audio sent successfully: ${title}`);
logger.success(`Audio sent successfully: ${title}`);
// Clean up thumbnail file
if (thumbnailPath) {
fs.unlink(thumbnailPath, (err: any) => {
if (err) logger.error('Failed to delete thumbnail:', err);
});
}
return; return;
} catch (error: any) { } catch (error: any) {
console.error('Audio send failed:', error.message); logger.error('Audio send failed:', error.message);
console.error('Error code:', error.code); logger.error('Error code:', error.code);
// Clean up thumbnail file on error
if (thumbnailPath) {
fs.unlink(thumbnailPath, () => {});
}
// Fallback: try as document // Fallback: try as document
try { try {
console.log('🔄 Retrying as document...'); logger.info('Retrying as document...');
const docStream = fs.createReadStream(filePath); const docStream = fs.createReadStream(filePath);
await this.bot.sendDocument(chatId, docStream, { await this.bot.sendDocument(chatId, docStream, {
caption: `🎵 ${title}\n👤 ${performer || 'Unknown Artist'}`, caption: `${title}\n${performer || 'Unknown Artist'}`,
parse_mode: undefined parse_mode: undefined
}, { }, {
filename: customFilename, filename: customFilename,
contentType: 'audio/mpeg' contentType: 'audio/mpeg'
}); });
console.log(`Document sent successfully: ${title}`); logger.success(`Document sent successfully: ${title}`);
return; return;
} catch (documentError: any) { } catch (documentError: any) {
console.error('Document send also failed:', documentError.message); logger.error('Document send also failed:', documentError.message);
throw documentError; throw documentError;
} }
} }
} catch (error: any) { } catch (error: any) {
console.error('Send failed completely:', error.message); logger.error('Send failed completely:', error.message);
console.error('Full error:', error); logger.error('Full error:', error);
// Send error message to user // Send error message to user
try { try {
await this.bot.sendMessage(chatId, await this.bot.sendMessage(chatId,
`Не удалось отправить файл.\n🎵 ${title}\n\опробуйте другой трек.` `Не удалось отправить файл.\n${title}\n\опробуйте другой трек.`
); );
} catch { } catch {
console.error('Could not even send error message'); logger.error('Could not even send error message');
} }
// Re-throw to trigger unhandled rejection handler // Re-throw to trigger unhandled rejection handler
@@ -382,11 +434,11 @@ export class QuixoticBot {
const data: WebAppData = JSON.parse(msg.web_app.data); const data: WebAppData = JSON.parse(msg.web_app.data);
if (data.action === 'send_audio') { if (data.action === 'send_audio') {
console.log(`🎵 WebApp request: ${data.title}`); logger.telegram('WebApp request', data.title);
await this.sendAudioFileInternal(chatId, data.audioUrl, data.title); await this.sendAudioFileInternal(chatId, data.audioUrl, data.title);
} }
} catch (parseError: any) { } catch (parseError: any) {
console.error('WebApp data parse error:', parseError.message); logger.error('WebApp data parse error:', parseError.message);
} }
} }
@@ -405,7 +457,7 @@ if (require.main === module) {
const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com'; const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com';
if (!token) { if (!token) {
console.error('TELEGRAM_BOT_TOKEN environment variable is required'); logger.error('TELEGRAM_BOT_TOKEN environment variable is required');
process.exit(1); process.exit(1);
} }

View File

@@ -1,4 +1,5 @@
import { Pool } from 'pg'; import { Pool } from 'pg';
import { logger } from './logger';
interface TelegramUser { interface TelegramUser {
id: number; id: number;
@@ -39,7 +40,7 @@ export class Database {
`); `);
if (!tablesExist.rows[0].exists) { if (!tablesExist.rows[0].exists) {
console.log('Creating database tables...'); logger.info('Creating database tables...');
// Users table // Users table
await this.pool.query(`CREATE TABLE users ( await this.pool.query(`CREATE TABLE users (
@@ -69,12 +70,12 @@ export class Database {
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)`); )`);
console.log('Database tables created successfully'); logger.success('Database tables created successfully');
} else { } else {
console.log('Database tables already exist'); logger.info('Database tables already exist');
} }
} catch (error) { } catch (error) {
console.error('Database initialization error:', error); logger.error('Database initialization error:', error);
} }
} }

81
src/logger.ts Normal file
View File

@@ -0,0 +1,81 @@
import winston from 'winston';
/**
* Professional logging utility using Winston
* Provides colored console output with timestamps
*/
const { combine, timestamp, printf, colorize, align } = winston.format;
// Custom format for clean, readable logs
const logFormat = printf(({ level, message, timestamp }) => {
return `${timestamp} ${level}: ${message}`;
});
// Create Winston logger instance
const winstonLogger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: combine(
colorize({ all: true }),
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
align(),
logFormat
),
transports: [
new winston.transports.Console({
stderrLevels: ['error']
})
]
});
// Wrapper class for convenience methods
class Logger {
debug(message: string, ...meta: any[]): void {
winstonLogger.debug(message, ...meta);
}
info(message: string, ...meta: any[]): void {
winstonLogger.info(message, ...meta);
}
warn(message: string, ...meta: any[]): void {
winstonLogger.warn(message, ...meta);
}
error(message: string, ...meta: any[]): void {
winstonLogger.error(message, ...meta);
}
// Success is just info with green color
success(message: string, ...meta: any[]): void {
winstonLogger.info(message, ...meta);
}
// Specialized logging methods
http(method: string, path: string, status: number): void {
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
winstonLogger.log(level, `${method} ${path} ${status}`);
}
database(operation: string, details: string): void {
this.debug(`[DB] ${operation}: ${details}`);
}
telegram(action: string, details?: string): void {
const msg = details ? `[Telegram] ${action}: ${details}` : `[Telegram] ${action}`;
this.info(msg);
}
soundcloud(action: string, details?: string): void {
const msg = details ? `[SoundCloud] ${action}: ${details}` : `[SoundCloud] ${action}`;
this.info(msg);
}
ffmpeg(action: string, details?: string): void {
const msg = details ? `[FFmpeg] ${action}: ${details}` : `[FFmpeg] ${action}`;
this.debug(msg);
}
}
// Export singleton instance
export const logger = new Logger();

View File

@@ -10,6 +10,7 @@ ffmpeg.setFfprobePath('/usr/bin/ffprobe');
import { Database } from './database'; import { Database } from './database';
import { SoundCloudService } from './soundcloud'; import { SoundCloudService } from './soundcloud';
import { QuixoticBot } from './bot'; import { QuixoticBot } from './bot';
import { logger } from './logger';
const app = express(); const app = express();
const port = process.env.PORT || 3000; const port = process.env.PORT || 3000;
@@ -70,7 +71,10 @@ if (!fs.existsSync(downloadsDir)) {
// Routes // Routes
app.get('/', (req: Request, res: Response) => { app.get('/', (req: Request, res: Response) => {
const indexPath = path.join(__dirname, '../public/index.html'); // Use minified HTML in production
const isProduction = process.env.NODE_ENV === 'production';
const htmlFile = isProduction ? 'index.min.html' : 'index.html';
const indexPath = path.join(__dirname, '../public', htmlFile);
// Set cache headers for HTML (short cache) // Set cache headers for HTML (short cache)
res.set({ res.set({
@@ -97,7 +101,7 @@ app.post('/api/search', async (req: Request, res: Response) => {
await db.addSearchHistory(user.id, query); await db.addSearchHistory(user.id, query);
} }
} catch (dbError) { } catch (dbError) {
console.error('Database error:', dbError); logger.error('Database error:', dbError);
} }
} }
@@ -105,7 +109,7 @@ app.post('/api/search', async (req: Request, res: Response) => {
res.json({ videos }); res.json({ videos });
} catch (error) { } catch (error) {
console.error('Search error:', error); logger.error('Search error:', error);
res.status(500).json({ error: 'Failed to search videos' }); res.status(500).json({ error: 'Failed to search videos' });
} }
}); });
@@ -114,7 +118,7 @@ app.post('/api/search', async (req: Request, res: Response) => {
app.post('/api/convert', async (req: Request, res: Response) => { app.post('/api/convert', async (req: Request, res: Response) => {
try { try {
const { videoId, title, userId, url }: { videoId?: string; title?: string; userId?: string; url?: string } = req.body; const { videoId, title, userId, url }: { videoId?: string; title?: string; userId?: string; url?: string } = req.body;
console.log('Convert request received:', { videoId, title, userId }); logger.info(`Convert request received: ${title} (ID: ${videoId})`);
if (!videoId) { if (!videoId) {
return res.status(400).json({ error: 'Video ID is required' }); return res.status(400).json({ error: 'Video ID is required' });
@@ -127,18 +131,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
// Check if file already exists // Check if file already exists
if (fs.existsSync(outputPath)) { if (fs.existsSync(outputPath)) {
console.log('File already exists, serving cached version'); logger.info('File already exists, serving cached version');
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`; const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
return res.json({ audioUrl, title }); return res.json({ audioUrl, title });
} }
console.log(`Starting MP3 conversion for: ${title}`); logger.info(`Starting MP3 conversion: ${title}`);
try { try {
// Get audio stream from YouTube // Get audio stream from YouTube
console.log(`Attempting to get audio stream for: ${videoId}`); logger.debug(`Attempting to get audio stream for: ${videoId}`);
const audioStream = await soundcloud.getAudioStream(videoId, url); const audioStream = await soundcloud.getAudioStream(videoId, url);
console.log('Audio stream obtained, starting FFmpeg conversion...'); logger.info('Audio stream obtained, starting FFmpeg conversion...');
// Download to temporary file first, then convert // Download to temporary file first, then convert
const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`); const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`);
@@ -152,19 +156,19 @@ app.post('/api/convert', async (req: Request, res: Response) => {
writeStream.on('error', reject); writeStream.on('error', reject);
}); });
console.log('Temporary file saved, starting FFmpeg conversion...'); logger.info('Temporary file saved, starting FFmpeg conversion...');
// Debug: check temp file // Debug: check temp file
const stats = fs.statSync(tempInputPath); const stats = fs.statSync(tempInputPath);
console.log(`Temp file size: ${stats.size} bytes`); logger.debug(`Temp file size: ${stats.size} bytes`);
// Test ffmpeg with simple command first // Test ffmpeg with simple command first
try { try {
const { execSync } = require('child_process'); const { execSync } = require('child_process');
execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' }); execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' });
console.log('FFmpeg file test passed'); logger.debug('FFmpeg file test passed');
} catch (e: any) { } catch (e: any) {
console.error('FFmpeg file test failed:', e.stderr || e.message); logger.error('FFmpeg file test failed:', e.stderr || e.message);
} }
// Convert temporary file to MP3 using ffmpeg // Convert temporary file to MP3 using ffmpeg
@@ -177,23 +181,23 @@ app.post('/api/convert', async (req: Request, res: Response) => {
.format('mp3') .format('mp3')
.output(outputPath) .output(outputPath)
.on('start', (command: string) => { .on('start', (command: string) => {
console.log('FFmpeg started:', command); logger.ffmpeg('Started', command);
}) })
.on('progress', (progress: any) => { .on('progress', (progress: any) => {
if (progress.percent) { if (progress.percent) {
console.log(`Conversion progress: ${Math.round(progress.percent)}%`); logger.ffmpeg('Progress', `${Math.round(progress.percent)}%`);
} }
}) })
.on('end', () => { .on('end', () => {
console.log('MP3 conversion completed successfully'); logger.success('MP3 conversion completed successfully');
// Clean up temporary file // Clean up temporary file
fs.unlink(tempInputPath, (err) => { fs.unlink(tempInputPath, (err) => {
if (err) console.error('Failed to delete temp file:', err); if (err) logger.error('Failed to delete temp file:', err);
}); });
resolve(); resolve();
}) })
.on('error', (err: Error) => { .on('error', (err: Error) => {
console.error('FFmpeg error:', err.message); logger.error('FFmpeg error:', err.message);
// Clean up temporary file on error // Clean up temporary file on error
fs.unlink(tempInputPath, () => {}); fs.unlink(tempInputPath, () => {});
reject(err); reject(err);
@@ -210,18 +214,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
await db.addDownload(user.id, videoId, title || '', outputPath); await db.addDownload(user.id, videoId, title || '', outputPath);
} }
} catch (dbError) { } catch (dbError) {
console.error('Database error:', dbError); logger.error('Database error:', dbError);
} }
} }
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`; const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
console.log('Conversion successful, file available at:', audioUrl); logger.success(`Conversion successful: ${audioUrl}`);
res.json({ audioUrl, title }); res.json({ audioUrl, title });
} catch (conversionError: any) { } catch (conversionError: any) {
console.error('Conversion failed for video:', videoId); logger.error(`Conversion failed for video: ${videoId}`);
console.error('Error details:', conversionError.message); logger.error('Error details:', conversionError.message);
console.error('Full error:', conversionError); logger.error('Full error:', conversionError);
// Return error - no fallbacks for Telegram bot // Return error - no fallbacks for Telegram bot
return res.status(503).json({ return res.status(503).json({
@@ -232,18 +236,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
} }
} catch (error) { } catch (error) {
console.error('Server error:', error); logger.error('Server error:', error);
res.status(500).json({ error: 'Failed to process request' }); res.status(500).json({ error: 'Failed to process request' });
} }
}); });
// Direct Telegram API for sending audio // Direct Telegram API for sending audio
app.post('/api/telegram-send', async (req: Request, res: Response) => { app.post('/api/telegram-send', async (req: Request, res: Response) => {
console.log('🚀 Telegram send request received'); logger.telegram('Send request received');
try { try {
const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body; const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body;
console.log(`📤 Sending to user ${userId}: ${title}`); logger.telegram('Sending to user', `${userId}: ${title}`);
if (!userId || !audioUrl || !title) { if (!userId || !audioUrl || !title) {
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
@@ -251,18 +255,18 @@ app.post('/api/telegram-send', async (req: Request, res: Response) => {
const botInstance = (global as any).quixoticBot; const botInstance = (global as any).quixoticBot;
if (!botInstance) { if (!botInstance) {
console.log('Bot not available'); logger.error('Bot not available');
return res.status(500).json({ error: 'Bot not available' }); return res.status(500).json({ error: 'Bot not available' });
} }
const chatId = parseInt(userId); const chatId = parseInt(userId);
await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail); await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail);
console.log('Audio sent successfully'); logger.success('Audio sent successfully');
res.json({ success: true, message: 'Audio sent successfully' }); res.json({ success: true, message: 'Audio sent successfully' });
} catch (error: any) { } catch (error: any) {
console.error('Send failed:', error.message); logger.error('Send failed:', error.message);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -277,7 +281,7 @@ app.get('/health', (req: Request, res: Response) => {
// Error handler // Error handler
app.use((err: Error, _req: Request, res: Response, _next: any) => { app.use((err: Error, _req: Request, res: Response, _next: any) => {
console.error(err.stack); logger.error(err.stack || err.message);
res.status(500).json({ error: 'Something went wrong!' }); res.status(500).json({ error: 'Something went wrong!' });
}); });
@@ -297,7 +301,7 @@ setInterval(() => {
if (now - stats.mtime.getTime() > maxAge) { if (now - stats.mtime.getTime() > maxAge) {
fs.unlink(filePath, (err) => { fs.unlink(filePath, (err) => {
if (!err) { if (!err) {
console.log('Deleted old file:', file); logger.info('Deleted old file:', file);
} }
}); });
} }
@@ -307,9 +311,9 @@ setInterval(() => {
}, 60 * 60 * 1000); // Run every hour }, 60 * 60 * 1000); // Run every hour
app.listen(port, () => { app.listen(port, () => {
console.log(`Quixotic server running on port ${port}`); logger.success(`Quixotic server running on port ${port}`);
console.log(`Downloads directory: ${downloadsDir}`); logger.info(`Downloads directory: ${downloadsDir}`);
console.log(`Open in browser: http://localhost:${port}`); logger.info(`Open in browser: http://localhost:${port}`);
}); });
// Initialize Telegram bot // Initialize Telegram bot
@@ -321,33 +325,33 @@ if (botToken && botToken.length > 10 && botToken !== 'your_telegram_bot_token_he
const botInstance = new QuixoticBot(botToken, webAppUrl); const botInstance = new QuixoticBot(botToken, webAppUrl);
// Store bot instance globally for API access // Store bot instance globally for API access
(global as any).quixoticBot = botInstance; (global as any).quixoticBot = botInstance;
console.log('🤖 Telegram bot started and stored globally'); logger.telegram('Bot started and stored globally');
} catch (error: any) { } catch (error: any) {
console.error('Bot initialization failed:', error.message); logger.error('Bot initialization failed:', error.message);
console.warn('⚠️ Bot disabled due to error'); logger.warn('Bot disabled due to error');
console.warn('⚠️ Telegram integration will not be available'); logger.warn('Telegram integration will not be available');
// Don't crash the server, continue without bot // Don't crash the server, continue without bot
} }
} else { } else {
console.warn('⚠️ TELEGRAM_BOT_TOKEN not configured properly'); logger.warn('TELEGRAM_BOT_TOKEN not configured properly');
console.warn('⚠️ Bot will not start - only web interface will be available'); logger.warn('Bot will not start - only web interface will be available');
console.warn(' To enable Telegram bot, set a valid TELEGRAM_BOT_TOKEN'); logger.info('To enable Telegram bot, set a valid TELEGRAM_BOT_TOKEN');
} }
// Handle unhandled promise rejections // Handle unhandled promise rejections
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => { process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
console.error('🚨 Unhandled Rejection at:', promise); logger.error('Unhandled Rejection at:', promise);
console.error('Reason:', reason); logger.error('Reason:', reason);
// Log but don't crash the server // Log but don't crash the server
if (reason?.code === 'ETELEGRAM') { if (reason?.code === 'ETELEGRAM') {
console.warn('⚠️ Telegram API error - continuing operation'); logger.warn('Telegram API error - continuing operation');
} }
}); });
// Handle uncaught exceptions // Handle uncaught exceptions
process.on('uncaughtException', (error: Error) => { process.on('uncaughtException', (error: Error) => {
console.error('🚨 Uncaught Exception:', error); logger.error('Uncaught Exception:', error);
// Log but try to continue // Log but try to continue
}); });

View File

@@ -1,5 +1,6 @@
import scdl from 'soundcloud-downloader'; import scdl from 'soundcloud-downloader';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { logger } from './logger';
interface SearchTrack { interface SearchTrack {
id: number; id: number;
@@ -36,7 +37,7 @@ interface TrackInfo {
export class SoundCloudService { export class SoundCloudService {
constructor() { constructor() {
console.log('SoundCloud service initialized'); logger.soundcloud('Service initialized');
} }
private getHighQualityThumbnail(originalUrl: string): string { private getHighQualityThumbnail(originalUrl: string): string {
@@ -68,7 +69,7 @@ export class SoundCloudService {
async searchTracks(query: string, maxResults: number = 10): Promise<TrackResult[]> { async searchTracks(query: string, maxResults: number = 10): Promise<TrackResult[]> {
try { try {
console.log(`Searching SoundCloud for: ${query}`); logger.soundcloud('Searching', query);
// Search for tracks on SoundCloud // Search for tracks on SoundCloud
const searchResult = await scdl.search({ const searchResult = await scdl.search({
@@ -101,7 +102,7 @@ export class SoundCloudService {
} }
if (!tracks || tracks.length === 0) { if (!tracks || tracks.length === 0) {
console.log('No tracks found'); logger.warn('No tracks found');
return []; return [];
} }
@@ -116,11 +117,11 @@ export class SoundCloudService {
downloadable: track.downloadable downloadable: track.downloadable
})); }));
console.log(`Found ${trackResults.length} tracks on SoundCloud`); logger.success(`Found ${trackResults.length} tracks on SoundCloud`);
return trackResults; return trackResults;
} catch (error: any) { } catch (error: any) {
console.error('SoundCloud search error:', error.message); logger.error('SoundCloud search error:', error.message);
return []; return [];
} }
} }
@@ -136,20 +137,20 @@ export class SoundCloudService {
thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || '') thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || '')
}; };
} catch (error) { } catch (error) {
console.error('Error getting track info:', error); logger.error('Error getting track info:', error);
throw error; throw error;
} }
} }
async getAudioStream(trackId: string | number, trackUrl?: string): Promise<Readable> { async getAudioStream(trackId: string | number, trackUrl?: string): Promise<Readable> {
try { try {
console.log(`Getting audio stream for track: ${trackId}`); logger.soundcloud('Getting audio stream', `track: ${trackId}`);
// If trackUrl is provided, use it directly // If trackUrl is provided, use it directly
if (trackUrl) { if (trackUrl) {
console.log(`Using provided track URL: ${trackUrl}`); logger.debug(`Using provided track URL: ${trackUrl}`);
const stream = await scdl.download(trackUrl); const stream = await scdl.download(trackUrl);
console.log('Audio stream obtained successfully from SoundCloud using URL'); logger.success('Audio stream obtained successfully from SoundCloud using URL');
return stream; return stream;
} }
@@ -160,39 +161,39 @@ export class SoundCloudService {
throw new Error('Track is not streamable'); throw new Error('Track is not streamable');
} }
console.log(`Track: ${trackInfo.title}`); logger.debug(`Track: ${trackInfo.title}`);
console.log(`Artist: ${trackInfo.user?.username || 'Unknown'}`); logger.debug(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
console.log(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`); logger.debug(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
// Use the permalink_url from track info // Use the permalink_url from track info
const stream = await scdl.download(trackInfo.permalink_url); const stream = await scdl.download(trackInfo.permalink_url);
console.log('Audio stream obtained successfully from SoundCloud'); logger.success('Audio stream obtained successfully from SoundCloud');
return stream; return stream;
} catch (error: any) { } catch (error: any) {
console.error('SoundCloud download failed:', error.message); logger.error('SoundCloud download failed:', error.message);
// Try alternative approaches // Try alternative approaches
try { try {
console.log('Trying alternative SoundCloud methods...'); logger.info('Trying alternative SoundCloud methods...');
// Try with track ID directly // Try with track ID directly
const stream = await scdl.download(String(trackId)); const stream = await scdl.download(String(trackId));
console.log('Audio stream obtained with track ID method'); logger.success('Audio stream obtained with track ID method');
return stream; return stream;
} catch { } catch {
console.error('Track ID method failed, trying URL construction...'); logger.error('Track ID method failed, trying URL construction...');
// Final fallback - try constructing different URL formats // Final fallback - try constructing different URL formats
try { try {
const trackUrl = `https://soundcloud.com/${trackId}`; const trackUrl = `https://soundcloud.com/${trackId}`;
const stream = await scdl.download(trackUrl); const stream = await scdl.download(trackUrl);
console.log('Audio stream obtained with constructed URL method'); logger.success('Audio stream obtained with constructed URL method');
return stream; return stream;
} catch (finalError: any) { } catch (finalError: any) {
console.error('All methods failed:', finalError.message); logger.error('All methods failed:', finalError.message);
throw new Error(`SoundCloud download failed: ${error.message}`); throw new Error(`SoundCloud download failed: ${error.message}`);
} }
} }

185
yarn.lock
View File

@@ -7,6 +7,11 @@
resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.3.tgz"
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
"@colors/colors@1.6.0", "@colors/colors@^1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.6.0.tgz#ec6cd237440700bc23ca23087f513c75508958b0"
integrity sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==
"@cspotcode/source-map-support@^0.8.0": "@cspotcode/source-map-support@^0.8.0":
version "0.8.1" version "0.8.1"
resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz" resolved "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz"
@@ -48,6 +53,15 @@
tunnel-agent "^0.6.0" tunnel-agent "^0.6.0"
uuid "^8.3.2" uuid "^8.3.2"
"@dabh/diagnostics@^2.0.8":
version "2.0.8"
resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.8.tgz#ead97e72ca312cf0e6dd7af0d300b58993a31a5e"
integrity sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==
dependencies:
"@so-ric/colorspace" "^1.1.6"
enabled "2.0.x"
kuler "^2.0.0"
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0":
version "4.7.0" version "4.7.0"
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz"
@@ -205,6 +219,14 @@
"@nodelib/fs.scandir" "2.1.5" "@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0" fastq "^1.6.0"
"@so-ric/colorspace@^1.1.6":
version "1.1.6"
resolved "https://registry.yarnpkg.com/@so-ric/colorspace/-/colorspace-1.1.6.tgz#62515d8b9f27746b76950a83bde1af812d91923b"
integrity sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==
dependencies:
color "^5.0.2"
text-hex "1.0.x"
"@tsconfig/node10@^1.0.7": "@tsconfig/node10@^1.0.7":
version "1.0.11" version "1.0.11"
resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz" resolved "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz"
@@ -316,13 +338,20 @@
"@types/node" "*" "@types/node" "*"
"@types/request" "*" "@types/request" "*"
"@types/node@*", "@types/node@^24.3.0": "@types/node@*":
version "24.3.0" version "24.3.0"
resolved "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz" resolved "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz"
integrity sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow== integrity sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==
dependencies: dependencies:
undici-types "~7.10.0" undici-types "~7.10.0"
"@types/node@^24.10.0":
version "24.10.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.10.0.tgz#6b79086b0dfc54e775a34ba8114dcc4e0221f31f"
integrity sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==
dependencies:
undici-types "~7.16.0"
"@types/pg@^8.15.5": "@types/pg@^8.15.5":
version "8.15.5" version "8.15.5"
resolved "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz" resolved "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz"
@@ -391,6 +420,11 @@
resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz" resolved "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz"
integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA== integrity sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==
"@types/triple-beam@^1.3.2":
version "1.3.5"
resolved "https://registry.yarnpkg.com/@types/triple-beam/-/triple-beam-1.3.5.tgz#74fef9ffbaa198eb8b588be029f38b00299caa2c"
integrity sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==
"@typescript-eslint/eslint-plugin@^8.41.0": "@typescript-eslint/eslint-plugin@^8.41.0":
version "8.41.0" version "8.41.0"
resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz"
@@ -609,6 +643,11 @@ async@^0.2.9:
resolved "https://registry.npmjs.org/async/-/async-0.2.10.tgz" resolved "https://registry.npmjs.org/async/-/async-0.2.10.tgz"
integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ== integrity sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==
async@^3.2.3:
version "3.2.6"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce"
integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==
asynckit@^0.4.0: asynckit@^0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz"
@@ -801,11 +840,38 @@ color-convert@^2.0.1:
dependencies: dependencies:
color-name "~1.1.4" color-name "~1.1.4"
color-convert@^3.0.1:
version "3.1.2"
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-3.1.2.tgz#cef9e0fd4cb90b07c14697b3fa70af9d7f4870f1"
integrity sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==
dependencies:
color-name "^2.0.0"
color-name@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.0.2.tgz#85054825a23e6d6f81d3503f660c4c4a2a15f04f"
integrity sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==
color-name@~1.1.4: color-name@~1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^2.0.0:
version "2.1.2"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-2.1.2.tgz#db1dd52414cc9037ada8fa7d936b8e9f6c3366c9"
integrity sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==
dependencies:
color-name "^2.0.0"
color@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/color/-/color-5.0.2.tgz#712ec894007ab27b37207732d182784e001b4a3d"
integrity sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==
dependencies:
color-convert "^3.0.1"
color-string "^2.0.0"
combined-stream@^1.0.8, combined-stream@~1.0.6: combined-stream@^1.0.8, combined-stream@~1.0.6:
version "1.0.8" version "1.0.8"
resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz"
@@ -1027,6 +1093,11 @@ ee-first@1.1.1:
resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz" resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz"
integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
enabled@2.0.x:
version "2.0.0"
resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2"
integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==
encodeurl@~1.0.2: encodeurl@~1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz"
@@ -1344,6 +1415,11 @@ fastq@^1.6.0:
dependencies: dependencies:
reusify "^1.0.4" reusify "^1.0.4"
fecha@^4.2.0:
version "4.2.3"
resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd"
integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==
file-entry-cache@^8.0.0: file-entry-cache@^8.0.0:
version "8.0.0" version "8.0.0"
resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz" resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz"
@@ -1405,6 +1481,11 @@ fluent-ffmpeg@^2.1.2:
async "^0.2.9" async "^0.2.9"
which "^1.1.1" which "^1.1.1"
fn.name@1.x.x:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
follow-redirects@^1.15.6: follow-redirects@^1.15.6:
version "1.15.11" version "1.15.11"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
@@ -1675,7 +1756,7 @@ imurmurhash@^0.1.4:
resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz" resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz"
integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==
inherits@2.0.4, inherits@~2.0.3: inherits@2.0.4, inherits@^2.0.3, inherits@~2.0.3:
version "2.0.4" version "2.0.4"
resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -1832,6 +1913,11 @@ is-shared-array-buffer@^1.0.4:
dependencies: dependencies:
call-bound "^1.0.3" call-bound "^1.0.3"
is-stream@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
is-string@^1.1.1: is-string@^1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz" resolved "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz"
@@ -1955,6 +2041,11 @@ keyv@^4.5.4:
dependencies: dependencies:
json-buffer "3.0.1" json-buffer "3.0.1"
kuler@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==
levn@^0.4.1: levn@^0.4.1:
version "0.4.1" version "0.4.1"
resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz" resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
@@ -1980,6 +2071,18 @@ lodash@^4.17.15:
resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
logform@^2.7.0:
version "2.7.0"
resolved "https://registry.yarnpkg.com/logform/-/logform-2.7.0.tgz#cfca97528ef290f2e125a08396805002b2d060d1"
integrity sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==
dependencies:
"@colors/colors" "1.6.0"
"@types/triple-beam" "^1.3.2"
fecha "^4.2.0"
ms "^2.1.1"
safe-stable-stringify "^2.3.1"
triple-beam "^1.3.0"
lower-case@^2.0.2: lower-case@^2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28"
@@ -2184,6 +2287,13 @@ once@^1.3.1, once@^1.4.0:
dependencies: dependencies:
wrappy "1" wrappy "1"
one-time@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==
dependencies:
fn.name "1.x.x"
optionator@^0.9.3: optionator@^0.9.3:
version "0.9.4" version "0.9.4"
resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"
@@ -2455,6 +2565,15 @@ readable-stream@^2.3.5:
string_decoder "~1.1.1" string_decoder "~1.1.1"
util-deprecate "~1.0.1" util-deprecate "~1.0.1"
readable-stream@^3.4.0, readable-stream@^3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967"
integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==
dependencies:
inherits "^2.0.3"
string_decoder "^1.1.1"
util-deprecate "^1.0.1"
readdirp@~3.6.0: readdirp@~3.6.0:
version "3.6.0" version "3.6.0"
resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz" resolved "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz"
@@ -2533,7 +2652,7 @@ safe-array-concat@^1.1.3:
has-symbols "^1.1.0" has-symbols "^1.1.0"
isarray "^2.0.5" isarray "^2.0.5"
safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1: safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.1, safe-buffer@~5.2.0:
version "5.2.1" version "5.2.1"
resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -2560,6 +2679,11 @@ safe-regex-test@^1.1.0:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.2.1" is-regex "^1.2.1"
safe-stable-stringify@^2.3.1:
version "2.5.0"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: "safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
version "2.1.2" version "2.1.2"
resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
@@ -2748,6 +2872,11 @@ sshpk@^1.18.0:
safer-buffer "^2.0.2" safer-buffer "^2.0.2"
tweetnacl "~0.14.0" tweetnacl "~0.14.0"
stack-trace@0.0.x:
version "0.0.10"
resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
integrity sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==
statuses@2.0.1: statuses@2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz" resolved "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz"
@@ -2798,6 +2927,13 @@ string.prototype.trimstart@^1.0.8:
define-properties "^1.2.1" define-properties "^1.2.1"
es-object-atoms "^1.0.0" es-object-atoms "^1.0.0"
string_decoder@^1.1.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
dependencies:
safe-buffer "~5.2.0"
string_decoder@~1.1.1: string_decoder@~1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz"
@@ -2834,6 +2970,11 @@ terser@^5.15.1, terser@^5.44.1:
commander "^2.20.0" commander "^2.20.0"
source-map-support "~0.5.20" source-map-support "~0.5.20"
text-hex@1.0.x:
version "1.0.0"
resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
tldts-core@^6.1.86: tldts-core@^6.1.86:
version "6.1.86" version "6.1.86"
resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz" resolved "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz"
@@ -2880,6 +3021,11 @@ tough-cookie@^5.0.0:
dependencies: dependencies:
tldts "^6.1.32" tldts "^6.1.32"
triple-beam@^1.3.0:
version "1.4.1"
resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984"
integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==
ts-api-utils@^2.1.0: ts-api-utils@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"
@@ -3006,6 +3152,11 @@ undici-types@~7.10.0:
resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz" resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz"
integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag== integrity sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==
undici-types@~7.16.0:
version "7.16.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46"
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
universalify@^0.2.0: universalify@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz" resolved "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz"
@@ -3031,7 +3182,7 @@ url-parse@^1.5.3:
querystringify "^2.1.1" querystringify "^2.1.1"
requires-port "^1.0.0" requires-port "^1.0.0"
util-deprecate@~1.0.1: util-deprecate@^1.0.1, util-deprecate@~1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"
integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==
@@ -3132,6 +3283,32 @@ which@^2.0.1:
dependencies: dependencies:
isexe "^2.0.0" isexe "^2.0.0"
winston-transport@^4.9.0:
version "4.9.0"
resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.9.0.tgz#3bba345de10297654ea6f33519424560003b3bf9"
integrity sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==
dependencies:
logform "^2.7.0"
readable-stream "^3.6.2"
triple-beam "^1.3.0"
winston@^3.18.3:
version "3.18.3"
resolved "https://registry.yarnpkg.com/winston/-/winston-3.18.3.tgz#93ac10808c8e1081d723bc8811cd2f445ddfdcd1"
integrity sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==
dependencies:
"@colors/colors" "^1.6.0"
"@dabh/diagnostics" "^2.0.8"
async "^3.2.3"
is-stream "^2.0.0"
logform "^2.7.0"
one-time "^1.0.0"
readable-stream "^3.4.0"
safe-stable-stringify "^2.3.1"
stack-trace "0.0.x"
triple-beam "^1.3.0"
winston-transport "^4.9.0"
word-wrap@^1.2.5: word-wrap@^1.2.5:
version "1.2.5" version "1.2.5"
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"