Compare commits

13 Commits
python ... main

Author SHA1 Message Date
Andrey Kondratev
cd2c3b6989 hide messages 2025-11-10 17:22:10 +05:00
Andrey Kondratev
21a32ffc79 fix authors? 2025-11-10 16:51:57 +05:00
Andrey Kondratev
beb2d19019 version fix 2025-11-10 16:27:35 +05:00
Andrey Kondratev
f6b696a5f8 cache versions 2025-11-10 14:27:58 +05:00
Andrey Kondratev
712c25a881 fix lint and more features! 2025-11-10 14:15:40 +05:00
Andrey Kondratev
82a9596370 new ui?! 2025-11-10 13:56:19 +05:00
Andrey Kondratev
6db48b16a7 faster smaller 2025-11-09 18:48:57 +05:00
Andrey Kondratev
ca27a2b3f0 100! 2025-11-07 21:12:49 +05:00
Andrey Kondratev
5d7c6b2a09 more fixes 2025-11-07 14:37:21 +05:00
Andrey Kondratev
53633dd837 scale 2025-11-07 14:34:16 +05:00
Andrey Kondratev
bd0a0cca28 fix lighthouse 2025-11-07 14:20:36 +05:00
Andrey Kondratev
0110301a60 new app guide 2025-11-05 21:45:21 +05:00
Andrey Kondratev
e7dc0c59e3 global traefik 2025-11-05 20:09:55 +05:00
24 changed files with 2935 additions and 444 deletions

View File

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

3
.gitignore vendored
View File

@@ -75,6 +75,9 @@ build/
out/
coverage/
# Version file (generated at build time)
public/version.json
# Backup files
*.bak
*.backup

View File

@@ -0,0 +1,190 @@
# How to Add New Application with Global Traefik
## Current Working Configuration
Global Traefik is successfully running on the server managing multiple applications:
- **traefik.quixy.uk** - Traefik dashboard (working ✅)
- **music.quixy.uk** - Quixotic app (working ✅)
## Steps to Add New Application
### 1. Update Application's docker-compose.yml
Add the following to your app service:
```yaml
services:
your-app:
image: your-image
container_name: your-app-name
restart: unless-stopped
# Your app configuration
environment:
PORT: 3000 # or whatever port your app uses
# Traefik labels
labels:
- "traefik.enable=true"
# Router configuration
- "traefik.http.routers.yourapp.rule=Host(`yourapp.quixy.uk`)"
- "traefik.http.routers.yourapp.entrypoints=websecure"
- "traefik.http.routers.yourapp.tls.certresolver=letsencrypt"
- "traefik.http.routers.yourapp.service=yourapp"
# Service configuration (must match the port your app listens on)
- "traefik.http.services.yourapp.loadbalancer.server.port=3000"
# Network specification
- "traefik.docker.network=traefik-global"
# Networks - connect to both traefik-global and internal network
networks:
- traefik-global
- your-internal-network # if you have databases, etc.
networks:
# External network managed by global Traefik
traefik-global:
external: true
# Internal network for app-only communication (optional)
your-internal-network:
driver: bridge
```
### 2. Key Points
**Router Name**: Use unique name for each app (e.g., `yourapp`, `music`, `api`, etc.)
- `traefik.http.routers.YOURAPP.rule=...`
- `traefik.http.routers.YOURAPP.entrypoints=...`
- `traefik.http.services.YOURAPP.loadbalancer.server.port=...`
**Port**: Must match the INTERNAL port your app listens on inside the container
- If your app runs on port 3000 inside container → use `port=3000`
- If your app runs on port 8080 inside container → use `port=8080`
**Networks**: App must be in `traefik-global` network for Traefik to reach it
- Database containers should NOT be in traefik-global (security)
- App connects to both networks (bridge between Traefik and internal services)
### 3. Deploy Application
```bash
# Navigate to app directory
cd /path/to/your-app
# Start the application
docker-compose up -d
# Check logs
docker logs your-app-name -f
# Verify Traefik detected it
docker logs traefik-global | grep yourapp
```
### 4. Configure DNS
Add A record:
```
yourapp.quixy.uk → YOUR_SERVER_IP
```
### 5. Verify
After DNS propagation (5-30 minutes):
- App accessible at: `https://yourapp.quixy.uk`
- SSL certificate auto-generated by Let's Encrypt
- HTTP automatically redirects to HTTPS
## Example: Quixotic Music App (Working Configuration)
```yaml
services:
quixotic-app:
build:
context: .
dockerfile: Dockerfile
container_name: quixotic-app
restart: unless-stopped
env_file:
- .env.docker
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://user:pass@postgres:5432/db
volumes:
- downloads:/app/downloads
labels:
- "traefik.enable=true"
- "traefik.http.routers.quixotic.rule=Host(`music.quixy.uk`)"
- "traefik.http.routers.quixotic.entrypoints=websecure"
- "traefik.http.routers.quixotic.tls.certresolver=letsencrypt"
- "traefik.http.routers.quixotic.service=quixotic"
- "traefik.http.services.quixotic.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-global"
depends_on:
postgres:
condition: service_healthy
networks:
- quixotic # Internal network for postgres
- traefik-global # External network for Traefik
postgres:
image: postgres:15-alpine
container_name: quixotic-postgres
networks:
- quixotic # Only internal network, NOT traefik-global
networks:
quixotic:
driver: bridge
traefik-global:
external: true
```
## Troubleshooting
### App not accessible
```bash
# Check container is running
docker ps | grep your-app
# Check container is in traefik-global network
docker inspect your-app-name | grep Networks -A 10
# If not in network, connect manually
docker network connect traefik-global your-app-name
```
### 502 Bad Gateway
- Wrong port in labels (check what port app listens on inside container)
- App not responding (check app logs)
- App not in traefik-global network
### 404 Not Found
- Wrong Host() rule in labels
- DNS not configured
- Traefik didn't detect container (check traefik logs)
### SSL Certificate not issued
- DNS not propagated yet (wait 5-30 minutes)
- Ports 80/443 not open in firewall
- Check traefik logs for ACME errors
## Current Traefik Routes (Working)
- `music.quixy.uk` → quixotic@docker → port 3000 ✅
- `traefik.quixy.uk` → traefik-dashboard@docker → api@internal
- Auto HTTP→HTTPS redirect enabled ✅
- ACME challenge working ✅
## Important Notes
1. **Never expose database ports** - keep databases in internal networks only
2. **Each app needs unique router name** - use app name as prefix
3. **Port must match container internal port** - not host port
4. **DNS must be configured** - before SSL will work
5. **Traefik auto-discovers** - no restart needed when adding apps

View File

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

56
docker-compose.local.yml Normal file
View File

@@ -0,0 +1,56 @@
services:
postgres:
image: postgres:15-alpine
container_name: quixotic-postgres
restart: unless-stopped
environment:
POSTGRES_DB: quixotic
POSTGRES_USER: quixotic
POSTGRES_PASSWORD: quixotic123
volumes:
- postgres-data:/var/lib/postgresql/data
- ./database/init:/docker-entrypoint-initdb.d
networks:
- quixotic
healthcheck:
test: ["CMD-SHELL", "pg_isready -U quixotic"]
interval: 5s
timeout: 5s
retries: 5
quixotic-app:
build:
context: .
dockerfile: Dockerfile
cache_from:
- quixotic-app:latest
image: quixotic-app:latest
container_name: quixotic-app
restart: unless-stopped
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://quixotic:quixotic123@postgres:5432/quixotic
DATABASE_SSL: false
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
WEB_APP_URL: http://localhost:3000
volumes:
- downloads:/app/downloads
# Mount source code for hot reload (uncomment for development)
# - ./src:/app/src
# - ./public:/app/public
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
networks:
- quixotic
volumes:
downloads:
postgres-data:
networks:
quixotic:
driver: bridge

