Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd2c3b6989 | ||
|
|
21a32ffc79 | ||
|
|
beb2d19019 | ||
|
|
f6b696a5f8 | ||
|
|
712c25a881 | ||
|
|
82a9596370 | ||
|
|
6db48b16a7 | ||
|
|
ca27a2b3f0 | ||
|
|
5d7c6b2a09 | ||
|
|
53633dd837 | ||
|
|
bd0a0cca28 | ||
|
|
0110301a60 | ||
|
|
e7dc0c59e3 |
@@ -58,7 +58,3 @@ docs
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# Misc
|
||||
.cache
|
||||
.parcel-cache
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,6 +75,9 @@ build/
|
||||
out/
|
||||
coverage/
|
||||
|
||||
# Version file (generated at build time)
|
||||
public/version.json
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
|
||||
190
.serena/memories/traefik_add_new_app_guide.md
Normal file
190
.serena/memories/traefik_add_new_app_guide.md
Normal 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
|
||||
18
Dockerfile
18
Dockerfile
@@ -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,4 +62,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "dist/server.js"]
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
||||
56
docker-compose.local.yml
Normal file
56
docker-compose.local.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -77,8 +77,11 @@ 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"
|
||||
}
|
||||
}
|
||||
];
|
||||
];
|
||||
|
||||
14
package.json
14
package.json
@@ -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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
@@ -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
1
public/index.min.html
Normal file
File diff suppressed because one or more lines are too long
22
public/manifest.json
Normal file
22
public/manifest.json
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
875
public/script.ts
875
public/script.ts
File diff suppressed because it is too large
Load Diff
10
public/sitemap.xml
Normal file
10
public/sitemap.xml
Normal 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>
|
||||
523
public/style.css
523
public/style.css
@@ -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 {
|
||||
|
||||
46
scripts/generate-version.js
Normal file
46
scripts/generate-version.js
Normal 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
127
scripts/minify.js
Executable 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();
|
||||
264
src/bot.ts
264
src/bot.ts
@@ -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}`);
|
||||
}
|
||||
|
||||
// Generate custom filename for display
|
||||
const safeTitle = (title || '').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 fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
|
||||
// Try sending as audio with custom filename
|
||||
// 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 || '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}.mp3` : `${safeTitle}.mp3`;
|
||||
|
||||
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\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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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();
|
||||
222
src/server.ts
222
src/server.ts
@@ -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;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user