new ui?!
This commit is contained in:
@@ -58,7 +58,3 @@ docs
|
|||||||
Dockerfile*
|
Dockerfile*
|
||||||
docker-compose*
|
docker-compose*
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
# Misc
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
12
Dockerfile
12
Dockerfile
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
365
public/script.ts
365
public/script.ts
@@ -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,14 +593,49 @@ 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 '';
|
||||||
const mins = Math.floor(seconds / 60);
|
const mins = Math.floor(seconds / 60);
|
||||||
@@ -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();
|
||||||
|
|||||||
197
public/style.css
197
public/style.css
@@ -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;
|
||||||
|
|||||||
158
src/bot.ts
158
src/bot.ts
@@ -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Попробуйте другой трек.`
|
`Не удалось отправить файл.\n${title}\n\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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
81
src/logger.ts
Normal 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();
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
185
yarn.lock
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user