View File

@@ -1,40 +1,4 @@
services:
# Traefik reverse proxy
traefik:
image: traefik:v3.5.1
container_name: quixotic-traefik
restart: unless-stopped
env_file:
- .env.docker
command:
- --api.dashboard=true
- --api.insecure=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@example.com}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --log.level=INFO
ports:
- "80:80"
- "443:443"
- "8080:8080" # Traefik dashboard
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-ssl-certs:/letsencrypt
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.${DOMAIN:-localhost}`)"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=auth"
- "traefik.http.middlewares.auth.basicauth.users=${TRAEFIK_AUTH:-admin:$$2y$$10$$8qCUOc.FKLB8o4X8ZGVb7OU4xrslBUjOdBPtRz9wM7YJ9.XsGVzui}" # admin:password
networks:
- quixotic
# PostgreSQL database
postgres:
image: postgres:15-alpine
container_name: quixotic-postgres
@@ -56,7 +20,6 @@ services:
timeout: 5s
retries: 5
# Main application
quixotic-app:
build:
context: .
@@ -74,34 +37,26 @@ services:
- downloads:/app/downloads
labels:
- "traefik.enable=true"
# HTTPS router for production domains
- "traefik.http.routers.quixotic.rule=Host(`${DOMAIN:-localhost}`) && !Host(`localhost`)"
- "traefik.http.routers.quixotic.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.quixotic.entrypoints=websecure"
- "traefik.http.routers.quixotic.tls.certresolver=letsencrypt"
- "traefik.http.routers.quixotic.service=quixotic"
# HTTP router for localhost (no SSL)
- "traefik.http.routers.quixotic-http.rule=Host(`localhost`)"
- "traefik.http.routers.quixotic-http.entrypoints=web"
- "traefik.http.routers.quixotic-http.service=quixotic"
# HTTP to HTTPS redirect only for non-localhost
- "traefik.http.routers.quixotic-redirect.rule=Host(`${DOMAIN:-localhost}`) && !Host(`localhost`)"
- "traefik.http.routers.quixotic-redirect.entrypoints=web"
- "traefik.http.routers.quixotic-redirect.middlewares=redirect-to-https"
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
- "traefik.http.services.quixotic.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-global"
depends_on:
traefik:
condition: service_started
postgres:
condition: service_healthy
networks:
- quixotic
- traefik-global
volumes:
traefik-ssl-certs:
downloads:
postgres-data:
networks:
quixotic:
driver: bridge
traefik-global:
external: true

View File

@@ -77,7 +77,10 @@ export default [
"indent": ["error", 4],
"quotes": ["error", "single"],
"semi": ["error", "always"],
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}],
"no-console": "off"
}
}

View File

@@ -4,9 +4,12 @@
"description": "Telegram miniapp for YouTube music search and MP3 conversion",
"main": "dist/server.js",
"scripts": {
"build": "tsc && tsc -p tsconfig.frontend.json",
"build": "node scripts/generate-version.js && tsc && tsc -p tsconfig.frontend.json",
"build:backend": "tsc",
"build:frontend": "tsc -p tsconfig.frontend.json",
"build:prod": "node scripts/generate-version.js && yarn build && node scripts/minify.js",
"minify": "node scripts/minify.js",
"version": "node scripts/generate-version.js",
"start": "node dist/server.js",
"dev": "ts-node src/server.ts",
"dev:watch": "nodemon --exec ts-node src/server.ts",
@@ -29,22 +32,27 @@
},
"packageManager": "yarn@1.22.19",
"dependencies": {
"compression": "^1.8.1",
"express": "^4.18.2",
"fluent-ffmpeg": "^2.1.2",
"node-telegram-bot-api": "^0.64.0",
"pg": "^8.11.3",
"soundcloud-downloader": "^1.0.0"
"soundcloud-downloader": "^1.0.0",
"winston": "^3.18.3"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.3",
"@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/pg": "^8.15.5",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
"eslint": "^9.34.0",
"html-minifier-terser": "^7.2.0",
"nodemon": "^3.0.2",
"terser": "^5.44.1",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
},

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@@ -2,20 +2,83 @@
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, initial-scale=1.0, maximum-scale=1.0">
<title>Quixotic Music</title>
<meta name="theme-color" content="#007AFF">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
<title>Quixotic Music - Поиск и скачивание музыки</title>
<meta name="description" content="Удобный сервис для поиска и скачивания музыки. Найдите любимые треки по названию песни или исполнителю.">
<meta name="keywords" content="музыка, поиск музыки, скачать музыку, mp3, треки, песни, исполнители">
<meta name="author" content="Quixotic Music">
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1">
<!-- Canonical URL -->
<link rel="canonical" href="https://music.quixy.uk/">
<!-- Cache Control for iOS -->
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<link rel="stylesheet" href="style.css">
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- Theme & App -->
<meta name="theme-color" content="#007AFF">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Quixotic Music">
<meta name="application-name" content="Quixotic Music">
<meta name="mobile-web-app-capable" content="yes">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:url" content="https://music.quixy.uk/">
<meta property="og:title" content="Quixotic Music - Поиск и скачивание музыки">
<meta property="og:description" content="Удобный сервис для поиска и скачивания музыки. Найдите любимые треки по названию песни или исполнителю.">
<meta property="og:site_name" content="Quixotic Music">
<meta property="og:locale" content="ru_RU">
<meta property="og:locale:alternate" content="en_US">
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Quixotic Music - Поиск и скачивание музыки">
<meta name="twitter:description" content="Удобный сервис для поиска и скачивания музыки. Найдите любимые треки по названию песни или исполнителю.">
<meta name="twitter:creator" content="@quixotic">
<!-- Telegram -->
<meta property="telegram:channel" content="@quixotic">
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon" sizes="180x180" href="/favicon.ico">
<!-- PWA Manifest -->
<link rel="manifest" href="/manifest.json">
<!-- Structured Data (JSON-LD) - минифицирован -->
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebApplication","name":"Quixotic Music","description":"Удобный сервис для поиска и скачивания музыки","url":"https://music.quixy.uk/","applicationCategory":"MultimediaApplication","operatingSystem":"Any","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"}}</script>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Preconnect to external resources -->
<link rel="preconnect" href="https://telegram.org" crossorigin>
<link rel="dns-prefetch" href="https://telegram.org">
<!-- Critical CSS - inline the most important styles -->
<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)}
</style>
<!-- Load full CSS asynchronously with fallback -->
<link rel="stylesheet" href="style.css?v=VERSION" media="print" onload="this.media='all';this.onload=null">
<noscript><link rel="stylesheet" href="style.css?v=VERSION"></noscript>
<!-- Load Telegram script asynchronously (defer) -->
<script src="https://telegram.org/js/telegram-web-app.js" defer></script>
</head>
<body>
<div class="tg-root">
<div class="tg-pull-indicator" id="pullIndicator">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="tg-pull-indicator__icon">
<path d="M12 5v14M5 12l7 7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<span class="tg-pull-indicator__text">Обновить</span>
</div>
<div class="tg-content">
<div class="tg-placeholder" id="welcomePlaceholder">
<div class="tg-placeholder__icon">🎵</div>
@@ -28,6 +91,89 @@
<div class="tg-spinner__text">Поиск музыки...</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-recent-searches tg-hidden" id="recentSearches">
<div class="tg-recent-searches__header">
<span class="tg-recent-searches__title">Недавние поиски</span>
<button class="tg-recent-searches__clear" id="clearRecentBtn" type="button">Очистить</button>
</div>
<div class="tg-recent-searches__list" id="recentSearchesList">
<!-- Recent searches will be populated here -->
</div>
</div>
<div class="tg-list tg-hidden" id="results">
<!-- Search results will appear here -->
</div>
@@ -45,11 +191,18 @@
id="searchInput"
placeholder="Название песни или исполнитель..."
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>
<script src="dist/script.js?v=2"></script>
<!-- Load app script with defer for better performance -->
<script src="dist/script.js?v=VERSION" defer></script>
</body>
</html>

1
public/index.min.html Normal file

File diff suppressed because one or more lines are too long

22
public/manifest.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "Quixotic Music",
"short_name": "Quixotic",
"description": "Удобный сервис для поиска и скачивания музыки",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#007AFF",
"orientation": "portrait",
"icons": [
{
"src": "/favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"categories": ["music", "entertainment"],
"lang": "ru",
"dir": "ltr",
"scope": "/",
"prefer_related_applications": false
}

View File

@@ -1,12 +1,6 @@
# Allow search engines
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Yandexbot
Allow: /
# Allow all search engines and bots
User-agent: *
Disallow: /
Allow: /
# Sitemap
Sitemap: https://music.quixy.uk/sitemap.xml

File diff suppressed because it is too large Load Diff

10
public/sitemap.xml Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://music.quixy.uk/</loc>
<lastmod>2025-11-07</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
</urlset>

View File

@@ -22,13 +22,24 @@
--tg-spacing-xl: 20px;
--tg-spacing-xxl: 24px;
/* Typography */
/* Typography - Refined type scale (Major Third - 1.25) */
--tg-font-size-xs: 12px;
--tg-font-size-sm: 14px;
--tg-font-size-md: 16px;
--tg-font-size-lg: 17px;
--tg-font-size-xl: 20px;
--tg-font-size-xxl: 28px;
--tg-font-size-md: 16px; /* base */
--tg-font-size-lg: 18px; /* 16 * 1.125 ≈ 18 */
--tg-font-size-xl: 22px; /* 18 * 1.222 ≈ 22 */
--tg-font-size-xxl: 28px; /* 22 * 1.273 ≈ 28 */
/* Font weights */
--tg-font-weight-regular: 400;
--tg-font-weight-medium: 500;
--tg-font-weight-semibold: 600;
--tg-font-weight-bold: 700;
/* Letter spacing for improved readability */
--tg-letter-spacing-tight: -0.01em;
--tg-letter-spacing-normal: 0;
--tg-letter-spacing-wide: 0.01em;
--tg-line-height-tight: 1.2;
--tg-line-height-normal: 1.4;
@@ -71,6 +82,52 @@ body {
display: flex;
flex-direction: column;
gap: var(--tg-spacing-xl);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.tg-content.tg-pulling {
transition: none;
}
/* Pull-to-refresh indicator */
.tg-pull-indicator {
position: fixed;
top: -60px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--tg-spacing-xs);
opacity: 0;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 50;
pointer-events: none;
}
.tg-content.tg-pulling .tg-pull-indicator {
opacity: 1;
top: 20px;
}
.tg-pull-indicator__icon {
color: var(--tg-color-button);
transition: transform 0.3s ease;
}
.tg-content.tg-pulling .tg-pull-indicator__icon {
animation: pullRotate 0.6s ease-in-out infinite;
}
.tg-pull-indicator__text {
font-size: var(--tg-font-size-sm);
color: var(--tg-color-hint);
font-weight: var(--tg-font-weight-medium);
}
@keyframes pullRotate {
0%, 100% { transform: rotate(0deg); }
50% { transform: rotate(180deg); }
}
/* Form components */
@@ -93,6 +150,7 @@ body {
width: 100%;
height: 48px;
padding: 0 var(--tg-spacing-lg);
padding-right: 48px; /* Make room for clear button */
background: var(--tg-color-section-bg);
border: 2px solid var(--tg-color-secondary-bg);
border-radius: var(--tg-border-radius);
@@ -111,6 +169,33 @@ body {
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 */
.tg-button {
position: relative;
@@ -121,7 +206,7 @@ body {
border: none;
border-radius: var(--tg-border-radius);
font-family: inherit;
font-weight: 500;
font-weight: var(--tg-font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
outline: none;
@@ -178,7 +263,8 @@ body {
.tg-placeholder__title {
font-size: var(--tg-font-size-xl);
font-weight: 600;
font-weight: var(--tg-font-weight-semibold);
letter-spacing: var(--tg-letter-spacing-tight);
color: var(--tg-color-text);
margin-bottom: var(--tg-spacing-sm);
}
@@ -203,25 +289,29 @@ body {
padding: var(--tg-spacing-xxl);
display: none;
z-index: 10;
opacity: 0;
transition: opacity 0.15s ease-in;
}
.tg-spinner.tg-spinner--visible {
display: block;
animation: tg-fade-in 0.15s ease-in forwards;
}
.tg-spinner__icon {
width: 32px;
height: 32px;
border: 2px solid var(--tg-color-secondary-bg);
border-top: 2px solid var(--tg-color-button);
width: 40px;
height: 40px;
border: 3px solid var(--tg-color-secondary-bg);
border-top: 3px solid var(--tg-color-button);
border-radius: 50%;
margin: 0 auto var(--tg-spacing-lg);
animation: tg-spin 1s linear infinite;
animation: tg-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
.tg-spinner__text {
font-size: var(--tg-font-size-sm);
color: var(--tg-color-hint);
font-weight: var(--tg-font-weight-medium);
}
@keyframes tg-spin {
@@ -229,6 +319,77 @@ body {
100% { transform: rotate(360deg); }
}
@keyframes tg-fade-in {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
/* 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 */
.tg-list {
display: none;
@@ -249,6 +410,32 @@ body {
user-select: none;
-webkit-tap-highlight-color: transparent;
position: relative;
opacity: 0;
animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
/* Staggered animation delays for first 20 items */
.tg-list-item:nth-child(1) { animation-delay: 0.05s; }
.tg-list-item:nth-child(2) { animation-delay: 0.08s; }
.tg-list-item:nth-child(3) { animation-delay: 0.11s; }
.tg-list-item:nth-child(4) { animation-delay: 0.14s; }
.tg-list-item:nth-child(5) { animation-delay: 0.17s; }
.tg-list-item:nth-child(6) { animation-delay: 0.20s; }
.tg-list-item:nth-child(7) { animation-delay: 0.23s; }
.tg-list-item:nth-child(8) { animation-delay: 0.26s; }
.tg-list-item:nth-child(9) { animation-delay: 0.29s; }
.tg-list-item:nth-child(10) { animation-delay: 0.32s; }
.tg-list-item:nth-child(n+11) { animation-delay: 0.35s; }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Hover effects for desktop */
@@ -298,6 +485,34 @@ body {
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 {
position: absolute;
bottom: 2px;
@@ -307,7 +522,7 @@ body {
font-size: var(--tg-font-size-xs);
padding: 2px 4px;
border-radius: 4px;
font-weight: 500;
font-weight: var(--tg-font-weight-medium);
}
.tg-list-item__info {
@@ -317,12 +532,14 @@ body {
.tg-list-item__title {
font-size: var(--tg-font-size-md);
font-weight: 500;
font-weight: var(--tg-font-weight-medium);
letter-spacing: var(--tg-letter-spacing-tight);
color: var(--tg-color-text);
line-height: var(--tg-line-height-tight);
line-height: 1.3; /* Улучшенный line-height для многострочных заголовков */
margin-bottom: var(--tg-spacing-xs);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
@@ -356,22 +573,109 @@ body {
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 */
.tg-status-message {
position: fixed;
top: 20px;
left: var(--tg-spacing-lg);
right: var(--tg-spacing-lg);
top: 12px;
left: var(--tg-spacing-md);
right: var(--tg-spacing-md);
z-index: 1000;
padding: var(--tg-spacing-md) var(--tg-spacing-lg);
border-radius: var(--tg-border-radius);
font-size: var(--tg-font-size-sm);
font-weight: 500;
padding: 10px 14px;
border-radius: 10px;
font-size: 13px;
font-weight: var(--tg-font-weight-medium);
animation: tg-slide-down 0.3s ease-out;
display: flex;
align-items: center;
gap: var(--tg-spacing-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 4px 12px rgba(0, 0, 0, 0.15);
gap: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2), 0 2px 6px rgba(0, 0, 0, 0.12);
max-width: 320px;
margin: 0 auto;
cursor: pointer;
transition: opacity 0.3s ease, transform 0.3s ease;
}
.tg-status-message:active {
transform: scale(0.98);
}
.tg-status-message--hiding {
animation: tg-fade-out 0.3s ease-out forwards;
}
.tg-status-message--success {
@@ -392,10 +696,16 @@ body {
color: #ffffff;
}
.tg-status-message--warning {
background: #ff9500;
border: 1px solid #e68500;
color: #ffffff;
}
@keyframes tg-slide-down {
from {
opacity: 0;
transform: translateY(-20px);
transform: translateY(-12px);
}
to {
@@ -404,6 +714,168 @@ body {
}
}
@keyframes tg-fade-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-12px);
}
}
/* Update notification */
.tg-update-notification {
position: fixed;
top: var(--tg-spacing-lg);
left: var(--tg-spacing-lg);
right: var(--tg-spacing-lg);
z-index: 1000;
animation: tg-slide-down 0.3s ease-out;
}
.tg-update-notification__content {
background: var(--tg-color-button);
color: var(--tg-color-button-text);
padding: var(--tg-spacing-md) var(--tg-spacing-lg);
border-radius: var(--tg-border-radius);
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-weight: var(--tg-font-weight-medium);
}
.tg-update-notification__button {
background: rgba(255, 255, 255, 0.2);
color: var(--tg-color-button-text);
border: none;
padding: var(--tg-spacing-sm) var(--tg-spacing-lg);
border-radius: var(--tg-border-radius-small);
font-size: var(--tg-font-size-sm);
font-weight: var(--tg-font-weight-semibold);
cursor: pointer;
transition: background 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.tg-update-notification__button:active {
background: rgba(255, 255, 255, 0.3);
}
/* Recent Searches */
.tg-recent-searches {
margin-bottom: var(--tg-spacing-lg);
animation: tg-fade-in 0.3s ease-out;
}
.tg-recent-searches__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--tg-spacing-sm);
font-size: var(--tg-font-size-sm);
color: var(--tg-color-hint);
padding: 0 var(--tg-spacing-xs);
}
.tg-recent-searches__title {
font-weight: var(--tg-font-weight-medium);
}
.tg-recent-searches__clear {
background: none;
border: none;
color: var(--tg-color-link);
cursor: pointer;
padding: 4px 8px;
font-size: var(--tg-font-size-sm);
font-weight: var(--tg-font-weight-medium);
border-radius: 4px;
transition: background 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.tg-recent-searches__clear:hover {
background: var(--tg-color-secondary-bg);
}
.tg-recent-searches__clear:active {
transform: scale(0.95);
}
.tg-recent-searches__list {
display: flex;
flex-direction: column;
gap: var(--tg-spacing-xs);
}
.tg-recent-search-item {
display: flex;
align-items: center;
gap: var(--tg-spacing-sm);
padding: var(--tg-spacing-sm) var(--tg-spacing-md);
background: var(--tg-color-section-bg);
border: none;
border-radius: var(--tg-border-radius);
width: 100%;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
font-size: var(--tg-font-size-md);
color: var(--tg-color-text);
-webkit-tap-highlight-color: transparent;
}
.tg-recent-search-item svg {
flex-shrink: 0;
color: var(--tg-color-hint);
}
.tg-recent-search-item span {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (hover: hover) and (pointer: fine) {
.tg-recent-search-item:hover {
background: var(--tg-color-secondary-bg);
transform: translateX(2px);
}
}
.tg-recent-search-item:active {
background: var(--tg-color-secondary-bg);
transform: scale(0.98);
}
/* Loading More indicator */
.tg-loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: var(--tg-spacing-sm);
padding: var(--tg-spacing-lg);
color: var(--tg-color-hint);
font-size: var(--tg-font-size-sm);
}
.tg-loading-more .tg-spinner__icon {
width: 20px;
height: 20px;
border-width: 2px;
margin: 0;
}
#scroll-sentinel {
height: 1px;
visibility: hidden;
}
/* Utility classes */
.tg-hidden {
display: none !important;
@@ -464,6 +936,7 @@ body {
text-align: left;
font-size: var(--tg-font-size-sm);
-webkit-line-clamp: 1;
line-clamp: 1;
}
.tg-list-item__subtitle {

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
function generateVersion() {
const timestamp = Date.now();
const date = new Date().toISOString();
let gitHash = null;
let gitBranch = null;
try {
gitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
} catch (e) {
console.warn('Warning: Could not get git info');
}
const version = {
timestamp,
date,
version: gitHash ? `${timestamp}-${gitHash}` : timestamp.toString(),
gitHash,
gitBranch,
buildDate: date
};
const outputPath = path.join(__dirname, '../public/version.json');
fs.writeFileSync(outputPath, JSON.stringify(version, null, 2));
console.log('✅ Version file generated:', version.version);
console.log(` Date: ${date}`);
if (gitHash) {
console.log(` Git: ${gitHash} (${gitBranch})`);
}
return version;
}
if (require.main === module) {
generateVersion();
}
module.exports = { generateVersion };

127
scripts/minify.js Executable file
View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { minify } = require('terser');
const { minify: minifyHtml } = require('html-minifier-terser');
const publicDir = path.join(__dirname, '..', 'public');
const distDir = path.join(publicDir, 'dist');
async function minifyJavaScript() {
console.log('🔧 Minifying JavaScript...');
const jsFile = path.join(distDir, 'script.js');
if (!fs.existsSync(jsFile)) {
console.error('❌ script.js not found. Run build first.');
process.exit(1);
}
const code = fs.readFileSync(jsFile, 'utf8');
const result = await minify(code, {
compress: {
dead_code: true,
drop_console: false, // Keep console for debugging
drop_debugger: true,
keep_classnames: true,
keep_fnames: false,
passes: 2
},
mangle: {
keep_classnames: true,
keep_fnames: false
},
format: {
comments: false
},
sourceMap: {
filename: 'script.js',
url: 'script.js.map'
}
});
if (result.code) {
fs.writeFileSync(jsFile, result.code);
if (result.map) {
fs.writeFileSync(jsFile + '.map', result.map);
}
const originalSize = Buffer.byteLength(code, 'utf8');
const minifiedSize = Buffer.byteLength(result.code, 'utf8');
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
console.log(`✅ JavaScript minified: ${originalSize}${minifiedSize} bytes (${savings}% reduction)`);
} else {
console.error('❌ Minification failed');
process.exit(1);
}
}
async function minifyHTML() {
console.log('🔧 Minifying HTML...');
const htmlFile = path.join(publicDir, 'index.html');
if (!fs.existsSync(htmlFile)) {
console.error('❌ index.html not found');
return;
}
const html = fs.readFileSync(htmlFile, 'utf8');
const minified = await minifyHtml(html, {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
minifyCSS: true,
minifyJS: false, // Don't minify inline JS (we handle it separately)
keepClosingSlash: true
});
const originalSize = Buffer.byteLength(html, 'utf8');
const minifiedSize = Buffer.byteLength(minified, 'utf8');
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
// Save to dist folder
fs.writeFileSync(path.join(publicDir, 'index.min.html'), minified);
console.log(`✅ HTML minified: ${originalSize}${minifiedSize} bytes (${savings}% reduction)`);
}
async function minifyCSS() {
console.log('🔧 Checking CSS...');
const cssFile = path.join(publicDir, 'style.css');
if (!fs.existsSync(cssFile)) {
console.log(' No CSS file to minify');
return;
}
const css = fs.readFileSync(cssFile, 'utf8');
const originalSize = Buffer.byteLength(css, 'utf8');
console.log(` CSS size: ${originalSize} bytes (already optimized)`);
}
async function main() {
console.log('🚀 Starting minification process...\n');
try {
await minifyJavaScript();
await minifyHTML();
await minifyCSS();
console.log('\n✨ All files minified successfully!');
} catch (error) {
console.error('❌ Minification error:', error);
process.exit(1);
}
}
main();

View File

@@ -1,5 +1,6 @@
import TelegramBot from 'node-telegram-bot-api';
import { Database } from './database';
import { logger } from './logger';
interface TelegramUser {
id: number;
@@ -40,14 +41,34 @@ export class QuixoticBot {
private db: Database;
constructor(token: string, webAppUrl: string) {
this.bot = new TelegramBot(token, { polling: true });
// Validate token format
if (!token || token.length < 40 || token === 'your_telegram_bot_token_here') {
throw new Error('Invalid or placeholder TELEGRAM_BOT_TOKEN provided');
}
// Use webhook in production, polling in development
const useWebhook = process.env.NODE_ENV === 'production' && process.env.WEBHOOK_URL;
if (useWebhook) {
logger.telegram('Using webhook mode for production');
this.bot = new TelegramBot(token, {
webHook: {
port: 8443,
host: '0.0.0.0'
}
});
} else {
logger.telegram('Using polling mode for development');
this.bot = new TelegramBot(token, { polling: true });
}
this.webAppUrl = webAppUrl;
this.db = new Database();
this.init();
}
private init(): void {
console.log('🤖 Telegram bot initialized');
logger.telegram('Bot initialized');
this.setupCommands();
this.setupHandlers();
}
@@ -62,7 +83,7 @@ export class QuixoticBot {
}
private setupHandlers(): void {
console.log('🔧 Setting up bot handlers...');
logger.telegram('Setting up bot handlers...');
// Handle messages
this.bot.on('message', (msg: any) => {
@@ -88,21 +109,21 @@ export class QuixoticBot {
const keyboard = {
inline_keyboard: [[
{
text: '🎵 Открыть Quixotic',
text: 'Открыть Quixotic',
web_app: { url: this.webAppUrl }
}
]]
};
await this.bot.sendMessage(chatId,
'🎵 Добро пожаловать в Quixotic!\n\n' +
'Добро пожаловать в Quixotic!\n\n' +
'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' +
'Нажми кнопку ниже, чтобы начать поиск:',
{ reply_markup: keyboard }
);
} catch (error) {
console.error('Start command error:', error);
await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.');
logger.error('Start command error:', error);
await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.');
}
});
@@ -110,13 +131,13 @@ export class QuixoticBot {
this.bot.onText(/\/help/, async (msg: Message) => {
const chatId = msg.chat.id;
const helpText = `🎵 *Quixotic - SoundCloud to MP3*
const helpText = `*Quixotic - SoundCloud to MP3*
*Как пользоваться:*
1️⃣ Нажми кнопку "Открыть Quixotic"
2️⃣ Введи название песни в поисковую строку
3️⃣ Выбери нужный трек из списка
4️⃣ Получи MP3 файл в чат!
1. Нажми кнопку "Открыть Quixotic"
2. Введи название песни в поисковую строку
3. Выбери нужный трек из списка
4. Получи MP3 файл в чат!
*Команды:*
/start - Запустить приложение
@@ -124,10 +145,10 @@ export class QuixoticBot {
/history - История поиска
*Возможности:*
Поиск по SoundCloud
Высокое качество MP3 (192kbps)
Быстрая конвертация
История поиска`;
- Поиск по SoundCloud
- Высокое качество MP3 (192kbps)
- Быстрая конвертация
- История поиска`;
await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' });
});
@@ -154,7 +175,7 @@ export class QuixoticBot {
return;
}
let historyText = '📋 *Последние поисковые запросы:*\n\n';
let historyText = '*Последние поисковые запросы:*\n\n';
history.forEach((item, index) => {
const date = new Date(item.created_at).toLocaleDateString('ru-RU');
historyText += `${index + 1}. ${item.query} _(${date})_\n`;
@@ -162,8 +183,8 @@ export class QuixoticBot {
await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' });
} catch (error) {
console.error('History command error:', error);
await this.bot.sendMessage(chatId, 'Ошибка получения истории.');
logger.error('History command error:', error);
await this.bot.sendMessage(chatId, 'Ошибка получения истории.');
}
});
@@ -206,10 +227,10 @@ export class QuixoticBot {
type: 'article',
id: `${index}`,
title: video.title,
description: `${video.channel} ${this.formatDuration(video.duration)}`,
description: `${video.channel} - ${this.formatDuration(video.duration)}`,
thumb_url: video.thumbnail,
input_message_content: {
message_text: `🎵 ${video.title}\n🔗 ${video.url}`
message_text: `${video.title}\n${video.url}`
}
}));
@@ -218,31 +239,31 @@ export class QuixoticBot {
is_personal: true
});
} catch (error) {
console.error('Inline query error:', error);
logger.error('Inline query error:', error);
await this.bot.answerInlineQuery(queryId, []);
}
});
// Error handler with detailed logging
this.bot.on('error', (error: any) => {
console.error('🚨 Telegram bot error:', error.message || error);
console.error('Error code:', error.code);
console.error('Full error:', error);
logger.error('Telegram bot error:', error.message || error);
logger.error('Error code:', error.code);
logger.error('Full error:', error);
});
// Handle polling errors specifically
this.bot.on('polling_error', (error: any) => {
console.error('🚨 Telegram polling error:', error.message || error);
console.error('Error code:', error.code);
logger.error('Telegram polling error:', error.message || error);
logger.error('Error code:', error.code);
// Don't crash on polling errors, just log them
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[]> {
@@ -251,12 +272,16 @@ export class QuixoticBot {
// Public method for external API calls
public async sendAudioFile(chatId: number, audioUrl: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
logger.debug(`sendAudioFile called with performer: ${performer}, thumbnail: ${thumbnail}`);
return this.sendAudioFileInternal(chatId, audioUrl, title, performer, thumbnail);
}
private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
try {
console.log(`📤 Sending: ${title} to chat ${chatId}`);
logger.telegram('Sending audio', `${title} to chat ${chatId}`);
logger.debug(`File source: ${audioUrlOrPath}`);
logger.debug(`Performer: ${performer || 'Not provided'}`);
logger.debug(`Thumbnail: ${thumbnail || 'Not provided'}`);
// Check if it's a URL or local file path
const isUrl = audioUrlOrPath.startsWith('http');
@@ -267,68 +292,181 @@ export class QuixoticBot {
const urlParts = audioUrlOrPath.split('/');
const filename = urlParts[urlParts.length - 1];
filePath = require('path').join(process.cwd(), 'downloads', filename);
logger.debug(`Converted URL to local path: ${filePath}`);
}
const fs = require('fs');
const path = require('path');
const https = require('https');
// Check if file exists
if (!fs.existsSync(filePath)) {
logger.error(`File not found: ${filePath}`);
throw new Error('File not found: ' + filePath);
}
// Get file stats for debugging
const stats = fs.statSync(filePath);
logger.debug(`File size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`);
// Generate custom filename for display
const safeTitle = (title || '').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 customFilename = safePerformer ? `${safePerformer} - ${safeTitle}` : `${safeTitle}`;
const customFilename = safePerformer ? `${safePerformer} - ${safeTitle}.mp3` : `${safeTitle}.mp3`;
// Try sending as audio with custom filename
logger.debug(`Sending as: ${customFilename}`);
// Download thumbnail if provided
let thumbnailPath: string | undefined;
if (thumbnail && thumbnail.startsWith('http')) {
try {
logger.debug(`Downloading thumbnail from: ${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!);
// Handle both http and https
const protocol = thumbnail.startsWith('https') ? https : require('http');
const request = protocol.get(thumbnail, (response: any) => {
// Follow redirects
if (response.statusCode === 301 || response.statusCode === 302) {
const redirectUrl = response.headers.location;
logger.debug(`Following redirect to: ${redirectUrl}`);
file.close();
fs.unlink(thumbnailPath!, () => {});
const redirectProtocol = redirectUrl.startsWith('https') ? https : require('http');
redirectProtocol.get(redirectUrl, (redirectResponse: any) => {
redirectResponse.pipe(file);
file.on('finish', () => {
file.close();
logger.success(`Thumbnail downloaded: ${thumbnailPath} (${fs.statSync(thumbnailPath!).size} bytes)`);
resolve();
});
}).on('error', (err: any) => {
file.close();
fs.unlink(thumbnailPath!, () => {});
reject(err);
});
} else {
response.pipe(file);
file.on('finish', () => {
file.close();
const fileSize = fs.statSync(thumbnailPath!).size;
logger.success(`Thumbnail downloaded: ${thumbnailPath} (${fileSize} bytes)`);
// Check if file is valid (at least 1KB)
if (fileSize < 1000) {
logger.warn('Thumbnail file too small, may be invalid');
fs.unlink(thumbnailPath!, () => {});
thumbnailPath = undefined;
}
resolve();
});
}
});
request.on('error', (err: any) => {
file.close();
fs.unlink(thumbnailPath!, () => {});
reject(err);
});
// Set timeout for thumbnail download
request.setTimeout(10000, () => {
request.destroy();
file.close();
fs.unlink(thumbnailPath!, () => {});
reject(new Error('Thumbnail download timeout'));
});
});
} catch (thumbError: any) {
logger.warn('Failed to download thumbnail:', thumbError.message);
thumbnailPath = undefined;
}
}
// Send file using stream (better for large files)
const fileStream = fs.createReadStream(filePath);
// Try sending as audio with metadata
try {
const fs = require('fs');
const options: any = {
title: title,
performer: performer || 'Unknown Artist',
caption: undefined,
parse_mode: undefined
};
// Check if file exists
if (!fs.existsSync(filePath)) {
throw new Error('File not found: ' + filePath);
// Add thumbnail if downloaded
if (thumbnailPath) {
options.thumbnail = fs.createReadStream(thumbnailPath);
}
await this.bot.sendAudio(chatId, filePath, {
title: title,
performer: performer,
caption: undefined,
thumbnail: thumbnail,
parse_mode: undefined
}, {
await this.bot.sendAudio(chatId, fileStream, options, {
filename: customFilename,
contentType: 'audio/mpeg'
});
console.log(`✅ Audio sent: ${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;
} catch (error: any) {
console.log('Audio send failed, trying as document...', error.message);
logger.error('Audio send failed:', error.message);
logger.error('Error code:', error.code);
// Fallback: try as document with custom filename
// Clean up thumbnail file on error
if (thumbnailPath) {
fs.unlink(thumbnailPath, () => {});
}
// Fallback: try as document
try {
await this.bot.sendDocument(chatId, filePath, {
caption: undefined,
logger.info('Retrying as document...');
const docStream = fs.createReadStream(filePath);
await this.bot.sendDocument(chatId, docStream, {
caption: `${title}\n${performer || 'Unknown Artist'}`,
parse_mode: undefined
}, {
filename: customFilename,
contentType: 'audio/mpeg'
});
console.log(`Document sent: ${title}`);
logger.success(`Document sent successfully: ${title}`);
return;
} catch (documentError: any) {
logger.error('Document send also failed:', documentError.message);
throw documentError;
}
}
} catch (error: any) {
console.error('Send failed:', error.message);
logger.error('Send failed completely:', error.message);
logger.error('Full error:', error);
// Send fallback with link if it was a URL
// Send error message to user
try {
const message = audioUrlOrPath.startsWith('http')
? `Не удалось отправить файл.\n🎵 ${title}\n🔗 ${audioUrlOrPath}`
: `Не удалось отправить файл: ${title}`;
await this.bot.sendMessage(chatId, message);
await this.bot.sendMessage(chatId,
`Не удалось отправить файл.\n${title}\n\опробуйте другой трек.`
);
} catch {
// Silent fail
logger.error('Could not even send error message');
}
// Re-throw to trigger unhandled rejection handler
throw error;
}
}
@@ -343,11 +481,11 @@ export class QuixoticBot {
const data: WebAppData = JSON.parse(msg.web_app.data);
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);
}
} catch (parseError: any) {
console.error('WebApp data parse error:', parseError.message);
logger.error('WebApp data parse error:', parseError.message);
}
}
@@ -366,7 +504,7 @@ if (require.main === module) {
const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com';
if (!token) {
console.error('TELEGRAM_BOT_TOKEN environment variable is required');
logger.error('TELEGRAM_BOT_TOKEN environment variable is required');
process.exit(1);
}

View File

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

81
src/logger.ts Normal file
View File

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

View File

@@ -1,4 +1,5 @@
import express, { Request, Response } from 'express';
import compression from 'compression';
import path from 'path';
import fs from 'fs';
import ffmpeg from 'fluent-ffmpeg';
@@ -9,6 +10,7 @@ ffmpeg.setFfprobePath('/usr/bin/ffprobe');
import { Database } from './database';
import { SoundCloudService } from './soundcloud';
import { QuixoticBot } from './bot';
import { logger } from './logger';
const app = express();
const port = process.env.PORT || 3000;
@@ -18,19 +20,57 @@ const db = new Database();
const soundcloud = new SoundCloudService();
// Middleware
app.use(compression()); // Enable gzip compression
app.use(express.json());
// Cache-busting middleware for iOS Safari
app.use('/dist/*.js', (req: Request, res: Response, next) => {
res.set({
'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
'Pragma': 'no-cache',
'Expires': '0'
});
app.use((req: Request, res: Response, next) => {
res.set('Content-Security-Policy',
'default-src \'self\'; ' +
'script-src \'self\' https://telegram.org \'unsafe-inline\'; ' +
'style-src \'self\' \'unsafe-inline\'; ' +
'img-src \'self\' data: https:; ' +
'font-src \'self\'; ' +
'media-src \'self\' blob: data:; ' +
'connect-src \'self\' https://telegram.org; ' +
'frame-ancestors \'self\'; ' +
'base-uri \'self\'; ' +
'form-action \'self\''
);
res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('X-Frame-Options', 'SAMEORIGIN');
res.set('X-Content-Type-Options', 'nosniff');
res.set('Referrer-Policy', 'strict-origin-when-cross-origin');
next();
});
app.use(express.static('public'));
// Optimized caching strategy
app.use(express.static('public', {
maxAge: 0, // Don't cache by default, set specific headers below
etag: true,
lastModified: true,
setHeaders: (res: Response, filePath: string) => {
// Cache images, fonts, etc. with immutable flag
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|woff|woff2|ttf|eot|svg)$/)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
// Cache CSS and JS with version string for 1 year (they have ?v= in URL)
else if (filePath.match(/\.(css|js)$/)) {
res.set('Cache-Control', 'public, max-age=31536000, immutable');
}
// HTML files - NO CACHE
else if (filePath.match(/\.html$/)) {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
// JSON files (version.json) - NO CACHE
else if (filePath.match(/\.json$/)) {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
res.set('Pragma', 'no-cache');
res.set('Expires', '0');
}
}
}));
// Ensure downloads directory exists
const downloadsDir = path.join(__dirname, '../downloads');
@@ -38,34 +78,59 @@ if (!fs.existsSync(downloadsDir)) {
fs.mkdirSync(downloadsDir, { recursive: true });
}
// Load version for cache busting
let appVersion = Date.now().toString();
try {
const versionPath = path.join(__dirname, '../public/version.json');
if (fs.existsSync(versionPath)) {
const versionData = JSON.parse(fs.readFileSync(versionPath, 'utf8'));
appVersion = versionData.version || appVersion;
logger.info(`App version loaded: ${appVersion}`);
}
} catch (error) {
logger.warn('Could not load version file, using timestamp');
}
// Routes
app.get('/', (req: Request, res: Response) => {
// Read and modify index.html to add timestamp for iOS cache busting
const indexPath = path.join(__dirname, '../public/index.html');
let html = fs.readFileSync(indexPath, 'utf8');
// Add timestamp to script URL for cache busting
const timestamp = Date.now();
html = html.replace('dist/script.js?v=2', `dist/script.js?v=${timestamp}`);
// 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 (no cache for HTML itself)
res.set({
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
});
res.send(html);
// Read HTML and inject version
try {
let html = fs.readFileSync(indexPath, 'utf8');
// Replace all version placeholders with actual version
html = html.replace(/\?v=(VERSION|\d+)/g, `?v=${appVersion}`);
res.send(html);
} catch (error) {
logger.error('Error serving HTML:', error);
res.sendFile(indexPath);
}
});
// Search videos
app.post('/api/search', async (req: Request, res: Response) => {
try {
const { query, userId }: { query?: string; userId?: string } = req.body;
const { query, userId, page }: { query?: string; userId?: string; page?: number } = req.body;
if (!query || query.trim().length === 0) {
return res.status(400).json({ error: 'Query is required' });
}
// Calculate offset based on page number (10 results per page)
const currentPage = page || 1;
const resultsPerPage = 10;
const offset = (currentPage - 1) * resultsPerPage;
// Save search history
if (userId && userId !== 'demo') {
try {
@@ -74,15 +139,19 @@ app.post('/api/search', async (req: Request, res: Response) => {
await db.addSearchHistory(user.id, query);
}
} catch (dbError) {
console.error('Database error:', dbError);
logger.error('Database error:', dbError);
}
}
const videos = await soundcloud.searchTracks(query.trim());
res.json({ videos });
const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset);
// Return hasMore flag based on results
const hasMore = videos.length === resultsPerPage;
res.json({ videos, hasMore });
} catch (error) {
console.error('Search error:', error);
logger.error('Search error:', error);
res.status(500).json({ error: 'Failed to search videos' });
}
});
@@ -91,7 +160,7 @@ app.post('/api/search', async (req: Request, res: Response) => {
app.post('/api/convert', async (req: Request, res: Response) => {
try {
const { videoId, title, userId, url, performer }: { videoId?: string; title?: string; userId?: string; url?: string; performer?: string } = req.body;
console.log('Convert request received:', { videoId, title, userId });
logger.info(`Convert request received: ${title} by ${performer || 'Unknown'} (ID: ${videoId})`);
if (!videoId) {
return res.status(400).json({ error: 'Video ID is required' });
@@ -104,18 +173,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
// Check if file already exists
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}`;
return res.json({ audioUrl, title });
}
console.log(`Starting MP3 conversion for: ${title}`);
logger.info(`Starting MP3 conversion: ${title}`);
try {
// 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);
console.log('Audio stream obtained, starting FFmpeg conversion...');
logger.info('Audio stream obtained, starting FFmpeg conversion...');
// Download to temporary file first, then convert
const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`);
@@ -129,19 +198,19 @@ app.post('/api/convert', async (req: Request, res: Response) => {
writeStream.on('error', reject);
});
console.log('Temporary file saved, starting FFmpeg conversion...');
logger.info('Temporary file saved, starting FFmpeg conversion...');
// Debug: check temp file
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
try {
const { execSync } = require('child_process');
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) {
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
@@ -154,23 +223,23 @@ app.post('/api/convert', async (req: Request, res: Response) => {
.format('mp3')
.output(outputPath)
.on('start', (command: string) => {
console.log('FFmpeg started:', command);
logger.ffmpeg('Started', command);
})
.on('progress', (progress: any) => {
if (progress.percent) {
console.log(`Conversion progress: ${Math.round(progress.percent)}%`);
logger.ffmpeg('Progress', `${Math.round(progress.percent)}%`);
}
})
.on('end', () => {
console.log('MP3 conversion completed successfully');
logger.success('MP3 conversion completed successfully');
// Clean up temporary file
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();
})
.on('error', (err: Error) => {
console.error('FFmpeg error:', err.message);
logger.error('FFmpeg error:', err.message);
// Clean up temporary file on error
fs.unlink(tempInputPath, () => {});
reject(err);
@@ -187,18 +256,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
await db.addDownload(user.id, videoId, title || '', outputPath);
}
} catch (dbError) {
console.error('Database error:', dbError);
logger.error('Database error:', dbError);
}
}
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 });
} catch (conversionError: any) {
console.error('Conversion failed for video:', videoId);
console.error('Error details:', conversionError.message);
console.error('Full error:', conversionError);
logger.error(`Conversion failed for video: ${videoId}`);
logger.error('Error details:', conversionError.message);
logger.error('Full error:', conversionError);
// Return error - no fallbacks for Telegram bot
return res.status(503).json({
@@ -209,18 +278,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
}
} catch (error) {
console.error('Server error:', error);
logger.error('Server error:', error);
res.status(500).json({ error: 'Failed to process request' });
}
});
// Direct Telegram API for sending audio
app.post('/api/telegram-send', async (req: Request, res: Response) => {
console.log('🚀 Telegram send request received');
logger.telegram('Send request received');
try {
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) {
return res.status(400).json({ error: 'Missing required fields' });
@@ -228,18 +297,18 @@ app.post('/api/telegram-send', async (req: Request, res: Response) => {
const botInstance = (global as any).quixoticBot;
if (!botInstance) {
console.log('Bot not available');
logger.error('Bot not available');
return res.status(500).json({ error: 'Bot not available' });
}
const chatId = parseInt(userId);
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' });
} catch (error: any) {
console.error('Send failed:', error.message);
logger.error('Send failed:', error.message);
res.status(500).json({ error: error.message });
}
});
@@ -252,9 +321,25 @@ app.get('/health', (req: Request, res: Response) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Version endpoint for client-side cache busting
app.get('/api/version', (req: Request, res: Response) => {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
try {
const versionPath = path.join(__dirname, '../public/version.json');
if (fs.existsSync(versionPath)) {
const versionData = fs.readFileSync(versionPath, 'utf8');
res.json(JSON.parse(versionData));
} else {
res.json({ version: appVersion, timestamp: Date.now() });
}
} catch (error) {
res.json({ version: appVersion, timestamp: Date.now() });
}
});
// Error handler
app.use((_err: Error, _req: Request, res: Response) => {
console.error(_err.stack);
app.use((err: Error, _req: Request, res: Response, _next: any) => {
logger.error(err.stack || err.message);
res.status(500).json({ error: 'Something went wrong!' });
});
@@ -274,7 +359,7 @@ setInterval(() => {
if (now - stats.mtime.getTime() > maxAge) {
fs.unlink(filePath, (err) => {
if (!err) {
console.log('Deleted old file:', file);
logger.info('Deleted old file:', file);
}
});
}
@@ -284,27 +369,48 @@ setInterval(() => {
}, 60 * 60 * 1000); // Run every hour
app.listen(port, () => {
console.log(`Quixotic server running on port ${port}`);
console.log(`Downloads directory: ${downloadsDir}`);
console.log(`Open in browser: http://localhost:${port}`);
logger.success(`Quixotic server running on port ${port}`);
logger.info(`Downloads directory: ${downloadsDir}`);
logger.info(`Open in browser: http://localhost:${port}`);
});
// Initialize Telegram bot
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const webAppUrl = process.env.WEB_APP_URL || `http://localhost:${port}`;
if (botToken && botToken.length > 10) {
if (botToken && botToken.length > 10 && botToken !== 'your_telegram_bot_token_here') {
try {
const botInstance = new QuixoticBot(botToken, webAppUrl);
// Store bot instance globally for API access
(global as any).quixoticBot = botInstance;
console.log('🤖 Telegram bot started and stored globally');
logger.telegram('Bot started and stored globally');
} catch (error: any) {
console.error('Bot initialization failed:', error.message);
console.warn('⚠️ Bot disabled due to error');
logger.error('Bot initialization failed:', error.message);
logger.warn('Bot disabled due to error');
logger.warn('Telegram integration will not be available');
// Don't crash the server, continue without bot
}
} else {
console.warn('⚠️ TELEGRAM_BOT_TOKEN not found or invalid - bot will not start');
logger.warn('TELEGRAM_BOT_TOKEN not configured properly');
logger.warn('Bot will not start - only web interface will be available');
logger.info('To enable Telegram bot, set a valid TELEGRAM_BOT_TOKEN');
}
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
logger.error('Unhandled Rejection at:', promise);
logger.error('Reason:', reason);
// Log but don't crash the server
if (reason?.code === 'ETELEGRAM') {
logger.warn('Telegram API error - continuing operation');
}
});
// Handle uncaught exceptions
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught Exception:', error);
// Log but try to continue
});
export default app;

