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*
|
Dockerfile*
|
||||||
docker-compose*
|
docker-compose*
|
||||||
.dockerignore
|
.dockerignore
|
||||||
|
|
||||||
# Misc
|
|
||||||
.cache
|
|
||||||
.parcel-cache
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -75,6 +75,9 @@ build/
|
|||||||
out/
|
out/
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
|
# Version file (generated at build time)
|
||||||
|
public/version.json
|
||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
*.backup
|
*.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
|
||||||
16
Dockerfile
16
Dockerfile
@@ -3,18 +3,23 @@ FROM node:18-alpine AS builder
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files first (better caching)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
COPY yarn.lock* ./
|
COPY yarn.lock* ./
|
||||||
|
|
||||||
# Install all dependencies (including dev for build)
|
# Install all dependencies (including dev for build)
|
||||||
|
# This layer will be cached unless package.json changes
|
||||||
RUN yarn install --frozen-lockfile && yarn cache clean
|
RUN yarn install --frozen-lockfile && yarn cache clean
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code (separate from dependencies)
|
||||||
COPY . .
|
COPY tsconfig*.json ./
|
||||||
|
COPY eslint.config.mjs ./
|
||||||
|
COPY scripts ./scripts
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
# Build the application
|
# Build the application with minification
|
||||||
RUN yarn build
|
RUN yarn build:prod
|
||||||
|
|
||||||
# Clean dev dependencies
|
# Clean dev dependencies
|
||||||
RUN yarn install --production --frozen-lockfile
|
RUN yarn install --production --frozen-lockfile
|
||||||
@@ -28,6 +33,7 @@ RUN apk update && apk add --no-cache ffmpeg
|
|||||||
# Set ffmpeg paths
|
# Set ffmpeg paths
|
||||||
ENV FFMPEG_PATH=/usr/bin/ffmpeg
|
ENV FFMPEG_PATH=/usr/bin/ffmpeg
|
||||||
ENV FFPROBE_PATH=/usr/bin/ffprobe
|
ENV FFPROBE_PATH=/usr/bin/ffprobe
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
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:
|
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:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: quixotic-postgres
|
container_name: quixotic-postgres
|
||||||
@@ -56,7 +20,6 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# Main application
|
|
||||||
quixotic-app:
|
quixotic-app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -74,34 +37,26 @@ services:
|
|||||||
- downloads:/app/downloads
|
- downloads:/app/downloads
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTPS router for production domains
|
- "traefik.http.routers.quixotic.rule=Host(`${DOMAIN}`)"
|
||||||
- "traefik.http.routers.quixotic.rule=Host(`${DOMAIN:-localhost}`) && !Host(`localhost`)"
|
|
||||||
- "traefik.http.routers.quixotic.entrypoints=websecure"
|
- "traefik.http.routers.quixotic.entrypoints=websecure"
|
||||||
- "traefik.http.routers.quixotic.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.quixotic.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.routers.quixotic.service=quixotic"
|
- "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.http.services.quixotic.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=traefik-global"
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
traefik:
|
|
||||||
condition: service_started
|
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
networks:
|
networks:
|
||||||
- quixotic
|
- quixotic
|
||||||
|
- traefik-global
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
traefik-ssl-certs:
|
|
||||||
downloads:
|
downloads:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
quixotic:
|
quixotic:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
traefik-global:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ export default [
|
|||||||
"indent": ["error", 4],
|
"indent": ["error", 4],
|
||||||
"quotes": ["error", "single"],
|
"quotes": ["error", "single"],
|
||||||
"semi": ["error", "always"],
|
"semi": ["error", "always"],
|
||||||
"@typescript-eslint/no-unused-vars": "warn",
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_"
|
||||||
|
}],
|
||||||
"no-console": "off"
|
"no-console": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -4,9 +4,12 @@
|
|||||||
"description": "Telegram miniapp for YouTube music search and MP3 conversion",
|
"description": "Telegram miniapp for YouTube music search and MP3 conversion",
|
||||||
"main": "dist/server.js",
|
"main": "dist/server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && tsc -p tsconfig.frontend.json",
|
"build": "node scripts/generate-version.js && tsc && tsc -p tsconfig.frontend.json",
|
||||||
"build:backend": "tsc",
|
"build:backend": "tsc",
|
||||||
"build:frontend": "tsc -p tsconfig.frontend.json",
|
"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",
|
"start": "node dist/server.js",
|
||||||
"dev": "ts-node src/server.ts",
|
"dev": "ts-node src/server.ts",
|
||||||
"dev:watch": "nodemon --exec ts-node src/server.ts",
|
"dev:watch": "nodemon --exec ts-node src/server.ts",
|
||||||
@@ -29,22 +32,27 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.19",
|
"packageManager": "yarn@1.22.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"compression": "^1.8.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"node-telegram-bot-api": "^0.64.0",
|
"node-telegram-bot-api": "^0.64.0",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"soundcloud-downloader": "^1.0.0"
|
"soundcloud-downloader": "^1.0.0",
|
||||||
|
"winston": "^3.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/compression": "^1.8.1",
|
||||||
"@types/express": "^5.0.3",
|
"@types/express": "^5.0.3",
|
||||||
"@types/fluent-ffmpeg": "^2.1.27",
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/node-telegram-bot-api": "^0.64.10",
|
"@types/node-telegram-bot-api": "^0.64.10",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.34.0",
|
||||||
|
"html-minifier-terser": "^7.2.0",
|
||||||
"nodemon": "^3.0.2",
|
"nodemon": "^3.0.2",
|
||||||
|
"terser": "^5.44.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.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">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0">
|
||||||
<title>Quixotic Music</title>
|
<title>Quixotic Music - Поиск и скачивание музыки</title>
|
||||||
<meta name="theme-color" content="#007AFF">
|
<meta name="description" content="Удобный сервис для поиска и скачивания музыки. Найдите любимые треки по названию песни или исполнителю.">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="keywords" content="музыка, поиск музыки, скачать музыку, mp3, треки, песни, исполнители">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
<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="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
|
|
||||||
<link rel="stylesheet" href="style.css">
|
<!-- Theme & App -->
|
||||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="tg-root">
|
<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-content">
|
||||||
<div class="tg-placeholder" id="welcomePlaceholder">
|
<div class="tg-placeholder" id="welcomePlaceholder">
|
||||||
<div class="tg-placeholder__icon">🎵</div>
|
<div class="tg-placeholder__icon">🎵</div>
|
||||||
@@ -28,6 +91,89 @@
|
|||||||
<div class="tg-spinner__text">Поиск музыки...</div>
|
<div class="tg-spinner__text">Поиск музыки...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tg-skeleton-list tg-hidden" id="skeletonList">
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tg-skeleton-item">
|
||||||
|
<div class="tg-skeleton-thumbnail"></div>
|
||||||
|
<div class="tg-skeleton-text">
|
||||||
|
<div class="tg-skeleton-line"></div>
|
||||||
|
<div class="tg-skeleton-line tg-skeleton-line--short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tg-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">
|
<div class="tg-list tg-hidden" id="results">
|
||||||
<!-- Search results will appear here -->
|
<!-- Search results will appear here -->
|
||||||
</div>
|
</div>
|
||||||
@@ -45,11 +191,18 @@
|
|||||||
id="searchInput"
|
id="searchInput"
|
||||||
placeholder="Название песни или исполнитель..."
|
placeholder="Название песни или исполнитель..."
|
||||||
autocomplete="off">
|
autocomplete="off">
|
||||||
|
<button class="tg-input-clear tg-hidden" id="clearButton" type="button" aria-label="Очистить поиск">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M15 5L5 15M5 5L15 15" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</body>
|
||||||
</html>
|
</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
|
# Allow all search engines and bots
|
||||||
User-agent: Googlebot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Bingbot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Yandexbot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /
|
Allow: /
|
||||||
|
|
||||||
|
# Sitemap
|
||||||
|
Sitemap: https://music.quixy.uk/sitemap.xml
|
||||||
|
|||||||
871
public/script.ts
871
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-xl: 20px;
|
||||||
--tg-spacing-xxl: 24px;
|
--tg-spacing-xxl: 24px;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography - Refined type scale (Major Third - 1.25) */
|
||||||
--tg-font-size-xs: 12px;
|
--tg-font-size-xs: 12px;
|
||||||
--tg-font-size-sm: 14px;
|
--tg-font-size-sm: 14px;
|
||||||
--tg-font-size-md: 16px;
|
--tg-font-size-md: 16px; /* base */
|
||||||
--tg-font-size-lg: 17px;
|
--tg-font-size-lg: 18px; /* 16 * 1.125 ≈ 18 */
|
||||||
--tg-font-size-xl: 20px;
|
--tg-font-size-xl: 22px; /* 18 * 1.222 ≈ 22 */
|
||||||
--tg-font-size-xxl: 28px;
|
--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-tight: 1.2;
|
||||||
--tg-line-height-normal: 1.4;
|
--tg-line-height-normal: 1.4;
|
||||||
@@ -71,6 +82,52 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--tg-spacing-xl);
|
gap: var(--tg-spacing-xl);
|
||||||
|
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-content.tg-pulling {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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 */
|
/* Form components */
|
||||||
@@ -93,6 +150,7 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding: 0 var(--tg-spacing-lg);
|
padding: 0 var(--tg-spacing-lg);
|
||||||
|
padding-right: 48px; /* Make room for clear button */
|
||||||
background: var(--tg-color-section-bg);
|
background: var(--tg-color-section-bg);
|
||||||
border: 2px solid var(--tg-color-secondary-bg);
|
border: 2px solid var(--tg-color-secondary-bg);
|
||||||
border-radius: var(--tg-border-radius);
|
border-radius: var(--tg-border-radius);
|
||||||
@@ -111,6 +169,33 @@ body {
|
|||||||
background: var(--tg-color-bg);
|
background: var(--tg-color-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tg-input-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--tg-color-hint);
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-input-clear:hover {
|
||||||
|
background: var(--tg-color-secondary-bg);
|
||||||
|
color: var(--tg-color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-input-clear:active {
|
||||||
|
transform: translateY(-50%) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
/* Button components */
|
/* Button components */
|
||||||
.tg-button {
|
.tg-button {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -121,7 +206,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--tg-border-radius);
|
border-radius: var(--tg-border-radius);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 500;
|
font-weight: var(--tg-font-weight-medium);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -178,7 +263,8 @@ body {
|
|||||||
|
|
||||||
.tg-placeholder__title {
|
.tg-placeholder__title {
|
||||||
font-size: var(--tg-font-size-xl);
|
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);
|
color: var(--tg-color-text);
|
||||||
margin-bottom: var(--tg-spacing-sm);
|
margin-bottom: var(--tg-spacing-sm);
|
||||||
}
|
}
|
||||||
@@ -203,25 +289,29 @@ body {
|
|||||||
padding: var(--tg-spacing-xxl);
|
padding: var(--tg-spacing-xxl);
|
||||||
display: none;
|
display: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-spinner.tg-spinner--visible {
|
.tg-spinner.tg-spinner--visible {
|
||||||
display: block;
|
display: block;
|
||||||
|
animation: tg-fade-in 0.15s ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-spinner__icon {
|
.tg-spinner__icon {
|
||||||
width: 32px;
|
width: 40px;
|
||||||
height: 32px;
|
height: 40px;
|
||||||
border: 2px solid var(--tg-color-secondary-bg);
|
border: 3px solid var(--tg-color-secondary-bg);
|
||||||
border-top: 2px solid var(--tg-color-button);
|
border-top: 3px solid var(--tg-color-button);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin: 0 auto var(--tg-spacing-lg);
|
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 {
|
.tg-spinner__text {
|
||||||
font-size: var(--tg-font-size-sm);
|
font-size: var(--tg-font-size-sm);
|
||||||
color: var(--tg-color-hint);
|
color: var(--tg-color-hint);
|
||||||
|
font-weight: var(--tg-font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tg-spin {
|
@keyframes tg-spin {
|
||||||
@@ -229,6 +319,77 @@ body {
|
|||||||
100% { transform: rotate(360deg); }
|
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 */
|
/* List component */
|
||||||
.tg-list {
|
.tg-list {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -249,6 +410,32 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
position: relative;
|
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 */
|
/* Hover effects for desktop */
|
||||||
@@ -298,6 +485,34 @@ body {
|
|||||||
image-rendering: optimizeQuality;
|
image-rendering: optimizeQuality;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tg-list-item__play-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-list-item__media:hover .tg-list-item__play-btn,
|
||||||
|
.tg-list-item--playing .tg-list-item__play-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tg-list-item__play-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tg-list-item__duration {
|
.tg-list-item__duration {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 2px;
|
bottom: 2px;
|
||||||
@@ -307,7 +522,7 @@ body {
|
|||||||
font-size: var(--tg-font-size-xs);
|
font-size: var(--tg-font-size-xs);
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: var(--tg-font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-list-item__info {
|
.tg-list-item__info {
|
||||||
@@ -317,12 +532,14 @@ body {
|
|||||||
|
|
||||||
.tg-list-item__title {
|
.tg-list-item__title {
|
||||||
font-size: var(--tg-font-size-md);
|
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);
|
color: var(--tg-color-text);
|
||||||
line-height: var(--tg-line-height-tight);
|
line-height: 1.3; /* Улучшенный line-height для многострочных заголовков */
|
||||||
margin-bottom: var(--tg-spacing-xs);
|
margin-bottom: var(--tg-spacing-xs);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -356,22 +573,109 @@ body {
|
|||||||
animation: tg-spin 1s linear infinite;
|
animation: tg-spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Conversion progress bar */
|
||||||
|
.tg-conversion-progress {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: var(--tg-spacing-xs);
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-conversion-progress__bar {
|
||||||
|
height: 4px;
|
||||||
|
background: var(--tg-color-secondary-bg);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-conversion-progress__fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--tg-color-button);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
border-radius: 2px;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-conversion-progress__text {
|
||||||
|
font-size: var(--tg-font-size-xs);
|
||||||
|
color: var(--tg-color-hint);
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio player */
|
||||||
|
.tg-audio-player {
|
||||||
|
padding: var(--tg-spacing-sm) var(--tg-spacing-md);
|
||||||
|
background: var(--tg-color-secondary-bg);
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-audio-player__progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--tg-spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-audio-player__progress-bar {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-audio-player__progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--tg-color-button);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-audio-player__time {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: var(--tg-font-size-xs);
|
||||||
|
color: var(--tg-color-hint);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tg-list-item--loading-preview {
|
||||||
|
opacity: 0.7;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status message */
|
/* Status message */
|
||||||
.tg-status-message {
|
.tg-status-message {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 20px;
|
top: 12px;
|
||||||
left: var(--tg-spacing-lg);
|
left: var(--tg-spacing-md);
|
||||||
right: var(--tg-spacing-lg);
|
right: var(--tg-spacing-md);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
padding: var(--tg-spacing-md) var(--tg-spacing-lg);
|
padding: 10px 14px;
|
||||||
border-radius: var(--tg-border-radius);
|
border-radius: 10px;
|
||||||
font-size: var(--tg-font-size-sm);
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: var(--tg-font-weight-medium);
|
||||||
animation: tg-slide-down 0.3s ease-out;
|
animation: tg-slide-down 0.3s ease-out;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--tg-spacing-sm);
|
gap: 8px;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25), 0 4px 12px rgba(0, 0, 0, 0.15);
|
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 {
|
.tg-status-message--success {
|
||||||
@@ -392,10 +696,16 @@ body {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tg-status-message--warning {
|
||||||
|
background: #ff9500;
|
||||||
|
border: 1px solid #e68500;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes tg-slide-down {
|
@keyframes tg-slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-20px);
|
transform: translateY(-12px);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
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 */
|
/* Utility classes */
|
||||||
.tg-hidden {
|
.tg-hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -464,6 +936,7 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--tg-font-size-sm);
|
font-size: var(--tg-font-size-sm);
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-list-item__subtitle {
|
.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();
|
||||||
252
src/bot.ts
252
src/bot.ts
@@ -1,5 +1,6 @@
|
|||||||
import TelegramBot from 'node-telegram-bot-api';
|
import TelegramBot from 'node-telegram-bot-api';
|
||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
interface TelegramUser {
|
interface TelegramUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -40,14 +41,34 @@ export class QuixoticBot {
|
|||||||
private db: Database;
|
private db: Database;
|
||||||
|
|
||||||
constructor(token: string, webAppUrl: string) {
|
constructor(token: string, webAppUrl: string) {
|
||||||
|
// 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.bot = new TelegramBot(token, { polling: true });
|
||||||
|
}
|
||||||
|
|
||||||
this.webAppUrl = webAppUrl;
|
this.webAppUrl = webAppUrl;
|
||||||
this.db = new Database();
|
this.db = new Database();
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
private init(): void {
|
private init(): void {
|
||||||
console.log('🤖 Telegram bot initialized');
|
logger.telegram('Bot initialized');
|
||||||
this.setupCommands();
|
this.setupCommands();
|
||||||
this.setupHandlers();
|
this.setupHandlers();
|
||||||
}
|
}
|
||||||
@@ -62,7 +83,7 @@ export class QuixoticBot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupHandlers(): void {
|
private setupHandlers(): void {
|
||||||
console.log('🔧 Setting up bot handlers...');
|
logger.telegram('Setting up bot handlers...');
|
||||||
|
|
||||||
// Handle messages
|
// Handle messages
|
||||||
this.bot.on('message', (msg: any) => {
|
this.bot.on('message', (msg: any) => {
|
||||||
@@ -88,21 +109,21 @@ export class QuixoticBot {
|
|||||||
const keyboard = {
|
const keyboard = {
|
||||||
inline_keyboard: [[
|
inline_keyboard: [[
|
||||||
{
|
{
|
||||||
text: '🎵 Открыть Quixotic',
|
text: 'Открыть Quixotic',
|
||||||
web_app: { url: this.webAppUrl }
|
web_app: { url: this.webAppUrl }
|
||||||
}
|
}
|
||||||
]]
|
]]
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.bot.sendMessage(chatId,
|
await this.bot.sendMessage(chatId,
|
||||||
'🎵 Добро пожаловать в Quixotic!\n\n' +
|
'Добро пожаловать в Quixotic!\n\n' +
|
||||||
'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' +
|
'Найди любую песню на SoundCloud и получи MP3 файл прямо в чат.\n\n' +
|
||||||
'Нажми кнопку ниже, чтобы начать поиск:',
|
'Нажми кнопку ниже, чтобы начать поиск:',
|
||||||
{ reply_markup: keyboard }
|
{ reply_markup: keyboard }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Start command error:', error);
|
logger.error('Start command error:', error);
|
||||||
await this.bot.sendMessage(chatId, '❌ Произошла ошибка. Попробуйте позже.');
|
await this.bot.sendMessage(chatId, 'Произошла ошибка. Попробуйте позже.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,13 +131,13 @@ export class QuixoticBot {
|
|||||||
this.bot.onText(/\/help/, async (msg: Message) => {
|
this.bot.onText(/\/help/, async (msg: Message) => {
|
||||||
const chatId = msg.chat.id;
|
const chatId = msg.chat.id;
|
||||||
|
|
||||||
const helpText = `🎵 *Quixotic - SoundCloud to MP3*
|
const helpText = `*Quixotic - SoundCloud to MP3*
|
||||||
|
|
||||||
*Как пользоваться:*
|
*Как пользоваться:*
|
||||||
1️⃣ Нажми кнопку "Открыть Quixotic"
|
1. Нажми кнопку "Открыть Quixotic"
|
||||||
2️⃣ Введи название песни в поисковую строку
|
2. Введи название песни в поисковую строку
|
||||||
3️⃣ Выбери нужный трек из списка
|
3. Выбери нужный трек из списка
|
||||||
4️⃣ Получи MP3 файл в чат!
|
4. Получи MP3 файл в чат!
|
||||||
|
|
||||||
*Команды:*
|
*Команды:*
|
||||||
/start - Запустить приложение
|
/start - Запустить приложение
|
||||||
@@ -124,10 +145,10 @@ export class QuixoticBot {
|
|||||||
/history - История поиска
|
/history - История поиска
|
||||||
|
|
||||||
*Возможности:*
|
*Возможности:*
|
||||||
✅ Поиск по SoundCloud
|
- Поиск по SoundCloud
|
||||||
✅ Высокое качество MP3 (192kbps)
|
- Высокое качество MP3 (192kbps)
|
||||||
✅ Быстрая конвертация
|
- Быстрая конвертация
|
||||||
✅ История поиска`;
|
- История поиска`;
|
||||||
|
|
||||||
await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' });
|
await this.bot.sendMessage(chatId, helpText, { parse_mode: 'Markdown' });
|
||||||
});
|
});
|
||||||
@@ -154,7 +175,7 @@ export class QuixoticBot {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let historyText = '📋 *Последние поисковые запросы:*\n\n';
|
let historyText = '*Последние поисковые запросы:*\n\n';
|
||||||
history.forEach((item, index) => {
|
history.forEach((item, index) => {
|
||||||
const date = new Date(item.created_at).toLocaleDateString('ru-RU');
|
const date = new Date(item.created_at).toLocaleDateString('ru-RU');
|
||||||
historyText += `${index + 1}. ${item.query} _(${date})_\n`;
|
historyText += `${index + 1}. ${item.query} _(${date})_\n`;
|
||||||
@@ -162,8 +183,8 @@ export class QuixoticBot {
|
|||||||
|
|
||||||
await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' });
|
await this.bot.sendMessage(chatId, historyText, { parse_mode: 'Markdown' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('History command error:', error);
|
logger.error('History command error:', error);
|
||||||
await this.bot.sendMessage(chatId, '❌ Ошибка получения истории.');
|
await this.bot.sendMessage(chatId, 'Ошибка получения истории.');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,10 +227,10 @@ export class QuixoticBot {
|
|||||||
type: 'article',
|
type: 'article',
|
||||||
id: `${index}`,
|
id: `${index}`,
|
||||||
title: video.title,
|
title: video.title,
|
||||||
description: `${video.channel} • ${this.formatDuration(video.duration)}`,
|
description: `${video.channel} - ${this.formatDuration(video.duration)}`,
|
||||||
thumb_url: video.thumbnail,
|
thumb_url: video.thumbnail,
|
||||||
input_message_content: {
|
input_message_content: {
|
||||||
message_text: `🎵 ${video.title}\n🔗 ${video.url}`
|
message_text: `${video.title}\n${video.url}`
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -218,31 +239,31 @@ export class QuixoticBot {
|
|||||||
is_personal: true
|
is_personal: true
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Inline query error:', error);
|
logger.error('Inline query error:', error);
|
||||||
await this.bot.answerInlineQuery(queryId, []);
|
await this.bot.answerInlineQuery(queryId, []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handler with detailed logging
|
// Error handler with detailed logging
|
||||||
this.bot.on('error', (error: any) => {
|
this.bot.on('error', (error: any) => {
|
||||||
console.error('🚨 Telegram bot error:', error.message || error);
|
logger.error('Telegram bot error:', error.message || error);
|
||||||
console.error('Error code:', error.code);
|
logger.error('Error code:', error.code);
|
||||||
console.error('Full error:', error);
|
logger.error('Full error:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle polling errors specifically
|
// Handle polling errors specifically
|
||||||
this.bot.on('polling_error', (error: any) => {
|
this.bot.on('polling_error', (error: any) => {
|
||||||
console.error('🚨 Telegram polling error:', error.message || error);
|
logger.error('Telegram polling error:', error.message || error);
|
||||||
console.error('Error code:', error.code);
|
logger.error('Error code:', error.code);
|
||||||
|
|
||||||
// Don't crash on polling errors, just log them
|
// Don't crash on polling errors, just log them
|
||||||
if (error.code === 'ETELEGRAM') {
|
if (error.code === 'ETELEGRAM') {
|
||||||
console.warn('⚠️ Telegram API error - continuing operation');
|
logger.warn('Telegram API error - continuing operation');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
console.log('✅ Bot handlers setup complete');
|
logger.telegram('Bot handlers setup complete');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSearchHistory(userId: number): Promise<SearchResult[]> {
|
private async getSearchHistory(userId: number): Promise<SearchResult[]> {
|
||||||
@@ -251,12 +272,16 @@ export class QuixoticBot {
|
|||||||
|
|
||||||
// Public method for external API calls
|
// Public method for external API calls
|
||||||
public async sendAudioFile(chatId: number, audioUrl: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
|
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);
|
return this.sendAudioFileInternal(chatId, audioUrl, title, performer, thumbnail);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
|
private async sendAudioFileInternal(chatId: number, audioUrlOrPath: string, title: string, performer?: string, thumbnail?: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log(`📤 Sending: ${title} to chat ${chatId}`);
|
logger.telegram('Sending audio', `${title} to chat ${chatId}`);
|
||||||
|
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
|
// Check if it's a URL or local file path
|
||||||
const isUrl = audioUrlOrPath.startsWith('http');
|
const isUrl = audioUrlOrPath.startsWith('http');
|
||||||
@@ -267,68 +292,181 @@ export class QuixoticBot {
|
|||||||
const urlParts = audioUrlOrPath.split('/');
|
const urlParts = audioUrlOrPath.split('/');
|
||||||
const filename = urlParts[urlParts.length - 1];
|
const filename = urlParts[urlParts.length - 1];
|
||||||
filePath = require('path').join(process.cwd(), 'downloads', filename);
|
filePath = require('path').join(process.cwd(), 'downloads', filename);
|
||||||
|
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}`;
|
|
||||||
|
|
||||||
// Try sending as audio with custom filename
|
|
||||||
try {
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
|
logger.error(`File not found: ${filePath}`);
|
||||||
throw new Error('File not found: ' + filePath);
|
throw new Error('File not found: ' + filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.bot.sendAudio(chatId, 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 options: any = {
|
||||||
title: title,
|
title: title,
|
||||||
performer: performer,
|
performer: performer || 'Unknown Artist',
|
||||||
caption: undefined,
|
caption: undefined,
|
||||||
thumbnail: thumbnail,
|
|
||||||
parse_mode: undefined
|
parse_mode: undefined
|
||||||
}, {
|
};
|
||||||
|
|
||||||
|
// Add thumbnail if downloaded
|
||||||
|
if (thumbnailPath) {
|
||||||
|
options.thumbnail = fs.createReadStream(thumbnailPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.bot.sendAudio(chatId, fileStream, options, {
|
||||||
filename: customFilename,
|
filename: customFilename,
|
||||||
contentType: 'audio/mpeg'
|
contentType: 'audio/mpeg'
|
||||||
});
|
});
|
||||||
console.log(`✅ Audio sent: ${title}`);
|
|
||||||
|
logger.success(`Audio sent successfully: ${title}`);
|
||||||
|
|
||||||
|
// Clean up thumbnail file
|
||||||
|
if (thumbnailPath) {
|
||||||
|
fs.unlink(thumbnailPath, (err: any) => {
|
||||||
|
if (err) logger.error('Failed to delete thumbnail:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.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 {
|
try {
|
||||||
await this.bot.sendDocument(chatId, filePath, {
|
logger.info('Retrying as document...');
|
||||||
caption: undefined,
|
const docStream = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
await this.bot.sendDocument(chatId, docStream, {
|
||||||
|
caption: `${title}\n${performer || 'Unknown Artist'}`,
|
||||||
parse_mode: undefined
|
parse_mode: undefined
|
||||||
}, {
|
}, {
|
||||||
filename: customFilename,
|
filename: customFilename,
|
||||||
contentType: 'audio/mpeg'
|
contentType: 'audio/mpeg'
|
||||||
});
|
});
|
||||||
console.log(`✅ Document sent: ${title}`);
|
logger.success(`Document sent successfully: ${title}`);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} catch (documentError: any) {
|
} catch (documentError: any) {
|
||||||
|
logger.error('Document send also failed:', documentError.message);
|
||||||
throw documentError;
|
throw documentError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} 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 {
|
try {
|
||||||
const message = audioUrlOrPath.startsWith('http')
|
await this.bot.sendMessage(chatId,
|
||||||
? `❌ Не удалось отправить файл.\n🎵 ${title}\n🔗 ${audioUrlOrPath}`
|
`Не удалось отправить файл.\n${title}\n\nПопробуйте другой трек.`
|
||||||
: `❌ Не удалось отправить файл: ${title}`;
|
);
|
||||||
|
|
||||||
await this.bot.sendMessage(chatId, message);
|
|
||||||
} catch {
|
} 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);
|
const data: WebAppData = JSON.parse(msg.web_app.data);
|
||||||
|
|
||||||
if (data.action === 'send_audio') {
|
if (data.action === 'send_audio') {
|
||||||
console.log(`🎵 WebApp request: ${data.title}`);
|
logger.telegram('WebApp request', data.title);
|
||||||
await this.sendAudioFileInternal(chatId, data.audioUrl, data.title);
|
await this.sendAudioFileInternal(chatId, data.audioUrl, data.title);
|
||||||
}
|
}
|
||||||
} catch (parseError: any) {
|
} catch (parseError: any) {
|
||||||
console.error('❌ WebApp data parse error:', parseError.message);
|
logger.error('WebApp data parse error:', parseError.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +504,7 @@ if (require.main === module) {
|
|||||||
const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com';
|
const webAppUrl = process.env.WEB_APP_URL || 'https://your-domain.com';
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.error('❌ TELEGRAM_BOT_TOKEN environment variable is required');
|
logger.error('TELEGRAM_BOT_TOKEN environment variable is required');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
interface TelegramUser {
|
interface TelegramUser {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -39,7 +40,7 @@ export class Database {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
if (!tablesExist.rows[0].exists) {
|
if (!tablesExist.rows[0].exists) {
|
||||||
console.log('Creating database tables...');
|
logger.info('Creating database tables...');
|
||||||
|
|
||||||
// Users table
|
// Users table
|
||||||
await this.pool.query(`CREATE TABLE users (
|
await this.pool.query(`CREATE TABLE users (
|
||||||
@@ -69,12 +70,12 @@ export class Database {
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)`);
|
)`);
|
||||||
|
|
||||||
console.log('Database tables created successfully');
|
logger.success('Database tables created successfully');
|
||||||
} else {
|
} else {
|
||||||
console.log('Database tables already exist');
|
logger.info('Database tables already exist');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Database initialization error:', error);
|
logger.error('Database initialization error:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
81
src/logger.ts
Normal file
81
src/logger.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import winston from 'winston';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Professional logging utility using Winston
|
||||||
|
* Provides colored console output with timestamps
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { combine, timestamp, printf, colorize, align } = winston.format;
|
||||||
|
|
||||||
|
// Custom format for clean, readable logs
|
||||||
|
const logFormat = printf(({ level, message, timestamp }) => {
|
||||||
|
return `${timestamp} ${level}: ${message}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create Winston logger instance
|
||||||
|
const winstonLogger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: combine(
|
||||||
|
colorize({ all: true }),
|
||||||
|
timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
align(),
|
||||||
|
logFormat
|
||||||
|
),
|
||||||
|
transports: [
|
||||||
|
new winston.transports.Console({
|
||||||
|
stderrLevels: ['error']
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrapper class for convenience methods
|
||||||
|
class Logger {
|
||||||
|
debug(message: string, ...meta: any[]): void {
|
||||||
|
winstonLogger.debug(message, ...meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, ...meta: any[]): void {
|
||||||
|
winstonLogger.info(message, ...meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: string, ...meta: any[]): void {
|
||||||
|
winstonLogger.warn(message, ...meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, ...meta: any[]): void {
|
||||||
|
winstonLogger.error(message, ...meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success is just info with green color
|
||||||
|
success(message: string, ...meta: any[]): void {
|
||||||
|
winstonLogger.info(message, ...meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specialized logging methods
|
||||||
|
http(method: string, path: string, status: number): void {
|
||||||
|
const level = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info';
|
||||||
|
winstonLogger.log(level, `${method} ${path} ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
database(operation: string, details: string): void {
|
||||||
|
this.debug(`[DB] ${operation}: ${details}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
telegram(action: string, details?: string): void {
|
||||||
|
const msg = details ? `[Telegram] ${action}: ${details}` : `[Telegram] ${action}`;
|
||||||
|
this.info(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
soundcloud(action: string, details?: string): void {
|
||||||
|
const msg = details ? `[SoundCloud] ${action}: ${details}` : `[SoundCloud] ${action}`;
|
||||||
|
this.info(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
ffmpeg(action: string, details?: string): void {
|
||||||
|
const msg = details ? `[FFmpeg] ${action}: ${details}` : `[FFmpeg] ${action}`;
|
||||||
|
this.debug(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const logger = new Logger();
|
||||||
220
src/server.ts
220
src/server.ts
@@ -1,4 +1,5 @@
|
|||||||
import express, { Request, Response } from 'express';
|
import express, { Request, Response } from 'express';
|
||||||
|
import compression from 'compression';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import ffmpeg from 'fluent-ffmpeg';
|
import ffmpeg from 'fluent-ffmpeg';
|
||||||
@@ -9,6 +10,7 @@ ffmpeg.setFfprobePath('/usr/bin/ffprobe');
|
|||||||
import { Database } from './database';
|
import { Database } from './database';
|
||||||
import { SoundCloudService } from './soundcloud';
|
import { SoundCloudService } from './soundcloud';
|
||||||
import { QuixoticBot } from './bot';
|
import { QuixoticBot } from './bot';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
@@ -18,19 +20,57 @@ const db = new Database();
|
|||||||
const soundcloud = new SoundCloudService();
|
const soundcloud = new SoundCloudService();
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
|
app.use(compression()); // Enable gzip compression
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use((req: Request, res: Response, next) => {
|
||||||
// Cache-busting middleware for iOS Safari
|
res.set('Content-Security-Policy',
|
||||||
app.use('/dist/*.js', (req: Request, res: Response, next) => {
|
'default-src \'self\'; ' +
|
||||||
res.set({
|
'script-src \'self\' https://telegram.org \'unsafe-inline\'; ' +
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate, max-age=0',
|
'style-src \'self\' \'unsafe-inline\'; ' +
|
||||||
'Pragma': 'no-cache',
|
'img-src \'self\' data: https:; ' +
|
||||||
'Expires': '0'
|
'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();
|
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
|
// Ensure downloads directory exists
|
||||||
const downloadsDir = path.join(__dirname, '../downloads');
|
const downloadsDir = path.join(__dirname, '../downloads');
|
||||||
@@ -38,34 +78,59 @@ if (!fs.existsSync(downloadsDir)) {
|
|||||||
fs.mkdirSync(downloadsDir, { recursive: true });
|
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
|
// Routes
|
||||||
app.get('/', (req: Request, res: Response) => {
|
app.get('/', (req: Request, res: Response) => {
|
||||||
// Read and modify index.html to add timestamp for iOS cache busting
|
// Use minified HTML in production
|
||||||
const indexPath = path.join(__dirname, '../public/index.html');
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
let html = fs.readFileSync(indexPath, 'utf8');
|
const htmlFile = isProduction ? 'index.min.html' : 'index.html';
|
||||||
|
const indexPath = path.join(__dirname, '../public', htmlFile);
|
||||||
// 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}`);
|
|
||||||
|
|
||||||
|
// Set cache headers for HTML (no cache for HTML itself)
|
||||||
res.set({
|
res.set({
|
||||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
'Pragma': 'no-cache',
|
'Pragma': 'no-cache',
|
||||||
'Expires': '0'
|
'Expires': '0'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
res.send(html);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error serving HTML:', error);
|
||||||
|
res.sendFile(indexPath);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search videos
|
// Search videos
|
||||||
app.post('/api/search', async (req: Request, res: Response) => {
|
app.post('/api/search', async (req: Request, res: Response) => {
|
||||||
try {
|
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) {
|
if (!query || query.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Query is required' });
|
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
|
// Save search history
|
||||||
if (userId && userId !== 'demo') {
|
if (userId && userId !== 'demo') {
|
||||||
try {
|
try {
|
||||||
@@ -74,15 +139,19 @@ app.post('/api/search', async (req: Request, res: Response) => {
|
|||||||
await db.addSearchHistory(user.id, query);
|
await db.addSearchHistory(user.id, query);
|
||||||
}
|
}
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error('Database error:', dbError);
|
logger.error('Database error:', dbError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const videos = await soundcloud.searchTracks(query.trim());
|
const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset);
|
||||||
res.json({ videos });
|
|
||||||
|
// Return hasMore flag based on results
|
||||||
|
const hasMore = videos.length === resultsPerPage;
|
||||||
|
|
||||||
|
res.json({ videos, hasMore });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
logger.error('Search error:', error);
|
||||||
res.status(500).json({ error: 'Failed to search videos' });
|
res.status(500).json({ error: 'Failed to search videos' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -91,7 +160,7 @@ app.post('/api/search', async (req: Request, res: Response) => {
|
|||||||
app.post('/api/convert', async (req: Request, res: Response) => {
|
app.post('/api/convert', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { videoId, title, userId, url, performer }: { videoId?: string; title?: string; userId?: string; url?: string; performer?: string } = req.body;
|
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) {
|
if (!videoId) {
|
||||||
return res.status(400).json({ error: 'Video ID is required' });
|
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
|
// Check if file already exists
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
console.log('File already exists, serving cached version');
|
logger.info('File already exists, serving cached version');
|
||||||
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
||||||
return res.json({ audioUrl, title });
|
return res.json({ audioUrl, title });
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Starting MP3 conversion for: ${title}`);
|
logger.info(`Starting MP3 conversion: ${title}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get audio stream from YouTube
|
// Get audio stream from YouTube
|
||||||
console.log(`Attempting to get audio stream for: ${videoId}`);
|
logger.debug(`Attempting to get audio stream for: ${videoId}`);
|
||||||
const audioStream = await soundcloud.getAudioStream(videoId, url);
|
const audioStream = await soundcloud.getAudioStream(videoId, url);
|
||||||
console.log('Audio stream obtained, starting FFmpeg conversion...');
|
logger.info('Audio stream obtained, starting FFmpeg conversion...');
|
||||||
|
|
||||||
// Download to temporary file first, then convert
|
// Download to temporary file first, then convert
|
||||||
const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`);
|
const tempInputPath = path.join(downloadsDir, `temp_${videoId}.tmp`);
|
||||||
@@ -129,19 +198,19 @@ app.post('/api/convert', async (req: Request, res: Response) => {
|
|||||||
writeStream.on('error', reject);
|
writeStream.on('error', reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Temporary file saved, starting FFmpeg conversion...');
|
logger.info('Temporary file saved, starting FFmpeg conversion...');
|
||||||
|
|
||||||
// Debug: check temp file
|
// Debug: check temp file
|
||||||
const stats = fs.statSync(tempInputPath);
|
const stats = fs.statSync(tempInputPath);
|
||||||
console.log(`Temp file size: ${stats.size} bytes`);
|
logger.debug(`Temp file size: ${stats.size} bytes`);
|
||||||
|
|
||||||
// Test ffmpeg with simple command first
|
// Test ffmpeg with simple command first
|
||||||
try {
|
try {
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' });
|
execSync(`ffmpeg -i "${tempInputPath}" -t 1 -f null -`, { encoding: 'utf8', stdio: 'pipe' });
|
||||||
console.log('FFmpeg file test passed');
|
logger.debug('FFmpeg file test passed');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('FFmpeg file test failed:', e.stderr || e.message);
|
logger.error('FFmpeg file test failed:', e.stderr || e.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert temporary file to MP3 using ffmpeg
|
// Convert temporary file to MP3 using ffmpeg
|
||||||
@@ -154,23 +223,23 @@ app.post('/api/convert', async (req: Request, res: Response) => {
|
|||||||
.format('mp3')
|
.format('mp3')
|
||||||
.output(outputPath)
|
.output(outputPath)
|
||||||
.on('start', (command: string) => {
|
.on('start', (command: string) => {
|
||||||
console.log('FFmpeg started:', command);
|
logger.ffmpeg('Started', command);
|
||||||
})
|
})
|
||||||
.on('progress', (progress: any) => {
|
.on('progress', (progress: any) => {
|
||||||
if (progress.percent) {
|
if (progress.percent) {
|
||||||
console.log(`Conversion progress: ${Math.round(progress.percent)}%`);
|
logger.ffmpeg('Progress', `${Math.round(progress.percent)}%`);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on('end', () => {
|
.on('end', () => {
|
||||||
console.log('MP3 conversion completed successfully');
|
logger.success('MP3 conversion completed successfully');
|
||||||
// Clean up temporary file
|
// Clean up temporary file
|
||||||
fs.unlink(tempInputPath, (err) => {
|
fs.unlink(tempInputPath, (err) => {
|
||||||
if (err) console.error('Failed to delete temp file:', err);
|
if (err) logger.error('Failed to delete temp file:', err);
|
||||||
});
|
});
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (err: Error) => {
|
.on('error', (err: Error) => {
|
||||||
console.error('FFmpeg error:', err.message);
|
logger.error('FFmpeg error:', err.message);
|
||||||
// Clean up temporary file on error
|
// Clean up temporary file on error
|
||||||
fs.unlink(tempInputPath, () => {});
|
fs.unlink(tempInputPath, () => {});
|
||||||
reject(err);
|
reject(err);
|
||||||
@@ -187,18 +256,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
|
|||||||
await db.addDownload(user.id, videoId, title || '', outputPath);
|
await db.addDownload(user.id, videoId, title || '', outputPath);
|
||||||
}
|
}
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
console.error('Database error:', dbError);
|
logger.error('Database error:', dbError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
const audioUrl = `${req.protocol}://${req.get('host')}/downloads/${filename}`;
|
||||||
console.log('Conversion successful, file available at:', audioUrl);
|
logger.success(`Conversion successful: ${audioUrl}`);
|
||||||
res.json({ audioUrl, title });
|
res.json({ audioUrl, title });
|
||||||
|
|
||||||
} catch (conversionError: any) {
|
} catch (conversionError: any) {
|
||||||
console.error('Conversion failed for video:', videoId);
|
logger.error(`Conversion failed for video: ${videoId}`);
|
||||||
console.error('Error details:', conversionError.message);
|
logger.error('Error details:', conversionError.message);
|
||||||
console.error('Full error:', conversionError);
|
logger.error('Full error:', conversionError);
|
||||||
|
|
||||||
// Return error - no fallbacks for Telegram bot
|
// Return error - no fallbacks for Telegram bot
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
@@ -209,18 +278,18 @@ app.post('/api/convert', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Server error:', error);
|
logger.error('Server error:', error);
|
||||||
res.status(500).json({ error: 'Failed to process request' });
|
res.status(500).json({ error: 'Failed to process request' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Direct Telegram API for sending audio
|
// Direct Telegram API for sending audio
|
||||||
app.post('/api/telegram-send', async (req: Request, res: Response) => {
|
app.post('/api/telegram-send', async (req: Request, res: Response) => {
|
||||||
console.log('🚀 Telegram send request received');
|
logger.telegram('Send request received');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body;
|
const { userId, audioUrl, title, performer, thumbnail }: { userId?: string; audioUrl?: string; title?: string; performer?: string; thumbnail?: string } = req.body;
|
||||||
console.log(`📤 Sending to user ${userId}: ${title}`);
|
logger.telegram('Sending to user', `${userId}: ${title}`);
|
||||||
|
|
||||||
if (!userId || !audioUrl || !title) {
|
if (!userId || !audioUrl || !title) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
@@ -228,18 +297,18 @@ app.post('/api/telegram-send', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const botInstance = (global as any).quixoticBot;
|
const botInstance = (global as any).quixoticBot;
|
||||||
if (!botInstance) {
|
if (!botInstance) {
|
||||||
console.log('❌ Bot not available');
|
logger.error('Bot not available');
|
||||||
return res.status(500).json({ error: 'Bot not available' });
|
return res.status(500).json({ error: 'Bot not available' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatId = parseInt(userId);
|
const chatId = parseInt(userId);
|
||||||
await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail);
|
await botInstance.sendAudioFile(chatId, audioUrl, title, performer, thumbnail);
|
||||||
console.log('✅ Audio sent successfully');
|
logger.success('Audio sent successfully');
|
||||||
|
|
||||||
res.json({ success: true, message: 'Audio sent successfully' });
|
res.json({ success: true, message: 'Audio sent successfully' });
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Send failed:', error.message);
|
logger.error('Send failed:', error.message);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -252,9 +321,25 @@ app.get('/health', (req: Request, res: Response) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
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
|
// Error handler
|
||||||
app.use((_err: Error, _req: Request, res: Response) => {
|
app.use((err: Error, _req: Request, res: Response, _next: any) => {
|
||||||
console.error(_err.stack);
|
logger.error(err.stack || err.message);
|
||||||
res.status(500).json({ error: 'Something went wrong!' });
|
res.status(500).json({ error: 'Something went wrong!' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -274,7 +359,7 @@ setInterval(() => {
|
|||||||
if (now - stats.mtime.getTime() > maxAge) {
|
if (now - stats.mtime.getTime() > maxAge) {
|
||||||
fs.unlink(filePath, (err) => {
|
fs.unlink(filePath, (err) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
console.log('Deleted old file:', file);
|
logger.info('Deleted old file:', file);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -284,27 +369,48 @@ setInterval(() => {
|
|||||||
}, 60 * 60 * 1000); // Run every hour
|
}, 60 * 60 * 1000); // Run every hour
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Quixotic server running on port ${port}`);
|
logger.success(`Quixotic server running on port ${port}`);
|
||||||
console.log(`Downloads directory: ${downloadsDir}`);
|
logger.info(`Downloads directory: ${downloadsDir}`);
|
||||||
console.log(`Open in browser: http://localhost:${port}`);
|
logger.info(`Open in browser: http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Telegram bot
|
// Initialize Telegram bot
|
||||||
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
const botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
const webAppUrl = process.env.WEB_APP_URL || `http://localhost:${port}`;
|
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 {
|
try {
|
||||||
const botInstance = new QuixoticBot(botToken, webAppUrl);
|
const botInstance = new QuixoticBot(botToken, webAppUrl);
|
||||||
// Store bot instance globally for API access
|
// Store bot instance globally for API access
|
||||||
(global as any).quixoticBot = botInstance;
|
(global as any).quixoticBot = botInstance;
|
||||||
console.log('🤖 Telegram bot started and stored globally');
|
logger.telegram('Bot started and stored globally');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Bot initialization failed:', error.message);
|
logger.error('Bot initialization failed:', error.message);
|
||||||
console.warn('⚠️ Bot disabled due to error');
|
logger.warn('Bot disabled due to error');
|
||||||
|
logger.warn('Telegram integration will not be available');
|
||||||
|
// Don't crash the server, continue without bot
|
||||||
}
|
}
|
||||||
} else {
|
} 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;
|
export default app;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import scdl from 'soundcloud-downloader';
|
import scdl from 'soundcloud-downloader';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
interface SearchTrack {
|
interface SearchTrack {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -36,7 +37,7 @@ interface TrackInfo {
|
|||||||
|
|
||||||
export class SoundCloudService {
|
export class SoundCloudService {
|
||||||
constructor() {
|
constructor() {
|
||||||
console.log('SoundCloud service initialized');
|
logger.soundcloud('Service initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHighQualityThumbnail(originalUrl: string): string {
|
private getHighQualityThumbnail(originalUrl: string): string {
|
||||||
@@ -48,32 +49,40 @@ export class SoundCloudService {
|
|||||||
// -large (100x100) -> -t500x500 (500x500) or -t300x300 (300x300)
|
// -large (100x100) -> -t500x500 (500x500) or -t300x300 (300x300)
|
||||||
// Try to get the highest quality version available
|
// Try to get the highest quality version available
|
||||||
|
|
||||||
|
let highQualityUrl = originalUrl;
|
||||||
|
|
||||||
if (originalUrl.includes('-large.')) {
|
if (originalUrl.includes('-large.')) {
|
||||||
// Replace -large with -t500x500 for better quality
|
// Replace -large with -t500x500 for better quality
|
||||||
return originalUrl.replace('-large.', '-t500x500.');
|
highQualityUrl = originalUrl.replace('-large.', '-t500x500.');
|
||||||
} else if (originalUrl.includes('-crop.')) {
|
} else if (originalUrl.includes('-crop.')) {
|
||||||
// If it's crop (400x400), try to get t500x500 or keep 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.')) {
|
} else if (originalUrl.includes('-t300x300.')) {
|
||||||
// If it's already 300x300, try to upgrade to 500x500
|
// 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')) {
|
} else if (originalUrl.includes('default_avatar_large.png')) {
|
||||||
// For default avatars, use a higher quality placeholder
|
// 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
|
// 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 {
|
try {
|
||||||
console.log(`Searching SoundCloud for: ${query}`);
|
logger.soundcloud('Searching', `${query} (offset: ${offset})`);
|
||||||
|
|
||||||
// Search for tracks on SoundCloud
|
// Search for tracks on SoundCloud
|
||||||
const searchResult = await scdl.search({
|
const searchResult = await scdl.search({
|
||||||
query: query,
|
query: query,
|
||||||
limit: maxResults,
|
limit: maxResults,
|
||||||
|
offset: offset,
|
||||||
resourceType: 'tracks'
|
resourceType: 'tracks'
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
@@ -101,7 +110,7 @@ export class SoundCloudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!tracks || tracks.length === 0) {
|
if (!tracks || tracks.length === 0) {
|
||||||
console.log('No tracks found');
|
logger.warn('No tracks found');
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,11 +125,11 @@ export class SoundCloudService {
|
|||||||
downloadable: track.downloadable
|
downloadable: track.downloadable
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log(`Found ${trackResults.length} tracks on SoundCloud`);
|
logger.success(`Found ${trackResults.length} tracks on SoundCloud`);
|
||||||
return trackResults;
|
return trackResults;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('SoundCloud search error:', error.message);
|
logger.error('SoundCloud search error:', error.message);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -136,20 +145,20 @@ export class SoundCloudService {
|
|||||||
thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || '')
|
thumbnail: this.getHighQualityThumbnail(track.artwork_url || track.user?.avatar_url || '')
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting track info:', error);
|
logger.error('Error getting track info:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAudioStream(trackId: string | number, trackUrl?: string): Promise<Readable> {
|
async getAudioStream(trackId: string | number, trackUrl?: string): Promise<Readable> {
|
||||||
try {
|
try {
|
||||||
console.log(`Getting audio stream for track: ${trackId}`);
|
logger.soundcloud('Getting audio stream', `track: ${trackId}`);
|
||||||
|
|
||||||
// If trackUrl is provided, use it directly
|
// If trackUrl is provided, use it directly
|
||||||
if (trackUrl) {
|
if (trackUrl) {
|
||||||
console.log(`Using provided track URL: ${trackUrl}`);
|
logger.debug(`Using provided track URL: ${trackUrl}`);
|
||||||
const stream = await scdl.download(trackUrl);
|
const stream = await scdl.download(trackUrl);
|
||||||
console.log('Audio stream obtained successfully from SoundCloud using URL');
|
logger.success('Audio stream obtained successfully from SoundCloud using URL');
|
||||||
return stream;
|
return stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,39 +169,39 @@ export class SoundCloudService {
|
|||||||
throw new Error('Track is not streamable');
|
throw new Error('Track is not streamable');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Track: ${trackInfo.title}`);
|
logger.debug(`Track: ${trackInfo.title}`);
|
||||||
console.log(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
|
logger.debug(`Artist: ${trackInfo.user?.username || 'Unknown'}`);
|
||||||
console.log(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
|
logger.debug(`Duration: ${Math.floor(trackInfo.duration / 1000)}s`);
|
||||||
|
|
||||||
// Use the permalink_url from track info
|
// Use the permalink_url from track info
|
||||||
const stream = await scdl.download(trackInfo.permalink_url);
|
const stream = await scdl.download(trackInfo.permalink_url);
|
||||||
|
|
||||||
console.log('Audio stream obtained successfully from SoundCloud');
|
logger.success('Audio stream obtained successfully from SoundCloud');
|
||||||
return stream;
|
return stream;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('SoundCloud download failed:', error.message);
|
logger.error('SoundCloud download failed:', error.message);
|
||||||
|
|
||||||
// Try alternative approaches
|
// Try alternative approaches
|
||||||
try {
|
try {
|
||||||
console.log('Trying alternative SoundCloud methods...');
|
logger.info('Trying alternative SoundCloud methods...');
|
||||||
|
|
||||||
// Try with track ID directly
|
// Try with track ID directly
|
||||||
const stream = await scdl.download(String(trackId));
|
const stream = await scdl.download(String(trackId));
|
||||||
console.log('Audio stream obtained with track ID method');
|
logger.success('Audio stream obtained with track ID method');
|
||||||
return stream;
|
return stream;
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
console.error('Track ID method failed, trying URL construction...');
|
logger.error('Track ID method failed, trying URL construction...');
|
||||||
|
|
||||||
// Final fallback - try constructing different URL formats
|
// Final fallback - try constructing different URL formats
|
||||||
try {
|
try {
|
||||||
const trackUrl = `https://soundcloud.com/${trackId}`;
|
const trackUrl = `https://soundcloud.com/${trackId}`;
|
||||||
const stream = await scdl.download(trackUrl);
|
const stream = await scdl.download(trackUrl);
|
||||||
console.log('Audio stream obtained with constructed URL method');
|
logger.success('Audio stream obtained with constructed URL method');
|
||||||
return stream;
|
return stream;
|
||||||
} catch (finalError: any) {
|
} catch (finalError: any) {
|
||||||
console.error('All methods failed:', finalError.message);
|
logger.error('All methods failed:', finalError.message);
|
||||||
throw new Error(`SoundCloud download failed: ${error.message}`);
|
throw new Error(`SoundCloud download failed: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user