View File

@@ -1,5 +1,6 @@
import scdl from 'soundcloud-downloader';
import { Readable } from 'stream';
import { logger } from './logger';
interface SearchTrack {
id: number;
@@ -36,7 +37,7 @@ interface TrackInfo {
export class SoundCloudService {
constructor() {
console.log('SoundCloud service initialized');
logger.soundcloud('Service initialized');
}
private getHighQualityThumbnail(originalUrl: string): string {
@@ -48,32 +49,40 @@ export class SoundCloudService {
// -large (100x100) -> -t500x500 (500x500) or -t300x300 (300x300)
// Try to get the highest quality version available
let highQualityUrl = originalUrl;
if (originalUrl.includes('-large.')) {
// Replace -large with -t500x500 for better quality
return originalUrl.replace('-large.', '-t500x500.');
highQualityUrl = originalUrl.replace('-large.', '-t500x500.');
} else if (originalUrl.includes('-crop.')) {
// If it's crop (400x400), try to get t500x500 or keep crop
return originalUrl.replace('-crop.', '-t500x500.');
highQualityUrl = originalUrl.replace('-crop.', '-t500x500.');
} else if (originalUrl.includes('-t300x300.')) {
// If it's already 300x300, try to upgrade to 500x500
return originalUrl.replace('-t300x300.', '-t500x500.');
highQualityUrl = originalUrl.replace('-t300x300.', '-t500x500.');
} else if (originalUrl.includes('default_avatar_large.png')) {
// For default avatars, use a higher quality placeholder
return 'https://via.placeholder.com/500x500/007AFF/ffffff?text=🎵';
highQualityUrl = 'https://via.placeholder.com/500x500/007AFF/ffffff?text=🎵';
}
// Log transformation if changed
if (highQualityUrl !== originalUrl) {
logger.debug(`Thumbnail upgraded: ${originalUrl.substring(0, 60)}... -> ${highQualityUrl.substring(0, 60)}...`);
}
// If no size suffix found or already high quality, return original
return originalUrl;
return highQualityUrl;
}
async searchTracks(query: string, maxResults: number = 10): Promise<TrackResult[]> {
async searchTracks(query: string, maxResults: number = 10, offset: number = 0): Promise<TrackResult[]> {
try {
console.log(`Searching SoundCloud for: ${query}`);
logger.soundcloud('Searching', `${query} (offset: ${offset})`);
// Search for tracks on SoundCloud
const searchResult = await scdl.search({
query: query,
limit: maxResults,
offset: offset,
resourceType: 'tracks'
}) as any;
@@ -101,7 +110,7 @@ export class SoundCloudService {
}
if (!tracks || tracks.length === 0) {
console.log('No tracks found');
logger.warn('No tracks found');
return [];
}
@@ -116,11 +125,11 @@ export class SoundCloudService {
downloadable: track.downloadable
}));
console.log(`Found ${trackResults.length} tracks on SoundCloud`);
logger.success(`Found ${trackResults.length} tracks on SoundCloud`);
return trackResults;
} catch (error: any) {
console.error('SoundCloud search error:', error.message);
logger.error('SoundCloud search error:', error.message);
return [];
}
}
@@ -136,20 +145,20 @@ export class SoundCloudService {
thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || '')
};
} catch (error) {
console.error('Error getting track info:', error);
logger.error('Error getting track info:', error);
throw error;
}
}
async getAudioStream(trackId: string | number, trackUrl?: string): Promise<Readable> {
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) {
console.log(`Using provided track URL: ${trackUrl}`);
logger.debug(`Using provided track URL: ${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;
}
@@ -160,39 +169,39 @@ export class SoundCloudService {
throw new Error('Track is not streamable');
}
console.log(`Track: ${trackInfo.title}`);
console.log(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
console.log(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
logger.debug(`Track: ${trackInfo.title}`);
logger.debug(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
logger.debug(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
// Use the permalink_url from track info
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;
} catch (error: any) {
console.error('SoundCloud download failed:', error.message);
logger.error('SoundCloud download failed:', error.message);
// Try alternative approaches
try {
console.log('Trying alternative SoundCloud methods...');
logger.info('Trying alternative SoundCloud methods...');
// Try with track ID directly
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;
} 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
try {
const trackUrl = `https://soundcloud.com/${trackId}`;
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;
} 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}`);
}
}

606
yarn.lock

File diff suppressed because it is too large Load Diff